@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.
- package/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +58 -0
- package/CHANGELOG.md +380 -0
- package/LICENSE +21 -0
- package/README.md +358 -0
- package/agents/ping.md +8 -0
- package/agents/worker.md +8 -0
- package/bin/interlock +2655 -0
- package/bin/interlock-graph +6 -0
- package/bin/interlock-run +508 -0
- package/bin/interlock-ship-acp +39 -0
- package/hooks/_shared.mjs +162 -0
- package/hooks/guard-commit.mjs +77 -0
- package/hooks/guard-tasks.mjs +107 -0
- package/hooks/guard-tests.mjs +63 -0
- package/hooks/preflight.mjs +107 -0
- package/lib/artifacts.mjs +316 -0
- package/lib/autonomy.mjs +149 -0
- package/lib/conformance.mjs +132 -0
- package/lib/doctor.mjs +1176 -0
- package/lib/drift.mjs +409 -0
- package/lib/eval-history.mjs +304 -0
- package/lib/evals-calibrate.mjs +317 -0
- package/lib/evals-capture.mjs +437 -0
- package/lib/evals-promote.mjs +257 -0
- package/lib/evals-triage.mjs +216 -0
- package/lib/findings.mjs +340 -0
- package/lib/graph/cli.mjs +266 -0
- package/lib/graph/extract/config-json.mjs +74 -0
- package/lib/graph/extract/create-extractor.mjs +35 -0
- package/lib/graph/extract/memory.mjs +99 -0
- package/lib/graph/extract/openspec.mjs +80 -0
- package/lib/graph/extract/python.mjs +137 -0
- package/lib/graph/extract/regex-fallback.mjs +187 -0
- package/lib/graph/extract/shell.mjs +71 -0
- package/lib/graph/extract/typescript.mjs +217 -0
- package/lib/graph/extract/walk.mjs +272 -0
- package/lib/graph/format.mjs +51 -0
- package/lib/graph/graph/build.mjs +374 -0
- package/lib/graph/graph/query.mjs +249 -0
- package/lib/graph/graph/report.mjs +122 -0
- package/lib/graph/graph/update.mjs +47 -0
- package/lib/graph/paths.mjs +66 -0
- package/lib/graph/retrieve/context.mjs +86 -0
- package/lib/graph/retrieve/docs-index.mjs +116 -0
- package/lib/graph/retrieve/markdown.mjs +246 -0
- package/lib/host/acp.mjs +551 -0
- package/lib/host/claude-cli.mjs +285 -0
- package/lib/host/codex.mjs +231 -0
- package/lib/host/model-map.mjs +129 -0
- package/lib/host/qwen.mjs +176 -0
- package/lib/host/registry.mjs +213 -0
- package/lib/host.mjs +393 -0
- package/lib/ledger.mjs +444 -0
- package/lib/limits.mjs +525 -0
- package/lib/locators.mjs +99 -0
- package/lib/merge-lanes.mjs +148 -0
- package/lib/metrics.mjs +99 -0
- package/lib/notify.mjs +229 -0
- package/lib/outcomes.mjs +560 -0
- package/lib/plan-fingerprint.mjs +570 -0
- package/lib/project-slug.mjs +18 -0
- package/lib/prompts/commit.mjs +26 -0
- package/lib/prompts/dimensions.mjs +70 -0
- package/lib/prompts/handoff.mjs +86 -0
- package/lib/prompts/implementer.mjs +150 -0
- package/lib/prompts/index.mjs +250 -0
- package/lib/prompts/planner.mjs +70 -0
- package/lib/prompts/remediate.mjs +137 -0
- package/lib/prompts/replan.mjs +23 -0
- package/lib/prompts/review.mjs +107 -0
- package/lib/prompts/schemas.mjs +206 -0
- package/lib/prompts/stage.mjs +48 -0
- package/lib/prompts/verify.mjs +88 -0
- package/lib/ready.mjs +812 -0
- package/lib/receipt.mjs +434 -0
- package/lib/remediate.mjs +275 -0
- package/lib/report-html.mjs +623 -0
- package/lib/report.mjs +1124 -0
- package/lib/review-core.mjs +713 -0
- package/lib/risk.mjs +370 -0
- package/lib/run-log.mjs +1004 -0
- package/lib/run-paths.mjs +239 -0
- package/lib/run.mjs +2921 -0
- package/lib/ship-stage.mjs +223 -0
- package/lib/spill.mjs +150 -0
- package/lib/surface.mjs +195 -0
- package/lib/verify.mjs +1074 -0
- package/lib/waves.mjs +2661 -0
- package/package.json +40 -0
- package/shared/CONTEXT-HYGIENE.md +84 -0
- package/shared/DECISION-LEDGER.md +141 -0
- package/shared/EXPLORE-BRIEF.md +96 -0
- package/shared/INVARIANT-SWEEP.md +57 -0
- package/shared/SESSION-SHAPES.md +261 -0
- package/shared/SKILL-CANDIDATE-BRIEF.md +106 -0
- package/shared/TEST-PROFILE.md +204 -0
- package/shared/TOOL-ECONOMY.md +155 -0
- package/skills/bootstrap/SKILL.md +226 -0
- package/skills/commit/SKILL.md +99 -0
- package/skills/dispatch/SKILL.md +78 -0
- package/skills/docs-digest/SKILL.md +109 -0
- package/skills/evals/SKILL.md +160 -0
- package/skills/explain-code/SKILL.md +42 -0
- package/skills/explain-code/commit-teachin.md +136 -0
- package/skills/explain-code/module-walkthrough.md +358 -0
- package/skills/explore/SKILL.md +159 -0
- package/skills/fix-tests/SKILL.md +120 -0
- package/skills/graph/SKILL.md +136 -0
- package/skills/manual-test-plan/SKILL.md +121 -0
- package/skills/mr/SKILL.md +170 -0
- package/skills/report/SKILL.md +121 -0
- package/skills/review-artifacts/SKILL.md +127 -0
- package/skills/review-code/REVIEW.template.md +49 -0
- package/skills/review-code/SKILL.md +156 -0
- package/skills/review-code/dimensions/architecture.md +10 -0
- package/skills/review-code/dimensions/devops.md +12 -0
- package/skills/review-code/dimensions/language.md +12 -0
- package/skills/review-code/dimensions/qa.md +12 -0
- package/skills/review-code/dimensions/security.md +12 -0
- package/skills/review-code/dimensions/technical-lead.md +19 -0
- package/skills/ship/SKILL.md +67 -0
- package/skills/spec/SKILL.md +264 -0
- package/skills/spec/continuity.md +66 -0
- 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)
|