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