@renzrollon/interlock 1.1.0 → 1.2.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 (48) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +94 -1
  3. package/CHANGELOG.md +142 -0
  4. package/bin/interlock +413 -65
  5. package/bin/interlock-run +119 -7
  6. package/hooks/guard-relaunch.mjs +115 -0
  7. package/hooks/guard-tests.mjs +51 -3
  8. package/hooks/hooks.json +1 -0
  9. package/hooks/mod.mjs +655 -0
  10. package/hooks/preflight.mjs +83 -13
  11. package/hooks/recorder.mjs +189 -0
  12. package/lib/agent-usage.mjs +524 -0
  13. package/lib/doctor.mjs +488 -28
  14. package/lib/eval-history.mjs +233 -0
  15. package/lib/evals-capture.mjs +5 -2
  16. package/lib/graph/cli.mjs +28 -5
  17. package/lib/graph/retrieve/context.mjs +8 -3
  18. package/lib/host/acp.mjs +77 -14
  19. package/lib/host/claude-cli.mjs +391 -21
  20. package/lib/host/claude-env.mjs +179 -0
  21. package/lib/host/codex.mjs +9 -1
  22. package/lib/host/effort.mjs +163 -0
  23. package/lib/host/model-map.mjs +43 -0
  24. package/lib/host/qwen.mjs +9 -1
  25. package/lib/host/registry.mjs +44 -10
  26. package/lib/host.mjs +46 -5
  27. package/lib/interrupted.mjs +167 -0
  28. package/lib/launch-ledger.mjs +198 -0
  29. package/lib/launch-rule.mjs +174 -0
  30. package/lib/limits.mjs +153 -45
  31. package/lib/meter-quiet.mjs +45 -0
  32. package/lib/plan-fingerprint.mjs +83 -13
  33. package/lib/prompts/implementer.mjs +1 -1
  34. package/lib/prompts/verify.mjs +24 -1
  35. package/lib/receipt.mjs +59 -8
  36. package/lib/report-html.mjs +89 -6
  37. package/lib/report.mjs +313 -12
  38. package/lib/resume-card.mjs +90 -2
  39. package/lib/run-log.mjs +192 -5
  40. package/lib/run.mjs +1193 -85
  41. package/lib/state-home.mjs +298 -0
  42. package/lib/verify.mjs +37 -0
  43. package/lib/waves.mjs +285 -83
  44. package/package.json +2 -1
  45. package/shared/TEST-PROFILE.md +10 -5
  46. package/skills/fix-tests/SKILL.md +1 -1
  47. package/types/index.d.ts +28 -0
  48. package/workflows/ship.js +75 -51
package/lib/run.mjs CHANGED
@@ -22,15 +22,26 @@
22
22
  // through `ctx.deps` rather than being written a second time here.
23
23
 
24
24
  import { createHash } from 'node:crypto'
25
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
25
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
26
26
  import { dirname, join, resolve } from 'node:path'
27
27
 
28
- import { LIMITS, EFFORT } from './limits.mjs'
28
+ import { LIMITS, EFFORT, RUNTIME } from './limits.mjs'
29
29
  import { spawnPrefix } from './host.mjs'
30
30
  import { PLUGIN_ROOT } from './doctor.mjs'
31
31
  import { RED_SECTION_MARKER, inspectChange, planCoverage, tickTasks } from './artifacts.mjs'
32
32
  import { FINGERPRINT_PATH, computeFingerprint, resolvePlanReuse, writeFingerprint } from './plan-fingerprint.mjs'
33
33
  import { clearStage } from './ship-stage.mjs'
34
+ import { readModelRouting, runtimeSlotsOf, WORKFLOW_MAX_CONCURRENT_RANGE } from './host/claude-env.mjs'
35
+ import { modelMatches } from './host/model-map.mjs'
36
+ import { readAgentUsage } from './agent-usage.mjs'
37
+ import { resolveStateHome } from './state-home.mjs'
38
+ import {
39
+ formatInterruptedBanner,
40
+ formatUnreadableNote,
41
+ isUnspoken,
42
+ markSpoken,
43
+ readInterruptedNotes
44
+ } from './interrupted.mjs'
34
45
  import { appendRunLogEvent, checkRunLog } from './run-log.mjs'
35
46
  import { appendOutcome, observedFromReceipt } from './outcomes.mjs'
36
47
  import { readPredictedPaths, readTouchedPaths } from './run-paths.mjs'
@@ -45,7 +56,7 @@ import {
45
56
  import { detectUnarchived } from './drift.mjs'
46
57
  import { projectSlug } from './project-slug.mjs'
47
58
  import { composeCloseMessage, postNtfy, readNotifyConfig } from './notify.mjs'
48
- import { SKIP_REASONS, planVerification, judgeVerification } from './verify.mjs'
59
+ import { BANNERS as VERIFY_BANNERS, SKIP_REASONS, foldCheckTimings, planVerification, judgeVerification } from './verify.mjs'
49
60
  import { resolveReview, readReviewPolicy } from './review-core.mjs'
50
61
  import { writeReviewMetrics } from './metrics.mjs'
51
62
  import { planRemediation } from './remediate.mjs'
@@ -59,6 +70,7 @@ import {
59
70
  createRunState,
60
71
  laneEffort,
61
72
  laneLabel,
73
+ laneTitle,
62
74
  laneModel,
63
75
  nextStep,
64
76
  planWaves,
@@ -112,6 +124,15 @@ export const REPLAN_PATH = join(WORK_DIR, 'replan.json')
112
124
  // the agent reports about itself (design D2).
113
125
  export const FINDINGS_PATH = join(WORK_DIR, 'findings.json')
114
126
  export const VERDICTS_PATH = join(WORK_DIR, 'verdicts.json')
127
+ // The exact bytes the last `run` subcommand printed. The Workflow host's relay
128
+ // is a model retyping stdout, and when its copy does not parse, this is the
129
+ // only record of what the CLI actually said — the halt names it rather than
130
+ // leaving the reader to dig the relay out of a transcript.
131
+ export const LAST_STEP_PATH = join(WORK_DIR, 'last-step.json')
132
+ // One line per `interlock verify exec`: the planned check it ran, its exit code
133
+ // and how long it took, measured by the CLI around the command itself. `run
134
+ // judge` folds these into the inter-wave verify budget and clears the file.
135
+ export const VERIFY_TIMINGS_PATH = join(WORK_DIR, 'verify-timings.jsonl')
115
136
 
116
137
  /** The first line of every briefing file: what it is, whose it is, and its hash. */
117
138
  export const BRIEFING_HEADER = (label, sha) =>
@@ -169,6 +190,12 @@ export const ASSUMED_CAPABILITIES = Object.freeze({
169
190
  // manifest written before this key existed reads `false` here, which is the
170
191
  // honest answer — that run measured no cache figures either.
171
192
  cacheAccounting: false,
193
+ // Merge default only. A Workflow run does not keep it: `interlock run start
194
+ // --host workflow` replaces it with the `claude --help` probe before the
195
+ // manifest is written. A runner manifest written before the key existed also
196
+ // reads `flag` here, although that run applied no effort at all. Nothing
197
+ // reads the key from an old manifest — a receipt without the field is null.
198
+ effort: 'flag',
172
199
  billing: 'claude-subscription-programmatic'
173
200
  })
174
201
 
@@ -257,9 +284,9 @@ export function newManifest({ change, flags, host, hostCapabilities }) {
257
284
  // so a driver that skips or repeats a step is told which call was expected
258
285
  // rather than silently advancing the state machine twice.
259
286
  lastThen: null,
260
- // The shared-tree HEAD captured when an isolated batch was dispatched, held
261
- // across the driver round trip so `record-batch` folds against the base the
262
- // lanes actually forked from. The emitted step carries it for the driver;
287
+ // The shared-tree snapshot captured when an isolated batch was dispatched
288
+ // (HEAD itself on a clean tree), held across the driver round trip so
289
+ // `record-batch` folds against the base the lanes actually forked from. The emitted step carries it for the driver;
263
290
  // this is where the CLI reads it back, because the driver returns only
264
291
  // results and the wave state has no such field.
265
292
  mergeBase: null,
@@ -299,7 +326,14 @@ export function newManifest({ change, flags, host, hostCapabilities }) {
299
326
  handoff: null,
300
327
  // The autonomy-ladder record `run close` wrote, from the CLI's own
301
328
  // surviving-blocker count (design D6). Storage only; nothing prints a level.
302
- autonomy: null
329
+ autonomy: null,
330
+ // Every briefed spawn this run dispatched once it had an id — wave, label,
331
+ // kind, routed model and briefing sha — which is the table `run close` joins
332
+ // the recorder's sidecar against (ship/agent-results).
333
+ dispatched: [],
334
+ // The runner lanes whose host recorded a session, for the resume card's
335
+ // `claude --resume` lines. Empty on the Workflow host, which has none.
336
+ laneSessions: []
303
337
  }
304
338
  }
305
339
 
@@ -377,6 +411,62 @@ export function hostId(manifest) {
377
411
  return typeof host === 'string' && host ? host : null
378
412
  }
379
413
 
414
+ /**
415
+ * The fields of a step the Workflow host's interpreter reads, and no others.
416
+ *
417
+ * On that host a step reaches `workflows/ship.js` through a relay — a model that
418
+ * runs the command and RETYPES its stdout into a result field. A 23 KB step was
419
+ * retyped with a duplicated key run and two dropped brackets, did not parse, and
420
+ * halted a healthy run at wave 6. Nearly all of those bytes were things the
421
+ * interpreter never reads: each spawn's inline `prompt` (the worker is handed
422
+ * `promptPath` and `promptSha256` instead), and the carried wave state —
423
+ * `remainingBatches`, `previousHandoffs`, `changed` — which the next `run`
424
+ * subcommand reads back from `state.json`, never from the step.
425
+ *
426
+ * A whitelist rather than a list of what to drop, so a field added to the step
427
+ * later cannot quietly grow the relay again. The other direction — the
428
+ * interpreter starting to read a field this drops — is pinned by a scan of
429
+ * ship.js in test/workflows.test.mjs.
430
+ */
431
+ export const RELAY_STEP_FIELDS = Object.freeze([
432
+ 'schema',
433
+ 'action',
434
+ 'then',
435
+ 'spawns',
436
+ 'banners',
437
+ 'change',
438
+ 'wave',
439
+ 'reason',
440
+ // The control-plane ping model `run start` decided (design D3). The driver
441
+ // sets its relay pings' model from this field and from nothing else.
442
+ 'pingModel'
443
+ ])
444
+
445
+ /**
446
+ * The step as the run's host should receive it.
447
+ *
448
+ * Branches on the host id for the reason `hostSessionId` does: "stdout is
449
+ * copied by a model" is a property of the Workflow runtime alone — the runner
450
+ * refuses that id and reads stdout as a process. Every other host, and a run
451
+ * that named none, gets the whole step: `bin/interlock-run` sends `prompt`
452
+ * inline and needs it.
453
+ */
454
+ export function relayStep(manifest, step) {
455
+ if (hostId(manifest) !== WORKFLOW_HOST || !step || typeof step !== 'object') return step
456
+ const out = {}
457
+ for (const field of RELAY_STEP_FIELDS) {
458
+ if (step[field] !== undefined) out[field] = step[field]
459
+ }
460
+ if (Array.isArray(step.spawns)) {
461
+ out.spawns = step.spawns.map(s => {
462
+ if (!s || typeof s !== 'object') return s
463
+ const { prompt, ...rest } = s
464
+ return rest
465
+ })
466
+ }
467
+ return out
468
+ }
469
+
380
470
  // --- the step budget (design D9) --------------------------------------------
381
471
 
382
472
  /**
@@ -583,6 +673,313 @@ export function summarizeUsage(manifest) {
583
673
  }
584
674
  }
585
675
 
676
+ // --- what the host observed about each agent (ship/agent-results) -----------
677
+ //
678
+ // Two hosts, two channels, one set of verdicts. On a runner the adapter reads
679
+ // each lane's `claude -p` envelope and its session transcript and forwards a
680
+ // host record beside the result (`--host-records`); on the Workflow host the
681
+ // recorder hooks leave one sidecar file per agent, which the close joins to the
682
+ // spawns this program dispatched. Either way the CLI decides here, in the words
683
+ // below, and a driver decides none of it.
684
+
685
+ /** The one wording both hosts print for a lane a different model ran (design D6). */
686
+ export function modelSubstitutedBanner(label, routed, ran) {
687
+ return `MODEL SUBSTITUTED: ${label} routed ${routed}, ran ${ran.join(', ')}`
688
+ }
689
+
690
+ /** A lane the host ended with an error or a non-zero exit, named by what it reported. */
691
+ export function laneStoppedBanner(label, how) {
692
+ return `LANE STOPPED BY HOST: ${label} ${how}`
693
+ }
694
+
695
+ /** A success with no structured result: the open vendor bug this adapter's configuration triggers. */
696
+ export function schemaResultMissingBanner(label) {
697
+ return (
698
+ `SCHEMA RESULT MISSING (claude): ${label} — success without structured_output ` +
699
+ `(anthropics/claude-code#82258)`
700
+ )
701
+ }
702
+
703
+ /** The host denied tool calls inside a lane. A count the reader interprets, never a verdict. */
704
+ export function toolsDeniedBanner(label, count, tools) {
705
+ return `TOOLS DENIED IN LANE: ${label} ${count}${tools.length ? ` (${tools.join(', ')})` : ''}`
706
+ }
707
+
708
+ /**
709
+ * Whether a spawn ran on a model other than the one it was routed to (design D6).
710
+ *
711
+ * The matcher compares; what it is fed decides what a mismatch MEANS, and the
712
+ * two observations do not mean the same thing. `servedModels` is turn-scoped —
713
+ * the model of every assistant turn the agent itself took, read from its own
714
+ * transcript — so ANY unmatched id is a model the plan did not choose, a
715
+ * fallback that served one turn included. `sessionModels` is session-scoped —
716
+ * the `claude -p` envelope's per-model breakdown, which also lists the host's
717
+ * internal calls (every captured envelope carried a haiku entry the session was
718
+ * not routed to) — so there only a session the routed model NEVER served is
719
+ * established. The strongest observation present decides; neither, or no
720
+ * routed model, decides nothing.
721
+ *
722
+ * The only caller of `modelMatches`; called from the runner's record path and
723
+ * the Workflow close, and nowhere else (`test/spine/host-adapters.test.mjs`).
724
+ *
725
+ * @param {string|null|undefined} routed
726
+ * @param {{servedModels?: string[]|null, sessionModels?: string[]|null}} [observed]
727
+ * @returns {{substituted: boolean|null, scope: 'turns'|'session'|null, ran: string[]}}
728
+ */
729
+ export function modelSubstitution(routed, { servedModels, sessionModels } = {}) {
730
+ const want = typeof routed === 'string' ? routed.trim() : ''
731
+ const ids = value => (Array.isArray(value) ? value.filter(m => typeof m === 'string' && m.trim()) : [])
732
+ const served = ids(servedModels)
733
+ const session = ids(sessionModels)
734
+ if (!want) return { substituted: null, scope: null, ran: [] }
735
+ if (served.length) {
736
+ return { substituted: served.some(m => modelMatches(want, m) === false), scope: 'turns', ran: served }
737
+ }
738
+ if (session.length) {
739
+ return { substituted: !session.some(m => modelMatches(want, m) === true), scope: 'session', ran: session }
740
+ }
741
+ return { substituted: null, scope: null, ran: [] }
742
+ }
743
+
744
+ /** The most recent spawn this run dispatched under a label, or null. */
745
+ function dispatchedSpawn(manifest, label) {
746
+ const list = Array.isArray(manifest.dispatched) ? manifest.dispatched : []
747
+ for (let i = list.length - 1; i >= 0; i--) if (list[i] && list[i].label === label) return list[i]
748
+ return null
749
+ }
750
+
751
+ /** The receipt's runner counts, created on the first host record and never before. */
752
+ function agentCounts(manifest) {
753
+ if (!manifest.agentCounts || typeof manifest.agentCounts !== 'object') {
754
+ manifest.agentCounts = { lanesStoppedByHost: 0, schemaResultsMissing: 0, toolsDeniedInLanes: 0, modelSubstitutions: 0 }
755
+ }
756
+ return manifest.agentCounts
757
+ }
758
+
759
+ /**
760
+ * Every record path's bookkeeping for the spawns it received: the usage the
761
+ * results carry, as before, and — on a runner — what the host observed about
762
+ * each one (design D9).
763
+ *
764
+ * For each host record it raises the lane banners, stores the lane's session for
765
+ * the resume card, increments the receipt's counts, and appends one
766
+ * `agent-result` to the trajectory. That append is the trajectory's fatal class:
767
+ * the failure is returned for the caller to halt on BEFORE anything is ticked.
768
+ * A step with no host records — a Workflow run, a host that sent none — raises
769
+ * nothing and counts nothing: absence stays unknown.
770
+ *
771
+ * @returns {{failure: string|null}}
772
+ */
773
+ function recordSpawnObservations(ctx, manifest, { results, hostRecords, wave = null } = {}) {
774
+ recordUsage(manifest, results, wave)
775
+ const records = Array.isArray(hostRecords) ? hostRecords.filter(r => r && typeof r === 'object') : []
776
+ if (!records.length) return { failure: null }
777
+ const counts = agentCounts(manifest)
778
+ if (!Array.isArray(manifest.laneSessions)) manifest.laneSessions = []
779
+ if (!Array.isArray(manifest.banners)) manifest.banners = []
780
+ let failure = null
781
+
782
+ for (const record of records) {
783
+ const label = typeof record.label === 'string' && record.label ? record.label : 'agent'
784
+ const subtype = typeof record.subtype === 'string' && record.subtype ? record.subtype : null
785
+ const exitCode = Number.isInteger(record.exitCode) ? record.exitCode : null
786
+ const stopped = record.isError === true || record.timedOut === true || (exitCode !== null && exitCode !== 0)
787
+
788
+ let outcome = 'returned'
789
+ if (stopped) {
790
+ const how =
791
+ subtype && subtype !== 'success'
792
+ ? subtype
793
+ : record.timedOut === true
794
+ ? 'timed out'
795
+ : exitCode !== null && exitCode !== 0
796
+ ? `exit ${exitCode}`
797
+ : subtype || 'error'
798
+ manifest.banners.push(laneStoppedBanner(label, how))
799
+ counts.lanesStoppedByHost++
800
+ outcome = `stopped by the host (${how})`
801
+ }
802
+ if (record.resultMissing === true) {
803
+ manifest.banners.push(schemaResultMissingBanner(label))
804
+ counts.schemaResultsMissing++
805
+ if (!stopped) outcome = 'returned no structured result'
806
+ }
807
+ const denials = record.permissionDenials && typeof record.permissionDenials === 'object' ? record.permissionDenials : null
808
+ const denied = denials && Number.isInteger(denials.count) ? denials.count : null
809
+ if (denied !== null && denied > 0) {
810
+ const tools = [...new Set((Array.isArray(denials.tools) ? denials.tools : []).filter(t => typeof t === 'string' && t))]
811
+ manifest.banners.push(toolsDeniedBanner(label, denied, tools))
812
+ counts.toolsDeniedInLanes++
813
+ }
814
+ const verdict = modelSubstitution(record.modelRouted, record)
815
+ if (verdict.substituted === true) {
816
+ manifest.banners.push(modelSubstitutedBanner(label, record.modelRouted.trim(), verdict.ran))
817
+ counts.modelSubstitutions++
818
+ }
819
+ if (typeof record.sessionId === 'string' && record.sessionId) {
820
+ manifest.laneSessions.push({ label, sessionId: record.sessionId, outcome })
821
+ }
822
+
823
+ if (!manifest.runId) continue
824
+ const spawned = dispatchedSpawn(manifest, label)
825
+ const written = appendRunLogEvent(stateHomeOf(ctx, manifest), {
826
+ runId: manifest.runId,
827
+ change: manifest.change,
828
+ type: 'agent-result',
829
+ label,
830
+ kind: spawned ? spawned.kind : undefined,
831
+ source: 'envelope',
832
+ subtype: record.subtype,
833
+ isError: record.isError,
834
+ terminalReason: record.terminalReason,
835
+ errors: record.errors,
836
+ permissionDenials: record.permissionDenials,
837
+ sessionId: record.sessionId,
838
+ numTurns: record.numTurns,
839
+ modelRouted: record.modelRouted,
840
+ servedModels: record.servedModels,
841
+ sessionModels: record.sessionModels,
842
+ modelScope: verdict.scope,
843
+ substituted: verdict.substituted,
844
+ resultMissing: record.resultMissing,
845
+ usage: record.usage,
846
+ hostCostUsd: record.hostCostUsd
847
+ })
848
+ if (!written.written) {
849
+ ctx.warn(`agent-result not recorded: ${written.reason}`)
850
+ if (!failure) failure = `${label}: ${written.reason}`
851
+ }
852
+ }
853
+ return { failure }
854
+ }
855
+
856
+ /** One wave's or the run's cache figures, summed over joined agents, unknown-contagious. */
857
+ function sumJoinedCache(agents) {
858
+ let read = 0
859
+ let tiers = { ephemeral_5m: 0, ephemeral_1h: 0 }
860
+ let total = 0
861
+ for (const agent of agents) {
862
+ const usage = agent.usage
863
+ read = read === null || !Number.isFinite(usage.cacheReadInputTokens) ? null : read + usage.cacheReadInputTokens
864
+ const creation = usage.cacheCreationInputTokens
865
+ const split =
866
+ creation && Number.isFinite(creation.ephemeral_5m) && Number.isFinite(creation.ephemeral_1h)
867
+ ? creation
868
+ : null
869
+ const known = split ? split.ephemeral_5m + split.ephemeral_1h : creation && Number.isFinite(creation.total) ? creation.total : null
870
+ total = total === null || known === null ? null : total + known
871
+ // One agent recorded only a total: the wave's split is unknown, its total is not.
872
+ tiers = tiers === null || !split ? null : { ephemeral_5m: tiers.ephemeral_5m + split.ephemeral_5m, ephemeral_1h: tiers.ephemeral_1h + split.ephemeral_1h }
873
+ }
874
+ return {
875
+ cacheReadInputTokens: read,
876
+ cacheCreationInputTokens: tiers !== null ? tiers : total !== null ? { total } : null
877
+ }
878
+ }
879
+
880
+ /**
881
+ * Join what the recorder hooks wrote for this run to the spawns it dispatched
882
+ * (design D5), by the briefing sha256 every bootstrap carries.
883
+ *
884
+ * The denominator is the briefed spawns dispatched once the run had an id: the
885
+ * recorder writes nothing without one, so a spawn dispatched before it (the
886
+ * planner) could never join, and counting it would make every run partial. A
887
+ * spawn that did not join, or joined a transcript with no usage, makes its
888
+ * wave's figures unknown. A recorded agent nobody dispatched — a relay ping,
889
+ * which carries no briefing, or any other workflow the matcher admitted — is
890
+ * counted apart and attributed nowhere.
891
+ */
892
+ function joinAgentUsage(root, manifest) {
893
+ const read = manifest.runId
894
+ ? readAgentUsage(root, manifest.runId)
895
+ : { present: false, agents: [], permissions: [], unreadable: [] }
896
+ const briefed = (Array.isArray(manifest.dispatched) ? manifest.dispatched : []).filter(
897
+ d => d && typeof d.sha === 'string' && d.sha
898
+ )
899
+ const pool = new Map()
900
+ for (const agent of read.agents) {
901
+ const key = typeof agent.briefingSha === 'string' && agent.briefingSha ? agent.briefingSha : null
902
+ if (!key) continue
903
+ if (!pool.has(key)) pool.set(key, [])
904
+ pool.get(key).push(agent)
905
+ }
906
+
907
+ const joined = []
908
+ const waves = new Map()
909
+ let unrecorded = 0
910
+ for (const spawned of briefed) {
911
+ const candidates = pool.get(spawned.sha) || []
912
+ const agent = candidates.shift() || null
913
+ const usable = Boolean(agent && agent.usage && typeof agent.usage === 'object')
914
+ if (!usable) unrecorded++
915
+ if (agent) joined.push({ spawned, agent })
916
+ const wave = Number.isInteger(spawned.wave) ? spawned.wave : null
917
+ if (!waves.has(wave)) waves.set(wave, { agents: [], complete: true })
918
+ const entry = waves.get(wave)
919
+ if (usable) entry.agents.push(agent)
920
+ else entry.complete = false
921
+ }
922
+ const leftover = [...pool.values()].flat().length
923
+ const noKey = read.agents.filter(a => !(typeof a.briefingSha === 'string' && a.briefingSha)).length
924
+
925
+ const cacheByWave = new Map()
926
+ for (const [wave, entry] of waves) {
927
+ cacheByWave.set(
928
+ wave,
929
+ entry.complete ? sumJoinedCache(entry.agents) : { cacheReadInputTokens: null, cacheCreationInputTokens: null }
930
+ )
931
+ }
932
+ const complete = read.present && briefed.length > 0 && unrecorded === 0
933
+ return {
934
+ present: read.present,
935
+ // Read before the close refines it: only a host that declared it cannot
936
+ // account for cache has its figures supplied by the hooks.
937
+ cacheFromHooks: hostCapability(manifest, 'cacheAccounting') === false,
938
+ briefed: briefed.length,
939
+ unrecorded,
940
+ complete,
941
+ joined,
942
+ cacheByWave,
943
+ runCache: complete
944
+ ? sumJoinedCache(joined.map(j => j.agent))
945
+ : { cacheReadInputTokens: null, cacheCreationInputTokens: null },
946
+ unjoined: { noKey, unknownKey: leftover },
947
+ permissions: read.permissions,
948
+ unreadable: read.unreadable
949
+ }
950
+ }
951
+
952
+ // --- the state home (ship/state-home) ----------------------------------------
953
+ //
954
+ // Corpora — the trajectory, the outcome line, the metrics, the resume card, the
955
+ // interrupted notes, the autonomy ledger — go to the state home, which is the
956
+ // main checkout when the run is in a linked worktree; per-run working state —
957
+ // this manifest, the wave state, briefings, spill, the marker, the sidecar —
958
+ // stays in `ctx.root`. `run start` resolves the home once and records it; every
959
+ // later subcommand reads it back from here and never resolves again.
960
+
961
+ /** Where this run's corpora are written: the recorded home, or the root for a manifest without one. */
962
+ export function stateHomeOf(ctx, manifest) {
963
+ return manifest && typeof manifest.stateHome === 'string' && manifest.stateHome ? manifest.stateHome : ctx.root
964
+ }
965
+
966
+ /** The test profile and the graph, located root-first then home (design D16). */
967
+ export const TEST_PROFILE_PATH = join('.claude', 'testing', 'profile.json')
968
+ export const GRAPH_FILE_PATH = join('.claude', 'graph', 'graph.json')
969
+
970
+ /** The graph line raised at start when neither the root nor the home has one. */
971
+ export const GRAPH_UNAVAILABLE_BANNER =
972
+ 'GRAPH UNAVAILABLE: never built — implementer and reviewer agents fall back to grep and will be slower'
973
+
974
+ /** Root first, then the home, never written: the inputs a worktree run reads through. */
975
+ function locateInput(root, home, relative) {
976
+ const atRoot = resolve(root, relative)
977
+ if (existsSync(atRoot)) return { path: atRoot, source: 'root' }
978
+ const atHome = resolve(home, relative)
979
+ if (resolve(home) !== resolve(root) && existsSync(atHome)) return { path: atHome, source: 'state-home' }
980
+ return { path: null, source: 'none' }
981
+ }
982
+
586
983
  // --- briefings (design D2) --------------------------------------------------
587
984
 
588
985
  // --- lane worktrees (design D7) ---------------------------------------------
@@ -631,8 +1028,9 @@ export function briefingHash(text) {
631
1028
  * The Workflow host cannot hold a briefing — it has no filesystem, and asking a
632
1029
  * ping to copy several kilobytes into a result field would breach the published
633
1030
  * result-field cap — so it spawns the worker with a fixed bootstrap naming this
634
- * path and requires the hash back. The ACP host reads `prompt` and sends it
635
- * inline. One file serves both.
1031
+ * path and requires the hash back — and never sees `prompt` at all, since
1032
+ * `relayStep` drops it from what that host's relay copies. The runner hosts
1033
+ * read `prompt` and send it inline. One file serves both.
636
1034
  */
637
1035
  export function writeBriefing(root, label, text) {
638
1036
  const promptSha256 = briefingHash(text)
@@ -656,9 +1054,12 @@ const AUTONOMY_LINE =
656
1054
  'guessing at product intent.'
657
1055
 
658
1056
  /** One agent a step asks for. */
659
- function spawn(root, { label, kind, model, effort, schema, prompt, isolation, worktree }) {
1057
+ function spawn(root, { label, title, kind, model, effort, schema, prompt, isolation, worktree }) {
660
1058
  return {
661
1059
  label,
1060
+ // What a person sees the agent as. `label` stays the key every record joins
1061
+ // on; a spawn with nothing to add is shown under its label.
1062
+ title: title || label,
662
1063
  kind,
663
1064
  model: model || null,
664
1065
  effort: effort === undefined ? null : effort,
@@ -786,6 +1187,7 @@ export function decorate(ctx, step, manifest, extra = {}) {
786
1187
  const spawns = lanes.map(lane =>
787
1188
  spawn(root, {
788
1189
  label: laneLabel(lane),
1190
+ title: laneTitle(lane),
789
1191
  kind: 'implementer',
790
1192
  model: laneModel(lane),
791
1193
  effort: laneEffort(lane),
@@ -809,13 +1211,19 @@ export function decorate(ctx, step, manifest, extra = {}) {
809
1211
  })
810
1212
  })
811
1213
  )
812
- // The shared tree's HEAD before this batch's lanes fork off — read now, not
813
- // after, so nothing else lands on the shared tree between the reading and
814
- // the fold. Only under isolation; unset, a batch runs byte-for-byte as it
815
- // did before worktree isolation existed.
1214
+ // The base this batch's lanes fork from: a snapshot of the shared tree as it
1215
+ // stands — tracked edits and every untracked file the ignore rules let
1216
+ // through — so it includes every earlier fold of this run. Not HEAD: the
1217
+ // ship commit is the run's last step, so HEAD never moves while batches
1218
+ // fold, and a lane forked from it edits a copy that lacks them; its
1219
+ // whole-file fold would then revert the earlier batch with exit 0. A clean
1220
+ // tree snapshots to HEAD itself. Captured now, not after, so nothing lands
1221
+ // on the shared tree between the capture and the fold. Only under
1222
+ // isolation; unset, a batch runs byte-for-byte as it did before worktree
1223
+ // isolation existed.
816
1224
  let mergeBase = null
817
1225
  if (isolate) {
818
- mergeBase = ctx.deps.headCommit(root)
1226
+ mergeBase = ctx.deps.snapshotTree(root, change)
819
1227
  if (!mergeBase) {
820
1228
  return haltStep(
821
1229
  'could not capture the shared-tree base commit before an isolated batch — merge-lanes ' +
@@ -826,8 +1234,8 @@ export function decorate(ctx, step, manifest, extra = {}) {
826
1234
  // Onto the manifest as well as onto the step. The step goes to the driver
827
1235
  // and the driver returns only results; `record-batch` reads a pure
828
1236
  // wave-state step that carries no such field, so without this the fold
829
- // would re-read HEAD *after* the lanes ran — a different commit whenever
830
- // anything else landed on the shared tree in between.
1237
+ // would re-read HEAD *after* the lanes ran — never the snapshot they
1238
+ // forked from.
831
1239
  manifest.mergeBase = mergeBase
832
1240
  }
833
1241
  return makeStep({
@@ -859,6 +1267,7 @@ export function decorate(ctx, step, manifest, extra = {}) {
859
1267
  label: `replan-${manifest.steps}`,
860
1268
  kind: 'ping',
861
1269
  model: 'haiku',
1270
+ // effort-routing: "Control-plane and prose spawns SHALL inherit the host's effort".
862
1271
  effort: null,
863
1272
  schema: REPLAN_SCHEMA,
864
1273
  prompt: assembleReplanPrompt({ change, replanPath: REPLAN_PATH })
@@ -912,7 +1321,7 @@ export function finalVerifyStep(ctx, manifest, carried = {}) {
912
1321
  function verifyStep(ctx, manifest, { context, label, changed, carried, action = 'verify' }) {
913
1322
  const { root } = ctx
914
1323
  const interWave = context === 'inter-wave'
915
- const profile = readProfile(root)
1324
+ const profile = readProfile(root, manifest)
916
1325
  const opts = { context, changed }
917
1326
  if (context === 'final') {
918
1327
  opts.e2e = manifest.flags.skipE2e !== true
@@ -927,6 +1336,9 @@ function verifyStep(ctx, manifest, { context, label, changed, carried, action =
927
1336
  }
928
1337
  const plan = planVerification(profile, opts)
929
1338
  writeWork(root, `vplan-${context}.json`, plan)
1339
+ // A timing left by an earlier checkpoint is not this one's, and the judge
1340
+ // cannot tell them apart — so the slate is cleared where the checks are named.
1341
+ clearVerifyTimings(root)
930
1342
 
931
1343
  if (!plan.steps.length) {
932
1344
  // A spent budget is the reason, not whichever kind happens to sit first in
@@ -963,7 +1375,7 @@ function verifyStep(ctx, manifest, { context, label, changed, carried, action =
963
1375
  label,
964
1376
  kind: 'verify',
965
1377
  model: null,
966
- effort: null,
1378
+ effort: EFFORT.verify,
967
1379
  schema: VERIFY_RESULT_SCHEMA,
968
1380
  prompt:
969
1381
  (context === 'final'
@@ -1189,7 +1601,7 @@ function adjudicateReview(ctx, manifest) {
1189
1601
  writeWork(root, 'review.json', result)
1190
1602
 
1191
1603
  const banners = []
1192
- const metrics = writeReviewMetrics(root, { change: manifest.change, counts: result.counts })
1604
+ const metrics = writeReviewMetrics(stateHomeOf(ctx, manifest), { change: manifest.change, counts: result.counts })
1193
1605
  if (!metrics.written) {
1194
1606
  banners.push(`REVIEW METRICS NOT WRITTEN: ${metrics.reason}`)
1195
1607
  }
@@ -1205,15 +1617,18 @@ function adjudicateReview(ctx, manifest) {
1205
1617
  * round one: a fixing round, or straight to final verification when nothing
1206
1618
  * survived to fix.
1207
1619
  */
1208
- export function runReviewed(ctx, { results }) {
1620
+ export function runReviewed(ctx, { results, hostRecords }) {
1209
1621
  const { root } = ctx
1210
1622
  const manifest = readManifest(root)
1211
- if (!manifest) return noManifest()
1623
+ if (!manifest) return noManifest(ctx)
1212
1624
  const capped = countStep(root, manifest)
1213
1625
  if (capped) return recordThen(root, manifest, capped)
1214
1626
 
1215
1627
  // Outside the waves, but not outside the run's spend (design D9).
1216
- recordUsage(manifest, results)
1628
+ const observed = recordSpawnObservations(ctx, manifest, { results, hostRecords })
1629
+ if (observed.failure) {
1630
+ return finishStep(ctx, manifest, trajectoryHaltStep('agent-result', observed.failure))
1631
+ }
1217
1632
 
1218
1633
  const { result, banners } = adjudicateReview(ctx, manifest)
1219
1634
  banners.push(...stageMarkerBanners(results))
@@ -1281,15 +1696,18 @@ export function runReviewed(ctx, { results }) {
1281
1696
  * the fix budget on it (moved from `workflows/ship.js`'s retired
1282
1697
  * `remediationBudget`) — only the verdict round itself may halt.
1283
1698
  */
1284
- export function runRemediated(ctx, { round, results }) {
1699
+ export function runRemediated(ctx, { round, results, hostRecords }) {
1285
1700
  const { root } = ctx
1286
1701
  const manifest = readManifest(root)
1287
- if (!manifest) return noManifest()
1702
+ if (!manifest) return noManifest(ctx)
1288
1703
  const capped = countStep(root, manifest)
1289
1704
  if (capped) return recordThen(root, manifest, capped)
1290
1705
 
1291
1706
  // Outside the waves, but not outside the run's spend (design D9).
1292
- recordUsage(manifest, results)
1707
+ const observed = recordSpawnObservations(ctx, manifest, { results, hostRecords })
1708
+ if (observed.failure) {
1709
+ return finishStep(ctx, manifest, trajectoryHaltStep('agent-result', observed.failure))
1710
+ }
1293
1711
 
1294
1712
  const roundCap = LIMITS.remediationRounds
1295
1713
  const completedRound = Number.isInteger(round) ? round : null
@@ -1364,9 +1782,15 @@ export function runRemediated(ctx, { round, results }) {
1364
1782
 
1365
1783
  // --- small file helpers -----------------------------------------------------
1366
1784
 
1367
- function readProfile(root) {
1785
+ function readProfile(root, manifest) {
1786
+ // The path `run start` located, root first and the state home second (design
1787
+ // D16), so a worktree run reads the main checkout's profile without copying
1788
+ // it. A manifest written before the field existed reads the root, as before.
1789
+ const recorded = manifest && Object.prototype.hasOwnProperty.call(manifest, 'testProfilePath')
1790
+ const path = recorded ? manifest.testProfilePath : join(root, TEST_PROFILE_PATH)
1791
+ if (typeof path !== 'string' || !path) return null
1368
1792
  try {
1369
- return JSON.parse(readFileSync(join(root, '.claude', 'testing', 'profile.json'), 'utf8'))
1793
+ return JSON.parse(readFileSync(path, 'utf8'))
1370
1794
  } catch {
1371
1795
  // No profile is not a degradation to report here — `planVerification` says
1372
1796
  // so itself, in the skip reasons it returns.
@@ -1374,6 +1798,39 @@ function readProfile(root) {
1374
1798
  }
1375
1799
  }
1376
1800
 
1801
+ /**
1802
+ * The timings `interlock verify exec` appended since the last clear. An
1803
+ * unreadable line is skipped rather than fatal: a timing is a budget input, and
1804
+ * a kind left without one is charged the round trip, with a banner saying so.
1805
+ */
1806
+ function readVerifyTimings(root) {
1807
+ let raw
1808
+ try {
1809
+ raw = readFileSync(join(root, VERIFY_TIMINGS_PATH), 'utf8')
1810
+ } catch {
1811
+ return []
1812
+ }
1813
+ const out = []
1814
+ for (const line of raw.split('\n')) {
1815
+ if (!line.trim()) continue
1816
+ try {
1817
+ out.push(JSON.parse(line))
1818
+ } catch {
1819
+ // skipped — see above
1820
+ }
1821
+ }
1822
+ return out
1823
+ }
1824
+
1825
+ function clearVerifyTimings(root) {
1826
+ try {
1827
+ rmSync(join(root, VERIFY_TIMINGS_PATH), { force: true })
1828
+ } catch {
1829
+ // Nothing to clear, or nothing clearable: the worst case is a stale timing
1830
+ // charged to the next checkpoint, which only ever makes the budget tighter.
1831
+ }
1832
+ }
1833
+
1377
1834
  function writeWork(root, name, value) {
1378
1835
  const path = join(root, WORK_DIR, name)
1379
1836
  mkdirSync(dirname(path), { recursive: true })
@@ -1454,7 +1911,7 @@ function logSpawns(ctx, manifest, step) {
1454
1911
  let failure = null
1455
1912
  for (const s of step.spawns || []) {
1456
1913
  if (s.kind === 'implementer') continue // logged by wave-state, at the lane
1457
- const written = appendRunLogEvent(ctx.root, {
1914
+ const written = appendRunLogEvent(stateHomeOf(ctx, manifest), {
1458
1915
  runId: manifest.runId || undefined,
1459
1916
  change: manifest.change,
1460
1917
  type: 'agent-spawn',
@@ -1474,7 +1931,7 @@ function logSpawns(ctx, manifest, step) {
1474
1931
 
1475
1932
  function logCliExit(ctx, manifest, command, exitCode) {
1476
1933
  if (!manifest.runId) return null
1477
- const written = appendRunLogEvent(ctx.root, {
1934
+ const written = appendRunLogEvent(stateHomeOf(ctx, manifest), {
1478
1935
  runId: manifest.runId,
1479
1936
  change: manifest.change,
1480
1937
  type: 'cli-exit',
@@ -1521,6 +1978,37 @@ function waveAppendReason(outcome) {
1521
1978
  /** Same, for `logAgentSpawns` — it returns a boolean and reports its reason to stderr. */
1522
1979
  const LANE_SPAWN_APPEND_FAILED = 'an implementer lane spawn did not land'
1523
1980
 
1981
+ /**
1982
+ * Log the implementer lanes a wave-state step dispatches — this batch's, and
1983
+ * only this batch's — at the moment the program names them.
1984
+ *
1985
+ * Every site that turns a wave-state step into a run step calls this, because
1986
+ * every one of them can emit a batch. Two used to, and they logged the WHOLE
1987
+ * wave at its first batch: a run that halted before a later batch left spawn
1988
+ * events for agents that never ran, while a wave entered through `run judge`
1989
+ * or `run replan` left none for the agents that did. The `run` program emits
1990
+ * one step per batch, so the batch is the unit.
1991
+ *
1992
+ * Returns false only when the append failed, read with `=== false` as every
1993
+ * caller already does — a stub that reports nothing is not a failure.
1994
+ */
1995
+ function logLaneSpawns(ctx, manifest, state, step, source) {
1996
+ // Consulted even for a step that dispatches nothing — with no lanes, which it
1997
+ // logs as nothing. Whether a step owes the trajectory a line is the writer's
1998
+ // question, and a site that skipped the call for some actions would be one
1999
+ // more place the answer could drift.
2000
+ const dispatching = step && (step.action === 'run-batch' || step.action === 'test-wave')
2001
+ return (
2002
+ ctx.deps.logAgentSpawns(stateHomeOf(ctx, manifest), {
2003
+ state,
2004
+ change: manifest.change,
2005
+ step,
2006
+ lanes: dispatching ? lanesOf(step) : [],
2007
+ source
2008
+ }) !== false
2009
+ )
2010
+ }
2011
+
1524
2012
  /** The shared tree's HEAD. Never throws: an unreadable base is null, never a guess. */
1525
2013
  function headCommit(ctx) {
1526
2014
  return ctx.deps.headCommit(ctx.root)
@@ -1641,10 +2129,25 @@ export function claimDerivedBanner() {
1641
2129
  * three tasks has told us nothing about the third — and "nothing" read as
1642
2130
  * success is how a task silently ships unimplemented. `not-attempted` is the one
1643
2131
  * outcome that is neither: it is reported, ticked nowhere, and counted nowhere.
2132
+ *
2133
+ * A result that is absent entirely is a lost agent — a crash, a timeout, or a
2134
+ * briefing the agent never acknowledged — not an agent that filed a bad report.
2135
+ * On a multi-task lane that costs one failure, charged to the first task, and
2136
+ * the rest are `not-attempted`. Charging every task would halt a chain on one
2137
+ * miss. A single-task lane is one failure either way, and stays that.
1644
2138
  */
1645
2139
  export function laneOutcomes(lane, result) {
1646
2140
  const failAll = error => lane.map(t => ({ id: t.id, outcome: 'failed', error, handoff: null }))
1647
- if (!result) return failAll('agent returned no result')
2141
+ if (!result) {
2142
+ if (lane.length >= 2) {
2143
+ return lane.map((t, i) =>
2144
+ i === 0
2145
+ ? { id: t.id, outcome: 'failed', error: 'agent returned no result', handoff: null }
2146
+ : { id: t.id, outcome: 'not-attempted' }
2147
+ )
2148
+ }
2149
+ return failAll('agent returned no result')
2150
+ }
1648
2151
 
1649
2152
  if (lane.length === 1) {
1650
2153
  const [only] = lane
@@ -1696,6 +2199,110 @@ export function laneOutcomes(lane, result) {
1696
2199
 
1697
2200
  // --- run start (design D4, D2) ----------------------------------------------
1698
2201
 
2202
+ /**
2203
+ * What the Workflow host's environment means for this run (design D1, D5).
2204
+ *
2205
+ * `interlock run start --host workflow` reads the model-routing variables from
2206
+ * its own environment, the way `hostSessionId` reads the session, and learns the
2207
+ * host version from `claude --version`; `bin/interlock` builds the observation
2208
+ * and this decides from it, once. The driver forwards what is decided and reads
2209
+ * none of these variables itself — `workflows/ship.js` used to, and raised
2210
+ * `MODEL ROUTING OVERRIDDEN` for a variable that, from 2.1.251, sets only the
2211
+ * default subagent model.
2212
+ *
2213
+ * Pure. The version-aware reading itself is `readModelRouting`, shared with the
2214
+ * doctor's advice row so the two cannot read one environment two ways.
2215
+ *
2216
+ * @param {object} observation from `observeClaudeEnv`
2217
+ * @returns {{banners: string[], notes: string[], pingModel: string|null,
2218
+ * modelSelect: 'forced'|null, slots: object}}
2219
+ */
2220
+ export function decideHostEnvironment(observation) {
2221
+ const o = observation && typeof observation === 'object' ? observation : {}
2222
+ const routing = readModelRouting(o)
2223
+ const banners = []
2224
+ const notes = []
2225
+ const unknown = routing.versionKnown ? '' : ' (host version unknown)'
2226
+
2227
+ if (routing.forceEffective) {
2228
+ banners.push(
2229
+ `MODEL ROUTING OVERRIDDEN: CLAUDE_CODE_SUBAGENT_MODEL_FORCE=${o.subagentModelForce} — every agent runs on ` +
2230
+ `one model (${o.subagentModel || "the host's default"}), so the per-tier assignment in the plan is not ` +
2231
+ `in effect${unknown}`
2232
+ )
2233
+ }
2234
+ if (routing.forceInert) {
2235
+ notes.push(
2236
+ `MODEL ROUTING NOTE: CLAUDE_CODE_SUBAGENT_MODEL_FORCE=${o.subagentModelForce} has no effect on Claude Code ` +
2237
+ `${routing.version}, which predates it; CLAUDE_CODE_SUBAGENT_MODEL is read as on any host of that version`
2238
+ )
2239
+ }
2240
+ if (routing.plainDefaultOnly) {
2241
+ notes.push(
2242
+ `MODEL ROUTING NOTE: CLAUDE_CODE_SUBAGENT_MODEL=${o.subagentModel} sets only the default subagent model on ` +
2243
+ `Claude Code ${routing.version}; the model each spawn names takes precedence`
2244
+ )
2245
+ }
2246
+ if (routing.plainOverrides) {
2247
+ banners.push(
2248
+ `MODEL ROUTING OVERRIDDEN: CLAUDE_CODE_SUBAGENT_MODEL=${o.subagentModel} — every agent runs on that model, ` +
2249
+ `so the per-tier assignment in the plan is not in effect${unknown}`
2250
+ )
2251
+ }
2252
+
2253
+ // The control-plane pings ask for haiku only when nothing overrides it and
2254
+ // the account can reach it. An override already decides every agent's model;
2255
+ // a Bedrock account often cannot reach haiku, and a failed ping halts the loop.
2256
+ let pingModel = 'haiku'
2257
+ if (routing.forceEffective || routing.plainOverrides) {
2258
+ pingModel = null
2259
+ } else if (Array.isArray(o.bedrock) && o.bedrock.length) {
2260
+ pingModel = null
2261
+ notes.push(
2262
+ `PING MODEL INHERITED: a Bedrock variable is set (${o.bedrock.join(', ')}), so control-plane pings run on ` +
2263
+ 'the session model'
2264
+ )
2265
+ }
2266
+
2267
+ const slots = { ...runtimeSlotsOf(o), vendorDefault: RUNTIME.maxConcurrentAgents }
2268
+ if (slots.invalid !== undefined) {
2269
+ notes.push(
2270
+ `RUNTIME SLOTS OVERRIDE IGNORED: CLAUDE_CODE_WORKFLOW_MAX_CONCURRENT_AGENTS=${slots.invalid} is not an ` +
2271
+ `integer from ${WORKFLOW_MAX_CONCURRENT_RANGE.min} to ${WORKFLOW_MAX_CONCURRENT_RANGE.max}, so this run ` +
2272
+ "reads it as unset; how the runtime reads it is not observed"
2273
+ )
2274
+ }
2275
+
2276
+ return { banners, notes, pingModel, modelSelect: routing.forceEffective ? 'forced' : null, slots }
2277
+ }
2278
+
2279
+ /**
2280
+ * Speak every interrupted-run note not yet spoken (design D7), whatever change
2281
+ * it names, and mark each spoken. Run start is the moment: the reader is about
2282
+ * to spend agents, and the run that was interrupted has no other record of how
2283
+ * it ended. Nothing here moves an exit code — the note is outcome-class.
2284
+ *
2285
+ * @returns {string[]} the banners to carry on the start step
2286
+ */
2287
+ function speakInterruptedNotes(root, manifest) {
2288
+ const { notes, unreadable } = readInterruptedNotes(root)
2289
+ const banners = []
2290
+ for (const note of notes) {
2291
+ if (!isUnspoken(note)) continue
2292
+ const banner = formatInterruptedBanner(note)
2293
+ banners.push(banner)
2294
+ manifest.banners.push(banner)
2295
+ const marked = markSpoken(root, note.runId)
2296
+ if (!marked.marked) {
2297
+ manifest.notes.push(
2298
+ `INTERRUPTED NOTE NOT MARKED: ${note.file}: ${marked.reason} — it will be spoken again at the next run start`
2299
+ )
2300
+ }
2301
+ }
2302
+ for (const entry of unreadable) manifest.notes.push(formatUnreadableNote(entry))
2303
+ return banners
2304
+ }
2305
+
1699
2306
  /**
1700
2307
  * Begin a run: write the manifest, validate the change, and either adopt a
1701
2308
  * reusable plan or ask for a classification.
@@ -1708,13 +2315,132 @@ export function laneOutcomes(lane, result) {
1708
2315
  * run; wrongly reusing would run the wrong plan.
1709
2316
  *
1710
2317
  * @param {{root: string, deps: object, warn: (m: string) => void}} ctx
1711
- * @param {{change: string, flags: object, host?: string, hostCapabilities?: object}} input
2318
+ * @param {{change: string, flags: object, host?: string, hostCapabilities?: object, hostEnv?: object, stateHome?: object}} input
2319
+ * `hostEnv` is the Workflow host's observed environment (`observeClaudeEnv`),
2320
+ * passed by `bin/interlock` on that host only. `stateHome` is the state home
2321
+ * `bin/interlock` resolved for this root (`resolveStateHome`, the explicit
2322
+ * `--state-home` applied); resolved here when absent.
1712
2323
  */
1713
- export function runStart(ctx, { change, flags, host, hostCapabilities }) {
2324
+ export function runStart(ctx, { change, flags, host, hostCapabilities, hostEnv, stateHome }) {
1714
2325
  const { root } = ctx
1715
2326
  const manifest = newManifest({ change, flags, host, hostCapabilities })
2327
+ // Where this run's corpora go, decided once and recorded (ship/state-home):
2328
+ // every later subcommand reads it back off the manifest and never resolves
2329
+ // again, so one run uses one home even if git state changes under it.
2330
+ const home = settleStateHome(ctx, manifest, stateHome)
2331
+ // What the Workflow host's environment means for this run, decided once here
2332
+ // and forwarded by the driver (design D2). Only that host's run start passes
2333
+ // an observation: a runner's lanes are `claude -p` processes whose top-level
2334
+ // model is `--model`, which the subagent-model variables do not govern.
2335
+ const carried = { banners: [] }
2336
+ if (hostEnv && typeof hostEnv === 'object') {
2337
+ const decided = decideHostEnvironment(hostEnv)
2338
+ manifest.banners.push(...decided.banners)
2339
+ manifest.notes.push(...decided.notes)
2340
+ manifest.pingModel = decided.pingModel
2341
+ manifest.runtimeSlots = decided.slots
2342
+ manifest.hostEnv = hostEnv
2343
+ // The same downward-only refinement the effort probe performs. A caller that
2344
+ // declared its own model selection keeps it.
2345
+ const declared = hostCapabilities && typeof hostCapabilities === 'object' && 'modelSelect' in hostCapabilities
2346
+ if (decided.modelSelect === 'forced' && manifest.host && !declared) manifest.host.modelSelect = 'forced'
2347
+ carried.banners.push(...decided.banners)
2348
+ carried.pingModel = decided.pingModel
2349
+ }
2350
+ // Said at the start rather than discovered at the first fold. The run still
2351
+ // proceeds: on a clean tree with `worktree.baseRef: head`, the first isolated
2352
+ // batch forks from HEAD, which is the snapshot, and folds.
2353
+ if (manifest.flags.isolateWaves === true && hostCapability(manifest, 'worktree') === 'runtime') {
2354
+ manifest.banners.push(ISOLATION_BASE_BANNER)
2355
+ }
2356
+ carried.banners.push(...home.banners)
1716
2357
  writeManifest(root, manifest)
2358
+ carried.banners.push(...speakInterruptedNotes(stateHomeOf(ctx, manifest), manifest))
2359
+
2360
+ return carryStart(startSteps(ctx, manifest, change), carried)
2361
+ }
2362
+
2363
+ /**
2364
+ * Record the state home, the surface, and where the two inputs a worktree run
2365
+ * reads through were found (design D14, D16). Returns the banners for the start
2366
+ * step; they are on `manifest.banners` as well, so the close carries them.
2367
+ *
2368
+ * Every input is located root first, then home: a worktree that built its own
2369
+ * graph or reconfigured its own profile uses its own, and nothing is ever copied
2370
+ * or written into the main checkout. One banner per input at most — the
2371
+ * read-through, the absence, or nothing — and the absences are the lines the
2372
+ * Workflow driver used to raise from its own `test -f`, which no longer exists:
2373
+ * two probes of one file in two places is how a driver and the CLI disagree.
2374
+ */
2375
+ function settleStateHome(ctx, manifest, given) {
2376
+ const root = resolve(ctx.root)
2377
+ const resolution =
2378
+ given && typeof given === 'object' && typeof given.home === 'string' && given.home
2379
+ ? given
2380
+ : resolveStateHome(root)
2381
+ const home = resolve(resolution.home)
2382
+ manifest.stateHome = home
2383
+ manifest.surface = typeof resolution.surface === 'string' ? resolution.surface : 'unknown'
2384
+ manifest.stateHomeReason = typeof resolution.reason === 'string' ? resolution.reason : null
2385
+ // How it was decided — the resolver, an explicit setting, a lane worktree or the
2386
+ // fallback — so a reader that takes the home off this manifest names it right.
2387
+ manifest.stateHomeFrom = typeof resolution.resolvedFrom === 'string' ? resolution.resolvedFrom : null
2388
+
2389
+ const banners = []
2390
+ if (manifest.surface === 'unknown' && manifest.stateHomeReason) {
2391
+ banners.push(`STATE HOME UNRESOLVED: ${manifest.stateHomeReason}`)
2392
+ }
2393
+ if (home !== root) {
2394
+ manifest.notes.push(
2395
+ resolution.resolvedFrom === 'explicit'
2396
+ ? `CORPORA IN STATE HOME: ${home} — set by --state-home or INTERLOCK_STATE_HOME; the trajectory, ` +
2397
+ `outcome line, metrics and resume card of this run are written there, not in ${root}`
2398
+ : `CORPORA IN MAIN CHECKOUT: ${home} — the trajectory, outcome line, metrics and resume card of ` +
2399
+ `this run are written there, not in this worktree`
2400
+ )
2401
+ }
2402
+
2403
+ const profile = locateInput(root, home, TEST_PROFILE_PATH)
2404
+ manifest.testProfilePath = profile.path
2405
+ manifest.testProfileSource = profile.source
2406
+ if (profile.source === 'state-home') banners.push(`TEST PROFILE FROM MAIN CHECKOUT: ${profile.path}`)
2407
+ else if (profile.source === 'none') banners.push(VERIFY_BANNERS.noProfile)
1717
2408
 
2409
+ const graph = locateInput(root, home, GRAPH_FILE_PATH)
2410
+ manifest.graphPath = graph.path
2411
+ manifest.graphSource = graph.source
2412
+ if (graph.source === 'state-home') {
2413
+ banners.push(
2414
+ `GRAPH FROM MAIN CHECKOUT: ${graph.path} — it may be stale for this worktree; a lane without a ` +
2415
+ `graph falls back to grep`
2416
+ )
2417
+ } else if (graph.source === 'none') {
2418
+ banners.push(GRAPH_UNAVAILABLE_BANNER)
2419
+ }
2420
+
2421
+ manifest.banners.push(...banners)
2422
+ return { home, banners }
2423
+ }
2424
+
2425
+ /**
2426
+ * Put what run start decided onto whichever step it returns — a classify, a
2427
+ * reused plan's first batch, a close with nothing left, or a halt. The manifest
2428
+ * already holds the banners; the step carries them so the driver reports them
2429
+ * from the first step on, and carries the ping model so the driver can set it.
2430
+ */
2431
+ function carryStart(step, { banners, pingModel }) {
2432
+ if (!step || typeof step !== 'object') return step
2433
+ if (banners.length) {
2434
+ const rest = (Array.isArray(step.banners) ? step.banners : []).filter(b => !banners.includes(b))
2435
+ step.banners = [...banners, ...rest]
2436
+ }
2437
+ if (pingModel !== undefined) step.pingModel = pingModel
2438
+ return step
2439
+ }
2440
+
2441
+ /** Every path out of run start, after the manifest exists. */
2442
+ function startSteps(ctx, manifest, change) {
2443
+ const { root } = ctx
1718
2444
  const capped = countStep(root, manifest)
1719
2445
  if (capped) return recordThen(root, manifest, capped)
1720
2446
 
@@ -1776,6 +2502,7 @@ export function runStart(ctx, { change, flags, host, hostCapabilities }) {
1776
2502
  label: 'plan-waves',
1777
2503
  kind: 'planner',
1778
2504
  model: null,
2505
+ // effort-routing: "Control-plane and prose spawns SHALL inherit the host's effort".
1779
2506
  effort: null,
1780
2507
  schema: PLANNER_SCHEMA,
1781
2508
  prompt: assemblePlannerPrompt({ change, classifiedPath: CLASSIFIED_PATH })
@@ -1802,8 +2529,15 @@ function adoptPlan(ctx, manifest, plan) {
1802
2529
  }
1803
2530
  writeState(root, state)
1804
2531
  manifest.runId = typeof state.runId === 'string' ? state.runId : null
1805
-
1806
- const started = appendRunLogEvent(root, {
2532
+ // On the manifest as well as on the trajectory, so the SessionEnd recorder
2533
+ // compares the ending session against one small file (design D7). Absent on a
2534
+ // host with no session, which never matches, so that recorder writes nothing.
2535
+ const sessionId = hostSessionId(manifest, ctx.env)
2536
+ if (sessionId) manifest.sessionId = sessionId
2537
+ const slotsBanner = runtimeSlotsBanner(manifest, state)
2538
+ if (slotsBanner) manifest.banners.push(slotsBanner)
2539
+
2540
+ const started = appendRunLogEvent(stateHomeOf(ctx, manifest), {
1807
2541
  runId: manifest.runId || undefined,
1808
2542
  change: manifest.change,
1809
2543
  type: 'run-start',
@@ -1813,14 +2547,20 @@ function adoptPlan(ctx, manifest, plan) {
1813
2547
  // `undefined`, not a placeholder: a host with no session records absent, and
1814
2548
  // the run is unaffected either way — nothing downstream reads this to decide
1815
2549
  // anything, and `run-log check` does not require it.
1816
- sessionId: hostSessionId(manifest, ctx.env) || undefined
2550
+ sessionId: sessionId || undefined,
2551
+ // Where this run ran and where its corpora went (ship/state-home). `cwd` is
2552
+ // what lets a reader in the main checkout find a worktree run's spill and
2553
+ // marker; a manifest written before these existed records none.
2554
+ surface: manifest.surface || undefined,
2555
+ stateHome: manifest.stateHome || undefined,
2556
+ cwd: resolve(root)
1817
2557
  })
1818
2558
  if (!started.written) {
1819
2559
  ctx.warn(`run-start not recorded: ${started.reason}`)
1820
2560
  return trajectoryHaltStep('run-start', started.reason)
1821
2561
  }
1822
2562
 
1823
- const created = ctx.deps.logWaveMutation(root, {
2563
+ const created = ctx.deps.logWaveMutation(stateHomeOf(ctx, manifest), {
1824
2564
  source: 'create',
1825
2565
  change: manifest.change,
1826
2566
  state
@@ -1834,11 +2574,43 @@ function adoptPlan(ctx, manifest, plan) {
1834
2574
  // the lane, by wave-state). Its verdict is read for the same reason every
1835
2575
  // other append's is: `wave-state next` exits 1 on this exact boolean, and the
1836
2576
  // live path owes the trajectory the same fatality.
1837
- if (ctx.deps.logAgentSpawns(root, { state, change: manifest.change, step, source: 'next' }) === false) {
2577
+ if (!logLaneSpawns(ctx, manifest, state, step, 'next')) {
1838
2578
  ctx.warn('an implementer agent-spawn was not recorded on run start')
1839
2579
  return trajectoryHaltStep('agent-spawn', LANE_SPAWN_APPEND_FAILED)
1840
2580
  }
1841
- return decorate(ctx, step, manifest)
2581
+ const first = decorate(ctx, step, manifest)
2582
+ if (slotsBanner && first && typeof first === 'object') {
2583
+ first.banners = [...(Array.isArray(first.banners) ? first.banners : []), slotsBanner]
2584
+ }
2585
+ return first
2586
+ }
2587
+
2588
+ /**
2589
+ * `WAVE WIDER THAN RUNTIME SLOTS`, or null (design D5).
2590
+ *
2591
+ * Raised only against an override the operator set: the vendor's own default is
2592
+ * reduced on small machines by an amount it does not publish, and inventing one
2593
+ * would be a threshold in prose. The plan is never resized — it is published
2594
+ * policy, and the runtime queues the extra lanes — so this only says that lanes
2595
+ * will wait, where they would otherwise wait without a word.
2596
+ */
2597
+ function runtimeSlotsBanner(manifest, state) {
2598
+ const slots = manifest && manifest.runtimeSlots
2599
+ if (!slots || slots.source !== 'env' || !Number.isInteger(slots.observed)) return null
2600
+ const waves = state && Array.isArray(state.waves) ? state.waves : []
2601
+ let widest = 0
2602
+ for (const wave of waves) {
2603
+ for (const batch of Array.isArray(wave && wave.batches) ? wave.batches : []) {
2604
+ if (Array.isArray(batch) && batch.length > widest) widest = batch.length
2605
+ }
2606
+ }
2607
+ if (widest <= slots.observed) return null
2608
+ const queued = widest - slots.observed
2609
+ return (
2610
+ `WAVE WIDER THAN RUNTIME SLOTS: the widest batch has ${widest} lanes and ` +
2611
+ `CLAUDE_CODE_WORKFLOW_MAX_CONCURRENT_AGENTS=${slots.observed} — ${queued} lane${queued === 1 ? '' : 's'} ` +
2612
+ `queue${queued === 1 ? 's' : ''} for a slot; the plan is not resized`
2613
+ )
1842
2614
  }
1843
2615
 
1844
2616
  // --- run classified (design D7) ---------------------------------------------
@@ -1922,7 +2694,7 @@ export function resolveRedWave({ tddMode, redSection, tasks }) {
1922
2694
  export function runClassified(ctx, { classifiedPath }) {
1923
2695
  const { root } = ctx
1924
2696
  const manifest = readManifest(root)
1925
- if (!manifest) return noManifest()
2697
+ if (!manifest) return noManifest(ctx)
1926
2698
  const capped = countStep(root, manifest)
1927
2699
  if (capped) return recordThen(root, manifest, capped)
1928
2700
 
@@ -2019,6 +2791,42 @@ export function runClassified(ctx, { classifiedPath }) {
2019
2791
 
2020
2792
  // --- run record-batch (design D5) -------------------------------------------
2021
2793
 
2794
+ // A runtime-worktree host forks each isolated lane from `worktree.baseRef`,
2795
+ // which a plugin can neither read nor set — so the lane's base is whatever that
2796
+ // names, not the run's snapshot. Bannered at `run start`, named at the halt.
2797
+ const ISOLATION_BASE_BANNER =
2798
+ 'ISOLATION BASE NOT CONTROLLED (workflow): the runtime forks lane worktrees from worktree.baseRef, ' +
2799
+ "not from the run's snapshot — an isolated batch whose lanes did not fork from the snapshot halts " +
2800
+ 'with LANE BASE MISMATCH'
2801
+ const RUNTIME_BASE_HALT =
2802
+ 'the Workflow runtime chose this base through worktree.baseRef, which a plugin cannot set; ' +
2803
+ '--isolate-waves on this host needs the follow-up change that creates lane worktrees from the CLI'
2804
+
2805
+ /**
2806
+ * The fold candidates whose worktree is not checked out at `base`.
2807
+ *
2808
+ * Read through git from each worktree, never from the lane's own report: that
2809
+ * report is the thing being audited. A lane git cannot answer for is a mismatch
2810
+ * only when it reported writes — a clean lane that changed nothing may have had
2811
+ * its worktree removed by the runtime, and that is an empty-write lane, not a
2812
+ * wrong base. A lane that named no worktree at all is left to `merge-lanes`,
2813
+ * which already halts on it as unresolved.
2814
+ *
2815
+ * @returns {Array<{label: string, head: string|null}>}
2816
+ */
2817
+ function laneBaseMismatches(ctx, candidates, base) {
2818
+ const mismatched = []
2819
+ for (const lane of candidates) {
2820
+ if (!lane.worktreePath) continue
2821
+ const head = ctx.deps.worktreeHead(lane.worktreePath)
2822
+ if (head === base) continue
2823
+ const wrote = Array.isArray(lane.reportedFiles) && lane.reportedFiles.length > 0
2824
+ if (!head && !wrote) continue
2825
+ mismatched.push({ label: lane.label, head })
2826
+ }
2827
+ return mismatched
2828
+ }
2829
+
2022
2830
  /**
2023
2831
  * Fold, adjudicate, record and tick — one CLI call where the loop used to spend
2024
2832
  * three agent turns.
@@ -2027,10 +2835,10 @@ export function runClassified(ctx, { classifiedPath }) {
2027
2835
  * named rather than removed: folding a partial write from a lane that stopped
2028
2836
  * mid-way is exactly the silent-overwrite defect the merge policy refuses.
2029
2837
  */
2030
- export function runRecordBatch(ctx, { results }) {
2838
+ export function runRecordBatch(ctx, { results, hostRecords }) {
2031
2839
  const { root } = ctx
2032
2840
  const manifest = readManifest(root)
2033
- if (!manifest) return noManifest()
2841
+ if (!manifest) return noManifest(ctx)
2034
2842
  const capped = countStep(root, manifest)
2035
2843
  if (capped) return recordThen(root, manifest, capped)
2036
2844
 
@@ -2052,7 +2860,12 @@ export function runRecordBatch(ctx, { results }) {
2052
2860
  const isolate = manifest.flags.isolateWaves === true
2053
2861
  const driverWorktrees = isolate && hostCapability(manifest, 'worktree') === 'driver'
2054
2862
 
2055
- recordUsage(manifest, results, pending.wave)
2863
+ // Before anything is folded or ticked: an `agent-result` that did not land
2864
+ // is a gap in the trajectory, and this batch's ticks must not outrun it.
2865
+ const observed = recordSpawnObservations(ctx, manifest, { results, hostRecords, wave: pending.wave })
2866
+ if (observed.failure) {
2867
+ return finishStep(ctx, manifest, trajectoryHaltStep('agent-result', observed.failure))
2868
+ }
2056
2869
 
2057
2870
  lanes.forEach((lane, i) => {
2058
2871
  const outcomes = laneOutcomes(lane, results[i])
@@ -2101,7 +2914,8 @@ export function runRecordBatch(ctx, { results }) {
2101
2914
  }
2102
2915
 
2103
2916
  // Fold this batch's successful lanes back into the shared tree before moving
2104
- // on — the NEXT batch's merge base is read off this tree.
2917
+ // on. The NEXT isolated batch snapshots this tree for its own base — that is
2918
+ // how a later batch, and a later wave, sees every fold before it.
2105
2919
  if (isolate && laneFoldCandidates.length) {
2106
2920
  // The base the lanes actually forked from, captured at dispatch and carried
2107
2921
  // on the manifest across the driver round trip. `pending` is a pure
@@ -2117,6 +2931,27 @@ export function runRecordBatch(ctx, { results }) {
2117
2931
  `fold, so lane diffs are taken against the post-batch HEAD (${base || 'unreadable'})`
2118
2932
  )
2119
2933
  }
2934
+ // Every lane's base, read from its own worktree before anything folds. A
2935
+ // lane forked from anything else reports the difference between the two as
2936
+ // its own write, and the whole-file fold silently reverts whatever that
2937
+ // difference held — so a wrong base halts the batch, and no lane of it folds.
2938
+ const mismatched = base ? laneBaseMismatches(ctx, laneFoldCandidates, base) : []
2939
+ if (mismatched.length) {
2940
+ const survivors = [
2941
+ ...laneFoldCandidates.filter(l => l.worktreePath && existsSync(l.worktreePath)),
2942
+ ...laneWorktreesPreserved
2943
+ ]
2944
+ .map(w => `${w.label}: ${w.worktreePath}`)
2945
+ .join('; ')
2946
+ return finishStep(ctx, manifest, haltStep(
2947
+ 'LANE BASE MISMATCH: ' +
2948
+ mismatched.map(m => `${m.label} forked from ${m.head || '(unreadable)'}, expected ${base}`).join('; ') +
2949
+ ` — no lane of wave ${pending.wave}'s batch was folded` +
2950
+ (hostCapability(manifest, 'worktree') === 'runtime' ? ` — ${RUNTIME_BASE_HALT}` : '') +
2951
+ ` — surviving worktrees: ${survivors || '(none)'}`,
2952
+ { banners }
2953
+ ))
2954
+ }
2120
2955
  const merged = base ? ctx.deps.runMergeLanes(root, laneFoldCandidates, base) : null
2121
2956
  if (Array.isArray(merged && merged.cleanupWarnings) && merged.cleanupWarnings.length) {
2122
2957
  banners.push(
@@ -2161,7 +2996,7 @@ export function runRecordBatch(ctx, { results }) {
2161
2996
  changedPaths: ctx.deps.observedChangedPaths(root)
2162
2997
  })
2163
2998
  writeState(root, after)
2164
- const recorded = ctx.deps.logWaveMutation(root, {
2999
+ const recorded = ctx.deps.logWaveMutation(stateHomeOf(ctx, manifest), {
2165
3000
  source: 'record-batch',
2166
3001
  change: manifest.change,
2167
3002
  state: after
@@ -2183,13 +3018,7 @@ export function runRecordBatch(ctx, { results }) {
2183
3018
  // Before the tick and before the commit, exactly like the wave-action above:
2184
3019
  // a batch whose implementer spawns went unrecorded is a batch nobody can
2185
3020
  // attribute, and `emitRecordedState` already exits 1 on this same boolean.
2186
- const laneSpawnsLogged = ctx.deps.logAgentSpawns(root, {
2187
- state: after,
2188
- change: manifest.change,
2189
- step: nextRaw,
2190
- source: 'record-batch'
2191
- })
2192
- if (laneSpawnsLogged === false) {
3021
+ if (!logLaneSpawns(ctx, manifest, after, nextRaw, 'record-batch')) {
2193
3022
  ctx.warn('an implementer agent-spawn was not recorded on record-batch')
2194
3023
  manifest.banners.push(...banners)
2195
3024
  return finishStep(
@@ -2252,15 +3081,18 @@ export function runRecordBatch(ctx, { results }) {
2252
3081
  * reports exit codes and counts; whether the run may continue is not its
2253
3082
  * question, and both drivers used to let it answer.
2254
3083
  */
2255
- export function runJudge(ctx, { context, results }) {
3084
+ export function runJudge(ctx, { context, results, hostRecords }) {
2256
3085
  const { root } = ctx
2257
3086
  const manifest = readManifest(root)
2258
- if (!manifest) return noManifest()
3087
+ if (!manifest) return noManifest(ctx)
2259
3088
  const capped = countStep(root, manifest)
2260
3089
  if (capped) return recordThen(root, manifest, capped)
2261
3090
 
2262
3091
  // Outside the waves, but not outside the run's spend (design D9).
2263
- recordUsage(manifest, results)
3092
+ const observed = recordSpawnObservations(ctx, manifest, { results, hostRecords })
3093
+ if (observed.failure) {
3094
+ return finishStep(ctx, manifest, trajectoryHaltStep('agent-result', observed.failure))
3095
+ }
2264
3096
 
2265
3097
  const state = readState(root)
2266
3098
  const plan = readJson(join(root, WORK_DIR, `vplan-${context}.json`))
@@ -2271,10 +3103,34 @@ export function runJudge(ctx, { context, results }) {
2271
3103
  // process — the next one — can see how long the agent between them took. An
2272
3104
  // unparseable or negative mark contributes nothing rather than a wrong number,
2273
3105
  // and the mark is cleared either way so it can never be counted twice.
3106
+ //
3107
+ // What is charged is the checks' own time, as `interlock verify exec` measured
3108
+ // it around each command. The interval from dispatch to judgement is only the
3109
+ // fallback: it also holds two relays and the verify agent's own turns, and
3110
+ // charging those as verification spent the whole budget on the first
3111
+ // checkpoint of a Workflow run and left every later one typecheck-only. The
3112
+ // fallback is spoken, never silent.
3113
+ const timings = readVerifyTimings(root)
3114
+ clearVerifyTimings(root)
2274
3115
  if (manifest.verifyStartedAt) {
2275
- const spent = Date.now() - Date.parse(manifest.verifyStartedAt)
2276
- if (Number.isFinite(spent) && spent >= 0) {
2277
- manifest.verifyElapsedMs = (numberOrUndefined(manifest.verifyElapsedMs) || 0) + Math.round(spent)
3116
+ const planned = plan && Array.isArray(plan.steps) ? plan.steps : []
3117
+ const timed = foldCheckTimings(planned, timings)
3118
+ const roundTrip = Date.now() - Date.parse(manifest.verifyStartedAt)
3119
+ let charged = null
3120
+ if (planned.length && !timed.missing.length) {
3121
+ charged = timed.ms
3122
+ } else if (Number.isFinite(roundTrip) && roundTrip >= 0) {
3123
+ charged = Math.round(roundTrip)
3124
+ if (planned.length) {
3125
+ banners.push(
3126
+ `VERIFY BUDGET CLOCKED BY ROUND TRIP: no \`interlock verify exec\` timing for ` +
3127
+ `${timed.missing.join(', ')} — ${charged}ms of relay and agent time counted against ` +
3128
+ `the inter-wave verify budget`
3129
+ )
3130
+ }
3131
+ }
3132
+ if (charged !== null) {
3133
+ manifest.verifyElapsedMs = (numberOrUndefined(manifest.verifyElapsedMs) || 0) + charged
2278
3134
  }
2279
3135
  manifest.verifyStartedAt = null
2280
3136
  }
@@ -2345,7 +3201,7 @@ export function runJudge(ctx, { context, results }) {
2345
3201
  }
2346
3202
  const after = recordVerifyResult(state, record)
2347
3203
  writeState(root, after)
2348
- const verified = ctx.deps.logWaveMutation(root, {
3204
+ const verified = ctx.deps.logWaveMutation(stateHomeOf(ctx, manifest), {
2349
3205
  source: 'record-verify',
2350
3206
  change: manifest.change,
2351
3207
  state: after
@@ -2360,6 +3216,11 @@ export function runJudge(ctx, { context, results }) {
2360
3216
  trajectoryHaltStep('wave-state record-verify', waveAppendReason(verified), { banners })
2361
3217
  )
2362
3218
  }
3219
+ // The next wave's first batch is named here, so its lanes are logged here.
3220
+ if (!logLaneSpawns(ctx, manifest, after, nextRaw, 'record-verify')) {
3221
+ ctx.warn('an implementer agent-spawn was not recorded on record-verify')
3222
+ return finishStep(ctx, manifest, trajectoryHaltStep('agent-spawn', LANE_SPAWN_APPEND_FAILED, { banners }))
3223
+ }
2363
3224
  return finishStep(ctx, manifest, decorate(ctx, nextRaw, manifest, { banners }))
2364
3225
  }
2365
3226
 
@@ -2444,6 +3305,7 @@ function handoffStep(ctx, manifest, banners = []) {
2444
3305
  label: 'handoff',
2445
3306
  kind: 'handoff',
2446
3307
  model: null,
3308
+ // effort-routing: "Control-plane and prose spawns SHALL inherit the host's effort".
2447
3309
  effort: null,
2448
3310
  schema: HANDOFF_RESULT_SCHEMA,
2449
3311
  prompt: assembleHandoffPrompt({
@@ -2480,6 +3342,7 @@ function commitStep(ctx, manifest, banners = []) {
2480
3342
  label: 'commit',
2481
3343
  kind: 'commit',
2482
3344
  model: null,
3345
+ // effort-routing: "Control-plane and prose spawns SHALL inherit the host's effort".
2483
3346
  effort: null,
2484
3347
  schema: COMMIT_SCHEMA,
2485
3348
  prompt: assembleCommitPrompt({ change: manifest.change, stageIndex: ++manifest.stageIndex })
@@ -2496,15 +3359,18 @@ function commitStep(ctx, manifest, banners = []) {
2496
3359
  * with no handoff step never calls it: `run judge --context final` emits the
2497
3360
  * commit directly.
2498
3361
  */
2499
- export function runCommit(ctx, { results }) {
3362
+ export function runCommit(ctx, { results, hostRecords }) {
2500
3363
  const { root } = ctx
2501
3364
  const manifest = readManifest(root)
2502
- if (!manifest) return noManifest()
3365
+ if (!manifest) return noManifest(ctx)
2503
3366
  const capped = countStep(root, manifest)
2504
3367
  if (capped) return recordThen(root, manifest, capped)
2505
3368
 
2506
3369
  // Outside the waves, but not outside the run's spend (design D9).
2507
- recordUsage(manifest, results)
3370
+ const observed = recordSpawnObservations(ctx, manifest, { results, hostRecords })
3371
+ if (observed.failure) {
3372
+ return finishStep(ctx, manifest, trajectoryHaltStep('agent-result', observed.failure))
3373
+ }
2508
3374
 
2509
3375
  const reported = results && results[0] && typeof results[0] === 'object' ? results[0] : null
2510
3376
  const count = value => (Number.isInteger(value) ? value : undefined)
@@ -2554,7 +3420,7 @@ function flattenVerifyResults(results) {
2554
3420
  export function runVerifyFinal(ctx) {
2555
3421
  const { root } = ctx
2556
3422
  const manifest = readManifest(root)
2557
- if (!manifest) return noManifest()
3423
+ if (!manifest) return noManifest(ctx)
2558
3424
  const capped = countStep(root, manifest)
2559
3425
  if (capped) return recordThen(root, manifest, capped)
2560
3426
  return finishStep(ctx, manifest, finalVerifyStep(ctx, manifest))
@@ -2563,15 +3429,18 @@ export function runVerifyFinal(ctx) {
2563
3429
  // --- run replan -------------------------------------------------------------
2564
3430
 
2565
3431
  /** Apply a revision the replan step produced, or carry on with the next step. */
2566
- export function runReplan(ctx, { results }) {
3432
+ export function runReplan(ctx, { results, hostRecords }) {
2567
3433
  const { root } = ctx
2568
3434
  const manifest = readManifest(root)
2569
- if (!manifest) return noManifest()
3435
+ if (!manifest) return noManifest(ctx)
2570
3436
  const capped = countStep(root, manifest)
2571
3437
  if (capped) return recordThen(root, manifest, capped)
2572
3438
 
2573
3439
  // Outside the waves, but not outside the run's spend (design D9).
2574
- recordUsage(manifest, results)
3440
+ const observed = recordSpawnObservations(ctx, manifest, { results, hostRecords })
3441
+ if (observed.failure) {
3442
+ return finishStep(ctx, manifest, trajectoryHaltStep('agent-result', observed.failure))
3443
+ }
2575
3444
 
2576
3445
  const state = readState(root)
2577
3446
  if (!state) return finishStep(ctx, manifest, haltStep('the run state could not be read'))
@@ -2581,7 +3450,12 @@ export function runReplan(ctx, { results }) {
2581
3450
  if (!revised || !Array.isArray(groups)) {
2582
3451
  // Nothing to revise is the ordinary outcome, and re-reading the state is
2583
3452
  // how the loop moves past a replan offer it declined.
2584
- return finishStep(ctx, manifest, decorate(ctx, nextStep(state), manifest))
3453
+ const declined = nextStep(state)
3454
+ if (!logLaneSpawns(ctx, manifest, state, declined, 'replan')) {
3455
+ ctx.warn('an implementer agent-spawn was not recorded on replan')
3456
+ return finishStep(ctx, manifest, trajectoryHaltStep('agent-spawn', LANE_SPAWN_APPEND_FAILED))
3457
+ }
3458
+ return finishStep(ctx, manifest, decorate(ctx, declined, manifest))
2585
3459
  }
2586
3460
 
2587
3461
  let after
@@ -2591,7 +3465,7 @@ export function runReplan(ctx, { results }) {
2591
3465
  return finishStep(ctx, manifest, haltStep(`replan rejected: ${(err && err.message) || String(err)}`))
2592
3466
  }
2593
3467
  writeState(root, after)
2594
- const replanned = ctx.deps.logWaveMutation(root, {
3468
+ const replanned = ctx.deps.logWaveMutation(stateHomeOf(ctx, manifest), {
2595
3469
  source: 'replan',
2596
3470
  change: manifest.change,
2597
3471
  state: after
@@ -2601,6 +3475,10 @@ export function runReplan(ctx, { results }) {
2601
3475
  ctx.warn('the wave-state trajectory append failed on replan')
2602
3476
  return finishStep(ctx, manifest, trajectoryHaltStep('wave-state replan', waveAppendReason(replanned)))
2603
3477
  }
3478
+ if (!logLaneSpawns(ctx, manifest, after, nextRaw, 'replan')) {
3479
+ ctx.warn('an implementer agent-spawn was not recorded on replan')
3480
+ return finishStep(ctx, manifest, trajectoryHaltStep('agent-spawn', LANE_SPAWN_APPEND_FAILED))
3481
+ }
2604
3482
  return finishStep(ctx, manifest, decorate(ctx, nextRaw, manifest))
2605
3483
  }
2606
3484
 
@@ -2610,22 +3488,50 @@ export function runReplan(ctx, { results }) {
2610
3488
  export function runNext(ctx) {
2611
3489
  const { root } = ctx
2612
3490
  const manifest = readManifest(root)
2613
- if (!manifest) return noManifest()
3491
+ if (!manifest) return noManifest(ctx)
2614
3492
  const capped = countStep(root, manifest)
2615
3493
  if (capped) return recordThen(root, manifest, capped)
2616
3494
  const state = readState(root)
2617
3495
  if (!state) return finishStep(ctx, manifest, haltStep('the run state could not be read'))
2618
- return finishStep(ctx, manifest, decorate(ctx, nextStep(state), manifest))
3496
+ // A re-read that names a batch is a batch the driver will dispatch again, so
3497
+ // those agents are spawns like any other.
3498
+ const pending = nextStep(state)
3499
+ if (!logLaneSpawns(ctx, manifest, state, pending, 'next')) {
3500
+ ctx.warn('an implementer agent-spawn was not recorded on next')
3501
+ return finishStep(ctx, manifest, trajectoryHaltStep('agent-spawn', LANE_SPAWN_APPEND_FAILED))
3502
+ }
3503
+ return finishStep(ctx, manifest, decorate(ctx, pending, manifest))
2619
3504
  }
2620
3505
 
2621
3506
  // --- helpers used by every subcommand ---------------------------------------
2622
3507
 
2623
- function noManifest() {
3508
+ function noManifest(ctx) {
3509
+ const elsewhere = manifestInStateHome(ctx.root)
3510
+ if (elsewhere) {
3511
+ return haltStep(
3512
+ `no run manifest at ${elsewhere.here} — one exists at ${elsewhere.there}; if this session moved into ` +
3513
+ `a worktree after run start, the run continues from there`
3514
+ )
3515
+ }
2624
3516
  return haltStep(
2625
3517
  `no run manifest at ${MANIFEST_PATH} — a run subcommand was reached without \`interlock run start\``
2626
3518
  )
2627
3519
  }
2628
3520
 
3521
+ /**
3522
+ * A manifest the state home holds when this root holds none (design D18): a
3523
+ * session that moved into a worktree after `run start` halts naming both, and
3524
+ * one that did not sees today's message. Resolved here because there is no
3525
+ * manifest to read a recorded home from.
3526
+ */
3527
+ function manifestInStateHome(root) {
3528
+ const here = resolve(root)
3529
+ const { home } = resolveStateHome(here)
3530
+ if (!home || resolve(home) === here) return null
3531
+ const there = join(home, MANIFEST_PATH)
3532
+ return existsSync(there) ? { here: join(here, MANIFEST_PATH), there } : null
3533
+ }
3534
+
2629
3535
  /**
2630
3536
  * Persist what this step observed and record the continuation it names.
2631
3537
  *
@@ -2634,6 +3540,8 @@ function noManifest() {
2634
3540
  * process — each `run` subcommand is its own.
2635
3541
  */
2636
3542
  function finishStep(ctx, manifest, step) {
3543
+ carryRaisedBanners(manifest, step)
3544
+ recordDispatched(manifest, step)
2637
3545
  const failure = logSpawns(ctx, manifest, step)
2638
3546
  // An agent-spawn nobody recorded is a gap in the trajectory, and the step it
2639
3547
  // belongs to must not run. A step that was ALREADY halting keeps its own
@@ -2648,6 +3556,48 @@ function finishStep(ctx, manifest, step) {
2648
3556
  return recordThen(ctx.root, manifest, final)
2649
3557
  }
2650
3558
 
3559
+ /**
3560
+ * Put on the step every banner the run raised since the last step carried one
3561
+ * (draw-the-ship-run-live, design D1).
3562
+ *
3563
+ * A banner pushed onto `manifest.banners` reaches the close summary whatever
3564
+ * happens; this makes sure it reaches a step as well, so a reader of the step
3565
+ * stream (the ship meter) displays what the CLI named instead of discovering it
3566
+ * at close. Derived from the same list the summary prints, in its order, and
3567
+ * additive: a step's own banners stay first, nothing already carried is raised
3568
+ * again. `bannersCarried` is how far into the list the steps have reached.
3569
+ */
3570
+ function carryRaisedBanners(manifest, step) {
3571
+ if (!step || typeof step !== 'object' || !Array.isArray(manifest.banners)) return
3572
+ const from = Number.isInteger(manifest.bannersCarried) ? manifest.bannersCarried : 0
3573
+ const raised = manifest.banners.slice(from)
3574
+ manifest.bannersCarried = manifest.banners.length
3575
+ const own = Array.isArray(step.banners) ? step.banners : []
3576
+ const missing = raised.filter((b, i) => typeof b === 'string' && !own.includes(b) && raised.indexOf(b) === i)
3577
+ if (missing.length) step.banners = [...own, ...missing]
3578
+ }
3579
+
3580
+ /**
3581
+ * The join table for the close (design D4): every spawn on the step, lane
3582
+ * spawns included, with the sha its bootstrap carries. Only once the run has an
3583
+ * id — the same rule `logSpawns` follows — because the recorder writes nothing
3584
+ * for a spawn dispatched before one, so it could never join. Bounded by the
3585
+ * published step cap times the batch width, so it needs no cap of its own.
3586
+ */
3587
+ function recordDispatched(manifest, step) {
3588
+ if (!manifest.runId || !step || !Array.isArray(step.spawns) || !step.spawns.length) return
3589
+ if (!Array.isArray(manifest.dispatched)) manifest.dispatched = []
3590
+ for (const s of step.spawns) {
3591
+ manifest.dispatched.push({
3592
+ wave: Number.isInteger(step.wave) ? step.wave : null,
3593
+ label: s.label,
3594
+ kind: s.kind,
3595
+ model: s.model || null,
3596
+ sha: typeof s.promptSha256 === 'string' ? s.promptSha256 : null
3597
+ })
3598
+ }
3599
+ }
3600
+
2651
3601
  // --- run close (design D10) -------------------------------------------------
2652
3602
 
2653
3603
  /**
@@ -2664,7 +3614,15 @@ function finishStep(ctx, manifest, step) {
2664
3614
  */
2665
3615
  export async function runClose(
2666
3616
  ctx,
2667
- { results = [], halt = null, hostBanners = [], hostObserved = null, notify = false, env = process.env } = {}
3617
+ {
3618
+ results = [],
3619
+ halt = null,
3620
+ hostBanners = [],
3621
+ hostObserved = null,
3622
+ hostRecords = [],
3623
+ notify = false,
3624
+ env = process.env
3625
+ } = {}
2668
3626
  ) {
2669
3627
  const { root } = ctx
2670
3628
  const absRoot = resolve(root)
@@ -2682,10 +3640,14 @@ export async function runClose(
2682
3640
  // The change name is unknown here — there is no manifest to read it from —
2683
3641
  // so the message carries the headline and the absent run id alone.
2684
3642
  const push = await attemptPush(notify, env, { headline, change: null, runId: null })
3643
+ // Where a record would have gone, so the reader can look there (ship/close-summary).
3644
+ const home = resolve(resolveStateHome(absRoot).home)
3645
+ const elsewhere = home !== absRoot && existsSync(join(home, MANIFEST_PATH)) ? join(home, MANIFEST_PATH) : null
2685
3646
  const rows = [
2686
3647
  ' run: none — the run halted before a plan was adopted',
2687
3648
  ` project: ${slug}`,
2688
- ` cwd: ${absRoot}`
3649
+ ` cwd: ${absRoot}`,
3650
+ ...(home !== absRoot ? [` state home: ${home}`] : [])
2689
3651
  ]
2690
3652
  if (push && push.sent !== undefined) {
2691
3653
  rows.push(push.sent ? ' push: sent (ntfy)' : ` push: failed — ${push.reason}`)
@@ -2699,13 +3661,19 @@ export async function runClose(
2699
3661
  banners: Array.isArray(hostBanners) ? hostBanners : [],
2700
3662
  summary:
2701
3663
  `${headline}\n\n` +
2702
- `There is no run manifest at ${MANIFEST_PATH}, so nothing was recorded for this run.\n` +
3664
+ (elsewhere
3665
+ ? `There is no run manifest at ${join(absRoot, MANIFEST_PATH)} — one exists at ${elsewhere}; if ` +
3666
+ `this session moved into a worktree after run start, the run continues from there.\n`
3667
+ : `There is no run manifest at ${MANIFEST_PATH}, so nothing was recorded for this run.\n`) +
2703
3668
  rows.join('\n')
2704
3669
  }
2705
3670
  }
2706
3671
 
2707
3672
  const haltReason = typeof halt === 'string' && halt.trim() ? halt.trim() : manifest.halted || null
2708
3673
  if (haltReason) manifest.halted = haltReason
3674
+ // Corpora go to the home `run start` recorded; the manifest, the state and the
3675
+ // sidecar stay in the root (ship/state-home).
3676
+ const home = stateHomeOf(ctx, manifest)
2709
3677
 
2710
3678
  // The commit result, when the close is the continuation of a commit step.
2711
3679
  const committed = results && results[0] && typeof results[0] === 'object' ? results[0] : null
@@ -2739,6 +3707,16 @@ export async function runClose(
2739
3707
  // any, because the runtime's counter sees turns this process never does.
2740
3708
  const counted = summarizeUsage(manifest)
2741
3709
  const hostSpend = Array.isArray(observed.spend) && observed.spend.length ? observed.spend : null
3710
+
3711
+ // What the recorder hooks wrote for this run, joined to the spawns this program
3712
+ // dispatched (ship/agent-results). Decided here, before the summary and the
3713
+ // degradation block are assembled, so its banners reach both.
3714
+ const joined = joinAgentUsage(root, manifest)
3715
+ const closeObserved = observeJoinedAgents(manifest, joined)
3716
+ // The committer's host record, on a runner: the commit step's continuation is
3717
+ // this close, so its lane is observed here. Usage is left as it was — the close
3718
+ // has never counted the committer's results — and only the host's record is read.
3719
+ const committerObserved = recordSpawnObservations(ctx, manifest, { results: [], hostRecords })
2742
3720
  const hostTotal =
2743
3721
  typeof observed.outputTokens === 'number' || observed.outputTokens === null
2744
3722
  ? observed.outputTokens
@@ -2774,7 +3752,8 @@ export async function runClose(
2774
3752
  if (
2775
3753
  hostCacheRead === undefined &&
2776
3754
  hostCacheCreation === undefined &&
2777
- hostCapability(manifest, 'cacheAccounting') === false
3755
+ hostCapability(manifest, 'cacheAccounting') === false &&
3756
+ closeObserved.usable === 0
2778
3757
  ) {
2779
3758
  manifest.notes.push(
2780
3759
  `CACHE ACCOUNTING NOT REPORTED: the ${hostId(manifest) || 'run'} host's runtime exposes no ` +
@@ -2783,13 +3762,23 @@ export async function runClose(
2783
3762
  )
2784
3763
  }
2785
3764
 
3765
+ const useJoin = joined.present && joined.cacheFromHooks && joined.briefed > 0
2786
3766
  const summary = {
2787
3767
  waves: manifest.waves || [],
2788
3768
  spend: hostSpend || counted.spend,
2789
3769
  outputTokens: hostTotal !== undefined ? hostTotal : counted.outputTokens,
2790
- cacheReadInputTokens: hostCacheRead !== undefined ? hostCacheRead : counted.cacheReadInputTokens,
3770
+ cacheReadInputTokens:
3771
+ hostCacheRead !== undefined
3772
+ ? hostCacheRead
3773
+ : useJoin
3774
+ ? joined.runCache.cacheReadInputTokens
3775
+ : counted.cacheReadInputTokens,
2791
3776
  cacheCreationInputTokens:
2792
- hostCacheCreation !== undefined ? hostCacheCreation : counted.cacheCreationInputTokens,
3777
+ hostCacheCreation !== undefined
3778
+ ? hostCacheCreation
3779
+ : useJoin
3780
+ ? joined.runCache.cacheCreationInputTokens
3781
+ : counted.cacheCreationInputTokens,
2793
3782
  review: tail.review,
2794
3783
  remediation: tail.remediation,
2795
3784
  remediationRounds: tail.remediationRounds,
@@ -2802,6 +3791,8 @@ export async function runClose(
2802
3791
  commitSkipped: manifest.commitSkipped === true,
2803
3792
  paths: readPathSets(root, manifest)
2804
3793
  }
3794
+ // The join's per-wave cache figures, where the host observed none (design D5).
3795
+ if (useJoin) summary.spend = fillJoinedCache(summary.spend, joined.cacheByWave)
2805
3796
 
2806
3797
  const degradations = degradationLines({
2807
3798
  banners: manifest.banners || [],
@@ -2865,19 +3856,36 @@ export async function runClose(
2865
3856
  ? fingerprint.hash
2866
3857
  : undefined
2867
3858
 
3859
+ // The six observation counts: absent when the run saw no host record and no
3860
+ // sidecar, a measured zero otherwise (design D8).
3861
+ const runnerCounts = manifest.agentCounts && typeof manifest.agentCounts === 'object' ? manifest.agentCounts : null
3862
+ const observedAny = Boolean(runnerCounts) || joined.present
3863
+ const count = (value = 0) => (observedAny ? value : undefined)
2868
3864
  const receipt = buildReceipt({
2869
3865
  change: manifest.change,
2870
3866
  summary,
2871
3867
  degradations,
2872
3868
  planFingerprint,
2873
3869
  leftoverTaskIds,
3870
+ lanesStoppedByHost: count(runnerCounts ? runnerCounts.lanesStoppedByHost : 0),
3871
+ schemaResultsMissing: count(runnerCounts ? runnerCounts.schemaResultsMissing : 0),
3872
+ toolsDeniedInLanes: count(runnerCounts ? runnerCounts.toolsDeniedInLanes : 0),
3873
+ modelSubstitutions: count((runnerCounts ? runnerCounts.modelSubstitutions : 0) + closeObserved.substitutions),
3874
+ permissionPrompts: count(closeObserved.permissionPrompts),
3875
+ autoModeDenials: count(closeObserved.autoModeDenials),
3876
+ surface: manifest.surface,
3877
+ stateHome: manifest.stateHome,
2874
3878
  host: manifest.host
2875
3879
  ? {
2876
3880
  id: hostId(manifest),
2877
3881
  billing: hostCapability(manifest, 'billing'),
2878
3882
  hooks: hostCapability(manifest, 'hooks'),
2879
3883
  usage: hostCapability(manifest, 'usage'),
2880
- cacheAccounting: hostCapability(manifest, 'cacheAccounting')
3884
+ cacheAccounting: hostCapability(manifest, 'cacheAccounting'),
3885
+ // The manifest's own field, never `hostCapability`: that falls back to
3886
+ // the assumed `flag`, and a run that recorded nothing would then read
3887
+ // as one whose effort was applied.
3888
+ effort: manifest.host.effort === undefined ? null : manifest.host.effort
2881
3889
  }
2882
3890
  : undefined
2883
3891
  })
@@ -2888,8 +3896,24 @@ export async function runClose(
2888
3896
  // degradation and a non-zero exit, exactly what an unreconstructable run gets
2889
3897
  // below. Silence would leave a run that reads complete and cannot be replayed.
2890
3898
  let trajectoryComplete = true
3899
+ if (committerObserved.failure) {
3900
+ trajectoryComplete = false
3901
+ degradations.push(`TRAJECTORY APPEND FAILED: agent-result ${committerObserved.failure}`)
3902
+ }
2891
3903
  if (manifest.runId) {
2892
- const written = appendRunLogEvent(root, {
3904
+ // One `agent-result` per joined agent, before the receipt: the same fatal
3905
+ // class as every other append here (ship/agent-results).
3906
+ for (const entry of closeObserved.results) {
3907
+ const result = appendRunLogEvent(home, { runId: manifest.runId, change: manifest.change, ...entry })
3908
+ if (!result.written) {
3909
+ ctx.warn(`agent-result not recorded: ${result.reason}`)
3910
+ trajectoryComplete = false
3911
+ degradations.push(`TRAJECTORY APPEND FAILED: agent-result ${entry.label}: ${result.reason}`)
3912
+ break
3913
+ }
3914
+ }
3915
+
3916
+ const written = appendRunLogEvent(home, {
2893
3917
  ...receipt,
2894
3918
  runId: manifest.runId,
2895
3919
  change: manifest.change
@@ -2900,7 +3924,7 @@ export async function runClose(
2900
3924
  degradations.push(`TRAJECTORY APPEND FAILED: run-receipt: ${written.reason}`)
2901
3925
  }
2902
3926
 
2903
- const terminal = appendRunLogEvent(root, {
3927
+ const terminal = appendRunLogEvent(home, {
2904
3928
  runId: manifest.runId,
2905
3929
  change: manifest.change,
2906
3930
  type: haltReason ? 'run-halt' : 'run-complete',
@@ -2916,7 +3940,7 @@ export async function runClose(
2916
3940
 
2917
3941
  // The outcome corpus. Its own write failure is reported and never touches the
2918
3942
  // exit code: losing a corpus line must not fail the run that produced it.
2919
- const outcome = appendOutcome(root, {
3943
+ const outcome = appendOutcome(home, {
2920
3944
  change: manifest.change,
2921
3945
  mode: manifest.flags.mode || 'checkpoint',
2922
3946
  observed: observedFromReceipt(receipt)
@@ -2928,7 +3952,7 @@ export async function runClose(
2928
3952
  // this repository deliberately keeps different per corpus.
2929
3953
  let reconstructable = true
2930
3954
  if (manifest.runId) {
2931
- const check = checkRunLog(root, manifest.runId)
3955
+ const check = checkRunLog(home, manifest.runId)
2932
3956
  reconstructable = check.ok !== false
2933
3957
  if (!reconstructable) {
2934
3958
  degradations.push(
@@ -2950,7 +3974,7 @@ export async function runClose(
2950
3974
  if (manifest.flags.strict === true && manifest.review) {
2951
3975
  const blockers = survivingBlockers(manifest)
2952
3976
  try {
2953
- recordAutonomy('review-code', { blockers }, { root })
3977
+ recordAutonomy('review-code', { blockers }, { root: home })
2954
3978
  manifest.autonomy = { path: 'review-code', blockers }
2955
3979
  } catch (err) {
2956
3980
  degradations.push(
@@ -2974,7 +3998,7 @@ export async function runClose(
2974
3998
  // was hand-edited, cannot change what a later run does.
2975
3999
  let resumeCard = null
2976
4000
  if (haltReason) {
2977
- const written = writeResumeCard(root, {
4001
+ const written = writeResumeCard(home, {
2978
4002
  change: manifest.change,
2979
4003
  runId: manifest.runId,
2980
4004
  halted: haltReason,
@@ -2984,7 +4008,9 @@ export async function runClose(
2984
4008
  waves: manifest.waves || [],
2985
4009
  degradations,
2986
4010
  projectSlug: slug,
2987
- cwd: absRoot
4011
+ cwd: absRoot,
4012
+ stateHome: home,
4013
+ laneSessions: Array.isArray(manifest.laneSessions) ? manifest.laneSessions : []
2988
4014
  })
2989
4015
  if (written.written) resumeCard = written.path
2990
4016
  else degradations.push(`RESUME CARD NOT WRITTEN: ${written.reason}`)
@@ -3008,6 +4034,7 @@ export async function runClose(
3008
4034
  degradations,
3009
4035
  runId: manifest.runId,
3010
4036
  root: absRoot,
4037
+ stateHome: home,
3011
4038
  projectSlug: slug,
3012
4039
  push,
3013
4040
  unarchived,
@@ -3016,6 +4043,87 @@ export async function runClose(
3016
4043
  }
3017
4044
  }
3018
4045
 
4046
+ /**
4047
+ * Turn the close's join into what a reader sees (ship/agent-results): the
4048
+ * partial and unjoined lines, the observed `hook` refinement, the permission
4049
+ * counts, a model substitution per joined spawn, and the `agent-result` records
4050
+ * the close appends before the receipt. Banners land on `manifest.banners`,
4051
+ * notes on `manifest.notes`, before the summary is assembled from them.
4052
+ */
4053
+ function observeJoinedAgents(manifest, joined) {
4054
+ const out = { usable: 0, substitutions: 0, permissionPrompts: 0, autoModeDenials: 0, results: [] }
4055
+ for (const entry of joined.unreadable) {
4056
+ manifest.notes.push(`AGENT USAGE UNREADABLE: ${entry.file}: ${entry.reason}`)
4057
+ }
4058
+ if (!joined.present) return out
4059
+
4060
+ out.usable = joined.joined.filter(j => j.agent.usage && typeof j.agent.usage === 'object').length
4061
+ // The cache half is the hooks standing in for a host that declared it cannot
4062
+ // report: on a host that reports its own figures (the runner), a lane is a
4063
+ // top-level session no subagent hook ever sees, and a sidecar holding only a
4064
+ // permission file must not read as every lane unrecorded.
4065
+ if (joined.cacheFromHooks && joined.briefed > 0 && joined.unrecorded > 0) {
4066
+ manifest.banners.push(`CACHE ACCOUNTING PARTIAL: ${joined.unrecorded} of ${joined.briefed} agents unrecorded`)
4067
+ }
4068
+ const unjoined = joined.unjoined.noKey + joined.unjoined.unknownKey
4069
+ if (unjoined > 0) {
4070
+ manifest.notes.push(
4071
+ `AGENT USAGE UNJOINED: ${unjoined} recorded agents matched no dispatched spawn ` +
4072
+ `(${joined.unjoined.noKey} without a briefing key, ${joined.unjoined.unknownKey} with a key no spawn dispatched)`
4073
+ )
4074
+ }
4075
+ // Observed only, and only from a declared `false`: no adapter declares `hook`,
4076
+ // and a partial join keeps what the host declared.
4077
+ if (joined.cacheFromHooks && joined.complete && manifest.host && typeof manifest.host === 'object') {
4078
+ manifest.host.cacheAccounting = 'hook'
4079
+ }
4080
+
4081
+ for (const { spawned, agent } of joined.joined) {
4082
+ const verdict = modelSubstitution(spawned.model, { servedModels: agent.models })
4083
+ if (verdict.substituted === true) {
4084
+ manifest.banners.push(modelSubstitutedBanner(spawned.label, spawned.model, verdict.ran))
4085
+ out.substitutions++
4086
+ }
4087
+ out.results.push({
4088
+ type: 'agent-result',
4089
+ label: spawned.label,
4090
+ kind: spawned.kind,
4091
+ agentId: agent.agentId,
4092
+ source: 'transcript',
4093
+ numTurns: agent.turns,
4094
+ modelRouted: spawned.model || undefined,
4095
+ servedModels: agent.models,
4096
+ modelScope: verdict.scope,
4097
+ substituted: verdict.substituted,
4098
+ usage: agent.usage
4099
+ })
4100
+ }
4101
+
4102
+ const requests = joined.permissions.filter(p => p && p.kind === 'request')
4103
+ const denials = joined.permissions.filter(p => p && p.kind === 'denied')
4104
+ out.permissionPrompts = requests.length
4105
+ out.autoModeDenials = denials.length
4106
+ if (requests.length) manifest.banners.push(`PERMISSION PROMPTS DURING RUN: ${requests.length}`)
4107
+ if (denials.length) {
4108
+ const tools = [...new Set(denials.map(p => p.tool).filter(t => typeof t === 'string' && t))]
4109
+ manifest.banners.push(`AUTO MODE DENIED ${denials.length} TOOL CALLS${tools.length ? ` (${tools.join(', ')})` : ''}`)
4110
+ }
4111
+ return out
4112
+ }
4113
+
4114
+ /**
4115
+ * The spend rows with the join's cache figures where the host observed none
4116
+ * (design D5's precedence: the host's own figure, then the join, then what the
4117
+ * results carried). A wave the join could not complete is recorded unknown.
4118
+ */
4119
+ function fillJoinedCache(rows, cacheByWave) {
4120
+ return rows.map(row => {
4121
+ if (!row || !Number.isInteger(row.wave) || !cacheByWave.has(row.wave)) return row
4122
+ if (Number.isFinite(row.cacheReadInputTokens)) return row
4123
+ return { ...row, ...cacheByWave.get(row.wave) }
4124
+ })
4125
+ }
4126
+
3019
4127
  /**
3020
4128
  * Attempt the close's push, or decide not to. Returns what `formatRunSummary`
3021
4129
  * takes as its `push` input: `null` when no push was attempted at all, or