@renzrollon/interlock 1.4.2 → 1.5.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/plugin.json +2 -2
- package/CHANGELOG.md +41 -0
- package/README.md +28 -3
- package/bin/interlock +16 -5
- package/lib/graph/cli.mjs +93 -15
- package/lib/graph/extract/bicep.mjs +67 -0
- package/lib/graph/extract/create-extractor.mjs +15 -0
- package/lib/graph/extract/csharp.mjs +206 -0
- package/lib/graph/extract/csproj.mjs +42 -0
- package/lib/graph/extract/memory.mjs +9 -4
- package/lib/graph/extract/openspec.mjs +9 -5
- package/lib/graph/extract/walk.mjs +37 -12
- package/lib/graph/graph/build.mjs +77 -13
- package/lib/graph/graph/link-csharp.mjs +210 -0
- package/lib/graph/graph/query.mjs +2 -0
- package/lib/graph/graph/report.mjs +11 -1
- package/lib/graph/graph/update.mjs +56 -16
- package/lib/graph/paths.mjs +56 -4
- package/lib/graph/retrieve/context.mjs +2 -0
- package/lib/graph/retrieve/docs-index.mjs +9 -10
- package/lib/graph/retrieve/markdown.mjs +8 -8
- package/lib/limits.mjs +11 -3
- package/lib/outcomes.mjs +8 -1
- package/lib/prompts/implementer.mjs +28 -3
- package/lib/prompts/index.mjs +37 -1
- package/lib/prompts/rejected.mjs +24 -0
- package/lib/prompts/review.mjs +38 -1
- package/lib/receipt.mjs +21 -1
- package/lib/resume-card.mjs +19 -0
- package/lib/run-log.mjs +30 -2
- package/lib/run.mjs +198 -25
- package/lib/verify.mjs +32 -1
- package/lib/waves.mjs +161 -19
- package/package.json +2 -2
- package/shared/TEST-PROFILE.md +1 -1
- package/skills/bootstrap/SKILL.md +11 -4
- package/skills/fix-tests/SKILL.md +1 -0
- package/skills/graph/SKILL.md +12 -1
- package/workflows/ship.js +2 -1
package/lib/verify.mjs
CHANGED
|
@@ -87,7 +87,7 @@ export const SKIP_REASONS = {
|
|
|
87
87
|
NO_COMMAND: 'no-detectable-command',
|
|
88
88
|
/** The failures were already red before this change touched anything. */
|
|
89
89
|
PRE_EXISTING: 'failures-are-pre-existing',
|
|
90
|
-
/**
|
|
90
|
+
/** The inter-wave verify budget the caller passed is spent; only typecheck still runs. */
|
|
91
91
|
BUDGET_EXCEEDED: 'verify-budget-exceeded',
|
|
92
92
|
/** `e2e.enabled` is false in the profile. */
|
|
93
93
|
E2E_DISABLED: 'e2e-not-enabled',
|
|
@@ -180,6 +180,37 @@ function step(kind, command, extra = {}) {
|
|
|
180
180
|
}
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The inter-wave verify budget for a run, derived from its test profile.
|
|
186
|
+
*
|
|
187
|
+
* `unit.timeout_ms` is the profile's allowance for one suite run; a run may
|
|
188
|
+
* spend at most `LIMITS.interWaveVerifications` checkpoints, so the allowance
|
|
189
|
+
* for the checkpoints the cap already permits is their product. The published
|
|
190
|
+
* `LIMITS.interWaveVerifyBudgetMs` is a floor: a profile can only raise the
|
|
191
|
+
* budget, never buy less verification with a small timeout. `source` says which
|
|
192
|
+
* one the budget came from, and `timeoutMs` is the profile's value when it was
|
|
193
|
+
* usable (null when it was not), so a budget left at the floor because the
|
|
194
|
+
* profile said nothing reads differently from one left there because the
|
|
195
|
+
* profile's own allowance is small.
|
|
196
|
+
*
|
|
197
|
+
* `planVerification` is deliberately untouched: its callers pass `budgetMs`.
|
|
198
|
+
*
|
|
199
|
+
* @param {object|null} profile parsed `.claude/testing/profile.json`, or null
|
|
200
|
+
* @returns {{ms: number, source: 'profile'|'published', timeoutMs: number|null}}
|
|
201
|
+
*/
|
|
202
|
+
export function interWaveVerifyBudget(profile) {
|
|
203
|
+
const unit = isObject(profile) && isObject(profile.unit) ? profile.unit : {}
|
|
204
|
+
const timeoutMs =
|
|
205
|
+
Number.isSafeInteger(unit.timeout_ms) && unit.timeout_ms > 0 ? unit.timeout_ms : null
|
|
206
|
+
if (timeoutMs === null) {
|
|
207
|
+
return { ms: LIMITS.interWaveVerifyBudgetMs, source: 'published', timeoutMs: null }
|
|
208
|
+
}
|
|
209
|
+
const derived = Math.min(LIMITS.interWaveVerifications * timeoutMs, Number.MAX_SAFE_INTEGER)
|
|
210
|
+
if (derived > LIMITS.interWaveVerifyBudgetMs) return { ms: derived, source: 'profile', timeoutMs }
|
|
211
|
+
return { ms: LIMITS.interWaveVerifyBudgetMs, source: 'published', timeoutMs }
|
|
212
|
+
}
|
|
213
|
+
|
|
183
214
|
/**
|
|
184
215
|
* Decide which verification commands should run for this change.
|
|
185
216
|
*
|
package/lib/waves.mjs
CHANGED
|
@@ -1587,9 +1587,16 @@ export const HALT_INTER_WAVE_VERIFY = 'inter-wave-verify'
|
|
|
1587
1587
|
// report — and a reconstruction cannot be validated, bounded or refused.
|
|
1588
1588
|
//
|
|
1589
1589
|
// So it is a schema with a character cap, and it fails closed. A packet that is
|
|
1590
|
-
// missing, malformed or over budget
|
|
1591
|
-
//
|
|
1592
|
-
//
|
|
1590
|
+
// missing, malformed or over budget is REJECTED: it is never truncated and never
|
|
1591
|
+
// passed through as prose for the next wave to interpret. Truncation is the
|
|
1592
|
+
// exact silent degradation this replaces.
|
|
1593
|
+
//
|
|
1594
|
+
// What a rejection does to the task is decided by the task's own claim. A task
|
|
1595
|
+
// that reported failure stays failed with its own reason. A task that reported
|
|
1596
|
+
// success stays done: the packet's shape was never evidence of work, and the
|
|
1597
|
+
// work is already in the tree. The rejection is recorded on its own
|
|
1598
|
+
// (`rejectedReports`), never counted toward the task-failure halt, and the next
|
|
1599
|
+
// wave is told the task filed no usable report.
|
|
1593
1600
|
|
|
1594
1601
|
/** Version tag on every stored packet, so a later shape change is detectable. */
|
|
1595
1602
|
export const HANDOFF_SCHEMA = 'interlock.wave-handoff/1'
|
|
@@ -1600,6 +1607,18 @@ const HANDOFF_STATUSES = new Set(['ok', 'blocked', 'partial'])
|
|
|
1600
1607
|
const EVIDENCE_LOCATOR = /^[^\s:]+(:\d+(-\d+)?)?$/
|
|
1601
1608
|
const MAX_EVIDENCE = 8
|
|
1602
1609
|
|
|
1610
|
+
// An agent-supplied value quoted inside a rejection reason. The reason travels
|
|
1611
|
+
// — to the banner, the run state, the next wave's briefing and the review — so
|
|
1612
|
+
// the quote is a short single-line diagnostic: JSON-escaped, so a newline in it
|
|
1613
|
+
// cannot break the line it is printed on, and clipped, so a pasted file body
|
|
1614
|
+
// cannot ride along as "the reason".
|
|
1615
|
+
const MAX_ECHO = 160
|
|
1616
|
+
|
|
1617
|
+
function echo(value) {
|
|
1618
|
+
const text = JSON.stringify(value) ?? String(value)
|
|
1619
|
+
return text.length > MAX_ECHO ? `${text.slice(0, MAX_ECHO - 1)}…` : text
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1603
1622
|
function handoffError(reason) {
|
|
1604
1623
|
return { ok: false, error: reason }
|
|
1605
1624
|
}
|
|
@@ -1622,16 +1641,16 @@ export function validateHandoff(taskId, packet) {
|
|
|
1622
1641
|
// version is different — that is a packet written against another contract.
|
|
1623
1642
|
const schema = packet.schema === undefined ? HANDOFF_SCHEMA : packet.schema
|
|
1624
1643
|
if (schema !== HANDOFF_SCHEMA) {
|
|
1625
|
-
return handoffError(`unknown handoff schema
|
|
1644
|
+
return handoffError(`unknown handoff schema ${echo(String(schema))} (expected ${HANDOFF_SCHEMA})`)
|
|
1626
1645
|
}
|
|
1627
1646
|
|
|
1628
1647
|
if (typeof packet.taskId !== 'string' || packet.taskId !== taskId) {
|
|
1629
|
-
return handoffError(`taskId must be "${taskId}", got ${
|
|
1648
|
+
return handoffError(`taskId must be "${taskId}", got ${echo(packet.taskId)}`)
|
|
1630
1649
|
}
|
|
1631
1650
|
|
|
1632
1651
|
if (!HANDOFF_STATUSES.has(packet.status)) {
|
|
1633
1652
|
return handoffError(
|
|
1634
|
-
`status must be one of ${[...HANDOFF_STATUSES].join('|')}, got ${
|
|
1653
|
+
`status must be one of ${[...HANDOFF_STATUSES].join('|')}, got ${echo(packet.status)}`
|
|
1635
1654
|
)
|
|
1636
1655
|
}
|
|
1637
1656
|
|
|
@@ -1657,7 +1676,7 @@ export function validateHandoff(taskId, packet) {
|
|
|
1657
1676
|
for (const entry of evidence) {
|
|
1658
1677
|
if (typeof entry !== 'string' || !EVIDENCE_LOCATOR.test(entry)) {
|
|
1659
1678
|
return handoffError(
|
|
1660
|
-
`evidence entry ${
|
|
1679
|
+
`evidence entry ${echo(entry)} is not a locator (path, path:line or path:start-end)`
|
|
1661
1680
|
)
|
|
1662
1681
|
}
|
|
1663
1682
|
}
|
|
@@ -2147,6 +2166,7 @@ export function nextStep(state) {
|
|
|
2147
2166
|
tasks: wave.batches[batchIndex],
|
|
2148
2167
|
remainingBatches: wave.batches.slice(batchIndex),
|
|
2149
2168
|
previousHandoffs: previousWaveHandoffs(state, waveIndex),
|
|
2169
|
+
...rejectedReportsField(state, waveIndex),
|
|
2150
2170
|
// The wave's changed-file set, canonically deduplicated. A caller that
|
|
2151
2171
|
// fuses the inter-wave verify plan into the record-batch ping (ship.js
|
|
2152
2172
|
// does) needs it here, and recomputing it from raw `task.paths` is how
|
|
@@ -2189,6 +2209,91 @@ function previousWaveHandoffs(state, waveIndex) {
|
|
|
2189
2209
|
return out
|
|
2190
2210
|
}
|
|
2191
2211
|
|
|
2212
|
+
/**
|
|
2213
|
+
* The tasks of the wave immediately before this one that claimed success and
|
|
2214
|
+
* filed no usable report, with the reason and what they said they touched.
|
|
2215
|
+
*
|
|
2216
|
+
* Same scope as `previousWaveHandoffs`, for the same reason. The rejected
|
|
2217
|
+
* packet's own text is never carried: that would be truncation by another name.
|
|
2218
|
+
* Paths come from `reportedPaths`, the task's own report of what it touched.
|
|
2219
|
+
*
|
|
2220
|
+
* @returns {Array<{taskId: string, reason: string, paths: string[], pathsOmitted?: number}>}
|
|
2221
|
+
*/
|
|
2222
|
+
function previousWaveRejectedReports(state, waveIndex) {
|
|
2223
|
+
const previous = state.waves[waveIndex - 1]
|
|
2224
|
+
if (!previous) return []
|
|
2225
|
+
const rejected = Array.isArray(state.rejectedReports) ? state.rejectedReports : []
|
|
2226
|
+
const reported =
|
|
2227
|
+
state.reportedPaths && typeof state.reportedPaths === 'object' ? state.reportedPaths : {}
|
|
2228
|
+
const ids = new Set(waveTasks(previous).map(t => t.id))
|
|
2229
|
+
const out = []
|
|
2230
|
+
for (const r of rejected) {
|
|
2231
|
+
if (!r || !ids.has(r.id)) continue
|
|
2232
|
+
out.push(rejectedReportEntry(r, reported))
|
|
2233
|
+
}
|
|
2234
|
+
return out
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
/**
|
|
2238
|
+
* One rejected report as it is handed on: the task, the short reason, and the
|
|
2239
|
+
* paths the task said it touched.
|
|
2240
|
+
*
|
|
2241
|
+
* A rejected report must not hand its reader more than a valid packet may. The
|
|
2242
|
+
* reason is the short adjudication reason. The paths are the task's own
|
|
2243
|
+
* unvalidated strings, so they are listed only while each is a single line and
|
|
2244
|
+
* the list still fits the published handoff cap; whatever is left out is
|
|
2245
|
+
* counted, never dropped in silence. `pathsOmitted` is present only when
|
|
2246
|
+
* something was left out, so an entry that fits is what it always was.
|
|
2247
|
+
*/
|
|
2248
|
+
function rejectedReportEntry(record, reported) {
|
|
2249
|
+
const claimed = Array.isArray(reported[record.id]) ? reported[record.id] : []
|
|
2250
|
+
const paths = []
|
|
2251
|
+
let used = 0
|
|
2252
|
+
let full = false
|
|
2253
|
+
for (const p of claimed) {
|
|
2254
|
+
if (full || typeof p !== 'string' || /[\r\n]/.test(p)) continue
|
|
2255
|
+
if (used + p.length > LIMITS.maxHandoffChars) {
|
|
2256
|
+
// The first path that does not fit closes the list: what is shown is a
|
|
2257
|
+
// prefix of what was reported, not whichever entries happened to be short.
|
|
2258
|
+
full = true
|
|
2259
|
+
continue
|
|
2260
|
+
}
|
|
2261
|
+
paths.push(p)
|
|
2262
|
+
used += p.length
|
|
2263
|
+
}
|
|
2264
|
+
const entry = {
|
|
2265
|
+
taskId: record.id,
|
|
2266
|
+
reason: shortReason(record.reason) || 'handoff rejected',
|
|
2267
|
+
paths
|
|
2268
|
+
}
|
|
2269
|
+
if (paths.length < claimed.length) entry.pathsOmitted = claimed.length - paths.length
|
|
2270
|
+
return entry
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
/**
|
|
2274
|
+
* Every rejected report of the run, in the order they were recorded.
|
|
2275
|
+
*
|
|
2276
|
+
* For a reader that sees the whole change at once — the review — rather than
|
|
2277
|
+
* one wave's successor. A wave with no later wave has nobody else to tell.
|
|
2278
|
+
*
|
|
2279
|
+
* @returns {Array<{taskId: string, reason: string, paths: string[], pathsOmitted?: number}>}
|
|
2280
|
+
*/
|
|
2281
|
+
export function runRejectedReports(state) {
|
|
2282
|
+
const rejected = state && Array.isArray(state.rejectedReports) ? state.rejectedReports : []
|
|
2283
|
+
const reported =
|
|
2284
|
+
state && state.reportedPaths && typeof state.reportedPaths === 'object' ? state.reportedPaths : {}
|
|
2285
|
+
return rejected
|
|
2286
|
+
.filter(r => r && typeof r.id === 'string')
|
|
2287
|
+
.map(r => rejectedReportEntry(r, reported))
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
// Present on the step only when non-empty, so a step for a run that rejected
|
|
2291
|
+
// nothing is byte-identical to one produced before the field existed.
|
|
2292
|
+
function rejectedReportsField(state, waveIndex) {
|
|
2293
|
+
const list = previousWaveRejectedReports(state, waveIndex)
|
|
2294
|
+
return list.length ? { previousRejectedReports: list } : {}
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2192
2297
|
// Groups a replan may still touch: implementation waves at or after the first
|
|
2193
2298
|
// index the cursor has not committed to, minus anything already executed.
|
|
2194
2299
|
function firstMutableIndex(state) {
|
|
@@ -2248,14 +2353,18 @@ function normalizeTaskResults(result) {
|
|
|
2248
2353
|
}
|
|
2249
2354
|
}
|
|
2250
2355
|
|
|
2251
|
-
// Claiming success is what obliges a report
|
|
2356
|
+
// Claiming success is what obliges a report, and a report that cannot be
|
|
2357
|
+
// used is rejected rather than passed on. The task keeps its own claim: the
|
|
2358
|
+
// packet's shape says nothing about the work, which is already in the tree.
|
|
2359
|
+
// The rejection is carried beside the success, not in place of it.
|
|
2252
2360
|
if (!checked.ok) {
|
|
2253
2361
|
return {
|
|
2254
2362
|
id: t.id,
|
|
2255
|
-
ok:
|
|
2256
|
-
error
|
|
2363
|
+
ok: true,
|
|
2364
|
+
error,
|
|
2257
2365
|
filesChanged,
|
|
2258
|
-
handoff: null
|
|
2366
|
+
handoff: null,
|
|
2367
|
+
reportRejected: checked.error
|
|
2259
2368
|
}
|
|
2260
2369
|
}
|
|
2261
2370
|
return { id: t.id, ok: true, error, filesChanged, handoff: checked.handoff }
|
|
@@ -2298,6 +2407,7 @@ export function recordBatchResult(state, result, opts = {}) {
|
|
|
2298
2407
|
// initialized, never treated as malformed. (design.md — Migration Plan.)
|
|
2299
2408
|
if (!next.reportedPaths || typeof next.reportedPaths !== 'object') next.reportedPaths = {}
|
|
2300
2409
|
if (!next.evidenceAudits || typeof next.evidenceAudits !== 'object') next.evidenceAudits = {}
|
|
2410
|
+
if (!Array.isArray(next.rejectedReports)) next.rejectedReports = []
|
|
2301
2411
|
|
|
2302
2412
|
// Resolved once per batch: git's answer is about the working tree, not about
|
|
2303
2413
|
// one task, so asking it per task would be the same answer at a higher cost
|
|
@@ -2319,8 +2429,19 @@ export function recordBatchResult(state, result, opts = {}) {
|
|
|
2319
2429
|
{ source: observed.length ? 'observed' : 'reported', reportedPaths: t.filesChanged }
|
|
2320
2430
|
)
|
|
2321
2431
|
}
|
|
2322
|
-
if (t.ok)
|
|
2323
|
-
|
|
2432
|
+
if (t.ok) {
|
|
2433
|
+
next.completed.push(t.id)
|
|
2434
|
+
if (typeof t.reportRejected === 'string') {
|
|
2435
|
+
next.rejectedReports.push({
|
|
2436
|
+
id: t.id,
|
|
2437
|
+
wave: wave.group,
|
|
2438
|
+
waveKind: wave.kind,
|
|
2439
|
+
// Capped where it is stored, so every reader of the record holds a
|
|
2440
|
+
// short adjudication reason and none of them a transcript.
|
|
2441
|
+
reason: shortReason(t.reportRejected) || 'handoff rejected'
|
|
2442
|
+
})
|
|
2443
|
+
}
|
|
2444
|
+
} else {
|
|
2324
2445
|
next.failures.push({
|
|
2325
2446
|
id: t.id,
|
|
2326
2447
|
wave: wave.group,
|
|
@@ -2393,8 +2514,9 @@ export function recordBatchResult(state, result, opts = {}) {
|
|
|
2393
2514
|
// What a batch recorded
|
|
2394
2515
|
// ---------------------------------------------------------------------------
|
|
2395
2516
|
//
|
|
2396
|
-
// `recordBatchResult` already adjudicates every task —
|
|
2397
|
-
//
|
|
2517
|
+
// `recordBatchResult` already adjudicates every task — a task that claimed
|
|
2518
|
+
// success with an unusable packet is done with its report rejected, a lane result
|
|
2519
|
+
// missing an outcome fails the whole lane — and
|
|
2398
2520
|
// then tells its caller nothing but the next action. So a caller learned what
|
|
2399
2521
|
// to do next and never learned what had just been decided, and both ship hosts
|
|
2400
2522
|
// ticked checkboxes and counted waves from the agent's own claim instead. That
|
|
@@ -2404,7 +2526,11 @@ export function recordBatchResult(state, result, opts = {}) {
|
|
|
2404
2526
|
// describes one batch, and a durable field holding it would be read as current
|
|
2405
2527
|
// by a run resumed a day later.
|
|
2406
2528
|
|
|
2407
|
-
/**
|
|
2529
|
+
/**
|
|
2530
|
+
* A task the batch recorded as done. When its handoff packet was unusable the
|
|
2531
|
+
* outcome still reads ok and carries `reportRejected` beside it: the task's
|
|
2532
|
+
* claim and its record agree, and the rejection is a fact about the report.
|
|
2533
|
+
*/
|
|
2408
2534
|
export const OUTCOME_OK = 'ok'
|
|
2409
2535
|
/** A task the batch adjudicated as failed, with the reason it was failed for. */
|
|
2410
2536
|
export const OUTCOME_FAILED = 'failed'
|
|
@@ -2447,7 +2573,8 @@ function plannedBatchIds(state) {
|
|
|
2447
2573
|
*
|
|
2448
2574
|
* @param {object} before the state passed to `recordBatchResult`
|
|
2449
2575
|
* @param {object} after the state it returned
|
|
2450
|
-
* @returns {Array<{id: string, outcome: string, reason?: string
|
|
2576
|
+
* @returns {Array<{id: string, outcome: string, reason?: string,
|
|
2577
|
+
* reportRejected?: string}>} this batch's
|
|
2451
2578
|
* outcomes only — never the run's accumulated history. Ordered by the plan,
|
|
2452
2579
|
* with anything recorded that the plan did not name appended after it.
|
|
2453
2580
|
*/
|
|
@@ -2457,9 +2584,21 @@ export function batchOutcomes(before, after) {
|
|
|
2457
2584
|
const completed = after && Array.isArray(after.completed) ? after.completed.slice(prevCompleted) : []
|
|
2458
2585
|
const failures = after && Array.isArray(after.failures) ? after.failures.slice(prevFailures) : []
|
|
2459
2586
|
|
|
2587
|
+
const prevRejected =
|
|
2588
|
+
before && Array.isArray(before.rejectedReports) ? before.rejectedReports.length : 0
|
|
2589
|
+
const rejected = new Map()
|
|
2590
|
+
for (const r of after && Array.isArray(after.rejectedReports) ? after.rejectedReports.slice(prevRejected) : []) {
|
|
2591
|
+
if (r && typeof r.id === 'string') rejected.set(r.id, shortReason(r.reason))
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2460
2594
|
const outcomes = new Map()
|
|
2461
2595
|
for (const id of completed) {
|
|
2462
|
-
if (typeof id
|
|
2596
|
+
if (typeof id !== 'string') continue
|
|
2597
|
+
const reportRejected = rejected.get(id)
|
|
2598
|
+
outcomes.set(
|
|
2599
|
+
id,
|
|
2600
|
+
reportRejected ? { id, outcome: OUTCOME_OK, reportRejected } : { id, outcome: OUTCOME_OK }
|
|
2601
|
+
)
|
|
2463
2602
|
}
|
|
2464
2603
|
for (const f of failures) {
|
|
2465
2604
|
if (!f || typeof f.id !== 'string') continue
|
|
@@ -2664,6 +2803,7 @@ function summarize(state) {
|
|
|
2664
2803
|
waves: state.waves.length,
|
|
2665
2804
|
tasksCompleted: state.completed.length,
|
|
2666
2805
|
taskFailures: state.failures.length,
|
|
2806
|
+
reportsRejected: Array.isArray(state.rejectedReports) ? state.rejectedReports.length : 0,
|
|
2667
2807
|
replansUsed: state.replansUsed,
|
|
2668
2808
|
unresolved: state.unresolved,
|
|
2669
2809
|
skippedVerifications: state.skippedVerifications,
|
|
@@ -2708,10 +2848,12 @@ export function formatRunState(state) {
|
|
|
2708
2848
|
requireState(state)
|
|
2709
2849
|
const step = nextStep(state)
|
|
2710
2850
|
const lines = []
|
|
2851
|
+
const reportsRejected = Array.isArray(state.rejectedReports) ? state.rejectedReports.length : 0
|
|
2711
2852
|
lines.push(
|
|
2712
2853
|
`run: ${state.completed.length} task(s) done, ${state.failures.length} failed ` +
|
|
2713
2854
|
`(halts above ${LIMITS.taskFailureHalt}), ` +
|
|
2714
|
-
`${state.replansUsed}/${LIMITS.replansPerRun} replans used`
|
|
2855
|
+
`${state.replansUsed}/${LIMITS.replansPerRun} replans used` +
|
|
2856
|
+
(reportsRejected > 0 ? `, ${reportsRejected} report(s) rejected` : '')
|
|
2715
2857
|
)
|
|
2716
2858
|
lines.push(` next: ${describeStep(step)}`)
|
|
2717
2859
|
for (const f of state.failures) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@renzrollon/interlock",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.5.0",
|
|
4
|
+
"description": "Spec-driven development for Claude Code, layered on OpenSpec. You read one spec; parallel agents ship it to a tested commit.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=18"
|
package/shared/TEST-PROFILE.md
CHANGED
|
@@ -67,7 +67,7 @@ the profile.
|
|
|
67
67
|
| `single_file` | How to run one file (string with path implied, or template) |
|
|
68
68
|
| `env` | Extra env vars required for a green run (not secrets — names/placeholders only) |
|
|
69
69
|
| `prerequisites` | Human-readable setup steps (dev server, seeded DB, `npx playwright install`, …) |
|
|
70
|
-
| `timeout_ms` | Soft budget for one full suite run; do not raise to mask races |
|
|
70
|
+
| `timeout_ms` | Soft budget for one full suite run; do not raise to mask races. The ship run's inter-wave verify budget is derived from `unit.timeout_ms` (floored at the published budget); `interlock verify plan --context inter-wave --json` prints the result |
|
|
71
71
|
| `coverage.enabled` | Whether a coverage command is known. `false` = not configured (never an error) |
|
|
72
72
|
| `coverage.command` | Full shell command that produces a coverage report (e.g. `npm run coverage`) |
|
|
73
73
|
| `coverage.report_path` | Where the report lands, relative to `unit.cwd` (`coverage/lcov.info`, `coverage/coverage-summary.json`, …) |
|
|
@@ -32,21 +32,28 @@ This is archaeology, not design. Every spec you write describes behavior that ex
|
|
|
32
32
|
test -f openspec/config.yaml || openspec init --tools claude
|
|
33
33
|
|
|
34
34
|
# Build the structural graph once — it seeds every explorer below.
|
|
35
|
-
# Never swallow the outcome: stderr stays visible, and
|
|
36
|
-
# (build errored / nothing
|
|
35
|
+
# Never swallow the outcome: stderr stays visible, and every way the graph can
|
|
36
|
+
# come back less than whole (build errored / nothing readable / nothing
|
|
37
|
+
# indexable / some paths unreadable) gets a banner.
|
|
37
38
|
graph_log=$(interlock-graph build .)
|
|
38
39
|
graph_status=$?
|
|
39
40
|
printf '%s\n' "$graph_log"
|
|
40
41
|
if [ "$graph_status" -ne 0 ]; then
|
|
41
42
|
echo "GRAPH UNAVAILABLE: build errored (exit $graph_status) — explorers fall back to grep and will be slower"
|
|
43
|
+
elif printf '%s' "$graph_log" | grep -q 'nothing indexable under the root could be read'; then
|
|
44
|
+
echo "GRAPH UNAVAILABLE: nothing under the root could be read — explorers fall back to grep and will be slower"
|
|
42
45
|
elif printf '%s' "$graph_log" | grep -q 'nodes=0'; then
|
|
43
46
|
echo "GRAPH UNAVAILABLE: nothing indexable found — explorers fall back to grep and will be slower"
|
|
47
|
+
elif printf '%s' "$graph_log" | grep -q 'skipped='; then
|
|
48
|
+
echo "GRAPH PARTIAL: some paths were unreadable and are not in the graph (each is named above as GRAPH SKIPPED UNREADABLE) — a missing edge for one of them is not evidence of absence"
|
|
44
49
|
fi
|
|
45
50
|
```
|
|
46
51
|
|
|
47
52
|
**A graph failure is not a stop.** Bootstrap works without it — docs and OpenSpec indexing still happen — so print the banner, carry it to the final report, and continue with degraded expectations: the explorers grep instead of following edges, and they are slower for it.
|
|
48
53
|
|
|
49
|
-
|
|
54
|
+
**A partial graph is not a failure, and not a whole graph either.** `interlock-graph build` exits 0 when it meets a path it cannot read: it skips the path, names it on stderr as `GRAPH SKIPPED UNREADABLE: <path> (<code>)`, and ends its summary line in `skipped=<n>`. The exit code alone no longer tells you the graph is complete. The explorers still follow edges, but nothing about a skipped path is in the graph, so tell them which paths those were and have them `Read` or grep there instead of trusting an empty result.
|
|
55
|
+
|
|
56
|
+
`nodes=0` is the ordinary case for a repo the graph doesn't structurally index (Go, Rust, Java, Ruby — structural indexing covers JavaScript/TypeScript, Python, shell, C# (`.cs`, `.csproj`) and Bicep (`.bicep`)). Say which reason applied — an unsupported language, or a tree that could not be read — rather than presenting either as a defect.
|
|
50
57
|
|
|
51
58
|
When the build did produce a graph, **read `.claude/graph/GRAPH_REPORT.md` before spawning anything**. Its hub nodes and module boundaries tell the explorers where to look, so they don't rediscover the tree from scratch.
|
|
52
59
|
|
|
@@ -159,7 +166,7 @@ Fill in the project context in `openspec/config.yaml` with what was discovered
|
|
|
159
166
|
|
|
160
167
|
## 6. REPORT
|
|
161
168
|
|
|
162
|
-
- Any `GRAPH UNAVAILABLE: <reason> — explorers fall back to grep and will be slower` banner from pre-flight, repeated verbatim. If the graph built, say so in one line instead — a report that mentions
|
|
169
|
+
- Any `GRAPH UNAVAILABLE: <reason> — explorers fall back to grep and will be slower` banner from pre-flight, repeated verbatim. A `GRAPH PARTIAL` banner is repeated verbatim too, followed by the paths the build named as `GRAPH SKIPPED UNREADABLE`. If the graph built whole, say so in one line instead — a report that mentions none of these reads like a graph-backed run that never happened.
|
|
163
170
|
- Architecture doc path and what it covers
|
|
164
171
|
- Specs created, and specs skipped because they already existed
|
|
165
172
|
- Features deliberately not specced, with the reason
|
|
@@ -109,6 +109,7 @@ Run the full suite again, then assert against the baseline:
|
|
|
109
109
|
## 7. Persist
|
|
110
110
|
|
|
111
111
|
- Refresh `.claude/testing/profile.json` (`updated`, `known_flaky`, `notes`) with anything learned about running tests. Preserve every key you did not resolve yourself.
|
|
112
|
+
- After a green **full** unit run, record `unit.timeout_ms` as that run's wall time in whole milliseconds, rounded up — a JSON integer, never seconds, a decimal or a string, any of which either aborts verification planning or reads as a far smaller allowance. Never write a value below a wall time you measured: a ship run derives its inter-wave verify budget from this field, and a value under the real runtime makes every checkpoint skip the suite. Do not apply a multiplier and do not copy a number from elsewhere — the measured time is the input.
|
|
112
113
|
- Confirm the `## Testing` block is still a single idempotent pair of markers.
|
|
113
114
|
- Write at most **3** `.claude/memory/failure-modes/<slug>.md` entries for genuinely recurring gotchas, with index lines in `.claude/memory/MEMORY.md`. One-off typos do not belong in memory.
|
|
114
115
|
- Write `.claude/metrics/fix-tests-<YYYYMMDD-HHMMSS>.json` with the baseline, final counts, clusters, fixes applied, and anything unresolved.
|
package/skills/graph/SKILL.md
CHANGED
|
@@ -14,10 +14,21 @@ metadata:
|
|
|
14
14
|
|
|
15
15
|
Build and query a **local, deterministic** codebase knowledge graph. No vector store, no external service, no network.
|
|
16
16
|
|
|
17
|
-
**What gets indexed:** JS/TS, Python (`.py`/`.pyi`), shell (`.sh`/`.bash`), and curated small config JSON (`package.json`, `tsconfig*.json` — not lockfiles). Markdown stays on the bounded-retrieval path (`DOCS_DIGEST.md` / `context` / `docs`) plus the OpenSpec overlay — never dump every `*.md` into `graph.json`.
|
|
17
|
+
**What gets indexed:** JS/TS, Python (`.py`/`.pyi`), shell (`.sh`/`.bash`), C# (`.cs`, and `.csproj` project references), Bicep (`.bicep`), and curated small config JSON (`package.json`, `tsconfig*.json` — not lockfiles). Markdown stays on the bounded-retrieval path (`DOCS_DIGEST.md` / `context` / `docs`) plus the OpenSpec overlay — never dump every `*.md` into `graph.json`.
|
|
18
18
|
|
|
19
19
|
Languages outside that set (Go, Rust, Java, Ruby) are **not** structurally indexed: you still get the docs and OpenSpec overlays, but no import/symbol edges. Say so plainly rather than implying the graph is complete.
|
|
20
20
|
|
|
21
|
+
**C# is inferred, not resolved.** A `.csproj` `ProjectReference` is an `EXTRACTED` project-to-project edge. A C# type reference and a test-to-type link are `INFERRED`: they come from identifier matching against the namespaces a file can see, not from a compiler. Treat them as hints to verify. Four limits are known and not worked around:
|
|
22
|
+
|
|
23
|
+
- A type named only by its fully-qualified name, with no `using`, is not seen.
|
|
24
|
+
- `global using` applies to the file that holds it, not to its project.
|
|
25
|
+
- A nested type is a symbol with no record of its nesting.
|
|
26
|
+
- A declaration-shaped line inside a verbatim or raw string literal is a false positive.
|
|
27
|
+
|
|
28
|
+
**Unreadable paths do not stop a build.** A file or directory the build cannot read is skipped, and each one is named on stderr as `GRAPH SKIPPED UNREADABLE: <path> (<code>)`. `build` and `update` still finish and exit 0; the summary line carries `skipped=<n>` and `GRAPH_REPORT.md` lists the paths under a "Not indexed" heading. Report those paths to the user rather than treating the graph as complete.
|
|
29
|
+
|
|
30
|
+
**A query says when its graph is partial.** `query`, `consumers`, `path`, `explain` and `context` print one line on stderr, `GRAPH PARTIAL: <n> path(s) were unreadable when this graph was built…`, whenever the graph they read was built past skipped paths. Their stdout and exit code are unchanged. When you see it, an empty or short answer is not evidence about the skipped paths: read `GRAPH_REPORT.md` for which they are, then `Read` or grep those paths directly before claiming a symbol has no other consumers.
|
|
31
|
+
|
|
21
32
|
**Artifacts, all project-local under `.claude/graph/`:**
|
|
22
33
|
|
|
23
34
|
| Path | Role |
|
package/workflows/ship.js
CHANGED
|
@@ -164,7 +164,8 @@ const RUNAWAY_BACKSTOP = 1000
|
|
|
164
164
|
// file whose first line carries its sha256; this script hands the worker the
|
|
165
165
|
// path and requires the hash back. A result whose `briefing` is absent or
|
|
166
166
|
// differs is treated as a null result — the task fails closed with a named
|
|
167
|
-
// reason
|
|
167
|
+
// reason. (A missing handoff packet no longer does: that task stays done and its
|
|
168
|
+
// report is rejected.)
|
|
168
169
|
//
|
|
169
170
|
// This text is plumbing, not policy: it names a file and a field and says
|
|
170
171
|
// nothing whatsoever about the work. It is pinned against
|