@descent-vtt/spec-harness 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/git.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"git.js","sourceRoot":"","sources":["../src/git.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The edge where the harness meets git.\n *\n * Reads, except for two things ADR-0003 allows and bounds: a temporary\n * worktree, added and always removed by the sandbox, and nothing else. Every\n * call is an argument vector, never a shell string, so a path or a ref cannot\n * become a command.\n */\n\nimport { execFile } from 'node:child_process';\n\nimport type { FileChange } from './types.js';\n\nexport interface Run {\n readonly code: number;\n readonly stdout: string;\n readonly stderr: string;\n}\n\n/** Runs git and answers whatever it answered; a missing git is a code of -1. */\nexport function git(args: readonly string[], cwd: string): Promise<Run> {\n return new Promise((resolve) => {\n // windowsHide only keeps a console window from flashing up on Windows,\n // and the sweep runs on Linux, where its mutant is equivalent.\n execFile('git', [...args], { cwd, maxBuffer: 256 * 1024 * 1024, encoding: 'utf8', windowsHide: true }, (error, stdout, stderr) => {\n // Every caller tells 0 from any other code, never one failure from\n // another, so which code a failure gets is equivalent to its mutants.\n const code = error === null ? 0 : typeof (error as { code?: unknown }).code === 'number' ? (error as { code: number }).code : -1;\n resolve({ code, stdout, stderr });\n });\n });\n}\n\nasync function value(args: readonly string[], cwd: string): Promise<string | null> {\n const run = await git(args, cwd);\n return run.code === 0 ? run.stdout.trim() : null;\n}\n\nexport class GitError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'GitError';\n }\n}\n\n/** The top of the work tree holding `cwd`, POSIX-separated, or `null` outside one. */\nexport async function workTreeRoot(cwd: string): Promise<string | null> {\n return value(['rev-parse', '--show-toplevel'], cwd);\n}\n\n/** The git directory every worktree of this repository shares. */\nexport async function commonDirectory(cwd: string): Promise<string | null> {\n return value(['rev-parse', '--path-format=absolute', '--git-common-dir'], cwd);\n}\n\n/**\n * Where git runs the hook `name` from, absolute: `core.hooksPath` read as git\n * reads it - relative to the work tree, absolute, or under `~` - and a linked\n * worktree's shared hooks. `null` when git cannot say, before 2.31.\n */\nexport async function hookPath(name: string, cwd: string): Promise<string | null> {\n return value(['rev-parse', '--path-format=absolute', '--git-path', `hooks/${name}`], cwd);\n}\n\n/** The checked-out branch's short name, or `null` on a detached head. */\nexport async function currentBranch(cwd: string): Promise<string | null> {\n return value(['symbolic-ref', '--quiet', '--short', 'HEAD'], cwd);\n}\n\n/** The remote's default branch as a local ref, such as `origin/main`, or `null`. */\nexport async function remoteDefault(cwd: string): Promise<string | null> {\n return value(['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], cwd);\n}\n\n/** Every local branch, by its short name. */\nexport async function localBranches(cwd: string): Promise<string[]> {\n const listed = await value(['for-each-ref', '--format=%(refname:short)', 'refs/heads/'], cwd);\n // for-each-ref fails only where git itself does, outside a repository,\n // where init never runs: the null check is there for the type.\n return listed === null || listed === '' ? [] : listed.split('\\n');\n}\n\nexport async function revision(ref: string, cwd: string): Promise<string | null> {\n return value(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], cwd);\n}\n\nexport async function mergeBase(a: string, b: string, cwd: string): Promise<string | null> {\n return value(['merge-base', a, b], cwd);\n}\n\n/** Parses `git diff --name-status -z` output. */\nexport function parseNameStatus(output: string): FileChange[] {\n const fields = output.split('\\0');\n const out: FileChange[] = [];\n for (let i = 0; i < fields.length; ) {\n const code = fields[i] as string;\n if (code === '') {\n i += 1;\n continue;\n }\n const letter = code.charAt(0);\n const status = letter as FileChange['status'];\n if (status === 'R' || status === 'C') {\n out.push({ status, from: fields[i + 1] ?? '', path: fields[i + 2] ?? '' });\n i += 3;\n } else {\n out.push({ status, path: fields[i + 1] ?? '' });\n i += 2;\n }\n }\n return out;\n}\n\n/** The files changed from one revision to another, renames found. */\nexport async function changes(from: string, to: string, cwd: string): Promise<FileChange[]> {\n const run = await git(['diff', '--name-status', '-z', '--find-renames', '--no-color', from, to, '--'], cwd);\n if (run.code !== 0) throw new GitError(`git diff ${from} ${to} failed: ${run.stderr.trim()}`);\n return parseNameStatus(run.stdout);\n}\n\n/** The files a commit would change: what is staged. */\nexport async function stagedChanges(cwd: string): Promise<FileChange[]> {\n const run = await git(['diff', '--cached', '--name-status', '-z', '--find-renames', '--no-color', '--'], cwd);\n if (run.code !== 0) throw new GitError(`git diff --cached failed: ${run.stderr.trim()}`);\n return parseNameStatus(run.stdout);\n}\n\n/** A file's content at a revision, or `null` when it does not exist there. */\nexport async function show(ref: string, path: string, cwd: string): Promise<string | null> {\n const run = await git(['show', `${ref}:${path}`], cwd);\n return run.code === 0 ? run.stdout : null;\n}\n\n/** Whether a commit id from {@link blameLine} is the all-zero one of a line nobody committed. */\nexport function isUncommitted(sha: string): boolean {\n return /^0+$/.test(sha);\n}\n\n/**\n * The commit that last changed one line of a file, as of a revision, or of\n * the working tree when `ref` is `null` - where a line nobody committed yet\n * belongs to the all-zero commit.\n */\nexport async function blameLine(ref: string | null, path: string, line: number, cwd: string): Promise<string | null> {\n const run = await git(['blame', '--porcelain', '-L', `${line},${line}`, ...(ref === null ? [] : [ref]), '--', path], cwd);\n // A blame that fails prints nothing on stdout, which names no commit. Its\n // output opens with the commit, so the anchor is equivalent to its mutant.\n const sha = /^([0-9a-f]{40,64}) /.exec(run.stdout)?.[1];\n return sha ?? null;\n}\n\n/** A good signature names its allowed signer's principal; any other names none. */\nexport type Signature =\n | { readonly good: true; readonly principal: string; readonly detail: string }\n | { readonly good: false; readonly principal: null; readonly detail: string };\n\n/**\n * Whether a commit carries a good SSH signature by a principal in the given\n * allowed-signers file. The file is passed in, never read from the commit's\n * own tree: a round cannot vouch for itself (ADR-0006).\n */\nexport async function verifyCommit(sha: string, allowedSignersFile: string, cwd: string): Promise<Signature> {\n const run = await git(\n ['-c', `gpg.ssh.allowedSignersFile=${allowedSignersFile}`, '-c', 'gpg.format=ssh', 'verify-commit', '--raw', sha],\n cwd,\n );\n const text = `${run.stderr}\\n${run.stdout}`;\n const principal = /Good \"git\" signature for (\\S+)/.exec(text)?.[1];\n // git writes the check to stderr, so the trim changes the first line only\n // for output on stdout alone, which verify-commit does not print; and split\n // always answers a first part.\n const detail = text.trim().split('\\n')[0]!;\n // git exits 0 only for a signature by a listed principal, which it names:\n // either condition alone is equivalent to both, and both trust neither.\n return run.code === 0 && principal !== undefined ? { good: true, principal, detail } : { good: false, principal: null, detail };\n}\n\nexport async function addWorktree(path: string, rev: string, cwd: string): Promise<void> {\n const run = await git(['worktree', 'add', '--detach', '--force', path, rev], cwd);\n if (run.code !== 0) throw new GitError(`git worktree add failed: ${run.stderr.trim()}`);\n}\n\n/**\n * Has git remove the worktree at `path`, and no other, with its record of it,\n * and answers whether git did. Git deletes what it can of the worktree's\n * directory and forgets the worktree even when it cannot delete it all. It\n * refuses a worktree whose directory has lost its `.git` file, and removes\n * its record of one whose directory is gone, found by the path it was added\n * at: `git worktree prune` would also forget every other worktree git has\n * lost track of, the person's among them.\n */\nexport async function removeWorktree(path: string, cwd: string): Promise<boolean> {\n // The sandbox asks again when this answers false, and git, asked again\n // about a worktree it has forgotten, changes nothing: the mutant that\n // always answers false is equivalent.\n return (await git(['worktree', 'remove', '--force', path], cwd)).code === 0;\n}\n"]}
1
+ {"version":3,"file":"git.js","sourceRoot":"","sources":["../src/git.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAU9C,gFAAgF;AAChF,MAAM,UAAU,GAAG,CAAC,IAAuB,EAAE,GAAW;IACtD,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE;QAC7B,uEAAuE;QACvE,+DAA+D;QAC/D,QAAQ,CAAC,KAAK,EAAE,CAAC,GAAG,IAAI,CAAC,EAAE,EAAE,GAAG,EAAE,SAAS,EAAE,GAAG,GAAG,IAAI,GAAG,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,EAAE;YAC/H,mEAAmE;YACnE,sEAAsE;YACtE,MAAM,IAAI,GAAG,KAAK,KAAK,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,OAAQ,KAA4B,CAAC,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAE,KAA0B,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;YACjI,OAAO,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;QACpC,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;AACL,CAAC;AAED,KAAK,UAAU,KAAK,CAAC,IAAuB,EAAE,GAAW;IACvD,MAAM,GAAG,GAAG,MAAM,GAAG,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;IACjC,OAAO,GAAG,CAAC,IAAI,KAAK,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;AACnD,CAAC;AAED,MAAM,OAAO,QAAS,SAAQ,KAAK;IACjC,YAAY,OAAe;QACzB,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,UAAU,CAAC;IACzB,CAAC;CACF;AAED,sFAAsF;AACtF,MAAM,CAAC,KAAK,UAAU,YAAY,CAAC,GAAW;IAC5C,OAAO,KAAK,CAAC,CAAC,WAAW,EAAE,iBAAiB,CAAC,EAAE,GAAG,CAAC,CAAC;AACtD,CAAC;AAED,kEAAkE;AAClE,MAAM,CAAC,KAAK,UAAU,eAAe,CAAC,GAAW;IAC/C,OAAO,KAAK,CAAC,CAAC,WAAW,EAAE,wBAAwB,EAAE,kBAAkB,CAAC,EAAE,GAAG,CAAC,CAAC;AACjF,CAAC;AAQD;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,MAAM,CAAC,KAAK,UAAU,QAAQ,CAAC,GAAW;IACxC,MAAM,GAAG,GAAG,MAAM,GAAG,CAAC,CAAC,WAAW,EAAE,iBAAiB,EAAE,wBAAwB,EAAE,kBAAkB,CAAC,EAAE,GAAG,CAAC,CAAC;IAC3G,IAAI,GAAG,CAAC,MAAM,KAAK,EAAE;QAAE,OAAO,GAAG,CAAC,IAAI,KAAK,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC;IAC/D,MAAM,KAAK,GAAG,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IACrC,IAAI,GAAG,CAAC,IAAI,KAAK,CAAC,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,IAAI,KAAK,CAAC,CAAC,CAAC,KAAK,EAAE;QAAE,OAAO,QAAQ,CAAC;IAC7E,MAAM,IAAI,GAAG,KAAK,CAAC,CAAC,CAAE,CAAC;IACvB,MAAM,SAAS,GAAG,KAAK,CAAC,CAAC,CAAE,CAAC;IAC5B,IAAI,IAAI,KAAK,EAAE,IAAI,SAAS,KAAK,EAAE,IAAI,IAAI,CAAC,IAAI,EAAE,KAAK,IAAI,IAAI,SAAS,CAAC,IAAI,EAAE,KAAK,SAAS;QAAE,OAAO,QAAQ,CAAC;IAC/G,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC;AAC7B,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,QAAQ,CAAC,IAAY,EAAE,GAAW;IACtD,MAAM,GAAG,GAAG,MAAM,GAAG,CAAC,CAAC,WAAW,EAAE,wBAAwB,EAAE,YAAY,EAAE,SAAS,IAAI,EAAE,CAAC,EAAE,GAAG,CAAC,CAAC;IACnG,IAAI,GAAG,CAAC,IAAI,KAAK,CAAC;QAAE,OAAO,EAAE,IAAI,EAAE,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,EAAE,CAAC;IACvD,OAAO,EAAE,OAAO,EAAE,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAE,EAAE,CAAC;AACpD,CAAC;AAED,MAAM,KAAK,GAAG,aAAa,CAAC;AAE5B;;;;;;;;;;GAUG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,GAAW;IAC7C,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,CAAC,cAAc,EAAE,SAAS,EAAE,MAAM,CAAC,EAAE,GAAG,CAAC,CAAC;IAClE,OAAO,GAAG,KAAK,IAAI,IAAI,GAAG,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,KAAK,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC;AAC/E,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,GAAW;IAC7C,OAAO,KAAK,CAAC,CAAC,cAAc,EAAE,SAAS,EAAE,SAAS,EAAE,0BAA0B,CAAC,EAAE,GAAG,CAAC,CAAC;AACxF,CAAC;AAED;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,GAAW;IAC7C,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,CAAC,cAAc,EAAE,iDAAiD,EAAE,KAAK,CAAC,EAAE,GAAG,CAAC,CAAC;IAC5G,uEAAuE;IACvE,+DAA+D;IAC/D,IAAI,MAAM,KAAK,IAAI,IAAI,MAAM,KAAK,EAAE;QAAE,OAAO,EAAE,CAAC;IAChD,OAAO,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE;QACrC,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QAC/B,OAAO,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC,CAAE,EAAE,GAAG,EAAE,KAAK,CAAC,CAAC,CAAE,EAAE,CAAC;IAC7C,CAAC,CAAC,CAAC;AACL,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,QAAQ,CAAC,GAAW,EAAE,GAAW;IACrD,OAAO,KAAK,CAAC,CAAC,WAAW,EAAE,UAAU,EAAE,SAAS,EAAE,GAAG,GAAG,WAAW,CAAC,EAAE,GAAG,CAAC,CAAC;AAC7E,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,SAAS,CAAC,CAAS,EAAE,CAAS,EAAE,GAAW;IAC/D,OAAO,KAAK,CAAC,CAAC,YAAY,EAAE,CAAC,EAAE,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC;AAC1C,CAAC;AAED,iDAAiD;AACjD,MAAM,UAAU,eAAe,CAAC,MAAc;IAC5C,MAAM,MAAM,GAAG,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IAClC,MAAM,GAAG,GAAiB,EAAE,CAAC;IAC7B,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,MAAM,CAAC,MAAM,GAAI,CAAC;QACpC,MAAM,IAAI,GAAG,MAAM,CAAC,CAAC,CAAW,CAAC;QACjC,IAAI,IAAI,KAAK,EAAE,EAAE,CAAC;YAChB,CAAC,IAAI,CAAC,CAAC;YACP,SAAS;QACX,CAAC;QACD,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;QAC9B,MAAM,MAAM,GAAG,MAA8B,CAAC;QAC9C,IAAI,MAAM,KAAK,GAAG,IAAI,MAAM,KAAK,GAAG,EAAE,CAAC;YACrC,GAAG,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,IAAI,EAAE,MAAM,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;YAC3E,CAAC,IAAI,CAAC,CAAC;QACT,CAAC;aAAM,CAAC;YACN,GAAG,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;YAChD,CAAC,IAAI,CAAC,CAAC;QACT,CAAC;IACH,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC;AAED,qEAAqE;AACrE,MAAM,CAAC,KAAK,UAAU,OAAO,CAAC,IAAY,EAAE,EAAU,EAAE,GAAW;IACjE,MAAM,GAAG,GAAG,MAAM,GAAG,CAAC,CAAC,MAAM,EAAE,eAAe,EAAE,IAAI,EAAE,gBAAgB,EAAE,YAAY,EAAE,IAAI,EAAE,EAAE,EAAE,IAAI,CAAC,EAAE,GAAG,CAAC,CAAC;IAC5G,IAAI,GAAG,CAAC,IAAI,KAAK,CAAC;QAAE,MAAM,IAAI,QAAQ,CAAC,YAAY,IAAI,IAAI,EAAE,YAAY,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;IAC9F,OAAO,eAAe,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;AACrC,CAAC;AAED,uDAAuD;AACvD,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,GAAW;IAC7C,MAAM,GAAG,GAAG,MAAM,GAAG,CAAC,CAAC,MAAM,EAAE,UAAU,EAAE,eAAe,EAAE,IAAI,EAAE,gBAAgB,EAAE,YAAY,EAAE,IAAI,CAAC,EAAE,GAAG,CAAC,CAAC;IAC9G,IAAI,GAAG,CAAC,IAAI,KAAK,CAAC;QAAE,MAAM,IAAI,QAAQ,CAAC,6BAA6B,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;IACzF,OAAO,eAAe,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;AACrC,CAAC;AAED,8EAA8E;AAC9E,MAAM,CAAC,KAAK,UAAU,IAAI,CAAC,GAAW,EAAE,IAAY,EAAE,GAAW;IAC/D,MAAM,GAAG,GAAG,MAAM,GAAG,CAAC,CAAC,MAAM,EAAE,GAAG,GAAG,IAAI,IAAI,EAAE,CAAC,EAAE,GAAG,CAAC,CAAC;IACvD,OAAO,GAAG,CAAC,IAAI,KAAK,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC;AAC5C,CAAC;AAED,iGAAiG;AACjG,MAAM,UAAU,aAAa,CAAC,GAAW;IACvC,OAAO,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AAC1B,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,SAAS,CAAC,GAAkB,EAAE,IAAY,EAAE,IAAY,EAAE,GAAW;IACzF,MAAM,GAAG,GAAG,MAAM,GAAG,CAAC,CAAC,OAAO,EAAE,aAAa,EAAE,IAAI,EAAE,GAAG,IAAI,IAAI,IAAI,EAAE,EAAE,GAAG,CAAC,GAAG,KAAK,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,GAAG,CAAC,CAAC;IAC1H,0EAA0E;IAC1E,2EAA2E;IAC3E,MAAM,GAAG,GAAG,qBAAqB,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;IACxD,OAAO,GAAG,IAAI,IAAI,CAAC;AACrB,CAAC;AAOD;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY,CAAC,GAAW,EAAE,kBAA0B,EAAE,GAAW;IACrF,MAAM,GAAG,GAAG,MAAM,GAAG,CACnB,CAAC,IAAI,EAAE,8BAA8B,kBAAkB,EAAE,EAAE,IAAI,EAAE,gBAAgB,EAAE,eAAe,EAAE,OAAO,EAAE,GAAG,CAAC,EACjH,GAAG,CACJ,CAAC;IACF,MAAM,IAAI,GAAG,GAAG,GAAG,CAAC,MAAM,KAAK,GAAG,CAAC,MAAM,EAAE,CAAC;IAC5C,MAAM,SAAS,GAAG,gCAAgC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;IACnE,0EAA0E;IAC1E,4EAA4E;IAC5E,+BAA+B;IAC/B,MAAM,MAAM,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAE,CAAC;IAC3C,0EAA0E;IAC1E,wEAAwE;IACxE,OAAO,GAAG,CAAC,IAAI,KAAK,CAAC,IAAI,SAAS,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,SAAS,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC;AAClI,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,WAAW,CAAC,IAAY,EAAE,GAAW,EAAE,GAAW;IACtE,MAAM,GAAG,GAAG,MAAM,GAAG,CAAC,CAAC,UAAU,EAAE,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,IAAI,EAAE,GAAG,CAAC,EAAE,GAAG,CAAC,CAAC;IAClF,IAAI,GAAG,CAAC,IAAI,KAAK,CAAC;QAAE,MAAM,IAAI,QAAQ,CAAC,4BAA4B,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;AAC1F,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,CAAC,KAAK,UAAU,cAAc,CAAC,IAAY,EAAE,GAAW;IAC5D,uEAAuE;IACvE,sEAAsE;IACtE,sCAAsC;IACtC,OAAO,CAAC,MAAM,GAAG,CAAC,CAAC,UAAU,EAAE,QAAQ,EAAE,SAAS,EAAE,IAAI,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,IAAI,KAAK,CAAC,CAAC;AAC9E,CAAC","sourcesContent":["/**\n * The edge where the harness meets git.\n *\n * Reads, except for two things ADR-0003 allows and bounds: a temporary\n * worktree, added and always removed by the sandbox, and nothing else. Every\n * call is an argument vector, never a shell string, so a path or a ref cannot\n * become a command.\n */\n\nimport { execFile } from 'node:child_process';\n\nimport type { FileChange, LocalBranch } from './types.js';\n\nexport interface Run {\n readonly code: number;\n readonly stdout: string;\n readonly stderr: string;\n}\n\n/** Runs git and answers whatever it answered; a missing git is a code of -1. */\nexport function git(args: readonly string[], cwd: string): Promise<Run> {\n return new Promise((resolve) => {\n // windowsHide only keeps a console window from flashing up on Windows,\n // and the sweep runs on Linux, where its mutant is equivalent.\n execFile('git', [...args], { cwd, maxBuffer: 256 * 1024 * 1024, encoding: 'utf8', windowsHide: true }, (error, stdout, stderr) => {\n // Every caller tells 0 from any other code, never one failure from\n // another, so which code a failure gets is equivalent to its mutants.\n const code = error === null ? 0 : typeof (error as { code?: unknown }).code === 'number' ? (error as { code: number }).code : -1;\n resolve({ code, stdout, stderr });\n });\n });\n}\n\nasync function value(args: readonly string[], cwd: string): Promise<string | null> {\n const run = await git(args, cwd);\n return run.code === 0 ? run.stdout.trim() : null;\n}\n\nexport class GitError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'GitError';\n }\n}\n\n/** The top of the work tree holding `cwd`, POSIX-separated, or `null` outside one. */\nexport async function workTreeRoot(cwd: string): Promise<string | null> {\n return value(['rev-parse', '--show-toplevel'], cwd);\n}\n\n/** The git directory every worktree of this repository shares. */\nexport async function commonDirectory(cwd: string): Promise<string | null> {\n return value(['rev-parse', '--path-format=absolute', '--git-common-dir'], cwd);\n}\n\n/** A work tree as one start of git names it: its top, and the git directory its worktrees share. */\nexport interface WorkTree {\n readonly root: string;\n readonly commonDir: string;\n}\n\n/**\n * The top of the work tree holding `cwd` and the common directory, asked of\n * git in one start where {@link workTreeRoot} and {@link commonDirectory}\n * are two: `null` outside a work tree, and `'unread'` where the one answer\n * cannot be taken for the two, which are then to be asked for as they were.\n *\n * Measured with git 2.43 and 2.55, rev-parse answers the two in the order\n * they stand on its line, a line each, and prints nothing where the first\n * fails. Its manual says of that only that `--show-toplevel` reports an\n * error where there is no working tree (git-rev-parse(1), Options for\n * Files, as git 2.55.0 has it, read 2026-10-14), so nothing is read on\n * trust. Two lines that are each what `trim` would leave, from a git that\n * did not fail, are the two answers. Nothing printed, by a git that\n * failed, is the first question failing, the one {@link workTreeRoot}\n * asks: `cwd` is in no work tree. Anything else is not read: a path that\n * holds a line break is more lines than two, and so is what a git before\n * 2.31 would print, to which `--path-format=absolute` is a flag to hand\n * back (the same manual, Options for Filtering; the option is new in the\n * release notes of 2.31.0). No such git was measured.\n *\n * `--path-format` reaches only what follows it on the line (Options for\n * Files), so the top is printed as it is where it is asked for alone. The\n * common directory is asked for from `cwd` here, and from the top by\n * {@link commonDirectory}: the same directory only where `cwd` is the top.\n */\nexport async function workTree(cwd: string): Promise<WorkTree | 'unread' | null> {\n const run = await git(['rev-parse', '--show-toplevel', '--path-format=absolute', '--git-common-dir'], cwd);\n if (run.stdout === '') return run.code === 0 ? 'unread' : null;\n const lines = run.stdout.split('\\n');\n if (run.code !== 0 || lines.length !== 3 || lines[2] !== '') return 'unread';\n const root = lines[0]!;\n const commonDir = lines[1]!;\n if (root === '' || commonDir === '' || root.trim() !== root || commonDir.trim() !== commonDir) return 'unread';\n return { root, commonDir };\n}\n\n/**\n * Where git runs the hook `name` from, absolute: `core.hooksPath` read as git\n * reads it - relative to the work tree, absolute, or under `~` - and a linked\n * worktree's shared hooks. Where git cannot say, the first line of what it\n * said instead: under a `core.hooksPath` that names a file it refuses the\n * path as one that is not a directory (measured with git 2.47 and 2.55).\n */\nexport async function hookPath(name: string, cwd: string): Promise<{ readonly path: string } | { readonly refused: string }> {\n const run = await git(['rev-parse', '--path-format=absolute', '--git-path', `hooks/${name}`], cwd);\n if (run.code === 0) return { path: run.stdout.trim() };\n return { refused: run.stderr.split(/\\r?\\n/)[0]! };\n}\n\nconst HEADS = 'refs/heads/';\n\n/**\n * The checked-out branch's name, or `null` on a detached head: what HEAD\n * points at, with `refs/heads/` taken off and nothing else. `--short` gives\n * the shortest name git reads as that ref and no other, which is another\n * name where a tag, or a ref under `refs/`, has the branch's: measured with\n * git 2.55, on `claude/brief-042-x` beside the tag `claude/brief-042-x` it\n * answers `heads/claude/brief-042-x`, which no template reads, and the\n * brief went unnamed (ADR-0005). A HEAD that points outside `refs/heads/`,\n * as `git symbolic-ref HEAD refs/tags/x` leaves it, is named as git status\n * names it, the ref whole.\n */\nexport async function currentBranch(cwd: string): Promise<string | null> {\n const ref = await value(['symbolic-ref', '--quiet', 'HEAD'], cwd);\n return ref !== null && ref.startsWith(HEADS) ? ref.slice(HEADS.length) : ref;\n}\n\n/**\n * The remote's default branch as a local ref, such as `origin/main`, or\n * `null`. This is a ref the harness hands git and spec-brief as the base, so\n * it is the short name git reads as that ref and no other: beside a local\n * branch or a tag named `origin/main`, measured with git 2.55, it is\n * `remotes/origin/main`, where `origin/main` would be read as the branch or\n * the tag (gitrevisions(7), the order a ref name is read in).\n */\nexport async function remoteDefault(cwd: string): Promise<string | null> {\n return value(['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], cwd);\n}\n\n/**\n * Every local branch. The two are one name but where a tag, or a ref under\n * `refs/`, has the branch's: `main` beside the tag `main` is the ref\n * `heads/main`. A ref name holds no control character\n * (git-check-ref-format(1)), so a tab parts the two.\n */\nexport async function localBranches(cwd: string): Promise<LocalBranch[]> {\n const listed = await value(['for-each-ref', '--format=%(refname:lstrip=2)%09%(refname:short)', HEADS], cwd);\n // for-each-ref fails only where git itself does, outside a repository,\n // where init never runs: the null check is there for the type.\n if (listed === null || listed === '') return [];\n return listed.split('\\n').map((line) => {\n const parts = line.split('\\t');\n return { name: parts[0]!, ref: parts[1]! };\n });\n}\n\nexport async function revision(ref: string, cwd: string): Promise<string | null> {\n return value(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], cwd);\n}\n\nexport async function mergeBase(a: string, b: string, cwd: string): Promise<string | null> {\n return value(['merge-base', a, b], cwd);\n}\n\n/** Parses `git diff --name-status -z` output. */\nexport function parseNameStatus(output: string): FileChange[] {\n const fields = output.split('\\0');\n const out: FileChange[] = [];\n for (let i = 0; i < fields.length; ) {\n const code = fields[i] as string;\n if (code === '') {\n i += 1;\n continue;\n }\n const letter = code.charAt(0);\n const status = letter as FileChange['status'];\n if (status === 'R' || status === 'C') {\n out.push({ status, from: fields[i + 1] ?? '', path: fields[i + 2] ?? '' });\n i += 3;\n } else {\n out.push({ status, path: fields[i + 1] ?? '' });\n i += 2;\n }\n }\n return out;\n}\n\n/** The files changed from one revision to another, renames found. */\nexport async function changes(from: string, to: string, cwd: string): Promise<FileChange[]> {\n const run = await git(['diff', '--name-status', '-z', '--find-renames', '--no-color', from, to, '--'], cwd);\n if (run.code !== 0) throw new GitError(`git diff ${from} ${to} failed: ${run.stderr.trim()}`);\n return parseNameStatus(run.stdout);\n}\n\n/** The files a commit would change: what is staged. */\nexport async function stagedChanges(cwd: string): Promise<FileChange[]> {\n const run = await git(['diff', '--cached', '--name-status', '-z', '--find-renames', '--no-color', '--'], cwd);\n if (run.code !== 0) throw new GitError(`git diff --cached failed: ${run.stderr.trim()}`);\n return parseNameStatus(run.stdout);\n}\n\n/** A file's content at a revision, or `null` when it does not exist there. */\nexport async function show(ref: string, path: string, cwd: string): Promise<string | null> {\n const run = await git(['show', `${ref}:${path}`], cwd);\n return run.code === 0 ? run.stdout : null;\n}\n\n/** Whether a commit id from {@link blameLine} is the all-zero one of a line nobody committed. */\nexport function isUncommitted(sha: string): boolean {\n return /^0+$/.test(sha);\n}\n\n/**\n * The commit that last changed one line of a file, as of a revision, or of\n * the working tree when `ref` is `null` - where a line nobody committed yet\n * belongs to the all-zero commit.\n */\nexport async function blameLine(ref: string | null, path: string, line: number, cwd: string): Promise<string | null> {\n const run = await git(['blame', '--porcelain', '-L', `${line},${line}`, ...(ref === null ? [] : [ref]), '--', path], cwd);\n // A blame that fails prints nothing on stdout, which names no commit. Its\n // output opens with the commit, so the anchor is equivalent to its mutant.\n const sha = /^([0-9a-f]{40,64}) /.exec(run.stdout)?.[1];\n return sha ?? null;\n}\n\n/** A good signature names its allowed signer's principal; any other names none. */\nexport type Signature =\n | { readonly good: true; readonly principal: string; readonly detail: string }\n | { readonly good: false; readonly principal: null; readonly detail: string };\n\n/**\n * Whether a commit carries a good SSH signature by a principal in the given\n * allowed-signers file. The file is passed in, never read from the commit's\n * own tree: a round cannot vouch for itself (ADR-0006).\n */\nexport async function verifyCommit(sha: string, allowedSignersFile: string, cwd: string): Promise<Signature> {\n const run = await git(\n ['-c', `gpg.ssh.allowedSignersFile=${allowedSignersFile}`, '-c', 'gpg.format=ssh', 'verify-commit', '--raw', sha],\n cwd,\n );\n const text = `${run.stderr}\\n${run.stdout}`;\n const principal = /Good \"git\" signature for (\\S+)/.exec(text)?.[1];\n // git writes the check to stderr, so the trim changes the first line only\n // for output on stdout alone, which verify-commit does not print; and split\n // always answers a first part.\n const detail = text.trim().split('\\n')[0]!;\n // git exits 0 only for a signature by a listed principal, which it names:\n // either condition alone is equivalent to both, and both trust neither.\n return run.code === 0 && principal !== undefined ? { good: true, principal, detail } : { good: false, principal: null, detail };\n}\n\nexport async function addWorktree(path: string, rev: string, cwd: string): Promise<void> {\n const run = await git(['worktree', 'add', '--detach', '--force', path, rev], cwd);\n if (run.code !== 0) throw new GitError(`git worktree add failed: ${run.stderr.trim()}`);\n}\n\n/**\n * Has git remove the worktree at `path`, and no other, with its record of it,\n * and answers whether git did. Git deletes what it can of the worktree's\n * directory and forgets the worktree even when it cannot delete it all. It\n * refuses a worktree whose directory has lost its `.git` file, and removes\n * its record of one whose directory is gone, found by the path it was added\n * at: `git worktree prune` would also forget every other worktree git has\n * lost track of, the person's among them.\n */\nexport async function removeWorktree(path: string, cwd: string): Promise<boolean> {\n // The sandbox asks again when this answers false, and git, asked again\n // about a worktree it has forgotten, changes nothing: the mutant that\n // always answers false is equivalent.\n return (await git(['worktree', 'remove', '--force', path], cwd)).code === 0;\n}\n"]}
package/dist/hooks.d.ts CHANGED
@@ -40,21 +40,87 @@ export interface HookRequest {
40
40
  readonly cwd: string | null;
41
41
  /** Files the tool writes, as the tool named them - absolute, usually. */
42
42
  readonly paths: readonly string[];
43
+ /** The session and the tool call the question is about, when it says: `session_id` and `tool_use_id`. */
44
+ readonly session: string | null;
45
+ readonly call: string | null;
43
46
  }
47
+ /**
48
+ * The event Claude Code runs as a session starts, resumes, is cleared or
49
+ * compacted, or is forked: no tool is named, and the hook checks what every
50
+ * write of the session will need (ADR-0005).
51
+ */
52
+ export declare const SESSION_START = "SessionStart";
44
53
  /** Reads a Claude Code hook's stdin, or says why it cannot. */
45
54
  export declare function parseClaudeHook(raw: string): HookRequest | {
46
55
  readonly error: string;
47
56
  };
57
+ /**
58
+ * The answer as a session starts: what would hold every write of it, in
59
+ * one line, for the person as `systemMessage` and for Claude as
60
+ * `additionalContext`; or nothing, where all is well. Always exit 0: the
61
+ * event cannot block, and a check that fails must not keep a session from
62
+ * starting.
63
+ */
64
+ export declare function sessionResponse(problem: string | null): HookResponse;
48
65
  export interface HookResponse {
49
66
  /** What to print on stdout: JSON, or nothing. */
50
67
  readonly stdout: string;
51
68
  readonly exitCode: number;
52
69
  }
70
+ /** What a warning after a write says: a decision's message and its next step. */
71
+ export type Warning = Pick<Decision, 'message' | 'hint'>;
72
+ /** The answer after a write: the warnings, as additional context for the model, or nothing. */
73
+ export declare function afterResponse(warnings: readonly Warning[]): HookResponse;
53
74
  /**
54
75
  * The answer to a Claude Code hook: a refusal or a question before a write,
55
76
  * a warning after one, or nothing.
56
77
  */
57
78
  export declare function claudeResponse(event: string, decisions: readonly Decision[]): HookResponse;
79
+ /** The decisions a run after the write would say: those that warn, each by its message and next step. */
80
+ export declare function warningsOf(decisions: readonly Decision[]): Warning[];
81
+ /**
82
+ * What the hook reads besides its question and its line, as it decides: the
83
+ * project Claude Code names, the brief the environment names, and the
84
+ * branch a forge's CI names on a detached head (ADR-0004).
85
+ */
86
+ export declare const CALL_VARIABLES: readonly string[];
87
+ /**
88
+ * A tool call, as both of its hooks see it: the release, the session and
89
+ * the call, the tool and its paths, the session's directory, what the
90
+ * hook's line gives, the directory the hook was started in, and the
91
+ * variables it reads (ADR-0002). What a run before the write keeps is
92
+ * answered after it only to a run for which all of it is the same.
93
+ */
94
+ export interface CallRecord {
95
+ readonly release: string;
96
+ readonly session: string;
97
+ readonly call: string;
98
+ readonly tool: string;
99
+ readonly paths: readonly string[];
100
+ readonly cwd: string | null;
101
+ readonly line: readonly (string | null)[];
102
+ readonly started: string;
103
+ readonly env: readonly (string | null)[];
104
+ }
105
+ /**
106
+ * The call a question is about, or `null` where it does not say which
107
+ * session and call it is, or says it in a form no file is named by.
108
+ */
109
+ export declare function callRecord(request: HookRequest, line: Readonly<{
110
+ root: string | undefined;
111
+ brief: string | undefined;
112
+ base: string | undefined;
113
+ }>, started: string, env: Readonly<Record<string, string | undefined>>, release: string): CallRecord | null;
114
+ /** The name of the file a call's decisions are kept under. */
115
+ export declare function callFile(record: CallRecord): string;
116
+ /** What a run before the write keeps for the run after it: the call, and the warnings it decided. */
117
+ export declare function keptCall(record: CallRecord, warnings: readonly Warning[]): string;
118
+ /**
119
+ * The warnings kept for this call, or `null` where what was kept is not
120
+ * JSON, is another call's, or holds anything but warnings: such a call is
121
+ * decided as it was before anything was kept (ADR-0002).
122
+ */
123
+ export declare function warningsKept(text: string, record: CallRecord): Warning[] | null;
58
124
  /**
59
125
  * The answer to git's pre-commit hook: every file the commit would change
60
126
  * that the round may not, and exit 1 if any is refused. A warning prints and
package/dist/hooks.js CHANGED
@@ -36,6 +36,12 @@ const PATH_FIELDS = ['file_path', 'path', 'notebook_path'];
36
36
  function isObject(value) {
37
37
  return typeof value === 'object' && value !== null && !Array.isArray(value);
38
38
  }
39
+ /**
40
+ * The event Claude Code runs as a session starts, resumes, is cleared or
41
+ * compacted, or is forked: no tool is named, and the hook checks what every
42
+ * write of the session will need (ADR-0005).
43
+ */
44
+ export const SESSION_START = 'SessionStart';
39
45
  /** Reads a Claude Code hook's stdin, or says why it cannot. */
40
46
  export function parseClaudeHook(raw) {
41
47
  let value;
@@ -49,24 +55,53 @@ export function parseClaudeHook(raw) {
49
55
  return { error: 'the hook input is not a JSON object' };
50
56
  const event = value['hook_event_name'];
51
57
  const tool = value['tool_name'];
52
- if (typeof event !== 'string' || typeof tool !== 'string')
58
+ if (typeof event !== 'string' || (typeof tool !== 'string' && event !== SESSION_START))
53
59
  return { error: 'the hook input names no event or tool' };
60
+ // A session's start names no tool, and is read as naming none.
61
+ const named = typeof tool === 'string' ? tool : '';
54
62
  const input = isObject(value['tool_input']) ? value['tool_input'] : {};
55
63
  const paths = [];
56
- if (WRITING_TOOLS.includes(tool)) {
64
+ if (WRITING_TOOLS.includes(named)) {
57
65
  for (const field of PATH_FIELDS) {
58
66
  const path = input[field];
59
67
  if (typeof path === 'string' && path !== '' && !paths.includes(path))
60
68
  paths.push(path);
61
69
  }
62
70
  }
63
- const cwd = value['cwd'];
64
- return { event, tool, cwd: typeof cwd === 'string' ? cwd : null, paths };
71
+ const text = (field) => {
72
+ const found = value[field];
73
+ return typeof found === 'string' ? found : null;
74
+ };
75
+ return { event, tool: named, cwd: text('cwd'), paths, session: text('session_id'), call: text('tool_use_id') };
76
+ }
77
+ /**
78
+ * The answer as a session starts: what would hold every write of it, in
79
+ * one line, for the person as `systemMessage` and for Claude as
80
+ * `additionalContext`; or nothing, where all is well. Always exit 0: the
81
+ * event cannot block, and a check that fails must not keep a session from
82
+ * starting.
83
+ */
84
+ export function sessionResponse(problem) {
85
+ if (problem === null)
86
+ return NONE;
87
+ const said = `spec-harness: ${problem}`;
88
+ return { stdout: `${JSON.stringify({ systemMessage: said, hookSpecificOutput: { hookEventName: SESSION_START, additionalContext: said } })}\n`, exitCode: 0 };
65
89
  }
66
90
  const NONE = { stdout: '', exitCode: 0 };
67
91
  function reasonOf(decisions) {
68
92
  return decisions.map((decision) => `spec-harness: ${decision.message}. Next: ${decision.hint}.`).join('\n');
69
93
  }
94
+ /** The answer after a write: the warnings, as additional context for the model, or nothing. */
95
+ export function afterResponse(warnings) {
96
+ if (warnings.length === 0)
97
+ return NONE;
98
+ return {
99
+ stdout: `${JSON.stringify({
100
+ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: reasonOf(warnings) },
101
+ })}\n`,
102
+ exitCode: 0,
103
+ };
104
+ }
70
105
  /**
71
106
  * The answer to a Claude Code hook: a refusal or a question before a write,
72
107
  * a warning after one, or nothing.
@@ -89,19 +124,81 @@ export function claudeResponse(event, decisions) {
89
124
  exitCode: 0,
90
125
  };
91
126
  }
92
- if (event === 'PostToolUse') {
93
- const warned = decisions.filter((decision) => decision.verdict === 'warn');
94
- if (warned.length === 0)
95
- return NONE;
96
- return {
97
- stdout: `${JSON.stringify({
98
- hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: reasonOf(warned) },
99
- })}\n`,
100
- exitCode: 0,
101
- };
102
- }
127
+ if (event === 'PostToolUse')
128
+ return afterResponse(warningsOf(decisions));
103
129
  return NONE;
104
130
  }
131
+ /** The decisions a run after the write would say: those that warn, each by its message and next step. */
132
+ export function warningsOf(decisions) {
133
+ return decisions.filter((decision) => decision.verdict === 'warn').map(({ message, hint }) => ({ message, hint }));
134
+ }
135
+ /**
136
+ * What the hook reads besides its question and its line, as it decides: the
137
+ * project Claude Code names, the brief the environment names, and the
138
+ * branch a forge's CI names on a detached head (ADR-0004).
139
+ */
140
+ export const CALL_VARIABLES = [
141
+ 'CLAUDE_PROJECT_DIR',
142
+ 'SPEC_BRIEF',
143
+ 'GITHUB_HEAD_REF',
144
+ 'GITHUB_REF_TYPE',
145
+ 'GITHUB_REF_NAME',
146
+ 'CI_MERGE_REQUEST_SOURCE_BRANCH_NAME',
147
+ 'CI_COMMIT_BRANCH',
148
+ ];
149
+ /** An id the hook names a file by: what Claude Code's ids are made of, and nothing a path could climb out by. */
150
+ const ID = /^[\w-]{1,256}$/;
151
+ /**
152
+ * The call a question is about, or `null` where it does not say which
153
+ * session and call it is, or says it in a form no file is named by.
154
+ */
155
+ export function callRecord(request, line, started, env, release) {
156
+ const { session, call } = request;
157
+ if (session === null || call === null || !ID.test(session) || !ID.test(call))
158
+ return null;
159
+ return {
160
+ release,
161
+ session,
162
+ call,
163
+ tool: request.tool,
164
+ paths: request.paths,
165
+ cwd: request.cwd,
166
+ line: [line.root ?? null, line.brief ?? null, line.base ?? null],
167
+ started,
168
+ env: CALL_VARIABLES.map((name) => env[name] ?? null),
169
+ };
170
+ }
171
+ /** The name of the file a call's decisions are kept under. */
172
+ export function callFile(record) {
173
+ return `${record.session}.${record.call}.json`;
174
+ }
175
+ /** What a run before the write keeps for the run after it: the call, and the warnings it decided. */
176
+ export function keptCall(record, warnings) {
177
+ return `${JSON.stringify({ record, warnings })}\n`;
178
+ }
179
+ function isWarning(value) {
180
+ return isObject(value) && typeof value['message'] === 'string' && typeof value['hint'] === 'string';
181
+ }
182
+ /**
183
+ * The warnings kept for this call, or `null` where what was kept is not
184
+ * JSON, is another call's, or holds anything but warnings: such a call is
185
+ * decided as it was before anything was kept (ADR-0002).
186
+ */
187
+ export function warningsKept(text, record) {
188
+ let value;
189
+ try {
190
+ value = JSON.parse(text);
191
+ }
192
+ catch {
193
+ // Read as an empty object, a text that is not JSON holds no record
194
+ // either: that mutant is equivalent.
195
+ return null;
196
+ }
197
+ if (!isObject(value) || JSON.stringify(value['record']) !== JSON.stringify(record))
198
+ return null;
199
+ const warnings = value['warnings'];
200
+ return Array.isArray(warnings) && warnings.every(isWarning) ? warnings.map(({ message, hint }) => ({ message, hint })) : null;
201
+ }
105
202
  /**
106
203
  * The answer to git's pre-commit hook: every file the commit would change
107
204
  * that the round may not, and exit 1 if any is refused. A warning prints and
package/dist/hooks.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"hooks.js","sourceRoot":"","sources":["../src/hooks.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The guard, spoken in the languages that ask it: an agent's pre- and\n * post-tool hooks, and git's pre-commit.\n *\n * Claude Code sends a hook one JSON object on stdin naming the tool and its\n * input. A file-editing tool names its file under `file_path`, `path` or\n * `notebook_path` depending on the tool and the release; all three are read,\n * because a guard that missed a renamed field would allow everything.\n *\n * What the answer may not do matters as much as what it does. Returning\n * `allow` from a pre-tool hook skips the person's own permission prompt, so\n * the guard never says `allow`: it says `deny`, or `ask`, or nothing. A\n * warning - a write outside the scope when the repository asked only to be\n * warned - is told to the model after the write, through the post-tool hook's\n * additional context, where it cannot unlock anything.\n */\n\nimport type { Decision } from './guard.js';\n\n/**\n * The tools whose input names a file they write, by every name a release of\n * Claude Code has had for one. The hook reads a path from each: a name more\n * costs nothing, and a Claude Code that still has the tool may send it.\n */\nexport const WRITING_TOOLS: readonly string[] = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit'];\n\n/**\n * Those of them a Claude Code the harness runs under can call\n * (`CLAUDE_CODE_MINIMUM`, versions.ts). Its tools reference\n * (https://code.claude.com/docs/en/tools-reference, read 2026-10-12) has\n * `Edit`, `Write` and `NotebookEdit` in its table of tools, and no\n * `MultiEdit`, which its permissions page calls a legacy tool; and the tool\n * inputs each release declares hold a `MultiEdit` up to 1.0.128 and none\n * from 2.0.0 on (ADR-0005). So what doctor and init say of the writes a\n * line is started for names these alone: a tool nobody can call leaves no\n * write unchecked.\n */\nexport const CALLABLE_TOOLS: readonly string[] = ['Edit', 'Write', 'NotebookEdit'];\n\nconst PATH_FIELDS: readonly string[] = ['file_path', 'path', 'notebook_path'];\n\nexport interface HookRequest {\n readonly event: string;\n readonly tool: string;\n /** The session's working directory, when the hook says. */\n readonly cwd: string | null;\n /** Files the tool writes, as the tool named them - absolute, usually. */\n readonly paths: readonly string[];\n}\n\ntype Json = Record<string, unknown>;\n\nfunction isObject(value: unknown): value is Json {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\n/** Reads a Claude Code hook's stdin, or says why it cannot. */\nexport function parseClaudeHook(raw: string): HookRequest | { readonly error: string } {\n let value: unknown;\n try {\n value = JSON.parse(raw);\n } catch {\n return { error: 'the hook input is not JSON' };\n }\n if (!isObject(value)) return { error: 'the hook input is not a JSON object' };\n const event = value['hook_event_name'];\n const tool = value['tool_name'];\n if (typeof event !== 'string' || typeof tool !== 'string') return { error: 'the hook input names no event or tool' };\n const input = isObject(value['tool_input']) ? value['tool_input'] : {};\n const paths: string[] = [];\n if (WRITING_TOOLS.includes(tool)) {\n for (const field of PATH_FIELDS) {\n const path = input[field];\n if (typeof path === 'string' && path !== '' && !paths.includes(path)) paths.push(path);\n }\n }\n const cwd = value['cwd'];\n return { event, tool, cwd: typeof cwd === 'string' ? cwd : null, paths };\n}\n\nexport interface HookResponse {\n /** What to print on stdout: JSON, or nothing. */\n readonly stdout: string;\n readonly exitCode: number;\n}\n\nconst NONE: HookResponse = { stdout: '', exitCode: 0 };\n\nfunction reasonOf(decisions: readonly Decision[]): string {\n return decisions.map((decision) => `spec-harness: ${decision.message}. Next: ${decision.hint}.`).join('\\n');\n}\n\n/**\n * The answer to a Claude Code hook: a refusal or a question before a write,\n * a warning after one, or nothing.\n */\nexport function claudeResponse(event: string, decisions: readonly Decision[]): HookResponse {\n if (event === 'PreToolUse') {\n const denied = decisions.filter((decision) => decision.verdict === 'deny');\n const asked = decisions.filter((decision) => decision.verdict === 'ask');\n const decided = denied.length > 0 ? { verdict: 'deny', list: denied } : asked.length > 0 ? { verdict: 'ask', list: asked } : null;\n if (decided === null) return NONE;\n return {\n stdout: `${JSON.stringify({\n hookSpecificOutput: {\n hookEventName: 'PreToolUse',\n permissionDecision: decided.verdict,\n permissionDecisionReason: reasonOf(decided.list),\n },\n })}\\n`,\n exitCode: 0,\n };\n }\n if (event === 'PostToolUse') {\n const warned = decisions.filter((decision) => decision.verdict === 'warn');\n if (warned.length === 0) return NONE;\n return {\n stdout: `${JSON.stringify({\n hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: reasonOf(warned) },\n })}\\n`,\n exitCode: 0,\n };\n }\n return NONE;\n}\n\n/**\n * The answer to git's pre-commit hook: every file the commit would change\n * that the round may not, and exit 1 if any is refused. A warning prints and\n * lets the commit through; a question has no one to ask in git, so it refuses.\n */\nexport function gitResponse(decisions: readonly Decision[]): { readonly text: string; readonly exitCode: number } {\n const lines: string[] = [];\n let refused = 0;\n for (const decision of decisions) {\n if (decision.verdict === 'allow') continue;\n const blocking = decision.verdict === 'deny' || decision.verdict === 'ask';\n if (blocking) refused += 1;\n lines.push(`${blocking ? 'refused' : 'warning'} ${decision.message}`, ` ${decision.hint}`);\n }\n if (refused > 0) lines.push(`spec-harness: ${refused} file(s) this round may not change; the commit was stopped`);\n return { text: lines.length === 0 ? '' : `${lines.join('\\n')}\\n`, exitCode: refused > 0 ? 1 : 0 };\n}\n"]}
1
+ {"version":3,"file":"hooks.js","sourceRoot":"","sources":["../src/hooks.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAIH;;;;GAIG;AACH,MAAM,CAAC,MAAM,aAAa,GAAsB,CAAC,MAAM,EAAE,OAAO,EAAE,WAAW,EAAE,cAAc,CAAC,CAAC;AAE/F;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,cAAc,GAAsB,CAAC,MAAM,EAAE,OAAO,EAAE,cAAc,CAAC,CAAC;AAEnF,MAAM,WAAW,GAAsB,CAAC,WAAW,EAAE,MAAM,EAAE,eAAe,CAAC,CAAC;AAgB9E,SAAS,QAAQ,CAAC,KAAc;IAC9B,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;AAC9E,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG,cAAc,CAAC;AAE5C,+DAA+D;AAC/D,MAAM,UAAU,eAAe,CAAC,GAAW;IACzC,IAAI,KAAc,CAAC;IACnB,IAAI,CAAC;QACH,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IAC1B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,KAAK,EAAE,4BAA4B,EAAE,CAAC;IACjD,CAAC;IACD,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC;QAAE,OAAO,EAAE,KAAK,EAAE,qCAAqC,EAAE,CAAC;IAC9E,MAAM,KAAK,GAAG,KAAK,CAAC,iBAAiB,CAAC,CAAC;IACvC,MAAM,IAAI,GAAG,KAAK,CAAC,WAAW,CAAC,CAAC;IAChC,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,CAAC,OAAO,IAAI,KAAK,QAAQ,IAAI,KAAK,KAAK,aAAa,CAAC;QAAE,OAAO,EAAE,KAAK,EAAE,uCAAuC,EAAE,CAAC;IAClJ,+DAA+D;IAC/D,MAAM,KAAK,GAAG,OAAO,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC;IACnD,MAAM,KAAK,GAAG,QAAQ,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;IACvE,MAAM,KAAK,GAAa,EAAE,CAAC;IAC3B,IAAI,aAAa,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC;QAClC,KAAK,MAAM,KAAK,IAAI,WAAW,EAAE,CAAC;YAChC,MAAM,IAAI,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC;YAC1B,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,IAAI,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,IAAI,CAAC;gBAAE,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACzF,CAAC;IACH,CAAC;IACD,MAAM,IAAI,GAAG,CAAC,KAAa,EAAiB,EAAE;QAC5C,MAAM,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC;QAC3B,OAAO,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC;IAClD,CAAC,CAAC;IACF,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,GAAG,EAAE,IAAI,CAAC,KAAK,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,CAAC,YAAY,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,aAAa,CAAC,EAAE,CAAC;AACjH,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,eAAe,CAAC,OAAsB;IACpD,IAAI,OAAO,KAAK,IAAI;QAAE,OAAO,IAAI,CAAC;IAClC,MAAM,IAAI,GAAG,iBAAiB,OAAO,EAAE,CAAC;IACxC,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC,SAAS,CAAC,EAAE,aAAa,EAAE,IAAI,EAAE,kBAAkB,EAAE,EAAE,aAAa,EAAE,aAAa,EAAE,iBAAiB,EAAE,IAAI,EAAE,EAAE,CAAC,IAAI,EAAE,QAAQ,EAAE,CAAC,EAAE,CAAC;AAChK,CAAC;AAQD,MAAM,IAAI,GAAiB,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,EAAE,CAAC,EAAE,CAAC;AAEvD,SAAS,QAAQ,CAAC,SAA6B;IAC7C,OAAO,SAAS,CAAC,GAAG,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,iBAAiB,QAAQ,CAAC,OAAO,WAAW,QAAQ,CAAC,IAAI,GAAG,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAC9G,CAAC;AAKD,+FAA+F;AAC/F,MAAM,UAAU,aAAa,CAAC,QAA4B;IACxD,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IACvC,OAAO;QACL,MAAM,EAAE,GAAG,IAAI,CAAC,SAAS,CAAC;YACxB,kBAAkB,EAAE,EAAE,aAAa,EAAE,aAAa,EAAE,iBAAiB,EAAE,QAAQ,CAAC,QAAQ,CAAC,EAAE;SAC5F,CAAC,IAAI;QACN,QAAQ,EAAE,CAAC;KACZ,CAAC;AACJ,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,cAAc,CAAC,KAAa,EAAE,SAA8B;IAC1E,IAAI,KAAK,KAAK,YAAY,EAAE,CAAC;QAC3B,MAAM,MAAM,GAAG,SAAS,CAAC,MAAM,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,QAAQ,CAAC,OAAO,KAAK,MAAM,CAAC,CAAC;QAC3E,MAAM,KAAK,GAAG,SAAS,CAAC,MAAM,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,QAAQ,CAAC,OAAO,KAAK,KAAK,CAAC,CAAC;QACzE,MAAM,OAAO,GAAG,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;QAClI,IAAI,OAAO,KAAK,IAAI;YAAE,OAAO,IAAI,CAAC;QAClC,OAAO;YACL,MAAM,EAAE,GAAG,IAAI,CAAC,SAAS,CAAC;gBACxB,kBAAkB,EAAE;oBAClB,aAAa,EAAE,YAAY;oBAC3B,kBAAkB,EAAE,OAAO,CAAC,OAAO;oBACnC,wBAAwB,EAAE,QAAQ,CAAC,OAAO,CAAC,IAAI,CAAC;iBACjD;aACF,CAAC,IAAI;YACN,QAAQ,EAAE,CAAC;SACZ,CAAC;IACJ,CAAC;IACD,IAAI,KAAK,KAAK,aAAa;QAAE,OAAO,aAAa,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,CAAC;IACzE,OAAO,IAAI,CAAC;AACd,CAAC;AAED,yGAAyG;AACzG,MAAM,UAAU,UAAU,CAAC,SAA8B;IACvD,OAAO,SAAS,CAAC,MAAM,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,QAAQ,CAAC,OAAO,KAAK,MAAM,CAAC,CAAC,GAAG,CAAC,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;AACrH,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,MAAM,cAAc,GAAsB;IAC/C,oBAAoB;IACpB,YAAY;IACZ,iBAAiB;IACjB,iBAAiB;IACjB,iBAAiB;IACjB,qCAAqC;IACrC,kBAAkB;CACnB,CAAC;AAqBF,iHAAiH;AACjH,MAAM,EAAE,GAAG,gBAAgB,CAAC;AAE5B;;;GAGG;AACH,MAAM,UAAU,UAAU,CACxB,OAAoB,EACpB,IAAiG,EACjG,OAAe,EACf,GAAiD,EACjD,OAAe;IAEf,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,GAAG,OAAO,CAAC;IAClC,IAAI,OAAO,KAAK,IAAI,IAAI,IAAI,KAAK,IAAI,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC;IAC1F,OAAO;QACL,OAAO;QACP,OAAO;QACP,IAAI;QACJ,IAAI,EAAE,OAAO,CAAC,IAAI;QAClB,KAAK,EAAE,OAAO,CAAC,KAAK;QACpB,GAAG,EAAE,OAAO,CAAC,GAAG;QAChB,IAAI,EAAE,CAAC,IAAI,CAAC,IAAI,IAAI,IAAI,EAAE,IAAI,CAAC,KAAK,IAAI,IAAI,EAAE,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC;QAChE,OAAO;QACP,GAAG,EAAE,cAAc,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,IAAI,CAAC;KACrD,CAAC;AACJ,CAAC;AAED,8DAA8D;AAC9D,MAAM,UAAU,QAAQ,CAAC,MAAkB;IACzC,OAAO,GAAG,MAAM,CAAC,OAAO,IAAI,MAAM,CAAC,IAAI,OAAO,CAAC;AACjD,CAAC;AAED,qGAAqG;AACrG,MAAM,UAAU,QAAQ,CAAC,MAAkB,EAAE,QAA4B;IACvE,OAAO,GAAG,IAAI,CAAC,SAAS,CAAC,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,IAAI,CAAC;AACrD,CAAC;AAED,SAAS,SAAS,CAAC,KAAc;IAC/B,OAAO,QAAQ,CAAC,KAAK,CAAC,IAAI,OAAO,KAAK,CAAC,SAAS,CAAC,KAAK,QAAQ,IAAI,OAAO,KAAK,CAAC,MAAM,CAAC,KAAK,QAAQ,CAAC;AACtG,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,YAAY,CAAC,IAAY,EAAE,MAAkB;IAC3D,IAAI,KAAc,CAAC;IACnB,IAAI,CAAC;QACH,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IAC3B,CAAC;IAAC,MAAM,CAAC;QACP,mEAAmE;QACnE,qCAAqC;QACrC,OAAO,IAAI,CAAC;IACd,CAAC;IACD,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,KAAK,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC;QAAE,OAAO,IAAI,CAAC;IAChG,MAAM,QAAQ,GAAG,KAAK,CAAC,UAAU,CAAC,CAAC;IACnC,OAAO,KAAK,CAAC,OAAO,CAAC,QAAQ,CAAC,IAAI,QAAQ,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;AAChI,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,WAAW,CAAC,SAA8B;IACxD,MAAM,KAAK,GAAa,EAAE,CAAC;IAC3B,IAAI,OAAO,GAAG,CAAC,CAAC;IAChB,KAAK,MAAM,QAAQ,IAAI,SAAS,EAAE,CAAC;QACjC,IAAI,QAAQ,CAAC,OAAO,KAAK,OAAO;YAAE,SAAS;QAC3C,MAAM,QAAQ,GAAG,QAAQ,CAAC,OAAO,KAAK,MAAM,IAAI,QAAQ,CAAC,OAAO,KAAK,KAAK,CAAC;QAC3E,IAAI,QAAQ;YAAE,OAAO,IAAI,CAAC,CAAC;QAC3B,KAAK,CAAC,IAAI,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,SAAS,KAAK,QAAQ,CAAC,OAAO,EAAE,EAAE,YAAY,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAC;IACtG,CAAC;IACD,IAAI,OAAO,GAAG,CAAC;QAAE,KAAK,CAAC,IAAI,CAAC,iBAAiB,OAAO,4DAA4D,CAAC,CAAC;IAClH,OAAO,EAAE,IAAI,EAAE,KAAK,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,QAAQ,EAAE,OAAO,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;AACpG,CAAC","sourcesContent":["/**\n * The guard, spoken in the languages that ask it: an agent's pre- and\n * post-tool hooks, and git's pre-commit.\n *\n * Claude Code sends a hook one JSON object on stdin naming the tool and its\n * input. A file-editing tool names its file under `file_path`, `path` or\n * `notebook_path` depending on the tool and the release; all three are read,\n * because a guard that missed a renamed field would allow everything.\n *\n * What the answer may not do matters as much as what it does. Returning\n * `allow` from a pre-tool hook skips the person's own permission prompt, so\n * the guard never says `allow`: it says `deny`, or `ask`, or nothing. A\n * warning - a write outside the scope when the repository asked only to be\n * warned - is told to the model after the write, through the post-tool hook's\n * additional context, where it cannot unlock anything.\n */\n\nimport type { Decision } from './guard.js';\n\n/**\n * The tools whose input names a file they write, by every name a release of\n * Claude Code has had for one. The hook reads a path from each: a name more\n * costs nothing, and a Claude Code that still has the tool may send it.\n */\nexport const WRITING_TOOLS: readonly string[] = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit'];\n\n/**\n * Those of them a Claude Code the harness runs under can call\n * (`CLAUDE_CODE_MINIMUM`, versions.ts). Its tools reference\n * (https://code.claude.com/docs/en/tools-reference, read 2026-10-12) has\n * `Edit`, `Write` and `NotebookEdit` in its table of tools, and no\n * `MultiEdit`, which its permissions page calls a legacy tool; and the tool\n * inputs each release declares hold a `MultiEdit` up to 1.0.128 and none\n * from 2.0.0 on (ADR-0005). So what doctor and init say of the writes a\n * line is started for names these alone: a tool nobody can call leaves no\n * write unchecked.\n */\nexport const CALLABLE_TOOLS: readonly string[] = ['Edit', 'Write', 'NotebookEdit'];\n\nconst PATH_FIELDS: readonly string[] = ['file_path', 'path', 'notebook_path'];\n\nexport interface HookRequest {\n readonly event: string;\n readonly tool: string;\n /** The session's working directory, when the hook says. */\n readonly cwd: string | null;\n /** Files the tool writes, as the tool named them - absolute, usually. */\n readonly paths: readonly string[];\n /** The session and the tool call the question is about, when it says: `session_id` and `tool_use_id`. */\n readonly session: string | null;\n readonly call: string | null;\n}\n\ntype Json = Record<string, unknown>;\n\nfunction isObject(value: unknown): value is Json {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\n/**\n * The event Claude Code runs as a session starts, resumes, is cleared or\n * compacted, or is forked: no tool is named, and the hook checks what every\n * write of the session will need (ADR-0005).\n */\nexport const SESSION_START = 'SessionStart';\n\n/** Reads a Claude Code hook's stdin, or says why it cannot. */\nexport function parseClaudeHook(raw: string): HookRequest | { readonly error: string } {\n let value: unknown;\n try {\n value = JSON.parse(raw);\n } catch {\n return { error: 'the hook input is not JSON' };\n }\n if (!isObject(value)) return { error: 'the hook input is not a JSON object' };\n const event = value['hook_event_name'];\n const tool = value['tool_name'];\n if (typeof event !== 'string' || (typeof tool !== 'string' && event !== SESSION_START)) return { error: 'the hook input names no event or tool' };\n // A session's start names no tool, and is read as naming none.\n const named = typeof tool === 'string' ? tool : '';\n const input = isObject(value['tool_input']) ? value['tool_input'] : {};\n const paths: string[] = [];\n if (WRITING_TOOLS.includes(named)) {\n for (const field of PATH_FIELDS) {\n const path = input[field];\n if (typeof path === 'string' && path !== '' && !paths.includes(path)) paths.push(path);\n }\n }\n const text = (field: string): string | null => {\n const found = value[field];\n return typeof found === 'string' ? found : null;\n };\n return { event, tool: named, cwd: text('cwd'), paths, session: text('session_id'), call: text('tool_use_id') };\n}\n\n/**\n * The answer as a session starts: what would hold every write of it, in\n * one line, for the person as `systemMessage` and for Claude as\n * `additionalContext`; or nothing, where all is well. Always exit 0: the\n * event cannot block, and a check that fails must not keep a session from\n * starting.\n */\nexport function sessionResponse(problem: string | null): HookResponse {\n if (problem === null) return NONE;\n const said = `spec-harness: ${problem}`;\n return { stdout: `${JSON.stringify({ systemMessage: said, hookSpecificOutput: { hookEventName: SESSION_START, additionalContext: said } })}\\n`, exitCode: 0 };\n}\n\nexport interface HookResponse {\n /** What to print on stdout: JSON, or nothing. */\n readonly stdout: string;\n readonly exitCode: number;\n}\n\nconst NONE: HookResponse = { stdout: '', exitCode: 0 };\n\nfunction reasonOf(decisions: readonly Warning[]): string {\n return decisions.map((decision) => `spec-harness: ${decision.message}. Next: ${decision.hint}.`).join('\\n');\n}\n\n/** What a warning after a write says: a decision's message and its next step. */\nexport type Warning = Pick<Decision, 'message' | 'hint'>;\n\n/** The answer after a write: the warnings, as additional context for the model, or nothing. */\nexport function afterResponse(warnings: readonly Warning[]): HookResponse {\n if (warnings.length === 0) return NONE;\n return {\n stdout: `${JSON.stringify({\n hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: reasonOf(warnings) },\n })}\\n`,\n exitCode: 0,\n };\n}\n\n/**\n * The answer to a Claude Code hook: a refusal or a question before a write,\n * a warning after one, or nothing.\n */\nexport function claudeResponse(event: string, decisions: readonly Decision[]): HookResponse {\n if (event === 'PreToolUse') {\n const denied = decisions.filter((decision) => decision.verdict === 'deny');\n const asked = decisions.filter((decision) => decision.verdict === 'ask');\n const decided = denied.length > 0 ? { verdict: 'deny', list: denied } : asked.length > 0 ? { verdict: 'ask', list: asked } : null;\n if (decided === null) return NONE;\n return {\n stdout: `${JSON.stringify({\n hookSpecificOutput: {\n hookEventName: 'PreToolUse',\n permissionDecision: decided.verdict,\n permissionDecisionReason: reasonOf(decided.list),\n },\n })}\\n`,\n exitCode: 0,\n };\n }\n if (event === 'PostToolUse') return afterResponse(warningsOf(decisions));\n return NONE;\n}\n\n/** The decisions a run after the write would say: those that warn, each by its message and next step. */\nexport function warningsOf(decisions: readonly Decision[]): Warning[] {\n return decisions.filter((decision) => decision.verdict === 'warn').map(({ message, hint }) => ({ message, hint }));\n}\n\n/**\n * What the hook reads besides its question and its line, as it decides: the\n * project Claude Code names, the brief the environment names, and the\n * branch a forge's CI names on a detached head (ADR-0004).\n */\nexport const CALL_VARIABLES: readonly string[] = [\n 'CLAUDE_PROJECT_DIR',\n 'SPEC_BRIEF',\n 'GITHUB_HEAD_REF',\n 'GITHUB_REF_TYPE',\n 'GITHUB_REF_NAME',\n 'CI_MERGE_REQUEST_SOURCE_BRANCH_NAME',\n 'CI_COMMIT_BRANCH',\n];\n\n/**\n * A tool call, as both of its hooks see it: the release, the session and\n * the call, the tool and its paths, the session's directory, what the\n * hook's line gives, the directory the hook was started in, and the\n * variables it reads (ADR-0002). What a run before the write keeps is\n * answered after it only to a run for which all of it is the same.\n */\nexport interface CallRecord {\n readonly release: string;\n readonly session: string;\n readonly call: string;\n readonly tool: string;\n readonly paths: readonly string[];\n readonly cwd: string | null;\n readonly line: readonly (string | null)[];\n readonly started: string;\n readonly env: readonly (string | null)[];\n}\n\n/** An id the hook names a file by: what Claude Code's ids are made of, and nothing a path could climb out by. */\nconst ID = /^[\\w-]{1,256}$/;\n\n/**\n * The call a question is about, or `null` where it does not say which\n * session and call it is, or says it in a form no file is named by.\n */\nexport function callRecord(\n request: HookRequest,\n line: Readonly<{ root: string | undefined; brief: string | undefined; base: string | undefined }>,\n started: string,\n env: Readonly<Record<string, string | undefined>>,\n release: string,\n): CallRecord | null {\n const { session, call } = request;\n if (session === null || call === null || !ID.test(session) || !ID.test(call)) return null;\n return {\n release,\n session,\n call,\n tool: request.tool,\n paths: request.paths,\n cwd: request.cwd,\n line: [line.root ?? null, line.brief ?? null, line.base ?? null],\n started,\n env: CALL_VARIABLES.map((name) => env[name] ?? null),\n };\n}\n\n/** The name of the file a call's decisions are kept under. */\nexport function callFile(record: CallRecord): string {\n return `${record.session}.${record.call}.json`;\n}\n\n/** What a run before the write keeps for the run after it: the call, and the warnings it decided. */\nexport function keptCall(record: CallRecord, warnings: readonly Warning[]): string {\n return `${JSON.stringify({ record, warnings })}\\n`;\n}\n\nfunction isWarning(value: unknown): value is Warning {\n return isObject(value) && typeof value['message'] === 'string' && typeof value['hint'] === 'string';\n}\n\n/**\n * The warnings kept for this call, or `null` where what was kept is not\n * JSON, is another call's, or holds anything but warnings: such a call is\n * decided as it was before anything was kept (ADR-0002).\n */\nexport function warningsKept(text: string, record: CallRecord): Warning[] | null {\n let value: unknown;\n try {\n value = JSON.parse(text);\n } catch {\n // Read as an empty object, a text that is not JSON holds no record\n // either: that mutant is equivalent.\n return null;\n }\n if (!isObject(value) || JSON.stringify(value['record']) !== JSON.stringify(record)) return null;\n const warnings = value['warnings'];\n return Array.isArray(warnings) && warnings.every(isWarning) ? warnings.map(({ message, hint }) => ({ message, hint })) : null;\n}\n\n/**\n * The answer to git's pre-commit hook: every file the commit would change\n * that the round may not, and exit 1 if any is refused. A warning prints and\n * lets the commit through; a question has no one to ask in git, so it refuses.\n */\nexport function gitResponse(decisions: readonly Decision[]): { readonly text: string; readonly exitCode: number } {\n const lines: string[] = [];\n let refused = 0;\n for (const decision of decisions) {\n if (decision.verdict === 'allow') continue;\n const blocking = decision.verdict === 'deny' || decision.verdict === 'ask';\n if (blocking) refused += 1;\n lines.push(`${blocking ? 'refused' : 'warning'} ${decision.message}`, ` ${decision.hint}`);\n }\n if (refused > 0) lines.push(`spec-harness: ${refused} file(s) this round may not change; the commit was stopped`);\n return { text: lines.length === 0 ? '' : `${lines.join('\\n')}\\n`, exitCode: refused > 0 ? 1 : 0 };\n}\n"]}
package/dist/round.d.ts CHANGED
@@ -3,8 +3,14 @@
3
3
  * share. Each gathers what a pure module needs - from git, the disk and the
4
4
  * siblings - and hands it over; the decisions are made in `context.ts`,
5
5
  * `audit.ts`, `guard.ts` and `rulings.ts`.
6
+ *
7
+ * The hook loads this module before every write it decides, so what only
8
+ * an audit, a verified ruling or `doctor` runs is imported where it runs:
9
+ * the audit's judgement, which brings the Markdown scanner with it, the
10
+ * hash a copy of the signers is named by, and what asks Claude Code its
11
+ * release.
6
12
  */
7
- import { type AuditReport } from './audit.js';
13
+ import type { AuditReport } from './audit.js';
8
14
  import { type BaseSource, type ClaudeCodeWiring, type ClaudeSettings, type PatternReader, type PluginState } from './configure.js';
9
15
  import { type ContextPacket } from './context.js';
10
16
  import type { DocumentReader } from './document.js';
@@ -42,8 +48,13 @@ export interface RulingCheck {
42
48
  */
43
49
  export declare function checkRulings(workspace: Workspace, brief: Pick<BriefRow, 'file'>, text: string, reader: DocumentReader, base: Base): Promise<RulingCheck>;
44
50
  export declare function briefText(workspace: Workspace, brief: BriefRow): Promise<string>;
45
- /** Decides paths against the brief, with the rulings verified against the base `baseFlag` names, or the configured one. */
46
- export declare function checkPaths(workspace: Workspace, brief: BriefRow | null, noBrief: string | undefined, given: readonly string[], cwd: string, reader: DocumentReader, baseFlag: string | undefined): Promise<Decision[]>;
51
+ /**
52
+ * Decides paths against the brief, with the rulings verified against the
53
+ * base `baseFlag` names, or the configured one. The reader may be given as
54
+ * what makes one: it is asked for where a ruling is read, and a caller that
55
+ * decides every write need not load a scanner for those that read none.
56
+ */
57
+ export declare function checkPaths(workspace: Workspace, brief: BriefRow | null, noBrief: string | undefined, given: readonly string[], cwd: string, reader: DocumentReader | (() => Promise<DocumentReader>), baseFlag: string | undefined): Promise<Decision[]>;
47
58
  export declare function buildContext(workspace: Workspace, brief: BriefRow, briefs: readonly BriefRow[], reader: DocumentReader, baseFlag: string | undefined): Promise<ContextPacket>;
48
59
  /**
49
60
  * Whether a path spec-brief's `plugins` names, read from the root, is this
@@ -99,8 +110,18 @@ export declare function claudeSettings(root: string, env: CliIO['env']): Promise
99
110
  * `init` reads the wiring through this too, so that it and `doctor` say
100
111
  * the same of every tree, and hands it the reader it reads the matchers of
101
112
  * the same files with, so that the two read each pattern once between them.
113
+ *
114
+ * A matcher that lists names with a comma is read as the Claude Code on
115
+ * `PATH` reads it, which turns on its release (configure.ts). `doctor`
116
+ * reports the release, and asks it wherever something wires the guard,
117
+ * since one too old for the hooks lets every write through: the lines are
118
+ * first read as a release that cannot be told reads them, under which a
119
+ * line that may be started counts as wiring. `init` has no other use for
120
+ * the release, so it says that it does not report it, and it is asked only
121
+ * where a line turns on it: where what is read of the lines differs between
122
+ * a release that cannot be told and the newest.
102
123
  */
103
- export declare function claudeCodeWiring(root: string, io: CliIO, pattern?: PatternReader): Promise<ClaudeCodeWiring>;
124
+ export declare function claudeCodeWiring(root: string, io: CliIO, pattern?: PatternReader, reported?: boolean): Promise<ClaudeCodeWiring>;
104
125
  /**
105
126
  * The brief a round works on, as doctor reports it: its id, and what is
106
127
  * wrong with the name when no round can work under it. Every other command