@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/lib/waves.mjs CHANGED
@@ -102,7 +102,8 @@
102
102
 
103
103
  import { randomUUID } from 'node:crypto'
104
104
 
105
- import { LIMITS, EFFORT, LANE_CAPS, SOLO, clampParallel } from './limits.mjs'
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
- * The label a lane runs under — the name its agent, its trajectory line and its
512
- * briefing file all carry.
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.2.0",
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"
@@ -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 eval 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
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
- `claude plugin eval` prints `` `plugin eval` is currently in early access `` and
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: Bash Read Write Edit Glob Grep
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 it reports ambiguity, 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.
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.
@@ -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 | python3 -c '
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
- **Keep the blocker and warning counts.** Continuity needs them, and it will not accept a review it cannot see.
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
- }