@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/cli.js
CHANGED
|
@@ -20,13 +20,13 @@
|
|
|
20
20
|
import { SiblingOutputError } from './briefs.js';
|
|
21
21
|
import { CONFIG_FILE, ConfigError, SIBLINGS, SPEC_BRIEF_CONFIGS } from './config.js';
|
|
22
22
|
import { dirname, join } from 'node:path';
|
|
23
|
-
import { gitDirectories,
|
|
24
|
-
import { GitError, show, stagedChanges } from './git.js';
|
|
25
|
-
import { afterResponse, callFile, callRecord, claudeResponse, gitResponse, keptCall, parseClaudeHook, SESSION_START, sessionResponse, warningsKept, warningsOf } from './hooks.js';
|
|
26
|
-
import { SiblingError } from './siblings.js';
|
|
27
|
-
import { emptyValue, heldHookLine, isBlank, isClaudeHook, readHookLine, refusal, twoValues, unreadHookLine } from './usage.js';
|
|
23
|
+
import { gitDirectories, isDirectory, keepCall, loadConfig, mainWorkTree, outsideEvery, reachedBy, StateError, takeCall, writesOnly } from './fs.js';
|
|
24
|
+
import { changedPaths, GitError, holdsPath, isShallow, show, stagedChanges, withBudget } from './git.js';
|
|
25
|
+
import { afterResponse, callFile, callRecord, claudeResponse, gitResponse, HOOK_BUDGET_MS, keptCall, mendResponse, NONE, parseClaudeHook, SESSION_START, sessionOf, sessionResponse, TURN_END, turnResponse, warningResponse, warningsKept, warningsOf } from './hooks.js';
|
|
26
|
+
import { declaredVersion, SiblingError } from './siblings.js';
|
|
27
|
+
import { answerBeside, emptyValue, heldHookLine, isBlank, isClaudeHook, readHookLine, refusal, twoValues, unreadHookLine } from './usage.js';
|
|
28
28
|
import { CLAUDE_CODE_MINIMUM, MINIMUM_VERSIONS } from './versions.js';
|
|
29
|
-
import { activeBrief, answerOf, describeActive, EXIT_ERROR, EXIT_FAILED, EXIT_OK,
|
|
29
|
+
import { activeBrief, answerOf, describeActive, EXIT_ERROR, EXIT_FAILED, EXIT_OK, json, MAIN_INSTALL, mayBeElsewhere, openFound, openWorkspace, otherTree, parseOptions, UsageError, version, writesBy, } from './workspace.js';
|
|
30
30
|
export { EXIT_ERROR, EXIT_FAILED, EXIT_OK, UsageError, version } from './workspace.js';
|
|
31
31
|
export const HELP = `spec-harness - context, guards, audits and rulings for rounds of agent work
|
|
32
32
|
|
|
@@ -37,8 +37,10 @@ Commands:
|
|
|
37
37
|
context [brief] What an agent needs to start a round: the brief, its scope,
|
|
38
38
|
rulings, dependencies, the rules in force, the cited documents.
|
|
39
39
|
guard <path...> May the round write these paths? Exit 1 if one is refused.
|
|
40
|
-
hook claude Answer a Claude Code PreToolUse, PostToolUse or
|
|
41
|
-
(stdin). At session start it says what would hold every
|
|
40
|
+
hook claude Answer a Claude Code PreToolUse, PostToolUse, SessionStart or Stop
|
|
41
|
+
hook (stdin). At session start it says what would hold every
|
|
42
|
+
write; at a turn's end, with "turnReport" on, what the work tree
|
|
43
|
+
holds that the brief protects or that is outside its scope.
|
|
42
44
|
hook git Answer git's pre-commit hook for the staged files.
|
|
43
45
|
escalate Ask a person to rule: --path <file> (repeatable) --reason <why>
|
|
44
46
|
[--option "<label>: <cost>"]... [--recommend <text>].
|
|
@@ -55,17 +57,24 @@ Commands:
|
|
|
55
57
|
premises Do the live briefs' premises still hold? For CI: exit 1 when one
|
|
56
58
|
is stale, or, with --strict, when spec-guard cannot read one.
|
|
57
59
|
init Configure the spec-* tools to agree. Prints the plan; --write
|
|
58
|
-
applies it; --git-hook adds a pre-commit hook
|
|
60
|
+
applies it; --git-hook adds a pre-commit hook; --block-on-failure
|
|
61
|
+
has Claude Code 2.1.295 or later hold a write when the hook cannot
|
|
62
|
+
start or runs past its timeout.
|
|
59
63
|
mcp Serve start_round, check_path, request_escalation, audit_round,
|
|
60
64
|
list_rounds and the workflow prompts over MCP on stdio.
|
|
61
65
|
doctor Which siblings are installed, at which versions, which brief is
|
|
62
66
|
named, how Claude Code runs the guard, and git's hook. Exit 1
|
|
63
67
|
when a sibling is older than this release needs, the guard is
|
|
64
68
|
installed twice, its hook's line turns it off or holds every
|
|
65
|
-
write
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
+
write or runs a script that is not there, the branch names a
|
|
70
|
+
brief spec-brief does not list, cannot be asked about, or has
|
|
71
|
+
archived, but for the branch git says archived it, a round's
|
|
72
|
+
last commit, which waits to merge (CI fetches the base with its
|
|
73
|
+
history for git to say), disableAllHooks or an array for a
|
|
74
|
+
matcher switches it off, the plugin that is turned on is not on
|
|
75
|
+
this machine and nothing else guards, Claude Code is too old to
|
|
76
|
+
run its hooks, or git stops every commit at what stands for its
|
|
77
|
+
hook.
|
|
69
78
|
|
|
70
79
|
Options:
|
|
71
80
|
--brief <id> The brief the round works on. Otherwise SPEC_BRIEF, then the branch.
|
|
@@ -78,16 +87,19 @@ Options:
|
|
|
78
87
|
--format <fmt> pretty or json; audit and premises also gitlab, sarif or github.
|
|
79
88
|
For every command but hook and mcp.
|
|
80
89
|
--strict Warnings fail the run. For guard, audit, premises and doctor.
|
|
90
|
+
--block-on-failure onFailure "block" on init's entries but the one as a turn ends. For init.
|
|
81
91
|
--help, --version One of the two: both on one line are refused.
|
|
82
92
|
|
|
83
93
|
An option a command does not read, an argument it does not take, an option or an
|
|
84
94
|
argument given an empty value, a brief given both as the argument and as --brief,
|
|
85
95
|
and an option that takes one value given two that name two things are refused, exit 2,
|
|
86
96
|
not read as if they were not there, and so with --help or --version on the line. The
|
|
87
|
-
same value twice, as --root . --root ./, is read once
|
|
88
|
-
--base in its report. hook claude reads its
|
|
89
|
-
it cannot read, two values for one option
|
|
90
|
-
directory
|
|
97
|
+
same value twice, as --root . --root ./, is read once, and on Windows a drive letter in
|
|
98
|
+
either case. doctor says an empty --brief or --base in its report. hook claude reads its
|
|
99
|
+
line as every release has, and says a line it cannot read, two values for one option or
|
|
100
|
+
--help beside a --root that is not a directory among them, a --root that is not a
|
|
101
|
+
directory, and a question it cannot read, as a warning to the person with exit 0: it
|
|
102
|
+
refuses nothing for any of them.
|
|
91
103
|
|
|
92
104
|
Exit codes: 0 clean, 1 refused or found something, 2 the answer cannot be trusted.
|
|
93
105
|
`;
|
|
@@ -97,11 +109,38 @@ Exit codes: 0 clean, 1 refused or found something, 2 the answer cannot be truste
|
|
|
97
109
|
* to a file no brief protects reads none.
|
|
98
110
|
*/
|
|
99
111
|
const reader = async () => (await import('./reader.js')).createReader();
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
112
|
+
/**
|
|
113
|
+
* The work tree with spec-brief's answers kept for a session of Claude
|
|
114
|
+
* Code's, and given again to it where all they were read from reads the
|
|
115
|
+
* same (ADR-0002); as it is where no session is named.
|
|
116
|
+
*/
|
|
117
|
+
function keptFor(workspace, session) {
|
|
118
|
+
return session === null ? workspace : { ...workspace, siblings: workspace.siblings.keeping(workspace.commonDir, session) };
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Whether the brief the branch names is one spec-brief has archived: a
|
|
122
|
+
* closed round's branch, whose every write is held as under any name that is
|
|
123
|
+
* no brief's, and in words of its own, since nothing on it is to be mended:
|
|
124
|
+
* its round ends when the branch merges (ADR-0004).
|
|
125
|
+
*/
|
|
126
|
+
function closesRound(active) {
|
|
127
|
+
return active.kind === 'archived' && active.source === 'branch';
|
|
128
|
+
}
|
|
129
|
+
/** What the hook says after what holds the write: how to mend it, or that a closed round's branch has nothing to mend. */
|
|
130
|
+
const MEND = 'Fix the brief or the branch before writing.';
|
|
131
|
+
const CLOSED = 'The round is archived and ends when this branch merges: write on the branch of a live brief.';
|
|
132
|
+
async function decisionsFor(workspace, options, io, given, cwd, session = null) {
|
|
133
|
+
// A path outside every work tree of the repository is decided as one
|
|
134
|
+
// outside it, whichever brief is named and whatever is wrong with the
|
|
135
|
+
// name: the problem held every write, a file outside the repository
|
|
136
|
+
// among them, where no brief decides one (ADR-0005). So no brief is asked
|
|
137
|
+
// about for such a write, and spec-brief is not run for it.
|
|
138
|
+
const outside = outsideEvery(given, workspace.root, workspace.commonDir, cwd);
|
|
139
|
+
const active = outside ? null : await activeBrief(keptFor(workspace, session), options, io.env);
|
|
140
|
+
const { brief, note, problem } = active === null ? { brief: null, note: undefined, problem: null } : describeActive(active);
|
|
141
|
+
// A problem is said only of a brief something names, so `active` is one.
|
|
103
142
|
if (problem !== null)
|
|
104
|
-
return { problem };
|
|
143
|
+
return { problem, closed: closesRound(active) };
|
|
105
144
|
const { checkPaths } = await import('./round.js');
|
|
106
145
|
return { decisions: await checkPaths(workspace, brief, note, given, cwd, reader, options.base), brief };
|
|
107
146
|
}
|
|
@@ -158,10 +197,11 @@ async function readStdin(io) {
|
|
|
158
197
|
}
|
|
159
198
|
/**
|
|
160
199
|
* The write Claude Code asks the hook about, or the session that starts, or
|
|
161
|
-
* the exit code that answers already:
|
|
162
|
-
*
|
|
163
|
-
* line is, so that a line that is wrong is said
|
|
164
|
-
* leaves unchecked, or a session whose every write
|
|
200
|
+
* the exit code that answers already, 0: for a question that cannot be read,
|
|
201
|
+
* said to the person, and for a tool that writes no file. Asked before
|
|
202
|
+
* anything on the hook's own line is, so that a line that is wrong is said
|
|
203
|
+
* where there is a write it leaves unchecked, or a session whose every write
|
|
204
|
+
* it leaves so.
|
|
165
205
|
*/
|
|
166
206
|
async function writeAsked(io) {
|
|
167
207
|
let question;
|
|
@@ -171,34 +211,61 @@ async function writeAsked(io) {
|
|
|
171
211
|
catch (error) {
|
|
172
212
|
// A question that could not be read is one the hook cannot see, as one
|
|
173
213
|
// it cannot parse is, below: it has no event to hold a write by.
|
|
174
|
-
io
|
|
175
|
-
return EXIT_FAILED;
|
|
214
|
+
return answer(io, warningResponse(`the hook input could not be read: ${error.message}, so nothing it asks about is checked`));
|
|
176
215
|
}
|
|
177
216
|
const request = parseClaudeHook(question);
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
io
|
|
182
|
-
|
|
183
|
-
}
|
|
184
|
-
return request.paths.length === 0 && request.event !== SESSION_START ? EXIT_OK : request;
|
|
217
|
+
// A hook that cannot read its question refuses nothing it cannot see, and
|
|
218
|
+
// says so where the person reads it.
|
|
219
|
+
if ('error' in request)
|
|
220
|
+
return answer(io, warningResponse(`${request.error}, so nothing it asks about is checked`));
|
|
221
|
+
return request.paths.length === 0 && request.event !== SESSION_START && request.event !== TURN_END ? EXIT_OK : request;
|
|
185
222
|
}
|
|
186
223
|
/**
|
|
187
224
|
* What is wrong with the hook's own line, said and nothing refused for it:
|
|
188
|
-
* one line,
|
|
189
|
-
*
|
|
190
|
-
*
|
|
225
|
+
* one line, shown to the person, and exit 0, where exit 2 would hold every
|
|
226
|
+
* write of the session, the one that mends the line among them. Exit 1 said
|
|
227
|
+
* it as well, and is what Claude Code counts as a hook that failed, which
|
|
228
|
+
* `onFailure: "block"` holds the write for (ADR-0005).
|
|
191
229
|
*/
|
|
192
230
|
function guardOff(io, { wrong, mend }, event) {
|
|
193
231
|
if (event === SESSION_START)
|
|
194
232
|
return answer(io, sessionResponse(`${wrong}, so the guard is off and no write of this session is checked; ${mend}`));
|
|
195
|
-
|
|
196
|
-
|
|
233
|
+
if (event === TURN_END)
|
|
234
|
+
return answer(io, turnResponse(`spec-harness: ${wrong}, so the guard is off and no write of this session is checked; ${mend}`));
|
|
235
|
+
return answer(io, warningResponse(`${wrong}, so the guard is off and this write is not checked; ${mend}`));
|
|
197
236
|
}
|
|
198
237
|
function answer(io, response) {
|
|
199
238
|
io.stdout.write(response.stdout);
|
|
200
239
|
return response.exitCode;
|
|
201
240
|
}
|
|
241
|
+
/**
|
|
242
|
+
* The answer to a write a configuration that does not load holds no more,
|
|
243
|
+
* or `null` for every other write, which is held as before (ADR-0005). Every
|
|
244
|
+
* write was held. A write outside every work tree of the repository is
|
|
245
|
+
* decided as such a write is, after the write as before it: nothing of the
|
|
246
|
+
* repository's decides it. And before a write to the configuration file
|
|
247
|
+
* itself, and to nothing else, the line says why every other write is held,
|
|
248
|
+
* so that a session can mend a typo in it. `cwd` is what a relative path
|
|
249
|
+
* is read from.
|
|
250
|
+
*/
|
|
251
|
+
function mending(error, request, cwd, env) {
|
|
252
|
+
// Every configuration the hook loads is read from a file, so `file` is
|
|
253
|
+
// never `null` here, and the test of it is the compiler's: its mutant is
|
|
254
|
+
// equivalent.
|
|
255
|
+
if (!(error instanceof ConfigError) || error.file === null)
|
|
256
|
+
return null;
|
|
257
|
+
// The work trees are named by the files git keeps, where git would name
|
|
258
|
+
// the same ones; a repository whose configuration or environment moves a
|
|
259
|
+
// work tree elsewhere is not known to leave the path outside. The file
|
|
260
|
+
// was loaded from the top git named, where a `.git` stands, so the files
|
|
261
|
+
// name a work tree, and the test of `null` is the compiler's.
|
|
262
|
+
const found = gitDirectories(dirname(error.file), env);
|
|
263
|
+
if (found !== null && found.exact && outsideEvery(request.paths, found.top, found.common, cwd))
|
|
264
|
+
return NONE;
|
|
265
|
+
if (request.event !== 'PreToolUse' || !writesOnly(request.paths, error.file, cwd))
|
|
266
|
+
return null;
|
|
267
|
+
return mendResponse(`${error.message}, so every other write is held until it loads; this write, to ${error.file} itself, is not checked`);
|
|
268
|
+
}
|
|
202
269
|
/**
|
|
203
270
|
* What would hold every write of the session that starts, said once as it
|
|
204
271
|
* starts and no write waits on it (ADR-0005): a configuration that does not
|
|
@@ -208,7 +275,7 @@ function answer(io, response) {
|
|
|
208
275
|
* `doctor` says, and with what mends it. `null` where nothing does. The
|
|
209
276
|
* check never ends the session's start: whatever it meets is said.
|
|
210
277
|
*/
|
|
211
|
-
async function sessionProblem(options, io, root, cwd) {
|
|
278
|
+
async function sessionProblem(options, io, root, cwd, session) {
|
|
212
279
|
const held = 'so every write of this session is held until it is mended';
|
|
213
280
|
try {
|
|
214
281
|
const named = await openWorkspace({ ...options, root }, io);
|
|
@@ -216,8 +283,13 @@ async function sessionProblem(options, io, root, cwd) {
|
|
|
216
283
|
// there, and so is the check (ADR-0005).
|
|
217
284
|
const found = cwd === null ? null : await otherTree(named, cwd);
|
|
218
285
|
const workspace = found === null ? named : await openFound(found, io, () => [named.root]);
|
|
219
|
-
const
|
|
220
|
-
|
|
286
|
+
const active = await activeBrief(keptFor(workspace, session), options, io.env);
|
|
287
|
+
const { problem } = describeActive(active);
|
|
288
|
+
if (problem === null)
|
|
289
|
+
return null;
|
|
290
|
+
if (closesRound(active))
|
|
291
|
+
return `${heldHookLine(problem).wrong}, so every write of this session is held: the round is archived and ends when this branch merges, so write on the branch of a live brief; spec-harness doctor says more`;
|
|
292
|
+
return `${heldHookLine(problem).wrong}, ${held}: fix the brief or the branch before writing; spec-harness doctor says more`;
|
|
221
293
|
}
|
|
222
294
|
catch (error) {
|
|
223
295
|
// A session in no work tree has nothing to guard, as a write there has
|
|
@@ -227,7 +299,57 @@ async function sessionProblem(options, io, root, cwd) {
|
|
|
227
299
|
// briefs, and whatever a defect throws.
|
|
228
300
|
if (error instanceof UsageError)
|
|
229
301
|
return null;
|
|
230
|
-
|
|
302
|
+
// But a write to the configuration file itself, which mends it, where
|
|
303
|
+
// the file is one a write may reach under its name alone (ADR-0005). The
|
|
304
|
+
// configuration is read from a file here as before a write, so the test
|
|
305
|
+
// of `file` is the compiler's, and its mutant is equivalent.
|
|
306
|
+
const mendable = error instanceof ConfigError && error.file !== null && writesOnly([error.file], error.file, root);
|
|
307
|
+
return `${error.message}, ${held}${mendable ? `, but for a write to ${error.file} itself, which is not checked` : ''}; spec-harness doctor says more`;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/** The most paths a report names one by one: past it, how many more there are. */
|
|
311
|
+
const REPORTED = 20;
|
|
312
|
+
/**
|
|
313
|
+
* What the work tree holds at the end of a turn that the brief protects or
|
|
314
|
+
* that is outside its scope, one line a path in the words a write of it is
|
|
315
|
+
* given; or `null` where there is nothing to say: `turnReport` is off, no
|
|
316
|
+
* brief is named, nothing changed is either, or the session is in no work
|
|
317
|
+
* tree (ADR-0005). The work tree is the one a write of the session is
|
|
318
|
+
* decided in. One start of git reads what changed, and nothing is asked of
|
|
319
|
+
* the base, so no ruling is read: a protected file a signed ruling allows
|
|
320
|
+
* is said as protected, and the audit reads the ruling. A configuration
|
|
321
|
+
* that does not load is said by every write and as the session starts, and
|
|
322
|
+
* here holds the report back without a word.
|
|
323
|
+
*/
|
|
324
|
+
async function turnReport(options, io, root, cwd) {
|
|
325
|
+
try {
|
|
326
|
+
const named = await openWorkspace({ ...options, root }, io);
|
|
327
|
+
const found = cwd === null ? null : await otherTree(named, cwd);
|
|
328
|
+
const workspace = found === null ? named : await openFound(found, io, () => [named.root]);
|
|
329
|
+
if (!workspace.config.turnReport)
|
|
330
|
+
return null;
|
|
331
|
+
const active = await activeBrief(workspace, options, io.env);
|
|
332
|
+
const { brief, problem } = describeActive(active);
|
|
333
|
+
if (problem !== null) {
|
|
334
|
+
const ends = closesRound(active) ? ': the round is archived and ends when this branch merges' : '';
|
|
335
|
+
return `spec-harness: ${heldHookLine(problem).wrong}, so what the work tree holds is not reported, and every write is held but one to a file outside every work tree of the repository${ends}; spec-harness doctor says more`;
|
|
336
|
+
}
|
|
337
|
+
if (brief === null)
|
|
338
|
+
return null;
|
|
339
|
+
const [changed, { decide }] = await Promise.all([changedPaths(workspace.root), import('./guard.js')]);
|
|
340
|
+
const outOfScope = workspace.config.outOfScope;
|
|
341
|
+
const said = changed.map((path) => decide({ path, given: path, brief, rulings: [], outOfScope })).filter((decision) => decision.verdict !== 'allow');
|
|
342
|
+
if (said.length === 0)
|
|
343
|
+
return null;
|
|
344
|
+
const lines = said.slice(0, REPORTED).map((decision) => `- ${decision.message}. Next: ${decision.hint}.`);
|
|
345
|
+
const more = said.length > REPORTED ? [`- and ${said.length - REPORTED} more: spec-harness guard <path> says each`] : [];
|
|
346
|
+
const paths = said.length === 1 ? `a path that brief ${brief.id} protects or that is outside its scope` : `${said.length} paths that brief ${brief.id} protects or that are outside its scope`;
|
|
347
|
+
return [`spec-harness: as this turn ended, the work tree held changes to ${paths}:`, ...lines, ...more].join('\n');
|
|
348
|
+
}
|
|
349
|
+
catch (error) {
|
|
350
|
+
if (error instanceof UsageError || error instanceof ConfigError)
|
|
351
|
+
return null;
|
|
352
|
+
return `spec-harness: ${error.message}, so what the work tree holds is not reported`;
|
|
231
353
|
}
|
|
232
354
|
}
|
|
233
355
|
/**
|
|
@@ -243,79 +365,6 @@ async function unreadLine(said, io) {
|
|
|
243
365
|
return request;
|
|
244
366
|
return guardOff(io, unreadHookLine(said).fault, request.event);
|
|
245
367
|
}
|
|
246
|
-
/**
|
|
247
|
-
* Whether a work tree other than the one whose top is `top` may hold `cwd`:
|
|
248
|
-
* `cwd` is outside it, or a `.git` stands in a directory between the two,
|
|
249
|
-
* where a linked worktree, a submodule or a repository of its own starts.
|
|
250
|
-
* Where neither is so, `cwd` is in that work tree, and git is asked nothing
|
|
251
|
-
* more: a session works at its project's top, or below it, for nearly every
|
|
252
|
-
* write. Answering that one may is what git is then asked, so the mutant
|
|
253
|
-
* that always answers so is equivalent, at the cost of a process, and so
|
|
254
|
-
* is each that only answers so more often: a slice one short reaches the
|
|
255
|
-
* top itself, which holds a `.git`, and a part named in place of none is
|
|
256
|
-
* a directory no top holds.
|
|
257
|
-
*/
|
|
258
|
-
function mayBeElsewhere(cwd, top) {
|
|
259
|
-
const path = repositoryPath(cwd, top, cwd);
|
|
260
|
-
if (path === null)
|
|
261
|
-
return true;
|
|
262
|
-
const parts = path === '' ? [] : path.split('/');
|
|
263
|
-
return parts.some((_, index) => holdsGit(join(top, ...parts.slice(0, index + 1))));
|
|
264
|
-
}
|
|
265
|
-
/**
|
|
266
|
-
* The work tree that holds `cwd`, where it is another work tree of the
|
|
267
|
-
* repository the hook's root names, by the git directory the two share;
|
|
268
|
-
* `null` where `cwd` is in the root's own, in another repository, or in
|
|
269
|
-
* none (ADR-0005).
|
|
270
|
-
*/
|
|
271
|
-
async function otherTree(named, cwd) {
|
|
272
|
-
if (!mayBeElsewhere(cwd, named.root))
|
|
273
|
-
return null;
|
|
274
|
-
const found = await findTree(cwd);
|
|
275
|
-
// The project's own work tree, found again, would decide every path the
|
|
276
|
-
// same way once more: the check saves reading its configuration twice, and
|
|
277
|
-
// its mutant is equivalent.
|
|
278
|
-
if (found === null || found.root === named.root)
|
|
279
|
-
return null;
|
|
280
|
-
const common = await foundCommonDir(found);
|
|
281
|
-
// git names a common directory wherever it names a top, so `null` is
|
|
282
|
-
// there for the type.
|
|
283
|
-
if (common === null || realSpelling(common) !== realSpelling(named.commonDir))
|
|
284
|
-
return null;
|
|
285
|
-
return found;
|
|
286
|
-
}
|
|
287
|
-
/**
|
|
288
|
-
* The work trees a write is decided in, each with the paths it decides
|
|
289
|
-
* (ADR-0005). The repository is the one the hook's root names, as before,
|
|
290
|
-
* and so is the work tree, but where the question's `cwd` is in another
|
|
291
|
-
* work tree of that repository, linked or main, by the git directory the
|
|
292
|
-
* two share: there a session works in a worktree while Claude Code names
|
|
293
|
-
* the project it started in, and the hook decided by the project's branch,
|
|
294
|
-
* which named no brief, so a protected write passed with no word. That work
|
|
295
|
-
* tree decides the paths it holds, by its branch, its briefs and its
|
|
296
|
-
* configuration, with its siblings looked for in its install and then the
|
|
297
|
-
* project's. A path it does not hold is decided as before, by the work tree
|
|
298
|
-
* the root names: a write to the project's own file from a worktree is
|
|
299
|
-
* refused where it was. Where both hold a path, one is inside the other,
|
|
300
|
-
* and the inner one holds it as git reads it. A `cwd` in another
|
|
301
|
-
* repository, or in none, leaves every path where it was.
|
|
302
|
-
*/
|
|
303
|
-
async function writesBy(named, cwd, paths, io) {
|
|
304
|
-
const asBefore = [{ workspace: named, paths }];
|
|
305
|
-
if (cwd === null)
|
|
306
|
-
return asBefore;
|
|
307
|
-
const found = await otherTree(named, cwd);
|
|
308
|
-
if (found === null)
|
|
309
|
-
return asBefore;
|
|
310
|
-
const inner = repositoryPath(found.root, named.root, cwd) !== null;
|
|
311
|
-
const holds = (path) => repositoryPath(path, found.root, cwd) !== null && (inner || repositoryPath(path, named.root, cwd) === null);
|
|
312
|
-
const own = paths.filter(holds);
|
|
313
|
-
if (own.length === 0)
|
|
314
|
-
return asBefore;
|
|
315
|
-
const workspace = await openFound(found, io, () => [named.root]);
|
|
316
|
-
const rest = paths.filter((path) => !holds(path));
|
|
317
|
-
return [{ workspace, paths: own }, ...(rest.length === 0 ? [] : [{ workspace: named, paths: rest }])];
|
|
318
|
-
}
|
|
319
368
|
/**
|
|
320
369
|
* The answer after a write, where it can be given with no process started,
|
|
321
370
|
* or `null` where it is to be decided as before (ADR-0002, ADR-0003).
|
|
@@ -329,7 +378,7 @@ async function writesBy(named, cwd, paths, io) {
|
|
|
329
378
|
* `deny` there is nothing to say, where the configuration read is the one
|
|
330
379
|
* git would have the hook read, of the work tree the session is in.
|
|
331
380
|
*/
|
|
332
|
-
async function answeredAfter(root, record, cwd, io) {
|
|
381
|
+
async function answeredAfter(root, record, cwd, paths, io) {
|
|
333
382
|
const found = gitDirectories(root, io.env);
|
|
334
383
|
if (found === null)
|
|
335
384
|
return null;
|
|
@@ -341,7 +390,10 @@ async function answeredAfter(root, record, cwd, io) {
|
|
|
341
390
|
if (warnings !== null)
|
|
342
391
|
return afterResponse(warnings);
|
|
343
392
|
}
|
|
344
|
-
|
|
393
|
+
// A path in another work tree than the one this configuration is read
|
|
394
|
+
// from may be decided by another's, which may warn, and so may the file a
|
|
395
|
+
// link has a path written to.
|
|
396
|
+
if (!found.exact || (cwd !== null && mayBeElsewhere(cwd, found.top)) || paths.some((path) => reachedBy(path, cwd ?? io.cwd).some((spelled) => mayBeElsewhere(spelled, found.top))))
|
|
345
397
|
return null;
|
|
346
398
|
try {
|
|
347
399
|
return (await loadConfig(found.top)).config.outOfScope === 'warn' ? null : afterResponse([]);
|
|
@@ -351,7 +403,25 @@ async function answeredAfter(root, record, cwd, io) {
|
|
|
351
403
|
return null;
|
|
352
404
|
}
|
|
353
405
|
}
|
|
354
|
-
|
|
406
|
+
/**
|
|
407
|
+
* The answer before a write the hook could not check: one line, and exit 2,
|
|
408
|
+
* which holds the write. Claude Code shows the line to Claude as the
|
|
409
|
+
* reason.
|
|
410
|
+
*/
|
|
411
|
+
function cannotCheck(io, error) {
|
|
412
|
+
io.stderr.write(`spec-harness: cannot check this write: ${error.message}\n`);
|
|
413
|
+
return EXIT_ERROR;
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* `hook claude` within one budget for every process it starts, below the
|
|
417
|
+
* timeout Claude Code gives its entry, so that a run of slow processes ends
|
|
418
|
+
* with its own answer and not the cancelling of it (ADR-0002); `hook git`
|
|
419
|
+
* as it was, which no entry of Claude Code's runs.
|
|
420
|
+
*/
|
|
421
|
+
function hookCommand(options, io) {
|
|
422
|
+
return options.positionals[0] === 'claude' ? withBudget(HOOK_BUDGET_MS, () => answerHook(options, io)) : answerHook(options, io);
|
|
423
|
+
}
|
|
424
|
+
async function answerHook(options, io) {
|
|
355
425
|
const kind = options.positionals[0];
|
|
356
426
|
if (kind === 'claude') {
|
|
357
427
|
const request = await writeAsked(io);
|
|
@@ -364,11 +434,14 @@ async function hookCommand(options, io) {
|
|
|
364
434
|
return guardOff(io, line.fault, request.event);
|
|
365
435
|
const cwd = request.cwd ?? io.cwd;
|
|
366
436
|
const root = line.root ?? io.env['CLAUDE_PROJECT_DIR'] ?? cwd;
|
|
437
|
+
const session = sessionOf(request);
|
|
367
438
|
if (request.event === SESSION_START)
|
|
368
|
-
return answer(io, sessionResponse(await sessionProblem(options, io, root, request.cwd)));
|
|
439
|
+
return answer(io, sessionResponse(await sessionProblem(options, io, root, request.cwd, session)));
|
|
440
|
+
if (request.event === TURN_END)
|
|
441
|
+
return answer(io, turnResponse(await turnReport(options, io, root, request.cwd)));
|
|
369
442
|
const record = callRecord(request, { root: line.root, brief: options.brief, base: options.base }, io.cwd, io.env, version());
|
|
370
443
|
if (request.event === 'PostToolUse') {
|
|
371
|
-
const answered = await answeredAfter(root, record, request.cwd, io);
|
|
444
|
+
const answered = await answeredAfter(root, record, request.cwd, request.paths, io);
|
|
372
445
|
if (answered !== null) {
|
|
373
446
|
io.stdout.write(answered.stdout);
|
|
374
447
|
return answered.exitCode;
|
|
@@ -381,6 +454,14 @@ async function hookCommand(options, io) {
|
|
|
381
454
|
catch (error) {
|
|
382
455
|
if (error instanceof UsageError)
|
|
383
456
|
return EXIT_OK;
|
|
457
|
+
const mended = mending(error, request, cwd, io.env);
|
|
458
|
+
if (mended !== null)
|
|
459
|
+
return answer(io, mended);
|
|
460
|
+
// git asked where the work tree is and did not answer within its time
|
|
461
|
+
// limit: before a write that is a write the hook could not check, said
|
|
462
|
+
// as one (ADR-0002).
|
|
463
|
+
if (error instanceof GitError && request.event === 'PreToolUse')
|
|
464
|
+
return cannotCheck(io, error);
|
|
384
465
|
throw error;
|
|
385
466
|
}
|
|
386
467
|
try {
|
|
@@ -392,12 +473,12 @@ async function hookCommand(options, io) {
|
|
|
392
473
|
// answer is made from, in any work tree that decides it.
|
|
393
474
|
let keeps = true;
|
|
394
475
|
for (const part of await writesBy(workspace, request.cwd, request.paths, io)) {
|
|
395
|
-
const decided = await decisionsFor(part.workspace, options, io, part.paths, cwd);
|
|
476
|
+
const decided = await decisionsFor(part.workspace, options, io, part.paths, cwd, session);
|
|
396
477
|
if ('problem' in decided) {
|
|
397
478
|
// Exit 2 blocks the tool call, and its stderr reaches the model. Where
|
|
398
479
|
// the name stands on the hook's own line, what is wrong with it is
|
|
399
480
|
// `doctor`'s to say as well, in the same words (usage.ts).
|
|
400
|
-
io.stderr.write(`spec-harness: ${heldHookLine(decided.problem).wrong}.
|
|
481
|
+
io.stderr.write(`spec-harness: ${heldHookLine(decided.problem).wrong}. ${decided.closed ? CLOSED : MEND}\n`);
|
|
401
482
|
return EXIT_ERROR;
|
|
402
483
|
}
|
|
403
484
|
// A line of the answer for each path, by the work tree that decided it.
|
|
@@ -418,10 +499,15 @@ async function hookCommand(options, io) {
|
|
|
418
499
|
catch (error) {
|
|
419
500
|
if (request.event !== 'PreToolUse')
|
|
420
501
|
throw error;
|
|
502
|
+
// The configuration of another work tree the write is decided in,
|
|
503
|
+
// which does not load, lets a write to itself through as the
|
|
504
|
+
// project's does.
|
|
505
|
+
const mended = mending(error, request, cwd, io.env);
|
|
506
|
+
if (mended !== null)
|
|
507
|
+
return answer(io, mended);
|
|
421
508
|
// A round is named and its briefs cannot be read: the guard cannot
|
|
422
509
|
// tell a protected file from any other, so the write waits.
|
|
423
|
-
io
|
|
424
|
-
return EXIT_ERROR;
|
|
510
|
+
return cannotCheck(io, error);
|
|
425
511
|
}
|
|
426
512
|
}
|
|
427
513
|
if (kind === 'git') {
|
|
@@ -459,6 +545,47 @@ async function baseAndSigners({ resolveBase }, workspace, flag, signersFile) {
|
|
|
459
545
|
const signersText = base.kind === 'resolved' ? await show(base.sha, signersFile, workspace.root) : null;
|
|
460
546
|
return { base, signersText };
|
|
461
547
|
}
|
|
548
|
+
async function closedRound(workspace, base, brief) {
|
|
549
|
+
if (base.kind === 'unresolved') {
|
|
550
|
+
return { state: 'unknown', brief, reason: `the base cannot be resolved (${base.reason}): fetch it under that name, with its history, as git fetch origin main:main fetches main` };
|
|
551
|
+
}
|
|
552
|
+
const [shallow, atBase, atHead] = await Promise.all([isShallow(workspace.root), holdsPath(base.mergeBase, brief.file, workspace.root), holdsPath(base.head, brief.file, workspace.root)]);
|
|
553
|
+
// A merge base read in a history cut short can be older than the one the
|
|
554
|
+
// whole history has, and a file absent from it present at the true one.
|
|
555
|
+
if (shallow !== false) {
|
|
556
|
+
return { state: 'unknown', brief, reason: 'the history is shallow, or git cannot say that it is not, and a merge base found in it may be older than the one the whole history has: fetch the whole history' };
|
|
557
|
+
}
|
|
558
|
+
if (atBase === null || atHead === null)
|
|
559
|
+
return { state: 'unknown', brief, reason: `git could not list ${brief.file} at the merge base with ${base.ref}, or at HEAD` };
|
|
560
|
+
if (atBase)
|
|
561
|
+
return { state: 'archived-before', brief, ref: base.ref, mergeBase: base.mergeBase };
|
|
562
|
+
// Absent from HEAD as well, the archive is not committed, or the file is
|
|
563
|
+
// not the one git names: neither is the archive this branch committed.
|
|
564
|
+
if (!atHead)
|
|
565
|
+
return { state: 'unknown', brief, reason: `${brief.file} is not committed at HEAD; commit the archive` };
|
|
566
|
+
return { state: 'waiting-to-merge', brief, ref: base.ref, mergeBase: base.mergeBase };
|
|
567
|
+
}
|
|
568
|
+
/** The note on doctor's `brief` line for a branch that names a brief spec-brief has archived. */
|
|
569
|
+
function describeClosedRound(closed) {
|
|
570
|
+
const { id, file } = closed.brief;
|
|
571
|
+
switch (closed.state) {
|
|
572
|
+
case 'waiting-to-merge':
|
|
573
|
+
return `brief ${id} was archived on this branch, which waits to merge: ${file} is in HEAD and not at the merge base with ${closed.ref}, ${closed.mergeBase.slice(0, 12)}, so this fails nothing; the hook holds every write on the branch all the same`;
|
|
574
|
+
case 'archived-before':
|
|
575
|
+
return `brief ${id} was archived before this branch: ${file} is at the merge base with ${closed.ref}, ${closed.mergeBase.slice(0, 12)}, so the branch is a closed round's, used again, and every write on it is held`;
|
|
576
|
+
case 'unknown':
|
|
577
|
+
return `whether brief ${id} was archived on this branch, which waits to merge, cannot be told: ${closed.reason}`;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* The release of spec-harness installed in a work tree, where the hook's
|
|
582
|
+
* line, init's and the plugin's, names its script under the project's
|
|
583
|
+
* `node_modules`; `null` where none is, or its manifest gives no version.
|
|
584
|
+
*/
|
|
585
|
+
function harnessRelease(root) {
|
|
586
|
+
const version = declaredVersion(join(root, 'node_modules', '@descent-vtt', 'spec-harness', 'package.json'));
|
|
587
|
+
return typeof version === 'string' ? version : null;
|
|
588
|
+
}
|
|
462
589
|
/**
|
|
463
590
|
* How Claude Code is wired to the guard, and the release of the one on
|
|
464
591
|
* `PATH`, which waits on the wiring: only a Claude Code that runs the guard
|
|
@@ -466,17 +593,18 @@ async function baseAndSigners({ resolveBase }, workspace, flag, signersFile) {
|
|
|
466
593
|
* write through unguarded. `null` where it was not asked. The wiring asks
|
|
467
594
|
* it itself, since which writes a line is started for can turn on the
|
|
468
595
|
* release as well (round.ts); the two are told apart here for the report,
|
|
469
|
-
* which has a place of its own for the release.
|
|
596
|
+
* which has a place of its own for the release. The script each line runs
|
|
597
|
+
* is looked for in `places`, where `${CLAUDE_PROJECT_DIR}` can point.
|
|
470
598
|
*/
|
|
471
|
-
async function wiringAndRelease({ claudeCodeWiring }, root, io) {
|
|
472
|
-
const { release, ...wiring } = await claudeCodeWiring(root, io);
|
|
599
|
+
async function wiringAndRelease({ claudeCodeWiring }, root, io, places) {
|
|
600
|
+
const { release, ...wiring } = await claudeCodeWiring(root, io, undefined, true, places);
|
|
473
601
|
return { wiring, release };
|
|
474
602
|
}
|
|
475
603
|
async function doctorCommand(options, io) {
|
|
476
604
|
// What doctor alone runs of them, loaded before anything is asked: the
|
|
477
605
|
// five things below are asked at once, and none waits on a module.
|
|
478
606
|
const [configure, round, setup, signing] = await Promise.all([import('./configure.js'), import('./round.js'), import('./setup.js'), import('./signers.js')]);
|
|
479
|
-
const { describeBase, describeClaudeCode, describeClaudeRelease, describeGitHook, describeHooksOff, describePlugin, describePresence, describeSignerKeys, describeSignerProblem, describeSigners, guardSwitchedOff, NO_SESSION_START, wiringState } = configure;
|
|
607
|
+
const { describeBase, describeClaudeCode, describeClaudeRelease, describeGitHook, describeHooksOff, describeInstalls, describePlugin, describePresence, describeSignerKeys, describeSignerProblem, describeSigners, guardSwitchedOff, guardWired, NO_SESSION_START, NO_TURN_END, wiringState } = configure;
|
|
480
608
|
const { diagnoseBrief, specBriefPlugin } = round;
|
|
481
609
|
const { gitHook } = setup;
|
|
482
610
|
const { notFido2, readAllowedSigners } = signing;
|
|
@@ -504,17 +632,28 @@ async function doctorCommand(options, io) {
|
|
|
504
632
|
// once, and read below in the order they were asked in before: where more
|
|
505
633
|
// than one fails, the first of that order is the one thrown, whichever
|
|
506
634
|
// failed first by the clock (ADR-0002).
|
|
635
|
+
// In a linked worktree, `${CLAUDE_PROJECT_DIR}` names it in a session
|
|
636
|
+
// started there and the main checkout in a worktree session of the desktop
|
|
637
|
+
// app (https://code.claude.com/docs/en/worktrees, What worktrees share with
|
|
638
|
+
// the main checkout, read 2026-10-24): the hook's script is looked for in
|
|
639
|
+
// both, and is not there only where it is in neither.
|
|
640
|
+
const main = mainWorkTree(workspace.commonDir);
|
|
507
641
|
const [briefAsked, baseAsked, pluginAsked, claudeAsked, hookAsked] = await Promise.allSettled([
|
|
508
642
|
diagnoseBrief(workspace, options, io.env),
|
|
509
643
|
baseAndSigners(round, workspace, options.base, signersFile),
|
|
510
644
|
specBriefPlugin(workspace.root),
|
|
511
|
-
wiringAndRelease(round, workspace.root, io),
|
|
645
|
+
wiringAndRelease(round, workspace.root, io, main === null ? [workspace.root] : [workspace.root, main]),
|
|
512
646
|
gitHook(workspace.root),
|
|
513
647
|
]);
|
|
514
648
|
const named = answerOf(briefAsked);
|
|
515
649
|
// A ruling counts only by a signature checked against the allowed signers
|
|
516
650
|
// on the base, and only once spec-brief's archive asks the plugin about it.
|
|
517
651
|
const { base, signersText } = answerOf(baseAsked);
|
|
652
|
+
// A branch that names a brief spec-brief has archived holds every write,
|
|
653
|
+
// and fails doctor, but for the branch that archived it: the last commit
|
|
654
|
+
// of every round, whose own CI run must not fail for it. git tells the
|
|
655
|
+
// two apart by the base, so they are told once it is resolved (ADR-0004).
|
|
656
|
+
const closed = named.archived === null ? null : await closedRound(workspace, base, named.archived);
|
|
518
657
|
const onBase = base.kind === 'resolved' ? signersText !== null : null;
|
|
519
658
|
const signers = describeSigners(signersFile, base.kind === 'resolved' ? base.ref : null, onBase === true);
|
|
520
659
|
// Which keys sign, as notes: a key that is not FIDO2 can still be one the
|
|
@@ -532,7 +671,7 @@ async function doctorCommand(options, io) {
|
|
|
532
671
|
// guard off or holds every write, one only a shell can read, the writes
|
|
533
672
|
// Claude Code starts it for where they are not all of them, and what
|
|
534
673
|
// onFailure changes.
|
|
535
|
-
const lineNotes = wiring.lines.flatMap((line) => [line.detail, line.cover, line.note]).filter((note) => note !== null);
|
|
674
|
+
const lineNotes = wiring.lines.flatMap((line) => [line.detail, line.cover, line.note, line.timeout]).filter((note) => note !== null);
|
|
536
675
|
// What stands over every line: a plugin that is turned on and is not on
|
|
537
676
|
// this machine, or cannot be told to be, and disableAllHooks, which
|
|
538
677
|
// switches off whatever hooks there are.
|
|
@@ -540,7 +679,14 @@ async function doctorCommand(options, io) {
|
|
|
540
679
|
// And whether the hook is started as a session starts, where settings hold
|
|
541
680
|
// the guard: a note, failing nothing, and `sessionStart` in the document.
|
|
542
681
|
const atStart = wiring.hooks.length > 0 && !wiring.sessionStart ? NO_SESSION_START : null;
|
|
543
|
-
|
|
682
|
+
// And whether the report the configuration asks for at a turn's end is
|
|
683
|
+
// made at all: a note, failing nothing, and `turnEnd` in the document.
|
|
684
|
+
const atEnd = workspace.config.turnReport && !wiring.turnEnd ? NO_TURN_END : null;
|
|
685
|
+
// And, in a linked worktree where something wires the guard, the release
|
|
686
|
+
// the hook's line runs where it is not this work tree's own: a note. In
|
|
687
|
+
// the main work tree both are read from one install, and are one.
|
|
688
|
+
const installs = guardWired(wiring) && main !== null ? describeInstalls(main, harnessRelease(main), harnessRelease(workspace.root)) : null;
|
|
689
|
+
const wiringNotes = [describePresence(wiring), wiring.hooksOff === null ? null : describeHooksOff(wiring.hooksOff, switchedOff), installs].filter((note) => note !== null);
|
|
544
690
|
// How the hook runs the harness is a note: one that goes through npx guards
|
|
545
691
|
// as well as one that does not, and fails nothing, --strict or not.
|
|
546
692
|
const { hook, note: hookNote } = answerOf(hookAsked);
|
|
@@ -552,6 +698,9 @@ async function doctorCommand(options, io) {
|
|
|
552
698
|
branchSource: workspace.branchSource ?? null,
|
|
553
699
|
brief: named.id,
|
|
554
700
|
briefProblem: named.problem,
|
|
701
|
+
closedRound: closed === null
|
|
702
|
+
? null
|
|
703
|
+
: { state: closed.state, file: closed.brief.file, mergeBase: closed.state === 'unknown' ? null : closed.mergeBase, detail: describeClosedRound(closed) },
|
|
555
704
|
base: base.kind === 'resolved' ? { ref: base.ref, source: base.source, mergeBase: base.mergeBase } : { unresolved: base.reason },
|
|
556
705
|
allowedSigners: {
|
|
557
706
|
file: signersFile,
|
|
@@ -580,6 +729,7 @@ async function doctorCommand(options, io) {
|
|
|
580
729
|
`root ${workspace.root}`,
|
|
581
730
|
`branch ${branch}`,
|
|
582
731
|
`brief ${named.problem === null ? (named.id ?? '(none named)') : `none: ${named.problem}`}`,
|
|
732
|
+
...more(closed === null ? [] : [describeClosedRound(closed)]),
|
|
583
733
|
`base ${describeBase(base)}`,
|
|
584
734
|
`signers ${signers}`,
|
|
585
735
|
...more(signerNotes),
|
|
@@ -589,6 +739,7 @@ async function doctorCommand(options, io) {
|
|
|
589
739
|
...more(lineNotes),
|
|
590
740
|
...more(release === null ? [] : [describeClaudeRelease(release)]),
|
|
591
741
|
...more(atStart === null ? [] : [atStart]),
|
|
742
|
+
...more(atEnd === null ? [] : [atEnd]),
|
|
592
743
|
`git ${describeGitHook(hook)}`,
|
|
593
744
|
...more(hookNote === null ? [] : [hookNote]),
|
|
594
745
|
'',
|
|
@@ -601,24 +752,32 @@ async function doctorCommand(options, io) {
|
|
|
601
752
|
// a choice; with none installed, the harness can check nothing. A double
|
|
602
753
|
// install is a problem too, where no Claude Code wiring at all is a choice,
|
|
603
754
|
// and so are a hook whose own line turns the guard off or holds every
|
|
604
|
-
// write,
|
|
605
|
-
//
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
//
|
|
609
|
-
//
|
|
610
|
-
//
|
|
611
|
-
//
|
|
755
|
+
// write, or whose script is not there, a branch that names a brief no round
|
|
756
|
+
// can work under, which holds every write as well, but for the branch that
|
|
757
|
+
// archived its brief and waits to merge (ADR-0004), or one that names a
|
|
758
|
+
// brief spec-brief cannot be asked about, which the hook holds every write
|
|
759
|
+
// for too, guard hooks that disableAllHooks
|
|
760
|
+
// switches off, a plugin that is turned on and is not on this machine
|
|
761
|
+
// where no hook of init's stands in for it, a Claude Code too old to run
|
|
762
|
+
// the hooks, and something at the path of git's pre-commit hook that git
|
|
763
|
+
// cannot run and stops every commit at. A release that cannot be told is
|
|
764
|
+
// not fine either, nor a line whose reading cannot be, nor a plugin that
|
|
765
|
+
// cannot be told to be on this machine, nor a hook git runs that cannot be
|
|
766
|
+
// read, which stops every commit as a script and runs as a program, nor a
|
|
767
|
+
// place git runs its hooks from that git cannot name: --strict fails the
|
|
768
|
+
// five.
|
|
612
769
|
const outdated = rows.some((row) => row.state === 'outdated');
|
|
613
770
|
const usable = rows.some((row) => row.state === 'found');
|
|
614
771
|
const off = wiring.lines.some((line) => line.state === 'off' || line.state === 'held');
|
|
615
772
|
const missing = wiring.presence?.state === 'absent' && wiring.hooks.length === 0;
|
|
616
773
|
const tooOld = release?.state === 'outdated';
|
|
617
774
|
const stopped = hook.state === 'stops';
|
|
775
|
+
const unworkable = named.branch && closed?.state !== 'waiting-to-merge';
|
|
618
776
|
// Only an unreadable hook has `runs`, so the test of its state is the
|
|
619
777
|
// compiler's, and its mutant is equivalent.
|
|
620
|
-
const untold = options.strict &&
|
|
621
|
-
|
|
778
|
+
const untold = options.strict &&
|
|
779
|
+
(release?.state === 'unknown' || wiring.lines.some((line) => line.state === 'unknown') || wiring.presence?.state === 'unknown' || (hook.state === 'unreadable' && hook.runs) || hook.state === 'unknown');
|
|
780
|
+
return outdated || !usable || claude === 'both' || off || unworkable || named.holds || switchedOff || missing || tooOld || stopped || untold ? EXIT_FAILED : EXIT_OK;
|
|
622
781
|
}
|
|
623
782
|
/* ---------------------------------------------------------------- dispatch */
|
|
624
783
|
/**
|
|
@@ -683,6 +842,13 @@ async function dispatch(argv, io) {
|
|
|
683
842
|
io.stderr.write(`spec-harness: ${refused}\n`);
|
|
684
843
|
return EXIT_ERROR;
|
|
685
844
|
}
|
|
845
|
+
// Claude Code's hook is exempt from every refusal above, and said a root
|
|
846
|
+
// that is not a directory only as it ran, which the help or the version
|
|
847
|
+
// answered in place of: printed beside one, either is a line it cannot
|
|
848
|
+
// read, said where there is a write it leaves unchecked (ADR-0005).
|
|
849
|
+
const beside = isClaudeHook(options.command, options.positionals[0]) ? answerBeside(options, io.cwd, isDirectory) : null;
|
|
850
|
+
if (beside !== null)
|
|
851
|
+
return unreadLine(beside, io);
|
|
686
852
|
if (options.version) {
|
|
687
853
|
io.stdout.write(`${version()}\n`);
|
|
688
854
|
return EXIT_OK;
|