@renzrollon/interlock 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/.claude-plugin/marketplace.json +19 -0
  2. package/.claude-plugin/plugin.json +58 -0
  3. package/CHANGELOG.md +380 -0
  4. package/LICENSE +21 -0
  5. package/README.md +358 -0
  6. package/agents/ping.md +8 -0
  7. package/agents/worker.md +8 -0
  8. package/bin/interlock +2655 -0
  9. package/bin/interlock-graph +6 -0
  10. package/bin/interlock-run +508 -0
  11. package/bin/interlock-ship-acp +39 -0
  12. package/hooks/_shared.mjs +162 -0
  13. package/hooks/guard-commit.mjs +77 -0
  14. package/hooks/guard-tasks.mjs +107 -0
  15. package/hooks/guard-tests.mjs +63 -0
  16. package/hooks/preflight.mjs +107 -0
  17. package/lib/artifacts.mjs +316 -0
  18. package/lib/autonomy.mjs +149 -0
  19. package/lib/conformance.mjs +132 -0
  20. package/lib/doctor.mjs +1176 -0
  21. package/lib/drift.mjs +409 -0
  22. package/lib/eval-history.mjs +304 -0
  23. package/lib/evals-calibrate.mjs +317 -0
  24. package/lib/evals-capture.mjs +437 -0
  25. package/lib/evals-promote.mjs +257 -0
  26. package/lib/evals-triage.mjs +216 -0
  27. package/lib/findings.mjs +340 -0
  28. package/lib/graph/cli.mjs +266 -0
  29. package/lib/graph/extract/config-json.mjs +74 -0
  30. package/lib/graph/extract/create-extractor.mjs +35 -0
  31. package/lib/graph/extract/memory.mjs +99 -0
  32. package/lib/graph/extract/openspec.mjs +80 -0
  33. package/lib/graph/extract/python.mjs +137 -0
  34. package/lib/graph/extract/regex-fallback.mjs +187 -0
  35. package/lib/graph/extract/shell.mjs +71 -0
  36. package/lib/graph/extract/typescript.mjs +217 -0
  37. package/lib/graph/extract/walk.mjs +272 -0
  38. package/lib/graph/format.mjs +51 -0
  39. package/lib/graph/graph/build.mjs +374 -0
  40. package/lib/graph/graph/query.mjs +249 -0
  41. package/lib/graph/graph/report.mjs +122 -0
  42. package/lib/graph/graph/update.mjs +47 -0
  43. package/lib/graph/paths.mjs +66 -0
  44. package/lib/graph/retrieve/context.mjs +86 -0
  45. package/lib/graph/retrieve/docs-index.mjs +116 -0
  46. package/lib/graph/retrieve/markdown.mjs +246 -0
  47. package/lib/host/acp.mjs +551 -0
  48. package/lib/host/claude-cli.mjs +285 -0
  49. package/lib/host/codex.mjs +231 -0
  50. package/lib/host/model-map.mjs +129 -0
  51. package/lib/host/qwen.mjs +176 -0
  52. package/lib/host/registry.mjs +213 -0
  53. package/lib/host.mjs +393 -0
  54. package/lib/ledger.mjs +444 -0
  55. package/lib/limits.mjs +525 -0
  56. package/lib/locators.mjs +99 -0
  57. package/lib/merge-lanes.mjs +148 -0
  58. package/lib/metrics.mjs +99 -0
  59. package/lib/notify.mjs +229 -0
  60. package/lib/outcomes.mjs +560 -0
  61. package/lib/plan-fingerprint.mjs +570 -0
  62. package/lib/project-slug.mjs +18 -0
  63. package/lib/prompts/commit.mjs +26 -0
  64. package/lib/prompts/dimensions.mjs +70 -0
  65. package/lib/prompts/handoff.mjs +86 -0
  66. package/lib/prompts/implementer.mjs +150 -0
  67. package/lib/prompts/index.mjs +250 -0
  68. package/lib/prompts/planner.mjs +70 -0
  69. package/lib/prompts/remediate.mjs +137 -0
  70. package/lib/prompts/replan.mjs +23 -0
  71. package/lib/prompts/review.mjs +107 -0
  72. package/lib/prompts/schemas.mjs +206 -0
  73. package/lib/prompts/stage.mjs +48 -0
  74. package/lib/prompts/verify.mjs +88 -0
  75. package/lib/ready.mjs +812 -0
  76. package/lib/receipt.mjs +434 -0
  77. package/lib/remediate.mjs +275 -0
  78. package/lib/report-html.mjs +623 -0
  79. package/lib/report.mjs +1124 -0
  80. package/lib/review-core.mjs +713 -0
  81. package/lib/risk.mjs +370 -0
  82. package/lib/run-log.mjs +1004 -0
  83. package/lib/run-paths.mjs +239 -0
  84. package/lib/run.mjs +2921 -0
  85. package/lib/ship-stage.mjs +223 -0
  86. package/lib/spill.mjs +150 -0
  87. package/lib/surface.mjs +195 -0
  88. package/lib/verify.mjs +1074 -0
  89. package/lib/waves.mjs +2661 -0
  90. package/package.json +40 -0
  91. package/shared/CONTEXT-HYGIENE.md +84 -0
  92. package/shared/DECISION-LEDGER.md +141 -0
  93. package/shared/EXPLORE-BRIEF.md +96 -0
  94. package/shared/INVARIANT-SWEEP.md +57 -0
  95. package/shared/SESSION-SHAPES.md +261 -0
  96. package/shared/SKILL-CANDIDATE-BRIEF.md +106 -0
  97. package/shared/TEST-PROFILE.md +204 -0
  98. package/shared/TOOL-ECONOMY.md +155 -0
  99. package/skills/bootstrap/SKILL.md +226 -0
  100. package/skills/commit/SKILL.md +99 -0
  101. package/skills/dispatch/SKILL.md +78 -0
  102. package/skills/docs-digest/SKILL.md +109 -0
  103. package/skills/evals/SKILL.md +160 -0
  104. package/skills/explain-code/SKILL.md +42 -0
  105. package/skills/explain-code/commit-teachin.md +136 -0
  106. package/skills/explain-code/module-walkthrough.md +358 -0
  107. package/skills/explore/SKILL.md +159 -0
  108. package/skills/fix-tests/SKILL.md +120 -0
  109. package/skills/graph/SKILL.md +136 -0
  110. package/skills/manual-test-plan/SKILL.md +121 -0
  111. package/skills/mr/SKILL.md +170 -0
  112. package/skills/report/SKILL.md +121 -0
  113. package/skills/review-artifacts/SKILL.md +127 -0
  114. package/skills/review-code/REVIEW.template.md +49 -0
  115. package/skills/review-code/SKILL.md +156 -0
  116. package/skills/review-code/dimensions/architecture.md +10 -0
  117. package/skills/review-code/dimensions/devops.md +12 -0
  118. package/skills/review-code/dimensions/language.md +12 -0
  119. package/skills/review-code/dimensions/qa.md +12 -0
  120. package/skills/review-code/dimensions/security.md +12 -0
  121. package/skills/review-code/dimensions/technical-lead.md +19 -0
  122. package/skills/ship/SKILL.md +67 -0
  123. package/skills/spec/SKILL.md +264 -0
  124. package/skills/spec/continuity.md +66 -0
  125. package/workflows/ship.js +565 -0
@@ -0,0 +1,162 @@
1
+ // Shared plumbing for the PreToolUse guards and the SessionStart preflight.
2
+ //
3
+ // A hook is a bare Node script the Claude Code host runs in its own process: it
4
+ // reads one JSON event on stdin and speaks its decision back through stdout and
5
+ // the exit code, per the host's hook protocol. It shares no memory with the
6
+ // plugin — the only state it has is what it reads off the filesystem. So every
7
+ // helper here is a pure filesystem/stdin read, and every one of them fails in
8
+ // the ALLOW direction: a guard that blocked a tool call because its own parse
9
+ // threw would be a worse failure than the one it exists to prevent.
10
+
11
+ import { readFileSync } from 'node:fs'
12
+ import { isAbsolute, resolve } from 'node:path'
13
+
14
+ /** Read the PreToolUse/SessionStart event the host writes to stdin. */
15
+ export async function readEvent() {
16
+ const chunks = []
17
+ for await (const chunk of process.stdin) chunks.push(chunk)
18
+ const raw = Buffer.concat(chunks).toString('utf8').trim()
19
+ if (!raw) return {}
20
+ try {
21
+ return JSON.parse(raw)
22
+ } catch {
23
+ // A malformed event is not something a guard should block on — allow.
24
+ return {}
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Allow the tool call. The host's default on a clean exit with no decision is to
30
+ * proceed, so allow is simply "say nothing, exit 0". Used for both the ordinary
31
+ * allow and the fail-open path after an internal error (with a stderr note).
32
+ */
33
+ export function allow(note) {
34
+ if (note) process.stderr.write(`${note}\n`)
35
+ process.exit(0)
36
+ }
37
+
38
+ /**
39
+ * Deny the tool call in the shape the host's PreToolUse protocol expects, so the
40
+ * blocked agent receives `reason` as actionable feedback rather than an opaque
41
+ * failure. `detail` travels alongside the human-readable reason as machine-
42
+ * readable fields (guard name, path/command, current stage).
43
+ */
44
+ export function deny(reason, detail = {}) {
45
+ const out = {
46
+ hookSpecificOutput: {
47
+ hookEventName: 'PreToolUse',
48
+ permissionDecision: 'deny',
49
+ permissionDecisionReason: reason
50
+ },
51
+ // Kept for a reader that wants the parts without re-parsing the sentence.
52
+ interlockGuard: detail
53
+ }
54
+ process.stdout.write(JSON.stringify(out) + '\n')
55
+ process.exit(0)
56
+ }
57
+
58
+ /** The tool name from the event, tolerant of the two casings seen in the wild. */
59
+ export function toolName(event) {
60
+ return event.tool_name || event.toolName || ''
61
+ }
62
+
63
+ /** The tool input object, under either key the host has used. */
64
+ export function toolInput(event) {
65
+ return event.tool_input || event.toolInput || {}
66
+ }
67
+
68
+ /** Project root the hook runs against — the host sets cwd to it. */
69
+ export function projectRoot(event) {
70
+ return event.cwd || process.cwd()
71
+ }
72
+
73
+ /**
74
+ * The absolute file path an Edit/Write targets, or null when the payload names
75
+ * no concrete path. An unresolvable target is not a target to protect, so the
76
+ * guards allow it — this returning null is how they reach that decision.
77
+ */
78
+ export function editTargetPath(event) {
79
+ const input = toolInput(event)
80
+ const raw = input.file_path || input.filePath || input.path
81
+ if (typeof raw !== 'string' || !raw.trim()) return null
82
+ return isAbsolute(raw) ? raw : resolve(projectRoot(event), raw)
83
+ }
84
+
85
+ /** Parse `.claude/testing/profile.json`, or null on absence/malformed. */
86
+ export function loadTestProfile(root) {
87
+ try {
88
+ const value = JSON.parse(readFileSync(resolve(root, '.claude', 'testing', 'profile.json'), 'utf8'))
89
+ return value && typeof value === 'object' ? value : null
90
+ } catch {
91
+ return null
92
+ }
93
+ }
94
+
95
+ // Directory names that hold tests, used only to recognise a test root token
96
+ // inside a profile command — never as a standalone hardcoded glob against the
97
+ // tree. The profile is the source of truth for where THIS project's tests live.
98
+ const TEST_DIR_TOKENS = new Set(['test', 'tests', '__tests__', 'e2e', 'cypress', 'playwright', 'spec', 'specs'])
99
+ const TEST_FILE_SUFFIX = /\.(test|spec)\.[a-z0-9]+$/i
100
+
101
+ /**
102
+ * Derive the set of test-root directories this project actually uses from its
103
+ * test profile, by reading the path tokens out of the profile's own commands
104
+ * (`find test -name ...`, `npx playwright test e2e/`, ...). A `<placeholder>`
105
+ * token is skipped — it is the argument the command leaves open, not a path.
106
+ *
107
+ * @returns {{ roots: string[], hasProfile: boolean }}
108
+ */
109
+ export function testRootsFromProfile(profile) {
110
+ if (!profile || typeof profile !== 'object') return { roots: [], hasProfile: false }
111
+ const roots = new Set()
112
+ const consider = raw => {
113
+ if (typeof raw !== 'string') return
114
+ for (const token of raw.trim().split(/\s+/)) {
115
+ if (!token || token.startsWith('<') || token.startsWith('-')) continue
116
+ const norm = token.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '')
117
+ const segments = norm.split('/')
118
+ // The first segment that is a known test-dir token anchors a root at the
119
+ // path up to and including it — `packages/app/e2e` → `packages/app/e2e`.
120
+ for (let i = 0; i < segments.length; i++) {
121
+ if (TEST_DIR_TOKENS.has(segments[i].toLowerCase())) {
122
+ roots.add(segments.slice(0, i + 1).join('/'))
123
+ break
124
+ }
125
+ }
126
+ }
127
+ }
128
+ for (const kind of ['unit', 'e2e']) {
129
+ const section = profile[kind]
130
+ if (!section || typeof section !== 'object') continue
131
+ consider(section.command)
132
+ consider(section.single_file)
133
+ consider(section.filter_syntax)
134
+ }
135
+ return { roots: [...roots], hasProfile: true }
136
+ }
137
+
138
+ /**
139
+ * Is `absPath` a test file for this project? True when it lives under one of the
140
+ * profile-derived roots, or when its filename carries the `.test.`/`.spec.`
141
+ * convention (a filename shape, not a directory glob). `root` is the project
142
+ * root the relative comparison is made against.
143
+ */
144
+ export function isTestPath(absPath, testRoots, root) {
145
+ if (typeof absPath !== 'string') return false
146
+ const rel = relPosix(root, absPath)
147
+ if (TEST_FILE_SUFFIX.test(rel)) return true
148
+ for (const testRoot of testRoots) {
149
+ const r = testRoot.replace(/\/+$/, '')
150
+ if (rel === r || rel.startsWith(r + '/')) return true
151
+ }
152
+ return false
153
+ }
154
+
155
+ /** Project-relative POSIX path, for stable comparison against profile roots. */
156
+ export function relPosix(root, absPath) {
157
+ const base = resolve(root).replace(/\\/g, '/').replace(/\/+$/, '')
158
+ const p = resolve(absPath).replace(/\\/g, '/')
159
+ if (p === base) return ''
160
+ if (p.startsWith(base + '/')) return p.slice(base.length + 1)
161
+ return p // outside the project root: return as-is, suffix check still applies
162
+ }
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+ // PreToolUse guard: no `git commit` outside the commit stage.
3
+ //
4
+ // `skills/commit/SKILL.md` and `skills/mr/SKILL.md` already set
5
+ // `disable-model-invocation: true` — the prose version of "do not commit until
6
+ // the commit stage." This guard is the deterministic half of the same intent: a
7
+ // Bash command that performs a `git commit` is denied unless the run's stage is
8
+ // `commit`. It bounds IN-RUN agents; it deliberately does NOT bound the human's
9
+ // own shell, so when there is no active run marker it fails open — blocking a
10
+ // developer's own commit on a stray marker would be the worse failure.
11
+ //
12
+ // The match canonicalises enough to catch `git commit`, `git -C <path> commit`,
13
+ // `git -c key=val commit`, and a leading-env invocation (`FOO=bar git commit`),
14
+ // across `&&`/`;`/`|` chains AND newlines — a multi-line script is the ordinary
15
+ // shape of a Bash tool call, not an evasion, so a chain split by a line break
16
+ // must segment the same way one split by `&&` does. A segment led by a shell
17
+ // keyword (`then`, `do`, `else`) is scanned past it, so `if …; then git commit;
18
+ // fi` is seen. It is a guardrail against the wrong-stage mistake, not a sandbox
19
+ // against a determined adversary.
20
+
21
+ import { readStage } from '../lib/ship-stage.mjs'
22
+ import { readEvent, allow, deny, toolName, toolInput, projectRoot } from './_shared.mjs'
23
+
24
+ const GUARD = 'guard-commit'
25
+
26
+ // git global options that take an argument, so the scan can step past the value
27
+ // and still find the `commit` subcommand after them.
28
+ const OPTS_WITH_ARG = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path'])
29
+
30
+ // Shell keywords that can lead a segment and are followed by the command
31
+ // itself: `if true; then git commit`, `for f in *; do git commit`.
32
+ const LEADING_KEYWORDS = new Set(['then', 'do', 'else'])
33
+
34
+ /** Does `command` perform a git commit in any of its chained segments? */
35
+ export function isGitCommit(command) {
36
+ if (typeof command !== 'string') return false
37
+ for (const segment of command.split(/&&|\|\||;|\||\r?\n|\r/)) {
38
+ const tokens = segment.trim().split(/\s+/).filter(Boolean)
39
+ let i = 0
40
+ // Leading environment assignments: FOO=bar git commit.
41
+ while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) i++
42
+ // …then a leading shell keyword, so `then git commit` is scanned like
43
+ // `git commit`.
44
+ while (i < tokens.length && LEADING_KEYWORDS.has(tokens[i])) i++
45
+ if (tokens[i] !== 'git') continue
46
+ i++
47
+ // git's own global options, some of which consume the next token.
48
+ while (i < tokens.length && tokens[i].startsWith('-')) {
49
+ const opt = tokens[i]
50
+ i++
51
+ if (OPTS_WITH_ARG.has(opt) && !opt.includes('=')) i++ // step past its argument
52
+ }
53
+ if (tokens[i] === 'commit') return true
54
+ }
55
+ return false
56
+ }
57
+
58
+ try {
59
+ const event = await readEvent()
60
+ if (toolName(event) !== 'Bash') allow()
61
+
62
+ const command = toolInput(event).command
63
+ if (!isGitCommit(command)) allow()
64
+
65
+ const { stage } = readStage({ root: projectRoot(event) })
66
+ if (stage === 'commit') allow() // the commit stage is exactly when a commit is legal
67
+ if (stage === 'unknown') allow() // no active run to bound — the human's own shell is not our business
68
+
69
+ deny(
70
+ `${GUARD}: \`git commit\` is blocked during the ${stage} stage of a ship run — commits are made ` +
71
+ `only in the commit stage, by the commit step, as one feature-level commit. This is the ` +
72
+ `deterministic form of the disable-model-invocation flag on skills/commit.`,
73
+ { guard: GUARD, command: String(command), stage }
74
+ )
75
+ } catch (err) {
76
+ allow(`${GUARD}: internal error, allowing by default: ${(err && err.message) || String(err)}`)
77
+ }
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env node
2
+ // PreToolUse guard: the completion tick belongs to the CLI, not to an agent's
3
+ // Edit tool.
4
+ //
5
+ // `interlock tasks tick` writes a change's `tasks.md` checkbox from an
6
+ // adjudicated outcome — the tick is keyed off what the run RECORDED, not off
7
+ // what an implementing agent claims (see the shipped change
8
+ // `2026-08-25-tick-tasks-from-recorded-outcomes`). That change removed the
9
+ // incentive to hand-tick; this guard removes the capability. An Edit/Write that
10
+ // flips a checkbox line in the active change's `tasks.md` while a run is live is
11
+ // denied; a prose-only edit that leaves every checkbox byte-identical is
12
+ // allowed, and so is any edit when no run is active.
13
+ //
14
+ // The CLI's own tick does not route through the Edit/Write tools, so it never
15
+ // trips this guard — the guard scopes to the agent-facing tools by construction.
16
+
17
+ import { readStage, STAGES } from '../lib/ship-stage.mjs'
18
+ import { readFileSync } from 'node:fs'
19
+ import { resolve } from 'node:path'
20
+ import {
21
+ readEvent,
22
+ allow,
23
+ deny,
24
+ toolName,
25
+ toolInput,
26
+ projectRoot,
27
+ editTargetPath,
28
+ relPosix
29
+ } from './_shared.mjs'
30
+
31
+ const GUARD = 'guard-tasks'
32
+ // Every stage a run publishes is an active-run stage; the tick is never the
33
+ // agent's job in any of them. (The commit-stage agent does not edit tasks.md,
34
+ // but denying a checkbox flip there too costs nothing and is the honest rule.)
35
+ const ACTIVE_STAGES = new Set(STAGES)
36
+ const CHECKBOX = /^\s*[-*]\s+\[([ xX])\]/
37
+
38
+ /**
39
+ * The checkbox STATES of a blob, in order — `' '` or `'x'` per checkbox line.
40
+ * Only the marker inside the brackets is captured, so a reworded task title or
41
+ * a prose edit anywhere leaves this sequence byte-identical; only a tick flip,
42
+ * or a checkbox added or removed, changes it. That is exactly the edit the CLI
43
+ * owns and a hand-edit must not make.
44
+ */
45
+ function checkboxStates(text) {
46
+ if (typeof text !== 'string') return []
47
+ const states = []
48
+ for (const line of text.split(/\r?\n/)) {
49
+ const m = CHECKBOX.exec(line)
50
+ if (m) states.push(m[1].toLowerCase())
51
+ }
52
+ return states
53
+ }
54
+
55
+ /** Do two blobs carry a different sequence of checkbox states (added, removed, or flipped)? */
56
+ function checkboxesDiffer(before, after) {
57
+ const a = checkboxStates(before)
58
+ const b = checkboxStates(after)
59
+ if (a.length !== b.length) return true
60
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return true
61
+ return false
62
+ }
63
+
64
+ try {
65
+ const event = await readEvent()
66
+ const tool = toolName(event)
67
+ if (tool !== 'Edit' && tool !== 'Write') allow()
68
+
69
+ const root = projectRoot(event)
70
+ const { stage, change } = readStage({ root })
71
+ if (!ACTIVE_STAGES.has(stage) || !change) allow() // no active run → allow
72
+
73
+ const target = editTargetPath(event)
74
+ if (!target) allow()
75
+
76
+ const tasksPath = resolve(root, 'openspec', 'changes', String(change), 'tasks.md')
77
+ if (resolve(target) !== tasksPath) allow() // not the active change's tasks.md
78
+
79
+ const input = toolInput(event)
80
+ let before
81
+ let after
82
+ if (tool === 'Edit') {
83
+ before = input.old_string || input.oldString || ''
84
+ after = input.new_string || input.newString || ''
85
+ } else {
86
+ // Write replaces the whole file — compare against what is on disk now.
87
+ try {
88
+ before = readFileSync(tasksPath, 'utf8')
89
+ } catch {
90
+ before = ''
91
+ }
92
+ after = input.content || input.contents || ''
93
+ }
94
+
95
+ if (!checkboxesDiffer(before, after)) allow() // prose-only edit, checkboxes byte-identical
96
+
97
+ const rel = relPosix(root, target) || target
98
+ deny(
99
+ `${GUARD}: changing a checkbox line in ${rel} is blocked during a ship run (stage ${stage}). ` +
100
+ `The completion tick is written by \`interlock tasks tick\` from the run's recorded outcomes, ` +
101
+ `never by hand — a hand-tick is how unimplemented work ships marked done. Edit the task's prose ` +
102
+ `freely; leave the \`- [ ]\` / \`- [x]\` markers to the CLI.`,
103
+ { guard: GUARD, path: rel, stage, change }
104
+ )
105
+ } catch (err) {
106
+ allow(`${GUARD}: internal error, allowing by default: ${(err && err.message) || String(err)}`)
107
+ }
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ // PreToolUse guard: an agent making a red check green must not be able to
3
+ // weaken the check itself.
4
+ //
5
+ // During `remediation` and `fix-tests` the run's whole job is to turn a failing
6
+ // assertion into a passing one. The cheapest way to do that dishonestly is to
7
+ // edit the test. This guard denies an Edit/Write to a test file for exactly
8
+ // those two stages, and allows everything else — including test edits during
9
+ // `implement`, where adding coverage for the feature you are building is the
10
+ // point, not a hazard.
11
+ //
12
+ // Fail open on everything it cannot establish: an unknown stage, an unresolvable
13
+ // path, a missing test profile, or its own crash all ALLOW. Blocking test edits
14
+ // whenever the marker is missing would break ordinary TDD the moment the plugin
15
+ // is installed — a cost strictly larger than the guard's benefit, which exists
16
+ // only inside a ship run's remediation window.
17
+
18
+ import { readStage } from '../lib/ship-stage.mjs'
19
+ import {
20
+ readEvent,
21
+ allow,
22
+ deny,
23
+ toolName,
24
+ projectRoot,
25
+ editTargetPath,
26
+ loadTestProfile,
27
+ testRootsFromProfile,
28
+ isTestPath,
29
+ relPosix
30
+ } from './_shared.mjs'
31
+
32
+ const GUARD = 'guard-tests'
33
+ const BLOCKED_STAGES = new Set(['remediation', 'fix-tests'])
34
+
35
+ try {
36
+ const event = await readEvent()
37
+ const tool = toolName(event)
38
+ if (tool !== 'Edit' && tool !== 'Write') allow()
39
+
40
+ const root = projectRoot(event)
41
+ const { stage } = readStage({ root })
42
+ if (!BLOCKED_STAGES.has(stage)) allow() // unknown or a non-repair stage → allow
43
+
44
+ const target = editTargetPath(event)
45
+ if (!target) allow() // no concrete path to protect
46
+
47
+ const { roots, hasProfile } = testRootsFromProfile(loadTestProfile(root))
48
+ if (!hasProfile) allow() // no profile → nothing names where tests live → allow
49
+
50
+ if (!isTestPath(target, roots, root)) allow() // not a test file → allow (source repair is expected)
51
+
52
+ const rel = relPosix(root, target) || target
53
+ deny(
54
+ `${GUARD}: editing the test file ${rel} is blocked during the ${stage} stage — ` +
55
+ `a run making a failing check pass must fix the code under test, not weaken the check. ` +
56
+ `If the test itself is wrong, stop the run and correct it outside remediation.`,
57
+ { guard: GUARD, path: rel, stage }
58
+ )
59
+ } catch (err) {
60
+ // The guard's own crash must never block a tool call. Allow, and leave a
61
+ // diagnostic on stderr rather than a wedged session.
62
+ allow(`${GUARD}: internal error, allowing by default: ${(err && err.message) || String(err)}`)
63
+ }
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env node
2
+ // SessionStart hook: run the preflight the run already owns, at the one moment
3
+ // it is cheap to act on — session start, before any wave.
4
+ //
5
+ // `interlock doctor` (lib/doctor.mjs) already computes the allowlist / Node /
6
+ // OpenSpec / writability preflight that decides whether a zero-touch run can
7
+ // even begin. The failure it catches — an un-allowlisted command that stalls
8
+ // wave four on an approval prompt no human is watching — is the expensive one.
9
+ // A human or a skill used to have to remember to run it; this hook fires it
10
+ // every session and surfaces any failure with the fix string the doctor emits.
11
+ //
12
+ // It NEVER blocks. `interlock doctor` is non-mutating, and a preflight that
13
+ // aborted the session because its own binary was missing would be worse than no
14
+ // preflight. Every path here exits 0.
15
+
16
+ import { execFileSync } from 'node:child_process'
17
+ import { existsSync } from 'node:fs'
18
+ import { dirname, join } from 'node:path'
19
+ import { fileURLToPath } from 'node:url'
20
+
21
+ const PLUGIN_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
22
+
23
+ /** Prefer the bundled binary (always present next to this hook), fall back to PATH. */
24
+ function interlockBin() {
25
+ const bundled = join(PLUGIN_ROOT, 'bin', 'interlock')
26
+ if (existsSync(bundled)) return bundled
27
+ return 'interlock' // let PATH resolution decide; a miss throws ENOENT below
28
+ }
29
+
30
+ function run() {
31
+ let raw
32
+ try {
33
+ raw = execFileSync(interlockBin(), ['doctor', '--json'], {
34
+ cwd: process.cwd(),
35
+ encoding: 'utf8',
36
+ stdio: ['ignore', 'pipe', 'pipe'],
37
+ timeout: 20000
38
+ })
39
+ } catch (err) {
40
+ // Two shapes of failure. A non-zero exit still carries JSON on stdout (the
41
+ // doctor exits 1 when a check fails) — parse it. A spawn failure (ENOENT:
42
+ // no binary) has no stdout, and that is the "binary not resolvable" case.
43
+ if (err && typeof err.stdout === 'string' && err.stdout.trim()) {
44
+ raw = err.stdout
45
+ } else {
46
+ report(
47
+ `interlock preflight could not run: ${(err && err.message) || String(err)}\n` +
48
+ `The \`interlock\` binary is not resolvable from this session. Inside Claude Code the ` +
49
+ `plugin's bin/ is normally injected; if you see this, reinstall the plugin. The session ` +
50
+ `continues — this preflight is advisory.`
51
+ )
52
+ return
53
+ }
54
+ }
55
+
56
+ let doctor
57
+ try {
58
+ doctor = JSON.parse(raw)
59
+ } catch {
60
+ report('interlock preflight ran but its output could not be parsed; skipping. The session continues.')
61
+ return
62
+ }
63
+
64
+ const checks = Array.isArray(doctor.checks) ? doctor.checks : []
65
+ const failures = checks.filter(c => c && c.status === 'fail')
66
+ if (!failures.length) {
67
+ const warns = (doctor.counts && doctor.counts.warn) || 0
68
+ // A clean preflight is quiet, not a wall of output — one confirming line.
69
+ report(`interlock preflight OK${warns ? ` (${warns} warning${warns === 1 ? '' : 's'})` : ''}.`)
70
+ return
71
+ }
72
+
73
+ const lines = ['interlock preflight found issues that can stall an unattended ship run:']
74
+ for (const c of failures) {
75
+ lines.push(` [FAIL] ${c.id}: ${c.detail}`)
76
+ if (c.fix) {
77
+ const [first, ...rest] = String(c.fix).split('\n')
78
+ lines.push(` fix: ${first}`)
79
+ for (const line of rest) lines.push(` ${line.trim()}`)
80
+ }
81
+ }
82
+ lines.push('The session still starts — apply the fixes above before /interlock:ship.')
83
+ report(lines.join('\n'))
84
+ }
85
+
86
+ /**
87
+ * Surface advisory context to the session. SessionStart stdout is folded into
88
+ * the session context by the host; the structured form makes the intent
89
+ * explicit and is ignored harmlessly by a host that does not read it.
90
+ */
91
+ function report(message) {
92
+ const out = {
93
+ hookSpecificOutput: {
94
+ hookEventName: 'SessionStart',
95
+ additionalContext: message
96
+ }
97
+ }
98
+ process.stdout.write(JSON.stringify(out) + '\n')
99
+ }
100
+
101
+ try {
102
+ run()
103
+ } catch (err) {
104
+ // Belt and suspenders: nothing this hook does may abort the session.
105
+ process.stderr.write(`interlock preflight hook error (ignored): ${(err && err.message) || String(err)}\n`)
106
+ }
107
+ process.exit(0)