@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
package/lib/limits.mjs ADDED
@@ -0,0 +1,525 @@
1
+ // Every cap the ship loop obeys, in one place.
2
+ //
3
+ // These numbers used to live in `skills/ship/SKILL.md` as prose — "cap two
4
+ // attempts", "cap five root-cause iterations", "more than two task failures".
5
+ // Prose caps drift: a number written in one heading gets restated differently
6
+ // three headings later, and nothing catches it. Worse, a model reading a cap in
7
+ // prose treats it as guidance, and the whole point of a cap is that it is not.
8
+ //
9
+ // So the loop engines read them from here, the CLI prints them, and the skills
10
+ // cite the CLI rather than restating the number. When a cap changes it changes
11
+ // once.
12
+ //
13
+ // Pure: no fs, no agent, no I/O.
14
+ // Exposed to skills as `interlock limits [--json]`.
15
+
16
+ /**
17
+ * Ceilings that come from the dynamic-workflow runtime rather than from
18
+ * Interlock policy. The runtime caps concurrency at 16 and total agents at 1000
19
+ * per run; exceeding either is the runtime's error, not ours, so the planner
20
+ * clamps below them rather than discovering them the hard way.
21
+ * @see https://code.claude.com/docs/en/workflows
22
+ */
23
+ export const RUNTIME = {
24
+ maxConcurrentAgents: 16,
25
+ maxAgentsPerRun: 1000
26
+ }
27
+
28
+ export const LIMITS = {
29
+ /** Agents spawned per batch. Waves wider than this are split. */
30
+ maxParallel: 8,
31
+
32
+ // `maxTasksPerAgent` — the single scalar lane cap — was REMOVED here rather
33
+ // than aliased to the tier table that replaced it (`LANE_CAPS.byTier` below).
34
+ // It had six readers: the planner, `makeWave`, `createRunState`, the plan
35
+ // fingerprint's canonical text, `formatFingerprint` and `formatLimits`. A
36
+ // deprecated alias would have let any one of them keep reading a scalar while
37
+ // the rest read the table, and the two would disagree silently about how long
38
+ // a lane may get — which is the drift this module exists to prevent. Removing
39
+ // it makes the sweep enforceable instead: a stale reader fails at import.
40
+
41
+ /**
42
+ * Targeted fix attempts after a failed inter-wave check, per wave. After
43
+ * this the errors are logged and the run either halts (if they block the
44
+ * next wave) or continues with a warning.
45
+ */
46
+ interWaveFixAttempts: 2,
47
+
48
+ /** Revisions of not-yet-executed wave groups, per run. */
49
+ replansPerRun: 2,
50
+
51
+ /** Review → fix → re-review cycles. Surviving blockers after this halt. */
52
+ remediationRounds: 2,
53
+
54
+ /**
55
+ * Root-cause repair iterations against a red unit suite, across the whole
56
+ * run. Repairing by root cause is slow by design; this bounds it before the
57
+ * run turns into an open-ended debugging session.
58
+ */
59
+ rootCauseIterations: 5,
60
+
61
+ /**
62
+ * Task failures tolerated across all waves. Strictly more than this halts —
63
+ * a run losing three tasks is not producing a coherent change.
64
+ */
65
+ taskFailureHalt: 2,
66
+
67
+ /**
68
+ * Steps one run of the ship program may take before it halts. A step is one
69
+ * CLI call that yields a step record; a run that has taken this many without
70
+ * reaching a terminal state is not converging.
71
+ *
72
+ * This was `MAX_LOOP_STEPS`, a literal restated in both drivers — a loop bound
73
+ * the cap-authority spec forbids stating anywhere but here. It is enforced by
74
+ * `lib/run.mjs`, which counts on the run manifest and returns a `halt` step
75
+ * naming this cap once it is exceeded. A driver's own `RUNAWAY_BACKSTOP` is a
76
+ * different thing: the host runtime's agent ceiling, not a policy cap.
77
+ */
78
+ maxRunSteps: 200,
79
+
80
+ // `memoryEntriesPerRun` was removed here rather than wired. It had no reader
81
+ // anywhere outside this file and its own value-pinning test, and memory
82
+ // writing is not implemented as a counted operation — so wiring it would have
83
+ // meant inventing an enforcement point to justify a number. A cap this module
84
+ // prints but nothing obeys is the same failure as a cap written in prose,
85
+ // which is the failure this module exists to end.
86
+
87
+ /**
88
+ * Soft budget for inter-wave verification. Past this, drop to typecheck only
89
+ * rather than letting the checks outweigh the work they guard.
90
+ */
91
+ interWaveVerifyBudgetMs: 60_000,
92
+
93
+ /**
94
+ * Combined stdout/stderr byte threshold past which a verify step's output is
95
+ * spilled to disk (`lib/spill.mjs`) instead of returned inline. See
96
+ * add-ship-run-inspectability design.md §3.
97
+ */
98
+ verifySpillBytes: 8192,
99
+
100
+ /**
101
+ * Character budget for a spilled step's head-and-tail preview, and the
102
+ * ceiling every other verify-result text field must stay under. A result
103
+ * field larger than this is rejected as an oversized-result leak rather than
104
+ * judged.
105
+ */
106
+ verifyPreviewChars: 4096,
107
+
108
+ /**
109
+ * Inter-wave verification checkpoints allowed per run. A wave boundary is
110
+ * ordering; a checkpoint is an agent. Ordering is free, so the cap is on
111
+ * checkpoints, not on waves. Docs-only waves skip without consuming a slot.
112
+ */
113
+ interWaveVerifications: 3,
114
+
115
+ /**
116
+ * Character budget for one task's wave handoff — `summary`, `next`,
117
+ * `blocker` and the joined `evidence` locators added together. A packet over
118
+ * this fails its task rather than being truncated: silent truncation is the
119
+ * degradation the schema exists to forbid, and a next wave reading half a
120
+ * sentence is worse off than one told the report was rejected.
121
+ * See add-wave-handoff-and-prompt-snapshots design.md §2.
122
+ */
123
+ maxHandoffChars: 2000,
124
+
125
+ /**
126
+ * Byte ceiling for the repo-root `REVIEW.md` policy file. A file larger than
127
+ * this is not scanned: it is reported as a problem and the run proceeds under
128
+ * default policy (fail-open, add-interlock-review-policy design.md D3). The
129
+ * bound exists so a pathological or accidental (a committed build log named
130
+ * `REVIEW.md`) file cannot make the policy reader do unbounded work — the same
131
+ * reason every other scan in this loop is bounded rather than trusting.
132
+ * Read by `readReviewPolicy` in `lib/review-core.mjs`.
133
+ */
134
+ maxReviewPolicyBytes: 65_536,
135
+
136
+ /**
137
+ * Milliseconds a close's push to the ntfy relay may take before it is
138
+ * abandoned as a failed push. A hanging network call must not stall the
139
+ * close itself (design D5, add-harden-unattended-ship-runs). Read by
140
+ * `postNtfy` in `lib/notify.mjs` via `AbortController`.
141
+ */
142
+ notifyTimeoutMs: 5000
143
+ }
144
+
145
+ /**
146
+ * Caps the eval suite obeys, published here so nothing restates them in
147
+ * `.github/workflows/evals.yml` — a cost written in workflow YAML and echoed in
148
+ * a skill drifts exactly like a prose cap does. The CI job reads them from
149
+ * `interlock limits` (add-interlock-evals design.md D8), following the same
150
+ * precedent the implementer prompt sets at `workflows/ship.js:194-195`.
151
+ *
152
+ * These are deliberately a separate object from LIMITS rather than more entries
153
+ * in it: LIMITS counts loop iterations, whereas a cost ceiling is a dollar
154
+ * amount. They are held to the same reader invariant all the same — the
155
+ * cap-authority test walks `.github/workflows/` alongside `lib/`, `bin/` and
156
+ * `workflows/`, and a CI reader counts when it names the published field
157
+ * (`evals.<cap>`) rather than the export. There is no exemption: a cap here with
158
+ * no reader fails the same test a cap in LIMITS would.
159
+ *
160
+ * `reportingThreshold` was removed rather than wired, following the
161
+ * `memoryEntriesPerRun` precedent above. It described a score at or above which
162
+ * a case reported as passing, but `lib/evals-triage.mjs` reads no score at all —
163
+ * `classifyCase` branches on each grader's `passed` boolean — so there was no
164
+ * path it could govern without inventing one. Publishing it told a contributor
165
+ * that a scoring rule existed which did not.
166
+ *
167
+ * The two cost ceilings are first guesses to be tuned from the `cost_usd` the
168
+ * first runs actually report (design.md D15), not numbers the specs depend on.
169
+ */
170
+ export const EVAL_CAPS = {
171
+ /** US dollars. Ceiling for the per-pull-request smoke subset. */
172
+ smokeCostUsd: 2,
173
+
174
+ /** US dollars. Ceiling for the scheduled full-suite run. */
175
+ fullRunCostUsd: 15,
176
+
177
+ /**
178
+ * US dollars. Ceiling for the scheduled **outcome** eval — the one that ships
179
+ * a committed fixture through the real loop and grades what is on disk
180
+ * (`evals/ship/run.mjs`), not the transcript-grading case suite above.
181
+ *
182
+ * Its two readers exist on the day it lands, which is the whole reason it may
183
+ * be published at all: `evals/ship/run.mjs` reads it from `interlock limits
184
+ * --json` before it starts a fixture, and `.github/workflows/ship-outcome-
185
+ * eval.yml` reads the same field to bound the job. The number appears nowhere
186
+ * else — not in the workflow YAML, not in the eval's README.
187
+ *
188
+ * Enforced at fixture and arm boundaries only. It stops the runner from
189
+ * STARTING the next arm; it never kills a run in flight, because a
190
+ * half-killed run cannot be graded and would be recorded as a failure it did
191
+ * not earn (design D6). A sweep that reaches it is reported partial.
192
+ *
193
+ * A first guess, to be tuned from the spend the first rows actually report —
194
+ * the same posture as the two ceilings above.
195
+ */
196
+ shipEvalCostUsd: 20,
197
+
198
+ /**
199
+ * Agent runs per case. Passed as `--runs` by the scheduled full-suite job,
200
+ * which overrides each case's own value: those were chosen for the fast
201
+ * per-change subset, and the scheduled arm is the one that can afford
202
+ * repetition. The smoke job's `--runs 1` is that subset's single-run posture,
203
+ * not a restatement of this cap.
204
+ */
205
+ runsPerCase: 3,
206
+
207
+ // --- the promotion rule (spec: evals/promotion) --------------------------
208
+ //
209
+ // These three are the whole rule that decides whether an eval case may fail a
210
+ // build. They live here, with readers, for the reason the module header gives:
211
+ // a promotion threshold written in a specification is a sentence a reviewer
212
+ // re-argues once per proposal. `lib/evals-promote.mjs` reads all three by
213
+ // name, and nothing restates them in a skill, a spec, a doc, or the workflow
214
+ // YAML.
215
+ //
216
+ // Landing them WITH readers is deliberate: `runsPerCase` sat here printed and
217
+ // unread, and `reportingThreshold` was removed for the same reason. A fourth
218
+ // unread cap is the one thing this group must not gain.
219
+
220
+ /**
221
+ * Consecutive qualifying runs a case must pass across before it may be
222
+ * promoted from advisory to blocking. A run triage classified no-signal or
223
+ * configuration does not qualify, is named, and does not break the chain.
224
+ * Read by `promote()` in `lib/evals-promote.mjs`.
225
+ */
226
+ promotionRuns: 3,
227
+
228
+ /**
229
+ * Trials of a case a run must carry to count as evidence for promotion. Below
230
+ * this the run is real but too thin to prove repeatability, and the case is
231
+ * refused naming the run rather than promoted on it.
232
+ * Read by `promote()` in `lib/evals-promote.mjs`.
233
+ */
234
+ promotionTrialsPerRun: 3,
235
+
236
+ /**
237
+ * Minimum measured judge/human agreement, as a fraction, for each judged
238
+ * grader of a case being promoted. `interlock evals calibrate` measures;
239
+ * this is the floor it is measured against, applied only here — the
240
+ * calibration report itself issues no verdict.
241
+ *
242
+ * The one non-integer in this group, which is why the positive-integer
243
+ * invariant is over LIMITS rather than over every cap group.
244
+ * Read by `promote()` in `lib/evals-promote.mjs`.
245
+ */
246
+ judgeAgreementFloor: 0.8
247
+ }
248
+
249
+ /**
250
+ * The price table the outcome eval converts measured tokens into dollars with,
251
+ * so `EVAL_CAPS.shipEvalCostUsd` can be a ceiling on money rather than on a
252
+ * token count nobody can compare across models (design D6).
253
+ *
254
+ * A separate export from EVAL_CAPS for the reason EFFORT and LANE_CAPS are: the
255
+ * cap groups hold scalars a code path obeys, and this is a table. It lives here
256
+ * all the same, because a price written in the runner and echoed in a workflow
257
+ * would drift exactly like a prose cap.
258
+ *
259
+ * `id` IS THE LOAD-BEARING FIELD. It travels on every recorded result row, so a
260
+ * later price revision cannot silently reinterpret rows written under the old
261
+ * one. Revising a price therefore means a NEW id, never an edit to a number
262
+ * under the existing one — old rows keep meaning what they meant, and a reader
263
+ * comparing across a revision can see that it happened.
264
+ *
265
+ * The figures are the published list prices for each tier, in US dollars per
266
+ * million tokens, as of the date in the id. They are an input to a ceiling and
267
+ * to a recorded estimate — never to a bill, and never to a gate.
268
+ *
269
+ * A model that is not in the table yields NO cost figure: the runner records the
270
+ * spend as absent with that reason rather than pricing it at zero or guessing a
271
+ * tier from the name. An unpriced run still runs; it just cannot be counted
272
+ * against the ceiling, and the sweep says so.
273
+ *
274
+ * Read by `evals/ship/run.mjs` through `interlock limits --json`; printed by
275
+ * `formatLimits()` below.
276
+ */
277
+ export const MODEL_PRICES = {
278
+ // A NEW id, not an edit under `anthropic-list-2026-09`. The base rates below
279
+ // are unchanged, but the table now answers a question the old one could not —
280
+ // what a cached prefix costs — and a row keyed to the old id was priced by a
281
+ // table with no cache structure at all. The `b` suffix rather than a new month
282
+ // because this is a revision of the same published list, not a later one.
283
+ id: 'anthropic-list-2026-09b',
284
+ perMillionTokens: {
285
+ 'claude-opus-5': { input: 15, output: 75 },
286
+ 'claude-sonnet-5': { input: 3, output: 15 },
287
+ 'claude-haiku-4-5': { input: 1, output: 5 }
288
+ },
289
+ /**
290
+ * What a cached prefix costs, as multipliers on the model's own `input` rate
291
+ * — the same shape for every model, which is why they are multipliers rather
292
+ * than a fourth and fifth column per model.
293
+ *
294
+ * `write` is keyed by LIFETIME TIER and is never flattened: a prefix written
295
+ * for five minutes and one written for an hour are different prices, and a
296
+ * total that added them could not be priced back apart. The keys are the tier
297
+ * names the host's usage envelope reports them under, so a recorded figure
298
+ * and its multiplier are looked up by the same string.
299
+ *
300
+ * A read is a tenth of base, which is the whole economic argument: a prefix
301
+ * that survives to be read costs 10% of what re-sending it costs, and a write
302
+ * that is never read cost 25% (or 100%) more than not caching at all.
303
+ *
304
+ * NO READER IN THIS CHANGE, deliberately (design D9): the figures are recorded
305
+ * so a dollar number becomes computable, and whether to compute and present one
306
+ * is a later decision. They live in the table rather than beside it so the two
307
+ * cannot drift — a run priced against one id and multiplied by a differently
308
+ * versioned constant is attributable to nothing.
309
+ */
310
+ cacheMultipliers: {
311
+ write: { ephemeral_5m: 1.25, ephemeral_1h: 2 },
312
+ read: 0.1
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Reasoning-effort defaults for the wave planner and the two adversarial steps.
318
+ *
319
+ * Deliberately a separate object from LIMITS, exactly like EVAL_CAPS/REPORT_CAPS:
320
+ * LIMITS holds positive-integer iteration counts (its invariant test asserts
321
+ * every entry is one, and every entry must be read as `LIMITS.<cap>` by a
322
+ * code path). These are a tier→effort table and two fixed effort strings —
323
+ * neither an integer nor a loop count. Keeping them here still satisfies the one
324
+ * property that matters: the mapping is read from a single source and surfaced
325
+ * by `interlock limits`, never restated in code that can drift.
326
+ *
327
+ * `byTier` maps a lane's hardest tier to a workflow-runtime effort. `null` means
328
+ * "inherit the session default — do not force": tiers 3–4 inherit by policy so
329
+ * the run keeps whatever the session/model default is (xhigh for coding since
330
+ * w16); an untiered lane inherits by fallback. `verify` and `skeptic` pin the
331
+ * inter-wave verify and the review skeptics regardless of any lane tier — the
332
+ * two steps whose whole job is catching what an implementer missed.
333
+ */
334
+ export const EFFORT = {
335
+ byTier: { 1: 'low', 2: 'low', 3: null, 4: null, 5: 'xhigh' },
336
+ verify: 'xhigh',
337
+ skeptic: 'xhigh'
338
+ }
339
+
340
+ /**
341
+ * How long a lane may get, per tier, and which tiers may be packed together.
342
+ *
343
+ * A lane is an ordered task list one agent executes start to finish. Until this
344
+ * table existed the bound was one scalar for every lane in the run, which forced
345
+ * one number to answer two different questions: how much sequential trivia one
346
+ * agent may hold, and how much judgement-heavy work it may hold. Those have
347
+ * different answers, so this is a table.
348
+ *
349
+ * `byTier` is keyed by the lane's HARDEST task tier (`laneTier`), because that is
350
+ * the tier the lane's agent is dispatched at. An untiered lane (tier 0) uses the
351
+ * tier-1 entry. Tier 4 keeps the old scalar's 4 — cross-file pattern-following is
352
+ * where a fresh context per task still pays. Tier 5 is raised to 8 because a
353
+ * tier-5 lane is the only opus lane in waves mode, and the point of an opus
354
+ * worker is that it can hold more of one design in its head.
355
+ *
356
+ * `cohesionMaxTier` is the ceiling for COHESION: path-disjoint components in one
357
+ * dependency layer whose hardest tier is at or below it may be packed into one
358
+ * lane. Above it a component stays a lane of its own, joined only by a real path
359
+ * collision. Set it to 0 to disable cohesion while keeping the table.
360
+ *
361
+ * A separate export from LIMITS for the same reason `EFFORT` is: the LIMITS
362
+ * invariant asserts every entry is a positive integer, and `byTier` is a table.
363
+ * The cap-authority check covers this group all the same — every entry here must
364
+ * have a reader in `lib/`, `bin/` or `workflows/`.
365
+ *
366
+ * Read by `lib/waves.mjs` (lane construction, cohesion packing) and
367
+ * `lib/plan-fingerprint.mjs`; printed by `interlock limits` and emitted as
368
+ * `laneCaps` by `interlock limits --json`.
369
+ */
370
+ export const LANE_CAPS = {
371
+ byTier: { 1: 8, 2: 8, 3: 6, 4: 4, 5: 8 },
372
+ cohesionMaxTier: 3
373
+ }
374
+
375
+ /**
376
+ * The envelope inside which a change may be planned as ONE lane — every task,
377
+ * in order, on one opus agent (`--solo`, or the classifier's recommendation).
378
+ *
379
+ * One bound, task count, on purpose. The classifier supplies the shape
380
+ * judgement ("this change is one coherent edit"); this supplies the ceiling.
381
+ * Neither restates the other, which is why the `plan-waves` prompt is forbidden
382
+ * from naming this number: a model that can read the bound can argue with it.
383
+ *
384
+ * A recommendation above the envelope is refused and named. An explicit `--solo`
385
+ * flag may exceed it — a human flag is the one input allowed to, the same way
386
+ * `--max-parallel` may ask for anything under the runtime ceiling — and the plan
387
+ * warns that it did.
388
+ *
389
+ * Read by `lib/waves.mjs` (mode decision) and `lib/plan-fingerprint.mjs`;
390
+ * printed by `interlock limits` and emitted as `solo` by `--json`.
391
+ */
392
+ export const SOLO = {
393
+ maxTasks: 20
394
+ }
395
+
396
+ /**
397
+ * Caps for the corpus reader (`interlock report`).
398
+ *
399
+ * Not a policy ceiling like the ones above — nothing halts on it. It bounds how
400
+ * much of an unbounded, ever-growing corpus one invocation opens, so a
401
+ * repository with fifty thousand trajectories still gets a report. Reaching it
402
+ * is stated in the output rather than sampled silently: a truncated scan that
403
+ * looked complete would be worse than no report at all.
404
+ */
405
+ export const REPORT_CAPS = {
406
+ /** Trajectory files opened in one `interlock report` invocation. */
407
+ maxRunsScanned: 2000
408
+ }
409
+
410
+ /**
411
+ * Clamp a requested parallelism to something both Interlock and the runtime
412
+ * will honour. A caller asking for 40 gets the runtime ceiling, not an error:
413
+ * over-asking is a preference, not a mistake worth failing a run over.
414
+ * @param {number} [requested]
415
+ * @returns {{value: number, clamped: boolean, reason: string|null}}
416
+ */
417
+ export function clampParallel(requested) {
418
+ if (!Number.isInteger(requested) || requested <= 0) {
419
+ return { value: LIMITS.maxParallel, clamped: false, reason: null }
420
+ }
421
+ if (requested > RUNTIME.maxConcurrentAgents) {
422
+ return {
423
+ value: RUNTIME.maxConcurrentAgents,
424
+ clamped: true,
425
+ reason: `workflow runtime allows at most ${RUNTIME.maxConcurrentAgents} concurrent agents`
426
+ }
427
+ }
428
+ return { value: requested, clamped: false, reason: null }
429
+ }
430
+
431
+ /** Human-readable dump, so a skill can show the caps without hardcoding them. */
432
+ export function formatLimits() {
433
+ const rows = [
434
+ ['max parallel agents per batch', LIMITS.maxParallel],
435
+ ['inter-wave fix attempts (per wave)', LIMITS.interWaveFixAttempts],
436
+ ['replans (per run)', LIMITS.replansPerRun],
437
+ ['remediation rounds', LIMITS.remediationRounds],
438
+ ['root-cause iterations (per run)', LIMITS.rootCauseIterations],
439
+ ['task failures tolerated', LIMITS.taskFailureHalt],
440
+ ['run steps (per run)', LIMITS.maxRunSteps],
441
+ ['inter-wave verify budget', `${LIMITS.interWaveVerifyBudgetMs / 1000}s`],
442
+ ['inter-wave verifications (per run)', LIMITS.interWaveVerifications],
443
+ ['verify spill threshold (bytes)', LIMITS.verifySpillBytes],
444
+ ['verify preview budget (chars)', LIMITS.verifyPreviewChars],
445
+ ['wave handoff budget (chars, per task)', LIMITS.maxHandoffChars],
446
+ ['review policy scan cap (bytes)', LIMITS.maxReviewPolicyBytes],
447
+ ['push timeout (ms)', LIMITS.notifyTimeoutMs]
448
+ ]
449
+ const evalRows = [
450
+ ['eval smoke cost ceiling (per PR)', `$${EVAL_CAPS.smokeCostUsd}`],
451
+ ['eval full-run cost ceiling (scheduled)', `$${EVAL_CAPS.fullRunCostUsd}`],
452
+ ['eval outcome-run cost ceiling (scheduled)', `$${EVAL_CAPS.shipEvalCostUsd}`],
453
+ ['eval runs per case', EVAL_CAPS.runsPerCase],
454
+ ['eval promotion: consecutive qualifying runs', EVAL_CAPS.promotionRuns],
455
+ ['eval promotion: trials per qualifying run', EVAL_CAPS.promotionTrialsPerRun],
456
+ ['eval promotion: judge agreement floor', `${Math.round(EVAL_CAPS.judgeAgreementFloor * 100)}%`]
457
+ ]
458
+ // The price table prints beside the ceiling it serves: an operator reading a
459
+ // dollar cap needs to see which prices turned tokens into those dollars, and
460
+ // the id is what a recorded row is keyed against.
461
+ const priceRows = [
462
+ ['price table id', MODEL_PRICES.id],
463
+ ...Object.keys(MODEL_PRICES.perMillionTokens)
464
+ .sort()
465
+ .map(model => [
466
+ `price: ${model} ($/Mtok)`,
467
+ `in ${MODEL_PRICES.perMillionTokens[model].input}, out ${MODEL_PRICES.perMillionTokens[model].output}`
468
+ ]),
469
+ // The cache multipliers print beside the base rates for the same reason the
470
+ // base rates print beside the ceiling: they are part of what turns a token
471
+ // count into a dollar figure, and a multiplier nobody can see is a price
472
+ // written in prose. One row per lifetime tier, because they are never summed.
473
+ ...Object.keys(MODEL_PRICES.cacheMultipliers.write)
474
+ .sort()
475
+ .map(tier => [`cache write multiplier: ${tier} (x input)`, MODEL_PRICES.cacheMultipliers.write[tier]]),
476
+ ['cache read multiplier (x input)', MODEL_PRICES.cacheMultipliers.read]
477
+ ]
478
+ const reportRows = [['report trajectory scan cap', REPORT_CAPS.maxRunsScanned]]
479
+ // One row per tier, rather than a rendered object: an operator reading this
480
+ // wants to know what bounds the lane in front of them, and a tier is how they
481
+ // find it. The cohesion ceiling and the solo envelope print beside them because
482
+ // all three answer one question — how many tasks may one agent be handed.
483
+ const laneRows = [
484
+ ['lane cap: tier 1 lane (also untiered)', LANE_CAPS.byTier[1]],
485
+ ['lane cap: tier 2 lane', LANE_CAPS.byTier[2]],
486
+ ['lane cap: tier 3 lane', LANE_CAPS.byTier[3]],
487
+ ['lane cap: tier 4 lane', LANE_CAPS.byTier[4]],
488
+ ['lane cap: tier 5 lane', LANE_CAPS.byTier[5]],
489
+ ['cohesion tier ceiling (packs at or below)', LANE_CAPS.cohesionMaxTier],
490
+ ['solo envelope (max tasks in one lane)', SOLO.maxTasks]
491
+ ]
492
+ // `null` in the tier table means inherit the session default; print it as
493
+ // "inherit" so an operator reads the policy, not an empty cell.
494
+ const inherit = v => (v === null || v === undefined ? 'inherit (session default)' : v)
495
+ const effortRows = [
496
+ ['effort: tier 1 lane', inherit(EFFORT.byTier[1])],
497
+ ['effort: tier 2 lane', inherit(EFFORT.byTier[2])],
498
+ ['effort: tier 3 lane', inherit(EFFORT.byTier[3])],
499
+ ['effort: tier 4 lane', inherit(EFFORT.byTier[4])],
500
+ ['effort: tier 5 lane', inherit(EFFORT.byTier[5])],
501
+ ['effort: inter-wave verify step', EFFORT.verify],
502
+ ['effort: review skeptic step', EFFORT.skeptic]
503
+ ]
504
+ const width = Math.max(
505
+ ...[...rows, ...laneRows, ...evalRows, ...priceRows, ...reportRows, ...effortRows].map(
506
+ ([label]) => label.length
507
+ )
508
+ )
509
+ const fmt = ([label, value]) => ` ${String(label).padEnd(width)} ${value}`
510
+ return (
511
+ rows.map(fmt).join('\n') +
512
+ '\n\n' +
513
+ laneRows.map(fmt).join('\n') +
514
+ '\n\n' +
515
+ evalRows.map(fmt).join('\n') +
516
+ '\n\n' +
517
+ priceRows.map(fmt).join('\n') +
518
+ '\n\n' +
519
+ reportRows.map(fmt).join('\n') +
520
+ '\n\n' +
521
+ effortRows.map(fmt).join('\n') +
522
+ `\n\nruntime ceilings: ${RUNTIME.maxConcurrentAgents} concurrent, ` +
523
+ `${RUNTIME.maxAgentsPerRun} agents per run\n`
524
+ )
525
+ }
@@ -0,0 +1,99 @@
1
+ // Evidence locators — one definition of "what a citation looks like".
2
+ //
3
+ // Two surfaces cite work by pointing at it: a review skeptic's `evidence`
4
+ // prose (`lib/review-core.mjs`) and a wave handoff packet's `evidence` array
5
+ // (`lib/waves.mjs`). Both audit a citation the same way, on two conditions,
6
+ // because either alone is defeated:
7
+ //
8
+ // 1. Shape — `path`, `path:line` or `path:start-end`. Non-emptiness alone was
9
+ // satisfied by the string "👍".
10
+ // 2. Membership — the path canonicalizes to one present in the changed-path
11
+ // set. Shape alone is satisfied by inventing `lib/nowhere.ts:1`.
12
+ //
13
+ // The line is never required to exist in the file. A citation may legitimately
14
+ // name a line the work itself later moved or deleted, so path membership is the
15
+ // strongest condition that cannot produce a false rejection.
16
+ //
17
+ // WHY THIS IS ONE MODULE AND NOT TWO REGEXES. The second surface arrived after
18
+ // the first, and the cheap version of adding it is a second regex next to the
19
+ // new caller. Two definitions of "locator" drift, and the drift is silent —
20
+ // each surface keeps passing its own tests while they diverge. So the
21
+ // canonicalizer and the membership index live here and are imported.
22
+ //
23
+ // WHAT IS DELIBERATELY *NOT* SHARED: the anchoring. `review-core` scans free
24
+ // prose, so `CITATION_TOKEN` matches within a sentence and tolerates a trailing
25
+ // `:column`. `validateHandoff` receives an array of discrete locators, so it
26
+ // anchors the whole string and forbids a colon in the path. Both are correct
27
+ // for their input, and unifying them would loosen handoff validation. Any
28
+ // future "cleanup" that merges them is a regression.
29
+ //
30
+ // Pure: no fs, no clock, no I/O.
31
+
32
+ import { canonicalizePath } from './risk.mjs'
33
+
34
+ // Re-exported so a caller needs one import for the whole vocabulary, and so a
35
+ // test can assert on the canonicalizer the audit actually uses rather than on a
36
+ // same-named function it hopes is the same one.
37
+ export { canonicalizePath }
38
+
39
+ /**
40
+ * `path:line`, `path:start-end`, and a trailing `:column` a tool may have
41
+ * added. The path is a non-whitespace run, so this half cannot see a path
42
+ * containing spaces — that case is handled by `citesLineIn` against a known
43
+ * spelling.
44
+ *
45
+ * Global, so `matchAll` finds every citation in a block of prose. Callers must
46
+ * not hold state on it beyond one `matchAll` (it carries `lastIndex`).
47
+ */
48
+ export const CITATION_TOKEN = /(\S+?):(\d+)(?:-\d+)?(?::\d+)?(?![\w.\-/])/g
49
+
50
+ /** Does `text` cite `path` with a line number after it? */
51
+ export function citesLineIn(text, path) {
52
+ for (let from = 0; ; ) {
53
+ const at = text.indexOf(path, from)
54
+ if (at === -1) return false
55
+ if (/^:\d+/.test(text.slice(at + path.length))) return true
56
+ from = at + 1
57
+ }
58
+ }
59
+
60
+ /**
61
+ * The changed-path set as canonical identities plus the spellings the caller
62
+ * used, so a citation can be matched either way.
63
+ *
64
+ * `null` — not an empty Set — when there is nothing to match against, so a
65
+ * caller can tell "no path set was available" from "the path set is empty",
66
+ * which are different facts about a citation that failed.
67
+ *
68
+ * @param {unknown} changedPaths
69
+ * @returns {Set<string>|null}
70
+ */
71
+ export function diffIndex(changedPaths) {
72
+ if (!Array.isArray(changedPaths) || !changedPaths.length) return null
73
+ const out = new Set()
74
+ for (const entry of changedPaths) {
75
+ if (typeof entry !== 'string' || !entry.trim()) continue
76
+ out.add(entry.trim())
77
+ const canonical = canonicalizePath(entry)
78
+ if (canonical) out.add(canonical)
79
+ }
80
+ return out.size ? out : null
81
+ }
82
+
83
+ /**
84
+ * The path half of one DISCRETE locator — `lib/a.mjs:12-40` → `lib/a.mjs`.
85
+ *
86
+ * For an array of locators, not for prose: it takes the whole string as the
87
+ * citation and only strips a trailing `:line` / `:start-end`. `null` when the
88
+ * remainder cannot be placed inside the repository, because an identity that
89
+ * could not be computed must not be treated as one that matches.
90
+ *
91
+ * @param {unknown} entry
92
+ * @returns {string|null}
93
+ */
94
+ export function locatorPath(entry) {
95
+ if (typeof entry !== 'string') return null
96
+ const trimmed = entry.trim()
97
+ if (!trimmed) return null
98
+ return canonicalizePath(trimmed.replace(/:\d+(-\d+)?$/, ''))
99
+ }