@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,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
+ }
@@ -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
+ }