@renzrollon/interlock 1.2.1 → 1.3.1
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/plugin.json +1 -2
- package/CHANGELOG.md +64 -5
- package/bin/interlock +142 -14
- package/hooks/mod.mjs +1013 -85
- package/hooks/preflight.mjs +168 -34
- package/lib/draw-plan.mjs +516 -0
- package/lib/draw-run.mjs +466 -0
- package/lib/lane.mjs +152 -0
- package/lib/limits.mjs +37 -2
- package/lib/meter-refusals.mjs +73 -0
- 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 +2 -2
- package/skills/review-artifacts/SKILL.md +3 -3
- package/skills/spec/SKILL.md +5 -5
- /package/{.claude-plugin → assets}/icon.png +0 -0
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.1",
|
|
4
4
|
"description": "Autonomous spec-driven development orchestration for Claude Code, layered on OpenSpec",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"agents",
|
|
23
23
|
"shared",
|
|
24
24
|
".claude-plugin/plugin.json",
|
|
25
|
-
"
|
|
25
|
+
"assets",
|
|
26
26
|
"README.md",
|
|
27
27
|
"LICENSE",
|
|
28
28
|
"CHANGELOG.md"
|
|
@@ -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
|
@@ -189,10 +189,10 @@ What this skill owes the ledger:
|
|
|
189
189
|
Check the file rather than trusting that you wrote it well:
|
|
190
190
|
|
|
191
191
|
```bash
|
|
192
|
-
interlock ledger "<name>"
|
|
192
|
+
interlock ledger "<name>" --json
|
|
193
193
|
```
|
|
194
194
|
|
|
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*.
|
|
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.
|
|
196
196
|
|
|
197
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.
|
|
198
198
|
|
|
@@ -202,10 +202,10 @@ A blocking ledger with rows in it is not a failure of this skill: an honest bloc
|
|
|
202
202
|
|
|
203
203
|
```bash
|
|
204
204
|
openspec validate
|
|
205
|
-
interlock validate "<name>"
|
|
205
|
+
interlock validate "<name>" --json
|
|
206
206
|
```
|
|
207
207
|
|
|
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.
|
|
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.
|
|
209
209
|
|
|
210
210
|
---
|
|
211
211
|
|
|
@@ -225,7 +225,7 @@ interlock autonomy clean review-artifacts explore spec
|
|
|
225
225
|
|
|
226
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.
|
|
227
227
|
|
|
228
|
-
|
|
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.
|
|
229
229
|
|
|
230
230
|
---
|
|
231
231
|
|
|
File without changes
|