@descent-vtt/spec-harness 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +325 -0
  4. package/README.md +39 -18
  5. package/dist/briefs.d.ts +45 -0
  6. package/dist/briefs.js +73 -0
  7. package/dist/briefs.js.map +1 -1
  8. package/dist/cli.d.ts +1 -1
  9. package/dist/cli.js +308 -142
  10. package/dist/cli.js.map +1 -1
  11. package/dist/commands.js +7 -2
  12. package/dist/commands.js.map +1 -1
  13. package/dist/config.d.ts +13 -1
  14. package/dist/config.js +14 -3
  15. package/dist/config.js.map +1 -1
  16. package/dist/configure.d.ts +94 -3
  17. package/dist/configure.js +204 -30
  18. package/dist/configure.js.map +1 -1
  19. package/dist/fs.d.ts +104 -1
  20. package/dist/fs.js +383 -11
  21. package/dist/fs.js.map +1 -1
  22. package/dist/git.d.ts +88 -2
  23. package/dist/git.js +155 -8
  24. package/dist/git.js.map +1 -1
  25. package/dist/guard.d.ts +20 -1
  26. package/dist/guard.js +31 -0
  27. package/dist/guard.js.map +1 -1
  28. package/dist/hooks.d.ts +59 -0
  29. package/dist/hooks.js +76 -3
  30. package/dist/hooks.js.map +1 -1
  31. package/dist/round.d.ts +27 -1
  32. package/dist/round.js +75 -17
  33. package/dist/round.js.map +1 -1
  34. package/dist/sandbox.js +4 -3
  35. package/dist/sandbox.js.map +1 -1
  36. package/dist/server.d.ts +25 -1
  37. package/dist/server.js +230 -28
  38. package/dist/server.js.map +1 -1
  39. package/dist/setup.js +40 -10
  40. package/dist/setup.js.map +1 -1
  41. package/dist/siblings.d.ts +29 -6
  42. package/dist/siblings.js +152 -39
  43. package/dist/siblings.js.map +1 -1
  44. package/dist/usage.d.ts +42 -7
  45. package/dist/usage.js +65 -6
  46. package/dist/usage.js.map +1 -1
  47. package/dist/workspace.d.ts +45 -3
  48. package/dist/workspace.js +143 -13
  49. package/dist/workspace.js.map +1 -1
  50. package/package.json +3 -3
package/dist/siblings.js CHANGED
@@ -15,7 +15,9 @@
15
15
  import { execFile } from 'node:child_process';
16
16
  import { existsSync, readFileSync } from 'node:fs';
17
17
  import { join } from 'node:path';
18
- import { parseBriefList, SiblingOutputError } from './briefs.js';
18
+ import { inputsOf, parseBriefList, SiblingOutputError } from './briefs.js';
19
+ import { hasher, isFile, keepAnswer, keptAnswer, readsAsListed } from './fs.js';
20
+ import { ANSWER_MS, stoppedAtLimit, timeFor, WORK_MS } from './git.js';
19
21
  import { checkVersion } from './versions.js';
20
22
  /**
21
23
  * Where a sibling is, or why it cannot be run. It is looked for in `root`'s
@@ -51,6 +53,10 @@ export function locate(name, root, config, installs = []) {
51
53
  }
52
54
  /** The `version` a package.json declares, or `undefined` when there is no such file or it is not JSON. */
53
55
  export function declaredVersion(file) {
56
+ // Only a file is read: a named pipe under the name was read without end,
57
+ // and before any sibling ran (ADR-0002).
58
+ if (!isFile(file))
59
+ return undefined;
54
60
  // A manifest that holds null throws on the read, and any other value that
55
61
  // is not an object has no version, so the catch answers for both. Falling
56
62
  // out of it answers undefined as well, and a Buffer parses as its text:
@@ -68,39 +74,107 @@ export function declaredVersion(file) {
68
74
  *
69
75
  * Never through a shell: an argument is a path or a brief id, and a shell
70
76
  * would read it as syntax. `node` as a command's first word is this Node, so
71
- * `["node", "path/to/bin.js"]` works on every host; a Windows `.cmd` shim such
72
- * as `npx` cannot be started without a shell, and the error says to name the
73
- * script instead.
77
+ * `["node", "path/to/bin.js"]` works on every host. A program that cannot be
78
+ * started is answered on every host in one way, code -1 and a line that says
79
+ * to name the tool's script: one not found, a script named without `node`,
80
+ * a Windows `.cmd` shim such as `npx`, which cannot be started without a
81
+ * shell, a directory or a file the account may not run.
82
+ *
83
+ * A sibling that does not answer within `limit` is stopped, and rejected as
84
+ * a `SiblingError` that names it: what it was asked waits on it, the hook
85
+ * before a write among them, which then holds the write (ADR-0002).
74
86
  */
75
- export function runSibling(command, args, root, timeoutMs = 600_000) {
87
+ export function runSibling(name, command, args, root, limit = WORK_MS) {
76
88
  const [first, ...rest] = command;
77
89
  const program = first === 'node' ? process.execPath : first;
78
- return new Promise((resolve) => {
79
- execFile(program, [...rest, ...args], {
80
- cwd: root,
81
- maxBuffer: 256 * 1024 * 1024,
82
- encoding: 'utf8',
83
- // Keeps a console window from opening on Windows; the sweep runs on
84
- // Linux, where the mutant is equivalent.
85
- windowsHide: true,
86
- timeout: timeoutMs,
87
- shell: false,
88
- env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' },
89
- }, (error, stdout, stderr) => {
90
- const failure = error;
91
- if (failure !== null && failure.code === 'ENOENT') {
92
- resolve({
93
- code: -1,
94
- stdout: '',
95
- stderr: `cannot start "${first}": name the tool's script, such as ["node", "node_modules/@descent-vtt/<tool>/bin/<tool>.js"]`,
96
- });
97
- return;
98
- }
99
- const code = failure === null ? 0 : typeof failure.code === 'number' ? failure.code : -1;
100
- resolve({ code, stdout, stderr });
101
- });
90
+ const { ms, said } = timeFor(limit);
91
+ if (ms === 0)
92
+ return Promise.reject(new SiblingError(`${name} was not started: ${said} had passed`));
93
+ const unstarted = {
94
+ code: -1,
95
+ stdout: '',
96
+ stderr: `cannot start "${first}": name the tool's script, such as ["node", "node_modules/@descent-vtt/<tool>/bin/<tool>.js"]`,
97
+ };
98
+ return new Promise((resolve, reject) => {
99
+ try {
100
+ execFile(program, [...rest, ...args], {
101
+ cwd: root,
102
+ maxBuffer: 256 * 1024 * 1024,
103
+ encoding: 'utf8',
104
+ // Keeps a console window from opening on Windows; the sweep runs on
105
+ // Linux, where the mutant is equivalent.
106
+ windowsHide: true,
107
+ timeout: ms,
108
+ shell: false,
109
+ env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' },
110
+ }, (error, stdout, stderr) => {
111
+ if (stoppedAtLimit(error)) {
112
+ reject(new SiblingError(`${name} did not answer within ${said}, and was stopped`));
113
+ return;
114
+ }
115
+ const failure = error;
116
+ // Of what reaches here, only the error of a process that was never
117
+ // made names the system call that failed, `spawn` and the program:
118
+ // ENOENT, and on Linux and macOS EACCES for a directory or a file
119
+ // that may not be run. One that ran has its exit code, and one
120
+ // stopped at its limit was answered above.
121
+ if (failure !== null && typeof failure.syscall === 'string') {
122
+ resolve(unstarted);
123
+ return;
124
+ }
125
+ const code = failure === null ? 0 : typeof failure.code === 'number' ? failure.code : -1;
126
+ resolve({ code, stdout, stderr });
127
+ });
128
+ }
129
+ catch {
130
+ // Some programs are refused before a process is made, and Node throws
131
+ // that rather than pass it to the callback: on Windows a script, as
132
+ // EFTYPE, and a `.cmd` or `.bat`, as EINVAL; on every host a NUL in a
133
+ // word of the command. The options are this function's own, so what
134
+ // execFile throws is a command it could not start.
135
+ resolve(unstarted);
136
+ }
102
137
  });
103
138
  }
139
+ /**
140
+ * Where and under what a session's answer of spec-brief's is kept, or
141
+ * `null` where none is. A command `tools` names is started every time:
142
+ * which version it runs cannot be told without starting it.
143
+ */
144
+ async function keeperOf(kept, root, sibling) {
145
+ // A command with no version is kept nothing by the test of the version an
146
+ // answer names, too: the mutant without this one reads, for every write, a
147
+ // kept answer there is none of, and is equivalent.
148
+ if (sibling.version === null)
149
+ return null;
150
+ const hash = await hasher();
151
+ const key = answerKey(root, kept.session, sibling.command, sibling.version);
152
+ return { commonDir: kept.commonDir, key, name: `${hash(key)}.json`, version: sibling.version, hash };
153
+ }
154
+ /**
155
+ * The briefs a kept answer gives, or `null` where it is not to be given:
156
+ * nothing kept, or what was kept is not a list document that names what it
157
+ * was read from in the version installed, or anything it names reads
158
+ * otherwise.
159
+ */
160
+ async function givenAgain(stdout, version, root, hash) {
161
+ // JSON.parse reads null as null, which names no inputs, and a null read
162
+ // past the test of none below throws, which answers null: the two tests
163
+ // are the compiler's, and their mutants equivalent.
164
+ if (stdout === null)
165
+ return null;
166
+ try {
167
+ const document = JSON.parse(stdout);
168
+ const inputs = inputsOf(document);
169
+ if (inputs === null || inputs.tool !== version || !(await readsAsListed(root, inputs, hash)))
170
+ return null;
171
+ return parseBriefList(document);
172
+ }
173
+ catch {
174
+ // What another program wrote there is no answer of spec-brief's.
175
+ return null;
176
+ }
177
+ }
104
178
  export class SiblingError extends Error {
105
179
  constructor(message) {
106
180
  super(message);
@@ -116,24 +190,56 @@ function parseJson(name, run) {
116
190
  throw new SiblingError(`${name} exited ${run.code} without JSON${said ? `: ${said}` : ''}`);
117
191
  }
118
192
  }
193
+ /** The command line spec-brief is asked for the briefs with. */
194
+ const LIST = ['list', '--archived', '--format', 'json', '--no-color'];
195
+ /**
196
+ * The variables beyond git's own that an answer of spec-brief's can turn on
197
+ * and its list does not name: which git and which Node run, where git finds
198
+ * the person's configuration and ignore files, and how Node runs a script.
199
+ * Every variable whose name begins `GIT_` is read with them.
200
+ */
201
+ const ANSWER_VARIABLES = ['PATH', 'HOME', 'USERPROFILE', 'HOMEDRIVE', 'HOMEPATH', 'XDG_CONFIG_HOME', 'NODE_OPTIONS', 'NODE_PATH'];
202
+ /**
203
+ * What a kept answer is the answer to: the work tree, the session, the
204
+ * spec-brief that answered, by its command, its version and the Node it ran
205
+ * on, the command line, and the variables above, as spec-brief is started
206
+ * with them (runSibling). An answer is given again only for the same.
207
+ */
208
+ export function answerKey(root, session, command, version, env = process.env) {
209
+ const variables = Object.keys(env)
210
+ .filter((name) => /^GIT_/i.test(name) || ANSWER_VARIABLES.includes(name.toUpperCase()))
211
+ // One case for each name, whichever: the mutant that folds to the other is equivalent.
212
+ .map((name) => [name.toUpperCase(), env[name]])
213
+ .sort();
214
+ return JSON.stringify({ root, session, command, version, node: process.version, args: LIST, variables });
215
+ }
119
216
  /** The siblings of the work tree at `root`, looked for in its install and then in each of `installs` (locate). */
120
217
  export function createSiblings(root, config, installs = []) {
121
- const found = (name) => {
122
- const sibling = locate(name, root, config, installs);
123
- if (sibling.kind !== 'found')
124
- throw new SiblingError(sibling.reason);
125
- return sibling.command;
126
- };
218
+ return siblingsOf(root, config, installs, null);
219
+ }
220
+ function siblingsOf(root, config, installs, kept) {
127
221
  return {
128
222
  root,
129
223
  locate: (name) => locate(name, root, config, installs),
130
- async briefs() {
131
- const run = await runSibling(found('spec-brief'), ['list', '--archived', '--format', 'json', '--no-color'], root);
224
+ async briefs(limit = ANSWER_MS) {
225
+ const sibling = locate('spec-brief', root, config, installs);
226
+ if (sibling.kind !== 'found')
227
+ throw new SiblingError(sibling.reason);
228
+ const keeper = kept === null ? null : await keeperOf(kept, root, sibling);
229
+ if (keeper !== null) {
230
+ const again = await givenAgain(await keptAnswer(keeper.commonDir, keeper.name, keeper.key), keeper.version, root, keeper.hash);
231
+ if (again !== null)
232
+ return again;
233
+ }
234
+ const run = await runSibling('spec-brief', sibling.command, LIST, root, limit);
132
235
  if (run.code === 2) {
133
236
  throw new SiblingError(`spec-brief could not list the briefs: ${run.stderr.trim().split('\n')[0] || 'exit 2'}`);
134
237
  }
238
+ let document;
239
+ let rows;
135
240
  try {
136
- return parseBriefList(parseJson('spec-brief', run));
241
+ document = parseJson('spec-brief', run);
242
+ rows = parseBriefList(document);
137
243
  }
138
244
  catch (error) {
139
245
  // parseJson throws a SiblingError, which this would copy, and
@@ -143,12 +249,19 @@ export function createSiblings(root, config, installs = []) {
143
249
  throw new SiblingError(error.message);
144
250
  throw error;
145
251
  }
252
+ // Kept only where spec-brief said all was well and named what it read,
253
+ // in the version that answered: any other answer is read once.
254
+ if (keeper !== null && run.code === 0 && inputsOf(document)?.tool === keeper.version) {
255
+ await keepAnswer(keeper.commonDir, keeper.name, keeper.key, run.stdout);
256
+ }
257
+ return rows;
146
258
  },
259
+ keeping: (commonDir, session) => siblingsOf(root, config, installs, { commonDir, session }),
147
260
  async json(name, args) {
148
261
  const sibling = locate(name, root, config, installs);
149
262
  if (sibling.kind !== 'found')
150
263
  return { absent: sibling.reason };
151
- const run = await runSibling(sibling.command, args, root);
264
+ const run = await runSibling(name, sibling.command, args, root);
152
265
  return { code: run.code, document: parseJson(name, run), stderr: run.stderr };
153
266
  },
154
267
  };
@@ -1 +1 @@
1
- {"version":3,"file":"siblings.js","sourceRoot":"","sources":["../src/siblings.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The edge where the harness meets spec-brief, spec-graph and spec-guard.\n *\n * Through their command lines and their versioned JSON, never by importing\n * their packages: each is released, pinned and dropped on its own, and none\n * becomes a dependency of this one (spec-core ADR-0005, ADR-0002 here).\n *\n * A sibling is found as the repository installed it -\n * `node_modules/@descent-vtt/<name>/bin/<name>.js`, run with this Node - or\n * as `.spec-harness.json` names it. One that is not there is `absent`, and\n * what needed it says so; nothing it would have checked is reported clean.\n * One installed below this release's minimum is `outdated`, and is reported\n * the same way rather than run (versions.ts).\n */\n\nimport { execFile } from 'node:child_process';\nimport { existsSync, readFileSync } from 'node:fs';\nimport { join } from 'node:path';\n\nimport { parseBriefList, SiblingOutputError } from './briefs.js';\nimport type { HarnessConfig, SiblingName } from './config.js';\nimport type { BriefRow } from './types.js';\nimport { checkVersion } from './versions.js';\n\nexport interface SiblingRun {\n readonly code: number;\n readonly stdout: string;\n readonly stderr: string;\n}\n\nexport type Sibling =\n /**\n * `version` is `null` for a command `.spec-harness.json` names, whose\n * version is not checked. `elsewhere` is the directory whose install it\n * is, where that is not the work tree's own.\n */\n | { readonly kind: 'found'; readonly command: readonly string[]; readonly version: string | null; readonly elsewhere?: string }\n | { readonly kind: 'absent'; readonly reason: string }\n /** Installed, at a version below the minimum or one that cannot be read, which is `null`. */\n | { readonly kind: 'outdated'; readonly reason: string; readonly version: string | null; readonly elsewhere?: string };\n\n/**\n * Where a sibling is, or why it cannot be run. It is looked for in `root`'s\n * install, then in each of `installs` in turn, and the first that has it\n * decides, at this release's floor: a work tree with an install of its own\n * runs it, outdated or not, and one with none is checked with the\n * project's, as git's hook and Claude Code's are where a linked worktree has\n * none (ADR-0005).\n */\n// A name a mutant puts on the default list is a directory relative to where\n// the run is, which holds no install: equivalent, here and below.\nexport function locate(name: SiblingName, root: string, config: HarnessConfig, installs: readonly string[] = []): Sibling {\n // A command the configuration names is run as named: which package it runs,\n // and so which version, is the configuration's to say.\n const configured = config.tools[name];\n if (configured !== null) return { kind: 'found', command: configured, version: null };\n for (const place of [root, ...installs]) {\n const directory = join(place, 'node_modules', '@descent-vtt', name);\n const bin = join(directory, 'bin', `${name}.js`);\n if (!existsSync(bin)) continue;\n const check = checkVersion(name, declaredVersion(join(directory, 'package.json')));\n const where = place === root ? {} : { elsewhere: place };\n if (!check.ok) return { kind: 'outdated', reason: check.reason, version: check.version, ...where };\n return { kind: 'found', command: [process.execPath, bin], version: check.version, ...where };\n }\n return {\n kind: 'absent',\n reason: `${name} is not installed here: npm install --save-dev @descent-vtt/${name}, or name its command under \"tools\" in .spec-harness.json`,\n };\n}\n\n/** The `version` a package.json declares, or `undefined` when there is no such file or it is not JSON. */\nexport function declaredVersion(file: string): unknown {\n // A manifest that holds null throws on the read, and any other value that\n // is not an object has no version, so the catch answers for both. Falling\n // out of it answers undefined as well, and a Buffer parses as its text:\n // the empty catch and the encoding are equivalent to their mutants.\n try {\n const manifest: { version?: unknown } = JSON.parse(readFileSync(file, 'utf8'));\n return manifest.version;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Runs a sibling with arguments, in the repository root.\n *\n * Never through a shell: an argument is a path or a brief id, and a shell\n * would read it as syntax. `node` as a command's first word is this Node, so\n * `[\"node\", \"path/to/bin.js\"]` works on every host; a Windows `.cmd` shim such\n * as `npx` cannot be started without a shell, and the error says to name the\n * script instead.\n */\nexport function runSibling(command: readonly string[], args: readonly string[], root: string, timeoutMs = 600_000): Promise<SiblingRun> {\n const [first, ...rest] = command;\n const program = first === 'node' ? process.execPath : (first as string);\n return new Promise((resolve) => {\n execFile(\n program,\n [...rest, ...args],\n {\n cwd: root,\n maxBuffer: 256 * 1024 * 1024,\n encoding: 'utf8',\n // Keeps a console window from opening on Windows; the sweep runs on\n // Linux, where the mutant is equivalent.\n windowsHide: true,\n timeout: timeoutMs,\n shell: false,\n env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' },\n },\n (error, stdout, stderr) => {\n const failure = error as (Error & { code?: unknown }) | null;\n if (failure !== null && failure.code === 'ENOENT') {\n resolve({\n code: -1,\n stdout: '',\n stderr: `cannot start \"${first}\": name the tool's script, such as [\"node\", \"node_modules/@descent-vtt/<tool>/bin/<tool>.js\"]`,\n });\n return;\n }\n const code = failure === null ? 0 : typeof failure.code === 'number' ? failure.code : -1;\n resolve({ code, stdout, stderr });\n },\n );\n });\n}\n\nexport class SiblingError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'SiblingError';\n }\n}\n\nfunction parseJson(name: string, run: SiblingRun): unknown {\n try {\n return JSON.parse(run.stdout);\n } catch {\n const said = (run.stderr.trim() || run.stdout.trim()).split('\\n').slice(0, 3).join(' ');\n throw new SiblingError(`${name} exited ${run.code} without JSON${said ? `: ${said}` : ''}`);\n }\n}\n\nexport interface Siblings {\n readonly root: string;\n locate(name: SiblingName): Sibling;\n /** Every brief, live and archived. Exit 2 from spec-brief is an error here too. */\n briefs(): Promise<BriefRow[]>;\n /** A sibling's JSON output for a command line, with its exit code and what it said on stderr; `absent` says why it cannot be run, missing or outdated. */\n json(name: SiblingName, args: readonly string[]): Promise<{ readonly code: number; readonly document: unknown; readonly stderr: string } | { readonly absent: string }>;\n}\n\n/** The siblings of the work tree at `root`, looked for in its install and then in each of `installs` (locate). */\nexport function createSiblings(root: string, config: HarnessConfig, installs: readonly string[] = []): Siblings {\n const found = (name: SiblingName): readonly string[] => {\n const sibling = locate(name, root, config, installs);\n if (sibling.kind !== 'found') throw new SiblingError(sibling.reason);\n return sibling.command;\n };\n return {\n root,\n locate: (name) => locate(name, root, config, installs),\n async briefs() {\n const run = await runSibling(found('spec-brief'), ['list', '--archived', '--format', 'json', '--no-color'], root);\n if (run.code === 2) {\n throw new SiblingError(`spec-brief could not list the briefs: ${run.stderr.trim().split('\\n')[0] || 'exit 2'}`);\n }\n try {\n return parseBriefList(parseJson('spec-brief', run));\n } catch (error) {\n // parseJson throws a SiblingError, which this would copy, and\n // parseBriefList nothing but its own: the check is equivalent to its\n // mutant, and keeps any other error the stack trace it needs.\n if (error instanceof SiblingOutputError) throw new SiblingError(error.message);\n throw error;\n }\n },\n async json(name, args) {\n const sibling = locate(name, root, config, installs);\n if (sibling.kind !== 'found') return { absent: sibling.reason };\n const run = await runSibling(sibling.command, args, root);\n return { code: run.code, document: parseJson(name, run), stderr: run.stderr };\n },\n };\n}\n"]}
1
+ {"version":3,"file":"siblings.js","sourceRoot":"","sources":["../src/siblings.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The edge where the harness meets spec-brief, spec-graph and spec-guard.\n *\n * Through their command lines and their versioned JSON, never by importing\n * their packages: each is released, pinned and dropped on its own, and none\n * becomes a dependency of this one (spec-core ADR-0005, ADR-0002 here).\n *\n * A sibling is found as the repository installed it -\n * `node_modules/@descent-vtt/<name>/bin/<name>.js`, run with this Node - or\n * as `.spec-harness.json` names it. One that is not there is `absent`, and\n * what needed it says so; nothing it would have checked is reported clean.\n * One installed below this release's minimum is `outdated`, and is reported\n * the same way rather than run (versions.ts).\n */\n\nimport { execFile } from 'node:child_process';\nimport { existsSync, readFileSync } from 'node:fs';\nimport { join } from 'node:path';\n\nimport { inputsOf, parseBriefList, SiblingOutputError } from './briefs.js';\nimport type { HarnessConfig, SiblingName } from './config.js';\nimport { hasher, isFile, keepAnswer, keptAnswer, readsAsListed, type Hash } from './fs.js';\nimport { ANSWER_MS, stoppedAtLimit, timeFor, WORK_MS } from './git.js';\nimport type { BriefRow } from './types.js';\nimport { checkVersion } from './versions.js';\n\nexport interface SiblingRun {\n readonly code: number;\n readonly stdout: string;\n readonly stderr: string;\n}\n\nexport type Sibling =\n /**\n * `version` is `null` for a command `.spec-harness.json` names, whose\n * version is not checked. `elsewhere` is the directory whose install it\n * is, where that is not the work tree's own.\n */\n | { readonly kind: 'found'; readonly command: readonly string[]; readonly version: string | null; readonly elsewhere?: string }\n | { readonly kind: 'absent'; readonly reason: string }\n /** Installed, at a version below the minimum or one that cannot be read, which is `null`. */\n | { readonly kind: 'outdated'; readonly reason: string; readonly version: string | null; readonly elsewhere?: string };\n\n/**\n * Where a sibling is, or why it cannot be run. It is looked for in `root`'s\n * install, then in each of `installs` in turn, and the first that has it\n * decides, at this release's floor: a work tree with an install of its own\n * runs it, outdated or not, and one with none is checked with the\n * project's, as git's hook and Claude Code's are where a linked worktree has\n * none (ADR-0005).\n */\n// A name a mutant puts on the default list is a directory relative to where\n// the run is, which holds no install: equivalent, here and below.\nexport function locate(name: SiblingName, root: string, config: HarnessConfig, installs: readonly string[] = []): Sibling {\n // A command the configuration names is run as named: which package it runs,\n // and so which version, is the configuration's to say.\n const configured = config.tools[name];\n if (configured !== null) return { kind: 'found', command: configured, version: null };\n for (const place of [root, ...installs]) {\n const directory = join(place, 'node_modules', '@descent-vtt', name);\n const bin = join(directory, 'bin', `${name}.js`);\n if (!existsSync(bin)) continue;\n const check = checkVersion(name, declaredVersion(join(directory, 'package.json')));\n const where = place === root ? {} : { elsewhere: place };\n if (!check.ok) return { kind: 'outdated', reason: check.reason, version: check.version, ...where };\n return { kind: 'found', command: [process.execPath, bin], version: check.version, ...where };\n }\n return {\n kind: 'absent',\n reason: `${name} is not installed here: npm install --save-dev @descent-vtt/${name}, or name its command under \"tools\" in .spec-harness.json`,\n };\n}\n\n/** The `version` a package.json declares, or `undefined` when there is no such file or it is not JSON. */\nexport function declaredVersion(file: string): unknown {\n // Only a file is read: a named pipe under the name was read without end,\n // and before any sibling ran (ADR-0002).\n if (!isFile(file)) return undefined;\n // A manifest that holds null throws on the read, and any other value that\n // is not an object has no version, so the catch answers for both. Falling\n // out of it answers undefined as well, and a Buffer parses as its text:\n // the empty catch and the encoding are equivalent to their mutants.\n try {\n const manifest: { version?: unknown } = JSON.parse(readFileSync(file, 'utf8'));\n return manifest.version;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Runs a sibling with arguments, in the repository root.\n *\n * Never through a shell: an argument is a path or a brief id, and a shell\n * would read it as syntax. `node` as a command's first word is this Node, so\n * `[\"node\", \"path/to/bin.js\"]` works on every host. A program that cannot be\n * started is answered on every host in one way, code -1 and a line that says\n * to name the tool's script: one not found, a script named without `node`,\n * a Windows `.cmd` shim such as `npx`, which cannot be started without a\n * shell, a directory or a file the account may not run.\n *\n * A sibling that does not answer within `limit` is stopped, and rejected as\n * a `SiblingError` that names it: what it was asked waits on it, the hook\n * before a write among them, which then holds the write (ADR-0002).\n */\nexport function runSibling(name: string, command: readonly string[], args: readonly string[], root: string, limit = WORK_MS): Promise<SiblingRun> {\n const [first, ...rest] = command;\n const program = first === 'node' ? process.execPath : (first as string);\n const { ms, said } = timeFor(limit);\n if (ms === 0) return Promise.reject(new SiblingError(`${name} was not started: ${said} had passed`));\n const unstarted: SiblingRun = {\n code: -1,\n stdout: '',\n stderr: `cannot start \"${first}\": name the tool's script, such as [\"node\", \"node_modules/@descent-vtt/<tool>/bin/<tool>.js\"]`,\n };\n return new Promise((resolve, reject) => {\n try {\n execFile(\n program,\n [...rest, ...args],\n {\n cwd: root,\n maxBuffer: 256 * 1024 * 1024,\n encoding: 'utf8',\n // Keeps a console window from opening on Windows; the sweep runs on\n // Linux, where the mutant is equivalent.\n windowsHide: true,\n timeout: ms,\n shell: false,\n env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' },\n },\n (error, stdout, stderr) => {\n if (stoppedAtLimit(error)) {\n reject(new SiblingError(`${name} did not answer within ${said}, and was stopped`));\n return;\n }\n const failure = error as (Error & { code?: unknown; syscall?: unknown }) | null;\n // Of what reaches here, only the error of a process that was never\n // made names the system call that failed, `spawn` and the program:\n // ENOENT, and on Linux and macOS EACCES for a directory or a file\n // that may not be run. One that ran has its exit code, and one\n // stopped at its limit was answered above.\n if (failure !== null && typeof failure.syscall === 'string') {\n resolve(unstarted);\n return;\n }\n const code = failure === null ? 0 : typeof failure.code === 'number' ? failure.code : -1;\n resolve({ code, stdout, stderr });\n },\n );\n } catch {\n // Some programs are refused before a process is made, and Node throws\n // that rather than pass it to the callback: on Windows a script, as\n // EFTYPE, and a `.cmd` or `.bat`, as EINVAL; on every host a NUL in a\n // word of the command. The options are this function's own, so what\n // execFile throws is a command it could not start.\n resolve(unstarted);\n }\n });\n}\n\n/**\n * Where and under what a session's answer of spec-brief's is kept, or\n * `null` where none is. A command `tools` names is started every time:\n * which version it runs cannot be told without starting it.\n */\nasync function keeperOf(kept: Kept, root: string, sibling: Extract<Sibling, { kind: 'found' }>): Promise<{ readonly commonDir: string; readonly key: string; readonly name: string; readonly version: string; readonly hash: Hash } | null> {\n // A command with no version is kept nothing by the test of the version an\n // answer names, too: the mutant without this one reads, for every write, a\n // kept answer there is none of, and is equivalent.\n if (sibling.version === null) return null;\n const hash = await hasher();\n const key = answerKey(root, kept.session, sibling.command, sibling.version);\n return { commonDir: kept.commonDir, key, name: `${hash(key)}.json`, version: sibling.version, hash };\n}\n\n/**\n * The briefs a kept answer gives, or `null` where it is not to be given:\n * nothing kept, or what was kept is not a list document that names what it\n * was read from in the version installed, or anything it names reads\n * otherwise.\n */\nasync function givenAgain(stdout: string | null, version: string, root: string, hash: Hash): Promise<BriefRow[] | null> {\n // JSON.parse reads null as null, which names no inputs, and a null read\n // past the test of none below throws, which answers null: the two tests\n // are the compiler's, and their mutants equivalent.\n if (stdout === null) return null;\n try {\n const document: unknown = JSON.parse(stdout);\n const inputs = inputsOf(document);\n if (inputs === null || inputs.tool !== version || !(await readsAsListed(root, inputs, hash))) return null;\n return parseBriefList(document);\n } catch {\n // What another program wrote there is no answer of spec-brief's.\n return null;\n }\n}\n\nexport class SiblingError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'SiblingError';\n }\n}\n\nfunction parseJson(name: string, run: SiblingRun): unknown {\n try {\n return JSON.parse(run.stdout);\n } catch {\n const said = (run.stderr.trim() || run.stdout.trim()).split('\\n').slice(0, 3).join(' ');\n throw new SiblingError(`${name} exited ${run.code} without JSON${said ? `: ${said}` : ''}`);\n }\n}\n\nexport interface Siblings {\n readonly root: string;\n locate(name: SiblingName): Sibling;\n /**\n * Every brief, live and archived. Exit 2 from spec-brief is an error here\n * too, and so is a spec-brief that does not answer within `limit`, the\n * time a question is given unless a caller gives another.\n */\n briefs(limit?: number): Promise<BriefRow[]>;\n /**\n * The same siblings, with spec-brief's list of the briefs kept for a\n * session under the common directory and given again to that session's\n * runs where every input it lists reads as listed (ADR-0002).\n */\n keeping(commonDir: string, session: string): Siblings;\n /** A sibling's JSON output for a command line, with its exit code and what it said on stderr; `absent` says why it cannot be run, missing or outdated. */\n json(name: SiblingName, args: readonly string[]): Promise<{ readonly code: number; readonly document: unknown; readonly stderr: string } | { readonly absent: string }>;\n}\n\n/** The command line spec-brief is asked for the briefs with. */\nconst LIST: readonly string[] = ['list', '--archived', '--format', 'json', '--no-color'];\n\n/**\n * The variables beyond git's own that an answer of spec-brief's can turn on\n * and its list does not name: which git and which Node run, where git finds\n * the person's configuration and ignore files, and how Node runs a script.\n * Every variable whose name begins `GIT_` is read with them.\n */\nconst ANSWER_VARIABLES: readonly string[] = ['PATH', 'HOME', 'USERPROFILE', 'HOMEDRIVE', 'HOMEPATH', 'XDG_CONFIG_HOME', 'NODE_OPTIONS', 'NODE_PATH'];\n\n/**\n * What a kept answer is the answer to: the work tree, the session, the\n * spec-brief that answered, by its command, its version and the Node it ran\n * on, the command line, and the variables above, as spec-brief is started\n * with them (runSibling). An answer is given again only for the same.\n */\nexport function answerKey(root: string, session: string, command: readonly string[], version: string, env: Readonly<Record<string, string | undefined>> = process.env): string {\n const variables = Object.keys(env)\n .filter((name) => /^GIT_/i.test(name) || ANSWER_VARIABLES.includes(name.toUpperCase()))\n // One case for each name, whichever: the mutant that folds to the other is equivalent.\n .map((name) => [name.toUpperCase(), env[name]])\n .sort();\n return JSON.stringify({ root, session, command, version, node: process.version, args: LIST, variables });\n}\n\n/** The siblings of the work tree at `root`, looked for in its install and then in each of `installs` (locate). */\nexport function createSiblings(root: string, config: HarnessConfig, installs: readonly string[] = []): Siblings {\n return siblingsOf(root, config, installs, null);\n}\n\n/** Where a session's answers are kept, and which session's they are. */\ninterface Kept {\n readonly commonDir: string;\n readonly session: string;\n}\n\nfunction siblingsOf(root: string, config: HarnessConfig, installs: readonly string[], kept: Kept | null): Siblings {\n return {\n root,\n locate: (name) => locate(name, root, config, installs),\n async briefs(limit = ANSWER_MS) {\n const sibling = locate('spec-brief', root, config, installs);\n if (sibling.kind !== 'found') throw new SiblingError(sibling.reason);\n const keeper = kept === null ? null : await keeperOf(kept, root, sibling);\n if (keeper !== null) {\n const again = await givenAgain(await keptAnswer(keeper.commonDir, keeper.name, keeper.key), keeper.version, root, keeper.hash);\n if (again !== null) return again;\n }\n const run = await runSibling('spec-brief', sibling.command, LIST, root, limit);\n if (run.code === 2) {\n throw new SiblingError(`spec-brief could not list the briefs: ${run.stderr.trim().split('\\n')[0] || 'exit 2'}`);\n }\n let document: unknown;\n let rows: BriefRow[];\n try {\n document = parseJson('spec-brief', run);\n rows = parseBriefList(document);\n } catch (error) {\n // parseJson throws a SiblingError, which this would copy, and\n // parseBriefList nothing but its own: the check is equivalent to its\n // mutant, and keeps any other error the stack trace it needs.\n if (error instanceof SiblingOutputError) throw new SiblingError(error.message);\n throw error;\n }\n // Kept only where spec-brief said all was well and named what it read,\n // in the version that answered: any other answer is read once.\n if (keeper !== null && run.code === 0 && inputsOf(document)?.tool === keeper.version) {\n await keepAnswer(keeper.commonDir, keeper.name, keeper.key, run.stdout);\n }\n return rows;\n },\n keeping: (commonDir, session) => siblingsOf(root, config, installs, { commonDir, session }),\n async json(name, args) {\n const sibling = locate(name, root, config, installs);\n if (sibling.kind !== 'found') return { absent: sibling.reason };\n const run = await runSibling(name, sibling.command, args, root);\n return { code: run.code, document: parseJson(name, run), stderr: run.stderr };\n },\n };\n}\n"]}
package/dist/usage.d.ts CHANGED
@@ -30,6 +30,9 @@ export interface CommandLine {
30
30
  readonly recommend: string | undefined;
31
31
  readonly show: string | undefined;
32
32
  readonly id: string | undefined;
33
+ /** Whether the line asks for the help, or for the version, which every command answers in place of running. */
34
+ readonly help: boolean;
35
+ readonly version: boolean;
33
36
  }
34
37
  /** Whether a value names nothing: empty, or only space. */
35
38
  export declare function isBlank(value: string): boolean;
@@ -77,6 +80,17 @@ declare const VALUED: {
77
80
  readonly says: "names no probe; give it a probe's id, or leave it out to run every probe";
78
81
  };
79
82
  };
83
+ /**
84
+ * A resolved path with its drive letter in one case. On Windows a drive
85
+ * letter names its volume in either case, `D:\` and `d:\` alike (Microsoft's
86
+ * Naming Files, Paths, and Namespaces, Naming Conventions, read 2026-10-19),
87
+ * where Node's `resolve` keeps the case it was written in (spec-core
88
+ * ADR-0005, amended 2026-10-19). The rest of the path is left as it is: NTFS
89
+ * can be made to tell names apart by case, so whether two names are one
90
+ * directory is the disk's to say. A path resolved elsewhere starts with `/`,
91
+ * and has no drive letter to fold.
92
+ */
93
+ export declare function foldDrive(path: string): string;
80
94
  /**
81
95
  * The first option that takes one value and was given two that read as two,
82
96
  * named with both as they were written; `null` where none was. The parser
@@ -203,7 +217,7 @@ declare const SHAPE: {
203
217
  readonly most: 0;
204
218
  readonly takes: "no argument";
205
219
  };
206
- readonly options: readonly ["root", "format", "write", "git-hook"];
220
+ readonly options: readonly ["root", "format", "write", "git-hook", "block-on-failure"];
207
221
  };
208
222
  readonly mcp: {
209
223
  readonly limit: {
@@ -230,7 +244,7 @@ export type Command = keyof typeof SHAPE;
230
244
  * every release, which this one cannot rewrite, and exit 2 from it holds
231
245
  * every write of a session, the write that would fix the line among them.
232
246
  * A line the parser cannot read is asked the same, and the hook says it
233
- * with exit 1 (cli.ts).
247
+ * to the person with exit 0 (cli.ts).
234
248
  */
235
249
  export declare function isClaudeHook(command: string | undefined, argument: string | undefined): boolean;
236
250
  /** The words for a `--root` that is a file, or is not there, wherever it is said. */
@@ -244,7 +258,7 @@ export interface LineFault {
244
258
  * What Claude Code's hook does with its own line, as far as the line decides
245
259
  * it: it guards, in the root the line gives it where it gives one and under
246
260
  * the brief it names where it names one; or the guard is off, for a fault
247
- * the hook says with exit 1 and holds no write for; or every write is held,
261
+ * the hook says with exit 0 and holds no write for; or every write is held,
248
262
  * exit 2, for a brief the line names that is not one (ADR-0005). The hook
249
263
  * reads its line through the functions below, and `doctor` reads a line it
250
264
  * finds in Claude Code's settings through the same ones, so what it says of
@@ -284,9 +298,9 @@ export interface GuardOff {
284
298
  readonly state: 'off';
285
299
  readonly fault: LineFault;
286
300
  /**
287
- * Set where the hook says nothing of the fault and exits 0, so that no
288
- * run of it fails: a root outside any work tree, or in a work tree that
289
- * does not hold the project. Every other fault it says, with exit 1.
301
+ * Set where the hook says nothing of the fault: a root outside any work
302
+ * tree, or in a work tree that does not hold the project. Every other
303
+ * fault it says, to the person, and every one with exit 0.
290
304
  */
291
305
  readonly silent?: true;
292
306
  /**
@@ -326,6 +340,25 @@ export declare function unreadHookLine(said: string): GuardOff;
326
340
  * someone had configured was off.
327
341
  */
328
342
  export declare function readHookLine(root: string | undefined, cwd: string, isDirectory: (path: string) => boolean): Guards | GuardOff;
343
+ /**
344
+ * What `--help` or `--version` on the hook's line stands beside that the
345
+ * hook would say as it runs, in the words for a line it cannot read; `null`
346
+ * where the line asks for neither, or stands beside nothing the line itself
347
+ * shows to be wrong. Every command answers either before it runs, so the
348
+ * hook printed the help over a root that is not a directory, exit 0, and
349
+ * said nothing of it (ADR-0005). A fault only git or spec-brief can tell
350
+ * stays unsaid: neither is asked before an answer that runs nothing.
351
+ */
352
+ export declare function answerBeside(line: Pick<CommandLine, 'help' | 'version' | 'root'>, cwd: string, isDirectory: (path: string) => boolean): string | null;
353
+ /**
354
+ * A line of the hook's that asks for the help or the version, which the hook
355
+ * prints, exit 0, in place of reading its question: no write is checked
356
+ * under it, and Claude Code, by its hooks reference, writes what a hook
357
+ * that exits 0 prints to its debug log, so nothing says the guard is off
358
+ * (ADR-0005). The hook says nothing of it, since a person who asks the hook
359
+ * for its help at a terminal is answered the same way.
360
+ */
361
+ export declare function answeringHookLine(asked: 'help' | 'version'): GuardOff;
329
362
  /**
330
363
  * A line whose root is a directory outside any git work tree. The hook
331
364
  * reads that as a session outside one, where it has nothing to guard: it
@@ -367,7 +400,9 @@ export declare function holderHookLine(line: Guards, tree: OtherTree, path: stri
367
400
  * every write under one that names no brief, which only spec-brief can tell.
368
401
  * A line with two values for an option that takes one is a line the hook
369
402
  * cannot read, whichever option it is, read from `cwd` as the hook reads it
370
- * from where it is started (cli.ts).
403
+ * from where it is started (cli.ts). So is one with the help or the version
404
+ * beside a root that is not a directory; and with either beside nothing it
405
+ * can see is wrong, the line checks no write, whatever else it holds.
371
406
  */
372
407
  export declare function claudeHookLine(words: readonly string[], parse: (argv: readonly string[]) => CommandLine, cwd: string, isDirectory: (path: string) => boolean): HookLine | null;
373
408
  /**
package/dist/usage.js CHANGED
@@ -44,19 +44,33 @@ const VALUED = literal({
44
44
  show: { of: every('show'), says: 'names no escalation; give it an id, as spec-harness escalate --list prints them' },
45
45
  id: { of: every('id'), says: "names no probe; give it a probe's id, or leave it out to run every probe" },
46
46
  });
47
+ /**
48
+ * A resolved path with its drive letter in one case. On Windows a drive
49
+ * letter names its volume in either case, `D:\` and `d:\` alike (Microsoft's
50
+ * Naming Files, Paths, and Namespaces, Naming Conventions, read 2026-10-19),
51
+ * where Node's `resolve` keeps the case it was written in (spec-core
52
+ * ADR-0005, amended 2026-10-19). The rest of the path is left as it is: NTFS
53
+ * can be made to tell names apart by case, so whether two names are one
54
+ * directory is the disk's to say. A path resolved elsewhere starts with `/`,
55
+ * and has no drive letter to fold.
56
+ */
57
+ export function foldDrive(path) {
58
+ return /^[a-z]:/.test(path) ? `${path[0].toUpperCase()}${path.slice(1)}` : path;
59
+ }
47
60
  /**
48
61
  * Whether two values of an option that takes one ask for one thing, as the
49
62
  * option reads them: a brief by its id as spec-brief's names are matched,
50
63
  * space around it dropped and `12` the brief `012` (ADR-0004); a root by the
51
64
  * directory it names from `cwd`, the one an empty root names on the hook's
52
- * line among them; any other as it is written, since each is read so, a
53
- * format, a probe's position, an id or text.
65
+ * line among them, with a drive letter in either case; any other as it is
66
+ * written, since each is read so, a format, a probe's position, an id or
67
+ * text.
54
68
  */
55
69
  function asksOne(option, first, other, cwd) {
56
70
  if (option === 'brief')
57
71
  return sameId(first.trim(), other.trim());
58
72
  if (option === 'root')
59
- return resolve(cwd, first) === resolve(cwd, other);
73
+ return foldDrive(resolve(cwd, first)) === foldDrive(resolve(cwd, other));
60
74
  return first === other;
61
75
  }
62
76
  /**
@@ -125,7 +139,7 @@ const SHAPE = literal({
125
139
  audit: { options: ['brief', 'base', 'root', 'format', 'strict'], ...ONE_BRIEF },
126
140
  probe: { options: ['brief', 'base', 'root', 'format', 'id', 'at'], ...ONE_BRIEF },
127
141
  premises: { options: ['brief', 'root', 'format', 'strict'], ...NO_ARGUMENT },
128
- init: { options: ['root', 'format', 'write', 'git-hook'], ...NO_ARGUMENT },
142
+ init: { options: ['root', 'format', 'write', 'git-hook', 'block-on-failure'], ...NO_ARGUMENT },
129
143
  mcp: { options: ['root'], ...NO_ARGUMENT },
130
144
  // A diagnosis says what is wrong with what it was given, where a command
131
145
  // that acts on it stops: a brief or a base that names nothing is a line of
@@ -151,7 +165,7 @@ function listed(options) {
151
165
  * every release, which this one cannot rewrite, and exit 2 from it holds
152
166
  * every write of a session, the write that would fix the line among them.
153
167
  * A line the parser cannot read is asked the same, and the hook says it
154
- * with exit 1 (cli.ts).
168
+ * to the person with exit 0 (cli.ts).
155
169
  */
156
170
  export function isClaudeHook(command, argument) {
157
171
  return command === 'hook' && argument === 'claude';
@@ -199,6 +213,42 @@ export function readHookLine(root, cwd, isDirectory) {
199
213
  }
200
214
  return { state: 'guards', root: given };
201
215
  }
216
+ /**
217
+ * What `--help` or `--version` on the hook's line stands beside that the
218
+ * hook would say as it runs, in the words for a line it cannot read; `null`
219
+ * where the line asks for neither, or stands beside nothing the line itself
220
+ * shows to be wrong. Every command answers either before it runs, so the
221
+ * hook printed the help over a root that is not a directory, exit 0, and
222
+ * said nothing of it (ADR-0005). A fault only git or spec-brief can tell
223
+ * stays unsaid: neither is asked before an answer that runs nothing.
224
+ */
225
+ export function answerBeside(line, cwd, isDirectory) {
226
+ if (!line.help && !line.version)
227
+ return null;
228
+ const read = readHookLine(line.root, cwd, isDirectory);
229
+ if (read.state === 'guards')
230
+ return null;
231
+ const asked = line.help ? 'help' : 'version';
232
+ return `--${asked} is given beside a fault, ${read.fault.wrong}, and the ${asked} would be printed in its place`;
233
+ }
234
+ /**
235
+ * A line of the hook's that asks for the help or the version, which the hook
236
+ * prints, exit 0, in place of reading its question: no write is checked
237
+ * under it, and Claude Code, by its hooks reference, writes what a hook
238
+ * that exits 0 prints to its debug log, so nothing says the guard is off
239
+ * (ADR-0005). The hook says nothing of it, since a person who asks the hook
240
+ * for its help at a terminal is answered the same way.
241
+ */
242
+ export function answeringHookLine(asked) {
243
+ return {
244
+ state: 'off',
245
+ silent: true,
246
+ fault: {
247
+ wrong: `the line asks for the ${asked}, which the hook prints, exit 0, in place of checking the write`,
248
+ mend: 'take that option off the line, where init writes "hook claude" and nothing after it',
249
+ },
250
+ };
251
+ }
202
252
  /**
203
253
  * A line whose root is a directory outside any git work tree. The hook
204
254
  * reads that as a session outside one, where it has nothing to guard: it
@@ -261,7 +311,9 @@ export function holderHookLine(line, tree, path) {
261
311
  * every write under one that names no brief, which only spec-brief can tell.
262
312
  * A line with two values for an option that takes one is a line the hook
263
313
  * cannot read, whichever option it is, read from `cwd` as the hook reads it
264
- * from where it is started (cli.ts).
314
+ * from where it is started (cli.ts). So is one with the help or the version
315
+ * beside a root that is not a directory; and with either beside nothing it
316
+ * can see is wrong, the line checks no write, whatever else it holds.
265
317
  */
266
318
  export function claudeHookLine(words, parse, cwd, isDirectory) {
267
319
  let line;
@@ -277,6 +329,13 @@ export function claudeHookLine(words, parse, cwd, isDirectory) {
277
329
  const twice = twoValues(line, cwd);
278
330
  if (twice !== null)
279
331
  return unreadHookLine(twice);
332
+ const beside = answerBeside(line, cwd, isDirectory);
333
+ if (beside !== null)
334
+ return unreadHookLine(beside);
335
+ if (line.help)
336
+ return answeringHookLine('help');
337
+ if (line.version)
338
+ return answeringHookLine('version');
280
339
  const read = readHookLine(line.root, cwd, isDirectory);
281
340
  return read.state === 'guards' && line.brief !== undefined && !isBlank(line.brief) ? { ...read, brief: line.brief } : read;
282
341
  }