@descent-vtt/spec-harness 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +212 -0
  4. package/README.md +30 -15
  5. package/dist/briefs.d.ts +45 -0
  6. package/dist/briefs.js +73 -0
  7. package/dist/briefs.js.map +1 -1
  8. package/dist/cli.d.ts +1 -1
  9. package/dist/cli.js +211 -134
  10. package/dist/cli.js.map +1 -1
  11. package/dist/commands.js +7 -2
  12. package/dist/commands.js.map +1 -1
  13. package/dist/config.d.ts +13 -1
  14. package/dist/config.js +14 -3
  15. package/dist/config.js.map +1 -1
  16. package/dist/configure.d.ts +55 -1
  17. package/dist/configure.js +130 -22
  18. package/dist/configure.js.map +1 -1
  19. package/dist/fs.d.ts +78 -1
  20. package/dist/fs.js +289 -10
  21. package/dist/fs.js.map +1 -1
  22. package/dist/git.d.ts +50 -2
  23. package/dist/git.js +98 -8
  24. package/dist/git.js.map +1 -1
  25. package/dist/hooks.d.ts +45 -0
  26. package/dist/hooks.js +62 -3
  27. package/dist/hooks.js.map +1 -1
  28. package/dist/round.d.ts +8 -0
  29. package/dist/round.js +23 -8
  30. package/dist/round.js.map +1 -1
  31. package/dist/sandbox.js +4 -3
  32. package/dist/sandbox.js.map +1 -1
  33. package/dist/server.d.ts +25 -1
  34. package/dist/server.js +194 -27
  35. package/dist/server.js.map +1 -1
  36. package/dist/setup.js +40 -10
  37. package/dist/setup.js.map +1 -1
  38. package/dist/siblings.d.ts +24 -3
  39. package/dist/siblings.js +106 -14
  40. package/dist/siblings.js.map +1 -1
  41. package/dist/usage.d.ts +42 -7
  42. package/dist/usage.js +65 -6
  43. package/dist/usage.js.map +1 -1
  44. package/dist/workspace.d.ts +45 -3
  45. package/dist/workspace.js +133 -13
  46. package/dist/workspace.js.map +1 -1
  47. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -19,14 +19,14 @@
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 } from 'node:path';
23
- import { gitDirectories, holdsGit, isDirectory, keepCall, loadConfig, realSpelling, repositoryPath, StateError, takeCall } from './fs.js';
24
- import { GitError, show, stagedChanges } from './git.js';
25
- import { afterResponse, callFile, callRecord, claudeResponse, gitResponse, keptCall, parseClaudeHook, SESSION_START, sessionResponse, warningsKept, warningsOf } from './hooks.js';
26
- import { SiblingError } from './siblings.js';
27
- import { emptyValue, heldHookLine, isBlank, isClaudeHook, readHookLine, refusal, twoValues, unreadHookLine } from './usage.js';
22
+ import { dirname, join, resolve } from 'node:path';
23
+ import { gitDirectories, isDirectory, keepCall, loadConfig, mainWorkTree, outsideEvery, StateError, takeCall, writesOnly } from './fs.js';
24
+ import { changedPaths, GitError, show, stagedChanges } from './git.js';
25
+ import { afterResponse, callFile, callRecord, claudeResponse, gitResponse, keptCall, mendResponse, NONE, parseClaudeHook, SESSION_START, sessionOf, sessionResponse, TURN_END, turnResponse, warningResponse, warningsKept, warningsOf } from './hooks.js';
26
+ import { declaredVersion, SiblingError } from './siblings.js';
27
+ import { answerBeside, emptyValue, heldHookLine, isBlank, isClaudeHook, readHookLine, refusal, twoValues, unreadHookLine } from './usage.js';
28
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';
29
+ import { activeBrief, answerOf, describeActive, EXIT_ERROR, EXIT_FAILED, EXIT_OK, json, MAIN_INSTALL, mayBeElsewhere, openFound, openWorkspace, otherTree, parseOptions, UsageError, version, writesBy, } from './workspace.js';
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,8 +37,10 @@ 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, PostToolUse or SessionStart hook
41
- (stdin). At session start it says what would hold every write.
40
+ hook claude Answer a Claude Code PreToolUse, PostToolUse, SessionStart or Stop
41
+ hook (stdin). At session start it says what would hold every
42
+ write; at a turn's end, with "turnReport" on, what the work tree
43
+ holds that the brief protects or that is outside its scope.
42
44
  hook git Answer git's pre-commit hook for the staged files.
43
45
  escalate Ask a person to rule: --path <file> (repeatable) --reason <why>
44
46
  [--option "<label>: <cost>"]... [--recommend <text>].
@@ -55,17 +57,21 @@ Commands:
55
57
  premises Do the live briefs' premises still hold? For CI: exit 1 when one
56
58
  is stale, or, with --strict, when spec-guard cannot read one.
57
59
  init Configure the spec-* tools to agree. Prints the plan; --write
58
- applies it; --git-hook adds a pre-commit hook.
60
+ applies it; --git-hook adds a pre-commit hook; --block-on-failure
61
+ has Claude Code 2.1.295 or later hold a write when the hook cannot
62
+ start or runs past its timeout.
59
63
  mcp Serve start_round, check_path, request_escalation, audit_round,
60
64
  list_rounds and the workflow prompts over MCP on stdio.
61
65
  doctor Which siblings are installed, at which versions, which brief is
62
66
  named, how Claude Code runs the guard, and git's hook. Exit 1
63
67
  when a sibling is older than this release needs, the guard is
64
68
  installed twice, its hook's line turns it off or holds every
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.
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
75
 
70
76
  Options:
71
77
  --brief <id> The brief the round works on. Otherwise SPEC_BRIEF, then the branch.
@@ -78,16 +84,19 @@ Options:
78
84
  --format <fmt> pretty or json; audit and premises also gitlab, sarif or github.
79
85
  For every command but hook and mcp.
80
86
  --strict Warnings fail the run. For guard, audit, premises and doctor.
87
+ --block-on-failure onFailure "block" on init's entries but the one as a turn ends. For init.
81
88
  --help, --version One of the two: both on one line are refused.
82
89
 
83
90
  An option a command does not read, an argument it does not take, an option or an
84
91
  argument given an empty value, a brief given both as the argument and as --brief,
85
92
  and an option that takes one value given two that name two things are refused, exit 2,
86
93
  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.
94
+ same value twice, as --root . --root ./, is read once, and on Windows a drive letter in
95
+ either case. doctor says an empty --brief or --base in its report. hook claude reads its
96
+ line as every release has, and says a line it cannot read, two values for one option or
97
+ --help beside a --root that is not a directory among them, a --root that is not a
98
+ directory, and a question it cannot read, as a warning to the person with exit 0: it
99
+ refuses nothing for any of them.
91
100
 
92
101
  Exit codes: 0 clean, 1 refused or found something, 2 the answer cannot be trusted.
93
102
  `;
@@ -97,9 +106,23 @@ Exit codes: 0 clean, 1 refused or found something, 2 the answer cannot be truste
97
106
  * to a file no brief protects reads none.
98
107
  */
99
108
  const reader = async () => (await import('./reader.js')).createReader();
100
- async function decisionsFor(workspace, options, io, given, cwd) {
101
- const active = await activeBrief(workspace, options, io.env);
102
- const { brief, note, problem } = describeActive(active);
109
+ /**
110
+ * The work tree with spec-brief's answers kept for a session of Claude
111
+ * Code's, and given again to it where all they were read from reads the
112
+ * same (ADR-0002); as it is where no session is named.
113
+ */
114
+ function keptFor(workspace, session) {
115
+ return session === null ? workspace : { ...workspace, siblings: workspace.siblings.keeping(workspace.commonDir, session) };
116
+ }
117
+ async function decisionsFor(workspace, options, io, given, cwd, session = null) {
118
+ // A path outside every work tree of the repository is decided as one
119
+ // outside it, whichever brief is named and whatever is wrong with the
120
+ // name: the problem held every write, a file outside the repository
121
+ // among them, where no brief decides one (ADR-0005). So no brief is asked
122
+ // about for such a write, and spec-brief is not run for it.
123
+ const outside = outsideEvery(given, workspace.root, workspace.commonDir, cwd);
124
+ const active = outside ? null : await activeBrief(keptFor(workspace, session), options, io.env);
125
+ const { brief, note, problem } = active === null ? { brief: null, note: undefined, problem: null } : describeActive(active);
103
126
  if (problem !== null)
104
127
  return { problem };
105
128
  const { checkPaths } = await import('./round.js');
@@ -158,10 +181,11 @@ async function readStdin(io) {
158
181
  }
159
182
  /**
160
183
  * 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.
184
+ * the exit code that answers already, 0: for a question that cannot be read,
185
+ * said to the person, and for a tool that writes no file. Asked before
186
+ * anything on the hook's own line is, so that a line that is wrong is said
187
+ * where there is a write it leaves unchecked, or a session whose every write
188
+ * it leaves so.
165
189
  */
166
190
  async function writeAsked(io) {
167
191
  let question;
@@ -171,34 +195,61 @@ async function writeAsked(io) {
171
195
  catch (error) {
172
196
  // A question that could not be read is one the hook cannot see, as one
173
197
  // it cannot parse is, below: it has no event to hold a write by.
174
- io.stderr.write(`spec-harness: the hook input could not be read: ${error.message}\n`);
175
- return EXIT_FAILED;
198
+ return answer(io, warningResponse(`the hook input could not be read: ${error.message}, so nothing it asks about is checked`));
176
199
  }
177
200
  const request = parseClaudeHook(question);
178
- if ('error' in request) {
179
- // A hook that cannot read its question refuses nothing it cannot see,
180
- // but says so where the person reads it: a non-blocking error.
181
- io.stderr.write(`spec-harness: ${request.error}\n`);
182
- return EXIT_FAILED;
183
- }
184
- return request.paths.length === 0 && request.event !== SESSION_START ? EXIT_OK : request;
201
+ // A hook that cannot read its question refuses nothing it cannot see, and
202
+ // says so where the person reads it.
203
+ if ('error' in request)
204
+ return answer(io, warningResponse(`${request.error}, so nothing it asks about is checked`));
205
+ return request.paths.length === 0 && request.event !== SESSION_START && request.event !== TURN_END ? EXIT_OK : request;
185
206
  }
186
207
  /**
187
208
  * What is wrong with the hook's own line, said and nothing refused for it:
188
- * one line, since Claude Code shows the first line of the stderr of a hook
189
- * that exits 1 and goes on with the write, where exit 2 would hold every
190
- * write of the session, the one that mends the line among them (ADR-0005).
209
+ * one line, shown to the person, and exit 0, where exit 2 would hold every
210
+ * write of the session, the one that mends the line among them. Exit 1 said
211
+ * it as well, and is what Claude Code counts as a hook that failed, which
212
+ * `onFailure: "block"` holds the write for (ADR-0005).
191
213
  */
192
214
  function guardOff(io, { wrong, mend }, event) {
193
215
  if (event === SESSION_START)
194
216
  return answer(io, sessionResponse(`${wrong}, so the guard is off and no write of this session is checked; ${mend}`));
195
- io.stderr.write(`spec-harness: ${wrong}, so the guard is off and this write is not checked; ${mend}\n`);
196
- return EXIT_FAILED;
217
+ if (event === TURN_END)
218
+ return answer(io, turnResponse(`spec-harness: ${wrong}, so the guard is off and no write of this session is checked; ${mend}`));
219
+ return answer(io, warningResponse(`${wrong}, so the guard is off and this write is not checked; ${mend}`));
197
220
  }
198
221
  function answer(io, response) {
199
222
  io.stdout.write(response.stdout);
200
223
  return response.exitCode;
201
224
  }
225
+ /**
226
+ * The answer to a write a configuration that does not load holds no more,
227
+ * or `null` for every other write, which is held as before (ADR-0005). Every
228
+ * write was held. A write outside every work tree of the repository is
229
+ * decided as such a write is, after the write as before it: nothing of the
230
+ * repository's decides it. And before a write to the configuration file
231
+ * itself, and to nothing else, the line says why every other write is held,
232
+ * so that a session can mend a typo in it. `cwd` is what a relative path
233
+ * is read from.
234
+ */
235
+ function mending(error, request, cwd, env) {
236
+ // Every configuration the hook loads is read from a file, so `file` is
237
+ // never `null` here, and the test of it is the compiler's: its mutant is
238
+ // equivalent.
239
+ if (!(error instanceof ConfigError) || error.file === null)
240
+ return null;
241
+ // The work trees are named by the files git keeps, where git would name
242
+ // the same ones; a repository whose configuration or environment moves a
243
+ // work tree elsewhere is not known to leave the path outside. The file
244
+ // was loaded from the top git named, where a `.git` stands, so the files
245
+ // name a work tree, and the test of `null` is the compiler's.
246
+ const found = gitDirectories(dirname(error.file), env);
247
+ if (found !== null && found.exact && outsideEvery(request.paths, found.top, found.common, cwd))
248
+ return NONE;
249
+ if (request.event !== 'PreToolUse' || !writesOnly(request.paths, error.file, cwd))
250
+ return null;
251
+ return mendResponse(`${error.message}, so every other write is held until it loads; this write, to ${error.file} itself, is not checked`);
252
+ }
202
253
  /**
203
254
  * What would hold every write of the session that starts, said once as it
204
255
  * starts and no write waits on it (ADR-0005): a configuration that does not
@@ -208,7 +259,7 @@ function answer(io, response) {
208
259
  * `doctor` says, and with what mends it. `null` where nothing does. The
209
260
  * check never ends the session's start: whatever it meets is said.
210
261
  */
211
- async function sessionProblem(options, io, root, cwd) {
262
+ async function sessionProblem(options, io, root, cwd, session) {
212
263
  const held = 'so every write of this session is held until it is mended';
213
264
  try {
214
265
  const named = await openWorkspace({ ...options, root }, io);
@@ -216,7 +267,7 @@ async function sessionProblem(options, io, root, cwd) {
216
267
  // there, and so is the check (ADR-0005).
217
268
  const found = cwd === null ? null : await otherTree(named, cwd);
218
269
  const workspace = found === null ? named : await openFound(found, io, () => [named.root]);
219
- const { problem } = describeActive(await activeBrief(workspace, options, io.env));
270
+ const { problem } = describeActive(await activeBrief(keptFor(workspace, session), options, io.env));
220
271
  return problem === null ? null : `${heldHookLine(problem).wrong}, ${held}: fix the brief or the branch before writing; spec-harness doctor says more`;
221
272
  }
222
273
  catch (error) {
@@ -227,7 +278,54 @@ async function sessionProblem(options, io, root, cwd) {
227
278
  // briefs, and whatever a defect throws.
228
279
  if (error instanceof UsageError)
229
280
  return null;
230
- return `${error.message}, ${held}; spec-harness doctor says more`;
281
+ // But a write to the configuration file itself, which mends it, where
282
+ // the file is one a write may reach under its name alone (ADR-0005). The
283
+ // configuration is read from a file here as before a write, so the test
284
+ // of `file` is the compiler's, and its mutant is equivalent.
285
+ const mendable = error instanceof ConfigError && error.file !== null && writesOnly([error.file], error.file, root);
286
+ return `${error.message}, ${held}${mendable ? `, but for a write to ${error.file} itself, which is not checked` : ''}; spec-harness doctor says more`;
287
+ }
288
+ }
289
+ /** The most paths a report names one by one: past it, how many more there are. */
290
+ const REPORTED = 20;
291
+ /**
292
+ * What the work tree holds at the end of a turn that the brief protects or
293
+ * that is outside its scope, one line a path in the words a write of it is
294
+ * given; or `null` where there is nothing to say: `turnReport` is off, no
295
+ * brief is named, nothing changed is either, or the session is in no work
296
+ * tree (ADR-0005). The work tree is the one a write of the session is
297
+ * decided in. One start of git reads what changed, and nothing is asked of
298
+ * the base, so no ruling is read: a protected file a signed ruling allows
299
+ * is said as protected, and the audit reads the ruling. A configuration
300
+ * that does not load is said by every write and as the session starts, and
301
+ * here holds the report back without a word.
302
+ */
303
+ async function turnReport(options, io, root, cwd) {
304
+ try {
305
+ const named = await openWorkspace({ ...options, root }, io);
306
+ const found = cwd === null ? null : await otherTree(named, cwd);
307
+ const workspace = found === null ? named : await openFound(found, io, () => [named.root]);
308
+ if (!workspace.config.turnReport)
309
+ return null;
310
+ const { brief, problem } = describeActive(await activeBrief(workspace, options, io.env));
311
+ if (problem !== null)
312
+ return `spec-harness: ${heldHookLine(problem).wrong}, so what the work tree holds is not reported, and every write is held; spec-harness doctor says more`;
313
+ if (brief === null)
314
+ return null;
315
+ const [changed, { decide }] = await Promise.all([changedPaths(workspace.root), import('./guard.js')]);
316
+ const outOfScope = workspace.config.outOfScope;
317
+ const said = changed.map((path) => decide({ path, given: path, brief, rulings: [], outOfScope })).filter((decision) => decision.verdict !== 'allow');
318
+ if (said.length === 0)
319
+ return null;
320
+ const lines = said.slice(0, REPORTED).map((decision) => `- ${decision.message}. Next: ${decision.hint}.`);
321
+ const more = said.length > REPORTED ? [`- and ${said.length - REPORTED} more: spec-harness guard <path> says each`] : [];
322
+ const paths = said.length === 1 ? `a path that brief ${brief.id} protects or that is outside its scope` : `${said.length} paths that brief ${brief.id} protects or that are outside its scope`;
323
+ return [`spec-harness: as this turn ended, the work tree held changes to ${paths}:`, ...lines, ...more].join('\n');
324
+ }
325
+ catch (error) {
326
+ if (error instanceof UsageError || error instanceof ConfigError)
327
+ return null;
328
+ return `spec-harness: ${error.message}, so what the work tree holds is not reported`;
231
329
  }
232
330
  }
233
331
  /**
@@ -243,79 +341,6 @@ async function unreadLine(said, io) {
243
341
  return request;
244
342
  return guardOff(io, unreadHookLine(said).fault, request.event);
245
343
  }
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
344
  /**
320
345
  * The answer after a write, where it can be given with no process started,
321
346
  * or `null` where it is to be decided as before (ADR-0002, ADR-0003).
@@ -329,7 +354,7 @@ async function writesBy(named, cwd, paths, io) {
329
354
  * `deny` there is nothing to say, where the configuration read is the one
330
355
  * git would have the hook read, of the work tree the session is in.
331
356
  */
332
- async function answeredAfter(root, record, cwd, io) {
357
+ async function answeredAfter(root, record, cwd, paths, io) {
333
358
  const found = gitDirectories(root, io.env);
334
359
  if (found === null)
335
360
  return null;
@@ -341,7 +366,9 @@ async function answeredAfter(root, record, cwd, io) {
341
366
  if (warnings !== null)
342
367
  return afterResponse(warnings);
343
368
  }
344
- if (!found.exact || (cwd !== null && mayBeElsewhere(cwd, found.top)))
369
+ // A path in another work tree than the one this configuration is read
370
+ // from may be decided by another's, which may warn.
371
+ if (!found.exact || (cwd !== null && mayBeElsewhere(cwd, found.top)) || paths.some((path) => mayBeElsewhere(resolve(cwd ?? io.cwd, path), found.top)))
345
372
  return null;
346
373
  try {
347
374
  return (await loadConfig(found.top)).config.outOfScope === 'warn' ? null : afterResponse([]);
@@ -351,6 +378,15 @@ async function answeredAfter(root, record, cwd, io) {
351
378
  return null;
352
379
  }
353
380
  }
381
+ /**
382
+ * The answer before a write the hook could not check: one line, and exit 2,
383
+ * which holds the write. Claude Code shows the line to Claude as the
384
+ * reason.
385
+ */
386
+ function cannotCheck(io, error) {
387
+ io.stderr.write(`spec-harness: cannot check this write: ${error.message}\n`);
388
+ return EXIT_ERROR;
389
+ }
354
390
  async function hookCommand(options, io) {
355
391
  const kind = options.positionals[0];
356
392
  if (kind === 'claude') {
@@ -364,11 +400,14 @@ async function hookCommand(options, io) {
364
400
  return guardOff(io, line.fault, request.event);
365
401
  const cwd = request.cwd ?? io.cwd;
366
402
  const root = line.root ?? io.env['CLAUDE_PROJECT_DIR'] ?? cwd;
403
+ const session = sessionOf(request);
367
404
  if (request.event === SESSION_START)
368
- return answer(io, sessionResponse(await sessionProblem(options, io, root, request.cwd)));
405
+ return answer(io, sessionResponse(await sessionProblem(options, io, root, request.cwd, session)));
406
+ if (request.event === TURN_END)
407
+ return answer(io, turnResponse(await turnReport(options, io, root, request.cwd)));
369
408
  const record = callRecord(request, { root: line.root, brief: options.brief, base: options.base }, io.cwd, io.env, version());
370
409
  if (request.event === 'PostToolUse') {
371
- const answered = await answeredAfter(root, record, request.cwd, io);
410
+ const answered = await answeredAfter(root, record, request.cwd, request.paths, io);
372
411
  if (answered !== null) {
373
412
  io.stdout.write(answered.stdout);
374
413
  return answered.exitCode;
@@ -381,6 +420,14 @@ async function hookCommand(options, io) {
381
420
  catch (error) {
382
421
  if (error instanceof UsageError)
383
422
  return EXIT_OK;
423
+ const mended = mending(error, request, cwd, io.env);
424
+ if (mended !== null)
425
+ return answer(io, mended);
426
+ // git asked where the work tree is and did not answer within its time
427
+ // limit: before a write that is a write the hook could not check, said
428
+ // as one (ADR-0002).
429
+ if (error instanceof GitError && request.event === 'PreToolUse')
430
+ return cannotCheck(io, error);
384
431
  throw error;
385
432
  }
386
433
  try {
@@ -392,7 +439,7 @@ async function hookCommand(options, io) {
392
439
  // answer is made from, in any work tree that decides it.
393
440
  let keeps = true;
394
441
  for (const part of await writesBy(workspace, request.cwd, request.paths, io)) {
395
- const decided = await decisionsFor(part.workspace, options, io, part.paths, cwd);
442
+ const decided = await decisionsFor(part.workspace, options, io, part.paths, cwd, session);
396
443
  if ('problem' in decided) {
397
444
  // Exit 2 blocks the tool call, and its stderr reaches the model. Where
398
445
  // the name stands on the hook's own line, what is wrong with it is
@@ -418,10 +465,15 @@ async function hookCommand(options, io) {
418
465
  catch (error) {
419
466
  if (request.event !== 'PreToolUse')
420
467
  throw error;
468
+ // The configuration of another work tree the write is decided in,
469
+ // which does not load, lets a write to itself through as the
470
+ // project's does.
471
+ const mended = mending(error, request, cwd, io.env);
472
+ if (mended !== null)
473
+ return answer(io, mended);
421
474
  // A round is named and its briefs cannot be read: the guard cannot
422
475
  // tell a protected file from any other, so the write waits.
423
- io.stderr.write(`spec-harness: cannot check this write: ${error.message}\n`);
424
- return EXIT_ERROR;
476
+ return cannotCheck(io, error);
425
477
  }
426
478
  }
427
479
  if (kind === 'git') {
@@ -459,6 +511,15 @@ async function baseAndSigners({ resolveBase }, workspace, flag, signersFile) {
459
511
  const signersText = base.kind === 'resolved' ? await show(base.sha, signersFile, workspace.root) : null;
460
512
  return { base, signersText };
461
513
  }
514
+ /**
515
+ * The release of spec-harness installed in a work tree, where the hook's
516
+ * line, init's and the plugin's, names its script under the project's
517
+ * `node_modules`; `null` where none is, or its manifest gives no version.
518
+ */
519
+ function harnessRelease(root) {
520
+ const version = declaredVersion(join(root, 'node_modules', '@descent-vtt', 'spec-harness', 'package.json'));
521
+ return typeof version === 'string' ? version : null;
522
+ }
462
523
  /**
463
524
  * How Claude Code is wired to the guard, and the release of the one on
464
525
  * `PATH`, which waits on the wiring: only a Claude Code that runs the guard
@@ -476,7 +537,7 @@ async function doctorCommand(options, io) {
476
537
  // What doctor alone runs of them, loaded before anything is asked: the
477
538
  // five things below are asked at once, and none waits on a module.
478
539
  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;
540
+ const { describeBase, describeClaudeCode, describeClaudeRelease, describeGitHook, describeHooksOff, describeInstalls, describePlugin, describePresence, describeSignerKeys, describeSignerProblem, describeSigners, guardSwitchedOff, guardWired, NO_SESSION_START, wiringState } = configure;
480
541
  const { diagnoseBrief, specBriefPlugin } = round;
481
542
  const { gitHook } = setup;
482
543
  const { notFido2, readAllowedSigners } = signing;
@@ -540,7 +601,12 @@ async function doctorCommand(options, io) {
540
601
  // And whether the hook is started as a session starts, where settings hold
541
602
  // the guard: a note, failing nothing, and `sessionStart` in the document.
542
603
  const atStart = wiring.hooks.length > 0 && !wiring.sessionStart ? NO_SESSION_START : null;
543
- const wiringNotes = [describePresence(wiring), wiring.hooksOff === null ? null : describeHooksOff(wiring.hooksOff, switchedOff)].filter((note) => note !== null);
604
+ // And, in a linked worktree where something wires the guard, the release
605
+ // the hook's line runs where it is not this work tree's own: a note. In
606
+ // the main work tree both are read from one install, and are one.
607
+ const main = mainWorkTree(workspace.commonDir);
608
+ const installs = guardWired(wiring) && main !== null ? describeInstalls(main, harnessRelease(main), harnessRelease(workspace.root)) : null;
609
+ const wiringNotes = [describePresence(wiring), wiring.hooksOff === null ? null : describeHooksOff(wiring.hooksOff, switchedOff), installs].filter((note) => note !== null);
544
610
  // How the hook runs the harness is a note: one that goes through npx guards
545
611
  // as well as one that does not, and fails nothing, --strict or not.
546
612
  const { hook, note: hookNote } = answerOf(hookAsked);
@@ -601,14 +667,17 @@ async function doctorCommand(options, io) {
601
667
  // a choice; with none installed, the harness can check nothing. A double
602
668
  // install is a problem too, where no Claude Code wiring at all is a choice,
603
669
  // and so are a hook whose own line turns the guard off or holds every
604
- // write, guard hooks that disableAllHooks switches off, a plugin that is
605
- // turned on and is not on this machine where no hook of init's stands in
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.
670
+ // write, a branch that names a brief no round can work under, which holds
671
+ // every write as well (ADR-0004), guard hooks that disableAllHooks
672
+ // switches off, a plugin that is turned on and is not on this machine
673
+ // where no hook of init's stands in for it, a Claude Code too old to run
674
+ // the hooks, and something at the path of git's pre-commit hook that git
675
+ // cannot run and stops every commit at. A release that cannot be told is
676
+ // not fine either, nor a line whose reading cannot be, nor a plugin that
677
+ // cannot be told to be on this machine, nor a hook git runs that cannot be
678
+ // read, which stops every commit as a script and runs as a program, nor a
679
+ // place git runs its hooks from that git cannot name: --strict fails the
680
+ // five.
612
681
  const outdated = rows.some((row) => row.state === 'outdated');
613
682
  const usable = rows.some((row) => row.state === 'found');
614
683
  const off = wiring.lines.some((line) => line.state === 'off' || line.state === 'held');
@@ -617,8 +686,9 @@ async function doctorCommand(options, io) {
617
686
  const stopped = hook.state === 'stops';
618
687
  // Only an unreadable hook has `runs`, so the test of its state is the
619
688
  // 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;
689
+ const untold = options.strict &&
690
+ (release?.state === 'unknown' || wiring.lines.some((line) => line.state === 'unknown') || wiring.presence?.state === 'unknown' || (hook.state === 'unreadable' && hook.runs) || hook.state === 'unknown');
691
+ return outdated || !usable || claude === 'both' || off || named.branch || switchedOff || missing || tooOld || stopped || untold ? EXIT_FAILED : EXIT_OK;
622
692
  }
623
693
  /* ---------------------------------------------------------------- dispatch */
624
694
  /**
@@ -683,6 +753,13 @@ async function dispatch(argv, io) {
683
753
  io.stderr.write(`spec-harness: ${refused}\n`);
684
754
  return EXIT_ERROR;
685
755
  }
756
+ // Claude Code's hook is exempt from every refusal above, and said a root
757
+ // that is not a directory only as it ran, which the help or the version
758
+ // answered in place of: printed beside one, either is a line it cannot
759
+ // read, said where there is a write it leaves unchecked (ADR-0005).
760
+ const beside = isClaudeHook(options.command, options.positionals[0]) ? answerBeside(options, io.cwd, isDirectory) : null;
761
+ if (beside !== null)
762
+ return unreadLine(beside, io);
686
763
  if (options.version) {
687
764
  io.stdout.write(`${version()}\n`);
688
765
  return EXIT_OK;