@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,223 @@
|
|
|
1
|
+
// The stage marker — the one channel from a ship run to the stateless
|
|
2
|
+
// `PreToolUse` guards that fire in a different process.
|
|
3
|
+
//
|
|
4
|
+
// A hook cannot ask the workflow "what stage are we in". It shares no memory
|
|
5
|
+
// with `workflows/ship.js`, it is spawned per tool call, and env vars do not
|
|
6
|
+
// survive across the separate subagent processes the workflow spawns. The only
|
|
7
|
+
// thing that reliably spans the run process and the hook process is the
|
|
8
|
+
// filesystem. So the run PUBLISHES its stage to a file, and the guard READS it.
|
|
9
|
+
//
|
|
10
|
+
// Three properties are load-bearing, and they mirror lib/doctor.mjs's own:
|
|
11
|
+
//
|
|
12
|
+
// 1. NEVER THROWS. A guard whose own crash blocks a tool call is worse than
|
|
13
|
+
// no guard. Every function here collapses a failure to a value: a write
|
|
14
|
+
// error returns a warning object, an absent or malformed marker reads back
|
|
15
|
+
// as `{ stage: 'unknown' }`. The caller decides; this module never throws
|
|
16
|
+
// the decision away as an exception.
|
|
17
|
+
//
|
|
18
|
+
// 2. AN UNKNOWN IS NOT A STAGE. A marker that is absent, unparseable, carries
|
|
19
|
+
// a stage outside the known set, or is stale resolves to `stage: 'unknown'`
|
|
20
|
+
// — never to the nearest known stage. The edit guards fail OPEN on unknown
|
|
21
|
+
// (see the stage-guard spec): treating a marker we could not trust as a
|
|
22
|
+
// real stage is exactly the false-positive that would brick ordinary TDD
|
|
23
|
+
// the moment the plugin is installed.
|
|
24
|
+
//
|
|
25
|
+
// 3. STALENESS NARROWS, IT DOES NOT CLOSE. A run that is killed before it can
|
|
26
|
+
// clear its marker leaves the file behind. Two signals catch that orphan:
|
|
27
|
+
// `pid` (is the run's process still alive?) and `index` (is this marker's
|
|
28
|
+
// write count the current run's, or one an abandoned run left?). Neither is
|
|
29
|
+
// trusted alone — pids recycle, and an index has no meaning without a
|
|
30
|
+
// caller that knows the current run's count — but together they narrow the
|
|
31
|
+
// window in which a leftover `remediation` marker could spuriously block an
|
|
32
|
+
// edit in a later session. They do not close it, and the guards' fail-open
|
|
33
|
+
// default is what makes that residual window safe rather than harmful.
|
|
34
|
+
//
|
|
35
|
+
// WHO WRITES IT. Not a driver. `lib/prompts/stage.mjs` renders the publish
|
|
36
|
+
// fragment from THIS module's own `stagePath`, `MARKER_FIELDS` and `STAGES`, and
|
|
37
|
+
// the agent performing each step writes the file as it enters the stage — so the
|
|
38
|
+
// path and JSON shape have exactly one definition, here, and no driver carries a
|
|
39
|
+
// second copy of them. `test/spine/ship-stage-drift.test.mjs` asserts that: it
|
|
40
|
+
// fails if any driver declares a marker literal at all.
|
|
41
|
+
//
|
|
42
|
+
// WHO CLEARS IT. `runClose` in `lib/run.mjs`, on every terminal path — the
|
|
43
|
+
// program routes a halt through `run close --halt` and both drivers' `stop()`
|
|
44
|
+
// call it, so the clean close and the halt clear the same way. A clear that
|
|
45
|
+
// fails is spoken as a degradation, never as an exit code: the marker is a guard
|
|
46
|
+
// input, not a gate.
|
|
47
|
+
|
|
48
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
49
|
+
import { dirname, join } from 'node:path'
|
|
50
|
+
|
|
51
|
+
/** The stages a run publishes. A marker holding anything else reads as unknown. */
|
|
52
|
+
export const STAGES = Object.freeze(['implement', 'verify', 'review', 'remediation', 'fix-tests', 'commit'])
|
|
53
|
+
|
|
54
|
+
/** The fields a marker carries. The workflow's duplicated literal must match. */
|
|
55
|
+
export const MARKER_FIELDS = Object.freeze(['stage', 'change', 'index', 'pid'])
|
|
56
|
+
|
|
57
|
+
/** Root of the per-run state tree the doctor already probes for writability. */
|
|
58
|
+
export const SHIP_DIR = join('.claude', 'ship')
|
|
59
|
+
|
|
60
|
+
/** The marker filename under `<SHIP_DIR>/<change>/`. */
|
|
61
|
+
export const MARKER_FILE = 'stage.json'
|
|
62
|
+
|
|
63
|
+
/** The reading returned whenever no trustworthy stage can be established. */
|
|
64
|
+
export const UNKNOWN = Object.freeze({ stage: 'unknown' })
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The marker path for a change: `.claude/ship/<change>/stage.json`, rooted at
|
|
68
|
+
* `root` (default cwd). The only derivation of it: `lib/prompts/stage.mjs`
|
|
69
|
+
* renders the agent's publish fragment from this function, and the drift test
|
|
70
|
+
* fails if a driver ever declares the path as a literal of its own.
|
|
71
|
+
*/
|
|
72
|
+
export function stagePath(change, root = '.') {
|
|
73
|
+
return join(root, SHIP_DIR, String(change), MARKER_FILE)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Is `pid` a live process? A probe, not a gate — any error resolves to a boolean. */
|
|
77
|
+
function processAlive(pid) {
|
|
78
|
+
if (!Number.isInteger(pid) || pid <= 0) return false
|
|
79
|
+
try {
|
|
80
|
+
// Signal 0 performs the permission/existence check without delivering a
|
|
81
|
+
// signal. ESRCH means no such process; EPERM means it exists but is not
|
|
82
|
+
// ours, which for a liveness question still counts as alive.
|
|
83
|
+
process.kill(pid, 0)
|
|
84
|
+
return true
|
|
85
|
+
} catch (err) {
|
|
86
|
+
return err && err.code === 'EPERM'
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Publish `stage` for `change`. Reads any existing marker to advance `index`
|
|
92
|
+
* monotonically, then writes `{ stage, change, index, pid }`. Records `pid` as
|
|
93
|
+
* this process's id — in a ship run the writer is a step agent whose parent is
|
|
94
|
+
* the run session, so the recorded pid is alive for the run and dead once the
|
|
95
|
+
* session ends, which is the signal a later session uses to reject an orphan.
|
|
96
|
+
*
|
|
97
|
+
* Never throws. On any filesystem error returns `{ ok: false, warning }` so the
|
|
98
|
+
* run can record a non-fatal note and continue — the marker is a guard input,
|
|
99
|
+
* not a gate the run itself depends on.
|
|
100
|
+
*
|
|
101
|
+
* @returns {{ ok: true, marker: object } | { ok: false, warning: string }}
|
|
102
|
+
*/
|
|
103
|
+
export function writeStage(change, stage, opts = {}) {
|
|
104
|
+
const root = opts.root || '.'
|
|
105
|
+
const pid = Number.isInteger(opts.pid) ? opts.pid : process.pid
|
|
106
|
+
const path = stagePath(change, root)
|
|
107
|
+
let index = 1
|
|
108
|
+
try {
|
|
109
|
+
const prior = readMarkerFile(path)
|
|
110
|
+
if (prior && Number.isInteger(prior.index) && prior.index > 0) index = prior.index + 1
|
|
111
|
+
} catch {
|
|
112
|
+
// A prior marker we cannot read is not a reason to refuse to write a new
|
|
113
|
+
// one — start the counter fresh rather than throw.
|
|
114
|
+
index = 1
|
|
115
|
+
}
|
|
116
|
+
const marker = { stage, change, index, pid }
|
|
117
|
+
try {
|
|
118
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
119
|
+
writeFileSync(path, JSON.stringify(marker) + '\n')
|
|
120
|
+
return { ok: true, marker }
|
|
121
|
+
} catch (err) {
|
|
122
|
+
return { ok: false, warning: `stage marker unwritable at ${path}: ${(err && err.message) || String(err)}` }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Read and parse one marker file. Returns null on absence or malformed JSON. */
|
|
127
|
+
function readMarkerFile(path) {
|
|
128
|
+
let raw
|
|
129
|
+
try {
|
|
130
|
+
raw = readFileSync(path, 'utf8')
|
|
131
|
+
} catch {
|
|
132
|
+
return null
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
const value = JSON.parse(raw)
|
|
136
|
+
return value && typeof value === 'object' ? value : null
|
|
137
|
+
} catch {
|
|
138
|
+
return null
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Read the current ship stage, for a guard that knows only the filesystem.
|
|
144
|
+
*
|
|
145
|
+
* With no change name (the hook's situation) it scans `.claude/ship/*` for
|
|
146
|
+
* markers and returns the freshest live one. A marker is LIVE when its `pid` is
|
|
147
|
+
* still a running process; a marker whose process is gone is treated as an
|
|
148
|
+
* orphan and ignored. Among live markers the highest `index` wins — that is the
|
|
149
|
+
* most recent transition. When a caller knows the current run's write count it
|
|
150
|
+
* passes `minIndex`, and any marker whose `index` is below it is rejected as a
|
|
151
|
+
* leftover from an earlier run.
|
|
152
|
+
*
|
|
153
|
+
* Returns `{ stage: 'unknown' }` when nothing trustworthy is found: no marker,
|
|
154
|
+
* malformed marker, unrecognized stage value, dead pid, or stale index. Never
|
|
155
|
+
* throws — a scan that blows up is an unknown stage, not an exception.
|
|
156
|
+
*
|
|
157
|
+
* @param {object} [opts]
|
|
158
|
+
* @param {string} [opts.root] project root (default cwd)
|
|
159
|
+
* @param {string} [opts.change] read only this change's marker
|
|
160
|
+
* @param {number} [opts.minIndex] reject markers whose index is below this
|
|
161
|
+
* @param {(pid:number)=>boolean} [opts.isAlive] liveness probe (injected in tests)
|
|
162
|
+
* @returns {{ stage: string, change?: string, index?: number, pid?: number, reason?: string }}
|
|
163
|
+
*/
|
|
164
|
+
export function readStage(opts = {}) {
|
|
165
|
+
const root = opts.root || '.'
|
|
166
|
+
const isAlive = typeof opts.isAlive === 'function' ? opts.isAlive : processAlive
|
|
167
|
+
const minIndex = Number.isInteger(opts.minIndex) ? opts.minIndex : null
|
|
168
|
+
|
|
169
|
+
let paths
|
|
170
|
+
try {
|
|
171
|
+
if (opts.change) {
|
|
172
|
+
paths = [stagePath(opts.change, root)]
|
|
173
|
+
} else {
|
|
174
|
+
const base = join(root, SHIP_DIR)
|
|
175
|
+
if (!existsSync(base)) return { ...UNKNOWN }
|
|
176
|
+
paths = readdirSync(base, { withFileTypes: true })
|
|
177
|
+
.filter(e => e.isDirectory())
|
|
178
|
+
.map(e => join(base, e.name, MARKER_FILE))
|
|
179
|
+
}
|
|
180
|
+
} catch {
|
|
181
|
+
return { ...UNKNOWN }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let best = null
|
|
185
|
+
for (const path of paths) {
|
|
186
|
+
const marker = readMarkerFile(path)
|
|
187
|
+
if (!marker) continue
|
|
188
|
+
if (!STAGES.includes(marker.stage)) continue // unrecognized stage → not a match
|
|
189
|
+
if (!Number.isInteger(marker.index) || marker.index <= 0) continue
|
|
190
|
+
if (minIndex !== null && marker.index < minIndex) continue // stale: behind the current run
|
|
191
|
+
if (!isAlive(marker.pid)) continue // orphaned: the run that wrote it is gone
|
|
192
|
+
if (!best || marker.index > best.index) best = marker
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (!best) return { ...UNKNOWN }
|
|
196
|
+
return { stage: best.stage, change: best.change, index: best.index, pid: best.pid }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Remove a change's marker. Called on every terminal path of a run so no stage
|
|
201
|
+
* leaks into the next session. Never throws — an already-absent marker is
|
|
202
|
+
* success, and a marker we cannot delete becomes a warning, not a crash.
|
|
203
|
+
*
|
|
204
|
+
* @returns {{ ok: true } | { ok: false, warning: string }}
|
|
205
|
+
*/
|
|
206
|
+
export function clearStage(change, opts = {}) {
|
|
207
|
+
const path = stagePath(change, opts.root || '.')
|
|
208
|
+
try {
|
|
209
|
+
rmSync(path, { force: true })
|
|
210
|
+
return { ok: true }
|
|
211
|
+
} catch (err) {
|
|
212
|
+
return { ok: false, warning: `stage marker not cleared at ${path}: ${(err && err.message) || String(err)}` }
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Kept for callers that want to know a marker exists without interpreting it.
|
|
217
|
+
export function markerExists(change, root = '.') {
|
|
218
|
+
try {
|
|
219
|
+
return statSync(stagePath(change, root)).isFile()
|
|
220
|
+
} catch {
|
|
221
|
+
return false
|
|
222
|
+
}
|
|
223
|
+
}
|
package/lib/spill.mjs
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Spill store for oversized verify output (add-ship-run-inspectability §3).
|
|
2
|
+
//
|
|
3
|
+
// A unit suite's combined stdout/stderr can run into hundreds of KB. Pasting
|
|
4
|
+
// that into a verify agent's structured result would blow past the very
|
|
5
|
+
// token-economy rules the ship loop exists to enforce. This module writes the
|
|
6
|
+
// full bytes to a stable on-disk locator once, and hands back a head-and-tail
|
|
7
|
+
// preview plus a hash — the caller's result object carries the locator and
|
|
8
|
+
// preview, never the full text.
|
|
9
|
+
//
|
|
10
|
+
// Not a dsh `ctx.spillStore`, not a generic tool-result bus: one function, one
|
|
11
|
+
// directory layout, consumed by the verify CLI helper (task 3.3). This module
|
|
12
|
+
// never judges pass/fail — that stays in `lib/verify.mjs`, which stays pure.
|
|
13
|
+
//
|
|
14
|
+
// Exposed to skills indirectly via `interlock verify` (spill wiring lands in
|
|
15
|
+
// task 3.3); this module is pure I/O with no agent, no fs-reading-back.
|
|
16
|
+
|
|
17
|
+
import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs'
|
|
18
|
+
import { join, posix as pathPosix } from 'node:path'
|
|
19
|
+
import { createHash } from 'node:crypto'
|
|
20
|
+
import { LIMITS } from './limits.mjs'
|
|
21
|
+
|
|
22
|
+
/** Where spilled suite output lives, relative to the repo root, POSIX-joined. */
|
|
23
|
+
export const SPILL_DIR = '.claude/ship/spill'
|
|
24
|
+
|
|
25
|
+
// Falls back to the design's pinned default if `lib/limits.mjs` has not yet
|
|
26
|
+
// grown `verifyPreviewChars` (see task 3.1) — keeps this module usable
|
|
27
|
+
// independent of load order, without restating the number as policy.
|
|
28
|
+
const DEFAULT_PREVIEW_CHARS = 4096
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Is this output big enough to spill?
|
|
32
|
+
*
|
|
33
|
+
* The threshold `interlock limits` prints, read here rather than described in
|
|
34
|
+
* prose. It used to be a sentence in a prompt — "if a check's combined
|
|
35
|
+
* stdout/stderr exceeds the spill threshold" — with the number itself living
|
|
36
|
+
* only in `LIMITS.verifySpillBytes` and in a test asserting it equals 8192. A
|
|
37
|
+
* cap whose only reader is a test pinning its value is not an enforced cap; it
|
|
38
|
+
* just looks like one.
|
|
39
|
+
*
|
|
40
|
+
* @param {string|Buffer|number} output the bytes, or a byte count
|
|
41
|
+
* @param {{threshold?: number}} [opts]
|
|
42
|
+
* @returns {boolean} true when the caller must spill rather than inline
|
|
43
|
+
*/
|
|
44
|
+
export function shouldSpill(output, opts = {}) {
|
|
45
|
+
const threshold =
|
|
46
|
+
Number.isInteger(opts.threshold) && opts.threshold >= 0
|
|
47
|
+
? opts.threshold
|
|
48
|
+
: (LIMITS.verifySpillBytes ?? 8192)
|
|
49
|
+
return byteLength(output) > threshold
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function byteLength(output) {
|
|
53
|
+
if (typeof output === 'number') return Number.isFinite(output) ? output : 0
|
|
54
|
+
if (Buffer.isBuffer(output)) return output.length
|
|
55
|
+
return Buffer.byteLength(String(output ?? ''), 'utf8')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function safeSegment(value, fallback) {
|
|
59
|
+
const s = typeof value === 'string' ? value.trim() : ''
|
|
60
|
+
const cleaned = s.replace(/[^a-zA-Z0-9._-]/g, '-')
|
|
61
|
+
return cleaned.length ? cleaned : fallback
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Next seq for this run's spill directory: contiguous, starting at 1, derived
|
|
65
|
+
// from what is already on disk rather than threaded through by the caller —
|
|
66
|
+
// `spillBytes`'s only inputs are the run id, the kind, and the bytes.
|
|
67
|
+
function nextSeq(dirAbs) {
|
|
68
|
+
if (!existsSync(dirAbs)) return 1
|
|
69
|
+
let max = 0
|
|
70
|
+
for (const name of readdirSync(dirAbs)) {
|
|
71
|
+
const m = /^(\d+)-/.exec(name)
|
|
72
|
+
if (m) max = Math.max(max, Number(m[1]))
|
|
73
|
+
}
|
|
74
|
+
return max + 1
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Build a head-and-tail preview with an explicit omission marker, so a model
|
|
79
|
+
* can see the start of a failure and its summary line without loading the
|
|
80
|
+
* middle of the suite output.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} text - full decoded content
|
|
83
|
+
* @param {number} bytes - byte length of the full content
|
|
84
|
+
* @param {string} locator - repo-relative POSIX path the full bytes live at
|
|
85
|
+
* @param {number} previewChars - total character budget for the preview
|
|
86
|
+
* @returns {{ preview: string, truncated: boolean }}
|
|
87
|
+
*/
|
|
88
|
+
export function buildPreview(text, bytes, locator, previewChars = DEFAULT_PREVIEW_CHARS) {
|
|
89
|
+
if (text.length <= previewChars) {
|
|
90
|
+
return { preview: text, truncated: false }
|
|
91
|
+
}
|
|
92
|
+
const marker = `\n…[spilled ${bytes} bytes, locator=${locator}]…\n`
|
|
93
|
+
const half = Math.max(0, Math.floor((previewChars - marker.length) / 2))
|
|
94
|
+
const head = text.slice(0, half)
|
|
95
|
+
const tail = half > 0 ? text.slice(text.length - half) : ''
|
|
96
|
+
return { preview: `${head}${marker}${tail}`, truncated: true }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Write verify output to `.claude/ship/spill/<runId>/<seq>-<kind>.log` and
|
|
101
|
+
* return a locator plus a bounded preview so callers never have to paste the
|
|
102
|
+
* full bytes into an agent-facing result.
|
|
103
|
+
*
|
|
104
|
+
* Never throws on its own account beyond what `mkdirSync`/`writeFileSync`
|
|
105
|
+
* raise for a genuinely unwritable tree — spill is only ever called from an
|
|
106
|
+
* impure CLI helper (task 3.3) that decides how to report that upward.
|
|
107
|
+
*
|
|
108
|
+
* The returned record carries `overThreshold`, computed by `shouldSpill` from
|
|
109
|
+
* `LIMITS.verifySpillBytes`, so the caller can report a step that spilled
|
|
110
|
+
* output it did not have to — and so the published threshold has a reader on
|
|
111
|
+
* the path it governs.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} root - repo root
|
|
114
|
+
* @param {{ runId: string, kind: string, bytes: string|Buffer, previewChars?: number }} opts
|
|
115
|
+
* @returns {{ locator: string, bytes: number, overThreshold: boolean, threshold: number,
|
|
116
|
+
* preview: string, truncated: boolean, sha256: string }}
|
|
117
|
+
*/
|
|
118
|
+
export function spillBytes(root, { runId, kind, bytes, previewChars } = {}) {
|
|
119
|
+
const runSeg = safeSegment(runId, 'unknown-run')
|
|
120
|
+
const kindSeg = safeSegment(kind, 'output')
|
|
121
|
+
const budget = Number.isInteger(previewChars)
|
|
122
|
+
? previewChars
|
|
123
|
+
: (LIMITS.verifyPreviewChars ?? DEFAULT_PREVIEW_CHARS)
|
|
124
|
+
|
|
125
|
+
const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(String(bytes ?? ''), 'utf8')
|
|
126
|
+
|
|
127
|
+
const dirRel = pathPosix.join(SPILL_DIR, runSeg)
|
|
128
|
+
const dirAbs = join(root, ...dirRel.split('/'))
|
|
129
|
+
mkdirSync(dirAbs, { recursive: true })
|
|
130
|
+
|
|
131
|
+
const seq = nextSeq(dirAbs)
|
|
132
|
+
const fileName = `${seq}-${kindSeg}.log`
|
|
133
|
+
const locator = pathPosix.join(dirRel, fileName)
|
|
134
|
+
const fileAbs = join(dirAbs, fileName)
|
|
135
|
+
|
|
136
|
+
writeFileSync(fileAbs, buf)
|
|
137
|
+
|
|
138
|
+
const sha256 = createHash('sha256').update(buf).digest('hex')
|
|
139
|
+
const { preview, truncated } = buildPreview(buf.toString('utf8'), buf.length, locator, budget)
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
locator,
|
|
143
|
+
bytes: buf.length,
|
|
144
|
+
overThreshold: shouldSpill(buf),
|
|
145
|
+
threshold: LIMITS.verifySpillBytes ?? 8192,
|
|
146
|
+
preview,
|
|
147
|
+
truncated,
|
|
148
|
+
sha256
|
|
149
|
+
}
|
|
150
|
+
}
|
package/lib/surface.mjs
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// Deterministic UI-surface classifier for a set of changed files.
|
|
2
|
+
//
|
|
3
|
+
// This question used to be answered twice, in English prose, by two skills that
|
|
4
|
+
// disagreed: the manual-test-plan skill asked the model to re-derive a tri-state
|
|
5
|
+
// UI-TESTABLE / UI-INDIRECT / NOT-UI-TESTABLE split on every run, while the
|
|
6
|
+
// devops review dimension made a looser, separate binary judgement about whether
|
|
7
|
+
// a change is "purely UI/frontend" and can be skipped. Same inputs, two
|
|
8
|
+
// hand-rolled answers, no reason for them to agree. This module replaces both
|
|
9
|
+
// prose implementations with one pure function so the tri-state and the devops
|
|
10
|
+
// skip decision are derived from the same rules, reproducibly, with no model
|
|
11
|
+
// call.
|
|
12
|
+
//
|
|
13
|
+
// Exposed to skills as `interlock surface --changed <files...>`.
|
|
14
|
+
//
|
|
15
|
+
// Path + extension logic only: no fs, no git, no agent, no I/O, no clock.
|
|
16
|
+
// Paths are repo-relative and POSIX-separated ('/').
|
|
17
|
+
|
|
18
|
+
export const UI_TESTABLE = 'UI-TESTABLE'
|
|
19
|
+
export const UI_INDIRECT = 'UI-INDIRECT'
|
|
20
|
+
export const NOT_UI_TESTABLE = 'NOT-UI-TESTABLE'
|
|
21
|
+
|
|
22
|
+
// --- rule tables ---------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
const TEST_DIRS = new Set(['__tests__', 'e2e', 'tests', 'cypress', 'playwright'])
|
|
25
|
+
const DOC_EXTS = new Set(['md', 'mdx', 'txt'])
|
|
26
|
+
const DOC_DIRS = new Set(['docs', 'openspec'])
|
|
27
|
+
const CONFIG_EXTS = new Set(['json', 'yml', 'yaml', 'toml', 'ini', 'lock'])
|
|
28
|
+
const TOOLING_DIRS = new Set(['scripts', 'tools'])
|
|
29
|
+
|
|
30
|
+
const UI_EXTS = new Set(['tsx', 'jsx', 'vue', 'svelte'])
|
|
31
|
+
const UI_DIRS = new Set(['app', 'pages', 'components', 'views', 'screens'])
|
|
32
|
+
const STYLE_EXTS = new Set(['css', 'scss', 'sass', 'less', 'styl'])
|
|
33
|
+
|
|
34
|
+
const STATE_DIRS = new Set(['hooks', 'store', 'stores', 'context', 'providers', 'reducers', 'selectors'])
|
|
35
|
+
const DATA_DIRS = new Set(['lib', 'services', 'models', 'schemas', 'db', 'prisma', 'graphql', 'resolvers', 'controllers', 'middleware'])
|
|
36
|
+
|
|
37
|
+
// Dependency manifests that make a change infra-relevant regardless of class.
|
|
38
|
+
const DEP_MANIFESTS = new Set(['package.json', 'requirements.txt', 'go.mod', 'cargo.toml'])
|
|
39
|
+
|
|
40
|
+
// --- helpers -------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
// Tolerate junk entries: non-strings become '', surrounding space is trimmed,
|
|
43
|
+
// and any number of leading './' segments are stripped.
|
|
44
|
+
function normalize(entry) {
|
|
45
|
+
if (typeof entry !== 'string') return ''
|
|
46
|
+
let p = entry.trim()
|
|
47
|
+
while (p.startsWith('./')) p = p.slice(2)
|
|
48
|
+
return p
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Split a normalized path once into the pieces every rule needs. Directory
|
|
52
|
+
// segments and the basename are compared lowercased so that Components/ and
|
|
53
|
+
// components/, Dockerfile and dockerfile, behave the same.
|
|
54
|
+
function parts(path) {
|
|
55
|
+
const segments = path.split('/').filter(Boolean)
|
|
56
|
+
const base = segments.length ? segments[segments.length - 1] : ''
|
|
57
|
+
const dirs = segments.slice(0, -1).map(s => s.toLowerCase())
|
|
58
|
+
const lower = base.toLowerCase()
|
|
59
|
+
const dot = lower.lastIndexOf('.')
|
|
60
|
+
const ext = dot > 0 ? lower.slice(dot + 1) : ''
|
|
61
|
+
return { dirs, lower, ext }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const notUI = (reason) => ({ class: NOT_UI_TESTABLE, reason, apiRoute: false })
|
|
65
|
+
const uiTestable = (reason) => ({ class: UI_TESTABLE, reason, apiRoute: false })
|
|
66
|
+
const uiIndirect = (reason, apiRoute = false) => ({ class: UI_INDIRECT, reason, apiRoute })
|
|
67
|
+
|
|
68
|
+
// Classify one path. Order is load-bearing: exclusions, then route/API
|
|
69
|
+
// handlers, then UI-TESTABLE, then UI-INDIRECT, then default.
|
|
70
|
+
function classifyOne(p) {
|
|
71
|
+
const { dirs, lower, ext } = p
|
|
72
|
+
const inDir = (set) => dirs.find(d => set.has(d))
|
|
73
|
+
|
|
74
|
+
// 1. NOT-UI-TESTABLE exclusions run first so that tests/, docs/ and tooling
|
|
75
|
+
// beat the UI rules — tests/components/Foo.tsx is a test, not a component.
|
|
76
|
+
if (/\.(test|spec)\./.test(lower)) return notUI('test file')
|
|
77
|
+
const testDir = inDir(TEST_DIRS)
|
|
78
|
+
if (testDir) return notUI(`test dir ${testDir}/`)
|
|
79
|
+
if (DOC_EXTS.has(ext)) return notUI('docs file')
|
|
80
|
+
const docDir = inDir(DOC_DIRS)
|
|
81
|
+
if (docDir) return notUI(`docs dir ${docDir}/`)
|
|
82
|
+
if (lower === 'license' || lower.startsWith('changelog')) return notUI('docs file')
|
|
83
|
+
|
|
84
|
+
// tailwind.config.* is the one config file with a user-visible effect, so it
|
|
85
|
+
// is carved out ahead of every config/tooling exclusion below.
|
|
86
|
+
const isTailwind = lower.startsWith('tailwind.config.')
|
|
87
|
+
if (!isTailwind) {
|
|
88
|
+
if (lower.startsWith('.') || dirs.some(d => d.startsWith('.'))) return notUI('dotfile or dot dir')
|
|
89
|
+
if (CONFIG_EXTS.has(ext)) return notUI('config file')
|
|
90
|
+
const toolDir = inDir(TOOLING_DIRS)
|
|
91
|
+
if (toolDir) return notUI(`tooling dir ${toolDir}/`)
|
|
92
|
+
if (lower.startsWith('dockerfile')) return notUI('container config')
|
|
93
|
+
if (lower === 'makefile') return notUI('build tooling')
|
|
94
|
+
if (/\.config\.[^.]+$/.test(lower)) return notUI('config file')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (lower === 'types.ts' || lower.endsWith('.d.ts')) return notUI('type-only file')
|
|
98
|
+
if (dirs.includes('types')) return notUI('type-only dir types/')
|
|
99
|
+
|
|
100
|
+
// 2. Request handlers, checked ahead of the UI rules: a file under app/ or
|
|
101
|
+
// pages/ that serves a request is reachable only via UI side-effects.
|
|
102
|
+
// Server actions belong here too — manual-test-plan names them explicitly
|
|
103
|
+
// as UI-INDIRECT, and review-code counts them toward the devops review
|
|
104
|
+
// alongside API routes ("no new API routes or Server Actions").
|
|
105
|
+
if (dirs.includes('api')) return uiIndirect('API route path', true)
|
|
106
|
+
if (lower === 'route.ts' || lower === 'route.js') return uiIndirect('route handler', true)
|
|
107
|
+
if (lower.startsWith('+server.')) return uiIndirect('server endpoint', true)
|
|
108
|
+
if (dirs.includes('actions') || dirs.includes('server-actions')) return uiIndirect('server actions', true)
|
|
109
|
+
if (lower === 'actions.ts' || lower === 'actions.js') return uiIndirect('server actions', true)
|
|
110
|
+
if (lower.endsWith('.server.ts')) return uiIndirect('server module', true)
|
|
111
|
+
|
|
112
|
+
// 3. UI-TESTABLE — directly user-visible surface.
|
|
113
|
+
if (UI_EXTS.has(ext)) return uiTestable(`.${ext} component`)
|
|
114
|
+
const uiDir = inDir(UI_DIRS)
|
|
115
|
+
if (uiDir) return uiTestable(`under ${uiDir}/`)
|
|
116
|
+
if (STYLE_EXTS.has(ext)) return uiTestable('style file')
|
|
117
|
+
if (isTailwind) return uiTestable('tailwind config')
|
|
118
|
+
|
|
119
|
+
// 4. UI-INDIRECT — reachable only through UI side-effects. Server actions and
|
|
120
|
+
// server modules are handled in rule 2 so they escape the UI directories;
|
|
121
|
+
// what remains here is state and data-layer code outside app/ and pages/.
|
|
122
|
+
const stateDir = inDir(STATE_DIRS)
|
|
123
|
+
if (stateDir) return uiIndirect(`under ${stateDir}/`)
|
|
124
|
+
const dataDir = inDir(DATA_DIRS)
|
|
125
|
+
if (dataDir) return uiIndirect(`under ${dataDir}/`)
|
|
126
|
+
|
|
127
|
+
// 5. Nothing matched — assume no user-visible effect.
|
|
128
|
+
return notUI('no UI surface match')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// review-devops' "purely UI/frontend, skip" rule, inverted: does this one file
|
|
132
|
+
// suggest infra impact? Independent of the tri-state class except that any
|
|
133
|
+
// API-route file counts.
|
|
134
|
+
function suggestsInfraImpact(p, record) {
|
|
135
|
+
const { dirs, lower, ext } = p
|
|
136
|
+
if (record.apiRoute) return true
|
|
137
|
+
if (DEP_MANIFESTS.has(lower)) return true
|
|
138
|
+
if (ext === 'lock') return true
|
|
139
|
+
if (lower.startsWith('.env')) return true
|
|
140
|
+
if (lower === '.gitlab-ci.yml') return true
|
|
141
|
+
if (lower.startsWith('dockerfile') || lower.startsWith('docker-compose')) return true
|
|
142
|
+
const gh = dirs.indexOf('.github')
|
|
143
|
+
if (gh !== -1 && dirs[gh + 1] === 'workflows') return true
|
|
144
|
+
return false
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// --- public API ----------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
// Classify an array of repo-relative path strings into the manual-test-plan
|
|
150
|
+
// tri-state, plus the rollups both callers need.
|
|
151
|
+
//
|
|
152
|
+
// Returns {
|
|
153
|
+
// files: [{ path, class, reason, infra }], // normalized path, one entry per usable input
|
|
154
|
+
// // `infra` is this file's own infra-impact
|
|
155
|
+
// // verdict — the per-file half of the
|
|
156
|
+
// // needsDevopsReview rollup, carried so a
|
|
157
|
+
// // caller naming the triggering paths does
|
|
158
|
+
// // not re-run the classifier per path
|
|
159
|
+
// hasUI, // any UI_TESTABLE or UI_INDIRECT
|
|
160
|
+
// uiCount, indirectCount, backendCount,
|
|
161
|
+
// needsManualTestPlan, // === hasUI
|
|
162
|
+
// needsDevopsReview // inverse of review-devops' skip rule
|
|
163
|
+
// }
|
|
164
|
+
export function classifySurface(changedFiles) {
|
|
165
|
+
const list = Array.isArray(changedFiles) ? changedFiles : []
|
|
166
|
+
const files = []
|
|
167
|
+
let uiCount = 0
|
|
168
|
+
let indirectCount = 0
|
|
169
|
+
let backendCount = 0
|
|
170
|
+
let needsDevopsReview = false
|
|
171
|
+
|
|
172
|
+
for (const entry of list) {
|
|
173
|
+
const path = normalize(entry)
|
|
174
|
+
if (!path) continue
|
|
175
|
+
const p = parts(path)
|
|
176
|
+
const record = classifyOne(p)
|
|
177
|
+
const infra = suggestsInfraImpact(p, record)
|
|
178
|
+
files.push({ path, class: record.class, reason: record.reason, infra })
|
|
179
|
+
if (record.class === UI_TESTABLE) uiCount += 1
|
|
180
|
+
else if (record.class === UI_INDIRECT) indirectCount += 1
|
|
181
|
+
else backendCount += 1
|
|
182
|
+
if (infra) needsDevopsReview = true
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const hasUI = uiCount > 0 || indirectCount > 0
|
|
186
|
+
return {
|
|
187
|
+
files,
|
|
188
|
+
hasUI,
|
|
189
|
+
uiCount,
|
|
190
|
+
indirectCount,
|
|
191
|
+
backendCount,
|
|
192
|
+
needsManualTestPlan: hasUI,
|
|
193
|
+
needsDevopsReview
|
|
194
|
+
}
|
|
195
|
+
}
|