@descent-vtt/spec-harness 0.18.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 +160 -0
- package/README.md +57 -5
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +274 -51
- package/dist/cli.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 +88 -32
- package/dist/configure.js +187 -68
- package/dist/configure.js.map +1 -1
- package/dist/fs.d.ts +73 -3
- package/dist/fs.js +176 -13
- package/dist/fs.js.map +1 -1
- package/dist/git.d.ts +35 -7
- package/dist/git.js +42 -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.js +11 -3
- package/dist/round.js.map +1 -1
- package/dist/setup.d.ts +6 -3
- package/dist/setup.js +39 -18
- 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 +25 -6
- package/dist/usage.js +73 -19
- package/dist/usage.js.map +1 -1
- package/dist/workspace.d.ts +38 -1
- package/dist/workspace.js +68 -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 {
|
|
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';
|
|
23
24
|
import { GitError, show, stagedChanges } from './git.js';
|
|
24
|
-
import { claudeResponse, gitResponse, parseClaudeHook } from './hooks.js';
|
|
25
|
+
import { afterResponse, callFile, callRecord, claudeResponse, gitResponse, keptCall, parseClaudeHook, SESSION_START, sessionResponse, warningsKept, warningsOf } from './hooks.js';
|
|
25
26
|
import { SiblingError } from './siblings.js';
|
|
26
|
-
import { emptyValue, heldHookLine, isBlank, isClaudeHook, readHookLine, refusal, unreadHookLine } from './usage.js';
|
|
27
|
+
import { 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, findTree, foundCommonDir, json, MAIN_INSTALL, openFound, openWorkspace, parseOptions, UsageError, version, } 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,8 @@ 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 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.
|
|
40
42
|
hook git Answer git's pre-commit hook for the staged files.
|
|
41
43
|
escalate Ask a person to rule: --path <file> (repeatable) --reason <why>
|
|
42
44
|
[--option "<label>: <cost>"]... [--recommend <text>].
|
|
@@ -76,14 +78,16 @@ 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
|
`;
|
|
@@ -99,7 +103,21 @@ async function decisionsFor(workspace, options, io, given, cwd) {
|
|
|
99
103
|
if (problem !== null)
|
|
100
104
|
return { problem };
|
|
101
105
|
const { checkPaths } = await import('./round.js');
|
|
102
|
-
return { decisions: await checkPaths(workspace, brief, note, given, cwd, reader, options.base) };
|
|
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)}/`);
|
|
103
121
|
}
|
|
104
122
|
const SYMBOL = { allow: 'ok ', warn: 'warning', ask: 'ask ', deny: 'refused' };
|
|
105
123
|
async function guardCommand(options, io) {
|
|
@@ -139,10 +157,11 @@ async function readStdin(io) {
|
|
|
139
157
|
return Buffer.concat(chunks).toString('utf8');
|
|
140
158
|
}
|
|
141
159
|
/**
|
|
142
|
-
* The write Claude Code asks the hook about, or the
|
|
143
|
-
* already: 1 for a question that cannot be read,
|
|
144
|
-
* no file. Asked before anything on the hook's own
|
|
145
|
-
* 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.
|
|
146
165
|
*/
|
|
147
166
|
async function writeAsked(io) {
|
|
148
167
|
let question;
|
|
@@ -162,7 +181,7 @@ async function writeAsked(io) {
|
|
|
162
181
|
io.stderr.write(`spec-harness: ${request.error}\n`);
|
|
163
182
|
return EXIT_FAILED;
|
|
164
183
|
}
|
|
165
|
-
return request.paths.length === 0 ? EXIT_OK : request;
|
|
184
|
+
return request.paths.length === 0 && request.event !== SESSION_START ? EXIT_OK : request;
|
|
166
185
|
}
|
|
167
186
|
/**
|
|
168
187
|
* What is wrong with the hook's own line, said and nothing refused for it:
|
|
@@ -170,10 +189,47 @@ async function writeAsked(io) {
|
|
|
170
189
|
* that exits 1 and goes on with the write, where exit 2 would hold every
|
|
171
190
|
* write of the session, the one that mends the line among them (ADR-0005).
|
|
172
191
|
*/
|
|
173
|
-
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}`));
|
|
174
195
|
io.stderr.write(`spec-harness: ${wrong}, so the guard is off and this write is not checked; ${mend}\n`);
|
|
175
196
|
return EXIT_FAILED;
|
|
176
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
|
+
}
|
|
177
233
|
/**
|
|
178
234
|
* Claude Code's hook on a line the parser refused: an option no command has,
|
|
179
235
|
* an option left without its value, a --format or an --at that is none. It
|
|
@@ -185,7 +241,115 @@ async function unreadLine(said, io) {
|
|
|
185
241
|
const request = await writeAsked(io);
|
|
186
242
|
if (typeof request === 'number')
|
|
187
243
|
return request;
|
|
188
|
-
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
|
+
}
|
|
189
353
|
}
|
|
190
354
|
async function hookCommand(options, io) {
|
|
191
355
|
const kind = options.positionals[0];
|
|
@@ -197,11 +361,22 @@ async function hookCommand(options, io) {
|
|
|
197
361
|
// reads the line where it finds it in a settings file (usage.ts).
|
|
198
362
|
const line = readHookLine(options.root, io.cwd, isDirectory);
|
|
199
363
|
if (line.state === 'off')
|
|
200
|
-
return guardOff(io, line.fault);
|
|
364
|
+
return guardOff(io, line.fault, request.event);
|
|
201
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
|
+
}
|
|
202
377
|
let workspace;
|
|
203
378
|
try {
|
|
204
|
-
workspace = await openWorkspace({ ...options, root
|
|
379
|
+
workspace = await openWorkspace({ ...options, root }, io);
|
|
205
380
|
}
|
|
206
381
|
catch (error) {
|
|
207
382
|
if (error instanceof UsageError)
|
|
@@ -209,16 +384,35 @@ async function hookCommand(options, io) {
|
|
|
209
384
|
throw error;
|
|
210
385
|
}
|
|
211
386
|
try {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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;
|
|
219
407
|
}
|
|
220
|
-
const response = claudeResponse(request.event,
|
|
408
|
+
const response = claudeResponse(request.event, decisions);
|
|
221
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
|
+
}
|
|
222
416
|
return response.exitCode;
|
|
223
417
|
}
|
|
224
418
|
catch (error) {
|
|
@@ -231,7 +425,10 @@ async function hookCommand(options, io) {
|
|
|
231
425
|
}
|
|
232
426
|
}
|
|
233
427
|
if (kind === 'git') {
|
|
234
|
-
|
|
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);
|
|
235
432
|
const staged = await stagedChanges(workspace.root);
|
|
236
433
|
const paths = staged.flatMap((change) => (change.from === undefined ? [change.path] : [change.from, change.path]));
|
|
237
434
|
if (paths.length === 0)
|
|
@@ -279,21 +476,25 @@ async function doctorCommand(options, io) {
|
|
|
279
476
|
// What doctor alone runs of them, loaded before anything is asked: the
|
|
280
477
|
// five things below are asked at once, and none waits on a module.
|
|
281
478
|
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;
|
|
479
|
+
const { describeBase, describeClaudeCode, describeClaudeRelease, describeGitHook, describeHooksOff, describePlugin, describePresence, describeSignerKeys, describeSignerProblem, describeSigners, guardSwitchedOff, NO_SESSION_START, wiringState } = configure;
|
|
283
480
|
const { diagnoseBrief, specBriefPlugin } = round;
|
|
284
481
|
const { gitHook } = setup;
|
|
285
482
|
const { notFido2, readAllowedSigners } = signing;
|
|
286
|
-
|
|
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);
|
|
287
486
|
const rows = SIBLINGS.map((name) => {
|
|
288
487
|
const sibling = workspace.siblings.locate(name);
|
|
289
488
|
const row = { tool: name, state: sibling.kind, minimum: MINIMUM_VERSIONS[name] };
|
|
290
489
|
if (sibling.kind === 'absent')
|
|
291
|
-
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`;
|
|
292
493
|
if (sibling.kind === 'outdated')
|
|
293
|
-
return { ...row, version: sibling.version, detail: sibling.reason };
|
|
494
|
+
return { ...row, version: sibling.version, elsewhere, detail: `${sibling.reason}${whose}` };
|
|
294
495
|
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 };
|
|
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 };
|
|
297
498
|
});
|
|
298
499
|
const signersFile = workspace.config.rulings.allowedSigners;
|
|
299
500
|
// Five things that wait on the work tree and on nothing of one another,
|
|
@@ -336,6 +537,9 @@ async function doctorCommand(options, io) {
|
|
|
336
537
|
// this machine, or cannot be told to be, and disableAllHooks, which
|
|
337
538
|
// switches off whatever hooks there are.
|
|
338
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;
|
|
339
543
|
const wiringNotes = [describePresence(wiring), wiring.hooksOff === null ? null : describeHooksOff(wiring.hooksOff, switchedOff)].filter((note) => note !== null);
|
|
340
544
|
// How the hook runs the harness is a note: one that goes through npx guards
|
|
341
545
|
// as well as one that does not, and fails nothing, --strict or not.
|
|
@@ -384,6 +588,7 @@ async function doctorCommand(options, io) {
|
|
|
384
588
|
...more(wiringNotes),
|
|
385
589
|
...more(lineNotes),
|
|
386
590
|
...more(release === null ? [] : [describeClaudeRelease(release)]),
|
|
591
|
+
...more(atStart === null ? [] : [atStart]),
|
|
387
592
|
`git ${describeGitHook(hook)}`,
|
|
388
593
|
...more(hookNote === null ? [] : [hookNote]),
|
|
389
594
|
'',
|
|
@@ -402,14 +607,17 @@ async function doctorCommand(options, io) {
|
|
|
402
607
|
// path of git's pre-commit hook that git cannot run and stops every
|
|
403
608
|
// commit at. A release that cannot be told is not fine either, nor a
|
|
404
609
|
// line whose reading cannot be, nor a plugin that cannot be told to be on
|
|
405
|
-
// this machine
|
|
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.
|
|
406
612
|
const outdated = rows.some((row) => row.state === 'outdated');
|
|
407
613
|
const usable = rows.some((row) => row.state === 'found');
|
|
408
614
|
const off = wiring.lines.some((line) => line.state === 'off' || line.state === 'held');
|
|
409
615
|
const missing = wiring.presence?.state === 'absent' && wiring.hooks.length === 0;
|
|
410
616
|
const tooOld = release?.state === 'outdated';
|
|
411
617
|
const stopped = hook.state === 'stops';
|
|
412
|
-
|
|
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));
|
|
413
621
|
return outdated || !usable || claude === 'both' || off || switchedOff || missing || tooOld || stopped || untold ? EXIT_FAILED : EXIT_OK;
|
|
414
622
|
}
|
|
415
623
|
/* ---------------------------------------------------------------- dispatch */
|
|
@@ -452,16 +660,20 @@ async function dispatch(argv, io) {
|
|
|
452
660
|
io.stderr.write(`spec-harness: ${refused.message}\n`);
|
|
453
661
|
return EXIT_ERROR;
|
|
454
662
|
}
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
if (
|
|
460
|
-
|
|
461
|
-
|
|
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;
|
|
462
672
|
}
|
|
463
|
-
|
|
464
|
-
|
|
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)) {
|
|
465
677
|
io.stderr.write(`spec-harness: unknown command "${options.command}"; see spec-harness --help\n`);
|
|
466
678
|
return EXIT_ERROR;
|
|
467
679
|
}
|
|
@@ -471,7 +683,15 @@ async function dispatch(argv, io) {
|
|
|
471
683
|
io.stderr.write(`spec-harness: ${refused}\n`);
|
|
472
684
|
return EXIT_ERROR;
|
|
473
685
|
}
|
|
474
|
-
|
|
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);
|
|
475
695
|
}
|
|
476
696
|
/**
|
|
477
697
|
* Runs the command line and resolves to the exit code, for an error nothing
|
|
@@ -506,12 +726,15 @@ export async function run(argv, io) {
|
|
|
506
726
|
catch (error) {
|
|
507
727
|
// A sibling's document of a shape the harness does not read is a sibling
|
|
508
728
|
// 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
|
|
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).
|
|
510
732
|
if (error instanceof UsageError ||
|
|
511
733
|
error instanceof ConfigError ||
|
|
512
734
|
error instanceof SiblingError ||
|
|
513
735
|
error instanceof SiblingOutputError ||
|
|
514
|
-
error instanceof GitError
|
|
736
|
+
error instanceof GitError ||
|
|
737
|
+
error instanceof StateError) {
|
|
515
738
|
io.stderr.write(`spec-harness: ${error.message}\n`);
|
|
516
739
|
return EXIT_ERROR;
|
|
517
740
|
}
|