@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +325 -0
- package/README.md +39 -18
- package/dist/briefs.d.ts +45 -0
- package/dist/briefs.js +73 -0
- package/dist/briefs.js.map +1 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +308 -142
- package/dist/cli.js.map +1 -1
- package/dist/commands.js +7 -2
- package/dist/commands.js.map +1 -1
- package/dist/config.d.ts +13 -1
- package/dist/config.js +14 -3
- package/dist/config.js.map +1 -1
- package/dist/configure.d.ts +94 -3
- package/dist/configure.js +204 -30
- package/dist/configure.js.map +1 -1
- package/dist/fs.d.ts +104 -1
- package/dist/fs.js +383 -11
- package/dist/fs.js.map +1 -1
- package/dist/git.d.ts +88 -2
- package/dist/git.js +155 -8
- package/dist/git.js.map +1 -1
- package/dist/guard.d.ts +20 -1
- package/dist/guard.js +31 -0
- package/dist/guard.js.map +1 -1
- package/dist/hooks.d.ts +59 -0
- package/dist/hooks.js +76 -3
- package/dist/hooks.js.map +1 -1
- package/dist/round.d.ts +27 -1
- package/dist/round.js +75 -17
- package/dist/round.js.map +1 -1
- package/dist/sandbox.js +4 -3
- package/dist/sandbox.js.map +1 -1
- package/dist/server.d.ts +25 -1
- package/dist/server.js +230 -28
- package/dist/server.js.map +1 -1
- package/dist/setup.js +40 -10
- package/dist/setup.js.map +1 -1
- package/dist/siblings.d.ts +29 -6
- package/dist/siblings.js +152 -39
- package/dist/siblings.js.map +1 -1
- package/dist/usage.d.ts +42 -7
- package/dist/usage.js +65 -6
- package/dist/usage.js.map +1 -1
- package/dist/workspace.d.ts +45 -3
- package/dist/workspace.js +143 -13
- package/dist/workspace.js.map +1 -1
- package/package.json +3 -3
package/dist/guard.d.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import type { OutOfScope } from './config.js';
|
|
12
12
|
import type { BriefRow } from './types.js';
|
|
13
13
|
export type Verdict = 'allow' | 'warn' | 'ask' | 'deny';
|
|
14
|
-
export type Reason = 'outside-repository' | 'no-brief' | 'brief-file' | 'protected' | 'ruled' | 'unreadable-protection' | 'in-scope' | 'out-of-scope';
|
|
14
|
+
export type Reason = 'outside-repository' | 'no-brief' | 'brief-file' | 'protected' | 'ruled' | 'unreadable-protection' | 'in-scope' | 'out-of-scope' | 'held';
|
|
15
15
|
export interface Decision {
|
|
16
16
|
/** Repository-relative, or the path as given when it lies outside. */
|
|
17
17
|
readonly path: string;
|
|
@@ -56,3 +56,22 @@ export declare function rooted(pattern: string): 'whole' | 'part' | null;
|
|
|
56
56
|
export declare function rulingFor(rulings: readonly VerifiedRuling[], path: string): VerifiedRuling | undefined;
|
|
57
57
|
/** Decides one path. Pure: the caller resolved the path, the brief and the rulings. */
|
|
58
58
|
export declare function decide(input: GuardInput): Decision;
|
|
59
|
+
/**
|
|
60
|
+
* A path decided as it reads and as the system writes it, where a link has
|
|
61
|
+
* the two name two files (ADR-0005): `read` is the decision of the first,
|
|
62
|
+
* and `written` the input of the second. Which of the two Claude Code's
|
|
63
|
+
* tools write is not on its pages, so the stricter stands and no link makes
|
|
64
|
+
* a protected file read as another; where neither is stricter, the path is
|
|
65
|
+
* decided as it reads, as it was. The written decision is said with the
|
|
66
|
+
* path as it was given, which reads as another file.
|
|
67
|
+
*/
|
|
68
|
+
export declare function decideWritten(read: Decision, written: GuardInput): Decision;
|
|
69
|
+
/**
|
|
70
|
+
* A path whose write the hook holds whatever the path is, but where it lies
|
|
71
|
+
* outside every work tree of the repository: what names the brief names
|
|
72
|
+
* none a round can work under, one spec-brief does not list or has
|
|
73
|
+
* archived (ADR-0004). Refused, in the words the hook holds the write with
|
|
74
|
+
* and its next step, so that the server's `check_path` answers the path as
|
|
75
|
+
* the hook decides a write of it (ADR-0008).
|
|
76
|
+
*/
|
|
77
|
+
export declare function heldBy(path: string, problem: string): Decision;
|
package/dist/guard.js
CHANGED
|
@@ -147,4 +147,35 @@ export function decide(input) {
|
|
|
147
147
|
: `if the round needs it, say so in ${brief.file} and add it to affectedFiles; the archive reports every file outside the scope`,
|
|
148
148
|
};
|
|
149
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* The verdicts that hold a write, from the least to the most. `allow` holds
|
|
152
|
+
* nothing, and is read below each: `indexOf` has it at -1.
|
|
153
|
+
*/
|
|
154
|
+
const STRICTNESS = ['warn', 'ask', 'deny'];
|
|
155
|
+
/**
|
|
156
|
+
* A path decided as it reads and as the system writes it, where a link has
|
|
157
|
+
* the two name two files (ADR-0005): `read` is the decision of the first,
|
|
158
|
+
* and `written` the input of the second. Which of the two Claude Code's
|
|
159
|
+
* tools write is not on its pages, so the stricter stands and no link makes
|
|
160
|
+
* a protected file read as another; where neither is stricter, the path is
|
|
161
|
+
* decided as it reads, as it was. The written decision is said with the
|
|
162
|
+
* path as it was given, which reads as another file.
|
|
163
|
+
*/
|
|
164
|
+
export function decideWritten(read, written) {
|
|
165
|
+
const other = decide(written);
|
|
166
|
+
if (STRICTNESS.indexOf(other.verdict) <= STRICTNESS.indexOf(read.verdict))
|
|
167
|
+
return read;
|
|
168
|
+
return { ...other, message: `${written.given} is written to ${other.path} through a link: ${other.message}` };
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* A path whose write the hook holds whatever the path is, but where it lies
|
|
172
|
+
* outside every work tree of the repository: what names the brief names
|
|
173
|
+
* none a round can work under, one spec-brief does not list or has
|
|
174
|
+
* archived (ADR-0004). Refused, in the words the hook holds the write with
|
|
175
|
+
* and its next step, so that the server's `check_path` answers the path as
|
|
176
|
+
* the hook decides a write of it (ADR-0008).
|
|
177
|
+
*/
|
|
178
|
+
export function heldBy(path, problem) {
|
|
179
|
+
return { path, verdict: 'deny', reason: 'held', because: [], message: problem, hint: 'fix the brief or the branch before writing' };
|
|
180
|
+
}
|
|
150
181
|
//# sourceMappingURL=guard.js.map
|
package/dist/guard.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"guard.js","sourceRoot":"","sources":["../src/guard.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * May this round write this file?\n *\n * The answer is read from the brief the round runs under: `protectedFiles`\n * are refused unless a person has ruled otherwise, `affectedFiles` are the\n * round's to write, and anything else is outside the scope the person\n * approved. A guard is a guardrail, not a boundary - an agent that writes\n * through a shell passes it - so the audit and spec-brief's archive are the\n * gates, and this is what keeps an honest agent from needing them (ADR-0005).\n */\n\nimport { globAlternatives, parseGlob, type Glob, type GlobAlternative } from './vendor/spec-core/pattern/index.js';\nimport type { OutOfScope } from './config.js';\nimport type { BriefRow } from './types.js';\n\nexport type Verdict = 'allow' | 'warn' | 'ask' | 'deny';\n\nexport type Reason =\n | 'outside-repository'\n | 'no-brief'\n | 'brief-file'\n | 'protected'\n | 'ruled'\n | 'unreadable-protection'\n | 'in-scope'\n | 'out-of-scope';\n\nexport interface Decision {\n /** Repository-relative, or the path as given when it lies outside. */\n readonly path: string;\n readonly verdict: Verdict;\n readonly reason: Reason;\n /** The patterns or ruling that decided it. */\n readonly because: readonly string[];\n readonly message: string;\n readonly hint: string;\n}\n\n/** A ruling whose signature has been verified: a person allowed these paths. */\nexport interface VerifiedRuling {\n readonly id: string;\n readonly paths: readonly string[];\n readonly signer: string;\n}\n\nexport interface GuardInput {\n /** Repository-relative POSIX path, or `null` for a path outside the repository. */\n readonly path: string | null;\n /** The path as the caller gave it, for messages. */\n readonly given: string;\n readonly brief: BriefRow | null;\n /** Why there is no brief, when there is none. */\n readonly noBrief?: string | undefined;\n readonly rulings: readonly VerifiedRuling[];\n readonly outOfScope: OutOfScope;\n}\n\nfunction compile(pattern: string): Glob | string {\n const parsed = parseGlob(pattern, { dialect: 'path', caseSensitive: true, literal: 'either' });\n return parsed.ok ? parsed.glob : parsed.error;\n}\n\n/**\n * Why the guard cannot read a pattern, in spec-core's words; `null` when it\n * can. The guard matches no path by a pattern it cannot read.\n */\nexport function whyUnreadable(pattern: string): string | null {\n const glob = compile(pattern);\n return typeof glob === 'string' ? glob : null;\n}\n\n/**\n * Whether a leading `/` roots a pattern the guard can read at the\n * filesystem's root: `whole` when every alternative is rooted, `part` when\n * some brace alternative is, `null` when none is or the pattern cannot be\n * read. The guard decides repository-relative paths, so a rooted\n * alternative matches none of them.\n */\nexport function rooted(pattern: string): 'whole' | 'part' | null {\n if (typeof compile(pattern) === 'string') return null;\n // spec-core refuses here only what its compile refuses, so a pattern the\n // guard reads always has its alternatives read.\n const { alternatives } = globAlternatives(pattern) as { readonly alternatives: readonly GlobAlternative[] };\n const roots = alternatives.filter((alternative) => alternative.rooted).length;\n if (roots === 0) return null;\n return roots === alternatives.length ? 'whole' : 'part';\n}\n\nfunction matching(patterns: readonly string[], path: string): { matched: string[]; unreadable: string[] } {\n const matched: string[] = [];\n const unreadable: string[] = [];\n for (const pattern of patterns) {\n const glob = compile(pattern);\n if (typeof glob === 'string') unreadable.push(`${pattern} (${glob})`);\n else if (glob.match(path)) matched.push(pattern);\n }\n return { matched, unreadable };\n}\n\n/** The first verified ruling whose paths cover `path`, read as the guard reads a scope. */\nexport function rulingFor(rulings: readonly VerifiedRuling[], path: string): VerifiedRuling | undefined {\n return rulings.find((candidate) => matching(candidate.paths, path).matched.length > 0);\n}\n\nconst ESCALATE =\n 'if the round cannot be done without it, stop and ask for a ruling: spec-harness escalate --path <file> --reason <why>, or the request_escalation tool';\n\n/** Decides one path. Pure: the caller resolved the path, the brief and the rulings. */\nexport function decide(input: GuardInput): Decision {\n const { path, given, brief } = input;\n if (path === null) {\n return {\n path: given,\n verdict: 'allow',\n reason: 'outside-repository',\n because: [],\n message: `${given} is outside the repository; no brief governs it`,\n hint: 'nothing to do',\n };\n }\n if (brief === null) {\n return {\n path,\n verdict: 'allow',\n reason: 'no-brief',\n because: [],\n message: `no brief governs ${path}: ${input.noBrief ?? 'no active brief'}`,\n hint: 'name the brief this work belongs to, so its scope can be checked',\n };\n }\n if (path === brief.file) {\n return {\n path,\n verdict: 'allow',\n reason: 'brief-file',\n because: [],\n message: `${path} is brief ${brief.id} itself`,\n hint: 'tick or disposition its boxes as the work lands',\n };\n }\n\n const protectedHits = matching(brief.protectedFiles, path);\n if (protectedHits.unreadable.length > 0) {\n // A protection that cannot be read cannot be honoured, and cannot be\n // assumed not to apply: refuse until the brief is fixed.\n return {\n path,\n verdict: 'deny',\n reason: 'unreadable-protection',\n because: protectedHits.unreadable,\n message: `brief ${brief.id} protects files with a pattern that cannot be read: ${protectedHits.unreadable.join('; ')}`,\n hint: `fix protectedFiles in ${brief.file} (spec-brief lint names the problem)`,\n };\n }\n if (protectedHits.matched.length > 0) {\n const ruling = rulingFor(input.rulings, path);\n if (ruling !== undefined) {\n return {\n path,\n verdict: 'allow',\n reason: 'ruled',\n because: [ruling.id],\n message: `${path} is protected by brief ${brief.id}, and ruling ${ruling.id}, signed by ${ruling.signer}, allows it`,\n hint: 'keep the change to what the ruling allows',\n };\n }\n return {\n path,\n verdict: 'deny',\n reason: 'protected',\n because: protectedHits.matched,\n message: `brief ${brief.id} does not empower this round to change ${path} (protectedFiles: ${protectedHits.matched.join(', ')})`,\n hint: ESCALATE,\n };\n }\n\n const affectedHits = matching(brief.affectedFiles, path);\n if (affectedHits.matched.length > 0) {\n return {\n path,\n verdict: 'allow',\n reason: 'in-scope',\n because: affectedHits.matched,\n message: `${path} is in brief ${brief.id}'s scope (${affectedHits.matched.join(', ')})`,\n hint: 'nothing to do',\n };\n }\n\n const verdict: Verdict = input.outOfScope;\n const scope = brief.affectedFiles.length === 0 ? 'declares no affectedFiles' : `covers ${brief.affectedFiles.join(', ')}`;\n return {\n path,\n verdict,\n reason: 'out-of-scope',\n because: affectedHits.unreadable,\n message: `${path} is outside brief ${brief.id}'s scope, which ${scope}`,\n hint:\n verdict === 'deny'\n ? `add it to affectedFiles in ${brief.file} if the round needs it, which is a change the person who approved the brief must see; or ${ESCALATE}`\n : `if the round needs it, say so in ${brief.file} and add it to affectedFiles; the archive reports every file outside the scope`,\n };\n}\n"]}
|
|
1
|
+
{"version":3,"file":"guard.js","sourceRoot":"","sources":["../src/guard.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * May this round write this file?\n *\n * The answer is read from the brief the round runs under: `protectedFiles`\n * are refused unless a person has ruled otherwise, `affectedFiles` are the\n * round's to write, and anything else is outside the scope the person\n * approved. A guard is a guardrail, not a boundary - an agent that writes\n * through a shell passes it - so the audit and spec-brief's archive are the\n * gates, and this is what keeps an honest agent from needing them (ADR-0005).\n */\n\nimport { globAlternatives, parseGlob, type Glob, type GlobAlternative } from './vendor/spec-core/pattern/index.js';\nimport type { OutOfScope } from './config.js';\nimport type { BriefRow } from './types.js';\n\nexport type Verdict = 'allow' | 'warn' | 'ask' | 'deny';\n\nexport type Reason =\n | 'outside-repository'\n | 'no-brief'\n | 'brief-file'\n | 'protected'\n | 'ruled'\n | 'unreadable-protection'\n | 'in-scope'\n | 'out-of-scope'\n | 'held';\n\nexport interface Decision {\n /** Repository-relative, or the path as given when it lies outside. */\n readonly path: string;\n readonly verdict: Verdict;\n readonly reason: Reason;\n /** The patterns or ruling that decided it. */\n readonly because: readonly string[];\n readonly message: string;\n readonly hint: string;\n}\n\n/** A ruling whose signature has been verified: a person allowed these paths. */\nexport interface VerifiedRuling {\n readonly id: string;\n readonly paths: readonly string[];\n readonly signer: string;\n}\n\nexport interface GuardInput {\n /** Repository-relative POSIX path, or `null` for a path outside the repository. */\n readonly path: string | null;\n /** The path as the caller gave it, for messages. */\n readonly given: string;\n readonly brief: BriefRow | null;\n /** Why there is no brief, when there is none. */\n readonly noBrief?: string | undefined;\n readonly rulings: readonly VerifiedRuling[];\n readonly outOfScope: OutOfScope;\n}\n\nfunction compile(pattern: string): Glob | string {\n const parsed = parseGlob(pattern, { dialect: 'path', caseSensitive: true, literal: 'either' });\n return parsed.ok ? parsed.glob : parsed.error;\n}\n\n/**\n * Why the guard cannot read a pattern, in spec-core's words; `null` when it\n * can. The guard matches no path by a pattern it cannot read.\n */\nexport function whyUnreadable(pattern: string): string | null {\n const glob = compile(pattern);\n return typeof glob === 'string' ? glob : null;\n}\n\n/**\n * Whether a leading `/` roots a pattern the guard can read at the\n * filesystem's root: `whole` when every alternative is rooted, `part` when\n * some brace alternative is, `null` when none is or the pattern cannot be\n * read. The guard decides repository-relative paths, so a rooted\n * alternative matches none of them.\n */\nexport function rooted(pattern: string): 'whole' | 'part' | null {\n if (typeof compile(pattern) === 'string') return null;\n // spec-core refuses here only what its compile refuses, so a pattern the\n // guard reads always has its alternatives read.\n const { alternatives } = globAlternatives(pattern) as { readonly alternatives: readonly GlobAlternative[] };\n const roots = alternatives.filter((alternative) => alternative.rooted).length;\n if (roots === 0) return null;\n return roots === alternatives.length ? 'whole' : 'part';\n}\n\nfunction matching(patterns: readonly string[], path: string): { matched: string[]; unreadable: string[] } {\n const matched: string[] = [];\n const unreadable: string[] = [];\n for (const pattern of patterns) {\n const glob = compile(pattern);\n if (typeof glob === 'string') unreadable.push(`${pattern} (${glob})`);\n else if (glob.match(path)) matched.push(pattern);\n }\n return { matched, unreadable };\n}\n\n/** The first verified ruling whose paths cover `path`, read as the guard reads a scope. */\nexport function rulingFor(rulings: readonly VerifiedRuling[], path: string): VerifiedRuling | undefined {\n return rulings.find((candidate) => matching(candidate.paths, path).matched.length > 0);\n}\n\nconst ESCALATE =\n 'if the round cannot be done without it, stop and ask for a ruling: spec-harness escalate --path <file> --reason <why>, or the request_escalation tool';\n\n/** Decides one path. Pure: the caller resolved the path, the brief and the rulings. */\nexport function decide(input: GuardInput): Decision {\n const { path, given, brief } = input;\n if (path === null) {\n return {\n path: given,\n verdict: 'allow',\n reason: 'outside-repository',\n because: [],\n message: `${given} is outside the repository; no brief governs it`,\n hint: 'nothing to do',\n };\n }\n if (brief === null) {\n return {\n path,\n verdict: 'allow',\n reason: 'no-brief',\n because: [],\n message: `no brief governs ${path}: ${input.noBrief ?? 'no active brief'}`,\n hint: 'name the brief this work belongs to, so its scope can be checked',\n };\n }\n if (path === brief.file) {\n return {\n path,\n verdict: 'allow',\n reason: 'brief-file',\n because: [],\n message: `${path} is brief ${brief.id} itself`,\n hint: 'tick or disposition its boxes as the work lands',\n };\n }\n\n const protectedHits = matching(brief.protectedFiles, path);\n if (protectedHits.unreadable.length > 0) {\n // A protection that cannot be read cannot be honoured, and cannot be\n // assumed not to apply: refuse until the brief is fixed.\n return {\n path,\n verdict: 'deny',\n reason: 'unreadable-protection',\n because: protectedHits.unreadable,\n message: `brief ${brief.id} protects files with a pattern that cannot be read: ${protectedHits.unreadable.join('; ')}`,\n hint: `fix protectedFiles in ${brief.file} (spec-brief lint names the problem)`,\n };\n }\n if (protectedHits.matched.length > 0) {\n const ruling = rulingFor(input.rulings, path);\n if (ruling !== undefined) {\n return {\n path,\n verdict: 'allow',\n reason: 'ruled',\n because: [ruling.id],\n message: `${path} is protected by brief ${brief.id}, and ruling ${ruling.id}, signed by ${ruling.signer}, allows it`,\n hint: 'keep the change to what the ruling allows',\n };\n }\n return {\n path,\n verdict: 'deny',\n reason: 'protected',\n because: protectedHits.matched,\n message: `brief ${brief.id} does not empower this round to change ${path} (protectedFiles: ${protectedHits.matched.join(', ')})`,\n hint: ESCALATE,\n };\n }\n\n const affectedHits = matching(brief.affectedFiles, path);\n if (affectedHits.matched.length > 0) {\n return {\n path,\n verdict: 'allow',\n reason: 'in-scope',\n because: affectedHits.matched,\n message: `${path} is in brief ${brief.id}'s scope (${affectedHits.matched.join(', ')})`,\n hint: 'nothing to do',\n };\n }\n\n const verdict: Verdict = input.outOfScope;\n const scope = brief.affectedFiles.length === 0 ? 'declares no affectedFiles' : `covers ${brief.affectedFiles.join(', ')}`;\n return {\n path,\n verdict,\n reason: 'out-of-scope',\n because: affectedHits.unreadable,\n message: `${path} is outside brief ${brief.id}'s scope, which ${scope}`,\n hint:\n verdict === 'deny'\n ? `add it to affectedFiles in ${brief.file} if the round needs it, which is a change the person who approved the brief must see; or ${ESCALATE}`\n : `if the round needs it, say so in ${brief.file} and add it to affectedFiles; the archive reports every file outside the scope`,\n };\n}\n\n/**\n * The verdicts that hold a write, from the least to the most. `allow` holds\n * nothing, and is read below each: `indexOf` has it at -1.\n */\nconst STRICTNESS: readonly Verdict[] = ['warn', 'ask', 'deny'];\n\n/**\n * A path decided as it reads and as the system writes it, where a link has\n * the two name two files (ADR-0005): `read` is the decision of the first,\n * and `written` the input of the second. Which of the two Claude Code's\n * tools write is not on its pages, so the stricter stands and no link makes\n * a protected file read as another; where neither is stricter, the path is\n * decided as it reads, as it was. The written decision is said with the\n * path as it was given, which reads as another file.\n */\nexport function decideWritten(read: Decision, written: GuardInput): Decision {\n const other = decide(written);\n if (STRICTNESS.indexOf(other.verdict) <= STRICTNESS.indexOf(read.verdict)) return read;\n return { ...other, message: `${written.given} is written to ${other.path} through a link: ${other.message}` };\n}\n\n/**\n * A path whose write the hook holds whatever the path is, but where it lies\n * outside every work tree of the repository: what names the brief names\n * none a round can work under, one spec-brief does not list or has\n * archived (ADR-0004). Refused, in the words the hook holds the write with\n * and its next step, so that the server's `check_path` answers the path as\n * the hook decides a write of it (ADR-0008).\n */\nexport function heldBy(path: string, problem: string): Decision {\n return { path, verdict: 'deny', reason: 'held', because: [], message: problem, hint: 'fix the brief or the branch before writing' };\n}\n"]}
|
package/dist/hooks.d.ts
CHANGED
|
@@ -50,6 +50,26 @@ export interface HookRequest {
|
|
|
50
50
|
* write of the session will need (ADR-0005).
|
|
51
51
|
*/
|
|
52
52
|
export declare const SESSION_START = "SessionStart";
|
|
53
|
+
/**
|
|
54
|
+
* The event Claude Code runs as its main agent finishes a turn, under which
|
|
55
|
+
* the hook reports what the work tree holds, where the configuration asks
|
|
56
|
+
* for it (ADR-0005). No tool is named.
|
|
57
|
+
*/
|
|
58
|
+
export declare const TURN_END = "Stop";
|
|
59
|
+
/**
|
|
60
|
+
* How long a run of Claude Code's hook is given, every process it starts
|
|
61
|
+
* together (ADR-0002). Claude Code's hooks reference (Timeouts and Common
|
|
62
|
+
* fields, read 2026-10-24) has a command hook cancelled at its entry's
|
|
63
|
+
* `timeout`, 600 seconds where the entry gives none, and a PreToolUse hook
|
|
64
|
+
* it cancels blocks no tool call; init and the plugin give the entry 60.
|
|
65
|
+
* Each process asked a question keeps its 20 seconds (`ANSWER_MS`,
|
|
66
|
+
* git.ts), but several that answer slowly, none of them at that, passed the
|
|
67
|
+
* minute, and the write went ahead. So a run has this, from when the hook
|
|
68
|
+
* begins its work, and a process is given no more of it than is left;
|
|
69
|
+
* Node's start and the reading of the question, before it, are inside the
|
|
70
|
+
* margin to the minute.
|
|
71
|
+
*/
|
|
72
|
+
export declare const HOOK_BUDGET_MS = 45000;
|
|
53
73
|
/** Reads a Claude Code hook's stdin, or says why it cannot. */
|
|
54
74
|
export declare function parseClaudeHook(raw: string): HookRequest | {
|
|
55
75
|
readonly error: string;
|
|
@@ -62,11 +82,44 @@ export declare function parseClaudeHook(raw: string): HookRequest | {
|
|
|
62
82
|
* starting.
|
|
63
83
|
*/
|
|
64
84
|
export declare function sessionResponse(problem: string | null): HookResponse;
|
|
85
|
+
/**
|
|
86
|
+
* The answer as a turn ends: the report, in one `systemMessage`, or
|
|
87
|
+
* nothing, where there is nothing to report. Always exit 0, and never a
|
|
88
|
+
* field that has Claude go on: `decision`, `additionalContext` and exit 2
|
|
89
|
+
* each continue the turn under Stop, and `continue: false` would stop one
|
|
90
|
+
* another hook continues. Claude Code shows a `systemMessage` to the person
|
|
91
|
+
* from an entry it waits on, and hands it to Claude on the next turn from
|
|
92
|
+
* one it runs in the background, as `init` writes it (ADR-0005, ADR-0012).
|
|
93
|
+
*/
|
|
94
|
+
export declare function turnResponse(report: string | null): HookResponse;
|
|
65
95
|
export interface HookResponse {
|
|
66
96
|
/** What to print on stdout: JSON, or nothing. */
|
|
67
97
|
readonly stdout: string;
|
|
68
98
|
readonly exitCode: number;
|
|
69
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* The answer before the one write a configuration that does not load lets
|
|
102
|
+
* through, a write to the configuration file itself: no decision, so that
|
|
103
|
+
* the person's own permission prompt stands as for any write the guard
|
|
104
|
+
* passes, and the line that says why every other write is held, for the
|
|
105
|
+
* person as `systemMessage` and for Claude as `additionalContext`, which
|
|
106
|
+
* Claude Code's hooks reference has a PreToolUse hook's answer carry
|
|
107
|
+
* (ADR-0005). Exit 0.
|
|
108
|
+
*/
|
|
109
|
+
export declare function mendResponse(said: string): HookResponse;
|
|
110
|
+
/** The answer that says nothing and decides nothing: the write goes on as the person's own permissions have it. */
|
|
111
|
+
export declare const NONE: HookResponse;
|
|
112
|
+
/**
|
|
113
|
+
* The answer where the hook checks nothing and says so, a question it cannot
|
|
114
|
+
* read or a line of its own that turns the guard off: one line, as
|
|
115
|
+
* `systemMessage`, which Claude Code's hooks reference has shown to the
|
|
116
|
+
* person on every event the hook answers, and exit 0. It decides nothing,
|
|
117
|
+
* so the call goes on as it would without the hook. It was exit 1, which
|
|
118
|
+
* the reference counts as a hook that failed: under `onFailure: "block"`,
|
|
119
|
+
* which holds the call of a hook that cannot start, it held every write of
|
|
120
|
+
* a session for a line only a write can mend (ADR-0005).
|
|
121
|
+
*/
|
|
122
|
+
export declare function warningResponse(said: string): HookResponse;
|
|
70
123
|
/** What a warning after a write says: a decision's message and its next step. */
|
|
71
124
|
export type Warning = Pick<Decision, 'message' | 'hint'>;
|
|
72
125
|
/** The answer after a write: the warnings, as additional context for the model, or nothing. */
|
|
@@ -102,6 +155,12 @@ export interface CallRecord {
|
|
|
102
155
|
readonly started: string;
|
|
103
156
|
readonly env: readonly (string | null)[];
|
|
104
157
|
}
|
|
158
|
+
/**
|
|
159
|
+
* The session a question is in, which spec-brief's answers are kept for,
|
|
160
|
+
* or `null` where it names none in the form Claude Code's ids have: such a
|
|
161
|
+
* question has every answer asked for afresh (ADR-0002).
|
|
162
|
+
*/
|
|
163
|
+
export declare function sessionOf(request: HookRequest): string | null;
|
|
105
164
|
/**
|
|
106
165
|
* The call a question is about, or `null` where it does not say which
|
|
107
166
|
* session and call it is, or says it in a form no file is named by.
|
package/dist/hooks.js
CHANGED
|
@@ -42,6 +42,28 @@ function isObject(value) {
|
|
|
42
42
|
* write of the session will need (ADR-0005).
|
|
43
43
|
*/
|
|
44
44
|
export const SESSION_START = 'SessionStart';
|
|
45
|
+
/**
|
|
46
|
+
* The event Claude Code runs as its main agent finishes a turn, under which
|
|
47
|
+
* the hook reports what the work tree holds, where the configuration asks
|
|
48
|
+
* for it (ADR-0005). No tool is named.
|
|
49
|
+
*/
|
|
50
|
+
export const TURN_END = 'Stop';
|
|
51
|
+
/**
|
|
52
|
+
* How long a run of Claude Code's hook is given, every process it starts
|
|
53
|
+
* together (ADR-0002). Claude Code's hooks reference (Timeouts and Common
|
|
54
|
+
* fields, read 2026-10-24) has a command hook cancelled at its entry's
|
|
55
|
+
* `timeout`, 600 seconds where the entry gives none, and a PreToolUse hook
|
|
56
|
+
* it cancels blocks no tool call; init and the plugin give the entry 60.
|
|
57
|
+
* Each process asked a question keeps its 20 seconds (`ANSWER_MS`,
|
|
58
|
+
* git.ts), but several that answer slowly, none of them at that, passed the
|
|
59
|
+
* minute, and the write went ahead. So a run has this, from when the hook
|
|
60
|
+
* begins its work, and a process is given no more of it than is left;
|
|
61
|
+
* Node's start and the reading of the question, before it, are inside the
|
|
62
|
+
* margin to the minute.
|
|
63
|
+
*/
|
|
64
|
+
export const HOOK_BUDGET_MS = 45_000;
|
|
65
|
+
/** The events that name no tool, which the hook answers of the session and not of a write. */
|
|
66
|
+
const NO_TOOL = [SESSION_START, TURN_END];
|
|
45
67
|
/** Reads a Claude Code hook's stdin, or says why it cannot. */
|
|
46
68
|
export function parseClaudeHook(raw) {
|
|
47
69
|
let value;
|
|
@@ -55,9 +77,9 @@ export function parseClaudeHook(raw) {
|
|
|
55
77
|
return { error: 'the hook input is not a JSON object' };
|
|
56
78
|
const event = value['hook_event_name'];
|
|
57
79
|
const tool = value['tool_name'];
|
|
58
|
-
if (typeof event !== 'string' || (typeof tool !== 'string' && event
|
|
80
|
+
if (typeof event !== 'string' || (typeof tool !== 'string' && !NO_TOOL.includes(event)))
|
|
59
81
|
return { error: 'the hook input names no event or tool' };
|
|
60
|
-
// A session's start
|
|
82
|
+
// A session's start and a turn's end name no tool, and are read as naming none.
|
|
61
83
|
const named = typeof tool === 'string' ? tool : '';
|
|
62
84
|
const input = isObject(value['tool_input']) ? value['tool_input'] : {};
|
|
63
85
|
const paths = [];
|
|
@@ -87,7 +109,48 @@ export function sessionResponse(problem) {
|
|
|
87
109
|
const said = `spec-harness: ${problem}`;
|
|
88
110
|
return { stdout: `${JSON.stringify({ systemMessage: said, hookSpecificOutput: { hookEventName: SESSION_START, additionalContext: said } })}\n`, exitCode: 0 };
|
|
89
111
|
}
|
|
90
|
-
|
|
112
|
+
/**
|
|
113
|
+
* The answer as a turn ends: the report, in one `systemMessage`, or
|
|
114
|
+
* nothing, where there is nothing to report. Always exit 0, and never a
|
|
115
|
+
* field that has Claude go on: `decision`, `additionalContext` and exit 2
|
|
116
|
+
* each continue the turn under Stop, and `continue: false` would stop one
|
|
117
|
+
* another hook continues. Claude Code shows a `systemMessage` to the person
|
|
118
|
+
* from an entry it waits on, and hands it to Claude on the next turn from
|
|
119
|
+
* one it runs in the background, as `init` writes it (ADR-0005, ADR-0012).
|
|
120
|
+
*/
|
|
121
|
+
export function turnResponse(report) {
|
|
122
|
+
if (report === null)
|
|
123
|
+
return NONE;
|
|
124
|
+
return { stdout: `${JSON.stringify({ systemMessage: report })}\n`, exitCode: 0 };
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The answer before the one write a configuration that does not load lets
|
|
128
|
+
* through, a write to the configuration file itself: no decision, so that
|
|
129
|
+
* the person's own permission prompt stands as for any write the guard
|
|
130
|
+
* passes, and the line that says why every other write is held, for the
|
|
131
|
+
* person as `systemMessage` and for Claude as `additionalContext`, which
|
|
132
|
+
* Claude Code's hooks reference has a PreToolUse hook's answer carry
|
|
133
|
+
* (ADR-0005). Exit 0.
|
|
134
|
+
*/
|
|
135
|
+
export function mendResponse(said) {
|
|
136
|
+
const line = `spec-harness: ${said}`;
|
|
137
|
+
return { stdout: `${JSON.stringify({ systemMessage: line, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: line } })}\n`, exitCode: 0 };
|
|
138
|
+
}
|
|
139
|
+
/** The answer that says nothing and decides nothing: the write goes on as the person's own permissions have it. */
|
|
140
|
+
export const NONE = { stdout: '', exitCode: 0 };
|
|
141
|
+
/**
|
|
142
|
+
* The answer where the hook checks nothing and says so, a question it cannot
|
|
143
|
+
* read or a line of its own that turns the guard off: one line, as
|
|
144
|
+
* `systemMessage`, which Claude Code's hooks reference has shown to the
|
|
145
|
+
* person on every event the hook answers, and exit 0. It decides nothing,
|
|
146
|
+
* so the call goes on as it would without the hook. It was exit 1, which
|
|
147
|
+
* the reference counts as a hook that failed: under `onFailure: "block"`,
|
|
148
|
+
* which holds the call of a hook that cannot start, it held every write of
|
|
149
|
+
* a session for a line only a write can mend (ADR-0005).
|
|
150
|
+
*/
|
|
151
|
+
export function warningResponse(said) {
|
|
152
|
+
return { stdout: `${JSON.stringify({ systemMessage: `spec-harness: ${said}` })}\n`, exitCode: 0 };
|
|
153
|
+
}
|
|
91
154
|
function reasonOf(decisions) {
|
|
92
155
|
return decisions.map((decision) => `spec-harness: ${decision.message}. Next: ${decision.hint}.`).join('\n');
|
|
93
156
|
}
|
|
@@ -148,6 +211,16 @@ export const CALL_VARIABLES = [
|
|
|
148
211
|
];
|
|
149
212
|
/** 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
213
|
const ID = /^[\w-]{1,256}$/;
|
|
214
|
+
/**
|
|
215
|
+
* The session a question is in, which spec-brief's answers are kept for,
|
|
216
|
+
* or `null` where it names none in the form Claude Code's ids have: such a
|
|
217
|
+
* question has every answer asked for afresh (ADR-0002).
|
|
218
|
+
*/
|
|
219
|
+
export function sessionOf(request) {
|
|
220
|
+
// The pattern reads null as "null", and what is answered is the session,
|
|
221
|
+
// null: the test of null is the compiler's, and its mutant equivalent.
|
|
222
|
+
return request.session !== null && ID.test(request.session) ? request.session : null;
|
|
223
|
+
}
|
|
151
224
|
/**
|
|
152
225
|
* The call a question is about, or `null` where it does not say which
|
|
153
226
|
* session and call it is, or says it in a form no file is named by.
|
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 /** 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"]}
|
|
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 /** 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/**\n * The event Claude Code runs as its main agent finishes a turn, under which\n * the hook reports what the work tree holds, where the configuration asks\n * for it (ADR-0005). No tool is named.\n */\nexport const TURN_END = 'Stop';\n\n/**\n * How long a run of Claude Code's hook is given, every process it starts\n * together (ADR-0002). Claude Code's hooks reference (Timeouts and Common\n * fields, read 2026-10-24) has a command hook cancelled at its entry's\n * `timeout`, 600 seconds where the entry gives none, and a PreToolUse hook\n * it cancels blocks no tool call; init and the plugin give the entry 60.\n * Each process asked a question keeps its 20 seconds (`ANSWER_MS`,\n * git.ts), but several that answer slowly, none of them at that, passed the\n * minute, and the write went ahead. So a run has this, from when the hook\n * begins its work, and a process is given no more of it than is left;\n * Node's start and the reading of the question, before it, are inside the\n * margin to the minute.\n */\nexport const HOOK_BUDGET_MS = 45_000;\n\n/** The events that name no tool, which the hook answers of the session and not of a write. */\nconst NO_TOOL: readonly string[] = [SESSION_START, TURN_END];\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' && !NO_TOOL.includes(event))) return { error: 'the hook input names no event or tool' };\n // A session's start and a turn's end name no tool, and are 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\n/**\n * The answer as a turn ends: the report, in one `systemMessage`, or\n * nothing, where there is nothing to report. Always exit 0, and never a\n * field that has Claude go on: `decision`, `additionalContext` and exit 2\n * each continue the turn under Stop, and `continue: false` would stop one\n * another hook continues. Claude Code shows a `systemMessage` to the person\n * from an entry it waits on, and hands it to Claude on the next turn from\n * one it runs in the background, as `init` writes it (ADR-0005, ADR-0012).\n */\nexport function turnResponse(report: string | null): HookResponse {\n if (report === null) return NONE;\n return { stdout: `${JSON.stringify({ systemMessage: report })}\\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\n/**\n * The answer before the one write a configuration that does not load lets\n * through, a write to the configuration file itself: no decision, so that\n * the person's own permission prompt stands as for any write the guard\n * passes, and the line that says why every other write is held, for the\n * person as `systemMessage` and for Claude as `additionalContext`, which\n * Claude Code's hooks reference has a PreToolUse hook's answer carry\n * (ADR-0005). Exit 0.\n */\nexport function mendResponse(said: string): HookResponse {\n const line = `spec-harness: ${said}`;\n return { stdout: `${JSON.stringify({ systemMessage: line, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: line } })}\\n`, exitCode: 0 };\n}\n\n/** The answer that says nothing and decides nothing: the write goes on as the person's own permissions have it. */\nexport const NONE: HookResponse = { stdout: '', exitCode: 0 };\n\n/**\n * The answer where the hook checks nothing and says so, a question it cannot\n * read or a line of its own that turns the guard off: one line, as\n * `systemMessage`, which Claude Code's hooks reference has shown to the\n * person on every event the hook answers, and exit 0. It decides nothing,\n * so the call goes on as it would without the hook. It was exit 1, which\n * the reference counts as a hook that failed: under `onFailure: \"block\"`,\n * which holds the call of a hook that cannot start, it held every write of\n * a session for a line only a write can mend (ADR-0005).\n */\nexport function warningResponse(said: string): HookResponse {\n return { stdout: `${JSON.stringify({ systemMessage: `spec-harness: ${said}` })}\\n`, exitCode: 0 };\n}\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 session a question is in, which spec-brief's answers are kept for,\n * or `null` where it names none in the form Claude Code's ids have: such a\n * question has every answer asked for afresh (ADR-0002).\n */\nexport function sessionOf(request: HookRequest): string | null {\n // The pattern reads null as \"null\", and what is answered is the session,\n // null: the test of null is the compiler's, and its mutant equivalent.\n return request.session !== null && ID.test(request.session) ? request.session : null;\n}\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
|
@@ -120,17 +120,43 @@ export declare function claudeSettings(root: string, env: CliIO['env']): Promise
|
|
|
120
120
|
* the release, so it says that it does not report it, and it is asked only
|
|
121
121
|
* where a line turns on it: where what is read of the lines differs between
|
|
122
122
|
* a release that cannot be told and the newest.
|
|
123
|
+
*
|
|
124
|
+
* `doctor` also looks for the script each line runs in `places`, each
|
|
125
|
+
* directory `${CLAUDE_PROJECT_DIR}` can name as a session starts, and says
|
|
126
|
+
* a line whose script is in none as a guard that is off (reportHooks).
|
|
127
|
+
* `init` gives none: it writes the line that names the project's install,
|
|
128
|
+
* which may come after it.
|
|
123
129
|
*/
|
|
124
|
-
export declare function claudeCodeWiring(root: string, io: CliIO, pattern?: PatternReader, reported?: boolean): Promise<ClaudeCodeWiring>;
|
|
130
|
+
export declare function claudeCodeWiring(root: string, io: CliIO, pattern?: PatternReader, reported?: boolean, places?: readonly string[] | null): Promise<ClaudeCodeWiring>;
|
|
125
131
|
/**
|
|
126
132
|
* The brief a round works on, as doctor reports it: its id, and what is
|
|
127
133
|
* wrong with the name when no round can work under it. Every other command
|
|
128
134
|
* stops there. A diagnosis says it, being what a person runs when the guard
|
|
129
135
|
* holds a write they expected it to pass.
|
|
136
|
+
*
|
|
137
|
+
* `branch` is whether the name no round can work under is the branch's: a
|
|
138
|
+
* brief it names that spec-brief does not know, or has archived. The hook
|
|
139
|
+
* reads the same branch and holds every write under such a name, which is a
|
|
140
|
+
* setup that holds every write, as a hook's line naming such a brief is
|
|
141
|
+
* (ADR-0004). A name the flag gives is the question doctor was asked, and
|
|
142
|
+
* holds no write of anyone's.
|
|
143
|
+
*
|
|
144
|
+
* `holds` is whether the branch names a brief and spec-brief cannot answer
|
|
145
|
+
* for it: not installed, too old, or failing as it is asked. The hook asks
|
|
146
|
+
* spec-brief before every write under the name, and holds each write it
|
|
147
|
+
* cannot ask about, so that too is a setup that holds every write.
|
|
148
|
+
*
|
|
149
|
+
* `archived` is the brief the branch names where spec-brief has archived
|
|
150
|
+
* it, which doctor reads further in git: the branch that archived it waits
|
|
151
|
+
* to merge, and fails nothing (ADR-0004). It reads nothing else, and the
|
|
152
|
+
* problem is the one every write is held with, as before.
|
|
130
153
|
*/
|
|
131
154
|
export declare function diagnoseBrief(workspace: Workspace, options: Pick<Options, 'brief'>, env: CliIO['env']): Promise<{
|
|
132
155
|
readonly id: string | null;
|
|
133
156
|
readonly problem: string | null;
|
|
157
|
+
readonly branch: boolean;
|
|
158
|
+
readonly holds: boolean;
|
|
159
|
+
readonly archived: BriefRow | null;
|
|
134
160
|
}>;
|
|
135
161
|
export interface AuditResult {
|
|
136
162
|
readonly brief: BriefRow;
|