@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,275 @@
1
+ // Remediation policy: what gets fixed, what gets deferred, and when to stop.
2
+ //
3
+ // A review produces findings; something has to decide which of them a fixer
4
+ // agent is allowed to touch, how those fixers may be fanned out, and at what
5
+ // point the loop stops trying. None of that is judgement — it is policy — so it
6
+ // lives here rather than being re-argued in prose on every run:
7
+ //
8
+ // 1. Blockers are always fixed; suggestions are always deferred. A model
9
+ // asked to triage its own review will negotiate with itself ("this
10
+ // blocker is really a suggestion"), which is exactly why the routing is
11
+ // not left to it. Warnings are fixed alongside blockers — they are cheap
12
+ // to fix while a fixer is already in the file — but an unresolved warning
13
+ // is never a halt reason. Only a blocker can stop a run.
14
+ //
15
+ // 2. Fixers are grouped by file. Two agents editing one file concurrently
16
+ // lose each other's edits, so `byFile` groups (disjoint by construction)
17
+ // are the parallel-safe partition and the unscoped group — findings with
18
+ // no single owning file — is applied last, sequentially, on top of them.
19
+ //
20
+ // 3. Rounds are capped at `LIMITS.remediationRounds`. Each round is
21
+ // fix → re-review, and the re-review is scoped to the dimensions that
22
+ // actually raised findings; re-running a dimension that was already clean
23
+ // is pure token cost.
24
+ //
25
+ // 4. Blockers still standing when the round budget is spent halt the run.
26
+ // Halting is returned as a boolean and a reason, not as advice, because a
27
+ // caller that can read the halt as a suggestion will talk itself past it.
28
+ // The verdict is its own round (`cap + 1`) rather than a flag on the last
29
+ // fix round — see `planRemediation`.
30
+ //
31
+ // 5. Nothing is dropped silently. A finding this module declines to fix is
32
+ // recorded in `deferred` with the reason it was declined, including the
33
+ // ones the tolerance band scored away.
34
+ //
35
+ // This module is a decision function, not a runner: state in, plan out. The
36
+ // dynamic-workflow runtime cannot import modules and has no filesystem or
37
+ // shell of its own, so its agents reach this policy by shelling out to
38
+ // `interlock remediate` and acting on the JSON. There is deliberately no loop,
39
+ // no agent callback and no I/O here.
40
+ //
41
+ // Pure: no fs, no agent, no I/O, no clock.
42
+ // Exposed to skills as `interlock remediate --findings <file> --round <n>`.
43
+
44
+ import { applyToleranceBand, groupByFile, TOLERANCE_BAND } from './findings.mjs'
45
+ import { LIMITS } from './limits.mjs'
46
+
47
+ function fail(message) {
48
+ const err = new Error(message)
49
+ err.userFacing = true
50
+ throw err
51
+ }
52
+
53
+ /** Severities a fixer agent is allowed to act on, in priority order. */
54
+ export const FIXABLE_SEVERITIES = ['blocker', 'warning']
55
+
56
+ /** Severities policy always defers, however the reviewer argues. */
57
+ export const DEFERRED_SEVERITIES = ['suggestion']
58
+
59
+ const DEFER_REASONS = {
60
+ suggestion:
61
+ 'suggestion — deferred by policy; a suggestion is never fixed inside a remediation round',
62
+ unknownSeverity: severity =>
63
+ `unrecognized severity ${severity} — deferred for human triage rather than guessed at`,
64
+ quality: min => `scored below the reporting quality band (minQualityToReport=${min})`,
65
+ budgetSpent: cap =>
66
+ `remediation budget spent — ${cap} fix round(s) used, no round remains to fix this`
67
+ }
68
+
69
+ // Blocker and warning are fixed; suggestion is deferred; anything else is
70
+ // deferred rather than guessed at. Returning a reason for every non-fix keeps
71
+ // requirement 5 (nothing disappears) structural rather than remembered.
72
+ function route(finding) {
73
+ const severity = finding.severity
74
+ if (FIXABLE_SEVERITIES.includes(severity)) return { fix: true, reason: null }
75
+ if (DEFERRED_SEVERITIES.includes(severity)) {
76
+ return { fix: false, reason: DEFER_REASONS.suggestion }
77
+ }
78
+ const shown = typeof severity === 'string' && severity.trim() ? `"${severity}"` : '(missing)'
79
+ return { fix: false, reason: DEFER_REASONS.unknownSeverity(shown) }
80
+ }
81
+
82
+ function deferralOf(finding, reason) {
83
+ return {
84
+ title: typeof finding.title === 'string' ? finding.title : null,
85
+ file: typeof finding.file === 'string' ? finding.file : null,
86
+ severity: typeof finding.severity === 'string' ? finding.severity : null,
87
+ dimension: typeof finding.dimension === 'string' ? finding.dimension : null,
88
+ reason
89
+ }
90
+ }
91
+
92
+ function validateRound(round, cap) {
93
+ if (!Number.isInteger(round) || round < 1) {
94
+ fail(`remediation round must be a positive integer (got ${JSON.stringify(round)})`)
95
+ }
96
+ if (round > cap + 1) {
97
+ fail(
98
+ `remediation round ${round} exceeds the cap: rounds 1-${cap} are fix passes ` +
99
+ `and round ${cap + 1} is the verdict`
100
+ )
101
+ }
102
+ }
103
+
104
+ function roundCapOf(limits) {
105
+ const cap = limits && limits.remediationRounds
106
+ if (!Number.isInteger(cap) || cap < 1) {
107
+ fail('limits.remediationRounds must be a positive integer')
108
+ }
109
+ return cap
110
+ }
111
+
112
+ /**
113
+ * Decide what one remediation round does.
114
+ *
115
+ * `findings` accepts every shape `evaluateGate` accepts — a bare array of
116
+ * findings, a single `{ dimension, findings }` object, or an array of those.
117
+ * Flattening and dimension-stamping are borrowed from `applyToleranceBand`
118
+ * rather than reimplemented, so the two modules cannot disagree about what a
119
+ * finding is. Degenerate input yields an empty plan, not an error: a review
120
+ * that found nothing is a normal outcome.
121
+ *
122
+ * Rounds `1 .. cap` are fix passes and never halt: surviving blockers at round
123
+ * 1 of 2 are expected, and surviving blockers at round 2 of 2 have not yet been
124
+ * given their fix pass. Round `cap + 1` is the verdict, made after the final
125
+ * re-review; it plans no fixes and halts iff blockers remain.
126
+ *
127
+ * The verdict is a separate round rather than a flag on the last fix round for
128
+ * two reasons. A halt raised alongside a fix plan makes that fix pass
129
+ * decorative — the caller can read the verdict early and skip the work that
130
+ * would have cleared it. And keeping them apart makes "the budget is spent" a
131
+ * fact about the input (`round === cap + 1`) rather than a judgement the caller
132
+ * makes about a plan it was handed.
133
+ *
134
+ * @param {*} findings review output in any accepted shape
135
+ * @param {{round?: number, limits?: object, band?: object|null}} [opts]
136
+ * `round` is 1-based and runs to `limits.remediationRounds + 1`; the last
137
+ * value is the verdict round. `band` overrides the tolerance band the way
138
+ * `evaluateGate` does; `null` disables it.
139
+ * @returns {{
140
+ * round: number, roundCap: number,
141
+ * isFinalFixRound: boolean, isFinalRound: boolean,
142
+ * fix: {byFile: Array<{file: string, findings: Array}>, unscoped: Array},
143
+ * deferred: Array<{title: string|null, file: string|null, severity: string|null,
144
+ * dimension: string|null, reason: string}>,
145
+ * reReviewDimensions: string[],
146
+ * halt: boolean, haltReason: string|null,
147
+ * counts: {blockers: number, warnings: number, suggestions: number,
148
+ * fixing: number, deferring: number}
149
+ * }}
150
+ */
151
+ export function planRemediation(findings, opts = {}) {
152
+ const limits = opts.limits === undefined ? LIMITS : opts.limits
153
+ const roundCap = roundCapOf(limits)
154
+ const round = opts.round === undefined ? 1 : opts.round
155
+ validateRound(round, roundCap)
156
+
157
+ // The verdict round reads the final re-review and reports; it never plans
158
+ // work, because there is no round left to do it in.
159
+ const isVerdictRound = round === roundCap + 1
160
+
161
+ const band = opts.band === undefined ? TOLERANCE_BAND : opts.band
162
+ const { reportable, dropped } = applyToleranceBand(findings, band)
163
+
164
+ const deferred = []
165
+
166
+ // Findings the band scored away are still findings someone raised, so they
167
+ // are deferred with a reason rather than vanishing between the gate and here.
168
+ const bandFloor = band ? band.minQualityToReport : null
169
+ for (const f of dropped) deferred.push(deferralOf(f, DEFER_REASONS.quality(bandFloor)))
170
+
171
+ // Copy before partitioning: the caller's finding objects must survive this
172
+ // call untouched, and the plan is handed to fixer agents that may annotate it.
173
+ const fixing = []
174
+ const counts = { blockers: 0, warnings: 0, suggestions: 0, fixing: 0, deferring: 0 }
175
+ for (const f of reportable) {
176
+ if (f.severity === 'blocker') counts.blockers += 1
177
+ else if (f.severity === 'warning') counts.warnings += 1
178
+ else if (f.severity === 'suggestion') counts.suggestions += 1
179
+
180
+ const verdict = route(f)
181
+ // At the verdict round even a fixable finding goes unfixed — so it is
182
+ // recorded with that as its reason rather than disappearing from the plan.
183
+ if (verdict.fix && isVerdictRound) {
184
+ deferred.push(deferralOf(f, DEFER_REASONS.budgetSpent(roundCap)))
185
+ } else if (verdict.fix) {
186
+ fixing.push({ ...f })
187
+ } else {
188
+ deferred.push(deferralOf(f, verdict.reason))
189
+ }
190
+ }
191
+ counts.fixing = fixing.length
192
+ counts.deferring = deferred.length
193
+
194
+ const { byFile, unscoped } = groupByFile(fixing)
195
+
196
+ // Only dimensions that raised something we are about to change are worth
197
+ // re-running. Nothing to fix means nothing to re-review.
198
+ const dimensions = new Set()
199
+ for (const f of fixing) {
200
+ if (typeof f.dimension === 'string' && f.dimension.trim()) dimensions.add(f.dimension)
201
+ }
202
+ const reReviewDimensions = [...dimensions].sort()
203
+
204
+ const halt = isVerdictRound && counts.blockers > 0
205
+ const haltReason = halt
206
+ ? `${counts.blockers} unresolved blocker(s) after ${roundCap} remediation round(s)`
207
+ : null
208
+
209
+ return {
210
+ round,
211
+ roundCap,
212
+ /** The last round that still fixes anything. */
213
+ isFinalFixRound: round === roundCap,
214
+ /** The verdict round — the last round of the loop, and the only one that halts. */
215
+ isFinalRound: isVerdictRound,
216
+ // byFile groups are disjoint, so every group may be fixed by its own agent
217
+ // in parallel; unscoped has no owning file and is applied last, in sequence.
218
+ fix: { byFile, unscoped },
219
+ deferred,
220
+ reReviewDimensions,
221
+ halt,
222
+ haltReason,
223
+ counts
224
+ }
225
+ }
226
+
227
+ /** Human-readable remediation plan, for the skill to echo before it fans out. */
228
+ export function formatRemediationPlan(plan) {
229
+ const lines = []
230
+ // The verdict round is a report, not a plan; rendering it as "0 to fix" would
231
+ // read like a fix round that found nothing left to do.
232
+ lines.push(
233
+ plan.isFinalRound
234
+ ? `REMEDIATION VERDICT after ${plan.roundCap} round(s) — ` +
235
+ (plan.halt
236
+ ? `${plan.counts.blockers} blocker(s) unresolved`
237
+ : 'clean, no unresolved blockers')
238
+ : `REMEDIATION round ${plan.round}/${plan.roundCap}` +
239
+ (plan.isFinalFixRound ? ' (last fix pass)' : '') +
240
+ ` — ${plan.counts.fixing} to fix, ${plan.counts.deferring} deferred`
241
+ )
242
+ lines.push(
243
+ ` blocker=${plan.counts.blockers} warning=${plan.counts.warnings} ` +
244
+ `suggestion=${plan.counts.suggestions}`
245
+ )
246
+
247
+ if (plan.fix.byFile.length) {
248
+ lines.push(` parallel — one fixer per file, ${plan.fix.byFile.length} group(s):`)
249
+ for (const g of plan.fix.byFile) {
250
+ lines.push(` ${g.file} — ${g.findings.length} finding(s)`)
251
+ }
252
+ }
253
+ if (plan.fix.unscoped.length) {
254
+ lines.push(` sequential — ${plan.fix.unscoped.length} unscoped finding(s), applied last`)
255
+ }
256
+ if (!plan.isFinalRound && !plan.fix.byFile.length && !plan.fix.unscoped.length) {
257
+ lines.push(' nothing to fix')
258
+ }
259
+
260
+ if (plan.reReviewDimensions.length) {
261
+ lines.push(` re-review dimensions: ${plan.reReviewDimensions.join(', ')}`)
262
+ } else if (plan.counts.fixing) {
263
+ lines.push(' re-review dimensions: (unlabelled — re-run the review that raised these)')
264
+ }
265
+
266
+ if (plan.deferred.length) {
267
+ lines.push(` deferred (${plan.deferred.length}):`)
268
+ for (const d of plan.deferred) {
269
+ lines.push(` [${d.severity || 'unknown'}] ${d.file || '(unscoped)'} — ${d.title || '(untitled)'}: ${d.reason}`)
270
+ }
271
+ }
272
+
273
+ if (plan.halt) lines.push(` HALT: ${plan.haltReason}`)
274
+ return lines.join('\n') + '\n'
275
+ }