@descent-vtt/spec-harness 0.19.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.
Files changed (50) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +325 -0
  4. package/README.md +39 -18
  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 +308 -142
  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 +94 -3
  17. package/dist/configure.js +204 -30
  18. package/dist/configure.js.map +1 -1
  19. package/dist/fs.d.ts +104 -1
  20. package/dist/fs.js +383 -11
  21. package/dist/fs.js.map +1 -1
  22. package/dist/git.d.ts +88 -2
  23. package/dist/git.js +155 -8
  24. package/dist/git.js.map +1 -1
  25. package/dist/guard.d.ts +20 -1
  26. package/dist/guard.js +31 -0
  27. package/dist/guard.js.map +1 -1
  28. package/dist/hooks.d.ts +59 -0
  29. package/dist/hooks.js +76 -3
  30. package/dist/hooks.js.map +1 -1
  31. package/dist/round.d.ts +27 -1
  32. package/dist/round.js +75 -17
  33. package/dist/round.js.map +1 -1
  34. package/dist/sandbox.js +4 -3
  35. package/dist/sandbox.js.map +1 -1
  36. package/dist/server.d.ts +25 -1
  37. package/dist/server.js +230 -28
  38. package/dist/server.js.map +1 -1
  39. package/dist/setup.js +40 -10
  40. package/dist/setup.js.map +1 -1
  41. package/dist/siblings.d.ts +29 -6
  42. package/dist/siblings.js +152 -39
  43. package/dist/siblings.js.map +1 -1
  44. package/dist/usage.d.ts +42 -7
  45. package/dist/usage.js +65 -6
  46. package/dist/usage.js.map +1 -1
  47. package/dist/workspace.d.ts +45 -3
  48. package/dist/workspace.js +143 -13
  49. package/dist/workspace.js.map +1 -1
  50. package/package.json +3 -3
@@ -1 +1 @@
1
- {"version":3,"file":"sandbox.js","sourceRoot":"","sources":["../src/sandbox.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Temporary worktrees: somewhere to run a command at a commit without\n * touching the person's own checkout.\n *\n * The family's tools read git and never write it (spec-core ADR-0005). The one\n * exception is this: a detached worktree under the system's temporary\n * directory, added for one job and removed after it, whether the job\n * succeeded, threw, or the process was interrupted (ADR-0003). The person's\n * branch, index, stash and working files are never touched; a failed round is\n * discarded by discarding its worktree, never by resetting theirs.\n *\n * Git forgets a worktree whatever becomes of its directory. A directory that\n * cannot be deleted, as on Windows while something a command started still\n * runs in it, costs the job nothing: its answer stands, the directory is\n * tried again as the process exits, and one still there then is named on the\n * standard error.\n */\n\nimport { execFileSync, spawn, type ChildProcess } from 'node:child_process';\nimport { mkdtempSync, rmSync } from 'node:fs';\nimport { constants, tmpdir } from 'node:os';\nimport { join } from 'node:path';\n\nimport { addWorktree, removeWorktree } from './git.js';\nimport { probeEnvironment } from './probe.js';\n\nconst live = new Map<string, string>();\nconst running = new Set<ChildProcess>();\n/**\n * The commands whose shell has ended and left a process in its process\n * group, each with the directory it ran in: what `withWorktree` stops when\n * the job in that directory ends, and an interrupt or the exit of the process\n * whenever it comes. Linux and macOS alone have such a group; on Windows\n * nothing is kept, since nothing a command left can be reached once its\n * shell has ended (ADR-0003).\n */\nconst left = new Map<ChildProcess, string>();\nlet installed = false;\n/** Set once the process is interrupted: from then on a command's end is never answered. */\nlet ending = false;\n\n/**\n * How long the sandbox waits for the commands it stopped to let go of their\n * output and their directory. They were stopped forced, so this is only the\n * time the system takes to end them; whatever still holds either after it has\n * left the tree, and no wait would end it.\n *\n * It is also how long the output of a command whose shell ended by itself is\n * waited for: what still holds it then is something the command started and\n * did not wait for, which is no part of the command.\n */\nconst SETTLE_MS = 3_000;\n\n/**\n * Whether a process has this id now. Signal 0 asks and sends nothing, and any\n * answer but \"no such process\" says one has it: another user's process\n * answers that it may not be signalled.\n */\nfunction taken(pid: number): boolean {\n try {\n process.kill(pid, 0);\n return true;\n } catch (error) {\n return (error as NodeJS.ErrnoException).code !== 'ESRCH';\n }\n}\n\n/**\n * Stops a command with what can be reached of what it started, forced:\n * SIGKILL to its process group on Linux and macOS, taskkill over its tree on\n * Windows. A process that left the group is not in it, and taskkill follows\n * a tree from parent to child while each parent runs: one whose parent has\n * ended is found under no root. Such a process runs on (ADR-0003).\n *\n * A command is stopped so at its timeout while its shell still runs, and at\n * an interrupt and at the exit of the process. One whose shell has ended is\n * answered and not stopped: what it left in its group runs on until the job\n * in its worktree ends, as what a step leaves runs on until its job ends in\n * CI, and is stopped then, by this.\n *\n * Both name the command by its id, and the id is the command's only while\n * something holds it (ADR-0003). Node holds the command's shell until it has\n * seen the shell end, and until then the system gives the id to nothing else.\n * After that the id is free on Windows, where a process started seconds later\n * can be given it: taskkill would stop that process with all it started, and\n * could not have found what the command left anyway, since it finds no tree\n * under a root that has ended. So a command whose shell has ended is not\n * stopped there. On Linux and macOS the id names the command's process group\n * as well, and stays out of use while the group has a member: what the shell\n * left in its group is still stopped, unless a process has the id now, which\n * says the group emptied and the id was given out again.\n *\n * `windowsHide` here and below only keeps a console window from opening on\n * Windows, which nothing reads, so its mutants are equivalent.\n */\nfunction stop(child: ChildProcess): void {\n // No pid means the spawn failed and nothing runs. Past the tick it failed\n // in, the kill below throws on the missing id and the fallback finds no\n // process, so the mutant that lets it through is equivalent; within that\n // tick nothing here stops a command.\n if (child.pid === undefined) return;\n // Node sets one of the two when it sees the shell end, and lets go of the\n // shell then. Windows reports no signal: there the second is set only once\n // the fallback below has stopped the shell, on the path no test takes.\n const held = child.exitCode === null && child.signalCode === null;\n // Windows is not asked whether a process has the id: one could be given it\n // between the answer and taskkill. Elsewhere `win32` is false already, so\n // the mutants that make it false are equivalent there.\n if (!held && (process.platform === 'win32' || taken(child.pid))) return;\n try {\n // False already on Linux and macOS, so the mutants that make it false\n // are equivalent there. On Windows the tests fail without taskkill:\n // stopping the shell alone leaves node holding the output. True already\n // on Windows, so the mutant that makes it true is equivalent there;\n // elsewhere there is no taskkill, and the shell alone is stopped.\n if (process.platform === 'win32') execFileSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });\n else process.kill(-child.pid, 'SIGKILL');\n } catch {\n // Reached when the group is already gone, and the command's shell with\n // it, as when the command left something outside the group holding its\n // output; on Windows when the shell has only just ended, which taskkill\n // sees before Node does; and there when taskkill cannot be run, where\n // stopping the shell is what is left. No test takes that last path.\n // `kill` goes through Node's own hold on the shell, and does nothing\n // once that is gone.\n child.kill('SIGKILL');\n }\n}\n\n/**\n * Deletes a worktree's directory, trying again for up to `SETTLE_MS` while\n * something holds it, and answers `null` once it is gone, or else why it is\n * not. On Windows a command just stopped lets go of its directory a moment\n * after taskkill returns, and an exit handler, which runs synchronously, has\n * no other way to wait; elsewhere a directory in use is deleted all the same.\n */\nfunction discard(directory: string): Error | null {\n const deadline = Date.now() + SETTLE_MS;\n for (;;) {\n try {\n rmSync(directory, { recursive: true, force: true });\n return null;\n } catch (error) {\n // `>=` would differ only in the millisecond the deadline falls on, so\n // that mutant is equivalent.\n if (Date.now() > deadline) return error as Error;\n // The pause only spares the processor between attempts: without it\n // the directory is deleted, or given up on, at the same moment, so\n // that mutant is equivalent.\n Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);\n }\n }\n}\n\n/**\n * Has git remove the worktree at `directory` with its record of it, as\n * `removeWorktree` does and synchronously, and answers whether git did.\n */\nfunction forget(directory: string, repository: string): boolean {\n try {\n execFileSync('git', ['worktree', 'remove', '--force', directory], { cwd: repository, stdio: 'ignore', windowsHide: true });\n // Asked again about a worktree it has forgotten, git changes nothing, so\n // the mutant that answers false here is equivalent.\n return true;\n } catch {\n // Git refused a worktree its job broke, could not delete all of the\n // directory, holds nothing of it any more, or cannot be run. The answer\n // is read with `!` and nowhere else, so the mutant that answers nothing\n // here is equivalent.\n return false;\n }\n}\n\n/**\n * Stops what the commands that ran in `directory` left in their process\n * groups, and forgets those commands: the end of a job, before its worktree\n * is removed. With no directory, what every command left: an interrupt, or\n * the exit of the process. One job's end leaves what another job's commands\n * left, which that job may still be using.\n */\nfunction clear(directory?: string): void {\n for (const [child, where] of left) {\n if (directory !== undefined && where !== directory) continue;\n stop(child);\n left.delete(child);\n }\n}\n\n/**\n * Stops every command still running, then removes every live worktree:\n * synchronously, the only kind of work an exit handler may do, so at exit\n * nothing waits for the commands to end but `discard`'s retries. A directory\n * that outlasts those is named on the standard error and left: nothing\n * thrown here could be caught, and the next worktree is still to remove.\n */\nfunction sweep(): void {\n for (const child of running) stop(child);\n clear();\n for (const [directory, repository] of live) {\n // In the order a job's end takes, for the reasons `withWorktree` gives.\n const forgotten = forget(directory, repository);\n const kept = discard(directory);\n if (kept !== null) process.stderr.write(`spec-harness: the temporary worktree at ${directory} could not be deleted and is left there: ${kept.message}\\n`);\n // The mutant that always asks again is equivalent, as in `forget`.\n else if (!forgotten) forget(directory, repository);\n }\n // A signal handler ends in an exit, which sweeps again: over worktrees\n // already gone and forgotten, where keeping them would change nothing, and\n // over one that could not be deleted, which would be named a second time.\n live.clear();\n}\n\n/**\n * Stops every running command with its tree, waits a bounded time for them to\n * let go of their output, removes the worktrees, and exits as a shell reports\n * a process the signal ended: 128 and the signal's number, the same on every\n * platform Node runs on. The commands lead process groups of their own on\n * Linux and macOS, so the terminal's signal never reached them.\n */\nasync function interrupted(signal: NodeJS.Signals): Promise<void> {\n ending = true;\n const stopping = [...running];\n for (const child of stopping) stop(child);\n // A command the stop ended is answered `SETTLE_MS` after its shell ended,\n // its output let go, so every one of them closes by then and the first of\n // the two below is over as soon as the second. The second is for a shell\n // the stop did not end, which no test can make: its mutant survives.\n await Promise.race([\n Promise.all(stopping.map((child) => new Promise((resolve) => child.once('close', resolve)))),\n new Promise((resolve) => setTimeout(resolve, SETTLE_MS)),\n ]);\n sweep();\n process.exit(128 + constants.signals[signal]);\n}\n\nfunction install(): void {\n if (installed) return;\n installed = true;\n process.once('exit', sweep);\n // `on`, not `once`: a second signal while the first waits would otherwise\n // meet Node's default, which ends the process before any 'exit' handler\n // runs, with the worktrees still in place. It runs the same steps again,\n // and the first to finish exits.\n //\n // SIGBREAK is Windows' own: Ctrl+Break, which GitHub's runner sends there,\n // after Ctrl+C, where it sends SIGTERM elsewhere. Unheard, it ends the\n // process before any 'exit' handler runs, and the commands run on.\n // Elsewhere there is no such signal and nothing calls its listener, so\n // its mutants are equivalent there.\n for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGBREAK'] satisfies NodeJS.Signals[]) process.on(signal, interrupted);\n}\n\n/**\n * Runs `work` in a detached worktree of `repository` at `revision`, and\n * removes the worktree after.\n *\n * Git removes it first, while it is as the job left it: git then forgets the\n * worktree whether or not all of its directory can be deleted. What git left\n * of the directory is deleted next, which is all of it when the job broke the\n * worktree, since git refuses one whose `.git` file is gone. Only then is\n * git asked again, once the directory is gone, when it forgets what it finds\n * missing (`removeWorktree`).\n *\n * A directory that cannot be deleted stays on the list for the exit to try\n * again, and does not take the job's answer with it.\n *\n * What the job's commands left running in their process groups is stopped\n * first, so that the worktree is removed with as little running in it as the\n * sandbox can reach.\n */\nexport async function withWorktree<T>(repository: string, revision: string, work: (directory: string) => Promise<T>): Promise<T> {\n install();\n const directory = mkdtempSync(join(tmpdir(), 'spec-harness-'));\n live.set(directory, repository);\n try {\n await addWorktree(directory, revision, repository);\n return await work(directory);\n } finally {\n clear(directory);\n const forgotten = await removeWorktree(directory, repository);\n if (discard(directory) === null) {\n // Asked again about a worktree it has forgotten, git changes nothing,\n // so the mutant that always asks again is equivalent.\n if (!forgotten) await removeWorktree(directory, repository);\n live.delete(directory);\n }\n }\n}\n\nexport interface CommandRun {\n /** How the command's shell ended; `null` when the command was stopped at its timeout. */\n readonly exitCode: number | null;\n /** What the command had printed by the time it was answered. */\n readonly output: string;\n /**\n * Set on a command answered with its output still open: something it\n * started holds the output, `SETTLE_MS` after its shell ended or after its\n * timeout stopped what it could reach, and runs on (ADR-0003).\n */\n readonly outputHeld?: true;\n}\n\n/**\n * Runs a command line in a directory, output and errors interleaved, until\n * its shell ends. A command is its shell: it is answered with the shell's\n * exit code and what was printed, once the shell has ended and its output\n * has closed. A command whose shell outlives `timeoutSeconds` is stopped\n * with what `stop` reaches of it, as is one still running when the process\n * is interrupted or exits. Once the process is interrupted it never answers:\n * the job waiting on it would otherwise carry on in a worktree being removed.\n *\n * The wait for the output is bounded, after the shell's own end as after the\n * timeout. A shell that ends leaves its output open only where something it\n * started still holds it, and that is no part of the command: the answer is\n * the shell's, as a step's is in CI, where GitHub's runner waits five seconds\n * for such an output and goes on. Here the bound is `SETTLE_MS`, at the\n * shell's end and at the timeout alike. Then the sandbox lets go of the\n * output and answers with what was printed and with `outputHeld`. Letting go\n * is what the end of this process would do to whatever holds the output,\n * only sooner, and without it the open output keeps this process from ending\n * once its work is done.\n *\n * The timeout is the shell's: once the shell has ended it stops nothing, and\n * a command already judged by its exit code is not made a stopped one by it.\n *\n * Through the shell, on purpose: a probe's command is a line from the\n * repository's own brief - `npm test -- x` - which the person approved with\n * the brief, exactly as CI runs the repository's own scripts. Nothing from\n * outside the repository reaches it.\n *\n * The line is run as it is written. Its environment is the person's own as\n * `probeEnvironment` hands it on: `CI` set, and npm told to fetch nothing for\n * a command where neither that environment nor the line says what it may\n * fetch (ADR-0007).\n */\nexport function runCommand(line: string, cwd: string, timeoutSeconds: number): Promise<CommandRun> {\n install();\n return new Promise((resolve) => {\n // Elsewhere the command leads a process group of its own, so the timeout\n // can stop the group. Windows has none to signal, and taskkill follows\n // the tree instead; a command detached there loses its output, which the\n // tests catch on Windows. Everywhere else `detached` is true already, so\n // the mutants that make it true are equivalent there, as are\n // `windowsHide`'s.\n //\n // It is given no input: nobody is there to type any, and a pipe that is\n // never written to and never closed kept a command that reads its input\n // waiting until the timeout stopped it.\n const child = spawn(line, {\n cwd,\n shell: true,\n windowsHide: true,\n detached: process.platform !== 'win32',\n env: probeEnvironment(process.env),\n stdio: ['ignore', 'pipe', 'pipe'],\n });\n running.add(child);\n let output = '';\n const keep = (chunk: Buffer): void => {\n // Enough to find a signature in; a runaway log is not kept whole.\n if (output.length < 4 * 1024 * 1024) output += chunk.toString('utf8');\n };\n // Both streams are piped, so both exist: `?.` only satisfies the type,\n // and its mutants are equivalent.\n child.stdout?.on('data', keep);\n child.stderr?.on('data', keep);\n const answer = (run: CommandRun): void => {\n if (!ending) resolve(run);\n };\n let stopped = false;\n let settling: NodeJS.Timeout | undefined;\n // Answers a command whose output is still open once the bound on the\n // wait for it has passed: `exitCode` is how its shell ended, or `null`\n // for one stopped at its timeout.\n const settle = (exitCode: number | null): void => {\n settling = setTimeout(() => {\n // With both let go, 'close' follows as soon as Node has seen the\n // shell end, and takes the command off the list of those running.\n // The streams exist, as above, so the mutants of `?.` are equivalent.\n child.stdout?.destroy();\n child.stderr?.destroy();\n answer({ exitCode, output, outputHeld: true });\n }, SETTLE_MS);\n };\n const timer = setTimeout(() => {\n stopped = true;\n stop(child);\n settle(null);\n }, timeoutSeconds * 1000);\n child.on('error', (error) => {\n // A spawn that failed starts no shell, whose end would clear the timer.\n clearTimeout(timer);\n resolve({ exitCode: 127, output: `${output}${error.message}\\n` });\n });\n child.on('exit', (code) => {\n // The shell its timeout stopped: the timeout has the answer already.\n if (stopped) return;\n // The shell ended by itself, so the timeout has nothing left to stop.\n clearTimeout(timer);\n // A group of the shell's id that still has a member is what the shell\n // left running in it, to be stopped when its job ends. The id is set:\n // a shell that was never started does not end. Windows has no group,\n // and answers that there is none.\n if (taken(-(child.pid as number))) left.set(child, cwd);\n // A shell that a signal ended has no code: a failure, as in 'close'.\n settle(code ?? 1);\n });\n child.on('close', (code) => {\n // The command's own timer is over by now: cleared as its shell ended\n // or as its spawn failed, or run. What is left is the bound.\n clearTimeout(settling);\n running.delete(child);\n // The second answer of a command already answered as held does nothing.\n answer({ exitCode: stopped ? null : (code ?? 1), output });\n });\n });\n}\n"]}
1
+ {"version":3,"file":"sandbox.js","sourceRoot":"","sources":["../src/sandbox.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Temporary worktrees: somewhere to run a command at a commit without\n * touching the person's own checkout.\n *\n * The family's tools read git and never write it (spec-core ADR-0005). The one\n * exception is this: a detached worktree under the system's temporary\n * directory, added for one job and removed after it, whether the job\n * succeeded, threw, or the process was interrupted (ADR-0003). The person's\n * branch, index, stash and working files are never touched; a failed round is\n * discarded by discarding its worktree, never by resetting theirs.\n *\n * Git forgets a worktree whatever becomes of its directory. A directory that\n * cannot be deleted, as on Windows while something a command started still\n * runs in it, costs the job nothing: its answer stands, the directory is\n * tried again as the process exits, and one still there then is named on the\n * standard error.\n */\n\nimport { execFileSync, spawn, type ChildProcess } from 'node:child_process';\nimport { mkdtempSync, rmSync } from 'node:fs';\nimport { constants, tmpdir } from 'node:os';\nimport { join } from 'node:path';\n\nimport { addWorktree, ANSWER_MS, removeWorktree, WORK_MS } from './git.js';\nimport { probeEnvironment } from './probe.js';\n\nconst live = new Map<string, string>();\nconst running = new Set<ChildProcess>();\n/**\n * The commands whose shell has ended and left a process in its process\n * group, each with the directory it ran in: what `withWorktree` stops when\n * the job in that directory ends, and an interrupt or the exit of the process\n * whenever it comes. Linux and macOS alone have such a group; on Windows\n * nothing is kept, since nothing a command left can be reached once its\n * shell has ended (ADR-0003).\n */\nconst left = new Map<ChildProcess, string>();\nlet installed = false;\n/** Set once the process is interrupted: from then on a command's end is never answered. */\nlet ending = false;\n\n/**\n * How long the sandbox waits for the commands it stopped to let go of their\n * output and their directory. They were stopped forced, so this is only the\n * time the system takes to end them; whatever still holds either after it has\n * left the tree, and no wait would end it.\n *\n * It is also how long the output of a command whose shell ended by itself is\n * waited for: what still holds it then is something the command started and\n * did not wait for, which is no part of the command.\n */\nconst SETTLE_MS = 3_000;\n\n/**\n * Whether a process has this id now. Signal 0 asks and sends nothing, and any\n * answer but \"no such process\" says one has it: another user's process\n * answers that it may not be signalled.\n */\nfunction taken(pid: number): boolean {\n try {\n process.kill(pid, 0);\n return true;\n } catch (error) {\n return (error as NodeJS.ErrnoException).code !== 'ESRCH';\n }\n}\n\n/**\n * Stops a command with what can be reached of what it started, forced:\n * SIGKILL to its process group on Linux and macOS, taskkill over its tree on\n * Windows. A process that left the group is not in it, and taskkill follows\n * a tree from parent to child while each parent runs: one whose parent has\n * ended is found under no root. Such a process runs on (ADR-0003).\n *\n * A command is stopped so at its timeout while its shell still runs, and at\n * an interrupt and at the exit of the process. One whose shell has ended is\n * answered and not stopped: what it left in its group runs on until the job\n * in its worktree ends, as what a step leaves runs on until its job ends in\n * CI, and is stopped then, by this.\n *\n * Both name the command by its id, and the id is the command's only while\n * something holds it (ADR-0003). Node holds the command's shell until it has\n * seen the shell end, and until then the system gives the id to nothing else.\n * After that the id is free on Windows, where a process started seconds later\n * can be given it: taskkill would stop that process with all it started, and\n * could not have found what the command left anyway, since it finds no tree\n * under a root that has ended. So a command whose shell has ended is not\n * stopped there. On Linux and macOS the id names the command's process group\n * as well, and stays out of use while the group has a member: what the shell\n * left in its group is still stopped, unless a process has the id now, which\n * says the group emptied and the id was given out again.\n *\n * `windowsHide` here and below only keeps a console window from opening on\n * Windows, which nothing reads, so its mutants are equivalent.\n */\nfunction stop(child: ChildProcess): void {\n // No pid means the spawn failed and nothing runs. Past the tick it failed\n // in, the kill below throws on the missing id and the fallback finds no\n // process, so the mutant that lets it through is equivalent; within that\n // tick nothing here stops a command.\n if (child.pid === undefined) return;\n // Node sets one of the two when it sees the shell end, and lets go of the\n // shell then. Windows reports no signal: there the second is set only once\n // the fallback below has stopped the shell, on the path no test takes.\n const held = child.exitCode === null && child.signalCode === null;\n // Windows is not asked whether a process has the id: one could be given it\n // between the answer and taskkill. Elsewhere `win32` is false already, so\n // the mutants that make it false are equivalent there.\n if (!held && (process.platform === 'win32' || taken(child.pid))) return;\n try {\n // False already on Linux and macOS, so the mutants that make it false\n // are equivalent there. On Windows the tests fail without taskkill:\n // stopping the shell alone leaves node holding the output. True already\n // on Windows, so the mutant that makes it true is equivalent there;\n // elsewhere there is no taskkill, and the shell alone is stopped.\n // taskkill is a question answered at once, and given the time one is (git.ts).\n if (process.platform === 'win32') execFileSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true, timeout: ANSWER_MS });\n else process.kill(-child.pid, 'SIGKILL');\n } catch {\n // Reached when the group is already gone, and the command's shell with\n // it, as when the command left something outside the group holding its\n // output; on Windows when the shell has only just ended, which taskkill\n // sees before Node does; and there when taskkill cannot be run, where\n // stopping the shell is what is left. No test takes that last path.\n // `kill` goes through Node's own hold on the shell, and does nothing\n // once that is gone.\n child.kill('SIGKILL');\n }\n}\n\n/**\n * Deletes a worktree's directory, trying again for up to `SETTLE_MS` while\n * something holds it, and answers `null` once it is gone, or else why it is\n * not. On Windows a command just stopped lets go of its directory a moment\n * after taskkill returns, and an exit handler, which runs synchronously, has\n * no other way to wait; elsewhere a directory in use is deleted all the same.\n */\nfunction discard(directory: string): Error | null {\n const deadline = Date.now() + SETTLE_MS;\n for (;;) {\n try {\n rmSync(directory, { recursive: true, force: true });\n return null;\n } catch (error) {\n // `>=` would differ only in the millisecond the deadline falls on, so\n // that mutant is equivalent.\n if (Date.now() > deadline) return error as Error;\n // The pause only spares the processor between attempts: without it\n // the directory is deleted, or given up on, at the same moment, so\n // that mutant is equivalent.\n Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);\n }\n }\n}\n\n/**\n * Has git remove the worktree at `directory` with its record of it, as\n * `removeWorktree` does and synchronously, and answers whether git did.\n */\nfunction forget(directory: string, repository: string): boolean {\n try {\n execFileSync('git', ['worktree', 'remove', '--force', directory], { cwd: repository, stdio: 'ignore', windowsHide: true, timeout: WORK_MS });\n // Asked again about a worktree it has forgotten, git changes nothing, so\n // the mutant that answers false here is equivalent.\n return true;\n } catch {\n // Git refused a worktree its job broke, could not delete all of the\n // directory, holds nothing of it any more, or cannot be run. The answer\n // is read with `!` and nowhere else, so the mutant that answers nothing\n // here is equivalent.\n return false;\n }\n}\n\n/**\n * Stops what the commands that ran in `directory` left in their process\n * groups, and forgets those commands: the end of a job, before its worktree\n * is removed. With no directory, what every command left: an interrupt, or\n * the exit of the process. One job's end leaves what another job's commands\n * left, which that job may still be using.\n */\nfunction clear(directory?: string): void {\n for (const [child, where] of left) {\n if (directory !== undefined && where !== directory) continue;\n stop(child);\n left.delete(child);\n }\n}\n\n/**\n * Stops every command still running, then removes every live worktree:\n * synchronously, the only kind of work an exit handler may do, so at exit\n * nothing waits for the commands to end but `discard`'s retries. A directory\n * that outlasts those is named on the standard error and left: nothing\n * thrown here could be caught, and the next worktree is still to remove.\n */\nfunction sweep(): void {\n for (const child of running) stop(child);\n clear();\n for (const [directory, repository] of live) {\n // In the order a job's end takes, for the reasons `withWorktree` gives.\n const forgotten = forget(directory, repository);\n const kept = discard(directory);\n if (kept !== null) process.stderr.write(`spec-harness: the temporary worktree at ${directory} could not be deleted and is left there: ${kept.message}\\n`);\n // The mutant that always asks again is equivalent, as in `forget`.\n else if (!forgotten) forget(directory, repository);\n }\n // A signal handler ends in an exit, which sweeps again: over worktrees\n // already gone and forgotten, where keeping them would change nothing, and\n // over one that could not be deleted, which would be named a second time.\n live.clear();\n}\n\n/**\n * Stops every running command with its tree, waits a bounded time for them to\n * let go of their output, removes the worktrees, and exits as a shell reports\n * a process the signal ended: 128 and the signal's number, the same on every\n * platform Node runs on. The commands lead process groups of their own on\n * Linux and macOS, so the terminal's signal never reached them.\n */\nasync function interrupted(signal: NodeJS.Signals): Promise<void> {\n ending = true;\n const stopping = [...running];\n for (const child of stopping) stop(child);\n // A command the stop ended is answered `SETTLE_MS` after its shell ended,\n // its output let go, so every one of them closes by then and the first of\n // the two below is over as soon as the second. The second is for a shell\n // the stop did not end, which no test can make: its mutant survives.\n await Promise.race([\n Promise.all(stopping.map((child) => new Promise((resolve) => child.once('close', resolve)))),\n new Promise((resolve) => setTimeout(resolve, SETTLE_MS)),\n ]);\n sweep();\n process.exit(128 + constants.signals[signal]);\n}\n\nfunction install(): void {\n if (installed) return;\n installed = true;\n process.once('exit', sweep);\n // `on`, not `once`: a second signal while the first waits would otherwise\n // meet Node's default, which ends the process before any 'exit' handler\n // runs, with the worktrees still in place. It runs the same steps again,\n // and the first to finish exits.\n //\n // SIGBREAK is Windows' own: Ctrl+Break, which GitHub's runner sends there,\n // after Ctrl+C, where it sends SIGTERM elsewhere. Unheard, it ends the\n // process before any 'exit' handler runs, and the commands run on.\n // Elsewhere there is no such signal and nothing calls its listener, so\n // its mutants are equivalent there.\n for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGBREAK'] satisfies NodeJS.Signals[]) process.on(signal, interrupted);\n}\n\n/**\n * Runs `work` in a detached worktree of `repository` at `revision`, and\n * removes the worktree after.\n *\n * Git removes it first, while it is as the job left it: git then forgets the\n * worktree whether or not all of its directory can be deleted. What git left\n * of the directory is deleted next, which is all of it when the job broke the\n * worktree, since git refuses one whose `.git` file is gone. Only then is\n * git asked again, once the directory is gone, when it forgets what it finds\n * missing (`removeWorktree`).\n *\n * A directory that cannot be deleted stays on the list for the exit to try\n * again, and does not take the job's answer with it.\n *\n * What the job's commands left running in their process groups is stopped\n * first, so that the worktree is removed with as little running in it as the\n * sandbox can reach.\n */\nexport async function withWorktree<T>(repository: string, revision: string, work: (directory: string) => Promise<T>): Promise<T> {\n install();\n const directory = mkdtempSync(join(tmpdir(), 'spec-harness-'));\n live.set(directory, repository);\n try {\n await addWorktree(directory, revision, repository);\n return await work(directory);\n } finally {\n clear(directory);\n const forgotten = await removeWorktree(directory, repository);\n if (discard(directory) === null) {\n // Asked again about a worktree it has forgotten, git changes nothing,\n // so the mutant that always asks again is equivalent.\n if (!forgotten) await removeWorktree(directory, repository);\n live.delete(directory);\n }\n }\n}\n\nexport interface CommandRun {\n /** How the command's shell ended; `null` when the command was stopped at its timeout. */\n readonly exitCode: number | null;\n /** What the command had printed by the time it was answered. */\n readonly output: string;\n /**\n * Set on a command answered with its output still open: something it\n * started holds the output, `SETTLE_MS` after its shell ended or after its\n * timeout stopped what it could reach, and runs on (ADR-0003).\n */\n readonly outputHeld?: true;\n}\n\n/**\n * Runs a command line in a directory, output and errors interleaved, until\n * its shell ends. A command is its shell: it is answered with the shell's\n * exit code and what was printed, once the shell has ended and its output\n * has closed. A command whose shell outlives `timeoutSeconds` is stopped\n * with what `stop` reaches of it, as is one still running when the process\n * is interrupted or exits. Once the process is interrupted it never answers:\n * the job waiting on it would otherwise carry on in a worktree being removed.\n *\n * The wait for the output is bounded, after the shell's own end as after the\n * timeout. A shell that ends leaves its output open only where something it\n * started still holds it, and that is no part of the command: the answer is\n * the shell's, as a step's is in CI, where GitHub's runner waits five seconds\n * for such an output and goes on. Here the bound is `SETTLE_MS`, at the\n * shell's end and at the timeout alike. Then the sandbox lets go of the\n * output and answers with what was printed and with `outputHeld`. Letting go\n * is what the end of this process would do to whatever holds the output,\n * only sooner, and without it the open output keeps this process from ending\n * once its work is done.\n *\n * The timeout is the shell's: once the shell has ended it stops nothing, and\n * a command already judged by its exit code is not made a stopped one by it.\n *\n * Through the shell, on purpose: a probe's command is a line from the\n * repository's own brief - `npm test -- x` - which the person approved with\n * the brief, exactly as CI runs the repository's own scripts. Nothing from\n * outside the repository reaches it.\n *\n * The line is run as it is written. Its environment is the person's own as\n * `probeEnvironment` hands it on: `CI` set, and npm told to fetch nothing for\n * a command where neither that environment nor the line says what it may\n * fetch (ADR-0007).\n */\nexport function runCommand(line: string, cwd: string, timeoutSeconds: number): Promise<CommandRun> {\n install();\n return new Promise((resolve) => {\n // Elsewhere the command leads a process group of its own, so the timeout\n // can stop the group. Windows has none to signal, and taskkill follows\n // the tree instead; a command detached there loses its output, which the\n // tests catch on Windows. Everywhere else `detached` is true already, so\n // the mutants that make it true are equivalent there, as are\n // `windowsHide`'s.\n //\n // It is given no input: nobody is there to type any, and a pipe that is\n // never written to and never closed kept a command that reads its input\n // waiting until the timeout stopped it.\n const child = spawn(line, {\n cwd,\n shell: true,\n windowsHide: true,\n detached: process.platform !== 'win32',\n env: probeEnvironment(process.env),\n stdio: ['ignore', 'pipe', 'pipe'],\n });\n running.add(child);\n let output = '';\n const keep = (chunk: Buffer): void => {\n // Enough to find a signature in; a runaway log is not kept whole.\n if (output.length < 4 * 1024 * 1024) output += chunk.toString('utf8');\n };\n // Both streams are piped, so both exist: `?.` only satisfies the type,\n // and its mutants are equivalent.\n child.stdout?.on('data', keep);\n child.stderr?.on('data', keep);\n const answer = (run: CommandRun): void => {\n if (!ending) resolve(run);\n };\n let stopped = false;\n let settling: NodeJS.Timeout | undefined;\n // Answers a command whose output is still open once the bound on the\n // wait for it has passed: `exitCode` is how its shell ended, or `null`\n // for one stopped at its timeout.\n const settle = (exitCode: number | null): void => {\n settling = setTimeout(() => {\n // With both let go, 'close' follows as soon as Node has seen the\n // shell end, and takes the command off the list of those running.\n // The streams exist, as above, so the mutants of `?.` are equivalent.\n child.stdout?.destroy();\n child.stderr?.destroy();\n answer({ exitCode, output, outputHeld: true });\n }, SETTLE_MS);\n };\n const timer = setTimeout(() => {\n stopped = true;\n stop(child);\n settle(null);\n }, timeoutSeconds * 1000);\n child.on('error', (error) => {\n // A spawn that failed starts no shell, whose end would clear the timer.\n clearTimeout(timer);\n resolve({ exitCode: 127, output: `${output}${error.message}\\n` });\n });\n child.on('exit', (code) => {\n // The shell its timeout stopped: the timeout has the answer already.\n if (stopped) return;\n // The shell ended by itself, so the timeout has nothing left to stop.\n clearTimeout(timer);\n // A group of the shell's id that still has a member is what the shell\n // left running in it, to be stopped when its job ends. The id is set:\n // a shell that was never started does not end. Windows has no group,\n // and answers that there is none.\n if (taken(-(child.pid as number))) left.set(child, cwd);\n // A shell that a signal ended has no code: a failure, as in 'close'.\n settle(code ?? 1);\n });\n child.on('close', (code) => {\n // The command's own timer is over by now: cleared as its shell ended\n // or as its spawn failed, or run. What is left is the bound.\n clearTimeout(settling);\n running.delete(child);\n // The second answer of a command already answered as held does nothing.\n answer({ exitCode: stopped ? null : (code ?? 1), output });\n });\n });\n}\n"]}
package/dist/server.d.ts CHANGED
@@ -8,6 +8,30 @@
8
8
  import { type PromptDefinition, type ToolDefinition } from './vendor/spec-core/jsonrpc/index.js';
9
9
  import { type CliIO, type Options, type Workspace } from './workspace.js';
10
10
  export declare const INSTRUCTIONS: string;
11
- export declare function tools(workspace: Workspace, env: CliIO['env']): ToolDefinition[];
11
+ /**
12
+ * The directories a client's roots name, or `null` where it declared no
13
+ * roots, answered with an error, or did not answer in time.
14
+ */
15
+ export type Roots = () => Promise<readonly string[] | null>;
16
+ /** How long a client is given to answer `roots/list`: Claude Code answers from what it holds (its MCP page, below). */
17
+ export declare const ROOTS_MS = 5000;
18
+ /**
19
+ * The client's roots, asked as MCP has a server ask them, with a request of
20
+ * the server's own, `roots/list`, where the client declared the capability
21
+ * as it started: in its `initialize`, or a later request's `_meta`. Claude
22
+ * Code's MCP page (https://code.claude.com/docs/en/mcp, Option 3: Add a local
23
+ * stdio server, read 2026-10-19) has it answer with the session's launch
24
+ * directory and every working directory added to it, and
25
+ * `CLAUDE_PROJECT_DIR` the stable project root, which does not move with
26
+ * them. The answer arrives on the input as a response, which the protocol's
27
+ * module passes over: it is read here first, by the id it was asked under.
28
+ */
29
+ export interface RootsClient {
30
+ /** Reads a message before the protocol's handler does: `true` where it was the answer to a request of this one's, which is then no one else's. */
31
+ observe(message: unknown): boolean;
32
+ readonly ask: Roots;
33
+ }
34
+ export declare function rootsClient(write: (line: string) => void, limit?: number): RootsClient;
35
+ export declare function tools(workspace: Workspace, env: CliIO['env'], roots?: Roots): ToolDefinition[];
12
36
  export declare function prompts(): PromptDefinition[];
13
37
  export declare function mcpCommand(options: Options, io: CliIO): Promise<number>;
package/dist/server.js CHANGED
@@ -6,6 +6,7 @@
6
6
  * brief the next answer uses.
7
7
  */
8
8
  import { readFileSync } from 'node:fs';
9
+ import { resolve } from 'node:path';
9
10
  import { Readable } from 'node:stream';
10
11
  import { fileURLToPath } from 'node:url';
11
12
  import { describeMeasured } from './audit.js';
@@ -13,10 +14,12 @@ import { findActive } from './briefs.js';
13
14
  import { briefIdFromBranch } from './branch.js';
14
15
  import { titleOf } from './context.js';
15
16
  import { createReader } from './reader.js';
17
+ import { outsideEvery, realSpelling } from './fs.js';
18
+ import { heldBy } from './guard.js';
16
19
  import { buildContext, checkPaths, raiseEscalation, runAudit } from './round.js';
17
20
  import { emptyValue, isBlank } from './usage.js';
18
- import { createMcpServer, serveLines, toolError, unknownArguments, } from './vendor/spec-core/jsonrpc/index.js';
19
- import { describeActive, openWorkspace, version } from './workspace.js';
21
+ import { CLIENT_CAPABILITIES_KEY, createMcpServer, isObject, serveLines, toolError, unknownArguments, } from './vendor/spec-core/jsonrpc/index.js';
22
+ import { describeActive, findTree, foundCommonDir, mayBeElsewhere, namedId, openFound, openWorkspace, version, writesBy } from './workspace.js';
20
23
  const reader = createReader();
21
24
  export const INSTRUCTIONS = 'spec-harness keeps a round of work inside the brief a person approved. ' +
22
25
  'Call start_round before working: it returns the brief, the scope you may write, what you must not touch, the rules in force and the documents the brief cites. ' +
@@ -38,6 +41,133 @@ const baseProperty = {
38
41
  function namesNothing(argument, option, value) {
39
42
  return toolError(`${emptyValue(`"${argument}"`, option, value)}.`);
40
43
  }
44
+ const directoryProperty = {
45
+ type: 'string',
46
+ description: "A directory in the work tree the round is in, such as the worktree you work in. Omit to use the work tree the client's roots name, where they name one of this repository, or else the project's.",
47
+ };
48
+ /** No client to ask: the tools as a caller of the package builds them, with no server around them. */
49
+ const NO_ROOTS = async () => null;
50
+ /** How long a client is given to answer `roots/list`: Claude Code answers from what it holds (its MCP page, below). */
51
+ export const ROOTS_MS = 5_000;
52
+ export function rootsClient(write, limit = ROOTS_MS) {
53
+ let declared = false;
54
+ let asked = 0;
55
+ const waiting = new Map();
56
+ const declares = (capabilities) => isObject(capabilities) && isObject(capabilities['roots']);
57
+ return {
58
+ observe(message) {
59
+ if (!isObject(message))
60
+ return false;
61
+ const params = isObject(message['params']) ? message['params'] : {};
62
+ const meta = isObject(params['_meta']) ? params['_meta'] : {};
63
+ if ((message['method'] === 'initialize' && declares(params['capabilities'])) || declares(meta[CLIENT_CAPABILITIES_KEY]))
64
+ declared = true;
65
+ const id = message['id'];
66
+ // The server's requests have strings for ids, so one that is no string
67
+ // finds nothing waiting either way: the mutant that drops the test is
68
+ // equivalent.
69
+ const settle = typeof id === 'string' && message['method'] === undefined ? waiting.get(id) : undefined;
70
+ if (settle === undefined)
71
+ return false;
72
+ settle(message);
73
+ return true;
74
+ },
75
+ async ask() {
76
+ if (!declared)
77
+ return null;
78
+ asked += 1;
79
+ const id = `spec-harness-roots-${asked}`;
80
+ const answer = await new Promise((settled) => {
81
+ const timer = setTimeout(() => settled(null), limit);
82
+ waiting.set(id, (message) => {
83
+ clearTimeout(timer);
84
+ settled(message);
85
+ });
86
+ write(JSON.stringify({ jsonrpc: '2.0', id, method: 'roots/list' }));
87
+ });
88
+ waiting.delete(id);
89
+ const result = isObject(answer) ? answer['result'] : undefined;
90
+ const roots = isObject(result) ? result['roots'] : undefined;
91
+ if (!Array.isArray(roots))
92
+ return null;
93
+ // A root is a `file:` URI, by the protocol; Node refuses one of any other scheme, which names no directory here.
94
+ return roots.flatMap((root) => {
95
+ const uri = isObject(root) ? root['uri'] : undefined;
96
+ if (typeof uri !== 'string')
97
+ return [];
98
+ try {
99
+ return [fileURLToPath(uri)];
100
+ }
101
+ catch {
102
+ return [];
103
+ }
104
+ });
105
+ },
106
+ };
107
+ }
108
+ /**
109
+ * The work tree of the server's repository that holds a directory: the
110
+ * server's own where it is in that, with no `.git` between, and otherwise the
111
+ * one git names, where it shares the git directory the server's repository
112
+ * keeps; `null` where the directory is in another repository, or in none.
113
+ */
114
+ async function treeOf(server, io, directory) {
115
+ // Asked anyway, git names the server's own top for a directory in it,
116
+ // which answers the server's own below: the mutant that always asks is
117
+ // equivalent, at a process's cost.
118
+ if (!mayBeElsewhere(directory, server.root))
119
+ return server;
120
+ const found = await findTree(directory);
121
+ if (found === null)
122
+ return null;
123
+ // Its own work tree, opened again, reads every answer the same way: the
124
+ // check saves a start of git, and its mutant is equivalent.
125
+ if (realSpelling(found.root) === realSpelling(server.root))
126
+ return server;
127
+ const common = await foundCommonDir(found);
128
+ // git names a common directory wherever it names a top, so `null` is there for the type.
129
+ if (common === null || realSpelling(common) !== realSpelling(server.commonDir))
130
+ return null;
131
+ return openFound(found, io, () => [server.root]);
132
+ }
133
+ /**
134
+ * The work tree a tool that reads a branch reads the round in (ADR-0008):
135
+ * the one a `directory` given names; where none is given, the one the
136
+ * client's roots name, where they name one work tree of this repository and
137
+ * no other; and the server's own otherwise, as before. The server is started
138
+ * once, for the project Claude Code names, and a session in a worktree
139
+ * names that worktree to it in neither the project nor the server's root.
140
+ */
141
+ async function chosen(server, io, roots, given) {
142
+ if (given !== undefined) {
143
+ if (typeof given !== 'string')
144
+ return toolError('"directory" must be a string.');
145
+ if (isBlank(given))
146
+ return toolError(`"directory" is ${JSON.stringify(given)}, which names no directory; give it the directory the round is in, or leave it out.`);
147
+ const workspace = await treeOf(server, io, resolve(server.root, given));
148
+ if (workspace === null)
149
+ return toolError(`"directory" is ${JSON.stringify(given)}, which is in no work tree of this repository; give a directory in the project or in one of its worktrees, or leave it out.`);
150
+ return { workspace, from: 'argument' };
151
+ }
152
+ const named = await roots();
153
+ if (named !== null) {
154
+ const trees = new Map();
155
+ for (const directory of named) {
156
+ const workspace = await treeOf(server, io, directory);
157
+ if (workspace !== null)
158
+ trees.set(realSpelling(workspace.root), workspace);
159
+ }
160
+ const [only, ...more] = [...trees.values()];
161
+ if (only !== undefined && more.length === 0)
162
+ return { workspace: only, from: 'roots' };
163
+ }
164
+ return { workspace: server, from: 'server' };
165
+ }
166
+ /** What a tool's answer says of the work tree it read: always in its structured part, and in its text where it is not the server's own. */
167
+ function readIn(where) {
168
+ const how = where.from === 'argument' ? 'the "directory" given' : "the client's roots";
169
+ return { said: where.from === 'server' ? '' : `\n\nRead in the work tree at ${where.workspace.root}, which ${how} name.`, workTree: { root: where.workspace.root, from: where.from } };
170
+ }
41
171
  /** The `base` argument, or the tool error for one that is not a string, or names no commit. */
42
172
  function baseOf(args) {
43
173
  const base = args['base'];
@@ -101,31 +231,73 @@ async function round(workspace, env, id) {
101
231
  return toolError(note);
102
232
  return { brief, briefs };
103
233
  }
104
- export function tools(workspace, env) {
234
+ /**
235
+ * The brief the argument, `SPEC_BRIEF` or the branch names, as the hook
236
+ * finds it: where nothing names one, spec-brief is not asked, and the answer
237
+ * is no brief whatever spec-brief would say (ADR-0004), so the empty list is
238
+ * equivalent to its mutant.
239
+ */
240
+ async function namedBrief(workspace, env, id) {
241
+ if (namedId(workspace, { brief: id }, env) === null)
242
+ return findActive([], {});
243
+ const briefs = await workspace.siblings.briefs();
244
+ return findActive(briefs, { argument: id, environment: env['SPEC_BRIEF'], branch: briefIdFromBranch(workspace.config.branches, workspace.branch) });
245
+ }
246
+ /**
247
+ * Each path as the hook decides a write of it alone (ADR-0005, ADR-0008): one
248
+ * outside every work tree of the repository as a file outside it, with no
249
+ * brief asked about, whatever names one; the rest by the brief named, with
250
+ * no brief where nothing names one, and held, in the hook's words, where
251
+ * what names it names none a round can work under. spec-brief is asked only
252
+ * where a path is inside, as the hook asks it.
253
+ */
254
+ async function asTheHook(workspace, env, id, paths, cwd, base) {
255
+ const outside = paths.map((path) => outsideEvery([path], workspace.root, workspace.commonDir, cwd));
256
+ // Paths all outside ask about no brief, and are answered as with none
257
+ // named: the empty list is equivalent to its mutant, as in namedBrief.
258
+ const { brief, note, problem } = describeActive(outside.every((each) => each) ? findActive([], {}) : await namedBrief(workspace, env, id), '"brief"');
259
+ if (problem === null)
260
+ return checkPaths(workspace, brief, note, paths, cwd, reader, base);
261
+ // Decided by no brief, a path outside is decided as outside the
262
+ // repository, and one inside is held, named as the repository names it.
263
+ const decided = await checkPaths(workspace, null, undefined, paths, cwd, reader, base);
264
+ return decided.map((decision, at) => (outside[at] ? decision : heldBy(decision.path, problem)));
265
+ }
266
+ export function tools(workspace, env, roots = NO_ROOTS) {
105
267
  const readOnly = { readOnlyHint: true, idempotentHint: true, openWorldHint: false };
268
+ // A tool writes nothing to the server's streams, so these are never
269
+ // written to, and their mutants are equivalent: what a tool says is its
270
+ // answer.
271
+ const quiet = { write: () => true };
272
+ const io = { stdout: quiet, stderr: quiet, cwd: workspace.cwd, env };
106
273
  return [
107
274
  {
108
275
  descriptor: {
109
276
  name: 'start_round',
110
277
  title: 'Start a round',
111
278
  description: 'Everything needed to start the round: the brief in full, the files it may write and must not change, signed rulings, the briefs it depends on, the architecture rules in force for its scope, and the documents it cites.',
112
- inputSchema: { type: 'object', properties: { brief: briefProperty, base: baseProperty }, additionalProperties: false },
279
+ inputSchema: { type: 'object', properties: { brief: briefProperty, base: baseProperty, directory: directoryProperty }, additionalProperties: false },
113
280
  annotations: readOnly,
114
281
  },
115
282
  async call(args) {
116
- const unknown = unknownArguments(args, ['brief', 'base']);
283
+ const unknown = unknownArguments(args, ['brief', 'base', 'directory']);
117
284
  if (unknown)
118
285
  return unknown;
119
286
  const base = baseOf(args);
120
287
  if ('text' in base)
121
288
  return base;
122
- const found = await round(workspace, env, args['brief']);
289
+ const where = await chosen(workspace, io, roots, args['directory']);
290
+ if ('text' in where)
291
+ return where;
292
+ const found = await round(where.workspace, env, args['brief']);
123
293
  if ('text' in found)
124
294
  return found;
125
- const packet = await buildContext(workspace, found.brief, found.briefs, reader, base.base);
295
+ const packet = await buildContext(where.workspace, found.brief, found.briefs, reader, base.base);
296
+ const { said, workTree } = readIn(where);
126
297
  return {
127
- text: packet.markdown,
298
+ text: `${packet.markdown}${said}`,
128
299
  structured: {
300
+ workTree,
129
301
  brief: found.brief.id,
130
302
  included: [...packet.included],
131
303
  omitted: [...packet.omitted],
@@ -144,13 +316,18 @@ export function tools(workspace, env) {
144
316
  descriptor: {
145
317
  name: 'check_path',
146
318
  title: 'May the round write these files',
147
- description: 'For each path: allowed (in scope, or covered by a signed ruling), outside the scope, or refused (protected by the brief). Works for files that do not exist yet.',
319
+ description: 'For each path, as the guard hook decides a write of it: allowed (in scope, covered by a signed ruling, outside every work tree of the repository, or with no brief named), outside the scope, or refused (protected by the brief, or held where what names the brief names none a round can work under). Works for files that do not exist yet.',
148
320
  inputSchema: {
149
321
  type: 'object',
150
322
  properties: {
151
- paths: { type: 'array', items: { type: 'string' }, description: 'Paths relative to the project root, or absolute inside it. An empty one is refused.' },
323
+ paths: {
324
+ type: 'array',
325
+ items: { type: 'string' },
326
+ description: 'Paths relative to the top of the work tree the round is in, or absolute; an absolute one in another work tree of this repository is decided by that one. An empty one is refused.',
327
+ },
152
328
  brief: briefProperty,
153
329
  base: baseProperty,
330
+ directory: directoryProperty,
154
331
  },
155
332
  required: ['paths'],
156
333
  additionalProperties: false,
@@ -158,7 +335,7 @@ export function tools(workspace, env) {
158
335
  annotations: readOnly,
159
336
  },
160
337
  async call(args) {
161
- const unknown = unknownArguments(args, ['paths', 'brief', 'base']);
338
+ const unknown = unknownArguments(args, ['paths', 'brief', 'base', 'directory']);
162
339
  if (unknown)
163
340
  return unknown;
164
341
  const given = pathsOf(args);
@@ -167,12 +344,23 @@ export function tools(workspace, env) {
167
344
  const base = baseOf(args);
168
345
  if ('text' in base)
169
346
  return base;
170
- const found = await round(workspace, env, args['brief']);
171
- if ('text' in found)
172
- return found;
173
- const decisions = await checkPaths(workspace, found.brief, undefined, given.paths, workspace.root, reader, base.base);
347
+ const where = await chosen(workspace, io, roots, args['directory']);
348
+ if ('text' in where)
349
+ return where;
350
+ const id = args['brief'];
351
+ if (id !== undefined && typeof id !== 'string')
352
+ return toolError('"brief" must be a string.');
353
+ if (id !== undefined && isBlank(id))
354
+ return namesNothing('brief', 'brief', id);
355
+ // Each path by the work tree of this repository that holds it, as the
356
+ // hook decides a write: the one read, another, or the server's own.
357
+ const decisions = [];
358
+ for (const part of await writesBy(workspace, where.workspace.root, given.paths, io)) {
359
+ decisions.push(...(await asTheHook(part.workspace, env, id, part.paths, where.workspace.root, base.base)));
360
+ }
174
361
  const text = decisions.map((d) => `${d.verdict}: ${d.message}${d.verdict === 'allow' ? '' : `. Next: ${d.hint}`}`).join('\n');
175
- return { text, structured: { decisions: decisions.map((d) => ({ ...d })) } };
362
+ const { said, workTree } = readIn(where);
363
+ return { text: `${text}${said}`, structured: { workTree, decisions: decisions.map((d) => ({ ...d })) } };
176
364
  },
177
365
  },
178
366
  {
@@ -192,6 +380,7 @@ export function tools(workspace, env) {
192
380
  },
193
381
  recommendation: { type: 'string', description: 'Which option you recommend, and why. Omit it when there is none: an empty one is refused.' },
194
382
  brief: briefProperty,
383
+ directory: directoryProperty,
195
384
  },
196
385
  required: ['paths', 'reason'],
197
386
  additionalProperties: false,
@@ -199,7 +388,7 @@ export function tools(workspace, env) {
199
388
  annotations: { readOnlyHint: false, idempotentHint: false, openWorldHint: false },
200
389
  },
201
390
  async call(args) {
202
- const unknown = unknownArguments(args, ['paths', 'reason', 'options', 'recommendation', 'brief']);
391
+ const unknown = unknownArguments(args, ['paths', 'reason', 'options', 'recommendation', 'brief', 'directory']);
203
392
  if (unknown)
204
393
  return unknown;
205
394
  // Every part of the memo before anything is written: a person rules
@@ -218,16 +407,20 @@ export function tools(workspace, env) {
218
407
  return toolError('"recommendation" must be a string.');
219
408
  if (recommendation !== undefined && isBlank(recommendation))
220
409
  return namesNothing('recommendation', 'recommend', recommendation);
221
- const found = await round(workspace, env, args['brief']);
410
+ const where = await chosen(workspace, io, roots, args['directory']);
411
+ if ('text' in where)
412
+ return where;
413
+ const found = await round(where.workspace, env, args['brief']);
222
414
  if ('text' in found)
223
415
  return found;
224
- const { request, memo } = await raiseEscalation(workspace, found.brief, {
416
+ const { request, memo } = await raiseEscalation(where.workspace, found.brief, {
225
417
  paths: given.paths,
226
418
  reason,
227
419
  options: choices.options,
228
420
  recommendation: recommendation ?? null,
229
421
  });
230
- return { text: `${memo}\nStop here until a person rules on ${request.id}.`, structured: { id: request.id } };
422
+ const { said, workTree } = readIn(where);
423
+ return { text: `${memo}\nStop here until a person rules on ${request.id}.${said}`, structured: { id: request.id, workTree } };
231
424
  },
232
425
  },
233
426
  {
@@ -235,29 +428,33 @@ export function tools(workspace, env) {
235
428
  name: 'audit_round',
236
429
  title: 'Audit the round',
237
430
  description: 'Did the round stay inside the lines: what the archive would refuse (open boxes, protected files, files outside the scope), the brief\'s own assertions, unverified rulings, every dependency added and every package allowed to run install scripts.',
238
- inputSchema: { type: 'object', properties: { brief: briefProperty, base: baseProperty }, additionalProperties: false },
431
+ inputSchema: { type: 'object', properties: { brief: briefProperty, base: baseProperty, directory: directoryProperty }, additionalProperties: false },
239
432
  annotations: readOnly,
240
433
  },
241
434
  async call(args) {
242
- const unknown = unknownArguments(args, ['brief', 'base']);
435
+ const unknown = unknownArguments(args, ['brief', 'base', 'directory']);
243
436
  if (unknown)
244
437
  return unknown;
245
438
  const base = baseOf(args);
246
439
  if ('text' in base)
247
440
  return base;
248
- const found = await round(workspace, env, args['brief']);
441
+ const where = await chosen(workspace, io, roots, args['directory']);
442
+ if ('text' in where)
443
+ return where;
444
+ const found = await round(where.workspace, env, args['brief']);
249
445
  if ('text' in found)
250
446
  return found;
251
- const result = await runAudit(workspace, found.brief, reader, base.base);
447
+ const result = await runAudit(where.workspace, found.brief, reader, base.base);
252
448
  const { counts, findings, measured } = result.report;
253
449
  // What was measured beside what was found: "nothing found" from an
254
450
  // audit that ran no goal is not a round that is done.
255
451
  const text = findings.length === 0
256
452
  ? 'The audit found nothing in what it measured.'
257
453
  : findings.map((f) => `${f.severity} ${f.rule}: ${f.message}. Next: ${f.hint}`).join('\n');
454
+ const { said, workTree } = readIn(where);
258
455
  return {
259
- text: `${text}\n\n${describeMeasured(measured)}\n\n${counts.error} error(s), ${counts.warning} warning(s), ${counts.note} note(s)`,
260
- structured: { counts, measured: { ...measured }, findings: findings.map((f) => ({ ...f })) },
456
+ text: `${text}\n\n${describeMeasured(measured)}\n\n${counts.error} error(s), ${counts.warning} warning(s), ${counts.note} note(s)${said}`,
457
+ structured: { workTree, counts, measured: { ...measured }, findings: findings.map((f) => ({ ...f })) },
261
458
  };
262
459
  },
263
460
  },
@@ -315,13 +512,18 @@ export async function mcpCommand(options, io) {
315
512
  // Claude Code starts a plugin's server in the plugin's directory, and names
316
513
  // the project in CLAUDE_PROJECT_DIR; the server answers for the project.
317
514
  const workspace = await openWorkspace({ ...options, root: options.root ?? (io.env['CLAUDE_PROJECT_DIR'] || undefined) }, io);
318
- const handle = createMcpServer({ name: 'spec-harness', version: version(), instructions: INSTRUCTIONS, tools: tools(workspace, io.env), prompts: prompts() });
515
+ const write = (line) => {
516
+ io.stdout.write(`${line}\n`);
517
+ };
518
+ // The work tree a session is in, where its client names it (ADR-0008).
519
+ const roots = rootsClient(write);
520
+ const handle = createMcpServer({ name: 'spec-harness', version: version(), instructions: INSTRUCTIONS, tools: tools(workspace, io.env, roots.ask), prompts: prompts() });
319
521
  // A session handed over whole, as a test hands it, is served as a client
320
522
  // would stream it on stdin. Only the built command line serves
321
523
  // process.stdin, and the suite spawns it from dist/, outside the sweep
322
524
  // (tests/integration/mcp.test.ts), so a mutant that ignores it survives.
323
525
  const input = io.stdin === undefined ? process.stdin : Readable.from([Buffer.from(await io.stdin())]);
324
- await serveLines(input, (line) => io.stdout.write(`${line}\n`), handle);
526
+ await serveLines(input, write, async (message) => (roots.observe(message) ? null : handle(message)));
325
527
  return 0;
326
528
  }
327
529
  //# sourceMappingURL=server.js.map