@iowarp/clio-coder 0.3.7 → 0.3.8

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 (223) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +7 -2
  3. package/dist/{acp-SK4MD6MM.js → acp-U67UHUK2.js} +13 -13
  4. package/dist/{agents-2FN2K6ME.js → agents-YU6SGALZ.js} +35 -33
  5. package/dist/assets/codewiki.json +1 -1
  6. package/dist/{auth-QIYZWM5I.js → auth-5ZPJOIVG.js} +32 -24
  7. package/dist/builtins-C6JMZVV6.js +17 -0
  8. package/dist/{chunk-YD734TPH.js → chunk-26LEYJZH.js} +2 -2
  9. package/dist/{chunk-TSHXZTOQ.js → chunk-2HEJ2F35.js} +5 -5
  10. package/dist/{chunk-WJHBC77E.js → chunk-2HFZQUHL.js} +5 -5
  11. package/dist/{chunk-FO5ZOVUY.js → chunk-3BINW3FP.js} +5 -5
  12. package/dist/{chunk-EOOQZZDE.js → chunk-4SPRNWDE.js} +18 -18
  13. package/dist/{chunk-EBEFWSGL.js → chunk-5DHKRSMQ.js} +7 -7
  14. package/dist/{chunk-OBMAI2DP.js → chunk-5H3GB5BO.js} +11 -9
  15. package/dist/{chunk-JTSEDYVQ.js → chunk-5Q2VVUKB.js} +7 -7
  16. package/dist/{chunk-VQNODYQ4.js → chunk-7RFXX52T.js} +294 -3704
  17. package/dist/{chunk-XEGB6BCN.js → chunk-7RGZWPB6.js} +5 -5
  18. package/dist/{chunk-GH5622CP.js → chunk-A2NJGIB3.js} +2 -2
  19. package/dist/{chunk-CEYBNUGC.js → chunk-A3WNZD3P.js} +323 -27
  20. package/dist/{chunk-D4MDIG46.js → chunk-B5CSFE7B.js} +7 -7
  21. package/dist/{chunk-2SFS6XQE.js → chunk-DGSYXYMX.js} +3 -2
  22. package/dist/{chunk-ZWLZP4ZT.js → chunk-DYIM5TJT.js} +98 -12
  23. package/dist/{chunk-KCMKRQX4.js → chunk-E77JEWSD.js} +42 -49
  24. package/dist/{chunk-J7PIKKWC.js → chunk-EMYUUSFG.js} +7 -7
  25. package/dist/{chunk-JRIO5UD2.js → chunk-EQ63NRB7.js} +5 -5
  26. package/dist/{chunk-UVDSQ6LW.js → chunk-FBVTI2TJ.js} +74 -28
  27. package/dist/{chunk-BMWK7ZIZ.js → chunk-FHJEP5SW.js} +13 -13
  28. package/dist/{chunk-FOT2FX5J.js → chunk-GN57SG4G.js} +7 -7
  29. package/dist/{chunk-SROCI7ZU.js → chunk-GPIEI3LY.js} +5 -5
  30. package/dist/{chunk-UFQ3F4FW.js → chunk-GU2UIAFZ.js} +4 -4
  31. package/dist/{chunk-UND3GU2L.js → chunk-H7IXIC72.js} +2 -2
  32. package/dist/{chunk-VREKEFLL.js → chunk-HLE42MG7.js} +3 -3
  33. package/dist/{chunk-465YSENW.js → chunk-IFBNV6H6.js} +3 -3
  34. package/dist/{chunk-LADCF22A.js → chunk-IGWKHNIQ.js} +113 -54
  35. package/dist/chunk-IIZWH4XA.js +172 -0
  36. package/dist/{chunk-UHXRNZ2J.js → chunk-IJ7RPIYJ.js} +4 -4
  37. package/dist/{chunk-M4AKACEO.js → chunk-J3YUBZWY.js} +2 -2
  38. package/dist/{chunk-AB4XIIVB.js → chunk-JOZYP4GM.js} +6 -6
  39. package/dist/{chunk-ZGH7FGS5.js → chunk-K4XHGFR5.js} +742 -12
  40. package/dist/{chunk-EELBMBT6.js → chunk-KTYTFRMB.js} +62 -5
  41. package/dist/chunk-LU7P4LHA.js +33 -0
  42. package/dist/{chunk-DQA7QLMD.js → chunk-ME6CCNFO.js} +9 -24
  43. package/dist/{chunk-KZ2H5X4G.js → chunk-MXKJU4JB.js} +88 -14
  44. package/dist/{chunk-DMD2AGVS.js → chunk-N22QMJKY.js} +20 -18
  45. package/dist/chunk-NMPKI6XL.js +3006 -0
  46. package/dist/chunk-NUGM5KR6.js +165 -0
  47. package/dist/{chunk-5C77SEEY.js → chunk-P43ETTHK.js} +2 -2
  48. package/dist/{chunk-JNXPYBB4.js → chunk-PMDBGQSJ.js} +2 -2
  49. package/dist/chunk-PT7HYKEM.js +165 -0
  50. package/dist/{chunk-GEYXPTRF.js → chunk-RWSI4YD7.js} +2 -2
  51. package/dist/{chunk-C4JBQ5SR.js → chunk-TANS5ZJS.js} +6 -6
  52. package/dist/{chunk-YTYFXUI3.js → chunk-TB5666IT.js} +9 -9
  53. package/dist/{chunk-UUANF5CR.js → chunk-TLQJPP24.js} +375 -687
  54. package/dist/{chunk-OB5HIGJY.js → chunk-TT36MB5S.js} +2 -1
  55. package/dist/{chunk-DJNLUABN.js → chunk-TTHACPOM.js} +135 -17
  56. package/dist/{chunk-6M7VS3J3.js → chunk-TYPGUK6W.js} +4 -4
  57. package/dist/{chunk-THKY7CD7.js → chunk-U6MBIEMB.js} +108 -24
  58. package/dist/{chunk-PD3MESLB.js → chunk-VAWNZU7Z.js} +4 -4
  59. package/dist/{chunk-IR4CFBFN.js → chunk-VCBR6CU7.js} +12 -12
  60. package/dist/{chunk-WHJYKASB.js → chunk-VHN4MY6O.js} +2 -2
  61. package/dist/{chunk-MXI6J5JF.js → chunk-VWZOAB7K.js} +10 -10
  62. package/dist/{chunk-QCTRSGHQ.js → chunk-WLFILSD5.js} +36 -36
  63. package/dist/{chunk-5UJ6ECTS.js → chunk-WNIJTQQK.js} +6 -6
  64. package/dist/{chunk-4DWFMQDR.js → chunk-WSB3FPX7.js} +68 -20
  65. package/dist/{chunk-3HAPLH5M.js → chunk-WWCZ5F23.js} +112 -10
  66. package/dist/{chunk-X2KV5FXT.js → chunk-WXY7KU3G.js} +2 -2
  67. package/dist/{chunk-PPAMZ32Z.js → chunk-XK56QHLX.js} +6 -1
  68. package/dist/{chunk-DOOEX22V.js → chunk-XWSF374K.js} +4 -4
  69. package/dist/{chunk-6TUKSZVF.js → chunk-YS5VLNH5.js} +8 -8
  70. package/dist/{chunk-ZZMN5OM4.js → chunk-ZNLWCMVZ.js} +2 -2
  71. package/dist/{chunk-WHGPSPT5.js → chunk-ZVJ5BLO2.js} +2 -2
  72. package/dist/cli/index.js +31 -31
  73. package/dist/{clio-WBVQEBKO.js → clio-QVTYJ57A.js} +8 -8
  74. package/dist/code-nav-FGGFIE7L.js +2 -2
  75. package/dist/codewiki/build-worker.js +4 -4
  76. package/dist/{components-F7OEATSO.js → components-ZFA3SAER.js} +8 -8
  77. package/dist/{config-TRBL3RCF.js → config-LW5IJFQN.js} +58 -53
  78. package/dist/{configure-OLCVPHNM.js → configure-7XIZCOU4.js} +26 -21
  79. package/dist/{context-XEWE3MOJ.js → context-L3WL3X7K.js} +47 -41
  80. package/dist/{context-WFPKQSM6.js → context-N52ZA626.js} +22 -22
  81. package/dist/{context-MJIJ6GOX.js → context-Y6Y7QPR6.js} +12 -12
  82. package/dist/{context-clear-KNOS2JPB.js → context-clear-MBQRLSDQ.js} +47 -41
  83. package/dist/{context-index-SSR5ECNE.js → context-index-HVMFQHK3.js} +5 -5
  84. package/dist/{context-working-set-EUXAZI6N.js → context-working-set-GS6DSO7F.js} +14 -14
  85. package/dist/{dispatch-runner-B7MTOVKL.js → dispatch-runner-22ZCNOM3.js} +83 -55
  86. package/dist/{docs-FLJTIDSE.js → docs-7LQ23DLM.js} +8 -8
  87. package/dist/{doctor-RN4YKO2X.js → doctor-M7YEDGAE.js} +24 -20
  88. package/dist/{eval-RUBJVSNQ.js → eval-BEC2WHDA.js} +71 -19
  89. package/dist/{evidence-JZNBUOQZ.js → evidence-REJUMSKM.js} +62 -56
  90. package/dist/{evolve-FJVC4KKI.js → evolve-PY5ZBA5K.js} +41 -35
  91. package/dist/{extensions-IQL36S7K.js → extensions-HVKU65YU.js} +6 -6
  92. package/dist/{fleet-BDKYJFCP.js → fleet-7WZEWRFA.js} +72 -67
  93. package/dist/{fleet-commands-ZFIWZSB3.js → fleet-commands-UVHWM76J.js} +10 -10
  94. package/dist/{fleet-graph-Y6HPXIVF.js → fleet-graph-6ULH7PES.js} +13 -13
  95. package/dist/{fleet-preflight-BHSNPBMH.js → fleet-preflight-J53T6CCE.js} +5 -5
  96. package/dist/{fleet-validate-BIYREGIK.js → fleet-validate-72PC4SLA.js} +17 -17
  97. package/dist/{init-LQUB5COQ.js → init-OG3TPGQG.js} +61 -56
  98. package/dist/{library-NJAHIGG4.js → library-CNTMPLRF.js} +18 -18
  99. package/dist/{memory-OG6HOYKM.js → memory-6IS7F275.js} +43 -37
  100. package/dist/{models-5ZG5XY7J.js → models-ENRJDA5W.js} +33 -28
  101. package/dist/{monitor-TJ7AMTGB.js → monitor-XLDVO7TN.js} +56 -38
  102. package/dist/{orchestrator-WZYB54DM.js → orchestrator-6KSPYRHA.js} +421 -303
  103. package/dist/{paths-XUC7GS6E.js → paths-DBXMZMDU.js} +5 -5
  104. package/dist/registry-LG64LTF4.js +11 -0
  105. package/dist/{reset-PXQT45IY.js → reset-RZ4ER727.js} +10 -10
  106. package/dist/{run-FQ74YF62.js → run-Y2CNK5RU.js} +79 -74
  107. package/dist/{share-FW7SVCL3.js → share-A55GYP6Z.js} +16 -16
  108. package/dist/{skills-7E7IRB3R.js → skills-ALC5J6AT.js} +19 -19
  109. package/dist/{skills-eval-LI75W6OK.js → skills-eval-JPBEBYQU.js} +52 -47
  110. package/dist/support-MIETYA5E.js +38 -0
  111. package/dist/{targets-4CIFKCTW.js → targets-VGNXIR3S.js} +42 -33
  112. package/dist/{terminal-lease-WUZY7ZV5.js → terminal-lease-WOBR64YA.js} +3 -3
  113. package/dist/{uninstall-7FV7IP4E.js → uninstall-ZJF5H5ZN.js} +8 -8
  114. package/dist/{upgrade-K2HVIVMQ.js → upgrade-FUSUAGHR.js} +27 -25
  115. package/dist/{usage-GTZELZQX.js → usage-N4MKVHKD.js} +51 -46
  116. package/dist/{verifiers-RLAHT27O.js → verifiers-YAWOJ3H2.js} +13 -13
  117. package/dist/{verify-BX3BRKH5.js → verify-LTDHYBGY.js} +9 -9
  118. package/dist/{wiki-generate-ASIFASCN.js → wiki-generate-6M7GHTBJ.js} +66 -61
  119. package/dist/worker/entry.js +68 -61
  120. package/docs/alcf-provider.md +1 -1
  121. package/docs/architecture.md +1 -1
  122. package/docs/artifact-versions.md +6 -5
  123. package/docs/built-in-agents.md +1 -1
  124. package/docs/commands-and-modes.md +2 -2
  125. package/docs/configuration-and-targets.md +8 -3
  126. package/docs/context-engine.md +1 -1
  127. package/docs/documentation-guide.md +1 -1
  128. package/docs/eval-runner.md +1 -1
  129. package/docs/evals-internal.md +1 -1
  130. package/docs/evidence-and-memory.md +70 -6
  131. package/docs/evolution.md +1 -1
  132. package/docs/extensions-and-sharing.md +1 -1
  133. package/docs/fleet-dispatch.md +34 -9
  134. package/docs/glossary.md +21 -1
  135. package/docs/installation-and-lifecycle.md +1 -1
  136. package/docs/middleware-and-components.md +1 -1
  137. package/docs/model-catalog.md +1 -1
  138. package/docs/observability.md +2 -2
  139. package/docs/proactive-memory.md +1 -1
  140. package/docs/prompt-envelope-and-tools.md +4 -2
  141. package/docs/provider-adapter-cookbook.md +1 -1
  142. package/docs/release-cut-checklist.md +41 -38
  143. package/docs/safety-model.md +1 -1
  144. package/docs/scientific-validation.md +1 -1
  145. package/docs/skills-marketplace.md +1 -1
  146. package/docs/tool-usage.md +1 -1
  147. package/docs/trace-store.md +1 -1
  148. package/docs/tui-design.md +1 -1
  149. package/docs/worker-dispatch-mechanics.md +1 -1
  150. package/package.json +1 -2
  151. package/src/cli/argv.ts +5 -0
  152. package/src/cli/configure.ts +107 -23
  153. package/src/cli/doctor.ts +5 -1
  154. package/src/cli/evidence.ts +30 -25
  155. package/src/cli/fleet-preflight.ts +2 -12
  156. package/src/cli/shared.ts +1 -0
  157. package/src/cli/targets.ts +4 -1
  158. package/src/cli/validate-model.ts +60 -5
  159. package/src/core/bus-events.ts +25 -0
  160. package/src/core/commit-attribution.ts +4 -4
  161. package/src/core/path-boundary.ts +100 -0
  162. package/src/domains/agents/extension.ts +2 -11
  163. package/src/domains/agents/fleet-contract.ts +30 -12
  164. package/src/domains/agents/recipe.ts +7 -1
  165. package/src/domains/agents/registry.ts +73 -5
  166. package/src/domains/agents/result-contract.ts +128 -17
  167. package/src/domains/agents/write-boundary.ts +15 -50
  168. package/src/domains/context/project-rules.ts +51 -1
  169. package/src/domains/dispatch/assignment-reconcile.ts +22 -5
  170. package/src/domains/dispatch/assignment-store.ts +151 -14
  171. package/src/domains/dispatch/contract.ts +15 -1
  172. package/src/domains/dispatch/delegation-plan.ts +2 -5
  173. package/src/domains/dispatch/execution-role.ts +9 -1
  174. package/src/domains/dispatch/extension.ts +156 -67
  175. package/src/domains/dispatch/fleet-run.ts +57 -3
  176. package/src/domains/dispatch/gate-role-prompts.ts +38 -0
  177. package/src/domains/dispatch/index.ts +3 -1
  178. package/src/domains/dispatch/intent-requirements.ts +40 -0
  179. package/src/domains/dispatch/intent.ts +84 -8
  180. package/src/domains/dispatch/path-scope.ts +370 -0
  181. package/src/domains/dispatch/receipt-integrity.ts +2 -1
  182. package/src/domains/dispatch/types.ts +14 -7
  183. package/src/domains/dispatch/validation.ts +6 -3
  184. package/src/domains/dispatch/write-boundary-enforcer.ts +45 -0
  185. package/src/domains/dispatch/write-boundary.ts +201 -22
  186. package/src/domains/eval/metrics/evidence.ts +79 -2
  187. package/src/domains/eval/runners/clio-run.ts +12 -2
  188. package/src/domains/evidence/build.ts +69 -11
  189. package/src/domains/evidence/index.ts +21 -0
  190. package/src/domains/evidence/provenance.ts +46 -11
  191. package/src/domains/evidence/trust-projection.ts +274 -0
  192. package/src/domains/evidence/trust-status.ts +145 -17
  193. package/src/domains/evidence/types.ts +4 -0
  194. package/src/domains/extensions/discovery.ts +88 -1
  195. package/src/domains/extensions/resources.ts +20 -8
  196. package/src/domains/extensions/state.ts +6 -2
  197. package/src/domains/extensions/types.ts +4 -1
  198. package/src/domains/lifecycle/doctor.ts +140 -1
  199. package/src/domains/prompts/contract.ts +3 -5
  200. package/src/domains/providers/extension.ts +30 -2
  201. package/src/domains/resources/common-loader.ts +3 -0
  202. package/src/domains/resources/prompts/loader.ts +119 -14
  203. package/src/domains/safety/policy-engine.ts +5 -5
  204. package/src/domains/safety/run-effects.ts +64 -1
  205. package/src/domains/safety/scope.ts +7 -12
  206. package/src/engine/acp/server.ts +4 -1
  207. package/src/engine/prompt-templates.ts +18 -1
  208. package/src/engine/worker-runtime.ts +6 -3
  209. package/src/interactive/dispatch-board.ts +53 -3
  210. package/src/interactive/interactive-event-projection.ts +14 -0
  211. package/src/interactive/overlays/settings.ts +141 -47
  212. package/src/interactive/slash-commands.ts +7 -2
  213. package/src/interactive/view/artifacts.ts +42 -9
  214. package/src/interactive/view/view-overlay.ts +15 -3
  215. package/src/interactive/worker-receipts.ts +14 -2
  216. package/src/interactive/worker-stream.ts +8 -0
  217. package/src/tools/dispatch-admission.ts +12 -13
  218. package/src/tools/dispatch-arguments.ts +27 -0
  219. package/src/tools/dispatch-plan.ts +29 -0
  220. package/src/tools/dispatch-runner.ts +48 -13
  221. package/src/tools/monitor.ts +13 -0
  222. package/src/tools/worker-evidence.ts +19 -13
  223. package/src/worker/spec-contract.ts +2 -1
@@ -123,6 +123,7 @@ import { AssignmentRegistry, applyActiveRouteSelection, asAssignmentId } from ".
123
123
  import type { AssignmentAttemptStartEvent } from "./assignment-events.js";
124
124
  import { reconcileOrphanAssignments } from "./assignment-reconcile.js";
125
125
  import {
126
+ assignmentProcessOwnerAlive,
126
127
  cancelStoredAssignment,
127
128
  failQueuedAssignment,
128
129
  getStoredAssignment,
@@ -167,6 +168,7 @@ import { routeFactVerdict } from "./fleet-preflight.js";
167
168
  import { competeStanceLiner, isBoundedGateRolePrompt } from "./gate-role-prompts.js";
168
169
  import { classifyHeartbeat, DEFAULT_HEARTBEAT_SPEC, type HeartbeatSpec, type HeartbeatStatus } from "./heartbeat.js";
169
170
  import { hostVerificationRejection, runHostVerification } from "./host-verification.js";
171
+ import { renderDispatchIntentRequirements } from "./intent-requirements.js";
170
172
  import { adaptJointRouteInput } from "./joint-route-adapter.js";
171
173
  import {
172
174
  configuredJointNodes,
@@ -181,6 +183,12 @@ import {
181
183
  resultContractWasDue,
182
184
  runStatusForOutcome,
183
185
  } from "./outcome.js";
186
+ import {
187
+ type DispatchPathScope,
188
+ declaredScopeReplacementDiagnostic,
189
+ declaredScopeReplacementNotice,
190
+ resolveDispatchPathScope,
191
+ } from "./path-scope.js";
184
192
  import { deriveEnvelopePhaseDurations, deriveRunPhaseDurations, recordRunTimingBestEffort } from "./phase-timing.js";
185
193
  import { createFleetPlacementPreviewResolver, createFleetPlacementResolver } from "./placement.js";
186
194
  import {
@@ -614,6 +622,14 @@ function recordWorkerRunEffect(recorder: RunEffectsRecorder, event: Record<strin
614
622
  */
615
623
  const DISPATCH_DRAIN_GRACE_MS = 2000;
616
624
 
625
+ /**
626
+ * How many finalized runs keep their observed write targets in memory. The
627
+ * write boundary reads a run's record while closing the window it ran in, which
628
+ * is the same wave, so a few hundred runs of headroom is far more than the
629
+ * lookup ever reaches back for.
630
+ */
631
+ const RUN_WRITE_TARGET_HISTORY = 512;
632
+
617
633
  async function awaitEventDrain(drained: Promise<void>, graceMs = DISPATCH_DRAIN_GRACE_MS): Promise<boolean> {
618
634
  let timer: ReturnType<typeof setTimeout> | undefined;
619
635
  const grace = new Promise<false>((resolve) => {
@@ -751,9 +767,14 @@ function pickOrchestratorScope(safety: SafetyContract): ScopeSpec {
751
767
  return safety.scopes.workspace;
752
768
  }
753
769
 
754
- function pickWorkerScope(safety: SafetyContract, requestedActions: ReadonlyArray<ActionClass>): ScopeSpec {
770
+ function pickWorkerScope(
771
+ safety: SafetyContract,
772
+ requestedActions: ReadonlyArray<ActionClass>,
773
+ pathScope: DispatchPathScope,
774
+ ): ScopeSpec {
755
775
  if (requestedActions.every((action) => action === "read")) return safety.scopes.readonly;
756
- return safety.scopes.workspace;
776
+ if (pathScope.writeBoundaries.length === 0) return safety.scopes.workspace;
777
+ return { ...safety.scopes.workspace, allowedWriteRoots: pathScope.writeBoundaries };
757
778
  }
758
779
 
759
780
  function deriveRequestedActions(tools: ReadonlyArray<ToolName>, safety: SafetyContract): ReadonlyArray<ActionClass> {
@@ -993,6 +1014,25 @@ function hasPersonaOverride(req: DispatchRequest): boolean {
993
1014
  return typeof req.systemPrompt === "string" && req.systemPrompt.trim().length > 0;
994
1015
  }
995
1016
 
1017
+ /**
1018
+ * Whether this request's system prompt is a caller-authored persona rather
1019
+ * than one of the coordinator's own bounded gate-role prompts.
1020
+ *
1021
+ * A reviewer, a compete judge, and a council synthesis all carry a system
1022
+ * prompt the coordinator wrote, pinned by {@link isBoundedGateRolePrompt} to
1023
+ * one exact text under one gate role and read-only autonomy. That is the
1024
+ * topology speaking, not an operator reshaping a recipe, so it is not the
1025
+ * thing the shadow and internal audiences are protected from. The ACP
1026
+ * delegation path already draws the line here; without the same line, a
1027
+ * council that seats the builtin read-only `researcher` (a shadow recipe, and
1028
+ * the agent every unnamed council seats) refused `--synthesis judge` outright,
1029
+ * before a single member ran.
1030
+ */
1031
+ function hasCallerPersonaOverride(req: DispatchRequest): boolean {
1032
+ if (!hasPersonaOverride(req)) return false;
1033
+ return !isBoundedGateRolePrompt({ role: req.gate?.role, autonomy: req.autonomy, systemPrompt: req.systemPrompt });
1034
+ }
1035
+
996
1036
  function personaOverrideFor(req: DispatchRequest, staticCompositionHash: string | null): RunPersonaOverride | null {
997
1037
  if (!hasPersonaOverride(req) || staticCompositionHash === null) return null;
998
1038
  return { promptHash: staticCompositionHash };
@@ -1003,9 +1043,8 @@ function personaOverrideFor(req: DispatchRequest, staticCompositionHash: string
1003
1043
  * flows through dynamic messages, never through the system prompt. The system
1004
1044
  * prompt itself is stable per recipe and tool surface with one exception: the
1005
1045
  * operator-editable layer (`additionalFragments`) folds in path-scoped project
1006
- * rules selected by `workerWorkingContextPaths`, which are recalled from the
1007
- * task and briefing text, so `staticCompositionHash` can differ between two
1008
- * runs of one recipe when a rule's glob matches one task and not the other.
1046
+ * rules selected from the canonical request scope, so `staticCompositionHash`
1047
+ * can differ between two runs of one recipe when their scoped rules differ.
1009
1048
  * A local model's worker prefix cache misses on that flip; the trade was made
1010
1049
  * deliberately in #96 to keep rules scoped rather than shipped wholesale.
1011
1050
  */
@@ -1074,35 +1113,6 @@ function workerProjectContextIncludesVerification(body: string): boolean {
1074
1113
  return body.includes("\nVerification expectations:\n");
1075
1114
  }
1076
1115
 
1077
- /**
1078
- * Path-like tokens in free text: a slash-separated segment, or a bare
1079
- * `name.ext` token. The model-facing dispatch tool has no structured
1080
- * path/file field, so a worker's task text is the only working-context
1081
- * signal available at compile time; this is best-effort recall, not a
1082
- * parser. A false-positive token is harmless because `selectActiveRules`
1083
- * only activates a rule when the token matches that rule's own glob.
1084
- */
1085
- const PATH_TOKEN_RE = /(?:[\w.-]+\/)+[\w.-]+|\b[\w-]+\.[A-Za-z0-9]{1,8}\b/g;
1086
-
1087
- /**
1088
- * Best-effort working-context paths for a dispatched worker: `writeRoots`
1089
- * when the caller set them (the precise signal, for internal/programmatic
1090
- * callers), plus path-like tokens recalled from the task and briefing text.
1091
- * Feeds the same `selectActiveRules` a session uses, so a worker whose task
1092
- * touches a ruled path reads that rule instead of never hearing about it.
1093
- */
1094
- function workerWorkingContextPaths(req: DispatchRequest): string[] {
1095
- const out = new Set<string>();
1096
- for (const root of req.writeRoots ?? []) out.add(root);
1097
- const text = `${req.task}\n${req.briefing ?? ""}`;
1098
- for (const match of text.matchAll(PATH_TOKEN_RE)) {
1099
- const token = match[0];
1100
- if (token.startsWith("http://") || token.startsWith("https://")) continue;
1101
- out.add(token);
1102
- }
1103
- return [...out];
1104
- }
1105
-
1106
1116
  export function buildDynamicPromptMessages(
1107
1117
  req: DispatchRequest,
1108
1118
  dynamicContext: WorkerDynamicContext = {},
@@ -1126,6 +1136,10 @@ export function buildDynamicPromptMessages(
1126
1136
  const body = `Safety posture: autonomy ${autonomy}. ${workerSafetyOneLiner(autonomy, permission)} Worker permission routing: ${permission}.`;
1127
1137
  messages.push({ id: "dispatch-safety-posture", body, contentHash: sha256(body) });
1128
1138
  }
1139
+ const requirements = renderDispatchIntentRequirements(req.intent);
1140
+ if (requirements !== null) {
1141
+ messages.push({ id: "dispatch-intent-requirements", body: requirements, contentHash: sha256(requirements) });
1142
+ }
1129
1143
  if (req.competeStance !== undefined) {
1130
1144
  const body = competeStanceLiner(req.competeStance);
1131
1145
  messages.push({ id: "dispatch-compete-stance", body, contentHash: sha256(body) });
@@ -1250,6 +1264,7 @@ interface DispatchAdmissionStage {
1250
1264
 
1251
1265
  interface DispatchWorkerSpecInput {
1252
1266
  req: DispatchRequest;
1267
+ pathScope: DispatchPathScope;
1253
1268
  target: ResolvedTarget;
1254
1269
  admission: DispatchAdmissionStage;
1255
1270
  recipe?: AgentRecipe | null;
@@ -1269,6 +1284,7 @@ interface DispatchWorkerSpecInput {
1269
1284
 
1270
1285
  interface DispatchLifecycleStage {
1271
1286
  recipe: AgentRecipe | null;
1287
+ pathScope: DispatchPathScope;
1272
1288
  admission: DispatchAdmissionStage;
1273
1289
  target: ResolvedTarget;
1274
1290
  cwd: string;
@@ -1304,6 +1320,7 @@ interface DispatchLifecycleStage {
1304
1320
 
1305
1321
  interface AcpDelegationLifecycleStage {
1306
1322
  admission: DispatchAdmissionStage;
1323
+ pathScope: DispatchPathScope;
1307
1324
  agentConfig: ReturnType<ConfigContract["get"]>["delegation"]["agents"][number];
1308
1325
  cwd: string;
1309
1326
  systemPrompt: string;
@@ -1488,10 +1505,6 @@ function explicitToolCapability(
1488
1505
  return typeof fromKnowledgeBase === "boolean" ? fromKnowledgeBase : null;
1489
1506
  }
1490
1507
 
1491
- function writeConfinedRequest(req: DispatchRequest): boolean {
1492
- return req.writeRoots !== undefined && req.writeRoots.length > 0;
1493
- }
1494
-
1495
1508
  function effectiveToolNames(
1496
1509
  allowedTools: ReadonlyArray<ToolName>,
1497
1510
  target: ResolvedTarget,
@@ -1717,6 +1730,7 @@ function resolveDispatchAdmissionStage(
1717
1730
  req: DispatchRequest,
1718
1731
  recipe: AgentRecipe,
1719
1732
  safety: SafetyContract,
1733
+ pathScope: DispatchPathScope,
1720
1734
  ): DispatchAdmissionStage {
1721
1735
  const recipeTools = recipe.tools;
1722
1736
  const candidateTools = recipeTools && recipeTools.length > 0 ? (Array.from(recipeTools) as ToolName[]) : [];
@@ -1730,7 +1744,7 @@ function resolveDispatchAdmissionStage(
1730
1744
  }
1731
1745
  const requestedActions = deriveRequestedActions(allowedTools, safety);
1732
1746
  const orchScope = pickOrchestratorScope(safety);
1733
- const workerScope = pickWorkerScope(safety, requestedActions);
1747
+ const workerScope = pickWorkerScope(safety, requestedActions, pathScope);
1734
1748
  const verdict = admit(
1735
1749
  {
1736
1750
  requestedScope: workerScope,
@@ -1755,7 +1769,7 @@ function resolveDelegationAdmissionStage(req: DispatchRequest, safety: SafetyCon
1755
1769
  const allowedTools = applyToolProfile([], req.toolProfile);
1756
1770
  const requestedActions = deriveRequestedActions(allowedTools, safety);
1757
1771
  const orchScope = pickOrchestratorScope(safety);
1758
- const workerScope = pickWorkerScope(safety, requestedActions);
1772
+ const workerScope = pickWorkerScope(safety, requestedActions, resolveDispatchPathScope(req));
1759
1773
  const verdict = admit(
1760
1774
  {
1761
1775
  requestedScope: workerScope,
@@ -1833,9 +1847,17 @@ function buildDispatchWorkerSpec(input: DispatchWorkerSpecInput, config?: Config
1833
1847
  // The worker repairs against exactly the contract the orchestrator will seal.
1834
1848
  // A gate role that overrides the recipe contract gets no worker-side repair,
1835
1849
  // for the same reason it gets no recipe validation.
1836
- if (appliesRecipeResultContract(input.req.gate?.role) && input.recipe?.resultContract) {
1837
- spec.resultContract = input.req.resultContractOverride ?? input.recipe.resultContract;
1838
- }
1850
+ //
1851
+ // The request's own override is the contract whether or not the seated
1852
+ // recipe declares one, because that is the resolution the seal already uses.
1853
+ // Requiring a recipe contract here meant a caller-supplied override against a
1854
+ // recipe with none was sealed but never sent, so the worker spent no repair
1855
+ // round on a contract it was never told about and the run failed on a shape
1856
+ // nothing had asked it for.
1857
+ const workerResultContract = appliesRecipeResultContract(input.req.gate?.role)
1858
+ ? (input.req.resultContractOverride ?? input.recipe?.resultContract)
1859
+ : undefined;
1860
+ if (workerResultContract) spec.resultContract = workerResultContract;
1839
1861
  const product = input.req.product ?? input.recipe?.product;
1840
1862
  if (product) spec.product = product;
1841
1863
  // The orchestrator's tool decision is the one the run was admitted under and
@@ -1886,14 +1908,9 @@ function buildDispatchWorkerSpec(input: DispatchWorkerSpecInput, config?: Config
1886
1908
  if (escalation) spec.escalation = { timeoutMs: escalation.timeoutMs, fallback: escalation.fallback };
1887
1909
  }
1888
1910
  assertRuntimeCanHonorWorkerPermissionMode(input.target.runtime, spec.onPermission);
1889
- // Carry write-root confinement to the worker safety seam. Refuse it up front
1890
- // on runtimes that cannot mediate per-tool calls (subprocess) so it is never
1891
- // silently ignored. Roots are resolved against the job cwd so a relative root
1892
- // reaches the worker absolute, matching the validation contract.
1893
- if (input.req.writeRoots !== undefined && input.req.writeRoots.length > 0) {
1894
- const jobCwd = input.req.cwd !== undefined && input.req.cwd.length > 0 ? input.req.cwd : process.cwd();
1895
- spec.writeRoots = input.req.writeRoots.map((root) => resolvePath(jobCwd, root));
1896
- }
1911
+ // Carry canonical write boundaries to the worker safety seam. Exact files
1912
+ // remain exact and trailing-slash entries remain subtrees after resolution.
1913
+ if (input.pathScope.writeBoundaries.length > 0) spec.writeRoots = [...input.pathScope.writeBoundaries];
1897
1914
  assertWriteRootsEnforceable(input.target.runtime, spec.writeRoots);
1898
1915
  // Carry the tool profile so external CLI runtimes that cannot mediate
1899
1916
  // per-tool calls can refuse a narrowing profile they would otherwise ignore.
@@ -2388,6 +2405,26 @@ export function createDispatchBundle(
2388
2405
  const active = new Map<string, ActiveRun>();
2389
2406
  const targetCooldowns = new Map<string, { until: number; reason: string }>();
2390
2407
  const ownedReservations = new Set<string>();
2408
+ /**
2409
+ * Paths each finalized run's own tool calls aimed a successful mutation at,
2410
+ * newest last. `null` records a run whose write record is unusable, which is
2411
+ * a different answer from an empty list and has to survive as one.
2412
+ *
2413
+ * Held in memory rather than sealed into the receipt: the only reader is the
2414
+ * write boundary closing a window seconds later in this process, and a
2415
+ * per-run path list is run-shaped evidence the receipt schema does not carry.
2416
+ * Bounded because a long session finalizes many runs and none of this is
2417
+ * needed once its window has closed.
2418
+ */
2419
+ const runWriteTargets = new Map<string, ReadonlyArray<string> | null>();
2420
+ const recordRunWriteTargets = (runId: string, paths: ReadonlyArray<string> | null): void => {
2421
+ runWriteTargets.set(runId, paths);
2422
+ while (runWriteTargets.size > RUN_WRITE_TARGET_HISTORY) {
2423
+ const oldest = runWriteTargets.keys().next();
2424
+ if (oldest.done === true) break;
2425
+ runWriteTargets.delete(oldest.value);
2426
+ }
2427
+ };
2391
2428
  const capacityAdmission = createCapacityAdmissionController({
2392
2429
  limits: () => {
2393
2430
  const settings = getEffectiveSettings();
@@ -3169,6 +3206,27 @@ export function createDispatchBundle(
3169
3206
  targetCooldowns.set(key, { until: now() + cooldownMs, reason: failureClass });
3170
3207
  }
3171
3208
 
3209
+ function publishDispatchPathScope(req: DispatchRequest, pathScope: DispatchPathScope): void {
3210
+ const replacementDiagnostic = declaredScopeReplacementDiagnostic(pathScope);
3211
+ if (replacementDiagnostic !== null) {
3212
+ reportDispatchDiagnostic("typed scope replacement", new Error(replacementDiagnostic));
3213
+ }
3214
+ // Only the replacement case reaches the transcript. A dispatch that
3215
+ // declared no intent is the ordinary case rather than an anomaly, and one
3216
+ // receipt in ninety-nine carries an intent key today, so noticing it would
3217
+ // warn on almost every dispatch and teach the operator to skip the channel.
3218
+ // Its provenance is not lost: the receipt seals `pathProvenance`, and the
3219
+ // approval artifact renders every inferred entry in full before a
3220
+ // supervised dispatch runs, which is where an operator can still act on it.
3221
+ const notice = declaredScopeReplacementNotice(pathScope);
3222
+ if (notice !== null) {
3223
+ context.bus.emit(BusChannels.DispatchScopeNotice, {
3224
+ ...notice,
3225
+ agentId: req.agentId,
3226
+ });
3227
+ }
3228
+ }
3229
+
3172
3230
  async function resolveLifecycle(req: DispatchRequest, settings: EffectiveSettings): Promise<DispatchLifecycleStage> {
3173
3231
  const recipe = agents.get(req.agentId);
3174
3232
  if (!recipe) {
@@ -3180,7 +3238,7 @@ export function createDispatchBundle(
3180
3238
  `dispatch: agent '${req.agentId}' is a ${spec.audience} agent reserved for Clio internal orchestration`,
3181
3239
  );
3182
3240
  }
3183
- if (hasPersonaOverride(req) && (spec.audience === "shadow" || spec.audience === "internal")) {
3241
+ if (hasCallerPersonaOverride(req) && (spec.audience === "shadow" || spec.audience === "internal")) {
3184
3242
  throw new Error(`dispatch: persona overrides are not allowed for ${spec.audience} agent '${req.agentId}'`);
3185
3243
  }
3186
3244
  // A read-only recipe pointed at a mutating task is knowable here and costs
@@ -3202,7 +3260,9 @@ export function createDispatchBundle(
3202
3260
  specs: agents.listSpecs(),
3203
3261
  });
3204
3262
  if (capabilityMismatch?.verdict === "refuse") throw new Error(capabilityMismatch.detail);
3205
- const admission = resolveDispatchAdmissionStage(req, recipe, safety);
3263
+ const pathScope = resolveDispatchPathScope(req);
3264
+ publishDispatchPathScope(req, pathScope);
3265
+ const admission = resolveDispatchAdmissionStage(req, recipe, safety, pathScope);
3206
3266
  const targets = readWorkerTargets(settings);
3207
3267
  const target = resolveDispatchTarget(
3208
3268
  req,
@@ -3221,7 +3281,7 @@ export function createDispatchBundle(
3221
3281
  const sessionAutonomy = settings?.autonomy ?? "auto-edit";
3222
3282
  const effectiveAutonomy = clampWorkerAutonomy(sessionAutonomy, req.autonomy);
3223
3283
  const effectiveTools = withLedgerToolNarrowing(
3224
- effectiveToolNames(admission.allowedTools, target, writeConfinedRequest(req), deniedToolNames(req)),
3284
+ effectiveToolNames(admission.allowedTools, target, pathScope.writeBoundaries.length > 0, deniedToolNames(req)),
3225
3285
  req,
3226
3286
  );
3227
3287
  assertPostRuntimeToolCompatibility(req.agentId, spec, effectiveTools, target);
@@ -3250,7 +3310,7 @@ export function createDispatchBundle(
3250
3310
  dynamic: false,
3251
3311
  },
3252
3312
  cwd,
3253
- workingContextPaths: workerWorkingContextPaths(req),
3313
+ workingContextPaths: pathScope.workingContextPaths,
3254
3314
  });
3255
3315
  const systemPrompt = compiledWorkerPrompt.systemPrompt;
3256
3316
  const budgetEnvelope = resolveEffectiveWorkerBudget({
@@ -3294,6 +3354,7 @@ export function createDispatchBundle(
3294
3354
  const limitations = runtimeLimitations(runtimeKind, target.runtime.id);
3295
3355
  return {
3296
3356
  recipe,
3357
+ pathScope,
3297
3358
  admission: effectiveAdmission,
3298
3359
  target,
3299
3360
  cwd,
@@ -3331,12 +3392,7 @@ export function createDispatchBundle(
3331
3392
  ): AcpDelegationLifecycleStage {
3332
3393
  const agentId = req.delegationAgentId;
3333
3394
  if (!agentId) throw new Error("dispatch: missing delegationAgentId");
3334
- const boundedGateRolePrompt = isBoundedGateRolePrompt({
3335
- role: req.gate?.role,
3336
- autonomy: req.autonomy,
3337
- systemPrompt: req.systemPrompt,
3338
- });
3339
- if (hasPersonaOverride(req) && !boundedGateRolePrompt) {
3395
+ if (hasCallerPersonaOverride(req)) {
3340
3396
  throw new Error(`dispatch: persona overrides are not allowed for ACP delegation agent '${agentId}'`);
3341
3397
  }
3342
3398
  if (req.agentId && maybeAgents) {
@@ -3365,6 +3421,7 @@ export function createDispatchBundle(
3365
3421
  );
3366
3422
  }
3367
3423
  const cwd = req.cwd ?? process.cwd();
3424
+ const pathScope = resolveDispatchPathScope(req);
3368
3425
  const personaBody = workerPersonaBody(req, null, []);
3369
3426
  // ACP owns an unknown external tool inventory, so it receives the raw
3370
3427
  // bounded persona rather than a native Clio schema-harness claim.
@@ -3391,6 +3448,7 @@ export function createDispatchBundle(
3391
3448
  const personaOverride = personaOverrideFor(req, staticCompositionHash);
3392
3449
  return {
3393
3450
  admission,
3451
+ pathScope,
3394
3452
  agentConfig: configured,
3395
3453
  cwd,
3396
3454
  systemPrompt,
@@ -3426,6 +3484,7 @@ export function createDispatchBundle(
3426
3484
  finalPromise: Promise<RunReceipt>;
3427
3485
  }> {
3428
3486
  const lifecycle = resolveAcpDelegationLifecycle(req, settings);
3487
+ publishDispatchPathScope(req, lifecycle.pathScope);
3429
3488
  timing.decisionCompletedAt = new Date(now()).toISOString();
3430
3489
  const targetId = `delegation:${lifecycle.agentConfig.id}`;
3431
3490
  const runtimeId = "acp";
@@ -3799,6 +3858,7 @@ export function createDispatchBundle(
3799
3858
  requestOrigin: lifecycle.requestOrigin,
3800
3859
  task: req.task,
3801
3860
  ...(req.intent !== undefined ? { intent: structuredClone(req.intent) } : {}),
3861
+ pathScope: structuredClone(lifecycle.pathScope.provenance),
3802
3862
  targetId,
3803
3863
  wireModelId,
3804
3864
  runtimeId,
@@ -4276,6 +4336,7 @@ export function createDispatchBundle(
4276
4336
  const spec = buildDispatchWorkerSpec(
4277
4337
  {
4278
4338
  req,
4339
+ pathScope: lifecycle.pathScope,
4279
4340
  target: lifecycle.target,
4280
4341
  admission: lifecycle.admission,
4281
4342
  recipe: lifecycle.recipe,
@@ -4893,6 +4954,7 @@ export function createDispatchBundle(
4893
4954
  outcomeCode,
4894
4955
  lineage,
4895
4956
  ...(req.intent !== undefined ? { intent: structuredClone(req.intent) } : {}),
4957
+ pathScope: structuredClone(lifecycle.pathScope.provenance),
4896
4958
  identity,
4897
4959
  node: placement?.node ?? LOCAL_RUN_NODE,
4898
4960
  ...receiptAttestationFields(worker.attestation?.() ?? null),
@@ -5276,6 +5338,25 @@ export function createDispatchBundle(
5276
5338
  }
5277
5339
  ledgerRef.update(envelope.id, ledgerPatch);
5278
5340
  const receipt = ledgerRef.recordReceipt(envelope.id, sealRouteDecision(receiptDraft, routeObservation.decision));
5341
+ // Two independent ways a run's write record can fail to be a closed
5342
+ // list, and either one files it as `null` rather than as an empty
5343
+ // one. The difference decides whether the write boundary may clear a
5344
+ // path it did not see this run touch.
5345
+ //
5346
+ // The telemetry axis is whether the events were seen at all: a
5347
+ // subprocess runtime publishes no tool events, and a partial trail
5348
+ // has its holes exactly where an unobserved write would sit.
5349
+ //
5350
+ // The opacity axis is whether the events that were seen say enough.
5351
+ // A run that called bash, verify, dispatch, or steer to a clean exit
5352
+ // could have written through a channel no argument names, so nothing
5353
+ // in the checkout can be cleared on the strength of its absence from
5354
+ // the set. That run's window keeps today's blame-the-whole-diff
5355
+ // behaviour; a run that never touched such a tool keeps the
5356
+ // protection.
5357
+ const writeRecordUsable =
5358
+ receipt.safety?.toolTelemetry?.coverage === "complete" && observedRunEffects.writeRecordComplete;
5359
+ recordRunWriteTargets(envelope.id, writeRecordUsable ? [...observedRunEffects.mutatedPaths] : null);
5279
5360
  await ledgerRef.persist();
5280
5361
  if (worktreeReceipt?.applied === true && req.taskWorktree !== undefined) {
5281
5362
  try {
@@ -5510,10 +5591,11 @@ export function createDispatchBundle(
5510
5591
  `dispatch: agent '${req.agentId}' is a ${agentSpec.audience} agent reserved for Clio internal orchestration`,
5511
5592
  );
5512
5593
  }
5513
- if (hasPersonaOverride(req) && (agentSpec.audience === "shadow" || agentSpec.audience === "internal")) {
5594
+ if (hasCallerPersonaOverride(req) && (agentSpec.audience === "shadow" || agentSpec.audience === "internal")) {
5514
5595
  throw new Error(`dispatch: persona overrides are not allowed for ${agentSpec.audience} agent '${req.agentId}'`);
5515
5596
  }
5516
- const admission = resolveDispatchAdmissionStage(req, recipe, safety);
5597
+ const pathScope = resolveDispatchPathScope(req);
5598
+ const admission = resolveDispatchAdmissionStage(req, recipe, safety, pathScope);
5517
5599
  const targets = readWorkerTargets(settings);
5518
5600
  const target = resolveDispatchTarget(
5519
5601
  req,
@@ -5526,14 +5608,14 @@ export function createDispatchBundle(
5526
5608
  );
5527
5609
  enforceCapabilityGate(target.target.id, target.modelCapabilities, req.requiredCapabilities);
5528
5610
  const effectiveTools = withLedgerToolNarrowing(
5529
- effectiveToolNames(admission.allowedTools, target, writeConfinedRequest(req), deniedToolNames(req)),
5611
+ effectiveToolNames(admission.allowedTools, target, pathScope.writeBoundaries.length > 0, deniedToolNames(req)),
5530
5612
  req,
5531
5613
  );
5532
5614
  assertPostRuntimeToolCompatibility(req.agentId, agentSpec, effectiveTools, target);
5533
5615
  assertRuntimeCanHonorWorkerPermissionMode(target.runtime, settings?.workers.onPermission ?? "deny");
5534
5616
  assertWorkerBudgetEnforceable(target.runtime, agentSpec.budget !== null || req.budget !== undefined);
5535
5617
  assertResponseSchemaEnforceable(target.runtime, target.modelCapabilities, req.responseSchema, effectiveTools.length);
5536
- assertWriteRootsEnforceable(target.runtime, req.writeRoots);
5618
+ assertWriteRootsEnforceable(target.runtime, pathScope.writeBoundaries);
5537
5619
  assertProtectedArtifactsEnforceable(
5538
5620
  target.runtime.id,
5539
5621
  target.runtime.kind !== "subprocess",
@@ -5833,7 +5915,7 @@ export function createDispatchBundle(
5833
5915
  async function sealCouncilSynthesis(input: {
5834
5916
  group: string;
5835
5917
  round: number;
5836
- kind: "none" | "vote";
5918
+ kind: "none" | "vote" | "judge";
5837
5919
  text: string;
5838
5920
  subjects: ReadonlyArray<{ runId: string; digest: string | null }>;
5839
5921
  template: RunReceipt;
@@ -5991,6 +6073,7 @@ export function createDispatchBundle(
5991
6073
  const reconciledLedger = ledger;
5992
6074
  const reconciled = await reconcileOrphanAssignments({
5993
6075
  listRunning: () => listStoredAssignments().filter((record) => record.status === "running"),
6076
+ ownerAlive: assignmentProcessOwnerAlive,
5994
6077
  lookupAttempt: (runId) => {
5995
6078
  const envelope = reconciledLedger?.get(runId) ?? null;
5996
6079
  if (!envelope) return null;
@@ -6000,7 +6083,8 @@ export function createDispatchBundle(
6000
6083
  succeeded: envelope.outcome === "succeeded",
6001
6084
  };
6002
6085
  },
6003
- settle: (assignmentId, terminalRunId, status) => settleStoredAssignment(assignmentId, terminalRunId, status),
6086
+ settle: (assignmentId, terminalRunId, status, owner) =>
6087
+ settleStoredAssignment(assignmentId, terminalRunId, status, owner),
6004
6088
  });
6005
6089
  if ((reconciled.recovered > 0 || reconciled.abandoned > 0) && process.env.CLIO_CODER_INTERACTIVE !== "1") {
6006
6090
  process.stderr.write(
@@ -6204,6 +6288,11 @@ export function createDispatchBundle(
6204
6288
  if (!ledger) return null;
6205
6289
  return ledger.get(runId);
6206
6290
  },
6291
+ observedRunWrites(runId) {
6292
+ // A run this process never finalized, or finalized long enough ago to
6293
+ // have been evicted, is unknown rather than silent, so both answer null.
6294
+ return runWriteTargets.get(runId) ?? null;
6295
+ },
6207
6296
  assignments: {
6208
6297
  get: (id) => assignments.get(id),
6209
6298
  getStored: (id) => getStoredAssignment(id),
@@ -21,6 +21,7 @@ import type { FleetCommandRegistry } from "../agents/fleet-commands.js";
21
21
  import type { FleetContract } from "../agents/fleet-contract.js";
22
22
  import { resultContractAuthorship } from "../agents/result-contract.js";
23
23
  import type { AgentSpec } from "../agents/spec.js";
24
+ import { claimAssignmentVerdict, recordAssignmentAttempt, settleStoredAssignment } from "./assignment-store.js";
24
25
  import { runCodeStep } from "./code-step.js";
25
26
  import { codeStepDir, writeCodeStepRecord } from "./code-step-store.js";
26
27
  import type { DispatchContract, DispatchRequest } from "./contract.js";
@@ -247,13 +248,47 @@ export async function executeFleetRun(input: ExecuteFleetRunInput): Promise<Flee
247
248
  dynamicPlans: [],
248
249
  };
249
250
  await writeFleetRun(fleetRunRecord);
251
+ // Every step of this run dispatches under `fleetRootId` as its lineage root,
252
+ // so every agent step also settles the assignment row of that id. One step is
253
+ // not the run, and letting the last one to finish write the row reported a
254
+ // green step of a run that aborted at step 2 of 7 as `succeeded`. The run
255
+ // claims the verdict here and answers for itself at the end.
256
+ await claimAssignmentVerdict(fleetRootId, "fleet");
257
+ /** File one step's terminal run id, whichever kind produced it. */
258
+ const fileStepAttempt = (terminalRunId: string): Promise<unknown> =>
259
+ recordAssignmentAttempt(fleetRootId, terminalRunId);
260
+ /**
261
+ * The run's own word on its ledger row. A settle that cannot be written
262
+ * leaves the row `running`, which startup reconciliation resolves to failed,
263
+ * so a lost write never becomes a false success.
264
+ */
265
+ const settleFleetAssignment = (cleanRun: boolean): Promise<unknown> =>
266
+ settleStoredAssignment(
267
+ fleetRootId,
268
+ fleetRunRecord.steps.at(-1)?.result.terminalRunId ?? fleetRootId,
269
+ cleanRun ? "succeeded" : "failed",
270
+ "fleet",
271
+ ).catch(() => undefined);
250
272
  const boundaryEnforcer = createWriteBoundaryEnforcer({
251
273
  root: workspaceRoot,
252
274
  rootId: fleetRootId,
253
275
  boundaryFor: (stepId) => livePlan.steps.find((step) => step.id === stepId)?.writes,
276
+ recordedWritesFor: (stepId) => {
277
+ const step = livePlan.steps.find((entry) => entry.id === stepId);
278
+ // A code step runs a registered command in a subprocess. Nothing
279
+ // enumerates what that command wrote, so the step contributes no record
280
+ // and its window falls back to blaming the whole diff.
281
+ if (step === undefined || step.kind !== "agent") return null;
282
+ const receipt = receiptsByStep.get(stepId);
283
+ if (receipt === undefined) return null;
284
+ return dispatch.observedRunWrites?.(receipt.runId) ?? null;
285
+ },
254
286
  onVerdict(verdict, path) {
255
- if (verdict.reason === null) return;
256
- notice(`write boundary ${verdict.window}: ${verdict.detail ?? verdict.reason} record=${path}`, "write-boundary");
287
+ // Concurrent changes are reported too. The window did not fail for them
288
+ // and nothing was touched, and an operator whose file changed under a
289
+ // running fleet should hear it from the fleet.
290
+ if (verdict.detail === null) return;
291
+ notice(`write boundary ${verdict.window}: ${verdict.detail} record=${path}`, "write-boundary");
257
292
  },
258
293
  });
259
294
  const receipts: RunReceipt[] = [];
@@ -285,6 +320,7 @@ export async function executeFleetRun(input: ExecuteFleetRunInput): Promise<Flee
285
320
  costUsd: receiptsByStep.get(stepId)?.costUsd ?? 0,
286
321
  result: replayedResult,
287
322
  });
323
+ await fileStepAttempt(replayedResult.terminalRunId);
288
324
  }
289
325
  const planStep = (stepId: string) => livePlan.steps.find((entry) => entry.id === stepId);
290
326
 
@@ -322,6 +358,9 @@ export async function executeFleetRun(input: ExecuteFleetRunInput): Promise<Flee
322
358
  try {
323
359
  result = await executePlan(livePlan, {
324
360
  preflight(step) {
361
+ // The fleet write boundary is deliberately enforced after the step. The enforcer in
362
+ // write-boundary-enforcer.ts snapshots and verifies the step window. Pre-emptive
363
+ // confinement for declared commands would require a command sandbox, which does not exist.
325
364
  const request: DispatchRequest = {
326
365
  agentId: step.agentId,
327
366
  executionRole: step.executionRole,
@@ -372,6 +411,9 @@ export async function executeFleetRun(input: ExecuteFleetRunInput): Promise<Flee
372
411
  if (step.planParentId !== undefined && parentReceipt === undefined) {
373
412
  throw new Error(`fleet dynamic step '${step.id}' has no terminal plan-step receipt`);
374
413
  }
414
+ // The fleet write boundary is deliberately enforced after the step. The enforcer in
415
+ // write-boundary-enforcer.ts snapshots and verifies the step window. Pre-emptive
416
+ // confinement for declared commands would require a command sandbox, which does not exist.
375
417
  const request: DispatchRequest = {
376
418
  agentId: step.agentId,
377
419
  executionRole: step.executionRole,
@@ -671,6 +713,11 @@ export async function executeFleetRun(input: ExecuteFleetRunInput): Promise<Flee
671
713
  onStepSettled: async (step, stepResult) => {
672
714
  fleetRunRecord.steps.push({ stepId: step.id, result: stepResult });
673
715
  await writeFleetRun(fleetRunRecord);
716
+ // The scheduler settles both kinds of step here, so this is where a
717
+ // deterministic step enters the assignment: a code step's `code-*` run
718
+ // id reads exactly like an agent receipt id, and its evidence sits under
719
+ // `code-steps/<fleetRootId>/`.
720
+ await fileStepAttempt(stepResult.terminalRunId);
674
721
  },
675
722
  spliceAfter(currentPlan, step, stepResult) {
676
723
  if (stepResult.delegationPlan === undefined) return null;
@@ -712,6 +759,9 @@ export async function executeFleetRun(input: ExecuteFleetRunInput): Promise<Flee
712
759
  } catch (error) {
713
760
  fleetRunRecord.endedAt = new Date().toISOString();
714
761
  await writeFleetRun(fleetRunRecord);
762
+ // A run the scheduler could not carry to the end never reached a verdict,
763
+ // and a fleet that threw is not a fleet that worked.
764
+ await settleFleetAssignment(false);
715
765
  throw error;
716
766
  }
717
767
  fleetRunRecord.endedAt = new Date().toISOString();
@@ -723,6 +773,10 @@ export async function executeFleetRun(input: ExecuteFleetRunInput): Promise<Flee
723
773
  const required = livePlan.steps.filter((step) => step.loop === undefined && !result.unneeded.includes(step.id));
724
774
  const failedLoops = result.loops.filter((loop) => !loop.resolved);
725
775
  const succeededStepCount = required.filter((step) => result.results.get(step.id)?.succeeded === true).length;
776
+ // A step a stop policy left unrun is counted but never succeeded, so a run
777
+ // that halted early can never reach the required count.
778
+ const cleanRun = failedLoops.length === 0 && result.skipped.length === 0 && succeededStepCount === required.length;
779
+ await settleFleetAssignment(cleanRun);
726
780
  return {
727
781
  rootId: fleetRootId,
728
782
  planHash: input.plan.hash,
@@ -732,6 +786,6 @@ export async function executeFleetRun(input: ExecuteFleetRunInput): Promise<Flee
732
786
  requiredStepCount: required.length,
733
787
  succeededStepCount,
734
788
  resolvedLoopCount: result.loops.length - failedLoops.length,
735
- cleanRun: failedLoops.length === 0 && result.skipped.length === 0 && succeededStepCount === required.length,
789
+ cleanRun,
736
790
  };
737
791
  }
@@ -1,5 +1,7 @@
1
1
  /** Bounded role prompts shared by dispatch topology construction and ACP admission. */
2
2
 
3
+ import { COUNCIL_BALLOT_SHAPE, COUNCIL_BALLOT_VERDICT_MAX_BYTES } from "../agents/result-contract.js";
4
+
3
5
  export const REVIEWER_GATE_PROMPT = [
4
6
  "You are a strict, read-only reviewer of one implementation run.",
5
7
  "Inspect the supplied result and repository state with read-only tools.",
@@ -26,6 +28,42 @@ export const COUNCIL_JUDGE_PROMPT = [
26
28
  'End with a JSON object only: {"verdict":"...","text":"the supported synthesis"}.',
27
29
  ].join("\n");
28
30
 
31
+ /**
32
+ * What a council member is told when the synthesis is `vote`.
33
+ *
34
+ * The tally is a strict majority over the members' `verdict` fields, and until
35
+ * this directive existed nothing ever asked a member for one (#230). It rides
36
+ * on the member's task rather than on a system prompt, so the seated recipe
37
+ * keeps its own persona: a council's point is running *that* agent across
38
+ * several targets, and replacing its body with a ballot prompt would run a
39
+ * different agent under the same name. The matching `council-ballot` contract
40
+ * travels with it as a `resultContractOverride`, so the postcondition the run
41
+ * seals is the one this text asks for, and a member that answers in its
42
+ * recipe's shape instead gets the ordinary bounded repair rounds rather than a
43
+ * silent absence from the tally.
44
+ *
45
+ * The precedence line is not decoration. A recipe body states its own result
46
+ * shape (`researcher` asks for findings), so without it the member holds two
47
+ * contradictory instructions and small local models resolve the conflict by
48
+ * burying a verdict inside the recipe's shape, which is exactly what the 0.3.7
49
+ * release test observed and read as correct behavior.
50
+ */
51
+ export const COUNCIL_VOTE_MEMBER_DIRECTIVE = [
52
+ "This council round is a vote. Do the work the task asks for, then cast one ballot on it.",
53
+ `The verdict is a short comparable token the other members can match exactly, such as "yes", "no", "keep", or the name of an option you were given. It must be a single line of at most ${COUNCIL_BALLOT_VERDICT_MAX_BYTES} bytes and is lower-cased before the tally. Anything longer is reasoning: put that in text, along with the evidence behind it.`,
54
+ "Your recipe's own result shape does not apply to this run. The council reads the ballot and nothing else.",
55
+ `End with a JSON object only: ${COUNCIL_BALLOT_SHAPE}`,
56
+ ].join("\n");
57
+
58
+ /**
59
+ * The task a council member is given under `synthesis: "vote"`. Plan admission
60
+ * composes it here once, then the runner reuses the task pinned by that plan so
61
+ * the approval artifact shows the exact ask the member receives.
62
+ */
63
+ export function renderCouncilVoteMemberTask(originalTask: string): string {
64
+ return [originalTask, COUNCIL_VOTE_MEMBER_DIRECTIVE].join("\n\n");
65
+ }
66
+
29
67
  /**
30
68
  * Posture a compete candidate runs under. Candidates otherwise differ only by
31
69
  * worktree, and identical agents produce correlated output, so each candidate