@descent-vtt/spec-harness 0.20.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +113 -0
- package/README.md +13 -7
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +119 -30
- package/dist/cli.js.map +1 -1
- package/dist/configure.d.ts +40 -3
- package/dist/configure.js +75 -9
- package/dist/configure.js.map +1 -1
- package/dist/fs.d.ts +30 -4
- package/dist/fs.js +104 -11
- package/dist/fs.js.map +1 -1
- package/dist/git.d.ts +38 -0
- package/dist/git.js +59 -2
- package/dist/git.js.map +1 -1
- package/dist/guard.d.ts +20 -1
- package/dist/guard.js +31 -0
- package/dist/guard.js.map +1 -1
- package/dist/hooks.d.ts +14 -0
- package/dist/hooks.js +14 -0
- package/dist/hooks.js.map +1 -1
- package/dist/round.d.ts +19 -1
- package/dist/round.js +59 -16
- package/dist/round.js.map +1 -1
- package/dist/server.js +42 -7
- package/dist/server.js.map +1 -1
- package/dist/siblings.d.ts +5 -3
- package/dist/siblings.js +52 -31
- package/dist/siblings.js.map +1 -1
- package/dist/workspace.js +23 -13
- package/dist/workspace.js.map +1 -1
- package/package.json +3 -3
package/dist/cli.js
CHANGED
|
@@ -19,10 +19,10 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { SiblingOutputError } from './briefs.js';
|
|
21
21
|
import { CONFIG_FILE, ConfigError, SIBLINGS, SPEC_BRIEF_CONFIGS } from './config.js';
|
|
22
|
-
import { dirname, join
|
|
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';
|
|
22
|
+
import { dirname, join } from 'node:path';
|
|
23
|
+
import { gitDirectories, isDirectory, keepCall, loadConfig, mainWorkTree, outsideEvery, reachedBy, StateError, takeCall, writesOnly } from './fs.js';
|
|
24
|
+
import { changedPaths, GitError, holdsPath, isShallow, show, stagedChanges, withBudget } from './git.js';
|
|
25
|
+
import { afterResponse, callFile, callRecord, claudeResponse, gitResponse, HOOK_BUDGET_MS, keptCall, mendResponse, NONE, parseClaudeHook, SESSION_START, sessionOf, sessionResponse, TURN_END, turnResponse, warningResponse, warningsKept, warningsOf } from './hooks.js';
|
|
26
26
|
import { declaredVersion, SiblingError } from './siblings.js';
|
|
27
27
|
import { answerBeside, emptyValue, heldHookLine, isBlank, isClaudeHook, readHookLine, refusal, twoValues, unreadHookLine } from './usage.js';
|
|
28
28
|
import { CLAUDE_CODE_MINIMUM, MINIMUM_VERSIONS } from './versions.js';
|
|
@@ -66,12 +66,15 @@ Commands:
|
|
|
66
66
|
named, how Claude Code runs the guard, and git's hook. Exit 1
|
|
67
67
|
when a sibling is older than this release needs, the guard is
|
|
68
68
|
installed twice, its hook's line turns it off or holds every
|
|
69
|
-
write
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
69
|
+
write or runs a script that is not there, the branch names a
|
|
70
|
+
brief spec-brief does not list, cannot be asked about, or has
|
|
71
|
+
archived, but for the branch git says archived it, a round's
|
|
72
|
+
last commit, which waits to merge (CI fetches the base with its
|
|
73
|
+
history for git to say), disableAllHooks or an array for a
|
|
74
|
+
matcher switches it off, the plugin that is turned on is not on
|
|
75
|
+
this machine and nothing else guards, Claude Code is too old to
|
|
76
|
+
run its hooks, or git stops every commit at what stands for its
|
|
77
|
+
hook.
|
|
75
78
|
|
|
76
79
|
Options:
|
|
77
80
|
--brief <id> The brief the round works on. Otherwise SPEC_BRIEF, then the branch.
|
|
@@ -114,6 +117,18 @@ const reader = async () => (await import('./reader.js')).createReader();
|
|
|
114
117
|
function keptFor(workspace, session) {
|
|
115
118
|
return session === null ? workspace : { ...workspace, siblings: workspace.siblings.keeping(workspace.commonDir, session) };
|
|
116
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* Whether the brief the branch names is one spec-brief has archived: a
|
|
122
|
+
* closed round's branch, whose every write is held as under any name that is
|
|
123
|
+
* no brief's, and in words of its own, since nothing on it is to be mended:
|
|
124
|
+
* its round ends when the branch merges (ADR-0004).
|
|
125
|
+
*/
|
|
126
|
+
function closesRound(active) {
|
|
127
|
+
return active.kind === 'archived' && active.source === 'branch';
|
|
128
|
+
}
|
|
129
|
+
/** What the hook says after what holds the write: how to mend it, or that a closed round's branch has nothing to mend. */
|
|
130
|
+
const MEND = 'Fix the brief or the branch before writing.';
|
|
131
|
+
const CLOSED = 'The round is archived and ends when this branch merges: write on the branch of a live brief.';
|
|
117
132
|
async function decisionsFor(workspace, options, io, given, cwd, session = null) {
|
|
118
133
|
// A path outside every work tree of the repository is decided as one
|
|
119
134
|
// outside it, whichever brief is named and whatever is wrong with the
|
|
@@ -123,8 +138,9 @@ async function decisionsFor(workspace, options, io, given, cwd, session = null)
|
|
|
123
138
|
const outside = outsideEvery(given, workspace.root, workspace.commonDir, cwd);
|
|
124
139
|
const active = outside ? null : await activeBrief(keptFor(workspace, session), options, io.env);
|
|
125
140
|
const { brief, note, problem } = active === null ? { brief: null, note: undefined, problem: null } : describeActive(active);
|
|
141
|
+
// A problem is said only of a brief something names, so `active` is one.
|
|
126
142
|
if (problem !== null)
|
|
127
|
-
return { problem };
|
|
143
|
+
return { problem, closed: closesRound(active) };
|
|
128
144
|
const { checkPaths } = await import('./round.js');
|
|
129
145
|
return { decisions: await checkPaths(workspace, brief, note, given, cwd, reader, options.base), brief };
|
|
130
146
|
}
|
|
@@ -267,8 +283,13 @@ async function sessionProblem(options, io, root, cwd, session) {
|
|
|
267
283
|
// there, and so is the check (ADR-0005).
|
|
268
284
|
const found = cwd === null ? null : await otherTree(named, cwd);
|
|
269
285
|
const workspace = found === null ? named : await openFound(found, io, () => [named.root]);
|
|
270
|
-
const
|
|
271
|
-
|
|
286
|
+
const active = await activeBrief(keptFor(workspace, session), options, io.env);
|
|
287
|
+
const { problem } = describeActive(active);
|
|
288
|
+
if (problem === null)
|
|
289
|
+
return null;
|
|
290
|
+
if (closesRound(active))
|
|
291
|
+
return `${heldHookLine(problem).wrong}, so every write of this session is held: the round is archived and ends when this branch merges, so write on the branch of a live brief; spec-harness doctor says more`;
|
|
292
|
+
return `${heldHookLine(problem).wrong}, ${held}: fix the brief or the branch before writing; spec-harness doctor says more`;
|
|
272
293
|
}
|
|
273
294
|
catch (error) {
|
|
274
295
|
// A session in no work tree has nothing to guard, as a write there has
|
|
@@ -307,9 +328,12 @@ async function turnReport(options, io, root, cwd) {
|
|
|
307
328
|
const workspace = found === null ? named : await openFound(found, io, () => [named.root]);
|
|
308
329
|
if (!workspace.config.turnReport)
|
|
309
330
|
return null;
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
|
|
331
|
+
const active = await activeBrief(workspace, options, io.env);
|
|
332
|
+
const { brief, problem } = describeActive(active);
|
|
333
|
+
if (problem !== null) {
|
|
334
|
+
const ends = closesRound(active) ? ': the round is archived and ends when this branch merges' : '';
|
|
335
|
+
return `spec-harness: ${heldHookLine(problem).wrong}, so what the work tree holds is not reported, and every write is held but one to a file outside every work tree of the repository${ends}; spec-harness doctor says more`;
|
|
336
|
+
}
|
|
313
337
|
if (brief === null)
|
|
314
338
|
return null;
|
|
315
339
|
const [changed, { decide }] = await Promise.all([changedPaths(workspace.root), import('./guard.js')]);
|
|
@@ -367,8 +391,9 @@ async function answeredAfter(root, record, cwd, paths, io) {
|
|
|
367
391
|
return afterResponse(warnings);
|
|
368
392
|
}
|
|
369
393
|
// 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
|
-
|
|
394
|
+
// from may be decided by another's, which may warn, and so may the file a
|
|
395
|
+
// link has a path written to.
|
|
396
|
+
if (!found.exact || (cwd !== null && mayBeElsewhere(cwd, found.top)) || paths.some((path) => reachedBy(path, cwd ?? io.cwd).some((spelled) => mayBeElsewhere(spelled, found.top))))
|
|
372
397
|
return null;
|
|
373
398
|
try {
|
|
374
399
|
return (await loadConfig(found.top)).config.outOfScope === 'warn' ? null : afterResponse([]);
|
|
@@ -387,7 +412,16 @@ function cannotCheck(io, error) {
|
|
|
387
412
|
io.stderr.write(`spec-harness: cannot check this write: ${error.message}\n`);
|
|
388
413
|
return EXIT_ERROR;
|
|
389
414
|
}
|
|
390
|
-
|
|
415
|
+
/**
|
|
416
|
+
* `hook claude` within one budget for every process it starts, below the
|
|
417
|
+
* timeout Claude Code gives its entry, so that a run of slow processes ends
|
|
418
|
+
* with its own answer and not the cancelling of it (ADR-0002); `hook git`
|
|
419
|
+
* as it was, which no entry of Claude Code's runs.
|
|
420
|
+
*/
|
|
421
|
+
function hookCommand(options, io) {
|
|
422
|
+
return options.positionals[0] === 'claude' ? withBudget(HOOK_BUDGET_MS, () => answerHook(options, io)) : answerHook(options, io);
|
|
423
|
+
}
|
|
424
|
+
async function answerHook(options, io) {
|
|
391
425
|
const kind = options.positionals[0];
|
|
392
426
|
if (kind === 'claude') {
|
|
393
427
|
const request = await writeAsked(io);
|
|
@@ -444,7 +478,7 @@ async function hookCommand(options, io) {
|
|
|
444
478
|
// Exit 2 blocks the tool call, and its stderr reaches the model. Where
|
|
445
479
|
// the name stands on the hook's own line, what is wrong with it is
|
|
446
480
|
// `doctor`'s to say as well, in the same words (usage.ts).
|
|
447
|
-
io.stderr.write(`spec-harness: ${heldHookLine(decided.problem).wrong}.
|
|
481
|
+
io.stderr.write(`spec-harness: ${heldHookLine(decided.problem).wrong}. ${decided.closed ? CLOSED : MEND}\n`);
|
|
448
482
|
return EXIT_ERROR;
|
|
449
483
|
}
|
|
450
484
|
// A line of the answer for each path, by the work tree that decided it.
|
|
@@ -511,6 +545,38 @@ async function baseAndSigners({ resolveBase }, workspace, flag, signersFile) {
|
|
|
511
545
|
const signersText = base.kind === 'resolved' ? await show(base.sha, signersFile, workspace.root) : null;
|
|
512
546
|
return { base, signersText };
|
|
513
547
|
}
|
|
548
|
+
async function closedRound(workspace, base, brief) {
|
|
549
|
+
if (base.kind === 'unresolved') {
|
|
550
|
+
return { state: 'unknown', brief, reason: `the base cannot be resolved (${base.reason}): fetch it under that name, with its history, as git fetch origin main:main fetches main` };
|
|
551
|
+
}
|
|
552
|
+
const [shallow, atBase, atHead] = await Promise.all([isShallow(workspace.root), holdsPath(base.mergeBase, brief.file, workspace.root), holdsPath(base.head, brief.file, workspace.root)]);
|
|
553
|
+
// A merge base read in a history cut short can be older than the one the
|
|
554
|
+
// whole history has, and a file absent from it present at the true one.
|
|
555
|
+
if (shallow !== false) {
|
|
556
|
+
return { state: 'unknown', brief, reason: 'the history is shallow, or git cannot say that it is not, and a merge base found in it may be older than the one the whole history has: fetch the whole history' };
|
|
557
|
+
}
|
|
558
|
+
if (atBase === null || atHead === null)
|
|
559
|
+
return { state: 'unknown', brief, reason: `git could not list ${brief.file} at the merge base with ${base.ref}, or at HEAD` };
|
|
560
|
+
if (atBase)
|
|
561
|
+
return { state: 'archived-before', brief, ref: base.ref, mergeBase: base.mergeBase };
|
|
562
|
+
// Absent from HEAD as well, the archive is not committed, or the file is
|
|
563
|
+
// not the one git names: neither is the archive this branch committed.
|
|
564
|
+
if (!atHead)
|
|
565
|
+
return { state: 'unknown', brief, reason: `${brief.file} is not committed at HEAD; commit the archive` };
|
|
566
|
+
return { state: 'waiting-to-merge', brief, ref: base.ref, mergeBase: base.mergeBase };
|
|
567
|
+
}
|
|
568
|
+
/** The note on doctor's `brief` line for a branch that names a brief spec-brief has archived. */
|
|
569
|
+
function describeClosedRound(closed) {
|
|
570
|
+
const { id, file } = closed.brief;
|
|
571
|
+
switch (closed.state) {
|
|
572
|
+
case 'waiting-to-merge':
|
|
573
|
+
return `brief ${id} was archived on this branch, which waits to merge: ${file} is in HEAD and not at the merge base with ${closed.ref}, ${closed.mergeBase.slice(0, 12)}, so this fails nothing; the hook holds every write on the branch all the same`;
|
|
574
|
+
case 'archived-before':
|
|
575
|
+
return `brief ${id} was archived before this branch: ${file} is at the merge base with ${closed.ref}, ${closed.mergeBase.slice(0, 12)}, so the branch is a closed round's, used again, and every write on it is held`;
|
|
576
|
+
case 'unknown':
|
|
577
|
+
return `whether brief ${id} was archived on this branch, which waits to merge, cannot be told: ${closed.reason}`;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
514
580
|
/**
|
|
515
581
|
* The release of spec-harness installed in a work tree, where the hook's
|
|
516
582
|
* line, init's and the plugin's, names its script under the project's
|
|
@@ -527,17 +593,18 @@ function harnessRelease(root) {
|
|
|
527
593
|
* write through unguarded. `null` where it was not asked. The wiring asks
|
|
528
594
|
* it itself, since which writes a line is started for can turn on the
|
|
529
595
|
* release as well (round.ts); the two are told apart here for the report,
|
|
530
|
-
* which has a place of its own for the release.
|
|
596
|
+
* which has a place of its own for the release. The script each line runs
|
|
597
|
+
* is looked for in `places`, where `${CLAUDE_PROJECT_DIR}` can point.
|
|
531
598
|
*/
|
|
532
|
-
async function wiringAndRelease({ claudeCodeWiring }, root, io) {
|
|
533
|
-
const { release, ...wiring } = await claudeCodeWiring(root, io);
|
|
599
|
+
async function wiringAndRelease({ claudeCodeWiring }, root, io, places) {
|
|
600
|
+
const { release, ...wiring } = await claudeCodeWiring(root, io, undefined, true, places);
|
|
534
601
|
return { wiring, release };
|
|
535
602
|
}
|
|
536
603
|
async function doctorCommand(options, io) {
|
|
537
604
|
// What doctor alone runs of them, loaded before anything is asked: the
|
|
538
605
|
// five things below are asked at once, and none waits on a module.
|
|
539
606
|
const [configure, round, setup, signing] = await Promise.all([import('./configure.js'), import('./round.js'), import('./setup.js'), import('./signers.js')]);
|
|
540
|
-
const { describeBase, describeClaudeCode, describeClaudeRelease, describeGitHook, describeHooksOff, describeInstalls, describePlugin, describePresence, describeSignerKeys, describeSignerProblem, describeSigners, guardSwitchedOff, guardWired, NO_SESSION_START, wiringState } = configure;
|
|
607
|
+
const { describeBase, describeClaudeCode, describeClaudeRelease, describeGitHook, describeHooksOff, describeInstalls, describePlugin, describePresence, describeSignerKeys, describeSignerProblem, describeSigners, guardSwitchedOff, guardWired, NO_SESSION_START, NO_TURN_END, wiringState } = configure;
|
|
541
608
|
const { diagnoseBrief, specBriefPlugin } = round;
|
|
542
609
|
const { gitHook } = setup;
|
|
543
610
|
const { notFido2, readAllowedSigners } = signing;
|
|
@@ -565,17 +632,28 @@ async function doctorCommand(options, io) {
|
|
|
565
632
|
// once, and read below in the order they were asked in before: where more
|
|
566
633
|
// than one fails, the first of that order is the one thrown, whichever
|
|
567
634
|
// failed first by the clock (ADR-0002).
|
|
635
|
+
// In a linked worktree, `${CLAUDE_PROJECT_DIR}` names it in a session
|
|
636
|
+
// started there and the main checkout in a worktree session of the desktop
|
|
637
|
+
// app (https://code.claude.com/docs/en/worktrees, What worktrees share with
|
|
638
|
+
// the main checkout, read 2026-10-24): the hook's script is looked for in
|
|
639
|
+
// both, and is not there only where it is in neither.
|
|
640
|
+
const main = mainWorkTree(workspace.commonDir);
|
|
568
641
|
const [briefAsked, baseAsked, pluginAsked, claudeAsked, hookAsked] = await Promise.allSettled([
|
|
569
642
|
diagnoseBrief(workspace, options, io.env),
|
|
570
643
|
baseAndSigners(round, workspace, options.base, signersFile),
|
|
571
644
|
specBriefPlugin(workspace.root),
|
|
572
|
-
wiringAndRelease(round, workspace.root, io),
|
|
645
|
+
wiringAndRelease(round, workspace.root, io, main === null ? [workspace.root] : [workspace.root, main]),
|
|
573
646
|
gitHook(workspace.root),
|
|
574
647
|
]);
|
|
575
648
|
const named = answerOf(briefAsked);
|
|
576
649
|
// A ruling counts only by a signature checked against the allowed signers
|
|
577
650
|
// on the base, and only once spec-brief's archive asks the plugin about it.
|
|
578
651
|
const { base, signersText } = answerOf(baseAsked);
|
|
652
|
+
// A branch that names a brief spec-brief has archived holds every write,
|
|
653
|
+
// and fails doctor, but for the branch that archived it: the last commit
|
|
654
|
+
// of every round, whose own CI run must not fail for it. git tells the
|
|
655
|
+
// two apart by the base, so they are told once it is resolved (ADR-0004).
|
|
656
|
+
const closed = named.archived === null ? null : await closedRound(workspace, base, named.archived);
|
|
579
657
|
const onBase = base.kind === 'resolved' ? signersText !== null : null;
|
|
580
658
|
const signers = describeSigners(signersFile, base.kind === 'resolved' ? base.ref : null, onBase === true);
|
|
581
659
|
// Which keys sign, as notes: a key that is not FIDO2 can still be one the
|
|
@@ -593,7 +671,7 @@ async function doctorCommand(options, io) {
|
|
|
593
671
|
// guard off or holds every write, one only a shell can read, the writes
|
|
594
672
|
// Claude Code starts it for where they are not all of them, and what
|
|
595
673
|
// onFailure changes.
|
|
596
|
-
const lineNotes = wiring.lines.flatMap((line) => [line.detail, line.cover, line.note]).filter((note) => note !== null);
|
|
674
|
+
const lineNotes = wiring.lines.flatMap((line) => [line.detail, line.cover, line.note, line.timeout]).filter((note) => note !== null);
|
|
597
675
|
// What stands over every line: a plugin that is turned on and is not on
|
|
598
676
|
// this machine, or cannot be told to be, and disableAllHooks, which
|
|
599
677
|
// switches off whatever hooks there are.
|
|
@@ -601,10 +679,12 @@ async function doctorCommand(options, io) {
|
|
|
601
679
|
// And whether the hook is started as a session starts, where settings hold
|
|
602
680
|
// the guard: a note, failing nothing, and `sessionStart` in the document.
|
|
603
681
|
const atStart = wiring.hooks.length > 0 && !wiring.sessionStart ? NO_SESSION_START : null;
|
|
682
|
+
// And whether the report the configuration asks for at a turn's end is
|
|
683
|
+
// made at all: a note, failing nothing, and `turnEnd` in the document.
|
|
684
|
+
const atEnd = workspace.config.turnReport && !wiring.turnEnd ? NO_TURN_END : null;
|
|
604
685
|
// And, in a linked worktree where something wires the guard, the release
|
|
605
686
|
// the hook's line runs where it is not this work tree's own: a note. In
|
|
606
687
|
// the main work tree both are read from one install, and are one.
|
|
607
|
-
const main = mainWorkTree(workspace.commonDir);
|
|
608
688
|
const installs = guardWired(wiring) && main !== null ? describeInstalls(main, harnessRelease(main), harnessRelease(workspace.root)) : null;
|
|
609
689
|
const wiringNotes = [describePresence(wiring), wiring.hooksOff === null ? null : describeHooksOff(wiring.hooksOff, switchedOff), installs].filter((note) => note !== null);
|
|
610
690
|
// How the hook runs the harness is a note: one that goes through npx guards
|
|
@@ -618,6 +698,9 @@ async function doctorCommand(options, io) {
|
|
|
618
698
|
branchSource: workspace.branchSource ?? null,
|
|
619
699
|
brief: named.id,
|
|
620
700
|
briefProblem: named.problem,
|
|
701
|
+
closedRound: closed === null
|
|
702
|
+
? null
|
|
703
|
+
: { state: closed.state, file: closed.brief.file, mergeBase: closed.state === 'unknown' ? null : closed.mergeBase, detail: describeClosedRound(closed) },
|
|
621
704
|
base: base.kind === 'resolved' ? { ref: base.ref, source: base.source, mergeBase: base.mergeBase } : { unresolved: base.reason },
|
|
622
705
|
allowedSigners: {
|
|
623
706
|
file: signersFile,
|
|
@@ -646,6 +729,7 @@ async function doctorCommand(options, io) {
|
|
|
646
729
|
`root ${workspace.root}`,
|
|
647
730
|
`branch ${branch}`,
|
|
648
731
|
`brief ${named.problem === null ? (named.id ?? '(none named)') : `none: ${named.problem}`}`,
|
|
732
|
+
...more(closed === null ? [] : [describeClosedRound(closed)]),
|
|
649
733
|
`base ${describeBase(base)}`,
|
|
650
734
|
`signers ${signers}`,
|
|
651
735
|
...more(signerNotes),
|
|
@@ -655,6 +739,7 @@ async function doctorCommand(options, io) {
|
|
|
655
739
|
...more(lineNotes),
|
|
656
740
|
...more(release === null ? [] : [describeClaudeRelease(release)]),
|
|
657
741
|
...more(atStart === null ? [] : [atStart]),
|
|
742
|
+
...more(atEnd === null ? [] : [atEnd]),
|
|
658
743
|
`git ${describeGitHook(hook)}`,
|
|
659
744
|
...more(hookNote === null ? [] : [hookNote]),
|
|
660
745
|
'',
|
|
@@ -667,8 +752,11 @@ async function doctorCommand(options, io) {
|
|
|
667
752
|
// a choice; with none installed, the harness can check nothing. A double
|
|
668
753
|
// install is a problem too, where no Claude Code wiring at all is a choice,
|
|
669
754
|
// and so are a hook whose own line turns the guard off or holds every
|
|
670
|
-
// write, a branch that names a brief no round
|
|
671
|
-
// every write as well
|
|
755
|
+
// write, or whose script is not there, a branch that names a brief no round
|
|
756
|
+
// can work under, which holds every write as well, but for the branch that
|
|
757
|
+
// archived its brief and waits to merge (ADR-0004), or one that names a
|
|
758
|
+
// brief spec-brief cannot be asked about, which the hook holds every write
|
|
759
|
+
// for too, guard hooks that disableAllHooks
|
|
672
760
|
// switches off, a plugin that is turned on and is not on this machine
|
|
673
761
|
// where no hook of init's stands in for it, a Claude Code too old to run
|
|
674
762
|
// the hooks, and something at the path of git's pre-commit hook that git
|
|
@@ -684,11 +772,12 @@ async function doctorCommand(options, io) {
|
|
|
684
772
|
const missing = wiring.presence?.state === 'absent' && wiring.hooks.length === 0;
|
|
685
773
|
const tooOld = release?.state === 'outdated';
|
|
686
774
|
const stopped = hook.state === 'stops';
|
|
775
|
+
const unworkable = named.branch && closed?.state !== 'waiting-to-merge';
|
|
687
776
|
// Only an unreadable hook has `runs`, so the test of its state is the
|
|
688
777
|
// compiler's, and its mutant is equivalent.
|
|
689
778
|
const untold = options.strict &&
|
|
690
779
|
(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.
|
|
780
|
+
return outdated || !usable || claude === 'both' || off || unworkable || named.holds || switchedOff || missing || tooOld || stopped || untold ? EXIT_FAILED : EXIT_OK;
|
|
692
781
|
}
|
|
693
782
|
/* ---------------------------------------------------------------- dispatch */
|
|
694
783
|
/**
|