SLOPSHOPPER

workitems

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

newprocesstimer
v0.4.1MITupdated 2026-10-09niksavis/handily/mods/workitems
A shopper browsing a rack in a slop shop
README

workitems

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.

What it reads

TrackerDetected byRead path
basicly.basicly/ledger/template.jsonbasicly tracker items --json, after approval
beads (bd, br).beads/issues.jsonlBuilt-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.jsonGeneric JSON, JSON Lines or front matter reader
Any other (adapter)an entry in your ~/.config/handily/adapters.json<command> describe --json, <command> items --json
  • The mod looks only at the session root ($.session.root()). It never reads a parent folder.
  • It detects the tracker again when the working directory changes.
  • It reads one source per repo: the first one in the table that it finds, or the one that .handily.json names. The snapshot lists each other source that it finds under ignored.
  • A tracker file over 4 MiB, or a malformed line, makes the read fail. The reason names the file. .beads/issues.jsonl is the one exception to the size rule (see beads).
  • Each read of a tracker file stays inside the repo root. A file that resolves outside the root, for example through a link, makes the read fail. The reason names the file.
  • Each reader checks the 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.
  • A front matter file holds one item. When the mod cannot read one such file, it skips only that item. The snapshot 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.
  • The front matter reader reads plain, quoted and block (|, >) 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.

basicly

  • The mod runs this command from 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).
  • A basicly without 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 0.21.1 or later runs only the installed package for this command. An older basicly also runs the repo code in .basicly/core/kit/tracker.
  • It reads 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.
  • When 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.
  • The poll reads again when a file in .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.
  • The mod runs the program path that the approval recorded. It checks the approval once for each read, before the call.
  • The mod reads only the open statuses. So when a record leaves them, for example when it is closed or deferred, refresh() reports it under closed, with the status closed and the last raw status that the mod read.

beads

  • The beads reader skips a line whose _type is not issue, and a tombstone line.
  • When .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.
A tracker file over 4 MiB

The engine reads no file over 4 MiB. So the size of .beads/issues.jsonl chooses the read path:

Size of .beads/issues.jsonlRead path
4 MiB or lessThe mod reads the file itself, as above
Over 4 MiBThe 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
  • The mod runs 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.
  • The mod reads only these open items. So when an item leaves the list, refresh() reports it under closed, with the status closed and the last raw status that the mod read.
  • The mod reads 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.
  • The mod shows no item when the list can be incomplete. Each failure names the cause and the fix:
CauseReason
br is not on PATHbr is not on PATH. Install br to list the open items of ...
The output was cut at 4 MiBbr list output was cut off. Close some open items, ...
A non-zero exitbr list exited N. Run it in a shell to see why.
br list did not start or did not end in timebr list did not start or did not end in time ...
The output is not JSON, or has no issues listbr list printed no valid JSON ... or ... printed no issues list ...
has_more is not false, or total is not a numberbr list did not say that it printed every item ...
total differs from the number of itemsbr list printed N of M items ...
An item is not an object, has an invalid field or an unsafe textbr 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.

  • Until you approve the run, the state is approval-needed. On a surface that cannot run a command, the state is terminal-only.
  • The poll reads again when the size or the modification time of the file changes, when the real path of br on PATH changes, for example when you install or move br, and after you approve br list.
  • When the read changes between the file and br, refresh() compares the items without parent. So a child item that did not change is not reported as updated.
  • When the file shrinks to 4 MiB or less, the mod reads the file itself again. A closed item that the br list did not hold is then not reported as created.

beans

  • Each .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.
  • The beans folder is .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.
  • A file under the archive/ folder is closed, whatever its status says.
  • beans has no assignee. The tags become the labels.
beans statusItem status
todoopen
in-progressin_progress
draftother
completed, scrappedclosed
beans priorityItem priority
critical0
high1
normal2
low3
deferred4

A priority word that is not in this table makes the read fail. The reason names the file and the line.

.handily.json

.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" }
}
KeyMeaning
sourceOptional. basicly, beads, beans, files or adapter. The mod reads this source
globsThe item files, relative to the repo root. * and ? match in one folder, ** in any
formatjson (one item or a list of items per file), jsonl (one item per line) or frontmatter
fieldsThe file field of each item field. id, title and status are required
  • The item fields are id, title, status, priority, type, assignee, updatedAt, labels, parent and url.
  • A status value of open, in_progress, blocked, deferred or closed keeps its meaning. Any other value becomes other.
  • The 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.
  • The mod reads only the fields of the item itself, never a name that every object inherits, such as constructor.
  • A wildcard does not match a name that starts with a dot, unless the glob part starts with a dot too.
  • The mod resolves the real path of each folder that it lists and of each linked file. When one leads outside the repo root, the read fails, and the reason names the path and the glob.
  • For a linked file, the poll checks the size and the modification time of the file that the link leads to.
  • An unknown key, an unknown source, or a missing field makes the read fail. The reason names the fault.
  • .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 failure reason never shows a name or a value from the repo that holds a control character or is very long. Such a reason becomes a fixed text that names the source.

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:

  • The entry approves the command, not one version of the code. A git pull that changes the script runs the new code at the next read, with no new question.
  • The key is a path. A different clone that you later place at the same path inherits the entry.
  • The check that the program is outside the repo covers only 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.
  • Add an entry only for a repo whose code you already trust to run on your machine.

Setup:

  1. The repo can ship an adapter script and document the command for it. Read the script first.
  2. Find the real path of the repo root, for example with pwd -P in the repo.
  3. Add one entry to ~/.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"] }
  • The mod finds your home folder through HOME, or USERPROFILE when HOME is not set.
  • The key is the exact real path, with no patterns. A clone at another path does not match. A root that you open through a link resolves to the same key.
  • The mod refuses a program whose real path is inside the repo root, for example ["tools/run"]. Run a repo script through a program outside the repo, such as node.
  • The mod refuses an argument with a control character or over 256 characters.
  • The mod refuses an adapters file that is not a regular file.
  • When no entry matches the repo, the no-tracker line names ~/.config/handily/adapters.json as a place that the mod looked.
  • When .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.
  • The mod refuses a 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.
  • The item keys are <name>:<id>. The snapshot sourceLabel is the name.
  • Each 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.
  • The poll reads again when a file under watch or your adapters file changes.
  • The mod refuses a 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.
  • A non-zero exit, output over 4 MiB or output that is not valid JSON makes the read fail. The reason names the command in double quotes.

Approval

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.

  • In an interactive session the mod asks once, in the engine's question dialog, with the options 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.
  • The mod cannot hash a file over 4 MiB, so such a kit file makes the read fail, and the reason names it. A kit file whose name holds a control character or is over 256 characters also makes the read fail, and the reason does not repeat the name.
  • The mod also keeps the answer under a second key of the repo root, the command and the real path of basicly, with no kit files.
  • When the kit files differ from the files that you approved, the mod runs basicly --version with the approved program path. The version decides if the approval covers the kit files:
basicly --version printsA kit change
basicly X.Y.Z, 0.21.1 or laterdoes not ask again
basicly X.Y.Z, below 0.21.1asks again
any other text, or a non-zero exitasks again
nothing, because it did not startthe 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.

  • The mod keeps the last verdict of 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.
  • When the command or the real path of basicly changes, the old approval does not match, and the mod asks again.
  • An approval of basicly tracker list from an older workitems does not cover basicly tracker items. The approved command changed, so the mod asks you once again.
  • The question shows each argument in double quotes, with the real path of the program. It says that basicly 0.21.1 or later runs only the installed package, and that an older basicly also runs the repo code in the kit folder.
  • The mod refuses a 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 ..
  • On Windows the check that a program is outside the repo root ignores case, for a drive-letter root (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.
  • Only the answer 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.
  • The mod never asks in a session that is not interactive, such as claude -p. The state is then approval-needed.
  • A session that draws only on the desktop app cannot run a command. A CLI source then has the state terminal-only.

br list --json --limit 0 follows the same rules as basicly above, with one difference in what the approval covers:

  • The key holds the repo root, the command and the real path of br. It holds no repo file and no hash of the br program.
  • A new 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.
  • These facts about br list --json --limit 0 were checked on br 0.3.2 on 2026-10-09. The question names them in short:
CheckedResult
Files that it writesThe 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 nameNone 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 pathNot ruled out. No system call tracer was available
.beads/config.yamlHolds the prefix, defaults and sync settings. br config list shows no key that names a command
  • The key holds no repo file, so the mod never runs a version check of br.
  • The mod refuses a br whose real path is inside the repo root.

The contract

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.

PartUse
$.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:

FieldMeaning
versionA number that rises by 1 each time the data changes
atThe time of the read that gave this data
checkedAtThe time of the last check of the tracker file. The header age uses it

Refresh

  • refresh({ since }) returns the chan
Source 14 files
hooks/register.tsx 111 lines
1import 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}
111
hooks/approval.ts 277 lines
1import 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}
277
hooks/commands.ts 260 lines
1import 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}
260
hooks/readers/index.ts 114 lines
1import 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}
114
hooks/snapshot.ts 516 lines
1import 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}
516
hooks/states.ts 94 lines
1import 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}
94
hooks/config.ts 332 lines
1import 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}
332
hooks/readers/adapter.ts 334 lines
1import 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}
334
hooks/readers/basicly.ts 192 lines
1import 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}
192
hooks/readers/beads.ts 285 lines
1import 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}
285
hooks/readers/beans.ts 152 lines
1import 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}
152
hooks/readers/generic.ts 454 lines
1import 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