@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
@@ -8,6 +8,11 @@
8
8
  * a path that changed cannot be pinned on one of them, and the plan compiler
9
9
  * has already refused the case where more than one of them was allowed to
10
10
  * write. Every step in a violating window fails, and the verdict says why.
11
+ *
12
+ * The window also carries what its runs recorded writing, which is what keeps
13
+ * the checkout diff from being read as authorship. A change nobody in the
14
+ * window recorded belongs to whoever else has the checkout open, and the
15
+ * verdict reports it without touching it.
11
16
  */
12
17
 
13
18
  import { existsSync } from "node:fs";
@@ -16,9 +21,11 @@ import type { ExecutionPlan } from "./execution-plan.js";
16
21
  import type { ExecutionWriteBoundaryOutcome } from "./execution-scheduler.js";
17
22
  import {
18
23
  assertWriteBoundaryInsideRoot,
24
+ assertWriteBoundaryVisibleToGit,
19
25
  captureWorkspaceSnapshot,
20
26
  enforceWriteBoundary,
21
27
  type WorkspaceSnapshot,
28
+ type WriteBoundaryAttribution,
22
29
  type WriteBoundaryVerdict,
23
30
  writeWriteBoundaryVerdict,
24
31
  } from "./write-boundary.js";
@@ -30,6 +37,15 @@ export interface WriteBoundaryEnforcerInput {
30
37
  rootId: string;
31
38
  /** Declared allowlist for a step, or undefined when it declares no boundary. */
32
39
  boundaryFor(stepId: string): ReadonlyArray<string> | undefined;
40
+ /**
41
+ * What the step's own run recorded writing, or null when the step has no
42
+ * closed write record: a code step running a registered command, a runtime
43
+ * that publishes no tool telemetry, a run whose telemetry had holes, or a run
44
+ * that shelled out or spawned a worker and could have written a path no
45
+ * argument names. Absent entirely means the caller offers no record at all,
46
+ * and every change outside the declaration is blamed on the window.
47
+ */
48
+ recordedWritesFor?(stepId: string): ReadonlyArray<string> | null;
33
49
  /** Called once per closed window with the sealed verdict and where it landed. */
34
50
  onVerdict?(verdict: WriteBoundaryVerdict, path: string): void;
35
51
  }
@@ -54,6 +70,9 @@ export function preflightWriteBoundaries(plan: ExecutionPlan, root: string): voi
54
70
  );
55
71
  }
56
72
  for (const step of declared) assertWriteBoundaryInsideRoot(root, step.writes ?? []);
73
+ // One question over the whole plan: an entry is refused for being invisible
74
+ // to git whichever step declared it, and the diagnostic names the path.
75
+ assertWriteBoundaryVisibleToGit(root, [...new Set(declared.flatMap((step) => [...(step.writes ?? [])]))]);
57
76
  }
58
77
 
59
78
  export function createWriteBoundaryEnforcer(input: WriteBoundaryEnforcerInput): WriteBoundaryEnforcer {
@@ -63,10 +82,34 @@ export function createWriteBoundaryEnforcer(input: WriteBoundaryEnforcerInput):
63
82
  for (const stepId of stepIds) for (const entry of input.boundaryFor(stepId) ?? []) allow.add(entry);
64
83
  return [...allow].sort();
65
84
  };
85
+ /**
86
+ * The window's write witness. One step without a record makes the whole
87
+ * window's record incomplete: the steps share a checkout, so a change no
88
+ * other step wrote could still be the unobserved one's.
89
+ */
90
+ const attributionFor = (stepIds: ReadonlyArray<string>): WriteBoundaryAttribution | undefined => {
91
+ const recordedWritesFor = input.recordedWritesFor;
92
+ if (recordedWritesFor === undefined) return undefined;
93
+ const recorded = new Set<string>();
94
+ let complete = true;
95
+ for (const stepId of stepIds) {
96
+ const observed = recordedWritesFor(stepId);
97
+ if (observed === null) {
98
+ complete = false;
99
+ continue;
100
+ }
101
+ for (const target of observed) recorded.add(target);
102
+ }
103
+ return { recorded: [...recorded].sort(), complete };
104
+ };
66
105
  return {
67
106
  begin(window, stepIds) {
68
107
  const allow = allowFor(stepIds);
69
108
  assertWriteBoundaryInsideRoot(input.root, allow);
109
+ // Also checked whole-plan at preflight. Repeated here because a
110
+ // delegation plan splices steps into a running fleet, and their
111
+ // declarations reach this function without ever passing preflight.
112
+ assertWriteBoundaryVisibleToGit(input.root, allow);
70
113
  open.set(window, { snapshot: captureWorkspaceSnapshot(input.root), allow });
71
114
  },
72
115
  async verify(window, stepIds) {
@@ -75,11 +118,13 @@ export function createWriteBoundaryEnforcer(input: WriteBoundaryEnforcerInput):
75
118
  throw new Error(`write boundary: window '${window}' was verified without a snapshot`);
76
119
  }
77
120
  open.delete(window);
121
+ const attribution = attributionFor(stepIds);
78
122
  const verdict = enforceWriteBoundary({
79
123
  snapshot: pending.snapshot,
80
124
  window,
81
125
  stepIds,
82
126
  allow: pending.allow,
127
+ ...(attribution === undefined ? {} : { attribution }),
83
128
  });
84
129
  const path = writeWriteBoundaryVerdict(input.rootId, verdict);
85
130
  input.onVerdict?.(verdict, path);
@@ -21,18 +21,27 @@
21
21
  * Everything is pinned to the baseline commit recorded at snapshot time, so a
22
22
  * commit step that moves HEAD without touching the working tree reads as "no
23
23
  * change" instead of as a wholesale rewrite.
24
+ *
25
+ * A window's diff answers "what changed", never "who changed it". An operator
26
+ * editing the checkout while a fleet runs produces changes inside the window
27
+ * that no step made, and rolling those back to the baseline destroys work
28
+ * nobody asked Clio to touch. So a change is blamed on the window only when it
29
+ * intersects what the window's own runs recorded writing; everything else is
30
+ * reported as an unattributed concurrent change and left exactly as it is.
24
31
  */
25
32
 
26
33
  import { execFileSync } from "node:child_process";
27
34
  import { createHash } from "node:crypto";
28
35
  import { existsSync, lstatSync, mkdirSync, readlinkSync, realpathSync, rmSync, writeFileSync } from "node:fs";
29
- import { dirname, join, relative, resolve, sep } from "node:path";
36
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
37
+ import { asDirectoryPathBoundary, pathBoundaryEntryCovers } from "../../core/path-boundary.js";
30
38
  import { clioStateDir } from "../../core/xdg.js";
31
39
  import { atomicWrite } from "../../engine/session.js";
32
40
  import { isGitRepository } from "../../tools/compete-worktrees.js";
33
41
  import {
34
42
  describeWriteBoundary,
35
43
  normalizeWriteBoundary,
44
+ normalizeWriteBoundaryEntry,
36
45
  type WriteBoundary,
37
46
  writeBoundaryCovers,
38
47
  } from "../agents/write-boundary.js";
@@ -71,10 +80,15 @@ function gitBytes(root: string, args: ReadonlyArray<string>): Buffer {
71
80
  *
72
81
  * Enforcement sees exactly what git sees, so an ignored path is outside it: a
73
82
  * repository decides what counts as its own content, and this is not the place
74
- * to overrule that. The one thing subtracted is Clio's own state directory when
75
- * an operator has placed it inside the workspace. Receipts, code-step logs, and
76
- * boundary verdicts are the orchestrator writing its journal while the step
77
- * runs, and blaming the step for them would make every window a violation.
83
+ * to overrule that. The consequence is that a boundary declared over an ignored
84
+ * path could never be verified, which is why `assertWriteBoundaryVisibleToGit`
85
+ * refuses that declaration before anything runs rather than letting this
86
+ * function certify an unobserved window as clean.
87
+ *
88
+ * The one thing subtracted is Clio's own state directory when an operator has
89
+ * placed it inside the workspace. Receipts, code-step logs, and boundary
90
+ * verdicts are the orchestrator writing its journal while the step runs, and
91
+ * blaming the step for them would make every window a violation.
78
92
  */
79
93
  function dirtyPaths(root: string): string[] {
80
94
  const raw = git(root, ["status", "--porcelain=v1", "-z", "-uall", "--no-renames"]);
@@ -222,14 +236,55 @@ export interface WriteBoundaryUnrecoverable {
222
236
  }
223
237
 
224
238
  /**
225
- * `clean` means nothing outside the allowlist changed. `rolled-back` means
226
- * something did and the repository now looks as it did before. Anything else is
227
- * `rollback-incomplete`: the working tree is left exactly as the step made it
228
- * and handed to the operator with the list, because a rollback that guesses at
229
- * content it never recorded destroys work.
239
+ * `clean` means no change outside the allowlist is attributable to this
240
+ * window's steps. `rolled-back` means one was and the repository now looks as
241
+ * it did before. Anything else is `rollback-incomplete`: the working tree is
242
+ * left exactly as the step made it and handed to the operator with the list,
243
+ * because a rollback that guesses at content it never recorded destroys work.
244
+ *
245
+ * A `clean` verdict can still carry `unattributed` paths. Those changed inside
246
+ * the window and no run in it recorded writing them, so they are the
247
+ * operator's, another process's, or a build artifact's, and nothing here may
248
+ * touch them.
230
249
  */
231
250
  export type WriteBoundaryStatus = "clean" | "rolled-back" | "rollback-incomplete";
232
251
 
252
+ /**
253
+ * What the window's own runs recorded aiming a successful mutation at.
254
+ *
255
+ * This is the write-side witness the diff cannot be: the checkout says a path
256
+ * changed, and only the run's own tool-call record says whether the run is what
257
+ * changed it. The record comes from the same fold that grounds a sealed
258
+ * mutation report, so the boundary and the result contract judge one set of
259
+ * facts.
260
+ *
261
+ * A run can write through a channel no tool event enumerates, and then its path
262
+ * set is a lower bound rather than the whole list. That is not silently
263
+ * tolerated: the recorder knows which tools can do it (bash, verify, dispatch,
264
+ * steer, and any tool this process has no schema for) and reports the record
265
+ * open, which arrives here as `complete: false`. So the set is only ever read
266
+ * as a closed list for a run that could not have written outside it, and
267
+ * enforcement over a run that could stays exactly as strong as it was before
268
+ * attribution existed.
269
+ */
270
+ export interface WriteBoundaryAttribution {
271
+ /**
272
+ * Paths the window's runs aimed a successful mutation at. Absolute, or
273
+ * relative to the workspace root; anything resolving outside the workspace is
274
+ * dropped, because git could not have reported it as a change either.
275
+ */
276
+ recorded: ReadonlyArray<string>;
277
+ /**
278
+ * False when at least one step in the window has no closed write record: a
279
+ * code step running a registered command, a runtime that publishes no tool
280
+ * telemetry, a run whose telemetry came back with holes, or a run that
281
+ * called a tool able to write a path its arguments do not name. An open
282
+ * record cannot clear anybody, so the window falls back to blaming every
283
+ * change outside the allowlist, and the verdict says it did.
284
+ */
285
+ complete: boolean;
286
+ }
287
+
233
288
  export interface WriteBoundaryVerdict {
234
289
  version: 1;
235
290
  /** Which scheduling window this covers, e.g. `wave-2` or `revalidate-suite.check.1`. */
@@ -243,12 +298,25 @@ export interface WriteBoundaryVerdict {
243
298
  checkedAt: string;
244
299
  changedPaths: ReadonlyArray<string>;
245
300
  violations: ReadonlyArray<string>;
301
+ /**
302
+ * Paths outside the allowlist that changed inside the window and that no run
303
+ * in it recorded writing. Recorded so the operator can see them, never rolled
304
+ * back: the window did not make them, so the baseline commit is not their
305
+ * prior content.
306
+ */
307
+ unattributed: ReadonlyArray<string>;
308
+ /** Whether every step in the window offered an enumerable write record. */
309
+ attributionComplete: boolean;
246
310
  rolledBack: ReadonlyArray<WriteBoundaryRollback>;
247
311
  unrecoverable: ReadonlyArray<WriteBoundaryUnrecoverable>;
248
312
  status: WriteBoundaryStatus;
249
313
  /** Typed failure reason, or null when the window stayed inside its boundary. */
250
314
  reason: typeof WRITE_BOUNDARY_VIOLATION_REASON | null;
251
- /** Operator-facing message naming the offending paths and the declaration. */
315
+ /**
316
+ * Operator-facing message naming the offending paths and the declaration, or
317
+ * naming the concurrent changes the window saw but did not cause. Null only
318
+ * when neither happened.
319
+ */
252
320
  detail: string | null;
253
321
  digest: string;
254
322
  }
@@ -258,6 +326,12 @@ export interface EnforceWriteBoundaryInput {
258
326
  window: string;
259
327
  stepIds: ReadonlyArray<string>;
260
328
  allow: WriteBoundary;
329
+ /**
330
+ * What the window's runs recorded writing. Absent means no caller offered a
331
+ * record, which reads the same as an incomplete one: every change outside the
332
+ * allowlist is blamed on the window.
333
+ */
334
+ attribution?: WriteBoundaryAttribution;
261
335
  }
262
336
 
263
337
  function canonicalVerdict(verdict: Omit<WriteBoundaryVerdict, "digest">): string {
@@ -317,6 +391,22 @@ function rollbackPath(
317
391
  return { rolledBack: { path: change.path, action: "restored", restoredFrom: `${snapshot.head}:${change.path}` } };
318
392
  }
319
393
 
394
+ /**
395
+ * Whether one changed path falls inside what a run recorded writing.
396
+ *
397
+ * A recorded target is usually the exact file a write or edit named, but a
398
+ * command's delete target can be a directory, so a change under a recorded
399
+ * prefix counts as the same write. Matching is on segment boundaries: `work`
400
+ * does not cover `workspace/a.txt`.
401
+ */
402
+ function withinRecordedWrite(recorded: ReadonlySet<string>, path: string): boolean {
403
+ if (recorded.has(path)) return true;
404
+ for (const target of recorded) {
405
+ if (path.startsWith(`${target}/`)) return true;
406
+ }
407
+ return false;
408
+ }
409
+
320
410
  /**
321
411
  * Compare, roll back, and return the verdict. Never throws for a violation: a
322
412
  * violation is evidence the caller records and acts on, not an exception that
@@ -325,7 +415,21 @@ function rollbackPath(
325
415
  export function enforceWriteBoundary(input: EnforceWriteBoundaryInput): WriteBoundaryVerdict {
326
416
  const allow = normalizeWriteBoundary(input.allow);
327
417
  const changes = diffWorkspace(input.snapshot);
328
- const violations = changes.filter((change) => !writeBoundaryCovers(allow, change.path));
418
+ const outside = changes.filter((change) => !writeBoundaryCovers(allow, change.path));
419
+ const attributionComplete = input.attribution?.complete === true;
420
+ const recorded = new Set<string>();
421
+ for (const target of input.attribution?.recorded ?? []) {
422
+ const relativeTarget = repoRelative(input.snapshot.root, target);
423
+ if (relativeTarget !== null) recorded.add(relativeTarget);
424
+ }
425
+ // Without a complete record nothing can be cleared, so the window keeps the
426
+ // whole diff. With one, only what the runs actually wrote is theirs.
427
+ const violations = attributionComplete
428
+ ? outside.filter((change) => withinRecordedWrite(recorded, change.path))
429
+ : outside;
430
+ const unattributed = attributionComplete
431
+ ? outside.filter((change) => !withinRecordedWrite(recorded, change.path))
432
+ : [];
329
433
  const rolledBack: WriteBoundaryRollback[] = [];
330
434
  const unrecoverable: WriteBoundaryUnrecoverable[] = [];
331
435
  for (const change of violations) {
@@ -335,15 +439,15 @@ export function enforceWriteBoundary(input: EnforceWriteBoundaryInput): WriteBou
335
439
  }
336
440
  const status: WriteBoundaryStatus =
337
441
  violations.length === 0 ? "clean" : unrecoverable.length === 0 ? "rolled-back" : "rollback-incomplete";
338
- const attribution =
442
+ const who =
339
443
  input.stepIds.length === 1
340
444
  ? `step '${input.stepIds[0]}'`
341
445
  : `steps ${input.stepIds.map((id) => `'${id}'`).join(", ")} ran concurrently in one checkout, so the write cannot be attributed to one of them`;
342
- const detail =
446
+ const violationDetail =
343
447
  violations.length === 0
344
- ? null
448
+ ? []
345
449
  : [
346
- `${attribution} wrote outside its declared boundary.`,
450
+ `${who} wrote outside its declared boundary.`,
347
451
  `Unauthorized paths: ${violations.map((change) => change.path).join(", ")}.`,
348
452
  `Declared writes: ${describeWriteBoundary(allow)}.`,
349
453
  status === "rolled-back"
@@ -351,8 +455,21 @@ export function enforceWriteBoundary(input: EnforceWriteBoundaryInput): WriteBou
351
455
  : `Rollback is incomplete; the working tree is left as the step made it. Unrestorable: ${unrecoverable
352
456
  .map((entry) => `${entry.path} (${entry.reason})`)
353
457
  .join("; ")}.`,
458
+ attributionComplete
459
+ ? "Each was blamed on this window because a run in it recorded writing that path."
460
+ : "This window had no complete write record, so every change outside the declaration was blamed on it.",
354
461
  "If the change was legitimate, widen the step's `writes:` declaration to cover it.",
355
- ].join(" ");
462
+ ];
463
+ const concurrentDetail =
464
+ unattributed.length === 0
465
+ ? []
466
+ : [
467
+ `Concurrent changes were seen in this window that no run in it recorded writing: ${unattributed
468
+ .map((change) => change.path)
469
+ .join(", ")}.`,
470
+ "They were left untouched, because a path this window did not write is not this window's to restore.",
471
+ ];
472
+ const detail = [...violationDetail, ...concurrentDetail];
356
473
  const body: Omit<WriteBoundaryVerdict, "digest"> = {
357
474
  version: 1,
358
475
  window: input.window,
@@ -363,11 +480,13 @@ export function enforceWriteBoundary(input: EnforceWriteBoundaryInput): WriteBou
363
480
  checkedAt: new Date().toISOString(),
364
481
  changedPaths: changes.map((change) => change.path),
365
482
  violations: violations.map((change) => change.path),
483
+ unattributed: unattributed.map((change) => change.path),
484
+ attributionComplete,
366
485
  rolledBack,
367
486
  unrecoverable,
368
487
  status,
369
488
  reason: violations.length === 0 ? null : WRITE_BOUNDARY_VIOLATION_REASON,
370
- detail,
489
+ detail: detail.length === 0 ? null : detail.join(" "),
371
490
  };
372
491
  return { ...body, digest: createHash("sha256").update(canonicalVerdict(body), "utf8").digest("hex") };
373
492
  }
@@ -387,16 +506,76 @@ export function assertWriteBoundaryInsideRoot(root: string, allow: WriteBoundary
387
506
  // path a step is about to create.
388
507
  while (probe !== realRoot && !existsSync(probe)) probe = dirname(probe);
389
508
  const resolved = realpathSync(probe);
390
- if (resolved !== realRoot && !resolved.startsWith(realRoot + sep)) {
509
+ // The root is inside itself. The prefix walk above stops at the root when
510
+ // a declared directory does not exist yet, and `pathBoundaryEntryCovers`
511
+ // answers a different question: it asks whether a changed path falls under
512
+ // a declaration, where the directory entry itself is not a change git ever
513
+ // reports. Asking it about the root would refuse every boundary naming a
514
+ // path the step is about to create.
515
+ if (resolved !== realRoot && !pathBoundaryEntryCovers(asDirectoryPathBoundary(realRoot), resolved)) {
391
516
  throw new Error(`write boundary: entry '${entry}' resolves to ${resolved}, outside the workspace at ${realRoot}`);
392
517
  }
393
518
  }
394
519
  }
395
520
 
396
- /** Repo-relative form of an absolute path, or null when it is outside. */
521
+ /**
522
+ * Refuse a declaration git will never report on.
523
+ *
524
+ * `git status` does not list an ignored path, so a boundary entry the
525
+ * repository ignores names a region enforcement is blind to: every window over
526
+ * it is certified clean without a single observation, which is a stronger claim
527
+ * than "nothing was checked" and reads as a passing one. That is the same
528
+ * defect `assertWriteBoundaryInsideRoot` refuses for an entry that leaves the
529
+ * workspace through a symlink, so it is refused in the same place and at the
530
+ * same time, before anything runs.
531
+ *
532
+ * A tracked path is reportable whatever the ignore rules say, and git's own
533
+ * index-aware check is what decides that, so this never refuses a path the
534
+ * repository is actually following.
535
+ */
536
+ export function assertWriteBoundaryVisibleToGit(root: string, allow: WriteBoundary): void {
537
+ // Entry by entry rather than through `normalizeWriteBoundary`, whose 32-entry
538
+ // cap is a per-step declaration limit. A caller may legitimately ask this
539
+ // question about every entry a whole plan declared.
540
+ const entries = [...new Set(allow.map(normalizeWriteBoundaryEntry))].sort();
541
+ if (entries.length === 0) return;
542
+ const absolute = resolve(root);
543
+ let raw: string;
544
+ try {
545
+ raw = git(absolute, ["check-ignore", "-v", "-z", "--stdin"], `${entries.join("\0")}\0`);
546
+ } catch (error) {
547
+ // `check-ignore` exits 1 when nothing matched, which is the common answer
548
+ // and not a failure. Any other exit is a real inability to decide, and
549
+ // enforcement fails closed rather than assuming visibility.
550
+ const status = (error as { status?: number }).status;
551
+ if (status === 1) return;
552
+ throw new Error(
553
+ `write boundary: could not determine whether ${describeWriteBoundary(entries)} is ignored by git in ${absolute}: ${(error as Error).message}`,
554
+ );
555
+ }
556
+ const fields = raw.split("\0");
557
+ const refusals: string[] = [];
558
+ // `-v -z` emits source, line number, pattern, and pathname per match.
559
+ for (let index = 0; index + 3 < fields.length; index += 4) {
560
+ const [source, line, pattern, path] = fields.slice(index, index + 4);
561
+ if (path === undefined || path.length === 0) continue;
562
+ refusals.push(`'${path}' is ignored by ${source}:${line}:${pattern}`);
563
+ }
564
+ if (refusals.length === 0) return;
565
+ throw new Error(
566
+ `write boundary: ${refusals.join(", ")}. git status never reports an ignored path, so a boundary declared over one is verified against nothing and every window would be certified clean without an observation. Declare a path the repository tracks, or stop ignoring this one.`,
567
+ );
568
+ }
569
+
570
+ /**
571
+ * Workspace-relative form of a path, or null when it resolves outside. A
572
+ * relative input is taken as already workspace-relative, which is how a
573
+ * recorded write target and an absolute state directory both normalize here.
574
+ */
397
575
  function repoRelative(root: string, path: string): string | null {
398
- const rel = relative(resolve(root), resolve(path));
399
- if (rel.length === 0 || rel.startsWith("..") || rel.startsWith(`..${sep}`)) return null;
576
+ const base = resolve(root);
577
+ const rel = relative(base, resolve(base, path));
578
+ if (rel.length === 0 || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return null;
400
579
  return rel.split(sep).join("/");
401
580
  }
402
581
 
@@ -6,7 +6,35 @@
6
6
  * than silently pass.
7
7
  */
8
8
 
9
- import type { RunReceipt } from "../../dispatch/types.js";
9
+ import { readFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import type { RunEnvelope, RunReceipt } from "../../dispatch/types.js";
12
+ import { formatTrustSummary, summarizeTrustStatus } from "../../evidence/trust-projection.js";
13
+ import {
14
+ adaptRunReceiptTrustStatus,
15
+ inspectRunReceiptTrustStatus,
16
+ TRUST_STATUS_AXES,
17
+ } from "../../evidence/trust-status.js";
18
+
19
+ function dispatchScopeMetrics(receipt: RunReceipt): Record<string, string | boolean | number> {
20
+ const scope = receipt.pathScope;
21
+ if (scope === undefined) return {};
22
+ const entries = [...scope.workingContextPaths, ...scope.writeBoundaries];
23
+ const evidence = entries.flatMap((entry) => entry.evidence);
24
+ const count = (source: string): number => evidence.filter((entry) => entry.source === source).length;
25
+ return {
26
+ "dispatch.scope.mode": scope.mode,
27
+ "dispatch.scope.inferredPathCount": entries.filter((entry) =>
28
+ entry.evidence.some((item) => item.provenance === "inferred"),
29
+ ).length,
30
+ "dispatch.scope.derivedPathCount": entries.filter((entry) =>
31
+ entry.evidence.some((item) => item.provenance === "derived"),
32
+ ).length,
33
+ "dispatch.scope.source.task": count("task"),
34
+ "dispatch.scope.source.briefing": count("briefing"),
35
+ "dispatch.scope.source.writeRoots": count("writeRoots"),
36
+ };
37
+ }
10
38
 
11
39
  /**
12
40
  * Extract the sealed RunReceipt that `clio-coder run --agent … --json` prints after
@@ -53,9 +81,14 @@ function isReceiptShaped(value: unknown): value is RunReceipt {
53
81
  * mirrors the durable findings summary and is omitted when that summary is
54
82
  * absent, so a gate on the metric fails closed instead of inventing a value.
55
83
  */
56
- export function evidenceMetricsFromReceipt(receipt: RunReceipt): Record<string, string | boolean | number> {
84
+ export function evidenceMetricsFromReceipt(
85
+ receipt: RunReceipt,
86
+ options: { envelope?: RunEnvelope | null } = {},
87
+ ): Record<string, string | boolean | number> {
57
88
  return {
58
89
  "evidence.verification": receipt.verification.state,
90
+ ...dispatchScopeMetrics(receipt),
91
+ ...evidenceTrustMetrics(receipt, options.envelope ?? null),
59
92
  ...(receipt.findingsSummary === undefined
60
93
  ? {}
61
94
  : { "evidence.firstPassSuccess": receipt.findingsSummary.firstPassSuccess === true }),
@@ -64,3 +97,47 @@ export function evidenceMetricsFromReceipt(receipt: RunReceipt): Record<string,
64
97
  ...(typeof receipt.costUsd === "number" && Number.isFinite(receipt.costUsd) ? { "cost.usd": receipt.costUsd } : {}),
65
98
  };
66
99
  }
100
+
101
+ /**
102
+ * The canonical trust status as metrics, so a benchmark table reads the same
103
+ * verdict as every operator surface instead of the raw receipt marker. The
104
+ * receipt is authenticated against its ledger row when the caller can supply
105
+ * one; without a row the canonical model reports the seal unchecked and the
106
+ * receipt-owned axes unobserved, which is the honest answer for a receipt
107
+ * that only ever arrived through stdout.
108
+ */
109
+ function evidenceTrustMetrics(
110
+ receipt: RunReceipt,
111
+ envelope: RunEnvelope | null,
112
+ ): Record<string, string | boolean | number> {
113
+ // A missing row means the seal was never checked, not that it failed:
114
+ // only a row that exists and disagrees can break a seal.
115
+ const status =
116
+ envelope === null ? adaptRunReceiptTrustStatus(receipt) : inspectRunReceiptTrustStatus(receipt, envelope).status;
117
+ const summary = summarizeTrustStatus(status);
118
+ return {
119
+ "evidence.trust.version": summary.version,
120
+ "evidence.trust.verdict": summary.verdict,
121
+ "evidence.trust.summary": formatTrustSummary(status),
122
+ ...Object.fromEntries(TRUST_STATUS_AXES.map((axis) => [`evidence.trust.${axis}`, status[axis].state])),
123
+ };
124
+ }
125
+
126
+ /**
127
+ * The ledger row a stdout receipt was sealed from, read from the state dir
128
+ * the run wrote to. Null when the ledger or the row cannot be read; the
129
+ * caller then reports the seal unchecked rather than guessing.
130
+ */
131
+ export function readRunEnvelopeForReceipt(receipt: RunReceipt, stateDir: string): RunEnvelope | null {
132
+ try {
133
+ const parsed: unknown = JSON.parse(readFileSync(join(stateDir, "runs.json"), "utf8"));
134
+ if (!Array.isArray(parsed)) return null;
135
+ const row = parsed.find(
136
+ (entry): entry is RunEnvelope =>
137
+ typeof entry === "object" && entry !== null && (entry as { id?: unknown }).id === receipt.runId,
138
+ );
139
+ return row ?? null;
140
+ } catch {
141
+ return null;
142
+ }
143
+ }
@@ -1,5 +1,10 @@
1
1
  import { shellQuote } from "../../../core/shell-quote.js";
2
- import { evidenceMetricsFromReceipt, receiptFromRunJsonStdout } from "../metrics/evidence.js";
2
+ import { clioStateDir } from "../../../core/xdg.js";
3
+ import {
4
+ evidenceMetricsFromReceipt,
5
+ readRunEnvelopeForReceipt,
6
+ receiptFromRunJsonStdout,
7
+ } from "../metrics/evidence.js";
3
8
  import { streamInvariantMetrics } from "../metrics/invariants.js";
4
9
  import { tokenMetricEntries } from "../metrics/token-stream.js";
5
10
  import type { EvalRunnerV2, EvalSuiteTargetV2 } from "../schema/suite.js";
@@ -35,6 +40,11 @@ export async function runClioRunRunner(
35
40
  // prints; a runner without a receipt leaves them absent so any gate on
36
41
  // them fails closed instead of reading prose labels.
37
42
  const receipt = receiptFromRunJsonStdout(result.stdout);
43
+ // The canonical trust metrics authenticate the receipt against the ledger
44
+ // row the child wrote; a stdout receipt on its own only ever reads as an
45
+ // unchecked seal.
46
+ const envelope =
47
+ receipt === null ? null : readRunEnvelopeForReceipt(receipt, env?.CLIO_CODER_STATE_DIR ?? clioStateDir());
38
48
  return {
39
49
  assignmentId: receipt === null ? null : (receipt.lineage?.rootRunId ?? receipt.runId),
40
50
  terminalReceiptDigest: receipt?.integrity.digest ?? null,
@@ -52,7 +62,7 @@ export async function runClioRunRunner(
52
62
  "tools.failed": tools.failed,
53
63
  "tools.blocked": tools.blocked,
54
64
  "verifier.exitCode": result.exitCode,
55
- ...(receipt === null ? {} : evidenceMetricsFromReceipt(receipt)),
65
+ ...(receipt === null ? {} : evidenceMetricsFromReceipt(receipt, { envelope })),
56
66
  ...(receipt === null
57
67
  ? {}
58
68
  : { "evidence.qualityLabel": receipt.quality.typedValidations.length > 0 ? "measured" : "unmeasured" }),