Provider of one typed work item list over any tracker, for other handily mods.

workitems is a provider mod. It reads the work items of the tracker at the session root and gives other mods one typed list. It draws nothing of its own.
| Tracker | Detected by | Read path |
|---|---|---|
| basicly | .basicly/ledger/template.json | basicly tracker items --json, after approval |
beads (bd, br) | .beads/issues.jsonl | Built-in JSON Lines reader, or br list over 4 MiB |
| beans | .beans.yml or .beans/ | Built-in front matter reader |
Any other (files) | globs in .handily.json | Generic JSON, JSON Lines or front matter reader |
Any other (adapter) | an entry in your ~/.config/handily/adapters.json | <command> describe --json, <command> items --json |
$.session.root()). It never reads a parent folder..handily.json names. The snapshot lists each other source that it finds under ignored..beads/issues.jsonl is the one exception to the size rule (see beads).id, the title and the raw status of each item. A control character, a line break or a text direction control in one of them, or a text that is too long, makes the item unreadable. The limits are 200 characters for the id, 500 for the title and 100 for the status. The reason names the file and the field.caveat then counts the skipped files and names the first, for example 1 item file skipped: .beans/app-a1--x.md line 4 is malformed.|, >) scalars, lists in the [a, b] form, and lists of - item lines at any indent. It ignores a trailing # comment. A field in another form, such as a nested map, is unreadable. The mod skips the item only when the reader needs that field.PATH at the repo root: basicly tracker items --json --status open --status in_progress --status blocked. One call reads every open status. It runs only after you approve it (see Approval).tracker items exits with an error, and the read fails with the command and the exit code. basicly 0.21.5 has the command..basicly/core/kit/tracker.id, title, rawStatus, priority, type, assignee and updatedAt of each item. basicly leaves out a tombstoned record. A repeated id makes the read fail by name.basicly is not on PATH, the read fails with "basicly is not on PATH. Install it to read this tracker." The mod never runs the repo's own .basicly/core/kit/tracker/cli.py..basicly/ledger or any file under the kit folder changes.basicly and basicly --version run with PYTHONDONTWRITEBYTECODE=1 and PYTHONPYCACHEPREFIX set to a new folder. The new folder does not exist, so Python never runs a cached .pyc file from the repo.refresh() reports it under closed, with the status closed and the last raw status that the mod read._type is not issue, and a tombstone line..beads/metadata.json names the dolt backend, the data comes from bd. Then each item carries the label possibly stale, because bd keeps its data in Dolt.The engine reads no file over 4 MiB. So the size of .beads/issues.jsonl chooses the read path:
Size of .beads/issues.jsonl | Read path |
|---|---|
| 4 MiB or less | The mod reads the file itself, as above |
| Over 4 MiB | The mod runs br list --json --limit 0, after you approve it |
Over 4 MiB, on Dolt (bd) | The read fails by name. The mod runs nothing, because br reads no Dolt |
br from PATH at the repo root. It runs nothing before you approve it (see Approval). The snapshot sourceLabel is beads (br).br list with no status lists every item that is not closed and not a tombstone. In br 0.3.2 that includes deferred, draft, pinned and a custom status. --limit 0 asks for every such item.refresh() reports it under closed, with the status closed and the last raw status that the mod read.id, title, status, priority, issue_type, assignee, updated_at and labels, with the same checks as a line of the file. br list gives no dependencies, so an item from it has no parent.| Cause | Reason |
|---|---|
br is not on PATH | br is not on PATH. Install br to list the open items of ... |
| The output was cut at 4 MiB | br list output was cut off. Close some open items, ... |
| A non-zero exit | br list exited N. Run it in a shell to see why. |
br list did not start or did not end in time | br list did not start or did not end in time ... |
The output is not JSON, or has no issues list | br list printed no valid JSON ... or ... printed no issues list ... |
has_more is not false, or total is not a number | br list did not say that it printed every item ... |
total differs from the number of items | br list printed N of M items ... |
| An item is not an object, has an invalid field or an unsafe text | br list item N ... |
A reason in the last five rows ends with the fix Run "br list --json --limit 0" at the repo root to see why the list could not be read.
approval-needed. On a surface that cannot run a command, the state is terminal-only.br on PATH changes, for example when you install or move br, and after you approve br list.br, refresh() compares the items without parent. So a child item that did not change is not reported as updated.br list did not hold is then not reported as created..md file under the beans folder is one item. The id is the part of the file name before --, for example app-ab12 in app-ab12--add-the-export-button.md. A name without -- is the whole id, for example app-ab12 in app-ab12.md..beans/. The path key under beans: in .beans.yml moves it. The path . is the repo root. The reader skips a folder whose name starts with a dot, as beans does.archive/ folder is closed, whatever its status says.tags become the labels.beans status | Item status |
|---|---|
todo | open |
in-progress | in_progress |
draft | other |
completed, scrapped | closed |
beans priority | Item priority |
|---|---|
critical | 0 |
high | 1 |
normal | 2 |
low | 3 |
deferred | 4 |
A priority word that is not in this table makes the read fail. The reason names the file and the line.
.handily.json at the repo root chooses the source, or describes the files of another tracker.
{
"source": "files",
"globs": ["work/*.jsonl"],
"format": "jsonl",
"fields": { "id": "key", "title": "summary", "status": "state", "priority": "rank" }
}
| Key | Meaning |
|---|---|
source | Optional. basicly, beads, beans, files or adapter. The mod reads this source |
globs | The item files, relative to the repo root. * and ? match in one folder, ** in any |
format | json (one item or a list of items per file), jsonl (one item per line) or frontmatter |
fields | The file field of each item field. id, title and status are required |
id, title, status, priority, type, assignee, updatedAt, labels, parent and url.status value of open, in_progress, blocked, deferred or closed keeps its meaning. Any other value becomes other.priority must be a whole number of 0 or more, as a number or as a string of digits. An empty string is no priority.constructor..handily.json holds data only. A command in it runs nothing. The read fails, and the reason names your file ~/.config/handily/adapters.json and the repo root. It never repeats the repo's command (see CLI adapter contract 1).A tracker CLI that the mod does not know can serve its items through two commands. A repo never names the command. You name it, once per repo, in your own file ~/.config/handily/adapters.json. Typing the line is your consent, so the mod runs the adapter with no approval question.
What your entry allows. Read this before you add a line:
git pull that changes the script runs the new code at the next read, with no new question.argv[0]. With the working folder at the repo root, npx, python -m, uv run and the require of node load code from the repo itself. So ["node", "tools/tracker.mjs"] runs whatever that repo file holds.Setup:
pwd -P in the repo.~/.config/handily/adapters.json. The key is that exact real path, and the value is the program and its arguments. This example runs a repo script, with the limits above: { "/home/you/src/app": ["node", "tools/tracker.mjs"] }
HOME, or USERPROFILE when HOME is not set.["tools/run"]. Run a repo script through a program outside the repo, such as node.~/.config/handily/adapters.json as a place that the mod looked..handily.json names "source": "adapter" and your file has no entry for the repo, the read fails, and the reason names your file and the repo root.<command> describe --json prints one JSON object: {
"name": "tickets",
"version": "1.4.0",
"contract": 1,
"watch": ["tickets/*.json"],
"writes": [["close"], ["comments", "add"]],
"statusMap": { "todo": "open", "doing": "in_progress", "done": "closed" }
}
<command> items --json prints a JSON array of items. Each item has id, title and status, and can have priority, type, assignee, updatedAt, labels, parent and url.contract other than the number 1, and the reason names the value it got.statusMap maps the status of the tracker to open, in_progress, blocked, deferred, closed or other. A status that is not in the map keeps the rule of .handily.json.<name>:<id>. The snapshot sourceLabel is the name.writes entry is the arguments after the command of one write. The mod adds them to the snapshot as adapterWrites: { command, verbs }, for example the command node tools/tracker.mjs.watch or your adapters file changes.name, a writes entry, a watch glob or a statusMap key with a control character or over 256 characters, and more than 100 entries in one list. The reason does not repeat the value.basicly tracker items and br list run only after you approve them. The mod runs no basicly command before you approve the repo, not even basicly --version. The CLI adapter needs no approval, because you typed its command yourself.
Not now and Allow for this repo. Not now is the first option, so Enter answers Not now.Allow for this repo is kept in $.store under a key of the repo root, the command, the real path of basicly, and the sha256 of every file under .basicly/core/kit/tracker/, in every subfolder and with every suffix. A link in that folder makes the read fail, because the key cannot cover what it leads to.basicly, with no kit files.basicly --version with the approved program path. The version decides if the approval covers the kit files:basicly --version prints | A kit change |
|---|---|
basicly X.Y.Z, 0.21.1 or later | does not ask again |
basicly X.Y.Z, below 0.21.1 | asks again |
| any other text, or a non-zero exit | asks again |
| nothing, because it did not start | the read fails |
basicly 0.21.1 or later runs only the installed package, so the kit files do not change what it runs. An older basicly also runs the repo code in .basicly/core/kit/tracker.
basicly --version in memory while Claude Code runs, never in $.store or $.state. The verdict is keyed on the repo root, the command, the real path of basicly, the size and modification time of the file at that path, and the sha256 of every kit file. A later read with the same key runs no basicly --version. A kit change, a different program path or a reinstall of basicly at the same path changes the key, so the mod runs basicly --version again. A basicly below 0.21.1 has no tracker items command, so the read fails by name and runs no kit code. When the engine cannot give the size and the time of the program file, the mod keeps no verdict.basicly changes, the old approval does not match, and the mod asks again.basicly tracker list from an older workitems does not cover basicly tracker items. The approved command changed, so the mod asks you once again.basicly whose real path is inside the repo root, or whose real path the engine does not give. Program lookup skips a relative or empty PATH entry, such as ..C:\repo) and a UNC root (such as \\server\share). The check that a tracker file is inside the root compares with case, apart from the drive letter. A long-path prefix (\\?\ or \\?\UNC\) names the same root as the path without it.Allow for this repo stores an approval. After Not now, Enter, text typed under Other, or when you close the dialog, the mod stores nothing and the state is approval-needed. The mod asks again at the next session start.claude -p. The state is then approval-needed.terminal-only.br list --json --limit 0 follows the same rules as basicly above, with one difference in what the approval covers:
br. It holds no repo file and no hash of the br program.br at a new real path asks again. A new br at the same real path does not ask again, because the key holds the path, not the content of the program.br list --json --limit 0 were checked on br 0.3.2 on 2026-10-09. The question names them in short:| Checked | Result |
|---|---|
| Files that it writes | The git-ignored cache .beads/beads.db and .beads/beads.base.jsonl. It imports an edited issues.jsonl into the cache. It does not rewrite issues.jsonl |
| Programs that it starts by name | None of git, sh, bash, python3, node, env, editor and vi, each placed first on PATH as a spy. A control call proved that the spies record a call |
| Programs that it starts by path | Not ruled out. No system call tracer was available |
.beads/config.yaml | Holds the prefix, defaults and sync settings. br config list shows no key that names a command |
br.br whose real path is inside the repo root.The contract is types/index.d.ts. A dependent mod lists workitems under dependencies in its plugin.json, and the engine lays the contract into that mod's types folder.
| Part | Use |
|---|---|
$.state.get({ plugin: 'workitems', key: 'snapshot' }) | The last snapshot. A render that reads it draws again on a change |
$.workitems.refresh({ since }) | Reads again. Returns the created, updated and closed items and the version |
$.workitems.lines({ snapshot, now }) | The state lines of docs/mocks.md section 0, with their tone |
$.workitems.writeVerbs() | The write verbs of each tracker CLI that the mod knows |
$.workitems.classify(command) | Reads a shell command: write, echoed, opaque or none, with the tracker writes it found |
$.workitems.trackerFile({ path, root }) | The tracker file that path names, relative to root, or null |
The snapshot state is one of ok, failed, approval-needed, stale, no-tracker and terminal-only. This version produces each of them except stale.
Each snapshot also carries these fields:
| Field | Meaning |
|---|---|
version | A number that rises by 1 each time the data changes |
at | The time of the read that gave this data |
checkedAt | The time of the last check of the tracker file. The header age uses it |
refresh({ since }) returns the chanhooks/register.tsx 111 lines1import type { RenderSurface, Register, Timer } from 'claude-code'
2import { createApprovals, type Approvals } from './approval'
3import { classify, trackerFileOf } from './commands'
4import { createReaders, writeVerbs } from './readers/index'
5import { createProvider, POLL_INTERVAL_MS } from './snapshot'
6import { stateLines } from './states'
7
8function canRunCommandsOn(surfaces: readonly RenderSurface[]): boolean {
9 return surfaces.length === 0 || surfaces.includes('terminal')
10}
11
12function bytesOf(base64: string): Uint8Array {
13 return Uint8Array.from(atob(base64), (character) => character.charCodeAt(0))
14}
15
16export const register: Register = (on) => {
17 let poll: Timer | undefined
18 let approvals: Approvals | undefined
19
20 on('engine.create', async (_$, e, next) => {
21 const built = await next(e)
22 const engineApprovals = createApprovals({
23 stored: (key) => built.store.get(key),
24 store: (key, value) => built.store.set(key, value),
25 ask: (question, options) => built.ui.ask(question, options),
26 approved: () => {
27 provider.refresh().catch((error: unknown) => {
28 built.ui.log(`workitems: the refresh after an approval failed: ${String(error)}`)
29 })
30 },
31 log: (text) => {
32 built.ui.log(text)
33 },
34 })
35 approvals = engineApprovals
36 const provider = createProvider(
37 {
38 root: () => built.session.root(),
39 now: () => built.clock.now(),
40 exists: (path) => built.fs.exists(path),
41 stat: (path, options) => built.fs.stat(path, options),
42 list: (path) => built.fs.list(path),
43 read: (path) => built.fs.read(path),
44 readBytes: async (path) => bytesOf((await built.fs.read(path, { as: 'bytes' })).base64),
45 homeFolder: async () => {
46 const home = await built.env.get('HOME')
47 return home === undefined || home === '' ? built.env.get('USERPROFILE') : home
48 },
49 commands: {
50 canRun: async () => canRunCommandsOn(await built.session.surfaces()),
51 run: (argv, cwd, env) =>
52 built.process.run(argv, env === undefined ? { cwd } : { cwd, env: { ...env } }),
53 searchPath: async () => ({
54 path: await built.env.get('PATH'),
55 extensions: await built.env.get('PATHEXT'),
56 }),
57 approvals: engineApprovals,
58 },
59 publish: async (snapshot) => {
60 await built.state.set({ plugin: 'workitems', key: 'snapshot' }, snapshot)
61 },
62 },
63 createReaders(),
64 )
65 return {
66 ...built,
67 workitems: {
68 refresh: (args) => provider.refresh(args),
69 writeVerbs: () => Promise.resolve(writeVerbs),
70 classify: (command) => Promise.resolve(classify(command)),
71 trackerFile: (args) => Promise.resolve(trackerFileOf(args)),
72 lines: (args) => Promise.resolve(stateLines(args)),
73 },
74 }
75 })
76
77 on('session.start', async ($, e, next) => {
78 approvals?.startSession(e.isInteractive)
79 poll?.cancel()
80 let lastPollFailure: string | null = null
81 poll = $.clock.every(POLL_INTERVAL_MS, () => {
82 $.workitems.refresh().then(
83 () => {
84 lastPollFailure = null
85 },
86 (error: unknown) => {
87 const text = `workitems: the poll refresh failed: ${String(error)}`
88 if (text === lastPollFailure) return
89 lastPollFailure = text
90 $.ui.log(text)
91 },
92 )
93 })
94 await $.workitems.refresh().catch((error: unknown) => {
95 $.ui.log(`workitems: the first refresh failed: ${String(error)}`)
96 })
97 await $.state.set({ plugin: 'workitems', key: 'ready' }, { root: $.plugin.root })
98 return next(e)
99 })
100
101 on('classic.CwdChanged', async ($, e, next) => {
102 await $.workitems.refresh()
103 return next(e)
104 }).catch(($, e, next) => {
105 $.ui.log(
106 `workitems: the refresh after a directory change failed (${next.error.kind}): ${next.error.message ?? 'no message'}`,
107 )
108 return next(e)
109 })
110}
111hooks/approval.ts 277 lines1import type { AskOptions, FsEntry } from 'claude-code'
2import { argumentProblem, FileProblem, mayBeInside } from './config'
3import type { TrackerFiles } from './readers/index'
4
5export const APPROVE = 'Allow for this repo'
6export const DECLINE = 'Not now'
7export const ASK_HEADER = 'workitems'
8const STORE_PREFIX = 'approval:'
9const PROGRAM_STORE_PREFIX = 'program-approval:'
10const ABSOLUTE_PATH = /^([\\/]|[A-Za-z]:)/
11const ABSOLUTE_FOLDER = /^([\\/]|[A-Za-z]:[\\/])/
12const NAMES_A_FOLDER = /[\\/]/
13
14export type ApprovalKey = {
15 root: string
16 argv: readonly string[]
17 files: Readonly<Record<string, string>>
18 argv0: string
19}
20
21export type ApprovalHost = {
22 stored: (key: string) => Promise<unknown>
23 store: (key: string, value: ApprovalKey) => Promise<void>
24 ask: (question: string, options: AskOptions) => Promise<string>
25 approved: () => void
26 log: (text: string) => void
27}
28
29export type CoveredFolder = { path: string; suffix: string; recursive?: true }
30
31export type ApprovalRequest = {
32 command: readonly string[]
33 shown: readonly string[]
34 folders: readonly CoveredFolder[]
35 note: string
36 ignoresFolders: (key: ApprovalKey) => Promise<boolean>
37}
38
39export type Verdict = { approved: true; argv0: string } | { approved: false }
40
41export type Approvals = {
42 generation: () => number
43 startSession: (isInteractive: boolean) => void
44 check: (files: TrackerFiles, request: ApprovalRequest) => Promise<Verdict>
45}
46
47export type SearchPath = { path: string | undefined; extensions: string | undefined }
48
49export type ProgramSearch = {
50 searchPath: () => Promise<SearchPath>
51 exists: (path: string) => Promise<boolean>
52 realPath: (path: string) => Promise<string | undefined>
53 realPathAtRoot: (relativePath: string) => Promise<string | undefined>
54}
55
56export async function sha256Hex(bytes: Uint8Array): Promise<string> {
57 const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))
58 return [...digest].map((byte) => byte.toString(16).padStart(2, '0')).join('')
59}
60
61function candidatesOn(search: SearchPath, program: string): string[] {
62 if (search.path === undefined || search.path === '') return []
63 const isWindows = search.extensions !== undefined
64 const suffixes = ['', ...(search.extensions ?? '').split(';').filter((suffix) => suffix !== '')]
65 return search.path
66 .split(isWindows ? ';' : ':')
67 .filter((directory) => ABSOLUTE_FOLDER.test(directory))
68 .flatMap((directory) =>
69 suffixes.map((suffix) => `${directory.replace(/[\\/]+$/, '')}/${program}${suffix}`),
70 )
71}
72
73export async function resolveProgram(
74 program: string,
75 search: ProgramSearch,
76): Promise<string | undefined> {
77 if (ABSOLUTE_PATH.test(program)) return search.realPath(program)
78 if (NAMES_A_FOLDER.test(program)) return search.realPathAtRoot(program)
79 for (const candidate of candidatesOn(await search.searchPath(), program)) {
80 if (!(await search.exists(candidate))) continue
81 const real = await search.realPath(candidate)
82 if (real === undefined) {
83 throw new FileProblem(
84 `${candidate} could not be resolved to a real path, so it could not be read.`,
85 )
86 }
87 return real
88 }
89 return undefined
90}
91
92export function refuseUnsafeArguments(argv: readonly string[]): void {
93 for (const argument of argv) {
94 const problem = argumentProblem(argument)
95 if (problem !== null) {
96 throw new FileProblem(`the command has an argument ${problem}, so it could not be read.`)
97 }
98 }
99}
100
101export async function programOutsideRoot(
102 files: TrackerFiles,
103 argv: readonly string[],
104): Promise<string> {
105 refuseUnsafeArguments(argv)
106 const program = argv[0] ?? ''
107 const argv0 = await files.commands.which(program)
108 if (argv0 === undefined) {
109 throw new FileProblem(`${program} could not be found, so ${argv.join(' ')} could not be read.`)
110 }
111 const rootReal = await files.realPath('.')
112 if (rootReal === undefined) throw new FileProblem('the repo root could not be read.')
113 if (mayBeInside(rootReal, argv0)) {
114 throw new FileProblem(`${program} resolves inside the repo root, so it could not be read.`)
115 }
116 refuseUnsafeArguments([argv0])
117 return argv0
118}
119
120export function quotedCommand(argv: readonly string[]): string {
121 return argv.map((argument) => JSON.stringify(argument)).join(' ')
122}
123
124function joinedPath(folder: string, name: string): string {
125 return folder === '.' ? name : `${folder}/${name}`
126}
127
128type FolderEntry = { path: string; entry: FsEntry }
129
130async function folderEntries(
131 files: TrackerFiles,
132 folder: CoveredFolder,
133 shownFolder: string = folder.path,
134): Promise<FolderEntry[]> {
135 if (!(await files.exists(folder.path))) return []
136 const found: FolderEntry[] = []
137 for (const entry of await files.list(folder.path)) {
138 if (argumentProblem(entry.name) !== null) {
139 throw new FileProblem(
140 `a file in ${shownFolder} has a name with a control character or over 256 characters, so it could not be read.`,
141 )
142 }
143 const path = joinedPath(folder.path, entry.name)
144 if (folder.recursive === true && entry.isLink) {
145 throw new FileProblem(
146 `${path} is a link, which the approval cannot cover, so it could not be read.`,
147 )
148 }
149 if (entry.kind === 'dir' && folder.recursive === true) {
150 found.push(...(await folderEntries(files, { ...folder, path }, shownFolder)))
151 continue
152 }
153 const isFile = entry.kind === 'file' || entry.isLink
154 if (isFile && entry.name.endsWith(folder.suffix)) found.push({ path, entry })
155 }
156 return found
157}
158
159async function coveredFiles(
160 files: TrackerFiles,
161 folders: readonly CoveredFolder[],
162): Promise<Record<string, string>> {
163 const digests = new Map<string, string>()
164 for (const folder of folders) {
165 for (const { path } of await folderEntries(files, folder)) {
166 const digest = await files.hash(path)
167 if (digest !== undefined) digests.set(path, digest)
168 }
169 }
170 const entries = [...digests.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
171 return Object.fromEntries(entries)
172}
173
174export async function coverStamp(
175 files: TrackerFiles,
176 folders: readonly CoveredFolder[],
177): Promise<string> {
178 const lines: string[] = []
179 for (const folder of folders) {
180 for (const { path, entry } of await folderEntries(files, folder)) {
181 lines.push(`${path} ${String(entry.size)} ${String(entry.mtimeMs)}`)
182 }
183 }
184 return lines.sort().join('\n')
185}
186
187function approvalQuestion(shown: readonly string[], note: string): string {
188 const lines = [
189 "Allow handily to run this repo's tracker CLI to read work items?",
190 quotedCommand(shown),
191 note,
192 ]
193 return lines.join('\n')
194}
195
196async function digestOf(value: object): Promise<string> {
197 return sha256Hex(new TextEncoder().encode(JSON.stringify(value)))
198}
199
200async function storeKeyOf(key: ApprovalKey): Promise<string> {
201 return `${STORE_PREFIX}${await digestOf(key)}`
202}
203
204async function programStoreKeyOf({ root, argv, argv0 }: ApprovalKey): Promise<string> {
205 return `${PROGRAM_STORE_PREFIX}${await digestOf({ root, argv, argv0 })}`
206}
207
208export function createApprovals(host: ApprovalHost): Approvals {
209 let isInteractive = false
210 let generation = 0
211 const declined = new Set<string>()
212 const asking = new Set<string>()
213
214 async function isStored(storeKey: string): Promise<boolean> {
215 return (await host.stored(storeKey)) !== undefined
216 }
217
218 async function keepProgramApproval(programKey: string, key: ApprovalKey): Promise<void> {
219 if (!(await isStored(programKey))) await host.store(programKey, key)
220 }
221
222 function askPerson(storeKey: string, key: ApprovalKey, question: string): void {
223 asking.add(storeKey)
224 host
225 .ask(question, { options: [DECLINE, APPROVE], header: ASK_HEADER })
226 .then(
227 async (answer) => {
228 if (answer !== APPROVE) {
229 declined.add(storeKey)
230 return
231 }
232 await host.store(storeKey, key)
233 await host.store(await programStoreKeyOf(key), key)
234 generation += 1
235 host.approved()
236 },
237 () => {
238 declined.add(storeKey)
239 },
240 )
241 .finally(() => {
242 asking.delete(storeKey)
243 })
244 .catch((error: unknown) => {
245 host.log(`workitems: the approval could not be kept: ${String(error)}`)
246 })
247 }
248
249 return {
250 generation: () => generation,
251 startSession: (interactive) => {
252 isInteractive = interactive
253 declined.clear()
254 generation += 1
255 },
256 check: async (files, request) => {
257 refuseUnsafeArguments(request.shown)
258 const covered = await coveredFiles(files, request.folders)
259 const argv0 = await programOutsideRoot(files, request.command)
260 const key = { root: files.root, argv: [...request.command], files: covered, argv0 }
261 const storeKey = await storeKeyOf(key)
262 const programKey = await programStoreKeyOf(key)
263 if (await isStored(storeKey)) {
264 await keepProgramApproval(programKey, key)
265 return { approved: true, argv0 }
266 }
267 if ((await isStored(programKey)) && (await request.ignoresFolders(key))) {
268 return { approved: true, argv0 }
269 }
270 const mayAsk = isInteractive && !declined.has(storeKey) && !asking.has(storeKey)
271 const shown = [argv0, ...request.shown.slice(1)]
272 if (mayAsk) askPerson(storeKey, key, approvalQuestion(shown, request.note))
273 return { approved: false }
274 },
275 }
276}
277hooks/commands.ts 260 lines1import type {
2 WorkitemsOpaqueReason as OpaqueReason,
3 WorkitemsParsedCommand as ParsedCommand,
4 WorkitemsTrackerCli as TrackerCli,
5 WorkitemsTrackerFileArgs,
6 WorkitemsTrackerWrite as TrackerWrite,
7 WorkitemsWriteVerbs as WriteVerbs,
8} from '../types'
9import { writeVerbs } from './readers/index'
10
11const KIT_SCRIPT = '.basicly/core/kit/tracker/cli.py'
12const KIT: TrackerCli = '.basicly/core/kit/tracker/cli.py'
13const TRACKER_NAMES = new Set(['br', 'bd', 'basicly'])
14const PYTHON_NAMES = new Set(['python3', 'python'])
15const NOT_A_WRITE_FLAGS = new Set(['--help', '-h', '--dry-run'])
16const BR_GLOBAL_VALUE_FLAGS = new Set(['--db', '--actor', '--lock-timeout'])
17const MAX_COMMAND_LENGTH = 8192
18const EXIT_STATUS = '$?'
19const WRITE_CHAIN = '&&'
20const LINE_BREAK = '\n'
21const OPERATORS = [WRITE_CHAIN, '||', '|', ';', LINE_BREAK]
22const TRAILING_OPERATORS = new Set([';', LINE_BREAK])
23const STATUS_ECHO_SEPARATORS = new Set([';', LINE_BREAK, WRITE_CHAIN])
24const BARE_WORD_CHAR = /^[A-Za-z0-9._/:=@,+%-]$/
25const DOUBLE_QUOTED_EXPANSION = /[$`\\]/
26const NOT_A_NAME_CHAR = /[^A-Za-z0-9._/:=@,+%\\-]+/
27const EXPANSION_CHARS = '$`\\*?[]{}~'
28const REDIRECTION_CHARS = '<>'
29
30type Segment = { words: string[]; separator: string; hasStatus: boolean }
31
32type Shape = { kind: 'read'; segments: Segment[] } | { kind: 'refused'; reason: OpaqueReason }
33
34function refusalOf(char: string): OpaqueReason {
35 if (REDIRECTION_CHARS.includes(char)) return 'redirection'
36 if (EXPANSION_CHARS.includes(char)) return 'expansion'
37 return 'syntax'
38}
39
40function readShape(command: string): Shape {
41 const segments: Segment[] = []
42 let words: string[] = []
43 let word = ''
44 let inWord = false
45 let hasStatus = false
46 let separator = ''
47 const endWord = () => {
48 if (inWord) words.push(word)
49 word = ''
50 inWord = false
51 }
52 for (let i = 0; i < command.length; i += 1) {
53 const char = command.charAt(i)
54 if (char === "'" || char === '"') {
55 const close = command.indexOf(char, i + 1)
56 if (close === -1) return { kind: 'refused', reason: 'syntax' }
57 const quoted = command.slice(i + 1, close)
58 if (char === '"') {
59 if (DOUBLE_QUOTED_EXPANSION.test(quoted.replaceAll(EXIT_STATUS, ''))) {
60 return { kind: 'refused', reason: 'expansion' }
61 }
62 hasStatus ||= quoted.includes(EXIT_STATUS)
63 }
64 word += quoted
65 inWord = true
66 i = close
67 } else if (command.startsWith(EXIT_STATUS, i)) {
68 hasStatus = true
69 word += EXIT_STATUS
70 inWord = true
71 i += EXIT_STATUS.length - 1
72 } else if (BARE_WORD_CHAR.test(char)) {
73 word += char
74 inWord = true
75 } else if (char === ' ' || char === '\t') {
76 endWord()
77 } else {
78 const operator = OPERATORS.find((each) => command.startsWith(each, i))
79 if (operator === undefined) return { kind: 'refused', reason: refusalOf(char) }
80 endWord()
81 i += operator.length - 1
82 if (words.length > 0) {
83 segments.push({ words, separator, hasStatus })
84 words = []
85 hasStatus = false
86 separator = operator
87 } else if (operator !== LINE_BREAK) {
88 return { kind: 'refused', reason: 'syntax' }
89 }
90 }
91 }
92 endWord()
93 if (words.length > 0) segments.push({ words, separator, hasStatus })
94 else if (segments.length > 0 && !TRAILING_OPERATORS.has(separator)) {
95 return { kind: 'refused', reason: 'syntax' }
96 }
97 return { kind: 'read', segments }
98}
99
100type StatusEcho = { line: string; isAfterAnd: boolean }
101
102function statusEchoOf({ words, separator }: Segment): StatusEcho | null {
103 const [first, ...args] = words
104 if (first !== 'echo' || !STATUS_ECHO_SEPARATORS.has(separator)) return null
105 if (args.some((word) => word.startsWith('-'))) return null
106 return { line: args.join(' '), isAfterAnd: separator === WRITE_CHAIN }
107}
108
109function slashed(path: string): string {
110 return path.replaceAll('\\', '/')
111}
112
113function isKitScript(word: string): boolean {
114 const path = slashed(word).replace(/^(\.\/)+/, '')
115 return path === KIT_SCRIPT || path.endsWith(`/${KIT_SCRIPT}`)
116}
117
118function isTrackerProgram(word: string): boolean {
119 return TRACKER_NAMES.has(slashed(word).split('/').pop() ?? '') || isKitScript(word)
120}
121
122function allowedTrackerCommand(words: readonly string[]): readonly string[] | null {
123 const [first = '', second] = words
124 if (TRACKER_NAMES.has(first) || first === KIT_SCRIPT) return words
125 if (PYTHON_NAMES.has(first) && second === KIT_SCRIPT) return words.slice(1)
126 return null
127}
128
129function isNotAWrite(words: readonly string[]): boolean {
130 return words.some((word) => NOT_A_WRITE_FLAGS.has(word))
131}
132
133function skipOptions(words: readonly string[], start: number, valueFlags: Set<string>): number {
134 let i = start
135 while (i < words.length && (words[i] ?? '').startsWith('-')) {
136 const flag = words[i] ?? ''
137 i += valueFlags.has(flag) ? 2 : 1
138 }
139 return i
140}
141
142function positionals(words: readonly string[], globalValueFlags: Set<string>): string[] {
143 const start = skipOptions(words, 0, globalValueFlags)
144 return words.slice(start).filter((word) => !word.startsWith('-'))
145}
146
147function verbOf(args: readonly string[], verbs: readonly string[]): string | null {
148 for (const verb of verbs) {
149 const parts = verb.split(' ')
150 if (parts.every((part, index) => args[index] === part)) return verb
151 }
152 return null
153}
154
155function writeOf(command: readonly string[], table: WriteVerbs): TrackerWrite | null {
156 const argv0 = command[0]
157 if (argv0 === undefined) return null
158 const name = slashed(argv0).split('/').pop() ?? ''
159 const rest = command.slice(1)
160 if (name === 'br' || name === 'bd') {
161 const verb = verbOf(positionals(rest, BR_GLOBAL_VALUE_FLAGS), table.br)
162 return verb === null ? null : { tracker: 'br', verb }
163 }
164 if (isKitScript(argv0)) {
165 const verb = verbOf(positionals(rest, new Set()), table[KIT])
166 return verb === null ? null : { tracker: KIT, verb }
167 }
168 if (name === 'basicly') {
169 const args = positionals(rest, new Set())
170 if (args[0] !== 'tracker') return null
171 if (args[1] === 'write') {
172 const verb = verbOf(args.slice(2), table[KIT])
173 return verb === null ? null : { tracker: KIT, verb }
174 }
175 const verb = verbOf(args.slice(1), table['basicly tracker'])
176 return verb === null ? null : { tracker: 'basicly tracker', verb }
177 }
178 return null
179}
180
181function looseWritesOf(words: readonly string[], table: WriteVerbs): TrackerWrite[] {
182 if (isNotAWrite(words)) return []
183 return words.flatMap((word, index) =>
184 isTrackerProgram(word) ? (writeOf(words.slice(index), table) ?? []) : [],
185 )
186}
187
188function parseSegments(segments: readonly Segment[], table: WriteVerbs): ParsedCommand {
189 const last = segments.at(-1)
190 const echo = last !== undefined && segments.length > 1 ? statusEchoOf(last) : null
191 const checked = echo === null ? segments : segments.slice(0, -1)
192 const writes: TrackerWrite[] = []
193 let hasStatus = false
194 let hasOutsideShape = false
195 let hasOtherCommand = false
196 let hasHiddenStatus = false
197 let hasChangedDirectory = false
198 for (const [index, segment] of checked.entries()) {
199 const { words } = segment
200 hasStatus ||= segment.hasStatus
201 hasHiddenStatus ||= index > 0 && segment.separator !== WRITE_CHAIN
202 if (words[0] === 'cd') {
203 hasChangedDirectory = true
204 hasOtherCommand ||= words.length !== 2
205 continue
206 }
207 const allowed = allowedTrackerCommand(words)
208 const runsKitElsewhere = hasChangedDirectory && allowed?.[0] === KIT_SCRIPT
209 if (runsKitElsewhere || (allowed === null && words.some(isTrackerProgram))) {
210 hasOutsideShape = true
211 writes.push(...looseWritesOf(words, table))
212 continue
213 }
214 const write = allowed === null || isNotAWrite(words) ? null : writeOf(allowed, table)
215 if (write) writes.push(write)
216 else hasOtherCommand = true
217 }
218 if (hasOutsideShape) return { kind: 'opaque', reason: 'shape', writes }
219 if (writes.length === 0) return { kind: 'none' }
220 if (hasStatus) return { kind: 'opaque', reason: 'expansion', writes }
221 if (hasOtherCommand) return { kind: 'opaque', reason: 'mixed', writes }
222 if (hasHiddenStatus) return { kind: 'opaque', reason: 'hidden-status', writes }
223 if (echo === null) return { kind: 'write', writes }
224 if (echo.line.includes(EXIT_STATUS)) return { kind: 'echoed', writes, line: echo.line }
225 if (echo.isAfterAnd) return { kind: 'write', writes }
226 return { kind: 'opaque', reason: 'hidden-status', writes }
227}
228
229function parseCommand(command: string, table: WriteVerbs): ParsedCommand {
230 const names = () => command.split(NOT_A_NAME_CHAR)
231 if (command.length > MAX_COMMAND_LENGTH) {
232 if (!names().some(isTrackerProgram)) return { kind: 'none' }
233 return { kind: 'opaque', reason: 'syntax', writes: [] }
234 }
235 const shape = readShape(command)
236 if (shape.kind === 'read') return parseSegments(shape.segments, table)
237 const words = names()
238 if (!words.some(isTrackerProgram)) return { kind: 'none' }
239 return { kind: 'opaque', reason: shape.reason, writes: looseWritesOf(words, table) }
240}
241
242export function classify(command: string): ParsedCommand {
243 return parseCommand(command, writeVerbs)
244}
245
246const TRACKER_FILES: readonly RegExp[] = [
247 /^\.beads\/issues\.jsonl$/,
248 /^\.basicly\/ledger\/(?:events|pending)-[^/]+\.jsonl$/,
249 /^\.basicly\/ledger\/snapshot\.jsonl$/,
250 /^\.beans\/(?:[^/]+\/)*[^/]+--[^/]+\.md$/,
251]
252
253export function trackerFileOf({ path: filePath, root }: WorkitemsTrackerFileArgs): string | null {
254 const path = slashed(filePath)
255 const prefix = `${slashed(root).replace(/\/+$/, '')}/`
256 if (!path.startsWith(prefix)) return null
257 const relative = path.slice(prefix.length)
258 return TRACKER_FILES.some((marker) => marker.test(relative)) ? relative : null
259}
260hooks/readers/index.ts 114 lines1import type { FsEntry, ProcessRunResult } from 'claude-code'
2import type {
3 WorkitemsAdapterWrites,
4 WorkitemsFailedReason,
5 WorkitemsItem,
6 WorkitemsWriteVerbs,
7} from '../../types'
8import type { Approvals } from '../approval'
9import { createAdapterReader } from './adapter'
10import { createBasiclyReader } from './basicly'
11import { beadsReader } from './beads'
12import { beansReader } from './beans'
13import { filesReader } from './generic'
14
15export type TrackerCommands = {
16 canRun: () => Promise<boolean>
17 run: (
18 argv: readonly string[],
19 env?: Readonly<Record<string, string>>,
20 ) => Promise<ProcessRunResult>
21 which: (program: string) => Promise<string | undefined>
22 stamp: (program: string) => Promise<string | undefined>
23 approvals: Approvals
24}
25
26export type TrackerFiles = {
27 root: string
28 read: (relativePath: string) => Promise<string>
29 exists: (relativePath: string) => Promise<boolean>
30 list: (relativeDirectory: string) => Promise<FsEntry[]>
31 realPath: (relativePath: string) => Promise<string | undefined>
32 stat: (
33 relativePath: string,
34 ) => Promise<{ size: number; mtimeMs: number; kind?: 'file' | 'dir' | 'other' }>
35 hash: (relativePath: string) => Promise<string | undefined>
36 readUserFile: (homeRelativePath: string) => Promise<string | undefined>
37 commands: TrackerCommands
38}
39
40export type ReadOutcome =
41 | {
42 ok: true
43 items: WorkitemsItem[]
44 sourceLabel: string
45 caveat: string | null
46 adapterWrites?: WorkitemsAdapterWrites
47 listsOpenOnly?: true
48 omitsParent?: true
49 }
50 | { ok: false; reason: WorkitemsFailedReason }
51 | { ok: false; state: 'approval-needed'; command: string; sourceLabel: string }
52 | { ok: false; state: 'terminal-only'; sourceLabel: string }
53
54export type Reader = {
55 name: string
56 marker: string
57 lookedForAs?: string
58 isPresent?: (files: TrackerFiles) => Promise<boolean>
59 signature?: (files: TrackerFiles) => Promise<string>
60 read: (files: TrackerFiles) => Promise<ReadOutcome>
61}
62
63export function createReaders(): readonly Reader[] {
64 return [createBasiclyReader(), beadsReader, beansReader, filesReader, createAdapterReader()]
65}
66
67export const writeVerbs: WorkitemsWriteVerbs = {
68 br: [
69 'close',
70 'create',
71 'defer',
72 'delete',
73 'q',
74 'reopen',
75 'undefer',
76 'update',
77 'comments add',
78 'dep add',
79 'dep remove',
80 'dep import',
81 'label add',
82 'label remove',
83 'label rename',
84 'epic close-eligible',
85 ],
86 'basicly tracker': [
87 'close',
88 'comments add',
89 'create',
90 'dep add',
91 'dep remove',
92 'gate report',
93 'update',
94 ],
95 '.basicly/core/kit/tracker/cli.py': [
96 'create',
97 'compact',
98 'sync',
99 'import',
100 'migrate-fields',
101 'child',
102 'update',
103 'close',
104 'comment',
105 'dep',
106 'undep',
107 'assign',
108 'claim',
109 'resolve',
110 'unassign',
111 'delete',
112 ],
113}
114hooks/snapshot.ts 516 lines1import type { FsEntry, ProcessRunResult } from 'claude-code'
2import type {
3 WorkitemsDiff,
4 WorkitemsFailedReason,
5 WorkitemsItem,
6 WorkitemsRefreshArgs,
7 WorkitemsRefreshResult,
8 WorkitemsSnapshot,
9} from '../types'
10import { resolveProgram, sha256Hex, type Approvals, type SearchPath } from './approval'
11import { FileProblem, FileTooLarge, isInside, isUnsafeCharacter } from './config'
12import { detect } from './detect'
13import type { ReadOutcome, Reader, TrackerFiles } from './readers/index'
14
15export const MAX_FILE_BYTES = 4 * 1024 * 1024
16export const POLL_INTERVAL_MS = 2000
17export const DIFF_HISTORY_LIMIT = 50
18
19export type FileStat = {
20 size: number
21 mtimeMs: number
22 realPath?: string
23 kind?: 'file' | 'dir' | 'other'
24}
25
26export type CommandHost = {
27 canRun: () => Promise<boolean>
28 run: (
29 argv: readonly string[],
30 cwd: string,
31 env?: Readonly<Record<string, string>>,
32 ) => Promise<ProcessRunResult>
33 searchPath: () => Promise<SearchPath>
34 approvals: Approvals
35}
36
37export type ProviderHost = {
38 root: () => Promise<string>
39 now: () => Promise<number>
40 exists: (path: string) => Promise<boolean>
41 stat: (path: string, options?: { resolve: boolean }) => Promise<FileStat>
42 list: (path: string) => Promise<FsEntry[]>
43 read: (path: string) => Promise<string>
44 readBytes: (path: string) => Promise<Uint8Array>
45 homeFolder: () => Promise<string | undefined>
46 commands: CommandHost
47 publish: (snapshot: WorkitemsSnapshot) => Promise<void>
48}
49
50export type Provider = {
51 refresh: (args?: WorkitemsRefreshArgs) => Promise<WorkitemsRefreshResult>
52}
53
54function emptyDiff(): WorkitemsDiff {
55 return { created: [], updated: [], closed: [] }
56}
57
58export function pathAtRoot(root: string, relativePath: string): string {
59 return `${root.replace(/[\\/]+$/, '')}/${relativePath}`
60}
61
62function filesAtRoot(host: ProviderHost, root: string): TrackerFiles {
63 async function statAt(relativePath: string): Promise<FileStat> {
64 try {
65 const { size, mtimeMs, kind } = await host.stat(pathAtRoot(root, relativePath))
66 return kind === undefined ? { size, mtimeMs } : { size, mtimeMs, kind }
67 } catch {
68 throw new FileProblem(`${relativePath} could not be read.`)
69 }
70 }
71 async function realPathOf(path: string): Promise<string | undefined> {
72 try {
73 return (await host.stat(path, { resolve: true })).realPath
74 } catch {
75 return undefined
76 }
77 }
78 let rootReal: Promise<string | undefined> | undefined
79 async function confinedPath(relativePath: string): Promise<string> {
80 const path = pathAtRoot(root, relativePath)
81 rootReal ??= realPathOf(root)
82 const base = await rootReal
83 if (base === undefined) throw new FileProblem('the repo root could not be read.')
84 const real = await realPathOf(path)
85 if (real === undefined) throw new FileProblem(`${relativePath} could not be read.`)
86 if (!isInside(base, real)) {
87 throw new FileProblem(
88 `${relativePath} resolves outside the repo root, so it could not be read.`,
89 )
90 }
91 return path
92 }
93 async function hashAt(relativePath: string): Promise<string | undefined> {
94 const path = pathAtRoot(root, relativePath)
95 let stat: FileStat
96 try {
97 stat = await host.stat(path)
98 } catch {
99 return undefined
100 }
101 if (stat.kind === 'dir') return undefined
102 if (stat.size > MAX_FILE_BYTES) {
103 throw new FileProblem(
104 `${relativePath} is over 4 MiB, so the approval cannot hash it and it could not be read.`,
105 )
106 }
107 try {
108 return await sha256Hex(await host.readBytes(path))
109 } catch {
110 throw new FileProblem(`${relativePath} could not be read.`)
111 }
112 }
113 async function readUserFile(homeRelativePath: string): Promise<string | undefined> {
114 const home = await host.homeFolder()
115 if (home === undefined || home === '') return undefined
116 const path = `${home.replace(/[\\/]+$/, '')}/${homeRelativePath}`
117 if (!(await host.exists(path))) return undefined
118 let stat: FileStat
119 try {
120 stat = await host.stat(path)
121 } catch {
122 throw new FileProblem(`~/${homeRelativePath} could not be read.`)
123 }
124 if (stat.kind !== 'file') {
125 throw new FileProblem(`~/${homeRelativePath} is not a regular file, so it could not be read.`)
126 }
127 if (stat.size > MAX_FILE_BYTES) throw new FileProblem(`~/${homeRelativePath} is over 4 MiB.`)
128 try {
129 return await host.read(path)
130 } catch {
131 throw new FileProblem(`~/${homeRelativePath} could not be read.`)
132 }
133 }
134 return {
135 root,
136 stat: statAt,
137 hash: hashAt,
138 readUserFile,
139 commands: {
140 canRun: () => host.commands.canRun(),
141 run: (argv, env) => host.commands.run(argv, root, env),
142 which: (program) =>
143 resolveProgram(program, {
144 searchPath: () => host.commands.searchPath(),
145 exists: (path) => host.exists(path),
146 realPath: realPathOf,
147 realPathAtRoot: (relativePath) => realPathOf(pathAtRoot(root, relativePath)),
148 }),
149 stamp: async (program) => {
150 try {
151 const { size, mtimeMs } = await host.stat(program)
152 return `${String(size)} ${String(mtimeMs)}`
153 } catch {
154 return undefined
155 }
156 },
157 approvals: host.commands.approvals,
158 },
159 exists: (relativePath) => host.exists(pathAtRoot(root, relativePath)),
160 list: async (relativeDirectory) => {
161 try {
162 return await host.list(pathAtRoot(root, relativeDirectory))
163 } catch {
164 throw new FileProblem(`${relativeDirectory} could not be read.`)
165 }
166 },
167 realPath: async (relativePath) => {
168 try {
169 return (await host.stat(pathAtRoot(root, relativePath), { resolve: true })).realPath
170 } catch {
171 return undefined
172 }
173 },
174 read: async (relativePath) => {
175 const path = await confinedPath(relativePath)
176 const stat = await statAt(relativePath)
177 if (stat.size > MAX_FILE_BYTES) throw new FileTooLarge(`${relativePath} is over 4 MiB.`)
178 try {
179 return await host.read(path)
180 } catch {
181 throw new FileProblem(`${relativePath} could not be read.`)
182 }
183 },
184 }
185}
186
187type DiffRules = {
188 isMissingClosed: boolean
189 wasOpenOnly: boolean
190 ignoresParent: boolean
191}
192
193function comparable(item: WorkitemsItem, ignoresParent: boolean): string {
194 return JSON.stringify(ignoresParent ? { ...item, parent: undefined } : item)
195}
196
197export function diffItems(
198 before: readonly WorkitemsItem[],
199 after: readonly WorkitemsItem[],
200 { isMissingClosed, wasOpenOnly, ignoresParent }: DiffRules,
201): WorkitemsDiff {
202 const previous = new Map(before.map((item) => [item.key, item]))
203 const diff = {
204 created: [] as WorkitemsItem[],
205 updated: [] as WorkitemsItem[],
206 closed: [] as WorkitemsItem[],
207 }
208 for (const item of after) {
209 const old = previous.get(item.key)
210 const wasClosedBefore = !old && wasOpenOnly && item.status === 'closed'
211 if (wasClosedBefore) continue
212 if (!old) diff.created.push(item)
213 else if (old.status !== 'closed' && item.status === 'closed') diff.closed.push(item)
214 else if (comparable(old, ignoresParent) !== comparable(item, ignoresParent)) {
215 diff.updated.push(item)
216 }
217 }
218 if (!isMissingClosed) return diff
219 const current = new Set(after.map((item) => item.key))
220 for (const old of before) {
221 if (!current.has(old.key) && old.status !== 'closed')
222 diff.closed.push({ ...old, status: 'closed' })
223 }
224 return diff
225}
226
227async function readSafely(reader: Reader, files: TrackerFiles): Promise<ReadOutcome> {
228 try {
229 return await reader.read(files)
230 } catch (error) {
231 if (error instanceof FileProblem) return { ok: false, reason: error.reason }
232 throw error
233 }
234}
235
236async function signatureOf(
237 host: ProviderHost,
238 files: TrackerFiles,
239 root: string,
240 reader: Reader,
241): Promise<string> {
242 try {
243 if (reader.signature) return [root, reader.name, await reader.signature(files)].join('\n')
244 const stat = await host.stat(pathAtRoot(root, reader.marker))
245 return [root, reader.name, String(stat.mtimeMs), String(stat.size)].join('\n')
246 } catch {
247 return [root, reader.name, 'unreadable'].join('\n')
248 }
249}
250
251type DiffKind = keyof WorkitemsDiff
252
253const KINDS_BY_STRENGTH: readonly DiffKind[] = ['updated', 'closed', 'created']
254
255export function mergeDiffs(diffs: readonly WorkitemsDiff[]): WorkitemsDiff {
256 const merged = new Map<string, { kind: DiffKind; item: WorkitemsItem }>()
257 for (const diff of diffs) {
258 for (const kind of KINDS_BY_STRENGTH) {
259 for (const item of diff[kind]) {
260 const seen = merged.get(item.key)
261 const isSeenStronger =
262 seen !== undefined &&
263 KINDS_BY_STRENGTH.indexOf(seen.kind) > KINDS_BY_STRENGTH.indexOf(kind)
264 merged.set(item.key, { kind: isSeenStronger ? seen.kind : kind, item })
265 }
266 }
267 }
268 const result = {
269 created: [] as WorkitemsItem[],
270 updated: [] as WorkitemsItem[],
271 closed: [] as WorkitemsItem[],
272 }
273 for (const { kind, item } of merged.values()) result[kind].push(item)
274 return result
275}
276
277type Stamps = 'version' | 'at' | 'checkedAt'
278type SnapshotData = WorkitemsSnapshot extends infer S
279 ? S extends unknown
280 ? Omit<S, Stamps>
281 : never
282 : never
283
284type ReadResult = {
285 data: SnapshotData
286 baselineItems: readonly WorkitemsItem[] | undefined
287 listsOpenOnly: boolean
288 omitsParent: boolean
289}
290
291const MAX_SHOWN_TEXT = 1000
292
293function isShownText(text: string): boolean {
294 if (text.length > MAX_SHOWN_TEXT) return false
295 for (const character of text) {
296 if (isUnsafeCharacter(character.codePointAt(0) ?? 0)) return false
297 }
298 return true
299}
300
301function shownReason(reader: Reader, reason: WorkitemsFailedReason): WorkitemsFailedReason {
302 if (isShownText(reason)) return reason
303 return `${reader.name} found a name or a value with a control character or over ${String(MAX_SHOWN_TEXT)} characters, so it could not be read.`
304}
305
306function shownCaveat(reader: Reader, caveat: string | null): string | null {
307 if (caveat === null || isShownText(caveat)) return caveat
308 return `${reader.name} skipped an item file whose name has a control character or is over ${String(MAX_SHOWN_TEXT)} characters.`
309}
310
311async function readSource(
312 reader: Reader,
313 ignored: readonly string[],
314 files: TrackerFiles,
315 root: string,
316): Promise<ReadResult> {
317 const outcome = await readSafely(reader, files)
318 const sourced = { root, source: reader.name, caveat: null, items: [], ignored }
319 if (!outcome.ok && 'state' in outcome && outcome.state === 'terminal-only') {
320 return {
321 baselineItems: undefined,
322 listsOpenOnly: false,
323 omitsParent: false,
324 data: { ...sourced, state: outcome.state, reason: null, sourceLabel: outcome.sourceLabel },
325 }
326 }
327 if (!outcome.ok && 'state' in outcome) {
328 return {
329 baselineItems: undefined,
330 listsOpenOnly: false,
331 omitsParent: false,
332 data: {
333 ...sourced,
334 state: outcome.state,
335 reason: outcome.command,
336 sourceLabel: outcome.sourceLabel,
337 },
338 }
339 }
340 if (!outcome.ok) {
341 return {
342 baselineItems: undefined,
343 listsOpenOnly: false,
344 omitsParent: false,
345 data: {
346 state: 'failed',
347 reason: shownReason(reader, outcome.reason),
348 root,
349 source: reader.name,
350 sourceLabel: reader.name,
351 caveat: null,
352 items: [],
353 ignored,
354 },
355 }
356 }
357 return {
358 baselineItems: outcome.items,
359 listsOpenOnly: outcome.listsOpenOnly === true,
360 omitsParent: outcome.omitsParent === true,
361 data: {
362 state: 'ok',
363 reason: null,
364 root,
365 source: reader.name,
366 sourceLabel: outcome.sourceLabel,
367 caveat: shownCaveat(reader, outcome.caveat),
368 items: outcome.items,
369 ignored,
370 ...(outcome.adapterWrites === undefined ? {} : { adapterWrites: outcome.adapterWrites }),
371 },
372 }
373}
374
375function noTracker(root: string, lookedFor: `looked for ${string}`): ReadResult {
376 return {
377 baselineItems: [],
378 listsOpenOnly: false,
379 omitsParent: false,
380 data: {
381 state: 'no-tracker',
382 reason: lookedFor,
383 root,
384 source: null,
385 sourceLabel: null,
386 caveat: null,
387 items: [],
388 ignored: [],
389 },
390 }
391}
392
393function deliveredToItsOwnCallers(): undefined {
394 return undefined
395}
396
397export function createProvider(host: ProviderHost, readers: readonly Reader[]): Provider {
398 let current: WorkitemsSnapshot | undefined
399 let baseline:
400 | {
401 root: string
402 source: string | null
403 items: readonly WorkitemsItem[]
404 listsOpenOnly: boolean
405 omitsParent: boolean
406 }
407 | undefined
408 let lastSignature: string | undefined
409 let version = 0
410 const history: { version: number; diff: WorkitemsDiff }[] = []
411 let tail: Promise<void> = Promise.resolve()
412 let queued: Promise<void> | undefined
413
414 function sinceOf(args: WorkitemsRefreshArgs | undefined): number {
415 const since = args?.since ?? version
416 if (!Number.isInteger(since) || since < 0) {
417 throw new RangeError(
418 `workitems refresh: since must be a whole number of 0 or more, not ${String(since)}`,
419 )
420 }
421 if (since > version) {
422 throw new RangeError(
423 `workitems refresh: since ${String(since)} is newer than the current version ${String(version)}`,
424 )
425 }
426 return since
427 }
428
429 function diffSince(since: number): WorkitemsDiff {
430 if (since === version) return emptyDiff()
431 const oldestKept = history[0]?.version ?? version + 1
432 if (since + 1 < oldestKept) {
433 throw new RangeError(
434 `workitems refresh: since ${String(since)} is older than the oldest kept diff (version ${String(oldestKept)}); call refresh() without since`,
435 )
436 }
437 return mergeDiffs(history.filter((entry) => entry.version > since).map((entry) => entry.diff))
438 }
439
440 async function readOnce(): Promise<void> {
441 const root = await host.root()
442 const files = filesAtRoot(host, root)
443 const detection = await detect(readers, files)
444 const signature = detection.found
445 ? [
446 await signatureOf(host, files, root, detection.reader),
447 `ignored ${detection.ignored.join(' ')}`,
448 ].join('\n')
449 : [root, 'no-tracker'].join('\n')
450 const checkedAt = await host.now()
451 if (current && signature === lastSignature) {
452 const checked: WorkitemsSnapshot = { ...current, checkedAt }
453 await host.publish(checked)
454 current = checked
455 return
456 }
457 const result = detection.found
458 ? await readSource(detection.reader, detection.ignored, files, root)
459 : noTracker(root, detection.lookedFor)
460 const nextVersion = version + 1
461 const snapshot: WorkitemsSnapshot = {
462 ...result.data,
463 version: nextVersion,
464 at: checkedAt,
465 checkedAt,
466 }
467 await host.publish(snapshot)
468 const source = detection.found ? detection.reader.name : null
469 const isSameSource = baseline?.source === source
470 const isMissingClosed = result.listsOpenOnly && isSameSource
471 const wasOpenOnly = isSameSource && baseline?.listsOpenOnly === true
472 const ignoresParent = result.omitsParent || baseline?.omitsParent === true
473 const diff =
474 result.baselineItems !== undefined && baseline?.root === root
475 ? diffItems(baseline.items, result.baselineItems, {
476 isMissingClosed,
477 wasOpenOnly,
478 ignoresParent,
479 })
480 : emptyDiff()
481 if (result.baselineItems !== undefined) {
482 baseline = {
483 root,
484 source,
485 items: result.baselineItems,
486 listsOpenOnly: result.listsOpenOnly,
487 omitsParent: result.omitsParent,
488 }
489 }
490 version = nextVersion
491 history.push({ version, diff })
492 if (history.length > DIFF_HISTORY_LIMIT) history.shift()
493 lastSignature = signature
494 current = snapshot
495 }
496
497 function requestRead(): Promise<void> {
498 if (queued) return queued
499 const read = tail.catch(deliveredToItsOwnCallers).then(() => {
500 queued = undefined
501 return readOnce()
502 })
503 queued = read
504 tail = read
505 return read
506 }
507
508 return {
509 refresh: async (args) => {
510 const since = sinceOf(args)
511 await requestRead()
512 return { ...diffSince(since), version }
513 },
514 }
515}
516hooks/states.ts 94 lines1import type {
2 WorkitemsLine,
3 WorkitemsLinesArgs,
4 WorkitemsSnapshotBase,
5 WorkitemsSourced,
6} from '../types'
7
8const SECOND_MS = 1000
9const MINUTE_MS = 60 * SECOND_MS
10const HOUR_MS = 60 * MINUTE_MS
11
12export function numeral(value: number): `${number}` {
13 return value.toString() as `${number}`
14}
15
16export function ageText(elapsedMs: number): string {
17 const elapsed = Math.max(0, elapsedMs)
18 if (elapsed < MINUTE_MS) return `${numeral(Math.floor(elapsed / SECOND_MS))} s`
19 if (elapsed < HOUR_MS) return `${numeral(Math.floor(elapsed / MINUTE_MS))} min`
20 return `${numeral(Math.floor(elapsed / HOUR_MS))} h`
21}
22
23function headerLines(
24 snapshot: WorkitemsSnapshotBase & WorkitemsSourced,
25 now: number,
26): WorkitemsLine[] {
27 const label = snapshot.sourceLabel
28 const open = numeral(snapshot.items.filter((item) => item.status !== 'closed').length)
29 const age = ageText(now - snapshot.checkedAt)
30 const header: WorkitemsLine =
31 snapshot.caveat === null
32 ? { kind: 'header', tone: 'dim', text: `${label} · ${open} open · read ${age} ago` }
33 : {
34 kind: 'header',
35 tone: 'dim',
36 text: `${label} · ${open} open · read ${age} ago · may be stale: ${snapshot.caveat}`,
37 }
38 if (snapshot.ignored.length === 0) return [header]
39 return [
40 header,
41 {
42 kind: 'ignored',
43 tone: 'dim',
44 text: `Using ${snapshot.source}; ignoring ${snapshot.ignored.join(', ')}. Name one in .handily.json to change it.`,
45 },
46 ]
47}
48
49export function stateLines({ snapshot, now }: WorkitemsLinesArgs): WorkitemsLine[] {
50 switch (snapshot.state) {
51 case 'ok':
52 return headerLines(snapshot, now)
53 case 'failed':
54 return [{ kind: 'failed', tone: 'error', text: `Work items unavailable: ${snapshot.reason}` }]
55 case 'stale':
56 return [
57 {
58 kind: 'stale',
59 tone: 'warning',
60 text: `Work items may be out of date: last good read ${ageText(now - snapshot.at)} ago (${snapshot.reason}).`,
61 },
62 ]
63 case 'approval-needed':
64 return [
65 {
66 kind: 'approval-needed',
67 tone: 'warning',
68 text: `Work items need your approval to run ${snapshot.reason}.`,
69 },
70 {
71 kind: 'approval-hint',
72 tone: 'dim',
73 text: 'Asked at the next refresh in an interactive session.',
74 },
75 ]
76 case 'no-tracker':
77 return [
78 {
79 kind: 'no-tracker',
80 tone: 'dim',
81 text: `No tracker found at the repo root (${snapshot.reason}).`,
82 },
83 ]
84 case 'terminal-only':
85 return [
86 {
87 kind: 'terminal-only',
88 tone: 'dim',
89 text: `${snapshot.sourceLabel} is read through a CLI, which only a terminal session can run. Open this repo in a terminal to see its items.`,
90 },
91 ]
92 }
93}
94hooks/config.ts 332 lines1import type { FsEntry } from 'claude-code'
2import type { WorkitemsFailedReason } from '../types'
3import type { TrackerFiles } from './readers/index'
4
5export const CONFIG_FILE = '.handily.json'
6export const USER_ADAPTERS_FILE = '.config/handily/adapters.json'
7export const USER_ADAPTERS_SHOWN = `~/${USER_ADAPTERS_FILE}`
8export const MAX_ARGUMENT_LENGTH = 256
9
10export function isUnsafeCharacter(code: number): boolean {
11 const isControl = code < 0x20 || (code >= 0x7f && code <= 0x9f)
12 const isLineBreak = code === 0x2028 || code === 0x2029
13 const isBidiControl = (code >= 0x202a && code <= 0x202e) || (code >= 0x2066 && code <= 0x2069)
14 return isControl || isLineBreak || isBidiControl
15}
16
17export function argumentProblem(argument: string): string | null {
18 for (const character of argument) {
19 if (isUnsafeCharacter(character.codePointAt(0) ?? 0)) return 'with a control character'
20 }
21 if (argument.length > MAX_ARGUMENT_LENGTH) {
22 return `over ${String(MAX_ARGUMENT_LENGTH)} characters`
23 }
24 return null
25}
26
27export class FileProblem extends Error {
28 constructor(readonly reason: WorkitemsFailedReason) {
29 super(reason)
30 }
31}
32
33export class FileTooLarge extends FileProblem {}
34
35export type FilesFormat = 'json' | 'jsonl' | 'frontmatter'
36
37export const MAPPED_FIELDS = [
38 'id',
39 'title',
40 'status',
41 'priority',
42 'type',
43 'assignee',
44 'updatedAt',
45 'labels',
46 'parent',
47 'url',
48] as const
49
50export type MappedField = (typeof MAPPED_FIELDS)[number]
51
52export type FieldMap = { id: string; title: string; status: string } & Partial<
53 Record<MappedField, string>
54>
55
56export type FilesConfig = { globs: readonly string[]; format: FilesFormat; fields: FieldMap }
57
58export type HandilyConfig = { source: string | null; files: FilesConfig | null }
59
60export type ConfigOutcome =
61 { ok: true; config: HandilyConfig } | { ok: false; reason: WorkitemsFailedReason }
62
63export type GlobMatch = { path: string; size: number; mtimeMs: number }
64
65const FORMATS: readonly FilesFormat[] = ['json', 'jsonl', 'frontmatter']
66const TOP_LEVEL_KEYS = ['source', 'globs', 'format', 'fields'] as const
67const REQUIRED_FIELDS = ['id', 'title', 'status'] as const
68const UNSUPPORTED_GLOB_CHARACTERS = /[[\]{}\\]/
69const ABSOLUTE_PATH = /^([\\/]|[A-Za-z]:)/
70
71class ConfigFault extends Error {}
72
73function configFault(detail: string): never {
74 throw new ConfigFault(`${CONFIG_FILE} ${detail}, so it could not be read.`)
75}
76
77function isRecord(value: unknown): value is Record<string, unknown> {
78 return typeof value === 'object' && value !== null && !Array.isArray(value)
79}
80
81function isText(value: unknown): value is string {
82 return typeof value === 'string' && value !== ''
83}
84
85function parseJson(text: string): unknown {
86 try {
87 return JSON.parse(text) as unknown
88 } catch {
89 return undefined
90 }
91}
92
93function globsOf(value: unknown): string[] {
94 if (!Array.isArray(value) || value.length === 0 || !value.every(isText)) {
95 configFault('needs globs as a list of paths')
96 }
97 return value
98}
99
100function formatOf(value: unknown): FilesFormat {
101 const format = FORMATS.find((known) => known === value)
102 if (!format) configFault(`needs a format of ${FORMATS.join(', ')}`)
103 return format
104}
105
106function fieldsOf(value: unknown): FieldMap {
107 if (!isRecord(value)) configFault('needs fields as a map of item fields to file fields')
108 const fields: Partial<Record<MappedField, string>> = {}
109 for (const [key, name] of Object.entries(value)) {
110 const field = MAPPED_FIELDS.find((known) => known === key)
111 if (!field) configFault(`fields names ${key}, which is not one of ${MAPPED_FIELDS.join(', ')}`)
112 if (!isText(name)) configFault(`fields gives ${key} no file field`)
113 fields[field] = name
114 }
115 for (const required of REQUIRED_FIELDS) {
116 if (!fields[required]) configFault(`fields has no ${required}`)
117 }
118 return fields as FieldMap
119}
120
121async function repoCommandFault(files: TrackerFiles): Promise<never> {
122 const rootReal = await files.realPath('.')
123 const isShown = rootReal !== undefined && argumentProblem(rootReal) === null
124 const entry = isShown ? `an entry for ${JSON.stringify(rootReal)}` : 'an entry for this repo'
125 throw new ConfigFault(
126 `${CONFIG_FILE} names a command, which handily never runs from the repo. To use an adapter, add ${entry} to ${USER_ADAPTERS_SHOWN}, as the workitems README explains. The repo command could not be read.`,
127 )
128}
129
130function configOf(parsed: unknown): HandilyConfig {
131 if (!isRecord(parsed)) configFault('is not a JSON object')
132 for (const key of Object.keys(parsed)) {
133 if (!TOP_LEVEL_KEYS.some((known) => known === key)) {
134 configFault(`has the key ${key}, which is not one of ${TOP_LEVEL_KEYS.join(', ')}`)
135 }
136 }
137 const { source, globs, format, fields } = parsed
138 if (source !== undefined && !isText(source)) configFault('needs source as a tracker name')
139 const namesFiles = globs !== undefined || format !== undefined || fields !== undefined
140 return {
141 source: source ?? null,
142 files: namesFiles
143 ? { globs: globsOf(globs), format: formatOf(format), fields: fieldsOf(fields) }
144 : null,
145 }
146}
147
148const configReads = new WeakMap<TrackerFiles, Promise<ConfigOutcome>>()
149
150export function readConfig(files: TrackerFiles): Promise<ConfigOutcome> {
151 let outcome = configReads.get(files)
152 if (!outcome) {
153 outcome = readConfigOnce(files)
154 configReads.set(files, outcome)
155 }
156 return outcome
157}
158
159async function readConfigOnce(files: TrackerFiles): Promise<ConfigOutcome> {
160 try {
161 if (!(await files.exists(CONFIG_FILE)))
162 return { ok: true, config: { source: null, files: null } }
163 const parsed = parseJson(await files.read(CONFIG_FILE))
164 if (isRecord(parsed) && Object.hasOwn(parsed, 'command')) {
165 await repoCommandFault(files)
166 }
167 return { ok: true, config: configOf(parsed) }
168 } catch (error) {
169 if (error instanceof FileProblem) return { ok: false, reason: error.reason }
170 if (error instanceof ConfigFault) {
171 return { ok: false, reason: error.message as WorkitemsFailedReason }
172 }
173 throw error
174 }
175}
176
177function joined(directory: string, name: string): string {
178 return directory === '' ? name : `${directory}/${name}`
179}
180
181const WINDOWS_ROOT = /^(?:[A-Za-z]:|\\\\)/
182const LONG_PATH_UNC_PREFIX = /^\\\\\?\\UNC\\/i
183const LONG_PATH_PREFIX = /^\\\\\?\\/
184
185function withoutLongPathPrefix(path: string): string {
186 return path.replace(LONG_PATH_UNC_PREFIX, '\\\\').replace(LONG_PATH_PREFIX, '')
187}
188
189const LOWER_DRIVE = /^[a-z]:/
190
191function comparable(path: string, isCaseBlind: boolean): string {
192 const separated = withoutLongPathPrefix(path.replace(/[\\/]+$/, '')).replaceAll('\\', '/')
193 if (isCaseBlind) return separated.toLowerCase()
194 return separated.replace(LOWER_DRIVE, (drive) => drive.toUpperCase())
195}
196
197function startsAtRoot(rootReal: string, real: string, isCaseBlind: boolean): boolean {
198 if (!WINDOWS_ROOT.test(rootReal)) {
199 const base = rootReal.replace(/[\\/]+$/, '')
200 return (
201 real === base || real.startsWith(`${base}/`) || (isCaseBlind && real.startsWith(`${base}\\`))
202 )
203 }
204 const base = comparable(rootReal, isCaseBlind)
205 const path = comparable(real, isCaseBlind)
206 return path === base || path.startsWith(`${base}/`)
207}
208
209export function isInside(rootReal: string, real: string): boolean {
210 return startsAtRoot(rootReal, real, false)
211}
212
213export function mayBeInside(rootReal: string, real: string): boolean {
214 return startsAtRoot(rootReal, real, true)
215}
216
217function segmentPattern(segment: string): RegExp {
218 const source = segment
219 .split('')
220 .map((character) => {
221 if (character === '*') return '[^/]*'
222 if (character === '?') return '[^/]'
223 return character.replace(/[.+^$()|]/g, '\\$&')
224 })
225 .join('')
226 return new RegExp(`^${source}$`)
227}
228
229function hasWildcard(segment: string): boolean {
230 return segment.includes('*') || segment.includes('?')
231}
232
233function isHidden(name: string, segment: string): boolean {
234 return name.startsWith('.') && !segment.startsWith('.')
235}
236
237type Walk = { files: TrackerFiles; rootReal: string; glob: string }
238
239async function confine(walk: Walk, path: string): Promise<void> {
240 const real = await walk.files.realPath(path === '' ? '.' : path)
241 if (real === undefined) {
242 throw new FileProblem(`the glob ${walk.glob} needs ${path}, which could not be read.`)
243 }
244 if (!isInside(walk.rootReal, real)) {
245 throw new FileProblem(
246 `${path} resolves outside the repo root, so the glob ${walk.glob} could not be read.`,
247 )
248 }
249}
250
251async function entriesOf(walk: Walk, directory: string): Promise<FsEntry[]> {
252 await confine(walk, directory)
253 return walk.files.list(directory === '' ? '.' : directory)
254}
255
256async function lastSegment(walk: Walk, directory: string, segment: string): Promise<GlobMatch[]> {
257 const pattern = segmentPattern(segment)
258 const matches: GlobMatch[] = []
259 for (const entry of await entriesOf(walk, directory)) {
260 const isCandidate = entry.kind === 'file' || entry.isLink
261 if (!isCandidate || isHidden(entry.name, segment) || !pattern.test(entry.name)) continue
262 const path = joined(directory, entry.name)
263 if (!entry.isLink) {
264 matches.push({ path, size: entry.size, mtimeMs: entry.mtimeMs })
265 continue
266 }
267 await confine(walk, path)
268 const target = await walk.files.stat(path)
269 matches.push({ path, size: target.size, mtimeMs: target.mtimeMs })
270 }
271 return matches
272}
273
274async function subdirectories(walk: Walk, directory: string, segment: string): Promise<string[]> {
275 const pattern = segment === '**' ? undefined : segmentPattern(segment)
276 return (await entriesOf(walk, directory))
277 .filter((entry) => entry.kind === 'dir' && !isHidden(entry.name, segment))
278 .filter((entry) => pattern === undefined || pattern.test(entry.name))
279 .map((entry) => joined(directory, entry.name))
280}
281
282async function expand(walk: Walk, directory: string, segments: string[]): Promise<GlobMatch[]> {
283 const [segment, ...rest] = segments
284 if (segment === undefined) return []
285 if (rest.length === 0) return lastSegment(walk, directory, segment)
286 if (segment === '**') {
287 const matches = await expand(walk, directory, rest)
288 for (const child of await subdirectories(walk, directory, segment)) {
289 matches.push(...(await expand(walk, child, segments)))
290 }
291 return matches
292 }
293 if (!hasWildcard(segment)) return expand(walk, joined(directory, segment), rest)
294 const matches: GlobMatch[] = []
295 for (const child of await subdirectories(walk, directory, segment)) {
296 matches.push(...(await expand(walk, child, rest)))
297 }
298 return matches
299}
300
301export async function matchGlobs(
302 files: TrackerFiles,
303 globs: readonly string[],
304): Promise<GlobMatch[]> {
305 const rootReal = await files.realPath('.')
306 if (rootReal === undefined) throw new FileProblem('the repo root could not be read.')
307 const byPath = new Map<string, GlobMatch>()
308 for (const glob of globs) {
309 if (ABSOLUTE_PATH.test(glob)) {
310 throw new FileProblem(
311 `the glob ${glob} is not relative to the repo root, so it could not be read.`,
312 )
313 }
314 if (UNSUPPORTED_GLOB_CHARACTERS.test(glob)) {
315 throw new FileProblem(
316 `the glob ${glob} uses a wildcard other than * and ?, so it could not be read.`,
317 )
318 }
319 const segments = glob.split('/').filter((segment) => segment !== '' && segment !== '.')
320 for (const match of await expand({ files, rootReal, glob }, '', segments)) {
321 byPath.set(match.path, match)
322 }
323 }
324 return [...byPath.values()].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
325}
326
327export function signatureOfMatches(matches: readonly GlobMatch[]): string {
328 return matches
329 .map((match) => [match.path, String(match.size), String(match.mtimeMs)].join(' '))
330 .join('\n')
331}
332hooks/readers/adapter.ts 334 lines1import type { WorkitemsFailedReason, WorkitemsItem, WorkitemsStatus } from '../../types'
2import { programOutsideRoot, quotedCommand } from '../approval'
3import {
4 argumentProblem,
5 CONFIG_FILE,
6 FileProblem,
7 matchGlobs,
8 signatureOfMatches,
9 USER_ADAPTERS_FILE,
10 USER_ADAPTERS_SHOWN,
11} from '../config'
12import { numeral } from '../states'
13import {
14 checkedItem,
15 ItemFault,
16 normalStatusOf,
17 optionalLabels,
18 optionalPriority,
19 optionalText,
20 requiredText,
21 uniqueByKey,
22 type Located,
23} from './generic'
24import type { ReadOutcome, Reader, TrackerFiles } from './index'
25
26const SOURCE = 'adapter'
27const MAX_DESCRIBED_ENTRIES = 100
28const UNSAFE_TEXT = 'with a control character or over 256 characters'
29const CONTRACT = 1
30const MAPPED_STATUSES: readonly WorkitemsStatus[] = [
31 'open',
32 'in_progress',
33 'blocked',
34 'deferred',
35 'closed',
36 'other',
37]
38
39type Description = {
40 name: string
41 watch: readonly string[]
42 writes: readonly string[]
43 statusMap: ReadonlyMap<string, WorkitemsStatus>
44}
45
46function isRecord(value: unknown): value is Record<string, unknown> {
47 return typeof value === 'object' && value !== null && !Array.isArray(value)
48}
49
50function isTextList(value: unknown): value is string[] {
51 return Array.isArray(value) && value.every((entry) => typeof entry === 'string' && entry !== '')
52}
53
54export type CommandFaultKind = 'not-run' | 'cut' | 'exit' | 'not-json'
55
56export class CommandFault extends ItemFault {
57 constructor(
58 reason: WorkitemsFailedReason,
59 readonly kind: CommandFaultKind,
60 ) {
61 super(reason)
62 }
63}
64
65export async function runJson(
66 files: TrackerFiles,
67 label: string,
68 argv: readonly string[],
69 env?: Readonly<Record<string, string>>,
70): Promise<unknown> {
71 let result
72 try {
73 result = await files.commands.run(argv, env)
74 } catch {
75 throw new CommandFault(
76 `${label} did not start or did not end in time, so it could not be read.`,
77 'not-run',
78 )
79 }
80 if (result.isStdoutTruncated) throw new CommandFault(`${label} output was cut off.`, 'cut')
81 if (result.exitCode !== 0) {
82 throw new CommandFault(
83 `${label} exited ${numeral(result.exitCode)}. Run it in a shell to see why.`,
84 'exit',
85 )
86 }
87 try {
88 return JSON.parse(result.stdout) as unknown
89 } catch {
90 throw new CommandFault(`${label} printed no valid JSON, so it could not be read.`, 'not-json')
91 }
92}
93
94function recordLocated(record: Record<string, unknown>, where: string): Located {
95 return {
96 where,
97 value: (field) => (Object.hasOwn(record, field) ? record[field] : undefined),
98 invalid: (field) => `${where} has an invalid ${field}, so it could not be read.`,
99 missing: (field) => `${where} has no ${field}, so it could not be read.`,
100 }
101}
102
103function refuseUnsafe(label: string, what: string, values: readonly string[]): void {
104 if (values.length > MAX_DESCRIBED_ENTRIES) {
105 throw new ItemFault(
106 `${label} gives more than ${String(MAX_DESCRIBED_ENTRIES)} ${what}s, so it could not be read.`,
107 )
108 }
109 if (values.some((value) => argumentProblem(value) !== null)) {
110 throw new ItemFault(`${label} gives a ${what} ${UNSAFE_TEXT}, so it could not be read.`)
111 }
112}
113
114function statusMapOf(label: string, value: unknown): Map<string, WorkitemsStatus> {
115 const map = new Map<string, WorkitemsStatus>()
116 if (value === undefined) return map
117 if (!isRecord(value))
118 throw new ItemFault(`${label} has an invalid statusMap, so it could not be read.`)
119 refuseUnsafe(label, 'status', Object.keys(value))
120 for (const [raw, normal] of Object.entries(value)) {
121 const status = MAPPED_STATUSES.find((known) => known === normal)
122 if (!status) {
123 throw new ItemFault(
124 `${label} maps a status to a value that is not one of ${MAPPED_STATUSES.join(', ')}, so it could not be read.`,
125 )
126 }
127 map.set(raw, status)
128 }
129 return map
130}
131
132function writesOf(label: string, value: unknown): string[] {
133 if (value === undefined) return []
134 if (
135 !Array.isArray(value) ||
136 !value.every(isTextList) ||
137 value.some((write) => write.length === 0)
138 ) {
139 throw new ItemFault(`${label} has invalid writes, so it could not be read.`)
140 }
141 const writes = value.map((write) => write.join(' '))
142 refuseUnsafe(label, 'write', writes)
143 return writes
144}
145
146function watchOf(label: string, value: unknown): string[] {
147 if (value === undefined) return []
148 if (!isTextList(value))
149 throw new ItemFault(`${label} has an invalid watch, so it could not be read.`)
150 refuseUnsafe(label, 'watch glob', value)
151 return value
152}
153
154function descriptionOf(label: string, parsed: unknown): Description {
155 if (!isRecord(parsed))
156 throw new ItemFault(`${label} printed no JSON object, so it could not be read.`)
157 const contract = parsed.contract
158 if (contract === undefined) {
159 throw new ItemFault(`${label} names no contract, so it could not be read.`)
160 }
161 if (typeof contract !== 'number' || !Number.isFinite(contract)) {
162 throw new ItemFault(
163 `${label} says contract ${JSON.stringify(contract)}, which is not the number 1, so it could not be read.`,
164 )
165 }
166 if (contract !== CONTRACT) {
167 throw new ItemFault(`the adapter says contract ${numeral(contract)}; handily reads contract 1.`)
168 }
169 const name = requiredText(recordLocated(parsed, label), 'name')
170 if (argumentProblem(name) !== null) {
171 throw new ItemFault(`${label} gives a name ${UNSAFE_TEXT}, so it could not be read.`)
172 }
173 return {
174 name,
175 watch: watchOf(label, parsed.watch),
176 writes: writesOf(label, parsed.writes),
177 statusMap: statusMapOf(label, parsed.statusMap),
178 }
179}
180
181function itemOf(
182 record: Record<string, unknown>,
183 where: string,
184 description: Description,
185): WorkitemsItem {
186 const located = recordLocated(record, where)
187 const id = requiredText(located, 'id')
188 const rawStatus = requiredText(located, 'status')
189 const item: WorkitemsItem = {
190 key: `${description.name}:${id}`,
191 id,
192 title: requiredText(located, 'title'),
193 status: description.statusMap.get(rawStatus) ?? normalStatusOf(rawStatus),
194 rawStatus,
195 priority: optionalPriority(located, 'priority'),
196 type: optionalText(located, 'type'),
197 assignee: optionalText(located, 'assignee'),
198 updatedAt: optionalText(located, 'updatedAt'),
199 source: description.name,
200 }
201 for (const key of ['url', 'parent'] as const) {
202 const value = optionalText(located, key)
203 if (value !== null) item[key] = value
204 }
205 const labels = optionalLabels(located, 'labels')
206 if (labels) item.labels = labels
207 return checkedItem(item, where)
208}
209
210function itemsOf(label: string, parsed: unknown, description: Description): WorkitemsItem[] {
211 if (!Array.isArray(parsed)) {
212 throw new ItemFault(`${label} printed no list of items, so it could not be read.`)
213 }
214 const found = parsed.map((record, index) => {
215 const where = `${label} item ${numeral(index + 1)}`
216 if (!isRecord(record))
217 throw new ItemFault(`${where} is not an object, so it could not be read.`)
218 return { item: itemOf(record, where, description), path: label }
219 })
220 return uniqueByKey(found)
221}
222
223type UserCommand =
224 | { kind: 'none' }
225 | { kind: 'found'; argv: readonly string[] }
226 | { kind: 'fault'; reason: WorkitemsFailedReason }
227
228function userFault(detail: string): UserCommand {
229 return { kind: 'fault', reason: `${USER_ADAPTERS_SHOWN} ${detail}, so it could not be read.` }
230}
231
232async function readUserCommand(files: TrackerFiles): Promise<UserCommand> {
233 let text: string | undefined
234 try {
235 text = await files.readUserFile(USER_ADAPTERS_FILE)
236 } catch (error) {
237 if (error instanceof FileProblem) return { kind: 'fault', reason: error.reason }
238 throw error
239 }
240 if (text === undefined) return { kind: 'none' }
241 const rootReal = await files.realPath('.')
242 if (rootReal === undefined) return { kind: 'fault', reason: 'the repo root could not be read.' }
243 let parsed: unknown
244 try {
245 parsed = JSON.parse(text) as unknown
246 } catch {
247 return userFault('is not valid JSON')
248 }
249 if (!isRecord(parsed)) return userFault('is not a JSON object')
250 if (!Object.hasOwn(parsed, rootReal)) return { kind: 'none' }
251 const entry = parsed[rootReal]
252 if (!isTextList(entry) || entry.length === 0)
253 return userFault('has an invalid entry for this repo')
254 return { kind: 'found', argv: entry }
255}
256
257const userCommands = new WeakMap<TrackerFiles, Promise<UserCommand>>()
258
259function userCommandOf(files: TrackerFiles): Promise<UserCommand> {
260 let lookup = userCommands.get(files)
261 if (!lookup) {
262 lookup = readUserCommand(files)
263 userCommands.set(files, lookup)
264 }
265 return lookup
266}
267
268async function hasUserCommand(files: TrackerFiles): Promise<boolean> {
269 return (await userCommandOf(files)).kind !== 'none'
270}
271
272async function missingEntry(files: TrackerFiles): Promise<WorkitemsFailedReason> {
273 const rootReal = await files.realPath('.')
274 const isShown = rootReal !== undefined && argumentProblem(rootReal) === null
275 const shown = isShown ? ` (${JSON.stringify(rootReal)})` : ''
276 return `${USER_ADAPTERS_SHOWN} has no entry for this repo${shown}, so it could not be read.`
277}
278
279export function createAdapterReader(): Reader {
280 let described: { argv: string; description: Description } | undefined
281
282 async function readAdapter(files: TrackerFiles): Promise<ReadOutcome> {
283 const lookup = await userCommandOf(files)
284 if (lookup.kind === 'fault') return { ok: false, reason: lookup.reason }
285 if (lookup.kind === 'none') return { ok: false, reason: await missingEntry(files) }
286 if (!(await files.commands.canRun())) {
287 return { ok: false, state: 'terminal-only', sourceLabel: SOURCE }
288 }
289 const { argv } = lookup
290 const argv0 = await programOutsideRoot(files, argv)
291 const label = quotedCommand(argv)
292 try {
293 described = undefined
294 const describeLabel = `${label} describe --json`
295 const describeArgv = [argv0, ...argv.slice(1), 'describe', '--json']
296 const description = descriptionOf(
297 describeLabel,
298 await runJson(files, describeLabel, describeArgv),
299 )
300 described = { argv: JSON.stringify(argv), description }
301 const itemsLabel = `${label} items --json`
302 const parsed = await runJson(files, itemsLabel, [argv0, ...argv.slice(1), 'items', '--json'])
303 return {
304 ok: true,
305 items: itemsOf(itemsLabel, parsed, description),
306 sourceLabel: description.name,
307 caveat: null,
308 adapterWrites: { command: argv.join(' '), verbs: description.writes },
309 }
310 } catch (error) {
311 if (error instanceof ItemFault) return { ok: false, reason: error.reason }
312 throw error
313 }
314 }
315
316 async function adapterSignature(files: TrackerFiles): Promise<string> {
317 const lookup = await userCommandOf(files)
318 const parts = [JSON.stringify(lookup)]
319 if (lookup.kind === 'found' && described?.argv === JSON.stringify(lookup.argv)) {
320 parts.push(signatureOfMatches(await matchGlobs(files, described.description.watch)))
321 }
322 return parts.join('\n')
323 }
324
325 return {
326 name: SOURCE,
327 marker: CONFIG_FILE,
328 lookedForAs: USER_ADAPTERS_SHOWN,
329 isPresent: hasUserCommand,
330 signature: adapterSignature,
331 read: readAdapter,
332 }
333}
334hooks/readers/basicly.ts 192 lines1import type { WorkitemsItem } from '../../types'
2import { numeral } from '../states'
3import { coverStamp, quotedCommand, type ApprovalKey, type CoveredFolder } from '../approval'
4import { runJson } from './adapter'
5import {
6 checkedItem,
7 ItemFault,
8 normalStatusOf,
9 optionalPriority,
10 optionalText,
11 requiredText,
12 uniqueByKey,
13 type Located,
14} from './generic'
15import type { ReadOutcome, Reader, TrackerFiles } from './index'
16
17const SOURCE = 'basicly'
18const LEDGER = '.basicly/ledger'
19const TEMPLATE = `${LEDGER}/template.json`
20const KIT_FOLDER = '.basicly/core/kit/tracker'
21const KIT_CODE: readonly CoveredFolder[] = [{ path: KIT_FOLDER, suffix: '', recursive: true }]
22const PROGRAM = 'basicly'
23const LABEL = 'basicly tracker items'
24const COMMAND = [PROGRAM, 'tracker', 'items'] as const
25const OPEN_STATUSES = ['open', 'in_progress', 'blocked'] as const
26const ITEMS_FLAGS = ['--json', ...OPEN_STATUSES.flatMap((status) => ['--status', status])]
27const VERSION_LABEL = 'basicly --version'
28const VERSION_LINE = /^basicly (\d+)\.(\d+)\.(\d+)$/
29const RUNS_ONLY_PACKAGE_SINCE = [0, 21, 1] as const
30const KIT_NOTE = `(basicly 0.21.1 or later runs only the installed package; asked again if the command changes. An older basicly also runs the repo code in ${KIT_FOLDER}; asked again if a file there changes)`
31
32function isRecord(value: unknown): value is Record<string, unknown> {
33 return typeof value === 'object' && value !== null && !Array.isArray(value)
34}
35
36function recordLocated(record: Record<string, unknown>, where: string): Located {
37 return {
38 where,
39 value: (field) => (Object.hasOwn(record, field) ? record[field] : undefined),
40 invalid: (field) => `${where} has an invalid ${field}, so it could not be read.`,
41 missing: (field) => `${where} has no ${field}, so it could not be read.`,
42 }
43}
44
45function itemOf(record: Record<string, unknown>, where: string): WorkitemsItem {
46 const located = recordLocated(record, where)
47 const id = requiredText(located, 'id')
48 const rawStatus = requiredText(located, 'rawStatus')
49 const item: WorkitemsItem = {
50 key: `${SOURCE}:${id}`,
51 id,
52 title: requiredText(located, 'title'),
53 status: normalStatusOf(rawStatus),
54 rawStatus,
55 priority: optionalPriority(located, 'priority'),
56 type: optionalText(located, 'type'),
57 assignee: optionalText(located, 'assignee'),
58 updatedAt: optionalText(located, 'updatedAt'),
59 source: SOURCE,
60 }
61 return checkedItem(item, where)
62}
63
64function itemsOf(label: string, parsed: unknown): WorkitemsItem[] {
65 if (!Array.isArray(parsed)) {
66 throw new ItemFault(`${label} printed no list of items, so it could not be read.`)
67 }
68 const found = parsed.map((record: unknown, index) => {
69 const where = `${label} record ${numeral(index + 1)}`
70 if (!isRecord(record))
71 throw new ItemFault(`${where} is not an object, so it could not be read.`)
72 return { item: itemOf(record, where), path: label }
73 })
74 return uniqueByKey(found)
75}
76
77function freshCacheFolder(root: string): string {
78 return `${root.replace(/[\\/]+$/, '')}/.handily-pycache-${crypto.randomUUID()}`
79}
80
81function pythonEnv(root: string): Record<string, string> {
82 return {
83 PYTHONDONTWRITEBYTECODE: '1',
84 PYTHONPYCACHEPREFIX: freshCacheFolder(root),
85 }
86}
87
88function runsOnlyPackage(versionOutput: string): boolean {
89 const match = VERSION_LINE.exec(versionOutput.trim())
90 if (match === null) return false
91 const version = match.slice(1).map(Number)
92 for (const [index, floor] of RUNS_ONLY_PACKAGE_SINCE.entries()) {
93 const part = version[index] ?? 0
94 if (part !== floor) return part > floor
95 }
96 return true
97}
98
99async function versionIgnoresKit(files: TrackerFiles, argv0: string): Promise<boolean> {
100 let result
101 try {
102 result = await files.commands.run([argv0, '--version'], pythonEnv(files.root))
103 } catch {
104 throw new ItemFault(
105 `${VERSION_LABEL} did not start or did not end in time, so it could not be read.`,
106 )
107 }
108 return result.exitCode === 0 && !result.isStdoutTruncated && runsOnlyPackage(result.stdout)
109}
110
111type VersionMemory = { kept?: { key: string; ignoresKit: boolean } }
112
113async function ignoresKit(
114 files: TrackerFiles,
115 approval: ApprovalKey,
116 memory: VersionMemory,
117): Promise<boolean> {
118 const program = await files.commands.stamp(approval.argv0)
119 const key = program === undefined ? undefined : JSON.stringify({ ...approval, program })
120 if (key !== undefined && memory.kept?.key === key) return memory.kept.ignoresKit
121 const verdict = await versionIgnoresKit(files, approval.argv0)
122 if (key !== undefined) memory.kept = { key, ignoresKit: verdict }
123 return verdict
124}
125
126function approvalNeeded(): ReadOutcome {
127 return {
128 ok: false,
129 state: 'approval-needed',
130 command: quotedCommand(COMMAND),
131 sourceLabel: SOURCE,
132 }
133}
134
135async function approvedProgram(
136 files: TrackerFiles,
137 memory: VersionMemory,
138): Promise<string | undefined> {
139 const verdict = await files.commands.approvals.check(files, {
140 command: COMMAND,
141 shown: [...COMMAND, ...ITEMS_FLAGS],
142 folders: KIT_CODE,
143 note: KIT_NOTE,
144 ignoresFolders: (approval) => ignoresKit(files, approval, memory),
145 })
146 return verdict.approved ? verdict.argv0 : undefined
147}
148
149async function listOpenItems(files: TrackerFiles, memory: VersionMemory): Promise<ReadOutcome> {
150 const argv0 = await approvedProgram(files, memory)
151 if (argv0 === undefined) return approvalNeeded()
152 const argv = [argv0, ...COMMAND.slice(1), ...ITEMS_FLAGS]
153 const items = itemsOf(LABEL, await runJson(files, LABEL, argv, pythonEnv(files.root)))
154 return { ok: true, items, sourceLabel: SOURCE, caveat: null, listsOpenOnly: true }
155}
156
157async function readBasicly(files: TrackerFiles, memory: VersionMemory): Promise<ReadOutcome> {
158 if (!(await files.commands.canRun())) {
159 return { ok: false, state: 'terminal-only', sourceLabel: SOURCE }
160 }
161 if ((await files.commands.which(PROGRAM)) === undefined) {
162 return { ok: false, reason: 'basicly is not on PATH. Install it to read this tracker.' }
163 }
164 try {
165 return await listOpenItems(files, memory)
166 } catch (error) {
167 if (error instanceof ItemFault) return { ok: false, reason: error.reason }
168 throw error
169 }
170}
171
172async function basiclySignature(files: TrackerFiles): Promise<string> {
173 const ledger = (await files.list(LEDGER))
174 .map((entry) => `${entry.name} ${String(entry.size)} ${String(entry.mtimeMs)}`)
175 .sort()
176 return [
177 ...ledger,
178 await coverStamp(files, KIT_CODE),
179 String(files.commands.approvals.generation()),
180 ].join('\n')
181}
182
183export function createBasiclyReader(): Reader {
184 const memory: VersionMemory = {}
185 return {
186 name: SOURCE,
187 marker: TEMPLATE,
188 signature: basiclySignature,
189 read: (files) => readBasicly(files, memory),
190 }
191}
192hooks/readers/beads.ts 285 lines1import type { WorkitemsFailedReason, WorkitemsItem, WorkitemsStatus } from '../../types'
2import { quotedCommand } from '../approval'
3import { FileProblem, FileTooLarge } from '../config'
4import { MAX_FILE_BYTES } from '../snapshot'
5import { numeral } from '../states'
6import { CommandFault, runJson } from './adapter'
7import { checkedItem, ItemFault } from './generic'
8import type { ReadOutcome, Reader, TrackerFiles } from './index'
9
10const ISSUES_FILE = '.beads/issues.jsonl'
11const METADATA_FILE = '.beads/metadata.json'
12const SOURCE = 'beads'
13const BR_SOURCE_LABEL = 'beads (br)'
14const BR = 'br'
15const LIST_COMMAND = [BR, 'list', '--json', '--limit', '0'] as const
16const LIST_LABEL = 'br list'
17const BR_NOTE =
18 '(checked on br 0.3.2: br list writes the .beads cache beads.db and beads.base.jsonl, imports an edited issues.jsonl into beads.db and does not rewrite issues.jsonl. It started no git, sh, bash, python3, node, env, editor or vi from PATH. A program that it starts by an absolute path was not ruled out. Asked again if the command or the real path of br changes. A new br at the same path is not asked again)'
19const SEE_WHY =
20 'Run "br list --json --limit 0" at the repo root to see why the list could not be read.'
21const TOO_MANY_OPEN = 'Close some open items, because their list is over 4 MiB.'
22const BR_MISSING = `br is not on PATH. Install br to list the open items of ${ISSUES_FILE}, which is over 4 MiB.`
23const DOLT_TOO_LARGE = `br cannot list a bd tracker on Dolt. Make ${ISSUES_FILE} 4 MiB or less to read it, because the engine reads no file that is over 4 MiB.`
24const KNOWN_STATUSES: readonly WorkitemsStatus[] = [
25 'open',
26 'in_progress',
27 'blocked',
28 'deferred',
29 'closed',
30]
31const TOMBSTONE = 'tombstone'
32const POSSIBLY_STALE = 'possibly stale'
33
34type Line = Record<string, unknown>
35
36class MalformedLine extends Error {}
37
38function isRecord(value: unknown): value is Line {
39 return typeof value === 'object' && value !== null && !Array.isArray(value)
40}
41
42function requiredText(line: Line, field: string): string {
43 const value = line[field]
44 if (typeof value !== 'string' || value === '') throw new MalformedLine(field)
45 return value
46}
47
48function optionalText(line: Line, field: string): string | null {
49 const value = line[field]
50 if (value === undefined || value === null || value === '') return null
51 if (typeof value !== 'string') throw new MalformedLine(field)
52 return value
53}
54
55function priorityOf(line: Line): number | null {
56 const value = line.priority
57 if (value === undefined || value === null) return null
58 const isBeadsPriority = Number.isInteger(value) && Number(value) >= 0 && Number(value) <= 4
59 if (!isBeadsPriority) throw new MalformedLine('priority')
60 return Number(value)
61}
62
63function labelsOf(line: Line): string[] | undefined {
64 const value = line.labels
65 if (value === undefined || value === null) return undefined
66 if (!Array.isArray(value) || !value.every((label) => typeof label === 'string')) {
67 throw new MalformedLine('labels')
68 }
69 return value
70}
71
72function parentOf(line: Line): string | undefined {
73 const dependencies = line.dependencies
74 if (!Array.isArray(dependencies)) return undefined
75 for (const dependency of dependencies) {
76 if (isRecord(dependency) && dependency.type === 'parent-child') {
77 const parent = dependency.depends_on_id
78 if (typeof parent === 'string' && parent !== '') return parent
79 }
80 }
81 return undefined
82}
83
84function statusOf(rawStatus: string): WorkitemsStatus {
85 return KNOWN_STATUSES.find((status) => status === rawStatus) ?? 'other'
86}
87
88function itemOf(line: Line): WorkitemsItem {
89 const id = requiredText(line, 'id')
90 const rawStatus = requiredText(line, 'status')
91 const item: WorkitemsItem = {
92 key: `${SOURCE}:${id}`,
93 id,
94 title: requiredText(line, 'title'),
95 status: statusOf(rawStatus),
96 rawStatus,
97 priority: priorityOf(line),
98 type: optionalText(line, 'issue_type'),
99 assignee: optionalText(line, 'assignee'),
100 updatedAt: optionalText(line, 'updated_at'),
101 source: SOURCE,
102 }
103 const labels = labelsOf(line)
104 if (labels) item.labels = labels
105 const parent = parentOf(line)
106 if (parent) item.parent = parent
107 return item
108}
109
110function isSkipped(line: Line): boolean {
111 const isOtherRecordType = '_type' in line && line._type !== 'issue'
112 return isOtherRecordType || line.status === TOMBSTONE
113}
114
115function parseJson(text: string): unknown {
116 try {
117 return JSON.parse(text) as unknown
118 } catch {
119 return undefined
120 }
121}
122
123type MalformedFault = (field: string) => WorkitemsFailedReason
124
125function issueAt(
126 value: unknown,
127 where: string,
128 malformed: MalformedFault,
129): WorkitemsItem | undefined {
130 try {
131 if (!isRecord(value)) throw new MalformedLine('json')
132 if (isSkipped(value)) return undefined
133 return checkedItem(itemOf(value), where)
134 } catch (error) {
135 if (error instanceof MalformedLine) throw new ItemFault(malformed(error.message))
136 throw error
137 }
138}
139
140export function parseIssues(text: string): ReadOutcome {
141 const items: WorkitemsItem[] = []
142 const lines = text.split('\n')
143 try {
144 for (const [index, raw] of lines.entries()) {
145 const trimmed = raw.trim()
146 if (trimmed === '') continue
147 const where = `${ISSUES_FILE} line ${numeral(index + 1)}` as const
148 const item = issueAt(parseJson(trimmed), where, () => `${where} is malformed.`)
149 if (item) items.push(item)
150 }
151 } catch (error) {
152 if (error instanceof ItemFault) return { ok: false, reason: error.reason }
153 throw error
154 }
155 return { ok: true, items, sourceLabel: SOURCE, caveat: null }
156}
157
158function listedIssues(parsed: unknown): WorkitemsItem[] {
159 const issues = isRecord(parsed) ? parsed.issues : undefined
160 if (!isRecord(parsed) || !Array.isArray(issues)) {
161 throw new ItemFault(`${LIST_LABEL} printed no issues list, so it could not be read.`)
162 }
163 if (parsed.has_more !== false || typeof parsed.total !== 'number') {
164 throw new ItemFault(
165 `${LIST_LABEL} did not say that it printed every item, so it could not be read.`,
166 )
167 }
168 if (parsed.total !== issues.length) {
169 throw new ItemFault(
170 `${LIST_LABEL} printed ${numeral(issues.length)} of ${numeral(parsed.total)} items, so it could not be read.`,
171 )
172 }
173 const items: WorkitemsItem[] = []
174 for (const [index, issue] of issues.entries()) {
175 const where = `${LIST_LABEL} item ${numeral(index + 1)}`
176 if (!isRecord(issue)) throw new ItemFault(`${where} is not an object, so it could not be read.`)
177 const item = issueAt(
178 issue,
179 where,
180 (field) => `${where} has an invalid ${field}, so it could not be read.`,
181 )
182 if (item) items.push(item)
183 }
184 return items
185}
186
187function approvalNeeded(): ReadOutcome {
188 return {
189 ok: false,
190 state: 'approval-needed',
191 command: quotedCommand(LIST_COMMAND),
192 sourceLabel: BR_SOURCE_LABEL,
193 }
194}
195
196async function approvedBr(files: TrackerFiles): Promise<string | undefined> {
197 const verdict = await files.commands.approvals.check(files, {
198 command: LIST_COMMAND,
199 shown: LIST_COMMAND,
200 folders: [],
201 note: BR_NOTE,
202 ignoresFolders: () => Promise.resolve(false),
203 })
204 return verdict.approved ? verdict.argv0 : undefined
205}
206
207function reasonWithFix(fault: ItemFault): WorkitemsFailedReason {
208 if (fault instanceof CommandFault && fault.kind === 'exit') return fault.reason
209 if (fault instanceof CommandFault && fault.kind === 'cut') {
210 return `${fault.reason} ${TOO_MANY_OPEN}`
211 }
212 return `${fault.reason} ${SEE_WHY}`
213}
214
215async function listOpenThroughBr(files: TrackerFiles): Promise<ReadOutcome> {
216 if (!(await files.commands.canRun())) {
217 return { ok: false, state: 'terminal-only', sourceLabel: BR_SOURCE_LABEL }
218 }
219 if ((await files.commands.which(BR)) === undefined) return { ok: false, reason: BR_MISSING }
220 const argv0 = await approvedBr(files)
221 if (argv0 === undefined) return approvalNeeded()
222 try {
223 const parsed = await runJson(files, LIST_LABEL, [argv0, ...LIST_COMMAND.slice(1)])
224 return {
225 ok: true,
226 items: listedIssues(parsed),
227 sourceLabel: BR_SOURCE_LABEL,
228 caveat: null,
229 listsOpenOnly: true,
230 omitsParent: true,
231 }
232 } catch (error) {
233 if (error instanceof ItemFault) return { ok: false, reason: reasonWithFix(error) }
234 throw error
235 }
236}
237
238async function isOnDolt(files: TrackerFiles): Promise<boolean> {
239 if (!(await files.exists(METADATA_FILE))) return false
240 const metadata = parseJson(await files.read(METADATA_FILE))
241 if (!isRecord(metadata)) throw new FileProblem(`${METADATA_FILE} could not be read.`)
242 return metadata.backend === 'dolt'
243}
244
245async function readLargeBeads(files: TrackerFiles): Promise<ReadOutcome> {
246 if (await isOnDolt(files)) return { ok: false, reason: DOLT_TOO_LARGE }
247 return listOpenThroughBr(files)
248}
249
250async function readBeads(files: TrackerFiles): Promise<ReadOutcome> {
251 let text: string
252 try {
253 text = await files.read(ISSUES_FILE)
254 } catch (error) {
255 if (error instanceof FileTooLarge) return readLargeBeads(files)
256 throw error
257 }
258 const outcome = parseIssues(text)
259 if (!outcome.ok || !(await isOnDolt(files))) return outcome
260 return {
261 ok: true,
262 items: outcome.items.map((item) => ({
263 ...item,
264 labels: [...(item.labels ?? []), POSSIBLY_STALE],
265 })),
266 sourceLabel: 'beads (bd)',
267 caveat: 'bd keeps data in Dolt',
268 }
269}
270
271async function beadsSignature(files: TrackerFiles): Promise<string> {
272 const { size, mtimeMs } = await files.stat(ISSUES_FILE)
273 const stamp = [String(mtimeMs), String(size)]
274 if (size <= MAX_FILE_BYTES) return stamp.join('\n')
275 const br = (await files.commands.which(BR)) ?? 'br not on PATH'
276 return [...stamp, br, String(files.commands.approvals.generation())].join('\n')
277}
278
279export const beadsReader: Reader = {
280 name: SOURCE,
281 marker: ISSUES_FILE,
282 signature: beadsSignature,
283 read: readBeads,
284}
285hooks/readers/beans.ts 152 lines1import type { WorkitemsItem, WorkitemsStatus } from '../../types'
2import { matchGlobs, signatureOfMatches, type GlobMatch } from '../config'
3import {
4 checkedItem,
5 frontMatterLocated,
6 ItemFault,
7 optionalLabels,
8 optionalText,
9 parseFrontMatter,
10 readItemFiles,
11 requiredText,
12 skippedCaveat,
13 uniqueByKey,
14 type Located,
15} from './generic'
16import type { ReadOutcome, Reader, TrackerFiles } from './index'
17
18const SOURCE = 'beans'
19const DEFAULT_PATH = '.beans'
20const BEANS_CONFIG = '.beans.yml'
21const ARCHIVE_FOLDER = 'archive'
22const BEAN_EXTENSION = '.md'
23const SLUG_SEPARATOR = '--'
24const STATUSES: Readonly<Record<string, WorkitemsStatus>> = {
25 todo: 'open',
26 'in-progress': 'in_progress',
27 draft: 'other',
28 completed: 'closed',
29 scrapped: 'closed',
30}
31const PRIORITIES: Readonly<Record<string, number>> = {
32 critical: 0,
33 high: 1,
34 normal: 2,
35 low: 3,
36 deferred: 4,
37}
38const TOP_LEVEL_LINE = /^\S/
39const BEANS_SECTION_LINE = /^beans:\s*(#.*)?$/
40const PATH_LINE = /^\s+path:\s*(.*)$/
41
42function unquoted(value: string): string {
43 const text = value.replace(/\s+#.*$/, '').trim()
44 const isQuoted = /^(["']).*\1$/.test(text)
45 return isQuoted ? text.slice(1, -1) : text
46}
47
48export function beansPathOf(configText: string): string {
49 let inBeansSection = false
50 for (const line of configText.split(/\r?\n/)) {
51 if (TOP_LEVEL_LINE.test(line)) inBeansSection = BEANS_SECTION_LINE.test(line)
52 const path = inBeansSection ? PATH_LINE.exec(line) : null
53 const value = path ? unquoted(path[1] ?? '') : ''
54 if (value !== '') return folderSegments(value).join('/')
55 }
56 return DEFAULT_PATH
57}
58
59function folderSegments(path: string): string[] {
60 return path.split('/').filter((segment) => segment !== '' && segment !== '.')
61}
62
63async function beansPath(files: TrackerFiles): Promise<string> {
64 if (!(await files.exists(BEANS_CONFIG))) return DEFAULT_PATH
65 return beansPathOf(await files.read(BEANS_CONFIG))
66}
67
68export function beanIdOf(fileName: string): string {
69 const name = fileName.slice(0, -BEAN_EXTENSION.length)
70 const slugStart = name.indexOf(SLUG_SEPARATOR)
71 return slugStart > 0 ? name.slice(0, slugStart) : name
72}
73
74function priorityOf(located: Located): number | null {
75 const word = optionalText(located, 'priority')
76 if (word === null) return null
77 const priority = PRIORITIES[word]
78 if (priority === undefined) throw new ItemFault(located.invalid('priority'))
79 return priority
80}
81
82function itemOf(located: Located, fileName: string, isArchived: boolean): WorkitemsItem {
83 const id = beanIdOf(fileName)
84 const rawStatus = requiredText(located, 'status')
85 const item: WorkitemsItem = {
86 key: `${SOURCE}:${id}`,
87 id,
88 title: requiredText(located, 'title'),
89 status: isArchived ? 'closed' : (STATUSES[rawStatus] ?? 'other'),
90 rawStatus,
91 priority: priorityOf(located),
92 type: optionalText(located, 'type'),
93 assignee: null,
94 updatedAt: optionalText(located, 'updated_at'),
95 source: SOURCE,
96 }
97 const labels = optionalLabels(located, 'tags')
98 if (labels) item.labels = labels
99 const parent = optionalText(located, 'parent')
100 if (parent !== null) item.parent = parent
101 return checkedItem(item, located.where)
102}
103
104async function beanFiles(files: TrackerFiles): Promise<{ root: string; matches: GlobMatch[] }> {
105 const root = await beansPath(files)
106 const glob = [root, '**', `*${BEAN_EXTENSION}`].filter((part) => part !== '').join('/')
107 return { root, matches: await matchGlobs(files, [glob]) }
108}
109
110function beanOf(root: string, path: string, text: string): WorkitemsItem {
111 const relative = root === '' ? path : path.slice(root.length + 1)
112 const fileName = relative.slice(relative.lastIndexOf('/') + 1)
113 const isArchived = relative.startsWith(`${ARCHIVE_FOLDER}/`)
114 return itemOf(frontMatterLocated(path, parseFrontMatter(path, text)), fileName, isArchived)
115}
116
117async function readBeans(files: TrackerFiles): Promise<ReadOutcome> {
118 try {
119 const { root, matches } = await beanFiles(files)
120 const paths = matches.map((match) => match.path)
121 const { found, skipped } = await readItemFiles(files, paths, (path, text) =>
122 beanOf(root, path, text),
123 )
124 return {
125 ok: true,
126 items: uniqueByKey(found),
127 sourceLabel: SOURCE,
128 caveat: skippedCaveat(skipped),
129 }
130 } catch (error) {
131 if (error instanceof ItemFault) return { ok: false, reason: error.reason }
132 throw error
133 }
134}
135
136async function isPresent(files: TrackerFiles): Promise<boolean> {
137 return (await files.exists(BEANS_CONFIG)) || files.exists(DEFAULT_PATH)
138}
139
140async function beansSignature(files: TrackerFiles): Promise<string> {
141 const { root, matches } = await beanFiles(files)
142 return [root, signatureOfMatches(matches)].join('\n')
143}
144
145export const beansReader: Reader = {
146 name: SOURCE,
147 marker: DEFAULT_PATH,
148 isPresent,
149 signature: beansSignature,
150 read: readBeans,
151}
152hooks/readers/generic.ts 454 lines1import type { WorkitemsFailedReason, WorkitemsItem, WorkitemsStatus } from '../../types'
2import {
3 CONFIG_FILE,
4 FileProblem,
5 isUnsafeCharacter,
6 matchGlobs,
7 readConfig,
8 signatureOfMatches,
9 type FieldMap,
10 type FilesConfig,
11 type MappedField,
12} from '../config'
13import { numeral } from '../states'
14import type { ReadOutcome, Reader, TrackerFiles } from './index'
15
16const SOURCE = 'files'
17const NORMAL_STATUSES: readonly WorkitemsStatus[] = [
18 'open',
19 'in_progress',
20 'blocked',
21 'deferred',
22 'closed',
23]
24const UNREADABLE = Symbol('unreadable')
25const FRONT_MATTER_FENCE = '---'
26const WHOLE_NUMBER = /^\d+$/
27const KEY_LINE = /^([A-Za-z_][\w-]*)\s*:(.*)$/
28const LIST_ITEM_LINE = /^\s*-(?:\s+(.*))?$/
29const BLOCK_SCALAR = /^([|>])([+-]?)\s*(#.*)?$/
30
31export class ItemFault extends Error {
32 constructor(readonly reason: WorkitemsFailedReason) {
33 super(reason)
34 }
35}
36
37export type Located = {
38 where: string
39 value: (field: string) => unknown
40 invalid: (field: string) => WorkitemsFailedReason
41 missing: (field: string) => WorkitemsFailedReason
42}
43
44function isRecord(value: unknown): value is Record<string, unknown> {
45 return typeof value === 'object' && value !== null && !Array.isArray(value)
46}
47
48function lineFault(path: string, line: number): WorkitemsFailedReason {
49 return `${path} line ${numeral(line)} is malformed.`
50}
51
52function missingFault(where: string, field: string): WorkitemsFailedReason {
53 return `${where} has no ${field}, so it could not be read.`
54}
55
56function isAbsent(value: unknown): boolean {
57 return value === undefined || value === null || value === ''
58}
59
60const TEXT_LIMITS = [
61 ['id', 'an id', 200],
62 ['title', 'a title', 500],
63 ['rawStatus', 'a status', 100],
64] as const
65
66function textProblem(text: string, name: string, limit: number): string | null {
67 let length = 0
68 for (const character of text) {
69 if (isUnsafeCharacter(character.codePointAt(0) ?? 0)) return `${name} with a control character`
70 length += 1
71 }
72 return length > limit ? `${name} over ${String(limit)} characters` : null
73}
74
75export function itemTextProblem(item: WorkitemsItem): string | null {
76 for (const [field, name, limit] of TEXT_LIMITS) {
77 const problem = textProblem(item[field], name, limit)
78 if (problem !== null) return problem
79 }
80 return null
81}
82
83export function checkedItem(item: WorkitemsItem, where: string): WorkitemsItem {
84 const problem = itemTextProblem(item)
85 if (problem !== null) throw new ItemFault(`${where} has ${problem}, so it could not be read.`)
86 return item
87}
88
89export function requiredText(located: Located, field: string): string {
90 const value = located.value(field)
91 if (isAbsent(value)) throw new ItemFault(located.missing(field))
92 if (typeof value === 'number' && Number.isFinite(value)) return String(value)
93 if (typeof value !== 'string') throw new ItemFault(located.invalid(field))
94 return value
95}
96
97export function optionalText(located: Located, field: string | undefined): string | null {
98 if (field === undefined) return null
99 const value = located.value(field)
100 if (isAbsent(value)) return null
101 if (typeof value !== 'string') throw new ItemFault(located.invalid(field))
102 return value
103}
104
105export function optionalLabels(
106 located: Located,
107 field: string | undefined,
108): readonly string[] | undefined {
109 if (field === undefined) return undefined
110 const value = located.value(field)
111 if (isAbsent(value)) return undefined
112 if (!Array.isArray(value) || !value.every((label) => typeof label === 'string')) {
113 throw new ItemFault(located.invalid(field))
114 }
115 return value
116}
117
118export function optionalPriority(located: Located, field: string | undefined): number | null {
119 if (field === undefined) return null
120 const value = located.value(field)
121 if (isAbsent(value)) return null
122 const priority = typeof value === 'string' && WHOLE_NUMBER.test(value) ? Number(value) : value
123 if (typeof priority !== 'number' || !Number.isInteger(priority) || priority < 0) {
124 throw new ItemFault(located.invalid(field))
125 }
126 return priority
127}
128
129export function normalStatusOf(rawStatus: string): WorkitemsStatus {
130 return NORMAL_STATUSES.find((status) => status === rawStatus) ?? 'other'
131}
132
133function closingQuoteOf(text: string): number {
134 const quote = text[0]
135 for (let index = 1; index < text.length; index += 1) {
136 const character = text[index]
137 if (quote === '"' && character === '\\') index += 1
138 else if (character === quote && quote === "'" && text[index + 1] === "'") index += 1
139 else if (character === quote) return index
140 }
141 return -1
142}
143
144function quotedScalarOf(text: string): string | typeof UNREADABLE {
145 const close = closingQuoteOf(text)
146 if (close < 0) return UNREADABLE
147 const after = text.slice(close + 1).trim()
148 if (after !== '' && !after.startsWith('#')) return UNREADABLE
149 const literal = text.slice(0, close + 1)
150 if (literal.startsWith("'")) return literal.slice(1, -1).replaceAll("''", "'")
151 try {
152 const parsed = JSON.parse(literal) as unknown
153 return typeof parsed === 'string' ? parsed : UNREADABLE
154 } catch {
155 return UNREADABLE
156 }
157}
158
159function scalarOf(raw: string): string | null | typeof UNREADABLE {
160 const text = raw.trim()
161 if (text.startsWith('"') || text.startsWith("'")) return quotedScalarOf(text)
162 const plain = text.replace(/(^|\s+)#.*$/, '')
163 if (plain === '' || plain === '~' || plain === 'null') return null
164 if (/^[[{&*!|>@`]/.test(plain)) return UNREADABLE
165 return plain
166}
167
168function flowListOf(rest: string): readonly string[] | typeof UNREADABLE {
169 const close = rest.lastIndexOf(']')
170 const after = rest.slice(close + 1).trim()
171 if (close < 0 || (after !== '' && !after.startsWith('#'))) return UNREADABLE
172 const inner = rest.slice(1, close).trim()
173 if (inner === '') return []
174 const items = inner.split(',').map(scalarOf)
175 if (items.some((item) => item === UNREADABLE)) return UNREADABLE
176 return items.map((item) => (typeof item === 'string' ? item : ''))
177}
178
179function blockScalarOf(style: string, chomping: string, body: readonly string[]): string {
180 const indent = Math.min(
181 ...body.filter((line) => line.trim() !== '').map((line) => /^\s*/.exec(line)?.[0].length ?? 0),
182 )
183 const lines = body.map((line) => line.slice(indent).replace(/\s+$/, ''))
184 while (lines.length > 0 && lines.at(-1) === '') lines.pop()
185 const text =
186 style === '|'
187 ? lines.join('\n')
188 : lines
189 .map((line) => (line === '' ? '\n' : line))
190 .join(' ')
191 .replace(/ ?\n ?/g, '\n')
192 return chomping === '-' ? text : `${text}\n`
193}
194
195function listOrNestedOf(body: readonly string[]): unknown {
196 const content = body.filter((line) => line.trim() !== '' && !line.trim().startsWith('#'))
197 if (content.length === 0) return undefined
198 const items = content.map((line) => LIST_ITEM_LINE.exec(line))
199 if (items.some((item) => item === null)) return UNREADABLE
200 const scalars = items.map((item) => scalarOf(item?.[1] ?? ''))
201 if (scalars.some((scalar) => scalar === UNREADABLE)) return UNREADABLE
202 return scalars.map((scalar) => (typeof scalar === 'string' ? scalar : ''))
203}
204
205type FrontMatter = { values: Map<string, unknown>; lines: Map<string, number> }
206
207function frontMatterLines(path: string, text: string): string[] {
208 const lines = text.split(/\r?\n/)
209 if (lines[0]?.trim() !== FRONT_MATTER_FENCE) throw new ItemFault(lineFault(path, 1))
210 const end = lines.findIndex((line, index) => index > 0 && line.trim() === FRONT_MATTER_FENCE)
211 if (end < 0) throw new ItemFault(lineFault(path, 1))
212 return lines.slice(1, end)
213}
214
215function isContinuation(line: string, takesListItems: boolean): boolean {
216 return line.trim() === '' || /^\s/.test(line) || (takesListItems && /^-(\s|$)/.test(line))
217}
218
219function valueOf(rest: string, body: readonly string[]): unknown {
220 const block = BLOCK_SCALAR.exec(rest)
221 if (block) return blockScalarOf(block[1] ?? '|', block[2] ?? '', body)
222 if (rest === '' || rest.startsWith('#')) return listOrNestedOf(body)
223 if (body.some((line) => line.trim() !== '')) return UNREADABLE
224 if (rest.startsWith('[')) return flowListOf(rest)
225 return scalarOf(rest)
226}
227
228export function parseFrontMatter(path: string, text: string): FrontMatter {
229 const values = new Map<string, unknown>()
230 const lines = new Map<string, number>()
231 const source = frontMatterLines(path, text)
232 let index = 0
233 while (index < source.length) {
234 const raw = source[index] ?? ''
235 const lineNumber = index + 2
236 index += 1
237 if (raw.trim() === '' || raw.startsWith('#')) continue
238 const keyLine = KEY_LINE.exec(raw)
239 if (!keyLine) throw new ItemFault(lineFault(path, lineNumber))
240 const key = keyLine[1] ?? ''
241 const rest = (keyLine[2] ?? '').trim()
242 const takesListItems = rest === '' || rest.startsWith('#')
243 const body: string[] = []
244 while (index < source.length && isContinuation(source[index] ?? '', takesListItems)) {
245 body.push(source[index] ?? '')
246 index += 1
247 }
248 lines.set(key, lineNumber)
249 values.set(key, valueOf(rest, body))
250 }
251 return { values, lines }
252}
253
254export function frontMatterLocated(path: string, frontMatter: FrontMatter): Located {
255 return {
256 where: path,
257 value: (field) => frontMatter.values.get(field),
258 invalid: (field) => lineFault(path, frontMatter.lines.get(field) ?? 1),
259 missing: (field) => missingFault(path, field),
260 }
261}
262
263function recordLocated(
264 record: Record<string, unknown>,
265 where: string,
266 invalid: Located['invalid'],
267): Located {
268 return {
269 where,
270 value: (field) => (Object.hasOwn(record, field) ? record[field] : undefined),
271 invalid,
272 missing: (field) => missingFault(where, field),
273 }
274}
275
276function itemOf(located: Located, fields: FieldMap): WorkitemsItem {
277 const id = requiredText(located, fields.id)
278 const rawStatus = requiredText(located, fields.status)
279 const item: WorkitemsItem = {
280 key: `${SOURCE}:${id}`,
281 id,
282 title: requiredText(located, fields.title),
283 status: normalStatusOf(rawStatus),
284 rawStatus,
285 priority: optionalPriority(located, fields.priority),
286 type: optionalText(located, fields.type),
287 assignee: optionalText(located, fields.assignee),
288 updatedAt: optionalText(located, fields.updatedAt),
289 source: SOURCE,
290 }
291 const optional: readonly [MappedField, 'url' | 'parent'][] = [
292 ['url', 'url'],
293 ['parent', 'parent'],
294 ]
295 for (const [field, key] of optional) {
296 const value = optionalText(located, fields[field])
297 if (value !== null) item[key] = value
298 }
299 const labels = optionalLabels(located, fields.labels)
300 if (labels) item.labels = labels
301 return checkedItem(item, located.where)
302}
303
304function jsonLocatedItems(path: string, text: string): Located[] {
305 let parsed: unknown
306 try {
307 parsed = JSON.parse(text) as unknown
308 } catch {
309 throw new ItemFault(`${path} is not valid JSON, so it could not be read.`)
310 }
311 const records = Array.isArray(parsed) ? parsed : [parsed]
312 return records.map((record, index) => {
313 const where = `${path} item ${numeral(index + 1)}`
314 if (!isRecord(record))
315 throw new ItemFault(`${where} is not an object, so it could not be read.`)
316 return recordLocated(
317 record,
318 where,
319 (field) => `${where} has an invalid ${field}, so it could not be read.`,
320 )
321 })
322}
323
324function jsonlLocatedItems(path: string, text: string): Located[] {
325 const located: Located[] = []
326 for (const [index, raw] of text.split('\n').entries()) {
327 if (raw.trim() === '') continue
328 let record: unknown
329 try {
330 record = JSON.parse(raw) as unknown
331 } catch {
332 throw new ItemFault(lineFault(path, index + 1))
333 }
334 if (!isRecord(record)) throw new ItemFault(lineFault(path, index + 1))
335 const where = `${path} line ${numeral(index + 1)}`
336 located.push(recordLocated(record, where, () => lineFault(path, index + 1)))
337 }
338 return located
339}
340
341export type FoundItem = { item: WorkitemsItem; path: string }
342
343export type ItemFiles = { found: FoundItem[]; skipped: WorkitemsFailedReason[] }
344
345export async function readItemFiles(
346 files: TrackerFiles,
347 paths: readonly string[],
348 itemOfFile: (path: string, text: string) => WorkitemsItem,
349): Promise<ItemFiles> {
350 const result: ItemFiles = { found: [], skipped: [] }
351 for (const path of paths) {
352 try {
353 result.found.push({ item: itemOfFile(path, await files.read(path)), path })
354 } catch (error) {
355 if (!(error instanceof ItemFault || error instanceof FileProblem)) throw error
356 result.skipped.push(error.reason)
357 }
358 }
359 return result
360}
361
362export function skippedCaveat(skipped: readonly WorkitemsFailedReason[]): string | null {
363 const [first] = skipped
364 if (first === undefined) return null
365 if (skipped.length === 1) return `1 item file skipped: ${first}`
366 return `${numeral(skipped.length)} item files skipped, the first: ${first}`
367}
368
369async function readFrontMatterFiles(
370 files: TrackerFiles,
371 config: FilesConfig,
372 paths: readonly string[],
373): Promise<ItemFiles> {
374 return readItemFiles(files, paths, (path, text) =>
375 itemOf(frontMatterLocated(path, parseFrontMatter(path, text)), config.fields),
376 )
377}
378
379async function readRecordFiles(
380 files: TrackerFiles,
381 config: FilesConfig,
382 paths: readonly string[],
383): Promise<ItemFiles> {
384 const found: FoundItem[] = []
385 for (const path of paths) {
386 const text = await files.read(path)
387 const located =
388 config.format === 'json' ? jsonLocatedItems(path, text) : jsonlLocatedItems(path, text)
389 for (const record of located) found.push({ item: itemOf(record, config.fields), path })
390 }
391 return { found, skipped: [] }
392}
393
394export function uniqueByKey(items: readonly FoundItem[]): WorkitemsItem[] {
395 const seen = new Set<string>()
396 for (const { item, path } of items) {
397 if (seen.has(item.key)) {
398 throw new ItemFault(`${path} repeats the id ${item.id}, so it could not be read.`)
399 }
400 seen.add(item.key)
401 }
402 return items.map(({ item }) => item)
403}
404
405async function filesConfigOf(files: TrackerFiles): Promise<FilesConfig> {
406 const outcome = await readConfig(files)
407 if (!outcome.ok) throw new ItemFault(outcome.reason)
408 if (outcome.config.files === null) {
409 throw new ItemFault(`${CONFIG_FILE} names no globs, so it could not be read.`)
410 }
411 return outcome.config.files
412}
413
414async function readFiles(files: TrackerFiles): Promise<ReadOutcome> {
415 try {
416 const config = await filesConfigOf(files)
417 const paths = (await matchGlobs(files, config.globs)).map((match) => match.path)
418 const { found, skipped } =
419 config.format === 'frontmatter'
420 ? await readFrontMatterFiles(files, config, paths)
421 : await readRecordFiles(files, config, paths)
422 return {
423 ok: true,
424 items: uniqueByKey(found),
425 sourceLabel: SOURCE,
426 caveat: skippedCaveat(skipped),
427 }
428 } catch (error) {
429 if (error instanceof ItemFault) return { ok: false, reason: error.reason }
430 throw error
431 }
432}
433
434async function isConfigured(files: TrackerFiles): Promise<boolean> {
435 const outcome = await readConfig(files)
436 return outcome.ok && outcome.config.files !== null
437}
438
439async function filesSignature(files: TrackerFiles): Promise<string> {
440 const config = await filesConfigOf(files)
441 return [JSON.stringify(config), signatureOfMatches(await matchGlobs(files, config.globs))].join(
442 '\n',
443 )
444}
445
446export const filesReader: Reader = {
447 name: SOURCE,
448 marker: CONFIG_FILE,
449 lookedForAs: CONFIG_FILE,
450 isPresent: isConfigured,
451 signature: filesSignature,
452 read: readFiles,
453}
454