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