@renzrollon/interlock 1.2.0 → 1.3.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/icon.png +0 -0
- package/.claude-plugin/plugin.json +16 -1
- package/CHANGELOG.md +68 -13
- package/bin/interlock +154 -18
- package/hooks/mod.mjs +835 -51
- package/hooks/preflight.mjs +209 -34
- package/lib/doctor.mjs +23 -18
- package/lib/draw-plan.mjs +496 -0
- package/lib/draw-run.mjs +466 -0
- package/lib/evals-triage.mjs +1 -1
- package/lib/lane.mjs +152 -0
- package/lib/limits.mjs +37 -2
- package/lib/meter-refusals.mjs +73 -0
- package/lib/notify-defaults.mjs +6 -0
- package/lib/notify.mjs +41 -25
- package/lib/plan-summary.mjs +119 -0
- package/lib/preflight-file.mjs +266 -0
- package/lib/resume-card.mjs +126 -8
- package/lib/run.mjs +43 -9
- package/lib/spec-meter.mjs +591 -0
- package/lib/waves.mjs +6 -107
- package/package.json +3 -2
- package/skills/evals/SKILL.md +3 -3
- package/skills/fix-tests/SKILL.md +2 -2
- package/skills/review-artifacts/SKILL.md +3 -3
- package/skills/spec/SKILL.md +6 -11
- package/skills/spec/scripts/compact-instructions.mjs +30 -0
- package/.claude-plugin/marketplace.json +0 -19
package/lib/waves.mjs
CHANGED
|
@@ -102,7 +102,8 @@
|
|
|
102
102
|
|
|
103
103
|
import { randomUUID } from 'node:crypto'
|
|
104
104
|
|
|
105
|
-
import { LIMITS,
|
|
105
|
+
import { LIMITS, LANE_CAPS, SOLO, clampParallel } from './limits.mjs'
|
|
106
|
+
import { dependsOnIds, laneEffort, laneLabel, laneModel, laneTier, laneTitle } from './lane.mjs'
|
|
106
107
|
import { diffIndex, locatorPath } from './locators.mjs'
|
|
107
108
|
import { canonicalizePath, isDocsPath } from './risk.mjs'
|
|
108
109
|
|
|
@@ -195,28 +196,6 @@ function unusablePaths(tasks) {
|
|
|
195
196
|
return out
|
|
196
197
|
}
|
|
197
198
|
|
|
198
|
-
/**
|
|
199
|
-
* One task's declared dependency edges, deduplicated, in the order written.
|
|
200
|
-
*
|
|
201
|
-
* THE ONLY READER of `task.dependsOn`, for the same reason `predictedPaths` is
|
|
202
|
-
* the only reader of `task.paths`: validation, layering and the replan path all
|
|
203
|
-
* ask the same question, and three readers each doing their own trimming is how
|
|
204
|
-
* one of them ends up matching an id the others do not.
|
|
205
|
-
*
|
|
206
|
-
* Entries are trimmed. A whitespace-only entry is dropped here and rejected by
|
|
207
|
-
* `validate`, which runs first on every path into the planner.
|
|
208
|
-
*/
|
|
209
|
-
function dependsOnIds(task) {
|
|
210
|
-
const raw = task && Array.isArray(task.dependsOn) ? task.dependsOn : []
|
|
211
|
-
const out = []
|
|
212
|
-
for (const entry of raw) {
|
|
213
|
-
if (typeof entry !== 'string' || !entry.trim()) continue
|
|
214
|
-
const id = entry.trim()
|
|
215
|
-
if (!out.includes(id)) out.push(id)
|
|
216
|
-
}
|
|
217
|
-
return out
|
|
218
|
-
}
|
|
219
|
-
|
|
220
199
|
function chunk(items, size) {
|
|
221
200
|
const out = []
|
|
222
201
|
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))
|
|
@@ -467,11 +446,6 @@ function laneCanonicalPaths(lane) {
|
|
|
467
446
|
return out
|
|
468
447
|
}
|
|
469
448
|
|
|
470
|
-
/** The tier a lane dispatches on: the highest among its tasks. */
|
|
471
|
-
export function laneTier(lane) {
|
|
472
|
-
return lane.reduce((m, t) => (Number.isInteger(t.tier) && t.tier > m ? t.tier : m), 0)
|
|
473
|
-
}
|
|
474
|
-
|
|
475
449
|
/**
|
|
476
450
|
* The effective per-tier lane cap table for one plan: the published table with a
|
|
477
451
|
* uniform override applied as a CEILING over every tier.
|
|
@@ -507,85 +481,10 @@ function capForTier(caps, tier) {
|
|
|
507
481
|
return Number.isInteger(cap) && cap > 0 ? cap : 1
|
|
508
482
|
}
|
|
509
483
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
* Stable across replays, so a resumed run cache-hits the lane it already ran.
|
|
515
|
-
* This derivation was written three times (the workflow script, the ACP driver,
|
|
516
|
-
* and `bin/interlock`'s own trajectory logger); it is stated here once because a
|
|
517
|
-
* trajectory line and the agent it describes must carry one name, not two.
|
|
518
|
-
*/
|
|
519
|
-
export function laneLabel(lane) {
|
|
520
|
-
return lane.length === 1 ? lane[0].id : `${lane[0].id}+${lane.length - 1}`
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
/** Separates a spawn's label from the words its title adds. */
|
|
524
|
-
export const TITLE_SEPARATOR = ' · '
|
|
525
|
-
|
|
526
|
-
/** The longest title a lane displays under, ellipsis included. */
|
|
527
|
-
const TITLE_MAX = 48
|
|
528
|
-
|
|
529
|
-
/**
|
|
530
|
-
* The name a lane is SHOWN under — its label, then the first words of its first
|
|
531
|
-
* task's description: `1.1+5 · Add the relaunch guard to…`.
|
|
532
|
-
*
|
|
533
|
-
* Display only. Everything that joins on a lane — its briefing file, worktree,
|
|
534
|
-
* trajectory line, record-batch fold — keys on `laneLabel`, because a key built
|
|
535
|
-
* from prose would change whenever a task was reworded. It is still a pure
|
|
536
|
-
* function of the lane, so a replay displays, and cache-hits, the same agent.
|
|
537
|
-
*/
|
|
538
|
-
export function laneTitle(lane) {
|
|
539
|
-
const label = laneLabel(lane)
|
|
540
|
-
const first = lane[0] && typeof lane[0].description === 'string' ? lane[0].description : ''
|
|
541
|
-
const words = first.replace(/[`*_]/g, '').replace(/\s+/g, ' ').trim().split(' ').slice(0, 6).join(' ')
|
|
542
|
-
const gist = words.replace(/[\s.,;:—-]+$/, '')
|
|
543
|
-
if (!gist) return label
|
|
544
|
-
const full = `${label}${TITLE_SEPARATOR}${gist}`
|
|
545
|
-
return full.length <= TITLE_MAX ? full : `${full.slice(0, TITLE_MAX - 1).trimEnd()}…`
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
/**
|
|
549
|
-
* The model a lane dispatches on, derived from the lane's shape and hardest tier.
|
|
550
|
-
*
|
|
551
|
-
* A lane of one task dispatches on that task's clamped model — haiku or sonnet,
|
|
552
|
-
* or opus for a tier-5 task or a solo promotion. A lane of two or more tasks is
|
|
553
|
-
* the work agent the planner chose not to split: it dispatches on opus when its
|
|
554
|
-
* hardest tier is at or above `LANE_CAPS.opusMinTier`, or when every task already
|
|
555
|
-
* carries opus (solo promotion of the whole change). Otherwise it runs on
|
|
556
|
-
* sonnet — routine multi-task work no longer pays the flagship model by default.
|
|
557
|
-
* Nothing here rewrites `task.model`: a narrowed lane of one returns to the
|
|
558
|
-
* task's model.
|
|
559
|
-
*/
|
|
560
|
-
export function laneModel(lane) {
|
|
561
|
-
if (!Array.isArray(lane) || lane.length === 0) return 'sonnet'
|
|
562
|
-
if (lane.length === 1) {
|
|
563
|
-
const only = lane[0]
|
|
564
|
-
return (only && only.model) || 'sonnet'
|
|
565
|
-
}
|
|
566
|
-
// Solo promotions rewrite every task to opus after the clamp; an all-opus
|
|
567
|
-
// multi-task lane is that decision (or a pure tier-5 pack), not the floor.
|
|
568
|
-
if (lane.every(t => t && t.model === 'opus')) return 'opus'
|
|
569
|
-
return laneTier(lane) >= LANE_CAPS.opusMinTier ? 'opus' : 'sonnet'
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
/**
|
|
573
|
-
* The reasoning effort a lane dispatches at — a sibling of `laneModel`, derived
|
|
574
|
-
* from the same hardest-task tier, looked up in the table published by
|
|
575
|
-
* `lib/limits.mjs`. A lane is one agent, and that agent must be capable of the
|
|
576
|
-
* hardest thing in the lane, so the effort is the hardest task's, never the
|
|
577
|
-
* first task's — the one direction of this trade that is not survivable.
|
|
578
|
-
*
|
|
579
|
-
* Returns `null` (inherit the session default — do not force) for tiers 3–4 by
|
|
580
|
-
* policy and for an untiered lane (`laneTier` → 0) by fallback; the plan's
|
|
581
|
-
* effort report distinguishes the two. Effort is derived from tier alone. The
|
|
582
|
-
* model a lane dispatches on follows shape and the opus floor, so a multi-task
|
|
583
|
-
* lane of tier-1 or tier-2 tasks runs on sonnet at `low`.
|
|
584
|
-
*/
|
|
585
|
-
export function laneEffort(lane) {
|
|
586
|
-
const tier = laneTier(lane)
|
|
587
|
-
return EFFORT.byTier[tier] ?? null
|
|
588
|
-
}
|
|
484
|
+
// The lane rule — tier, label, title, model, effort — lives in `lib/lane.mjs`,
|
|
485
|
+
// a module the engine can load (draw-wave-plan-and-handoff-graph-from-the-cli
|
|
486
|
+
// design D3). Re-exported so every importer of it from here is unchanged.
|
|
487
|
+
export { laneEffort, laneLabel, laneModel, laneTier, laneTitle, TITLE_SEPARATOR } from './lane.mjs'
|
|
589
488
|
|
|
590
489
|
/**
|
|
591
490
|
* Group a group's tasks into lanes: connected components over canonical-path
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@renzrollon/interlock",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Autonomous spec-driven development orchestration for Claude Code, layered on OpenSpec",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
"types",
|
|
22
22
|
"agents",
|
|
23
23
|
"shared",
|
|
24
|
-
".claude-plugin",
|
|
24
|
+
".claude-plugin/plugin.json",
|
|
25
|
+
".claude-plugin/icon.png",
|
|
25
26
|
"README.md",
|
|
26
27
|
"LICENSE",
|
|
27
28
|
"CHANGELOG.md"
|
package/skills/evals/SKILL.md
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
name: evals
|
|
3
3
|
description: Author an Interlock eval case from an observed model-behaviour failure, and read a completed eval results run. Use when you have a reproduced failure — an archived proposal, a changelog entry, a run artifact, or a transcript — worth regression-testing against a real model, or when an eval run has finished and you need to know whether it is a regression, run-to-run variance, or no signal. Authoring needs cited evidence; the verdict comes from `interlock evals triage`, never from your own reading.
|
|
4
4
|
license: MIT
|
|
5
|
-
compatibility: Requires the early-access
|
|
6
|
-
allowed-tools: Bash(interlock *) Bash(claude plugin eval
|
|
5
|
+
compatibility: Requires the early-access harness (claude plugin eval, enabled with CLAUDE_CODE_WALNUT_SPIRE=1) to RUN a suite. Authoring a case and triaging an existing results file need only Node.js >= 18 and the bundled interlock CLI.
|
|
6
|
+
allowed-tools: Bash(interlock *) Bash(claude plugin eval:*) Read Grep Glob Write
|
|
7
7
|
metadata:
|
|
8
8
|
type: authoring
|
|
9
9
|
outputs:
|
|
@@ -148,7 +148,7 @@ test until it passes" failure the suite exists to prevent, one layer up.
|
|
|
148
148
|
|
|
149
149
|
## 5. When the harness is gated
|
|
150
150
|
|
|
151
|
-
|
|
151
|
+
The host command claude plugin eval prints that it is currently in early access and
|
|
152
152
|
does nothing until `CLAUDE_CODE_WALNUT_SPIRE=1` is set in the environment — never
|
|
153
153
|
in committed `.claude/settings.json`, which would produce a suite that looks
|
|
154
154
|
configured and does not run.
|
|
@@ -4,7 +4,7 @@ description: Discover and persist how a project's tests run, then run the suite
|
|
|
4
4
|
license: MIT
|
|
5
5
|
compatibility: Requires the project's own test tooling. Node.js >= 18 for the bundled interlock CLI.
|
|
6
6
|
argument-hint: "[--e2e] [--dry-run] [--baseline <ref>] [--from-log <path>] [--reconfigure]"
|
|
7
|
-
allowed-tools:
|
|
7
|
+
allowed-tools: Read Write Edit Glob Grep
|
|
8
8
|
metadata:
|
|
9
9
|
type: execution
|
|
10
10
|
outputs:
|
|
@@ -40,7 +40,7 @@ Persist the profile and mirror it into the `## Testing` block of the first instr
|
|
|
40
40
|
|
|
41
41
|
## 2. Run and baseline
|
|
42
42
|
|
|
43
|
-
Run the unit command. Record `total`, `passed`, `failed`, `skipped` — this is the baseline that step 6 asserts against.
|
|
43
|
+
Run the unit command under the permissions already granted to this project. This skill does not pre-approve a shell. Record `total`, `passed`, `failed`, `skipped` — this is the baseline that step 6 asserts against.
|
|
44
44
|
|
|
45
45
|
Split failures into:
|
|
46
46
|
|
|
@@ -19,7 +19,7 @@ Resolve the change:
|
|
|
19
19
|
interlock validate [change-name] --json
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
If
|
|
22
|
+
An output carrying `error` and `candidates` instead of `ready` means the name did not resolve; `problems` is the list the change must clear. If the name did not resolve, pick the change the user named. If they named none and several are active, review the most recently modified and **say which one you picked** — this skill runs inside automated chains where a question would stall the flow.
|
|
23
23
|
|
|
24
24
|
Read all of `proposal.md`, `specs/**/*.md`, `design.md`, `tasks.md`.
|
|
25
25
|
|
|
@@ -119,9 +119,9 @@ Write `.claude/metrics/review-artifacts-<change>-<YYYYMMDD-HHMMSS>.json` in this
|
|
|
119
119
|
Then let the deterministic gate decide, rather than deciding in prose:
|
|
120
120
|
|
|
121
121
|
```bash
|
|
122
|
-
interlock gate --findings .claude/metrics/review-artifacts-<change>-<ts>.json --metrics <change>
|
|
122
|
+
interlock gate --findings .claude/metrics/review-artifacts-<change>-<ts>.json --metrics <change> --json
|
|
123
123
|
```
|
|
124
124
|
|
|
125
|
-
The gate blocks if and only if at least one finding is a `blocker`. Its exit status is the verdict: non-zero means blocked. Do not re-derive the verdict in prose.
|
|
125
|
+
The gate blocks if and only if at least one finding is a `blocker`. Its exit status is the verdict: non-zero means blocked. Do not re-derive the verdict in prose. In the JSON, `passed` is that same verdict; report the entries of `blockers` and `malformed`, read `metrics.written` and `metrics.reason` for whether the record landed, and hand `autonomyOutcome.blockers` on as the blocker count the spec skill passes to `interlock autonomy record`.
|
|
126
126
|
|
|
127
127
|
`--metrics <change>` is not optional decoration. It writes the four counts this gate produced — raised, dismissed, dropped by the quality band, surviving — to `.claude/metrics/` in the schema `interlock report` recognizes. Drop it and this review path becomes invisible to the report forever: the review-finding indicators read `unobserved` no matter how many gates ran. It is bookkeeping only and cannot change the verdict or the exit status.
|
package/skills/spec/SKILL.md
CHANGED
|
@@ -128,12 +128,7 @@ Parse `applyRequires` (artifacts needed before implementation), `artifacts` (eac
|
|
|
128
128
|
Then loop, in dependency order, over every artifact that is `ready`:
|
|
129
129
|
|
|
130
130
|
```bash
|
|
131
|
-
openspec instructions <artifact-id> --change "<name>" --json |
|
|
132
|
-
import json,sys
|
|
133
|
-
d=json.load(sys.stdin)
|
|
134
|
-
keep=("instruction","template","resolvedOutputPath","context","rules")
|
|
135
|
-
print(json.dumps({k:d[k] for k in keep if d.get(k)}, separators=(",",":")))
|
|
136
|
-
'
|
|
131
|
+
openspec instructions <artifact-id> --change "<name>" --json | node "${CLAUDE_PLUGIN_ROOT}/skills/spec/scripts/compact-instructions.mjs"
|
|
137
132
|
```
|
|
138
133
|
|
|
139
134
|
Filter each ready artifact independently; do not dump the full instructions response or prefetch every artifact. The compact response carries `instruction` (schema guidance), `template` (the structure to fill), `resolvedOutputPath`, and non-empty `context` / `rules`.
|
|
@@ -194,10 +189,10 @@ What this skill owes the ledger:
|
|
|
194
189
|
Check the file rather than trusting that you wrote it well:
|
|
195
190
|
|
|
196
191
|
```bash
|
|
197
|
-
interlock ledger "<name>"
|
|
192
|
+
interlock ledger "<name>" --json
|
|
198
193
|
```
|
|
199
194
|
|
|
200
|
-
It exits non-zero when the ledger blocks — a `needs_human` row remains, a row is invalid, or **the ledger is missing entirely**. An absent `decisions.md` is a failure of the audit, not an empty result: "no rows" reads as "nothing needs a human", which is the one conclusion a file that was never written cannot support. The output distinguishes *missing* from *present and empty* from *unparseable*.
|
|
195
|
+
It exits non-zero when the ledger blocks — a `needs_human` row remains, a row is invalid, or **the ledger is missing entirely**. An absent `decisions.md` is a failure of the audit, not an empty result: "no rows" reads as "nothing needs a human", which is the one conclusion a file that was never written cannot support. The output distinguishes *missing* from *present and empty* from *unparseable*. In the JSON, `blocking` is the verdict; report the `needs_human` entries of `rows` by `id` and `question` and the entries of `invalidRows` by `reason`, and read `missing` or `unparseable` as why a ledger with no rows still blocks.
|
|
201
196
|
|
|
202
197
|
A blocking ledger with rows in it is not a failure of this skill: an honest blocking ledger is the correct output for a change that hit a real product question. It is only a failure if the questions never got written down.
|
|
203
198
|
|
|
@@ -207,10 +202,10 @@ A blocking ledger with rows in it is not a failure of this skill: an honest bloc
|
|
|
207
202
|
|
|
208
203
|
```bash
|
|
209
204
|
openspec validate
|
|
210
|
-
interlock validate "<name>"
|
|
205
|
+
interlock validate "<name>" --json
|
|
211
206
|
```
|
|
212
207
|
|
|
213
|
-
`openspec validate` checks schema conformance. `interlock validate` checks the flow's own preconditions — all three artifacts present and non-empty, `tasks.md` actually containing checkbox tasks — and exits non-zero when the change is not implementable. Fix anything it reports before continuing.
|
|
208
|
+
`openspec validate` checks schema conformance. `interlock validate` checks the flow's own preconditions — all three artifacts present and non-empty, `tasks.md` actually containing checkbox tasks — and exits non-zero when the change is not implementable. Fix anything it reports before continuing. In the JSON, `ready` is the verdict and `problems` is the list to fix; an output carrying `error` and `candidates` instead of `ready` means the name did not resolve.
|
|
214
209
|
|
|
215
210
|
---
|
|
216
211
|
|
|
@@ -230,7 +225,7 @@ interlock autonomy clean review-artifacts explore spec
|
|
|
230
225
|
|
|
231
226
|
A clean gate credits `explore` and `spec` too; a blocker resets them. That is what stops this skill earning autonomy by emitting shallow specs — the downstream gate blames whoever produced the bad artifact.
|
|
232
227
|
|
|
233
|
-
|
|
228
|
+
**`<n>` is `autonomyOutcome.blockers` from the gate's JSON**, carried over from the review — never a count made in prose, including the review's own `SUMMARY:` line. Continuity takes the findings file itself, so keep its path.
|
|
234
229
|
|
|
235
230
|
---
|
|
236
231
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Compact an `openspec instructions --json` payload to the fields an author fills.
|
|
2
|
+
// Shipped in the plugin so the spec skill does not inline another interpreter.
|
|
3
|
+
|
|
4
|
+
import { readFileSync } from 'node:fs'
|
|
5
|
+
|
|
6
|
+
const keep = ['instruction', 'template', 'resolvedOutputPath', 'context', 'rules']
|
|
7
|
+
|
|
8
|
+
let raw
|
|
9
|
+
try {
|
|
10
|
+
raw = readFileSync(0, 'utf8')
|
|
11
|
+
} catch (err) {
|
|
12
|
+
process.stderr.write(`compact-instructions: could not read stdin: ${(err && err.message) || err}\n`)
|
|
13
|
+
process.exit(1)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let data
|
|
17
|
+
try {
|
|
18
|
+
data = JSON.parse(raw)
|
|
19
|
+
} catch (err) {
|
|
20
|
+
process.stderr.write(`compact-instructions: stdin is not JSON: ${(err && err.message) || err}\n`)
|
|
21
|
+
process.exit(1)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const out = {}
|
|
25
|
+
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
|
26
|
+
for (const key of keep) {
|
|
27
|
+
if (data[key]) out[key] = data[key]
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
process.stdout.write(JSON.stringify(out))
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "interlock",
|
|
3
|
-
"description": "Spec-driven development orchestration for Claude Code. Layers autonomous multi-agent workflows on top of OpenSpec: repo onboarding, reviewed specs, and start-to-commit shipping.",
|
|
4
|
-
"owner": {
|
|
5
|
-
"name": "Carl Rollon",
|
|
6
|
-
"url": "https://github.com/renzrollon"
|
|
7
|
-
},
|
|
8
|
-
"plugins": [
|
|
9
|
-
{
|
|
10
|
-
"name": "interlock",
|
|
11
|
-
"source": "./",
|
|
12
|
-
"description": "Autonomous spec-driven development orchestration for Claude Code, layered on OpenSpec.",
|
|
13
|
-
"version": "1.2.0",
|
|
14
|
-
"author": {
|
|
15
|
-
"name": "Carl Rollon"
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
]
|
|
19
|
-
}
|