@descent-vtt/spec-harness 0.17.0 → 0.19.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 +263 -0
- package/README.md +63 -5
- package/dist/cli.d.ts +15 -1
- package/dist/cli.js +334 -85
- package/dist/cli.js.map +1 -1
- package/dist/commands.js +12 -2
- package/dist/commands.js.map +1 -1
- package/dist/config.d.ts +2 -0
- package/dist/config.js +2 -0
- package/dist/config.js.map +1 -1
- package/dist/configure.d.ts +158 -18
- package/dist/configure.js +318 -55
- package/dist/configure.js.map +1 -1
- package/dist/fs.d.ts +83 -3
- package/dist/fs.js +190 -13
- package/dist/fs.js.map +1 -1
- package/dist/git.d.ts +66 -7
- package/dist/git.js +80 -8
- package/dist/git.js.map +1 -1
- package/dist/hooks.d.ts +66 -0
- package/dist/hooks.js +112 -15
- package/dist/hooks.js.map +1 -1
- package/dist/round.d.ts +25 -4
- package/dist/round.js +100 -33
- package/dist/round.js.map +1 -1
- package/dist/setup.d.ts +12 -2
- package/dist/setup.js +92 -26
- package/dist/setup.js.map +1 -1
- package/dist/siblings.d.ts +18 -4
- package/dist/siblings.js +30 -17
- 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 +71 -8
- package/dist/usage.js +108 -19
- package/dist/usage.js.map +1 -1
- package/dist/workspace.d.ts +38 -1
- package/dist/workspace.js +90 -15
- package/dist/workspace.js.map +1 -1
- package/hooks/hooks.json +16 -0
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -8,25 +8,25 @@
|
|
|
8
8
|
* failed, an output its reader closed, an error nothing here expected. A run
|
|
9
9
|
* that could not look must never exit as though it looked and found nothing
|
|
10
10
|
* (spec-core ADR-0005).
|
|
11
|
+
*
|
|
12
|
+
* A command loads what it runs. This module imports what every run needs,
|
|
13
|
+
* the hook's before a write among them, and a module that only some
|
|
14
|
+
* commands run is imported where one of them is the command: the hook
|
|
15
|
+
* runs before and after every write an agent makes, and what it loaded of
|
|
16
|
+
* the other commands was a wait before each (ADR-0002). A module that
|
|
17
|
+
* cannot be loaded is thrown where it was asked for, and ends the run in a
|
|
18
|
+
* line and exit 2, as every error does.
|
|
11
19
|
*/
|
|
12
20
|
import { SiblingOutputError } from './briefs.js';
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import { isDirectory } from './fs.js';
|
|
21
|
+
import { CONFIG_FILE, ConfigError, SIBLINGS, SPEC_BRIEF_CONFIGS } from './config.js';
|
|
22
|
+
import { dirname, join } from 'node:path';
|
|
23
|
+
import { gitDirectories, holdsGit, isDirectory, keepCall, loadConfig, realSpelling, repositoryPath, StateError, takeCall } from './fs.js';
|
|
17
24
|
import { GitError, show, stagedChanges } from './git.js';
|
|
18
|
-
import { claudeResponse, gitResponse, parseClaudeHook } from './hooks.js';
|
|
19
|
-
import { claudeVersion } from './host.js';
|
|
20
|
-
import { premisesCommand } from './premises.js';
|
|
21
|
-
import { createReader } from './reader.js';
|
|
22
|
-
import { checkPaths, claudeCodeWiring, diagnoseBrief, resolveBase, specBriefPlugin } from './round.js';
|
|
23
|
-
import { mcpCommand } from './server.js';
|
|
24
|
-
import { gitHook, initCommand } from './setup.js';
|
|
25
|
-
import { notFido2, readAllowedSigners } from './signers.js';
|
|
25
|
+
import { afterResponse, callFile, callRecord, claudeResponse, gitResponse, keptCall, parseClaudeHook, SESSION_START, sessionResponse, warningsKept, warningsOf } from './hooks.js';
|
|
26
26
|
import { SiblingError } from './siblings.js';
|
|
27
|
-
import { emptyValue, heldHookLine, isBlank, isClaudeHook, readHookLine, refusal, unreadHookLine } from './usage.js';
|
|
28
|
-
import {
|
|
29
|
-
import { activeBrief, answerOf, describeActive, EXIT_ERROR, EXIT_FAILED, EXIT_OK, json, openWorkspace, parseOptions, UsageError, version, } from './workspace.js';
|
|
27
|
+
import { emptyValue, heldHookLine, isBlank, isClaudeHook, readHookLine, refusal, twoValues, unreadHookLine } from './usage.js';
|
|
28
|
+
import { CLAUDE_CODE_MINIMUM, MINIMUM_VERSIONS } from './versions.js';
|
|
29
|
+
import { activeBrief, answerOf, describeActive, EXIT_ERROR, EXIT_FAILED, EXIT_OK, findTree, foundCommonDir, json, MAIN_INSTALL, openFound, openWorkspace, parseOptions, UsageError, version, } 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,7 +37,8 @@ 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 or
|
|
40
|
+
hook claude Answer a Claude Code PreToolUse, PostToolUse or SessionStart hook
|
|
41
|
+
(stdin). At session start it says what would hold every write.
|
|
41
42
|
hook git Answer git's pre-commit hook for the staged files.
|
|
42
43
|
escalate Ask a person to rule: --path <file> (repeatable) --reason <why>
|
|
43
44
|
[--option "<label>: <cost>"]... [--recommend <text>].
|
|
@@ -61,9 +62,10 @@ Commands:
|
|
|
61
62
|
named, how Claude Code runs the guard, and git's hook. Exit 1
|
|
62
63
|
when a sibling is older than this release needs, the guard is
|
|
63
64
|
installed twice, its hook's line turns it off or holds every
|
|
64
|
-
write, disableAllHooks
|
|
65
|
-
turned on is not on this machine and
|
|
66
|
-
Claude Code is too old to run its hooks
|
|
65
|
+
write, disableAllHooks or an array for a matcher switches it
|
|
66
|
+
off, the plugin that is turned on is not on this machine and
|
|
67
|
+
nothing else guards, Claude Code is too old to run its hooks,
|
|
68
|
+
or git stops every commit at what stands for its hook.
|
|
67
69
|
|
|
68
70
|
Options:
|
|
69
71
|
--brief <id> The brief the round works on. Otherwise SPEC_BRIEF, then the branch.
|
|
@@ -76,25 +78,46 @@ Options:
|
|
|
76
78
|
--format <fmt> pretty or json; audit and premises also gitlab, sarif or github.
|
|
77
79
|
For every command but hook and mcp.
|
|
78
80
|
--strict Warnings fail the run. For guard, audit, premises and doctor.
|
|
79
|
-
--help, --version
|
|
81
|
+
--help, --version One of the two: both on one line are refused.
|
|
80
82
|
|
|
81
83
|
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
|
-
|
|
84
|
+
argument given an empty value, a brief given both as the argument and as --brief,
|
|
85
|
+
and an option that takes one value given two that name two things are refused, exit 2,
|
|
86
|
+
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. doctor says an empty --brief or
|
|
88
|
+
--base in its report. hook claude reads its line as every release has, and says a line
|
|
89
|
+
it cannot read, two values for one option among them, and a --root that is not a
|
|
90
|
+
directory, with exit 1: it refuses nothing for either.
|
|
87
91
|
|
|
88
92
|
Exit codes: 0 clean, 1 refused or found something, 2 the answer cannot be trusted.
|
|
89
93
|
`;
|
|
90
94
|
/* ------------------------------------------------------------------- guard */
|
|
91
|
-
|
|
95
|
+
/**
|
|
96
|
+
* The reader of a brief's rulings, loaded where a ruling is read: a write
|
|
97
|
+
* to a file no brief protects reads none.
|
|
98
|
+
*/
|
|
99
|
+
const reader = async () => (await import('./reader.js')).createReader();
|
|
92
100
|
async function decisionsFor(workspace, options, io, given, cwd) {
|
|
93
101
|
const active = await activeBrief(workspace, options, io.env);
|
|
94
102
|
const { brief, note, problem } = describeActive(active);
|
|
95
103
|
if (problem !== null)
|
|
96
104
|
return { problem };
|
|
97
|
-
|
|
105
|
+
const { checkPaths } = await import('./round.js');
|
|
106
|
+
return { decisions: await checkPaths(workspace, brief, note, given, cwd, reader, options.base), brief };
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Whether a decision is of a file the answer after the write is made from:
|
|
110
|
+
* the configuration, spec-brief's, the brief, or a file beside it in the
|
|
111
|
+
* briefs' directory, which spec-brief reads too. A call that writes one is
|
|
112
|
+
* decided again after the write, so that what the write changed is read
|
|
113
|
+
* (ADR-0002).
|
|
114
|
+
*/
|
|
115
|
+
function decidesAnswers(decision, brief) {
|
|
116
|
+
if (decision.path === CONFIG_FILE || SPEC_BRIEF_CONFIGS.includes(decision.path))
|
|
117
|
+
return true;
|
|
118
|
+
if (brief === null)
|
|
119
|
+
return false;
|
|
120
|
+
return decision.path === brief.file || decision.path.startsWith(`${dirname(brief.file)}/`);
|
|
98
121
|
}
|
|
99
122
|
const SYMBOL = { allow: 'ok ', warn: 'warning', ask: 'ask ', deny: 'refused' };
|
|
100
123
|
async function guardCommand(options, io) {
|
|
@@ -134,10 +157,11 @@ async function readStdin(io) {
|
|
|
134
157
|
return Buffer.concat(chunks).toString('utf8');
|
|
135
158
|
}
|
|
136
159
|
/**
|
|
137
|
-
* The write Claude Code asks the hook about, or the
|
|
138
|
-
* already: 1 for a question that cannot be read,
|
|
139
|
-
* no file. Asked before anything on the hook's own
|
|
140
|
-
* that is wrong is said where there is a write it
|
|
160
|
+
* The write Claude Code asks the hook about, or the session that starts, or
|
|
161
|
+
* the exit code that answers already: 1 for a question that cannot be read,
|
|
162
|
+
* 0 for a tool that writes no file. Asked before anything on the hook's own
|
|
163
|
+
* line is, so that a line that is wrong is said where there is a write it
|
|
164
|
+
* leaves unchecked, or a session whose every write it leaves so.
|
|
141
165
|
*/
|
|
142
166
|
async function writeAsked(io) {
|
|
143
167
|
let question;
|
|
@@ -157,7 +181,7 @@ async function writeAsked(io) {
|
|
|
157
181
|
io.stderr.write(`spec-harness: ${request.error}\n`);
|
|
158
182
|
return EXIT_FAILED;
|
|
159
183
|
}
|
|
160
|
-
return request.paths.length === 0 ? EXIT_OK : request;
|
|
184
|
+
return request.paths.length === 0 && request.event !== SESSION_START ? EXIT_OK : request;
|
|
161
185
|
}
|
|
162
186
|
/**
|
|
163
187
|
* What is wrong with the hook's own line, said and nothing refused for it:
|
|
@@ -165,10 +189,47 @@ async function writeAsked(io) {
|
|
|
165
189
|
* that exits 1 and goes on with the write, where exit 2 would hold every
|
|
166
190
|
* write of the session, the one that mends the line among them (ADR-0005).
|
|
167
191
|
*/
|
|
168
|
-
function guardOff(io, { wrong, mend }) {
|
|
192
|
+
function guardOff(io, { wrong, mend }, event) {
|
|
193
|
+
if (event === SESSION_START)
|
|
194
|
+
return answer(io, sessionResponse(`${wrong}, so the guard is off and no write of this session is checked; ${mend}`));
|
|
169
195
|
io.stderr.write(`spec-harness: ${wrong}, so the guard is off and this write is not checked; ${mend}\n`);
|
|
170
196
|
return EXIT_FAILED;
|
|
171
197
|
}
|
|
198
|
+
function answer(io, response) {
|
|
199
|
+
io.stdout.write(response.stdout);
|
|
200
|
+
return response.exitCode;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* What would hold every write of the session that starts, said once as it
|
|
204
|
+
* starts and no write waits on it (ADR-0005): a configuration that does not
|
|
205
|
+
* load, a brief named that spec-brief does not list or has archived, and
|
|
206
|
+
* spec-brief itself, where a brief is named, missing, too old or unable to
|
|
207
|
+
* list the briefs. Each in the words a write would be held with, and
|
|
208
|
+
* `doctor` says, and with what mends it. `null` where nothing does. The
|
|
209
|
+
* check never ends the session's start: whatever it meets is said.
|
|
210
|
+
*/
|
|
211
|
+
async function sessionProblem(options, io, root, cwd) {
|
|
212
|
+
const held = 'so every write of this session is held until it is mended';
|
|
213
|
+
try {
|
|
214
|
+
const named = await openWorkspace({ ...options, root }, io);
|
|
215
|
+
// A session in another work tree of the project has its writes decided
|
|
216
|
+
// there, and so is the check (ADR-0005).
|
|
217
|
+
const found = cwd === null ? null : await otherTree(named, cwd);
|
|
218
|
+
const workspace = found === null ? named : await openFound(found, io, () => [named.root]);
|
|
219
|
+
const { problem } = describeActive(await activeBrief(workspace, options, io.env));
|
|
220
|
+
return problem === null ? null : `${heldHookLine(problem).wrong}, ${held}: fix the brief or the branch before writing; spec-harness doctor says more`;
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
// A session in no work tree has nothing to guard, as a write there has
|
|
224
|
+
// not. Anything else that stops the check stops a write's as well, which
|
|
225
|
+
// is then held as one the hook cannot check: a configuration that does
|
|
226
|
+
// not load, a spec-brief that is missing, too old or cannot list the
|
|
227
|
+
// briefs, and whatever a defect throws.
|
|
228
|
+
if (error instanceof UsageError)
|
|
229
|
+
return null;
|
|
230
|
+
return `${error.message}, ${held}; spec-harness doctor says more`;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
172
233
|
/**
|
|
173
234
|
* Claude Code's hook on a line the parser refused: an option no command has,
|
|
174
235
|
* an option left without its value, a --format or an --at that is none. It
|
|
@@ -180,7 +241,115 @@ async function unreadLine(said, io) {
|
|
|
180
241
|
const request = await writeAsked(io);
|
|
181
242
|
if (typeof request === 'number')
|
|
182
243
|
return request;
|
|
183
|
-
return guardOff(io, unreadHookLine(said).fault);
|
|
244
|
+
return guardOff(io, unreadHookLine(said).fault, request.event);
|
|
245
|
+
}
|
|
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
|
+
/**
|
|
320
|
+
* The answer after a write, where it can be given with no process started,
|
|
321
|
+
* or `null` where it is to be decided as before (ADR-0002, ADR-0003).
|
|
322
|
+
*
|
|
323
|
+
* The git directory is the one the files at the hook's root name. What the
|
|
324
|
+
* run before the write kept for this call, if anything, is taken away as it
|
|
325
|
+
* is read, and answered from where it is this call's: the same release,
|
|
326
|
+
* session, call, tool, paths, directories, line and variables. And where
|
|
327
|
+
* nothing is kept, a run after a write says nothing but a warning outside
|
|
328
|
+
* the scope, which only `outOfScope: "warn"` decides: under `ask` or
|
|
329
|
+
* `deny` there is nothing to say, where the configuration read is the one
|
|
330
|
+
* git would have the hook read, of the work tree the session is in.
|
|
331
|
+
*/
|
|
332
|
+
async function answeredAfter(root, record, cwd, io) {
|
|
333
|
+
const found = gitDirectories(root, io.env);
|
|
334
|
+
if (found === null)
|
|
335
|
+
return null;
|
|
336
|
+
if (record !== null) {
|
|
337
|
+
const kept = await takeCall(found.common, callFile(record));
|
|
338
|
+
// warningsKept reads no text as no record as well: the test is there for
|
|
339
|
+
// the type, and its mutant is equivalent.
|
|
340
|
+
const warnings = kept === null ? null : warningsKept(kept, record);
|
|
341
|
+
if (warnings !== null)
|
|
342
|
+
return afterResponse(warnings);
|
|
343
|
+
}
|
|
344
|
+
if (!found.exact || (cwd !== null && mayBeElsewhere(cwd, found.top)))
|
|
345
|
+
return null;
|
|
346
|
+
try {
|
|
347
|
+
return (await loadConfig(found.top)).config.outOfScope === 'warn' ? null : afterResponse([]);
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
// A configuration that does not load is said as it was, by the run.
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
184
353
|
}
|
|
185
354
|
async function hookCommand(options, io) {
|
|
186
355
|
const kind = options.positionals[0];
|
|
@@ -192,11 +361,22 @@ async function hookCommand(options, io) {
|
|
|
192
361
|
// reads the line where it finds it in a settings file (usage.ts).
|
|
193
362
|
const line = readHookLine(options.root, io.cwd, isDirectory);
|
|
194
363
|
if (line.state === 'off')
|
|
195
|
-
return guardOff(io, line.fault);
|
|
364
|
+
return guardOff(io, line.fault, request.event);
|
|
196
365
|
const cwd = request.cwd ?? io.cwd;
|
|
366
|
+
const root = line.root ?? io.env['CLAUDE_PROJECT_DIR'] ?? cwd;
|
|
367
|
+
if (request.event === SESSION_START)
|
|
368
|
+
return answer(io, sessionResponse(await sessionProblem(options, io, root, request.cwd)));
|
|
369
|
+
const record = callRecord(request, { root: line.root, brief: options.brief, base: options.base }, io.cwd, io.env, version());
|
|
370
|
+
if (request.event === 'PostToolUse') {
|
|
371
|
+
const answered = await answeredAfter(root, record, request.cwd, io);
|
|
372
|
+
if (answered !== null) {
|
|
373
|
+
io.stdout.write(answered.stdout);
|
|
374
|
+
return answered.exitCode;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
197
377
|
let workspace;
|
|
198
378
|
try {
|
|
199
|
-
workspace = await openWorkspace({ ...options, root
|
|
379
|
+
workspace = await openWorkspace({ ...options, root }, io);
|
|
200
380
|
}
|
|
201
381
|
catch (error) {
|
|
202
382
|
if (error instanceof UsageError)
|
|
@@ -204,16 +384,35 @@ async function hookCommand(options, io) {
|
|
|
204
384
|
throw error;
|
|
205
385
|
}
|
|
206
386
|
try {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
387
|
+
// The answer reads a decision by its verdict, which a mutant's text
|
|
388
|
+
// put first on this list has none of: that mutant is equivalent.
|
|
389
|
+
const decisions = [];
|
|
390
|
+
// Whether the run after the write may answer from what this one keeps:
|
|
391
|
+
// not where a decision refuses, nor where the write is to what the
|
|
392
|
+
// answer is made from, in any work tree that decides it.
|
|
393
|
+
let keeps = true;
|
|
394
|
+
for (const part of await writesBy(workspace, request.cwd, request.paths, io)) {
|
|
395
|
+
const decided = await decisionsFor(part.workspace, options, io, part.paths, cwd);
|
|
396
|
+
if ('problem' in decided) {
|
|
397
|
+
// Exit 2 blocks the tool call, and its stderr reaches the model. Where
|
|
398
|
+
// the name stands on the hook's own line, what is wrong with it is
|
|
399
|
+
// `doctor`'s to say as well, in the same words (usage.ts).
|
|
400
|
+
io.stderr.write(`spec-harness: ${heldHookLine(decided.problem).wrong}. Fix the brief or the branch before writing.\n`);
|
|
401
|
+
return EXIT_ERROR;
|
|
402
|
+
}
|
|
403
|
+
// A line of the answer for each path, by the work tree that decided it.
|
|
404
|
+
decisions.push(...decided.decisions);
|
|
405
|
+
if (decided.decisions.some((decision) => decision.verdict === 'deny' || decidesAnswers(decision, decided.brief)))
|
|
406
|
+
keeps = false;
|
|
214
407
|
}
|
|
215
|
-
const response = claudeResponse(request.event,
|
|
408
|
+
const response = claudeResponse(request.event, decisions);
|
|
216
409
|
io.stdout.write(response.stdout);
|
|
410
|
+
// What the run after the write will say, kept for it where the call
|
|
411
|
+
// goes ahead: a refused call has none. It changes nothing of the
|
|
412
|
+
// answer, which is made before it is kept.
|
|
413
|
+
if (request.event === 'PreToolUse' && record !== null && keeps) {
|
|
414
|
+
await keepCall(workspace.commonDir, callFile(record), keptCall(record, warningsOf(decisions)));
|
|
415
|
+
}
|
|
217
416
|
return response.exitCode;
|
|
218
417
|
}
|
|
219
418
|
catch (error) {
|
|
@@ -226,7 +425,10 @@ async function hookCommand(options, io) {
|
|
|
226
425
|
}
|
|
227
426
|
}
|
|
228
427
|
if (kind === 'git') {
|
|
229
|
-
|
|
428
|
+
// git starts its hook at the top of the work tree it commits in, a
|
|
429
|
+
// linked worktree's own, whose install may be none: it is then checked
|
|
430
|
+
// with the main work tree's (ADR-0005).
|
|
431
|
+
const workspace = await openWorkspace(options, io, MAIN_INSTALL);
|
|
230
432
|
const staged = await stagedChanges(workspace.root);
|
|
231
433
|
const paths = staged.flatMap((change) => (change.from === undefined ? [change.path] : [change.from, change.path]));
|
|
232
434
|
if (paths.length === 0)
|
|
@@ -248,7 +450,7 @@ async function hookCommand(options, io) {
|
|
|
248
450
|
* the base, and the two on nothing else doctor asks. A --base given nothing
|
|
249
451
|
* is no base, said as the brief's is, in the report.
|
|
250
452
|
*/
|
|
251
|
-
async function baseAndSigners(workspace, flag, signersFile) {
|
|
453
|
+
async function baseAndSigners({ resolveBase }, workspace, flag, signersFile) {
|
|
252
454
|
// Nothing in doctor asks what kind a base is but whether it is resolved, so
|
|
253
455
|
// the mutant that names this one otherwise is equivalent.
|
|
254
456
|
const base = flag !== undefined && isBlank(flag) ? { kind: 'unresolved', reason: emptyValue('--base', 'base', flag) } : await resolveBase(workspace, flag);
|
|
@@ -261,25 +463,38 @@ async function baseAndSigners(workspace, flag, signersFile) {
|
|
|
261
463
|
* How Claude Code is wired to the guard, and the release of the one on
|
|
262
464
|
* `PATH`, which waits on the wiring: only a Claude Code that runs the guard
|
|
263
465
|
* is asked its release, since one older than the hooks need lets every
|
|
264
|
-
* write through unguarded. `null` where it was not asked.
|
|
466
|
+
* write through unguarded. `null` where it was not asked. The wiring asks
|
|
467
|
+
* it itself, since which writes a line is started for can turn on the
|
|
468
|
+
* 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.
|
|
265
470
|
*/
|
|
266
|
-
async function wiringAndRelease(root, io) {
|
|
267
|
-
const wiring = await claudeCodeWiring(root, io);
|
|
268
|
-
const release = wiringState(wiring) === 'none' ? null : checkClaudeCode(await claudeVersion(io.env));
|
|
471
|
+
async function wiringAndRelease({ claudeCodeWiring }, root, io) {
|
|
472
|
+
const { release, ...wiring } = await claudeCodeWiring(root, io);
|
|
269
473
|
return { wiring, release };
|
|
270
474
|
}
|
|
271
475
|
async function doctorCommand(options, io) {
|
|
272
|
-
|
|
476
|
+
// What doctor alone runs of them, loaded before anything is asked: the
|
|
477
|
+
// five things below are asked at once, and none waits on a module.
|
|
478
|
+
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;
|
|
480
|
+
const { diagnoseBrief, specBriefPlugin } = round;
|
|
481
|
+
const { gitHook } = setup;
|
|
482
|
+
const { notFido2, readAllowedSigners } = signing;
|
|
483
|
+
// The siblings as git's hook finds them in this work tree: its own
|
|
484
|
+
// install, and in a linked worktree with none the main work tree's.
|
|
485
|
+
const workspace = await openWorkspace(options, io, MAIN_INSTALL);
|
|
273
486
|
const rows = SIBLINGS.map((name) => {
|
|
274
487
|
const sibling = workspace.siblings.locate(name);
|
|
275
488
|
const row = { tool: name, state: sibling.kind, minimum: MINIMUM_VERSIONS[name] };
|
|
276
489
|
if (sibling.kind === 'absent')
|
|
277
|
-
return { ...row, version: null, detail: sibling.reason };
|
|
490
|
+
return { ...row, version: null, elsewhere: null, detail: sibling.reason };
|
|
491
|
+
const elsewhere = sibling.elsewhere ?? null;
|
|
492
|
+
const whose = elsewhere === null ? '' : `, the install of ${elsewhere}: this work tree has none of its own`;
|
|
278
493
|
if (sibling.kind === 'outdated')
|
|
279
|
-
return { ...row, version: sibling.version, detail: sibling.reason };
|
|
494
|
+
return { ...row, version: sibling.version, elsewhere, detail: `${sibling.reason}${whose}` };
|
|
280
495
|
const command = sibling.command.join(' ');
|
|
281
|
-
const detail = sibling.version === null ? `${command} (named in .spec-harness.json; its version is not checked)` : `${command} (${sibling.version})`;
|
|
282
|
-
return { ...row, version: sibling.version, detail };
|
|
496
|
+
const detail = sibling.version === null ? `${command} (named in .spec-harness.json; its version is not checked)` : `${command} (${sibling.version}${whose})`;
|
|
497
|
+
return { ...row, version: sibling.version, elsewhere, detail };
|
|
283
498
|
});
|
|
284
499
|
const signersFile = workspace.config.rulings.allowedSigners;
|
|
285
500
|
// Five things that wait on the work tree and on nothing of one another,
|
|
@@ -291,9 +506,9 @@ async function doctorCommand(options, io) {
|
|
|
291
506
|
// failed first by the clock (ADR-0002).
|
|
292
507
|
const [briefAsked, baseAsked, pluginAsked, claudeAsked, hookAsked] = await Promise.allSettled([
|
|
293
508
|
diagnoseBrief(workspace, options, io.env),
|
|
294
|
-
baseAndSigners(workspace, options.base, signersFile),
|
|
509
|
+
baseAndSigners(round, workspace, options.base, signersFile),
|
|
295
510
|
specBriefPlugin(workspace.root),
|
|
296
|
-
wiringAndRelease(workspace.root, io),
|
|
511
|
+
wiringAndRelease(round, workspace.root, io),
|
|
297
512
|
gitHook(workspace.root),
|
|
298
513
|
]);
|
|
299
514
|
const named = answerOf(briefAsked);
|
|
@@ -322,6 +537,9 @@ async function doctorCommand(options, io) {
|
|
|
322
537
|
// this machine, or cannot be told to be, and disableAllHooks, which
|
|
323
538
|
// switches off whatever hooks there are.
|
|
324
539
|
const switchedOff = guardSwitchedOff(wiring);
|
|
540
|
+
// And whether the hook is started as a session starts, where settings hold
|
|
541
|
+
// the guard: a note, failing nothing, and `sessionStart` in the document.
|
|
542
|
+
const atStart = wiring.hooks.length > 0 && !wiring.sessionStart ? NO_SESSION_START : null;
|
|
325
543
|
const wiringNotes = [describePresence(wiring), wiring.hooksOff === null ? null : describeHooksOff(wiring.hooksOff, switchedOff)].filter((note) => note !== null);
|
|
326
544
|
// How the hook runs the harness is a note: one that goes through npx guards
|
|
327
545
|
// as well as one that does not, and fails nothing, --strict or not.
|
|
@@ -370,6 +588,7 @@ async function doctorCommand(options, io) {
|
|
|
370
588
|
...more(wiringNotes),
|
|
371
589
|
...more(lineNotes),
|
|
372
590
|
...more(release === null ? [] : [describeClaudeRelease(release)]),
|
|
591
|
+
...more(atStart === null ? [] : [atStart]),
|
|
373
592
|
`git ${describeGitHook(hook)}`,
|
|
374
593
|
...more(hookNote === null ? [] : [hookNote]),
|
|
375
594
|
'',
|
|
@@ -384,17 +603,22 @@ async function doctorCommand(options, io) {
|
|
|
384
603
|
// and so are a hook whose own line turns the guard off or holds every
|
|
385
604
|
// write, guard hooks that disableAllHooks switches off, a plugin that is
|
|
386
605
|
// turned on and is not on this machine where no hook of init's stands in
|
|
387
|
-
// for it,
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
//
|
|
606
|
+
// for it, a Claude Code too old to run the hooks, and something at the
|
|
607
|
+
// path of git's pre-commit hook that git cannot run and stops every
|
|
608
|
+
// commit at. A release that cannot be told is not fine either, nor a
|
|
609
|
+
// line whose reading cannot be, nor a plugin that cannot be told to be on
|
|
610
|
+
// this machine, nor a hook git runs that cannot be read, which stops every
|
|
611
|
+
// commit as a script and runs as a program: --strict fails the four.
|
|
391
612
|
const outdated = rows.some((row) => row.state === 'outdated');
|
|
392
613
|
const usable = rows.some((row) => row.state === 'found');
|
|
393
614
|
const off = wiring.lines.some((line) => line.state === 'off' || line.state === 'held');
|
|
394
615
|
const missing = wiring.presence?.state === 'absent' && wiring.hooks.length === 0;
|
|
395
616
|
const tooOld = release?.state === 'outdated';
|
|
396
|
-
const
|
|
397
|
-
|
|
617
|
+
const stopped = hook.state === 'stops';
|
|
618
|
+
// Only an unreadable hook has `runs`, so the test of its state is the
|
|
619
|
+
// compiler's, and its mutant is equivalent.
|
|
620
|
+
const untold = options.strict && (release?.state === 'unknown' || wiring.lines.some((line) => line.state === 'unknown') || wiring.presence?.state === 'unknown' || (hook.state === 'unreadable' && hook.runs));
|
|
621
|
+
return outdated || !usable || claude === 'both' || off || switchedOff || missing || tooOld || stopped || untold ? EXIT_FAILED : EXIT_OK;
|
|
398
622
|
}
|
|
399
623
|
/* ---------------------------------------------------------------- dispatch */
|
|
400
624
|
/**
|
|
@@ -404,19 +628,21 @@ async function doctorCommand(options, io) {
|
|
|
404
628
|
* construct` is an unknown command with exit 2.
|
|
405
629
|
*/
|
|
406
630
|
const COMMANDS = new Map(
|
|
407
|
-
// One for every command that has a shape (usage.ts), which the compiler
|
|
631
|
+
// One for every command that has a shape (usage.ts), which the compiler
|
|
632
|
+
// holds. A command of another module loads its module when it is the one
|
|
633
|
+
// typed, and no other command's.
|
|
408
634
|
Object.entries({
|
|
409
635
|
guard: guardCommand,
|
|
410
636
|
hook: hookCommand,
|
|
411
|
-
context: contextCommand,
|
|
412
|
-
audit: auditCommand,
|
|
413
|
-
escalate: escalateCommand,
|
|
414
|
-
rule: ruleCommand,
|
|
415
|
-
rulings: rulingsCommand,
|
|
416
|
-
probe: probeCommand,
|
|
417
|
-
premises: premisesCommand,
|
|
418
|
-
init: initCommand,
|
|
419
|
-
mcp: mcpCommand,
|
|
637
|
+
context: async (options, io) => (await import('./commands.js')).contextCommand(options, io),
|
|
638
|
+
audit: async (options, io) => (await import('./commands.js')).auditCommand(options, io),
|
|
639
|
+
escalate: async (options, io) => (await import('./commands.js')).escalateCommand(options, io),
|
|
640
|
+
rule: async (options, io) => (await import('./commands.js')).ruleCommand(options, io),
|
|
641
|
+
rulings: async (options, io) => (await import('./commands.js')).rulingsCommand(options, io),
|
|
642
|
+
probe: async (options, io) => (await import('./commands.js')).probeCommand(options, io),
|
|
643
|
+
premises: async (options, io) => (await import('./premises.js')).premisesCommand(options, io),
|
|
644
|
+
init: async (options, io) => (await import('./setup.js')).initCommand(options, io),
|
|
645
|
+
mcp: async (options, io) => (await import('./server.js')).mcpCommand(options, io),
|
|
420
646
|
doctor: doctorCommand,
|
|
421
647
|
}));
|
|
422
648
|
async function dispatch(argv, io) {
|
|
@@ -434,16 +660,20 @@ async function dispatch(argv, io) {
|
|
|
434
660
|
io.stderr.write(`spec-harness: ${refused.message}\n`);
|
|
435
661
|
return EXIT_ERROR;
|
|
436
662
|
}
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
if (
|
|
442
|
-
|
|
443
|
-
|
|
663
|
+
// Two values for an option that takes one: the parser kept the last, and
|
|
664
|
+
// which was meant cannot be told from the line, so it is refused as a line
|
|
665
|
+
// the parser cannot read is, and Claude Code's hook says it of its own.
|
|
666
|
+
const twice = twoValues(options, io.cwd);
|
|
667
|
+
if (twice !== null) {
|
|
668
|
+
if (isClaudeHook(options.command, options.positionals[0]))
|
|
669
|
+
return unreadLine(twice, io);
|
|
670
|
+
io.stderr.write(`spec-harness: ${twice}; give it one\n`);
|
|
671
|
+
return EXIT_ERROR;
|
|
444
672
|
}
|
|
445
|
-
|
|
446
|
-
|
|
673
|
+
// A line that is wrong is refused before --help or --version answers: an
|
|
674
|
+
// answer beside it said nothing of what was wrong, exit 0, as an option no
|
|
675
|
+
// command has never was (spec-core ADR-0005).
|
|
676
|
+
if (options.command !== undefined && !COMMANDS.has(options.command)) {
|
|
447
677
|
io.stderr.write(`spec-harness: unknown command "${options.command}"; see spec-harness --help\n`);
|
|
448
678
|
return EXIT_ERROR;
|
|
449
679
|
}
|
|
@@ -453,7 +683,15 @@ async function dispatch(argv, io) {
|
|
|
453
683
|
io.stderr.write(`spec-harness: ${refused}\n`);
|
|
454
684
|
return EXIT_ERROR;
|
|
455
685
|
}
|
|
456
|
-
|
|
686
|
+
if (options.version) {
|
|
687
|
+
io.stdout.write(`${version()}\n`);
|
|
688
|
+
return EXIT_OK;
|
|
689
|
+
}
|
|
690
|
+
if (options.help || options.command === undefined) {
|
|
691
|
+
io.stdout.write(HELP);
|
|
692
|
+
return options.help ? EXIT_OK : EXIT_ERROR;
|
|
693
|
+
}
|
|
694
|
+
return COMMANDS.get(options.command)(options, io);
|
|
457
695
|
}
|
|
458
696
|
/**
|
|
459
697
|
* Runs the command line and resolves to the exit code, for an error nothing
|
|
@@ -474,6 +712,12 @@ async function dispatch(argv, io) {
|
|
|
474
712
|
* launcher answers in the same words; here it is a caller's stream that
|
|
475
713
|
* throws it. It is read by its code: stdout and stderr are the only pipes
|
|
476
714
|
* the harness writes to.
|
|
715
|
+
*
|
|
716
|
+
* Nor is a module of the build that is not there. The launcher says a build
|
|
717
|
+
* without its command line in a line, and a module a command asks for where
|
|
718
|
+
* it runs is said in the same line: a build with a file missing is not a
|
|
719
|
+
* defect a stack leads to. It is read by its code too: the harness imports
|
|
720
|
+
* nothing but its own modules and Node's.
|
|
477
721
|
*/
|
|
478
722
|
export async function run(argv, io) {
|
|
479
723
|
try {
|
|
@@ -482,12 +726,15 @@ export async function run(argv, io) {
|
|
|
482
726
|
catch (error) {
|
|
483
727
|
// A sibling's document of a shape the harness does not read is a sibling
|
|
484
728
|
// that printed something it cannot read (answers.ts): exit 2. A git
|
|
485
|
-
// command that failed is what git said, and no line of the harness's
|
|
729
|
+
// command that failed is what git said, and no line of the harness's;
|
|
730
|
+
// and a path under git's directory where the harness keeps its state,
|
|
731
|
+
// which holds something else, is said in a line of its own (fs.ts).
|
|
486
732
|
if (error instanceof UsageError ||
|
|
487
733
|
error instanceof ConfigError ||
|
|
488
734
|
error instanceof SiblingError ||
|
|
489
735
|
error instanceof SiblingOutputError ||
|
|
490
|
-
error instanceof GitError
|
|
736
|
+
error instanceof GitError ||
|
|
737
|
+
error instanceof StateError) {
|
|
491
738
|
io.stderr.write(`spec-harness: ${error.message}\n`);
|
|
492
739
|
return EXIT_ERROR;
|
|
493
740
|
}
|
|
@@ -495,6 +742,8 @@ export async function run(argv, io) {
|
|
|
495
742
|
io.stderr.write(`spec-harness: unexpected error: ${String(error)}\n`);
|
|
496
743
|
else if (error.code === 'EPIPE')
|
|
497
744
|
io.stderr.write('spec-harness: stdout was closed before all of the output was written\n');
|
|
745
|
+
else if (error.code === 'ERR_MODULE_NOT_FOUND')
|
|
746
|
+
io.stderr.write('spec-harness: build output is missing; run "npm run build" first, or install the published package.\n');
|
|
498
747
|
else
|
|
499
748
|
io.stderr.write(`spec-harness: unexpected error: ${error.stack ?? error.message}\n`);
|
|
500
749
|
return EXIT_ERROR;
|