@descent-vtt/spec-harness 0.18.0 → 0.20.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 +372 -0
- package/README.md +78 -11
- 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 +384 -84
- 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 +15 -1
- package/dist/config.js +16 -3
- package/dist/config.js.map +1 -1
- package/dist/configure.d.ts +142 -32
- package/dist/configure.js +316 -89
- package/dist/configure.js.map +1 -1
- package/dist/fs.d.ts +150 -3
- package/dist/fs.js +459 -17
- package/dist/fs.js.map +1 -1
- package/dist/git.d.ts +85 -9
- package/dist/git.js +140 -16
- package/dist/git.js.map +1 -1
- package/dist/hooks.d.ts +111 -0
- package/dist/hooks.js +172 -16
- package/dist/hooks.js.map +1 -1
- package/dist/round.d.ts +8 -0
- package/dist/round.js +32 -9
- 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 +194 -27
- package/dist/server.js.map +1 -1
- package/dist/setup.d.ts +6 -3
- package/dist/setup.js +74 -23
- package/dist/setup.js.map +1 -1
- package/dist/siblings.d.ts +42 -7
- package/dist/siblings.js +135 -30
- package/dist/siblings.js.map +1 -1
- package/dist/types.d.ts +5 -0
- package/dist/types.js.map +1 -1
- package/dist/usage.d.ts +66 -12
- package/dist/usage.js +134 -21
- package/dist/usage.js.map +1 -1
- package/dist/workspace.d.ts +80 -1
- package/dist/workspace.js +188 -8
- package/dist/workspace.js.map +1 -1
- package/hooks/hooks.json +16 -0
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -18,14 +18,15 @@
|
|
|
18
18
|
* line and exit 2, as every error does.
|
|
19
19
|
*/
|
|
20
20
|
import { SiblingOutputError } from './briefs.js';
|
|
21
|
-
import { ConfigError, SIBLINGS } from './config.js';
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
21
|
+
import { CONFIG_FILE, ConfigError, SIBLINGS, SPEC_BRIEF_CONFIGS } from './config.js';
|
|
22
|
+
import { dirname, join, resolve } from 'node:path';
|
|
23
|
+
import { gitDirectories, isDirectory, keepCall, loadConfig, mainWorkTree, outsideEvery, StateError, takeCall, writesOnly } from './fs.js';
|
|
24
|
+
import { changedPaths, GitError, show, stagedChanges } from './git.js';
|
|
25
|
+
import { afterResponse, callFile, callRecord, claudeResponse, gitResponse, 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';
|
|
27
28
|
import { CLAUDE_CODE_MINIMUM, MINIMUM_VERSIONS } from './versions.js';
|
|
28
|
-
import { activeBrief, answerOf, describeActive, EXIT_ERROR, EXIT_FAILED, EXIT_OK, json, openWorkspace, parseOptions, UsageError, version, } from './workspace.js';
|
|
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';
|
|
29
30
|
export { EXIT_ERROR, EXIT_FAILED, EXIT_OK, UsageError, version } from './workspace.js';
|
|
30
31
|
export const HELP = `spec-harness - context, guards, audits and rulings for rounds of agent work
|
|
31
32
|
|
|
@@ -36,7 +37,10 @@ Commands:
|
|
|
36
37
|
context [brief] What an agent needs to start a round: the brief, its scope,
|
|
37
38
|
rulings, dependencies, the rules in force, the cited documents.
|
|
38
39
|
guard <path...> May the round write these paths? Exit 1 if one is refused.
|
|
39
|
-
hook claude Answer a Claude Code PreToolUse
|
|
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.
|
|
40
44
|
hook git Answer git's pre-commit hook for the staged files.
|
|
41
45
|
escalate Ask a person to rule: --path <file> (repeatable) --reason <why>
|
|
42
46
|
[--option "<label>: <cost>"]... [--recommend <text>].
|
|
@@ -53,17 +57,21 @@ Commands:
|
|
|
53
57
|
premises Do the live briefs' premises still hold? For CI: exit 1 when one
|
|
54
58
|
is stale, or, with --strict, when spec-guard cannot read one.
|
|
55
59
|
init Configure the spec-* tools to agree. Prints the plan; --write
|
|
56
|
-
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.
|
|
57
63
|
mcp Serve start_round, check_path, request_escalation, audit_round,
|
|
58
64
|
list_rounds and the workflow prompts over MCP on stdio.
|
|
59
65
|
doctor Which siblings are installed, at which versions, which brief is
|
|
60
66
|
named, how Claude Code runs the guard, and git's hook. Exit 1
|
|
61
67
|
when a sibling is older than this release needs, the guard is
|
|
62
68
|
installed twice, its hook's line turns it off or holds every
|
|
63
|
-
write,
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
69
|
+
write, the branch names a brief spec-brief does not list or
|
|
70
|
+
has archived, disableAllHooks or an array for a matcher
|
|
71
|
+
switches it off, the plugin that is turned on is not on this
|
|
72
|
+
machine and nothing else guards, Claude Code is too old to
|
|
73
|
+
run its hooks, or git stops every commit at what stands for
|
|
74
|
+
its hook.
|
|
67
75
|
|
|
68
76
|
Options:
|
|
69
77
|
--brief <id> The brief the round works on. Otherwise SPEC_BRIEF, then the branch.
|
|
@@ -76,14 +84,19 @@ Options:
|
|
|
76
84
|
--format <fmt> pretty or json; audit and premises also gitlab, sarif or github.
|
|
77
85
|
For every command but hook and mcp.
|
|
78
86
|
--strict Warnings fail the run. For guard, audit, premises and doctor.
|
|
79
|
-
--
|
|
87
|
+
--block-on-failure onFailure "block" on init's entries but the one as a turn ends. For init.
|
|
88
|
+
--help, --version One of the two: both on one line are refused.
|
|
80
89
|
|
|
81
90
|
An option a command does not read, an argument it does not take, an option or an
|
|
82
|
-
argument given an empty value,
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
91
|
+
argument given an empty value, a brief given both as the argument and as --brief,
|
|
92
|
+
and an option that takes one value given two that name two things are refused, exit 2,
|
|
93
|
+
not read as if they were not there, and so with --help or --version on the line. The
|
|
94
|
+
same value twice, as --root . --root ./, is read once, and on Windows a drive letter in
|
|
95
|
+
either case. doctor says an empty --brief or --base in its report. hook claude reads its
|
|
96
|
+
line as every release has, and says a line it cannot read, two values for one option or
|
|
97
|
+
--help beside a --root that is not a directory among them, a --root that is not a
|
|
98
|
+
directory, and a question it cannot read, as a warning to the person with exit 0: it
|
|
99
|
+
refuses nothing for any of them.
|
|
87
100
|
|
|
88
101
|
Exit codes: 0 clean, 1 refused or found something, 2 the answer cannot be trusted.
|
|
89
102
|
`;
|
|
@@ -93,13 +106,41 @@ Exit codes: 0 clean, 1 refused or found something, 2 the answer cannot be truste
|
|
|
93
106
|
* to a file no brief protects reads none.
|
|
94
107
|
*/
|
|
95
108
|
const reader = async () => (await import('./reader.js')).createReader();
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
109
|
+
/**
|
|
110
|
+
* The work tree with spec-brief's answers kept for a session of Claude
|
|
111
|
+
* Code's, and given again to it where all they were read from reads the
|
|
112
|
+
* same (ADR-0002); as it is where no session is named.
|
|
113
|
+
*/
|
|
114
|
+
function keptFor(workspace, session) {
|
|
115
|
+
return session === null ? workspace : { ...workspace, siblings: workspace.siblings.keeping(workspace.commonDir, session) };
|
|
116
|
+
}
|
|
117
|
+
async function decisionsFor(workspace, options, io, given, cwd, session = null) {
|
|
118
|
+
// A path outside every work tree of the repository is decided as one
|
|
119
|
+
// outside it, whichever brief is named and whatever is wrong with the
|
|
120
|
+
// name: the problem held every write, a file outside the repository
|
|
121
|
+
// among them, where no brief decides one (ADR-0005). So no brief is asked
|
|
122
|
+
// about for such a write, and spec-brief is not run for it.
|
|
123
|
+
const outside = outsideEvery(given, workspace.root, workspace.commonDir, cwd);
|
|
124
|
+
const active = outside ? null : await activeBrief(keptFor(workspace, session), options, io.env);
|
|
125
|
+
const { brief, note, problem } = active === null ? { brief: null, note: undefined, problem: null } : describeActive(active);
|
|
99
126
|
if (problem !== null)
|
|
100
127
|
return { problem };
|
|
101
128
|
const { checkPaths } = await import('./round.js');
|
|
102
|
-
return { decisions: await checkPaths(workspace, brief, note, given, cwd, reader, options.base) };
|
|
129
|
+
return { decisions: await checkPaths(workspace, brief, note, given, cwd, reader, options.base), brief };
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Whether a decision is of a file the answer after the write is made from:
|
|
133
|
+
* the configuration, spec-brief's, the brief, or a file beside it in the
|
|
134
|
+
* briefs' directory, which spec-brief reads too. A call that writes one is
|
|
135
|
+
* decided again after the write, so that what the write changed is read
|
|
136
|
+
* (ADR-0002).
|
|
137
|
+
*/
|
|
138
|
+
function decidesAnswers(decision, brief) {
|
|
139
|
+
if (decision.path === CONFIG_FILE || SPEC_BRIEF_CONFIGS.includes(decision.path))
|
|
140
|
+
return true;
|
|
141
|
+
if (brief === null)
|
|
142
|
+
return false;
|
|
143
|
+
return decision.path === brief.file || decision.path.startsWith(`${dirname(brief.file)}/`);
|
|
103
144
|
}
|
|
104
145
|
const SYMBOL = { allow: 'ok ', warn: 'warning', ask: 'ask ', deny: 'refused' };
|
|
105
146
|
async function guardCommand(options, io) {
|
|
@@ -139,10 +180,12 @@ async function readStdin(io) {
|
|
|
139
180
|
return Buffer.concat(chunks).toString('utf8');
|
|
140
181
|
}
|
|
141
182
|
/**
|
|
142
|
-
* The write Claude Code asks the hook about, or the
|
|
143
|
-
* already:
|
|
144
|
-
*
|
|
145
|
-
*
|
|
183
|
+
* The write Claude Code asks the hook about, or the session that starts, or
|
|
184
|
+
* the exit code that answers already, 0: for a question that cannot be read,
|
|
185
|
+
* said to the person, and for a tool that writes no file. Asked before
|
|
186
|
+
* anything on the hook's own line is, so that a line that is wrong is said
|
|
187
|
+
* where there is a write it leaves unchecked, or a session whose every write
|
|
188
|
+
* it leaves so.
|
|
146
189
|
*/
|
|
147
190
|
async function writeAsked(io) {
|
|
148
191
|
let question;
|
|
@@ -152,27 +195,138 @@ async function writeAsked(io) {
|
|
|
152
195
|
catch (error) {
|
|
153
196
|
// A question that could not be read is one the hook cannot see, as one
|
|
154
197
|
// it cannot parse is, below: it has no event to hold a write by.
|
|
155
|
-
io
|
|
156
|
-
return EXIT_FAILED;
|
|
198
|
+
return answer(io, warningResponse(`the hook input could not be read: ${error.message}, so nothing it asks about is checked`));
|
|
157
199
|
}
|
|
158
200
|
const request = parseClaudeHook(question);
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
io
|
|
163
|
-
|
|
164
|
-
}
|
|
165
|
-
return request.paths.length === 0 ? EXIT_OK : request;
|
|
201
|
+
// A hook that cannot read its question refuses nothing it cannot see, and
|
|
202
|
+
// says so where the person reads it.
|
|
203
|
+
if ('error' in request)
|
|
204
|
+
return answer(io, warningResponse(`${request.error}, so nothing it asks about is checked`));
|
|
205
|
+
return request.paths.length === 0 && request.event !== SESSION_START && request.event !== TURN_END ? EXIT_OK : request;
|
|
166
206
|
}
|
|
167
207
|
/**
|
|
168
208
|
* What is wrong with the hook's own line, said and nothing refused for it:
|
|
169
|
-
* one line,
|
|
170
|
-
*
|
|
171
|
-
*
|
|
209
|
+
* one line, shown to the person, and exit 0, where exit 2 would hold every
|
|
210
|
+
* write of the session, the one that mends the line among them. Exit 1 said
|
|
211
|
+
* it as well, and is what Claude Code counts as a hook that failed, which
|
|
212
|
+
* `onFailure: "block"` holds the write for (ADR-0005).
|
|
213
|
+
*/
|
|
214
|
+
function guardOff(io, { wrong, mend }, event) {
|
|
215
|
+
if (event === SESSION_START)
|
|
216
|
+
return answer(io, sessionResponse(`${wrong}, so the guard is off and no write of this session is checked; ${mend}`));
|
|
217
|
+
if (event === TURN_END)
|
|
218
|
+
return answer(io, turnResponse(`spec-harness: ${wrong}, so the guard is off and no write of this session is checked; ${mend}`));
|
|
219
|
+
return answer(io, warningResponse(`${wrong}, so the guard is off and this write is not checked; ${mend}`));
|
|
220
|
+
}
|
|
221
|
+
function answer(io, response) {
|
|
222
|
+
io.stdout.write(response.stdout);
|
|
223
|
+
return response.exitCode;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* The answer to a write a configuration that does not load holds no more,
|
|
227
|
+
* or `null` for every other write, which is held as before (ADR-0005). Every
|
|
228
|
+
* write was held. A write outside every work tree of the repository is
|
|
229
|
+
* decided as such a write is, after the write as before it: nothing of the
|
|
230
|
+
* repository's decides it. And before a write to the configuration file
|
|
231
|
+
* itself, and to nothing else, the line says why every other write is held,
|
|
232
|
+
* so that a session can mend a typo in it. `cwd` is what a relative path
|
|
233
|
+
* is read from.
|
|
234
|
+
*/
|
|
235
|
+
function mending(error, request, cwd, env) {
|
|
236
|
+
// Every configuration the hook loads is read from a file, so `file` is
|
|
237
|
+
// never `null` here, and the test of it is the compiler's: its mutant is
|
|
238
|
+
// equivalent.
|
|
239
|
+
if (!(error instanceof ConfigError) || error.file === null)
|
|
240
|
+
return null;
|
|
241
|
+
// The work trees are named by the files git keeps, where git would name
|
|
242
|
+
// the same ones; a repository whose configuration or environment moves a
|
|
243
|
+
// work tree elsewhere is not known to leave the path outside. The file
|
|
244
|
+
// was loaded from the top git named, where a `.git` stands, so the files
|
|
245
|
+
// name a work tree, and the test of `null` is the compiler's.
|
|
246
|
+
const found = gitDirectories(dirname(error.file), env);
|
|
247
|
+
if (found !== null && found.exact && outsideEvery(request.paths, found.top, found.common, cwd))
|
|
248
|
+
return NONE;
|
|
249
|
+
if (request.event !== 'PreToolUse' || !writesOnly(request.paths, error.file, cwd))
|
|
250
|
+
return null;
|
|
251
|
+
return mendResponse(`${error.message}, so every other write is held until it loads; this write, to ${error.file} itself, is not checked`);
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* What would hold every write of the session that starts, said once as it
|
|
255
|
+
* starts and no write waits on it (ADR-0005): a configuration that does not
|
|
256
|
+
* load, a brief named that spec-brief does not list or has archived, and
|
|
257
|
+
* spec-brief itself, where a brief is named, missing, too old or unable to
|
|
258
|
+
* list the briefs. Each in the words a write would be held with, and
|
|
259
|
+
* `doctor` says, and with what mends it. `null` where nothing does. The
|
|
260
|
+
* check never ends the session's start: whatever it meets is said.
|
|
261
|
+
*/
|
|
262
|
+
async function sessionProblem(options, io, root, cwd, session) {
|
|
263
|
+
const held = 'so every write of this session is held until it is mended';
|
|
264
|
+
try {
|
|
265
|
+
const named = await openWorkspace({ ...options, root }, io);
|
|
266
|
+
// A session in another work tree of the project has its writes decided
|
|
267
|
+
// there, and so is the check (ADR-0005).
|
|
268
|
+
const found = cwd === null ? null : await otherTree(named, cwd);
|
|
269
|
+
const workspace = found === null ? named : await openFound(found, io, () => [named.root]);
|
|
270
|
+
const { problem } = describeActive(await activeBrief(keptFor(workspace, session), options, io.env));
|
|
271
|
+
return problem === null ? null : `${heldHookLine(problem).wrong}, ${held}: fix the brief or the branch before writing; spec-harness doctor says more`;
|
|
272
|
+
}
|
|
273
|
+
catch (error) {
|
|
274
|
+
// A session in no work tree has nothing to guard, as a write there has
|
|
275
|
+
// not. Anything else that stops the check stops a write's as well, which
|
|
276
|
+
// is then held as one the hook cannot check: a configuration that does
|
|
277
|
+
// not load, a spec-brief that is missing, too old or cannot list the
|
|
278
|
+
// briefs, and whatever a defect throws.
|
|
279
|
+
if (error instanceof UsageError)
|
|
280
|
+
return null;
|
|
281
|
+
// But a write to the configuration file itself, which mends it, where
|
|
282
|
+
// the file is one a write may reach under its name alone (ADR-0005). The
|
|
283
|
+
// configuration is read from a file here as before a write, so the test
|
|
284
|
+
// of `file` is the compiler's, and its mutant is equivalent.
|
|
285
|
+
const mendable = error instanceof ConfigError && error.file !== null && writesOnly([error.file], error.file, root);
|
|
286
|
+
return `${error.message}, ${held}${mendable ? `, but for a write to ${error.file} itself, which is not checked` : ''}; spec-harness doctor says more`;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
/** The most paths a report names one by one: past it, how many more there are. */
|
|
290
|
+
const REPORTED = 20;
|
|
291
|
+
/**
|
|
292
|
+
* What the work tree holds at the end of a turn that the brief protects or
|
|
293
|
+
* that is outside its scope, one line a path in the words a write of it is
|
|
294
|
+
* given; or `null` where there is nothing to say: `turnReport` is off, no
|
|
295
|
+
* brief is named, nothing changed is either, or the session is in no work
|
|
296
|
+
* tree (ADR-0005). The work tree is the one a write of the session is
|
|
297
|
+
* decided in. One start of git reads what changed, and nothing is asked of
|
|
298
|
+
* the base, so no ruling is read: a protected file a signed ruling allows
|
|
299
|
+
* is said as protected, and the audit reads the ruling. A configuration
|
|
300
|
+
* that does not load is said by every write and as the session starts, and
|
|
301
|
+
* here holds the report back without a word.
|
|
172
302
|
*/
|
|
173
|
-
function
|
|
174
|
-
|
|
175
|
-
|
|
303
|
+
async function turnReport(options, io, root, cwd) {
|
|
304
|
+
try {
|
|
305
|
+
const named = await openWorkspace({ ...options, root }, io);
|
|
306
|
+
const found = cwd === null ? null : await otherTree(named, cwd);
|
|
307
|
+
const workspace = found === null ? named : await openFound(found, io, () => [named.root]);
|
|
308
|
+
if (!workspace.config.turnReport)
|
|
309
|
+
return null;
|
|
310
|
+
const { brief, problem } = describeActive(await activeBrief(workspace, options, io.env));
|
|
311
|
+
if (problem !== null)
|
|
312
|
+
return `spec-harness: ${heldHookLine(problem).wrong}, so what the work tree holds is not reported, and every write is held; spec-harness doctor says more`;
|
|
313
|
+
if (brief === null)
|
|
314
|
+
return null;
|
|
315
|
+
const [changed, { decide }] = await Promise.all([changedPaths(workspace.root), import('./guard.js')]);
|
|
316
|
+
const outOfScope = workspace.config.outOfScope;
|
|
317
|
+
const said = changed.map((path) => decide({ path, given: path, brief, rulings: [], outOfScope })).filter((decision) => decision.verdict !== 'allow');
|
|
318
|
+
if (said.length === 0)
|
|
319
|
+
return null;
|
|
320
|
+
const lines = said.slice(0, REPORTED).map((decision) => `- ${decision.message}. Next: ${decision.hint}.`);
|
|
321
|
+
const more = said.length > REPORTED ? [`- and ${said.length - REPORTED} more: spec-harness guard <path> says each`] : [];
|
|
322
|
+
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`;
|
|
323
|
+
return [`spec-harness: as this turn ended, the work tree held changes to ${paths}:`, ...lines, ...more].join('\n');
|
|
324
|
+
}
|
|
325
|
+
catch (error) {
|
|
326
|
+
if (error instanceof UsageError || error instanceof ConfigError)
|
|
327
|
+
return null;
|
|
328
|
+
return `spec-harness: ${error.message}, so what the work tree holds is not reported`;
|
|
329
|
+
}
|
|
176
330
|
}
|
|
177
331
|
/**
|
|
178
332
|
* Claude Code's hook on a line the parser refused: an option no command has,
|
|
@@ -185,7 +339,53 @@ async function unreadLine(said, io) {
|
|
|
185
339
|
const request = await writeAsked(io);
|
|
186
340
|
if (typeof request === 'number')
|
|
187
341
|
return request;
|
|
188
|
-
return guardOff(io, unreadHookLine(said).fault);
|
|
342
|
+
return guardOff(io, unreadHookLine(said).fault, request.event);
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* The answer after a write, where it can be given with no process started,
|
|
346
|
+
* or `null` where it is to be decided as before (ADR-0002, ADR-0003).
|
|
347
|
+
*
|
|
348
|
+
* The git directory is the one the files at the hook's root name. What the
|
|
349
|
+
* run before the write kept for this call, if anything, is taken away as it
|
|
350
|
+
* is read, and answered from where it is this call's: the same release,
|
|
351
|
+
* session, call, tool, paths, directories, line and variables. And where
|
|
352
|
+
* nothing is kept, a run after a write says nothing but a warning outside
|
|
353
|
+
* the scope, which only `outOfScope: "warn"` decides: under `ask` or
|
|
354
|
+
* `deny` there is nothing to say, where the configuration read is the one
|
|
355
|
+
* git would have the hook read, of the work tree the session is in.
|
|
356
|
+
*/
|
|
357
|
+
async function answeredAfter(root, record, cwd, paths, io) {
|
|
358
|
+
const found = gitDirectories(root, io.env);
|
|
359
|
+
if (found === null)
|
|
360
|
+
return null;
|
|
361
|
+
if (record !== null) {
|
|
362
|
+
const kept = await takeCall(found.common, callFile(record));
|
|
363
|
+
// warningsKept reads no text as no record as well: the test is there for
|
|
364
|
+
// the type, and its mutant is equivalent.
|
|
365
|
+
const warnings = kept === null ? null : warningsKept(kept, record);
|
|
366
|
+
if (warnings !== null)
|
|
367
|
+
return afterResponse(warnings);
|
|
368
|
+
}
|
|
369
|
+
// A path in another work tree than the one this configuration is read
|
|
370
|
+
// from may be decided by another's, which may warn.
|
|
371
|
+
if (!found.exact || (cwd !== null && mayBeElsewhere(cwd, found.top)) || paths.some((path) => mayBeElsewhere(resolve(cwd ?? io.cwd, path), found.top)))
|
|
372
|
+
return null;
|
|
373
|
+
try {
|
|
374
|
+
return (await loadConfig(found.top)).config.outOfScope === 'warn' ? null : afterResponse([]);
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
// A configuration that does not load is said as it was, by the run.
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* The answer before a write the hook could not check: one line, and exit 2,
|
|
383
|
+
* which holds the write. Claude Code shows the line to Claude as the
|
|
384
|
+
* reason.
|
|
385
|
+
*/
|
|
386
|
+
function cannotCheck(io, error) {
|
|
387
|
+
io.stderr.write(`spec-harness: cannot check this write: ${error.message}\n`);
|
|
388
|
+
return EXIT_ERROR;
|
|
189
389
|
}
|
|
190
390
|
async function hookCommand(options, io) {
|
|
191
391
|
const kind = options.positionals[0];
|
|
@@ -197,41 +397,90 @@ async function hookCommand(options, io) {
|
|
|
197
397
|
// reads the line where it finds it in a settings file (usage.ts).
|
|
198
398
|
const line = readHookLine(options.root, io.cwd, isDirectory);
|
|
199
399
|
if (line.state === 'off')
|
|
200
|
-
return guardOff(io, line.fault);
|
|
400
|
+
return guardOff(io, line.fault, request.event);
|
|
201
401
|
const cwd = request.cwd ?? io.cwd;
|
|
402
|
+
const root = line.root ?? io.env['CLAUDE_PROJECT_DIR'] ?? cwd;
|
|
403
|
+
const session = sessionOf(request);
|
|
404
|
+
if (request.event === SESSION_START)
|
|
405
|
+
return answer(io, sessionResponse(await sessionProblem(options, io, root, request.cwd, session)));
|
|
406
|
+
if (request.event === TURN_END)
|
|
407
|
+
return answer(io, turnResponse(await turnReport(options, io, root, request.cwd)));
|
|
408
|
+
const record = callRecord(request, { root: line.root, brief: options.brief, base: options.base }, io.cwd, io.env, version());
|
|
409
|
+
if (request.event === 'PostToolUse') {
|
|
410
|
+
const answered = await answeredAfter(root, record, request.cwd, request.paths, io);
|
|
411
|
+
if (answered !== null) {
|
|
412
|
+
io.stdout.write(answered.stdout);
|
|
413
|
+
return answered.exitCode;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
202
416
|
let workspace;
|
|
203
417
|
try {
|
|
204
|
-
workspace = await openWorkspace({ ...options, root
|
|
418
|
+
workspace = await openWorkspace({ ...options, root }, io);
|
|
205
419
|
}
|
|
206
420
|
catch (error) {
|
|
207
421
|
if (error instanceof UsageError)
|
|
208
422
|
return EXIT_OK;
|
|
423
|
+
const mended = mending(error, request, cwd, io.env);
|
|
424
|
+
if (mended !== null)
|
|
425
|
+
return answer(io, mended);
|
|
426
|
+
// git asked where the work tree is and did not answer within its time
|
|
427
|
+
// limit: before a write that is a write the hook could not check, said
|
|
428
|
+
// as one (ADR-0002).
|
|
429
|
+
if (error instanceof GitError && request.event === 'PreToolUse')
|
|
430
|
+
return cannotCheck(io, error);
|
|
209
431
|
throw error;
|
|
210
432
|
}
|
|
211
433
|
try {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
434
|
+
// The answer reads a decision by its verdict, which a mutant's text
|
|
435
|
+
// put first on this list has none of: that mutant is equivalent.
|
|
436
|
+
const decisions = [];
|
|
437
|
+
// Whether the run after the write may answer from what this one keeps:
|
|
438
|
+
// not where a decision refuses, nor where the write is to what the
|
|
439
|
+
// answer is made from, in any work tree that decides it.
|
|
440
|
+
let keeps = true;
|
|
441
|
+
for (const part of await writesBy(workspace, request.cwd, request.paths, io)) {
|
|
442
|
+
const decided = await decisionsFor(part.workspace, options, io, part.paths, cwd, session);
|
|
443
|
+
if ('problem' in decided) {
|
|
444
|
+
// Exit 2 blocks the tool call, and its stderr reaches the model. Where
|
|
445
|
+
// the name stands on the hook's own line, what is wrong with it is
|
|
446
|
+
// `doctor`'s to say as well, in the same words (usage.ts).
|
|
447
|
+
io.stderr.write(`spec-harness: ${heldHookLine(decided.problem).wrong}. Fix the brief or the branch before writing.\n`);
|
|
448
|
+
return EXIT_ERROR;
|
|
449
|
+
}
|
|
450
|
+
// A line of the answer for each path, by the work tree that decided it.
|
|
451
|
+
decisions.push(...decided.decisions);
|
|
452
|
+
if (decided.decisions.some((decision) => decision.verdict === 'deny' || decidesAnswers(decision, decided.brief)))
|
|
453
|
+
keeps = false;
|
|
219
454
|
}
|
|
220
|
-
const response = claudeResponse(request.event,
|
|
455
|
+
const response = claudeResponse(request.event, decisions);
|
|
221
456
|
io.stdout.write(response.stdout);
|
|
457
|
+
// What the run after the write will say, kept for it where the call
|
|
458
|
+
// goes ahead: a refused call has none. It changes nothing of the
|
|
459
|
+
// answer, which is made before it is kept.
|
|
460
|
+
if (request.event === 'PreToolUse' && record !== null && keeps) {
|
|
461
|
+
await keepCall(workspace.commonDir, callFile(record), keptCall(record, warningsOf(decisions)));
|
|
462
|
+
}
|
|
222
463
|
return response.exitCode;
|
|
223
464
|
}
|
|
224
465
|
catch (error) {
|
|
225
466
|
if (request.event !== 'PreToolUse')
|
|
226
467
|
throw error;
|
|
468
|
+
// The configuration of another work tree the write is decided in,
|
|
469
|
+
// which does not load, lets a write to itself through as the
|
|
470
|
+
// project's does.
|
|
471
|
+
const mended = mending(error, request, cwd, io.env);
|
|
472
|
+
if (mended !== null)
|
|
473
|
+
return answer(io, mended);
|
|
227
474
|
// A round is named and its briefs cannot be read: the guard cannot
|
|
228
475
|
// tell a protected file from any other, so the write waits.
|
|
229
|
-
io
|
|
230
|
-
return EXIT_ERROR;
|
|
476
|
+
return cannotCheck(io, error);
|
|
231
477
|
}
|
|
232
478
|
}
|
|
233
479
|
if (kind === 'git') {
|
|
234
|
-
|
|
480
|
+
// git starts its hook at the top of the work tree it commits in, a
|
|
481
|
+
// linked worktree's own, whose install may be none: it is then checked
|
|
482
|
+
// with the main work tree's (ADR-0005).
|
|
483
|
+
const workspace = await openWorkspace(options, io, MAIN_INSTALL);
|
|
235
484
|
const staged = await stagedChanges(workspace.root);
|
|
236
485
|
const paths = staged.flatMap((change) => (change.from === undefined ? [change.path] : [change.from, change.path]));
|
|
237
486
|
if (paths.length === 0)
|
|
@@ -262,6 +511,15 @@ async function baseAndSigners({ resolveBase }, workspace, flag, signersFile) {
|
|
|
262
511
|
const signersText = base.kind === 'resolved' ? await show(base.sha, signersFile, workspace.root) : null;
|
|
263
512
|
return { base, signersText };
|
|
264
513
|
}
|
|
514
|
+
/**
|
|
515
|
+
* The release of spec-harness installed in a work tree, where the hook's
|
|
516
|
+
* line, init's and the plugin's, names its script under the project's
|
|
517
|
+
* `node_modules`; `null` where none is, or its manifest gives no version.
|
|
518
|
+
*/
|
|
519
|
+
function harnessRelease(root) {
|
|
520
|
+
const version = declaredVersion(join(root, 'node_modules', '@descent-vtt', 'spec-harness', 'package.json'));
|
|
521
|
+
return typeof version === 'string' ? version : null;
|
|
522
|
+
}
|
|
265
523
|
/**
|
|
266
524
|
* How Claude Code is wired to the guard, and the release of the one on
|
|
267
525
|
* `PATH`, which waits on the wiring: only a Claude Code that runs the guard
|
|
@@ -279,21 +537,25 @@ async function doctorCommand(options, io) {
|
|
|
279
537
|
// What doctor alone runs of them, loaded before anything is asked: the
|
|
280
538
|
// five things below are asked at once, and none waits on a module.
|
|
281
539
|
const [configure, round, setup, signing] = await Promise.all([import('./configure.js'), import('./round.js'), import('./setup.js'), import('./signers.js')]);
|
|
282
|
-
const { describeBase, describeClaudeCode, describeClaudeRelease, describeGitHook, describeHooksOff, describePlugin, describePresence, describeSignerKeys, describeSignerProblem, describeSigners, guardSwitchedOff, wiringState } = configure;
|
|
540
|
+
const { describeBase, describeClaudeCode, describeClaudeRelease, describeGitHook, describeHooksOff, describeInstalls, describePlugin, describePresence, describeSignerKeys, describeSignerProblem, describeSigners, guardSwitchedOff, guardWired, NO_SESSION_START, wiringState } = configure;
|
|
283
541
|
const { diagnoseBrief, specBriefPlugin } = round;
|
|
284
542
|
const { gitHook } = setup;
|
|
285
543
|
const { notFido2, readAllowedSigners } = signing;
|
|
286
|
-
|
|
544
|
+
// The siblings as git's hook finds them in this work tree: its own
|
|
545
|
+
// install, and in a linked worktree with none the main work tree's.
|
|
546
|
+
const workspace = await openWorkspace(options, io, MAIN_INSTALL);
|
|
287
547
|
const rows = SIBLINGS.map((name) => {
|
|
288
548
|
const sibling = workspace.siblings.locate(name);
|
|
289
549
|
const row = { tool: name, state: sibling.kind, minimum: MINIMUM_VERSIONS[name] };
|
|
290
550
|
if (sibling.kind === 'absent')
|
|
291
|
-
return { ...row, version: null, detail: sibling.reason };
|
|
551
|
+
return { ...row, version: null, elsewhere: null, detail: sibling.reason };
|
|
552
|
+
const elsewhere = sibling.elsewhere ?? null;
|
|
553
|
+
const whose = elsewhere === null ? '' : `, the install of ${elsewhere}: this work tree has none of its own`;
|
|
292
554
|
if (sibling.kind === 'outdated')
|
|
293
|
-
return { ...row, version: sibling.version, detail: sibling.reason };
|
|
555
|
+
return { ...row, version: sibling.version, elsewhere, detail: `${sibling.reason}${whose}` };
|
|
294
556
|
const command = sibling.command.join(' ');
|
|
295
|
-
const detail = sibling.version === null ? `${command} (named in .spec-harness.json; its version is not checked)` : `${command} (${sibling.version})`;
|
|
296
|
-
return { ...row, version: sibling.version, detail };
|
|
557
|
+
const detail = sibling.version === null ? `${command} (named in .spec-harness.json; its version is not checked)` : `${command} (${sibling.version}${whose})`;
|
|
558
|
+
return { ...row, version: sibling.version, elsewhere, detail };
|
|
297
559
|
});
|
|
298
560
|
const signersFile = workspace.config.rulings.allowedSigners;
|
|
299
561
|
// Five things that wait on the work tree and on nothing of one another,
|
|
@@ -336,7 +598,15 @@ async function doctorCommand(options, io) {
|
|
|
336
598
|
// this machine, or cannot be told to be, and disableAllHooks, which
|
|
337
599
|
// switches off whatever hooks there are.
|
|
338
600
|
const switchedOff = guardSwitchedOff(wiring);
|
|
339
|
-
|
|
601
|
+
// And whether the hook is started as a session starts, where settings hold
|
|
602
|
+
// the guard: a note, failing nothing, and `sessionStart` in the document.
|
|
603
|
+
const atStart = wiring.hooks.length > 0 && !wiring.sessionStart ? NO_SESSION_START : null;
|
|
604
|
+
// And, in a linked worktree where something wires the guard, the release
|
|
605
|
+
// the hook's line runs where it is not this work tree's own: a note. In
|
|
606
|
+
// the main work tree both are read from one install, and are one.
|
|
607
|
+
const main = mainWorkTree(workspace.commonDir);
|
|
608
|
+
const installs = guardWired(wiring) && main !== null ? describeInstalls(main, harnessRelease(main), harnessRelease(workspace.root)) : null;
|
|
609
|
+
const wiringNotes = [describePresence(wiring), wiring.hooksOff === null ? null : describeHooksOff(wiring.hooksOff, switchedOff), installs].filter((note) => note !== null);
|
|
340
610
|
// How the hook runs the harness is a note: one that goes through npx guards
|
|
341
611
|
// as well as one that does not, and fails nothing, --strict or not.
|
|
342
612
|
const { hook, note: hookNote } = answerOf(hookAsked);
|
|
@@ -384,6 +654,7 @@ async function doctorCommand(options, io) {
|
|
|
384
654
|
...more(wiringNotes),
|
|
385
655
|
...more(lineNotes),
|
|
386
656
|
...more(release === null ? [] : [describeClaudeRelease(release)]),
|
|
657
|
+
...more(atStart === null ? [] : [atStart]),
|
|
387
658
|
`git ${describeGitHook(hook)}`,
|
|
388
659
|
...more(hookNote === null ? [] : [hookNote]),
|
|
389
660
|
'',
|
|
@@ -396,21 +667,28 @@ async function doctorCommand(options, io) {
|
|
|
396
667
|
// a choice; with none installed, the harness can check nothing. A double
|
|
397
668
|
// install is a problem too, where no Claude Code wiring at all is a choice,
|
|
398
669
|
// and so are a hook whose own line turns the guard off or holds every
|
|
399
|
-
// write,
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
//
|
|
670
|
+
// write, a branch that names a brief no round can work under, which holds
|
|
671
|
+
// every write as well (ADR-0004), guard hooks that disableAllHooks
|
|
672
|
+
// switches off, a plugin that is turned on and is not on this machine
|
|
673
|
+
// where no hook of init's stands in for it, a Claude Code too old to run
|
|
674
|
+
// the hooks, and something at the path of git's pre-commit hook that git
|
|
675
|
+
// cannot run and stops every commit at. A release that cannot be told is
|
|
676
|
+
// not fine either, nor a line whose reading cannot be, nor a plugin that
|
|
677
|
+
// cannot be told to be on this machine, nor a hook git runs that cannot be
|
|
678
|
+
// read, which stops every commit as a script and runs as a program, nor a
|
|
679
|
+
// place git runs its hooks from that git cannot name: --strict fails the
|
|
680
|
+
// five.
|
|
406
681
|
const outdated = rows.some((row) => row.state === 'outdated');
|
|
407
682
|
const usable = rows.some((row) => row.state === 'found');
|
|
408
683
|
const off = wiring.lines.some((line) => line.state === 'off' || line.state === 'held');
|
|
409
684
|
const missing = wiring.presence?.state === 'absent' && wiring.hooks.length === 0;
|
|
410
685
|
const tooOld = release?.state === 'outdated';
|
|
411
686
|
const stopped = hook.state === 'stops';
|
|
412
|
-
|
|
413
|
-
|
|
687
|
+
// Only an unreadable hook has `runs`, so the test of its state is the
|
|
688
|
+
// compiler's, and its mutant is equivalent.
|
|
689
|
+
const untold = options.strict &&
|
|
690
|
+
(release?.state === 'unknown' || wiring.lines.some((line) => line.state === 'unknown') || wiring.presence?.state === 'unknown' || (hook.state === 'unreadable' && hook.runs) || hook.state === 'unknown');
|
|
691
|
+
return outdated || !usable || claude === 'both' || off || named.branch || switchedOff || missing || tooOld || stopped || untold ? EXIT_FAILED : EXIT_OK;
|
|
414
692
|
}
|
|
415
693
|
/* ---------------------------------------------------------------- dispatch */
|
|
416
694
|
/**
|
|
@@ -452,16 +730,20 @@ async function dispatch(argv, io) {
|
|
|
452
730
|
io.stderr.write(`spec-harness: ${refused.message}\n`);
|
|
453
731
|
return EXIT_ERROR;
|
|
454
732
|
}
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
if (
|
|
460
|
-
|
|
461
|
-
|
|
733
|
+
// Two values for an option that takes one: the parser kept the last, and
|
|
734
|
+
// which was meant cannot be told from the line, so it is refused as a line
|
|
735
|
+
// the parser cannot read is, and Claude Code's hook says it of its own.
|
|
736
|
+
const twice = twoValues(options, io.cwd);
|
|
737
|
+
if (twice !== null) {
|
|
738
|
+
if (isClaudeHook(options.command, options.positionals[0]))
|
|
739
|
+
return unreadLine(twice, io);
|
|
740
|
+
io.stderr.write(`spec-harness: ${twice}; give it one\n`);
|
|
741
|
+
return EXIT_ERROR;
|
|
462
742
|
}
|
|
463
|
-
|
|
464
|
-
|
|
743
|
+
// A line that is wrong is refused before --help or --version answers: an
|
|
744
|
+
// answer beside it said nothing of what was wrong, exit 0, as an option no
|
|
745
|
+
// command has never was (spec-core ADR-0005).
|
|
746
|
+
if (options.command !== undefined && !COMMANDS.has(options.command)) {
|
|
465
747
|
io.stderr.write(`spec-harness: unknown command "${options.command}"; see spec-harness --help\n`);
|
|
466
748
|
return EXIT_ERROR;
|
|
467
749
|
}
|
|
@@ -471,7 +753,22 @@ async function dispatch(argv, io) {
|
|
|
471
753
|
io.stderr.write(`spec-harness: ${refused}\n`);
|
|
472
754
|
return EXIT_ERROR;
|
|
473
755
|
}
|
|
474
|
-
|
|
756
|
+
// Claude Code's hook is exempt from every refusal above, and said a root
|
|
757
|
+
// that is not a directory only as it ran, which the help or the version
|
|
758
|
+
// answered in place of: printed beside one, either is a line it cannot
|
|
759
|
+
// read, said where there is a write it leaves unchecked (ADR-0005).
|
|
760
|
+
const beside = isClaudeHook(options.command, options.positionals[0]) ? answerBeside(options, io.cwd, isDirectory) : null;
|
|
761
|
+
if (beside !== null)
|
|
762
|
+
return unreadLine(beside, io);
|
|
763
|
+
if (options.version) {
|
|
764
|
+
io.stdout.write(`${version()}\n`);
|
|
765
|
+
return EXIT_OK;
|
|
766
|
+
}
|
|
767
|
+
if (options.help || options.command === undefined) {
|
|
768
|
+
io.stdout.write(HELP);
|
|
769
|
+
return options.help ? EXIT_OK : EXIT_ERROR;
|
|
770
|
+
}
|
|
771
|
+
return COMMANDS.get(options.command)(options, io);
|
|
475
772
|
}
|
|
476
773
|
/**
|
|
477
774
|
* Runs the command line and resolves to the exit code, for an error nothing
|
|
@@ -506,12 +803,15 @@ export async function run(argv, io) {
|
|
|
506
803
|
catch (error) {
|
|
507
804
|
// A sibling's document of a shape the harness does not read is a sibling
|
|
508
805
|
// that printed something it cannot read (answers.ts): exit 2. A git
|
|
509
|
-
// command that failed is what git said, and no line of the harness's
|
|
806
|
+
// command that failed is what git said, and no line of the harness's;
|
|
807
|
+
// and a path under git's directory where the harness keeps its state,
|
|
808
|
+
// which holds something else, is said in a line of its own (fs.ts).
|
|
510
809
|
if (error instanceof UsageError ||
|
|
511
810
|
error instanceof ConfigError ||
|
|
512
811
|
error instanceof SiblingError ||
|
|
513
812
|
error instanceof SiblingOutputError ||
|
|
514
|
-
error instanceof GitError
|
|
813
|
+
error instanceof GitError ||
|
|
814
|
+
error instanceof StateError) {
|
|
515
815
|
io.stderr.write(`spec-harness: ${error.message}\n`);
|
|
516
816
|
return EXIT_ERROR;
|
|
517
817
|
}
|