@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/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, resolve } from 'node:path';
23
- import { gitDirectories, isDirectory, keepCall, loadConfig, mainWorkTree, outsideEvery, StateError, takeCall, writesOnly } from './fs.js';
24
- import { changedPaths, GitError, show, stagedChanges } from './git.js';
25
- import { afterResponse, callFile, callRecord, claudeResponse, gitResponse, keptCall, mendResponse, NONE, parseClaudeHook, SESSION_START, sessionOf, sessionResponse, TURN_END, turnResponse, warningResponse, warningsKept, warningsOf } from './hooks.js';
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, the branch names a brief spec-brief does not list or
70
- has archived, disableAllHooks or an array for a matcher
71
- switches it off, the plugin that is turned on is not on this
72
- machine and nothing else guards, Claude Code is too old to
73
- run its hooks, or git stops every commit at what stands for
74
- its hook.
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 { problem } = describeActive(await activeBrief(keptFor(workspace, session), options, io.env));
271
- return problem === null ? null : `${heldHookLine(problem).wrong}, ${held}: fix the brief or the branch before writing; spec-harness doctor says more`;
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 { brief, problem } = describeActive(await activeBrief(workspace, options, io.env));
311
- if (problem !== null)
312
- return `spec-harness: ${heldHookLine(problem).wrong}, so what the work tree holds is not reported, and every write is held; spec-harness doctor says more`;
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
- if (!found.exact || (cwd !== null && mayBeElsewhere(cwd, found.top)) || paths.some((path) => mayBeElsewhere(resolve(cwd ?? io.cwd, path), found.top)))
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
- async function hookCommand(options, io) {
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}. Fix the brief or the branch before writing.\n`);
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 can work under, which holds
671
- // every write as well (ADR-0004), guard hooks that disableAllHooks
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.branch || switchedOff || missing || tooOld || stopped || untold ? EXIT_FAILED : EXIT_OK;
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
  /**