@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +325 -0
- package/README.md +39 -18
- package/dist/briefs.d.ts +45 -0
- package/dist/briefs.js +73 -0
- package/dist/briefs.js.map +1 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +308 -142
- package/dist/cli.js.map +1 -1
- package/dist/commands.js +7 -2
- package/dist/commands.js.map +1 -1
- package/dist/config.d.ts +13 -1
- package/dist/config.js +14 -3
- package/dist/config.js.map +1 -1
- package/dist/configure.d.ts +94 -3
- package/dist/configure.js +204 -30
- package/dist/configure.js.map +1 -1
- package/dist/fs.d.ts +104 -1
- package/dist/fs.js +383 -11
- package/dist/fs.js.map +1 -1
- package/dist/git.d.ts +88 -2
- package/dist/git.js +155 -8
- package/dist/git.js.map +1 -1
- package/dist/guard.d.ts +20 -1
- package/dist/guard.js +31 -0
- package/dist/guard.js.map +1 -1
- package/dist/hooks.d.ts +59 -0
- package/dist/hooks.js +76 -3
- package/dist/hooks.js.map +1 -1
- package/dist/round.d.ts +27 -1
- package/dist/round.js +75 -17
- package/dist/round.js.map +1 -1
- package/dist/sandbox.js +4 -3
- package/dist/sandbox.js.map +1 -1
- package/dist/server.d.ts +25 -1
- package/dist/server.js +230 -28
- package/dist/server.js.map +1 -1
- package/dist/setup.js +40 -10
- package/dist/setup.js.map +1 -1
- package/dist/siblings.d.ts +29 -6
- package/dist/siblings.js +152 -39
- package/dist/siblings.js.map +1 -1
- package/dist/usage.d.ts +42 -7
- package/dist/usage.js +65 -6
- package/dist/usage.js.map +1 -1
- package/dist/workspace.d.ts +45 -3
- package/dist/workspace.js +143 -13
- package/dist/workspace.js.map +1 -1
- 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
|
|
72
|
-
*
|
|
73
|
-
* script
|
|
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,
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
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
|
-
|
|
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
|
};
|
package/dist/siblings.js.map
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
288
|
-
*
|
|
289
|
-
*
|
|
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
|
|
53
|
-
* format, a probe's position, an id or
|
|
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
|
|
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
|
}
|