@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,570 @@
1
+ // Plan reuse: proof that a stored execution plan still describes the same work.
2
+ //
3
+ // `plan-waves` is the most expensive fixed step of a ship run — it reads
4
+ // proposal.md, design.md, tasks.md and every delta spec in full — and it used to
5
+ // re-run unconditionally, including on a run whose plan was already sitting in
6
+ // `.claude/ship/plan.json` from ten minutes earlier. That cost is only avoidable
7
+ // if a caller can PROVE the plan is still current, so this module computes a
8
+ // fingerprint over the plan's inputs and compares a stored one against a fresh
9
+ // one.
10
+ //
11
+ // Two properties are load-bearing, and both are about failing in the right
12
+ // direction:
13
+ //
14
+ // 1. Checkbox markers are normalized before hashing. Without that, the first
15
+ // `interlock tasks tick` invalidates the plan and reuse never fires after
16
+ // wave 1. Only the marker is normalized — every other byte of a task line
17
+ // is hashed as written, so adding, removing, reordering or rewording a task
18
+ // still invalidates.
19
+ //
20
+ // 2. Nothing here ever throws in a way a caller could read as a match. A
21
+ // missing plan, an unreadable fingerprint, a plan written for another
22
+ // format, an unparseable file, or any error at all resolves to a NAMED
23
+ // no-reuse outcome. The fallback is running the classifier, which is what
24
+ // happens today, so the degraded path costs exactly what the current path
25
+ // always costs — and never yields a wrong plan.
26
+ //
27
+ // Note the direction differs from `interlock ready`: there, failing closed means
28
+ // refusing to proceed. Here it means doing the expensive-but-correct thing,
29
+ // which is why reuse can default on without a flag.
30
+ //
31
+ // Exposed to skills as `interlock plan fingerprint` / `interlock plan reuse`.
32
+
33
+ import { createHash } from 'node:crypto'
34
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
35
+ import { dirname, join } from 'node:path'
36
+
37
+ import { inspectChange, normalizeTaskMarkers, taskMatchesId } from './artifacts.mjs'
38
+ import { LIMITS, LANE_CAPS, SOLO } from './limits.mjs'
39
+
40
+ /**
41
+ * The shape of the plan this reader understands.
42
+ *
43
+ * Separate from the content hash on purpose: a plan can be perfectly current
44
+ * and still be shaped for a reader that no longer exists. `/1` was the plan
45
+ * whose batches held tasks; `/2` is the plan whose batches hold lanes; `/3` is
46
+ * the plan that carries a mode and whose lanes may be cohesion folds. A stored
47
+ * plan carrying another version is not reused however well its inputs match.
48
+ *
49
+ * The bump is what forces a `/2` plan to be rebuilt rather than reused. A `/2`
50
+ * plan's inputs can match perfectly and it will still hold one agent per
51
+ * disjoint task and no mode at all — reusing it would silently keep the old
52
+ * shape while the run believed it had the new one.
53
+ */
54
+ export const PLAN_FORMAT = 'interlock.ship-plan/3'
55
+
56
+ /** Where the plan and its fingerprint live — machine-local and gitignored. */
57
+ export const PLAN_PATH = '.claude/ship/plan.json'
58
+ export const FINGERPRINT_PATH = '.claude/ship/plan-fingerprint.json'
59
+ /** Default destination for the narrowed copy, so the source is never clobbered. */
60
+ export const NARROWED_PLAN_PATH = '.claude/ship/plan-reuse.json'
61
+
62
+ // Every way this can end, named. A bare boolean would collapse "no plan yet"
63
+ // and "the plan changed under us" into one answer, and the run summary has to
64
+ // say which — per docs/06-why-it-works.md §13, a run that silently changed its
65
+ // own cost is the failure the banner block exists to remove.
66
+ export const REUSE_MATCH = 'match'
67
+ export const REUSE_MISMATCH = 'inputs-changed'
68
+ export const REUSE_NO_PLAN = 'no-plan'
69
+ export const REUSE_NO_FINGERPRINT = 'no-fingerprint'
70
+ export const REUSE_UNREADABLE_PLAN = 'unreadable-plan'
71
+ export const REUSE_UNREADABLE_FINGERPRINT = 'unreadable-fingerprint'
72
+ export const REUSE_FORMAT_VERSION = 'plan-format-changed'
73
+ export const REUSE_ERROR = 'check-failed'
74
+
75
+ /** The artifacts a plan is derived from, in the order they are hashed. */
76
+ const CORE_ARTIFACTS = ['proposal.md', 'design.md', 'tasks.md']
77
+
78
+ function sha256(text) {
79
+ return createHash('sha256').update(text, 'utf8').digest('hex')
80
+ }
81
+
82
+ function abs(root, relPosix) {
83
+ return join(root, ...relPosix.split('/'))
84
+ }
85
+
86
+ // tasks.md is hashed through the marker normalizer; everything else is hashed as
87
+ // written. A file that cannot be read is recorded with a null digest rather than
88
+ // skipped: "this file is gone" has to change the fingerprint, and skipping it
89
+ // would make a deleted spec look like a plan that still matches.
90
+ function digestOf(path, rel) {
91
+ try {
92
+ const raw = readFileSync(path, 'utf8')
93
+ const text = rel === 'tasks.md' ? normalizeTaskMarkers(raw) : raw
94
+ return { path: rel, sha256: sha256(text), bytes: Buffer.byteLength(raw, 'utf8') }
95
+ } catch (err) {
96
+ return { path: rel, sha256: null, bytes: null, unreadable: err.message }
97
+ }
98
+ }
99
+
100
+ /**
101
+ * The dependency edge set a plan was built over, as one canonical line.
102
+ *
103
+ * `dependsOn` is a structural ordering signal that the artifact digests cannot
104
+ * see: two classifications of one unedited `tasks.md` can differ only in their
105
+ * edges, and the resulting plans schedule different work in different batches.
106
+ * So a plan carrying edges is a different plan, and the fingerprint says so.
107
+ *
108
+ * Sorted, and empty edges are dropped, so the line is a pure function of the
109
+ * edge set rather than of the order the classifier happened to emit. A task
110
+ * list with no edges anywhere contributes NOTHING — not an empty line — which
111
+ * is what keeps a fingerprint over an edge-free change byte-identical to the
112
+ * one this module computed before edges existed.
113
+ *
114
+ * @param {unknown} tasks a classified task list, or `{tasks: [...]}`
115
+ * @returns {string|null} the canonical line, or null when there are no edges
116
+ */
117
+ function edgesOf(tasks) {
118
+ const list = Array.isArray(tasks)
119
+ ? tasks
120
+ : tasks && typeof tasks === 'object' && Array.isArray(tasks.tasks)
121
+ ? tasks.tasks
122
+ : []
123
+ const edges = []
124
+ for (const t of list) {
125
+ if (!t || typeof t !== 'object' || typeof t.id !== 'string') continue
126
+ if (!Array.isArray(t.dependsOn)) continue
127
+ const deps = [
128
+ ...new Set(t.dependsOn.filter(d => typeof d === 'string' && d.trim()).map(d => d.trim()))
129
+ ].sort()
130
+ if (deps.length) edges.push(`${t.id}<-${deps.join(',')}`)
131
+ }
132
+ if (!edges.length) return null
133
+ return `depends-on ${edges.sort().join(' ')}`
134
+ }
135
+
136
+ /**
137
+ * Every cap and switch a plan's SHAPE depends on, as opposed to its content.
138
+ *
139
+ * `mode` is the override in force, not the mode the plan came out as: the
140
+ * classifier's recommendation is an OUTPUT of planning, so hashing it would make
141
+ * the fingerprint depend on something no reuse check can recompute without
142
+ * running the classifier — which is the cost reuse exists to avoid. `auto`
143
+ * records "no override was passed", and it is a distinct value from both `solo`
144
+ * and `waves` because a plan built under `--waves` and one built under no flag
145
+ * differ the moment a classifier starts recommending solo.
146
+ */
147
+ function capsOf(opts = {}) {
148
+ const requested = opts.maxParallel
149
+ const mode = opts.mode === 'solo' || opts.mode === 'waves' ? opts.mode : 'auto'
150
+ // The red wave a plan was built under. `null` is "no TDD shape", which is
151
+ // what every plan before this existed was built under — see the canonical
152
+ // text below for why that case must not appear in the hash at all.
153
+ const redWave = Number.isInteger(opts.redWave) ? opts.redWave : null
154
+ return {
155
+ maxParallel:
156
+ Number.isInteger(requested) && requested > 0 ? requested : LIMITS.maxParallel,
157
+ mode,
158
+ redWave,
159
+ laneCaps: { ...LANE_CAPS.byTier },
160
+ cohesionMaxTier: LANE_CAPS.cohesionMaxTier,
161
+ soloMaxTasks: SOLO.maxTasks
162
+ }
163
+ }
164
+
165
+ /** `1:8 2:8 3:6 4:4 5:8` — the tier table as one stable, sorted line. */
166
+ function laneCapsLine(table) {
167
+ return Object.keys(table)
168
+ .map(Number)
169
+ .sort((a, b) => a - b)
170
+ .map(tier => `${tier}:${table[tier]}`)
171
+ .join(' ')
172
+ }
173
+
174
+ /**
175
+ * Fingerprint the inputs a plan for `change` would be derived from.
176
+ *
177
+ * Pure apart from reading the change directory. Never throws: an unreadable
178
+ * change resolves to `ok:false` with a reason, because the caller's only correct
179
+ * response to "we could not compute this" is to re-plan, and an exception makes
180
+ * that decision harder rather than easier.
181
+ *
182
+ * @param {string} root repo root
183
+ * @param {string} change OpenSpec change name
184
+ * @param {{maxParallel?: number}} [opts]
185
+ */
186
+ export function computeFingerprint(root = '.', change, opts = {}) {
187
+ const caps = capsOf(opts)
188
+ const base = {
189
+ planFormat: PLAN_FORMAT,
190
+ change: typeof change === 'string' ? change : null,
191
+ ...caps
192
+ }
193
+ try {
194
+ if (!base.change) {
195
+ return { ...base, ok: false, hash: null, inputs: [], problems: ['no change name given'] }
196
+ }
197
+ const info = inspectChange(root, base.change)
198
+ if (!info.exists) {
199
+ return {
200
+ ...base,
201
+ ok: false,
202
+ hash: null,
203
+ inputs: [],
204
+ problems: [`change directory does not exist: ${info.path}`]
205
+ }
206
+ }
207
+
208
+ const inputs = []
209
+ for (const rel of CORE_ARTIFACTS) inputs.push(digestOf(join(info.path, rel), rel))
210
+ // `specFiles` is already sorted by inspectChange, so the hash does not
211
+ // depend on directory-read order.
212
+ for (const rel of info.specFiles) {
213
+ inputs.push(digestOf(join(info.path, 'specs', rel), `specs/${rel}`))
214
+ }
215
+
216
+ // The canonical text the hash is taken over. Written out line by line rather
217
+ // than JSON.stringify'd so the ordering is visible here instead of implied
218
+ // by key insertion order somewhere else.
219
+ // Appended after the caps and before the digests, and OMITTED ENTIRELY when
220
+ // the task list carries no edges — a null here has to leave the canonical
221
+ // text exactly as it was, or every existing fingerprint would invalidate
222
+ // for a signal none of them use.
223
+ const edges = edgesOf(opts.tasks)
224
+ const canonical = [
225
+ `plan-format ${PLAN_FORMAT}`,
226
+ `change ${base.change}`,
227
+ `max-parallel ${caps.maxParallel}`,
228
+ `mode ${caps.mode}`,
229
+ `lane-caps ${laneCapsLine(caps.laneCaps)}`,
230
+ `cohesion-max-tier ${caps.cohesionMaxTier}`,
231
+ `solo-max-tasks ${caps.soloMaxTasks}`,
232
+ // OMITTED ENTIRELY when there is no red wave, for the same reason `edges`
233
+ // is: a plan built without a TDD shape must hash exactly as it did before
234
+ // this line existed, or every stored fingerprint in every consumer repo
235
+ // invalidates at once for a signal none of them use. Present, it MUST be
236
+ // hashed — a cached non-TDD plan reused for a --tdd run would run the
237
+ // failing suite last while the run believed it had the new shape.
238
+ ...(caps.redWave !== null ? [`red-wave ${caps.redWave}`] : []),
239
+ ...(edges ? [edges] : []),
240
+ ...inputs.map(i => `${i.path} ${i.sha256 === null ? 'ABSENT' : i.sha256}`)
241
+ ].join('\n')
242
+
243
+ const problems = inputs.filter(i => i.sha256 === null).map(i => `unreadable: ${i.path}`)
244
+ // Carried on the result as well as hashed into it, so a reader can see WHY
245
+ // two fingerprints over identical artifacts differ.
246
+ return { ...base, ok: true, hash: sha256(canonical), edges, inputs, problems }
247
+ } catch (err) {
248
+ return {
249
+ ...base,
250
+ ok: false,
251
+ hash: null,
252
+ inputs: [],
253
+ problems: [`could not fingerprint the change: ${err && err.message ? err.message : err}`]
254
+ }
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Store a fingerprint beside the plan it belongs to.
260
+ *
261
+ * Reported, never thrown: losing the fingerprint costs a classifier run on the
262
+ * next invocation, which is the price of the behaviour that exists today. It is
263
+ * not worth failing the run that just produced a valid plan.
264
+ */
265
+ export function writeFingerprint(root = '.', fingerprint, opts = {}) {
266
+ const rel = typeof opts.path === 'string' && opts.path.trim() ? opts.path.trim() : FINGERPRINT_PATH
267
+ const dest = abs(root, rel)
268
+ try {
269
+ mkdirSync(dirname(dest), { recursive: true })
270
+ writeFileSync(dest, JSON.stringify(fingerprint, null, 2) + '\n')
271
+ return { written: true, path: rel, reason: null }
272
+ } catch (err) {
273
+ return { written: false, path: rel, reason: err && err.message ? err.message : String(err) }
274
+ }
275
+ }
276
+
277
+ /** A plan whose batches hold LANES (arrays of tasks), which is format /2. */
278
+ function isLanePlan(plan) {
279
+ if (!plan || typeof plan !== 'object' || !Array.isArray(plan.waves)) return false
280
+ const waves = plan.testWave ? [...plan.waves, plan.testWave] : plan.waves
281
+ for (const wave of waves) {
282
+ if (!wave || !Array.isArray(wave.batches)) return false
283
+ for (const batch of wave.batches) {
284
+ if (!Array.isArray(batch)) return false
285
+ for (const lane of batch) {
286
+ if (!Array.isArray(lane)) return false
287
+ for (const t of lane) if (!t || typeof t !== 'object' || typeof t.id !== 'string') return false
288
+ }
289
+ }
290
+ }
291
+ return true
292
+ }
293
+
294
+ function noReuse(status, reason, extra = {}) {
295
+ return { reuse: false, status, reason, plan: null, ...extra }
296
+ }
297
+
298
+ /**
299
+ * Decide whether the stored plan for `change` may be reused.
300
+ *
301
+ * Returns `{reuse, status, reason, plan, stored, current}`. `status` is one of
302
+ * the REUSE_* constants above — a named outcome, never a bare boolean, so the
303
+ * summary can report WHY it re-planned and a caller cannot confuse "we could not
304
+ * tell" with "it matches".
305
+ */
306
+ export function checkPlanReuse(root = '.', change, opts = {}) {
307
+ const planRel = typeof opts.planPath === 'string' && opts.planPath.trim() ? opts.planPath.trim() : PLAN_PATH
308
+ const fpRel =
309
+ typeof opts.fingerprintPath === 'string' && opts.fingerprintPath.trim()
310
+ ? opts.fingerprintPath.trim()
311
+ : FINGERPRINT_PATH
312
+ const paths = { planPath: planRel, fingerprintPath: fpRel, expectedFormat: PLAN_FORMAT }
313
+
314
+ try {
315
+ const planAbs = abs(root, planRel)
316
+ const fpAbs = abs(root, fpRel)
317
+
318
+ if (!existsSync(planAbs)) {
319
+ return noReuse(REUSE_NO_PLAN, `no stored plan at ${planRel}`, paths)
320
+ }
321
+ if (!existsSync(fpAbs)) {
322
+ return noReuse(
323
+ REUSE_NO_FINGERPRINT,
324
+ `a plan exists at ${planRel} but no fingerprint at ${fpRel}, so its provenance is unknown`,
325
+ paths
326
+ )
327
+ }
328
+
329
+ let stored
330
+ try {
331
+ stored = JSON.parse(readFileSync(fpAbs, 'utf8'))
332
+ } catch (err) {
333
+ return noReuse(
334
+ REUSE_UNREADABLE_FINGERPRINT,
335
+ `the stored fingerprint at ${fpRel} could not be read: ${err.message}`,
336
+ paths
337
+ )
338
+ }
339
+ if (!stored || typeof stored !== 'object' || typeof stored.hash !== 'string') {
340
+ return noReuse(
341
+ REUSE_UNREADABLE_FINGERPRINT,
342
+ `the stored fingerprint at ${fpRel} carries no hash`,
343
+ paths
344
+ )
345
+ }
346
+
347
+ let plan
348
+ try {
349
+ plan = JSON.parse(readFileSync(planAbs, 'utf8'))
350
+ } catch (err) {
351
+ return noReuse(
352
+ REUSE_UNREADABLE_PLAN,
353
+ `the stored plan at ${planRel} could not be read: ${err.message}`,
354
+ paths
355
+ )
356
+ }
357
+
358
+ // Checked BEFORE the hash, and reported as its own outcome: a plan whose
359
+ // inputs match perfectly is still unusable if it is shaped for a reader that
360
+ // no longer exists.
361
+ if (stored.planFormat !== PLAN_FORMAT) {
362
+ return noReuse(
363
+ REUSE_FORMAT_VERSION,
364
+ `the stored plan was written for ${JSON.stringify(stored.planFormat)} and this run reads ` +
365
+ `${PLAN_FORMAT}`,
366
+ { ...paths, stored }
367
+ )
368
+ }
369
+ if (!isLanePlan(plan)) {
370
+ return noReuse(
371
+ REUSE_UNREADABLE_PLAN,
372
+ `the stored plan at ${planRel} does not hold lanes, so it is not a ${PLAN_FORMAT} plan`,
373
+ { ...paths, stored }
374
+ )
375
+ }
376
+
377
+ const current = computeFingerprint(root, change, opts)
378
+ if (!current.ok) {
379
+ return noReuse(
380
+ REUSE_ERROR,
381
+ `the current inputs could not be fingerprinted: ${current.problems.join('; ')}`,
382
+ { ...paths, stored, current }
383
+ )
384
+ }
385
+ if (typeof stored.change === 'string' && stored.change !== current.change) {
386
+ return noReuse(
387
+ REUSE_MISMATCH,
388
+ `the stored plan was built for change "${stored.change}", not "${current.change}"`,
389
+ { ...paths, stored, current }
390
+ )
391
+ }
392
+ if (stored.hash !== current.hash) {
393
+ return noReuse(
394
+ REUSE_MISMATCH,
395
+ `the change's artifacts have been edited since the plan was built`,
396
+ { ...paths, stored, current }
397
+ )
398
+ }
399
+
400
+ return {
401
+ reuse: true,
402
+ status: REUSE_MATCH,
403
+ reason: `the stored plan still matches every input it was built from`,
404
+ plan,
405
+ stored,
406
+ current,
407
+ ...paths
408
+ }
409
+ } catch (err) {
410
+ // The one branch that must never surface as a match: an unexpected error is
411
+ // a reason to re-plan, and it says so.
412
+ return noReuse(
413
+ REUSE_ERROR,
414
+ `the reuse check itself failed: ${err && err.message ? err.message : err}`,
415
+ paths
416
+ )
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Drop already-complete tasks from a plan, keeping the order of what is left.
422
+ *
423
+ * Narrowing is safe without re-planning: removing a completed task cannot
424
+ * introduce a collision, and lanes are already path-disjoint, so the remaining
425
+ * schedule holds. An emptied lane is DROPPED rather than dispatched — an agent
426
+ * given no tasks is a spawn that can only report nothing.
427
+ *
428
+ * @param {object} plan a lane-shaped plan
429
+ * @param {(taskId: string) => boolean} isComplete
430
+ */
431
+ export function narrowPlan(plan, isComplete) {
432
+ const done = typeof isComplete === 'function' ? isComplete : () => false
433
+ const dropped = []
434
+
435
+ const narrowWave = wave => {
436
+ if (!wave || !Array.isArray(wave.batches)) return null
437
+ const batches = []
438
+ for (const batch of wave.batches) {
439
+ const lanes = []
440
+ for (const lane of Array.isArray(batch) ? batch : []) {
441
+ const kept = (Array.isArray(lane) ? lane : []).filter(t => {
442
+ if (t && typeof t.id === 'string' && done(t.id)) {
443
+ dropped.push(t.id)
444
+ return false
445
+ }
446
+ return true
447
+ })
448
+ if (kept.length) lanes.push(kept)
449
+ }
450
+ if (lanes.length) batches.push(lanes)
451
+ }
452
+ if (!batches.length) return null
453
+ return {
454
+ ...wave,
455
+ batches,
456
+ taskCount: batches.reduce((n, b) => n + b.reduce((m, lane) => m + lane.length, 0), 0)
457
+ }
458
+ }
459
+
460
+ const waves = (Array.isArray(plan.waves) ? plan.waves : []).map(narrowWave).filter(Boolean)
461
+ const testWave = plan.testWave ? narrowWave(plan.testWave) : null
462
+
463
+ const all = testWave ? [...waves, testWave] : waves
464
+ const laneCount = all.reduce((n, w) => n + w.batches.reduce((m, b) => m + b.length, 0), 0)
465
+ const taskCount = all.reduce((n, w) => n + w.taskCount, 0)
466
+
467
+ return {
468
+ plan: {
469
+ ...plan,
470
+ waves,
471
+ testWave,
472
+ waveCount: waves.length,
473
+ laneCount,
474
+ totalTasks: taskCount
475
+ },
476
+ dropped,
477
+ waveCount: waves.length,
478
+ laneCount,
479
+ taskCount,
480
+ // Every task in the plan was already complete. That is not an empty run to
481
+ // dispatch; it is a change with no remaining work, and the caller has to be
482
+ // able to tell the difference.
483
+ noRemainingWork: taskCount === 0
484
+ }
485
+ }
486
+
487
+ /**
488
+ * The text of every ticked checkbox in a change's tasks.md.
489
+ *
490
+ * Text, not ids: a checkbox line is `1.1 Do the thing`, and deciding whether a
491
+ * plan's task id matches one is `taskMatchesId`'s job — the same reader
492
+ * `interlock tasks tick` uses, so reuse and ticking cannot disagree about what
493
+ * "already done" means.
494
+ */
495
+ export function completedTaskLines(root = '.', change) {
496
+ try {
497
+ const info = inspectChange(root, change)
498
+ return info.tasks.items.filter(t => t.done).map(t => t.text)
499
+ } catch {
500
+ // Unreadable tasks.md: nothing is known to be complete, so nothing is
501
+ // dropped. Re-running a finished task is wasteful; skipping an unfinished
502
+ // one is a silently unimplemented task, so this errs the survivable way.
503
+ return []
504
+ }
505
+ }
506
+
507
+ /**
508
+ * Check, then narrow. What `ship` actually calls.
509
+ *
510
+ * @returns {object} the check result plus `narrowed` when the plan was reused
511
+ */
512
+ export function resolvePlanReuse(root = '.', change, opts = {}) {
513
+ const check = checkPlanReuse(root, change, opts)
514
+ if (!check.reuse) return { ...check, narrowed: null, noRemainingWork: false }
515
+
516
+ const completedText = completedTaskLines(root, change)
517
+ const isComplete = id => completedText.some(text => taskMatchesId(text, id))
518
+ const narrowed = narrowPlan(check.plan, isComplete)
519
+ return {
520
+ ...check,
521
+ narrowed: narrowed.plan,
522
+ droppedTaskIds: narrowed.dropped,
523
+ waveCount: narrowed.waveCount,
524
+ laneCount: narrowed.laneCount,
525
+ taskCount: narrowed.taskCount,
526
+ noRemainingWork: narrowed.noRemainingWork
527
+ }
528
+ }
529
+
530
+ /** Human-readable outcome, for the skill and the run summary to echo. */
531
+ export function formatPlanReuse(result) {
532
+ const lines = []
533
+ lines.push(
534
+ result.reuse
535
+ ? `PLAN REUSED — ${result.reason}`
536
+ : `PLAN REBUILT (${result.status}) — ${result.reason}`
537
+ )
538
+ if (result.reuse) {
539
+ lines.push(
540
+ ` ${result.taskCount ?? '?'} task(s) in ${result.laneCount ?? '?'} lane(s) across ` +
541
+ `${result.waveCount ?? '?'} wave(s) remain`
542
+ )
543
+ if (result.droppedTaskIds && result.droppedTaskIds.length) {
544
+ lines.push(` already complete, dropped: ${result.droppedTaskIds.join(', ')}`)
545
+ }
546
+ if (result.noRemainingWork) lines.push(' NO REMAINING WORK — every task in the plan is complete')
547
+ }
548
+ return lines.join('\n') + '\n'
549
+ }
550
+
551
+ /** Human-readable fingerprint dump. */
552
+ export function formatFingerprint(fp) {
553
+ const lines = []
554
+ lines.push(fp.ok ? `fingerprint ${fp.hash}` : `NOT FINGERPRINTED — ${fp.problems.join('; ')}`)
555
+ lines.push(` format ${fp.planFormat}, change ${fp.change}`)
556
+ lines.push(
557
+ ` maxParallel ${fp.maxParallel}, mode ${fp.mode}` +
558
+ (fp.redWave != null ? `, red wave ${fp.redWave}` : '')
559
+ )
560
+ lines.push(
561
+ ` lane-caps ${laneCapsLine(fp.laneCaps || LANE_CAPS.byTier)}, ` +
562
+ `cohesion-max-tier ${fp.cohesionMaxTier}, solo-max-tasks ${fp.soloMaxTasks}`
563
+ )
564
+ if (fp.edges) lines.push(` ${fp.edges}`)
565
+ for (const i of fp.inputs || []) {
566
+ lines.push(` ${i.sha256 === null ? 'ABSENT ' : i.sha256.slice(0, 12)} ${i.path}`)
567
+ }
568
+ for (const p of fp.problems || []) lines.push(` problem: ${p}`)
569
+ return lines.join('\n') + '\n'
570
+ }
@@ -0,0 +1,18 @@
1
+ // Mirrors the slug rule Claude Code's CLI uses under `~/.claude/projects` to
2
+ // name a project's directory from its absolute path. Observed mappings:
3
+ // `/` -> `-` (path separator)
4
+ // `.` -> `-` (e.g. `.claude` -> `-claude`)
5
+ // ` ` -> `-` (space, e.g. `Application Support`)
6
+ // `_` -> `-` (e.g. `w_1` -> `w-1`)
7
+ // Generalized: every character outside `[A-Za-z0-9]` becomes `-`.
8
+
9
+ /**
10
+ * @param {string} absPath
11
+ * @returns {string}
12
+ */
13
+ export function projectSlug(absPath) {
14
+ if (typeof absPath !== 'string') {
15
+ throw new TypeError('projectSlug expects a string path');
16
+ }
17
+ return absPath.replace(/[^A-Za-z0-9]/g, '-');
18
+ }
@@ -0,0 +1,26 @@
1
+ // The commit briefing — the lean form.
2
+ //
3
+ // The Workflow driver appended an autonomy-ladder record under `--strict`. That
4
+ // belongs to the strict tail, which this change does not move (design D6), and
5
+ // the lean form never carried it. So this is the lean text alone, and the tail's
6
+ // own briefing gains the ladder line when `emit-strict-tail-from-cli` moves it.
7
+
8
+ import { publishStageLine } from './stage.mjs'
9
+
10
+ /**
11
+ * @param {object} input
12
+ * @param {string} input.change
13
+ * @param {number} input.stageIndex the run's monotonic marker write count
14
+ * @returns {string}
15
+ */
16
+ export function assembleCommitPrompt({ change, stageIndex }) {
17
+ return (
18
+ `Commit change "${change}" as ONE feature-level commit.\n\n` +
19
+ // The commit stage is the ONE stage guard-commit lets `git commit` through.
20
+ // Publishing it first is what unblocks the commit below.
21
+ publishStageLine('commit', change, stageIndex) +
22
+ `Read the change artifacts and write a verb-phrase conventional-commit message with a short ` +
23
+ `outcome summary. Stage only the files this run touched — never \`git add -A\`, never amend, ` +
24
+ `never push.`
25
+ )
26
+ }
@@ -0,0 +1,70 @@
1
+ // Which review dimensions run, decided by a rule instead of a reviewer's
2
+ // judgement (design D4).
3
+ //
4
+ // The retired text asked the review agent to decide for itself: "devops when
5
+ // the diff touches deploy, config or infrastructure; security when it
6
+ // touches auth, input handling or data exposure" (`workflows/ship.js:2066-
7
+ // 2068`). That question has a deterministic answer the run already computes
8
+ // for its risk class — `lib/surface.mjs` classifies exactly "does this path
9
+ // suggest infra impact" (its `needsDevopsReview`, the review-devops skip
10
+ // rule inverted) and `lib/risk.mjs` classifies exactly "does this path touch
11
+ // auth/session/permissions/tenancy" (its `auth-session-permissions-tenancy`
12
+ // signal). This module calls those two classifiers rather than re-deriving
13
+ // their regexes, so the two places this question is answered cannot drift
14
+ // apart.
15
+ //
16
+ // `security`'s signal only covers authentication/permissions/tenancy paths —
17
+ // `lib/risk.mjs` has no separate signal for input-handling or data-exposure
18
+ // paths distinct from that one, and this module does not add one of its own;
19
+ // widening what counts belongs in `lib/risk.mjs`, where every other severity
20
+ // signal is named and tested, not duplicated here.
21
+ //
22
+ // Pure: no fs, no agent, no I/O, no clock.
23
+
24
+ import { classifySurface } from '../surface.mjs'
25
+ import { classifyRisk } from '../risk.mjs'
26
+
27
+ /** The four dimensions run every time, in the order they are dispatched. */
28
+ export const ALWAYS_ON_DIMENSIONS = Object.freeze(['language', 'architecture', 'qa', 'technical-lead'])
29
+
30
+ function normalizePaths(changedPaths) {
31
+ return Array.isArray(changedPaths) ? changedPaths.filter(p => typeof p === 'string' && p.trim()) : []
32
+ }
33
+
34
+ /**
35
+ * Select the review dimensions for one run's observed changed paths.
36
+ *
37
+ * @param {string[]} changedPaths
38
+ * @returns {{dimensions: string[], reasons: Record<string, string>}}
39
+ * `dimensions` always starts with the four always-on names, in order, then
40
+ * `devops` and/or `security` when their signal fired. `reasons` carries one
41
+ * entry per optional dimension actually added — never for the always-on
42
+ * four, which need no justification.
43
+ */
44
+ export function selectDimensions(changedPaths) {
45
+ const paths = normalizePaths(changedPaths)
46
+ const dimensions = [...ALWAYS_ON_DIMENSIONS]
47
+ const reasons = {}
48
+
49
+ const surface = classifySurface(paths)
50
+ if (surface.needsDevopsReview) {
51
+ dimensions.push('devops')
52
+ // Which paths, individually, drove the rollup — read off the single
53
+ // classifier pass that already computed each file's own verdict. Asking the
54
+ // classifier again per path would be a second place the answer could
55
+ // diverge, which is what this module's header says it exists to prevent.
56
+ const triggers = surface.files.filter(f => f.infra).map(f => f.path)
57
+ reasons.devops = triggers.length
58
+ ? `deploy/CI/config/infrastructure path(s): ${triggers.join(', ')}`
59
+ : 'a changed path suggests infrastructure impact'
60
+ }
61
+
62
+ const risk = classifyRisk({ changedPaths: paths })
63
+ const authSignal = risk.signals.find(s => s.signal === 'auth-session-permissions-tenancy')
64
+ if (authSignal) {
65
+ dimensions.push('security')
66
+ reasons.security = `authentication/permissions/tenancy path(s): ${authSignal.evidence}`
67
+ }
68
+
69
+ return { dimensions, reasons }
70
+ }