@cluesmith/codev 3.3.0 → 3.3.2

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 (202) hide show
  1. package/dashboard-dist/assets/{index-JCbkpAcV.js → index-D3XaKpZW.js} +45 -43
  2. package/dashboard-dist/assets/index-D3XaKpZW.js.map +1 -0
  3. package/dashboard-dist/index.html +1 -1
  4. package/dist/agent-farm/cli.d.ts.map +1 -1
  5. package/dist/agent-farm/cli.js +99 -36
  6. package/dist/agent-farm/cli.js.map +1 -1
  7. package/dist/agent-farm/commands/reset/constants.d.ts +119 -1
  8. package/dist/agent-farm/commands/reset/constants.d.ts.map +1 -1
  9. package/dist/agent-farm/commands/reset/constants.js +119 -1
  10. package/dist/agent-farm/commands/reset/constants.js.map +1 -1
  11. package/dist/agent-farm/commands/reset/context.d.ts +20 -2
  12. package/dist/agent-farm/commands/reset/context.d.ts.map +1 -1
  13. package/dist/agent-farm/commands/reset/context.js +44 -3
  14. package/dist/agent-farm/commands/reset/context.js.map +1 -1
  15. package/dist/agent-farm/commands/reset/index.d.ts +7 -7
  16. package/dist/agent-farm/commands/reset/index.js +8 -8
  17. package/dist/agent-farm/commands/reset/index.js.map +1 -1
  18. package/dist/agent-farm/commands/reset/receipt.d.ts +1 -1
  19. package/dist/agent-farm/commands/reset/receipt.d.ts.map +1 -1
  20. package/dist/agent-farm/commands/reset/receipt.js +19 -8
  21. package/dist/agent-farm/commands/reset/receipt.js.map +1 -1
  22. package/dist/agent-farm/commands/reset/reorient.d.ts +1 -1
  23. package/dist/agent-farm/commands/reset/reorient.d.ts.map +1 -1
  24. package/dist/agent-farm/commands/reset/reorient.js +12 -5
  25. package/dist/agent-farm/commands/reset/reorient.js.map +1 -1
  26. package/dist/agent-farm/commands/reset/self.d.ts +335 -0
  27. package/dist/agent-farm/commands/reset/self.d.ts.map +1 -0
  28. package/dist/agent-farm/commands/reset/self.js +636 -0
  29. package/dist/agent-farm/commands/reset/self.js.map +1 -0
  30. package/dist/agent-farm/commands/reset.d.ts +18 -3
  31. package/dist/agent-farm/commands/reset.d.ts.map +1 -1
  32. package/dist/agent-farm/commands/reset.js +55 -33
  33. package/dist/agent-farm/commands/reset.js.map +1 -1
  34. package/dist/agent-farm/commands/self-refresh.d.ts +60 -0
  35. package/dist/agent-farm/commands/self-refresh.d.ts.map +1 -0
  36. package/dist/agent-farm/commands/self-refresh.js +506 -0
  37. package/dist/agent-farm/commands/self-refresh.js.map +1 -0
  38. package/dist/agent-farm/commands/send.d.ts.map +1 -1
  39. package/dist/agent-farm/commands/send.js +27 -2
  40. package/dist/agent-farm/commands/send.js.map +1 -1
  41. package/dist/agent-farm/commands/spawn-worktree.js +1 -1
  42. package/dist/agent-farm/commands/spawn-worktree.js.map +1 -1
  43. package/dist/agent-farm/commands/spawn.d.ts +7 -0
  44. package/dist/agent-farm/commands/spawn.d.ts.map +1 -1
  45. package/dist/agent-farm/commands/spawn.js +13 -1
  46. package/dist/agent-farm/commands/spawn.js.map +1 -1
  47. package/dist/agent-farm/commands/tower.d.ts.map +1 -1
  48. package/dist/agent-farm/commands/tower.js +13 -0
  49. package/dist/agent-farm/commands/tower.js.map +1 -1
  50. package/dist/agent-farm/lib/reconnect-backoff.d.ts +10 -0
  51. package/dist/agent-farm/lib/reconnect-backoff.d.ts.map +1 -1
  52. package/dist/agent-farm/lib/reconnect-backoff.js +10 -0
  53. package/dist/agent-farm/lib/reconnect-backoff.js.map +1 -1
  54. package/dist/agent-farm/lib/tower-client.d.ts +12 -0
  55. package/dist/agent-farm/lib/tower-client.d.ts.map +1 -1
  56. package/dist/agent-farm/lib/tower-client.js +12 -0
  57. package/dist/agent-farm/lib/tower-client.js.map +1 -1
  58. package/dist/agent-farm/lib/tunnel-client.d.ts.map +1 -1
  59. package/dist/agent-farm/lib/tunnel-client.js +7 -3
  60. package/dist/agent-farm/lib/tunnel-client.js.map +1 -1
  61. package/dist/agent-farm/servers/canvas-relay.d.ts +60 -0
  62. package/dist/agent-farm/servers/canvas-relay.d.ts.map +1 -0
  63. package/dist/agent-farm/servers/canvas-relay.js +376 -0
  64. package/dist/agent-farm/servers/canvas-relay.js.map +1 -0
  65. package/dist/agent-farm/servers/gate-profiles.d.ts +1 -1
  66. package/dist/agent-farm/servers/gate-profiles.js +1 -1
  67. package/dist/agent-farm/servers/mailbox-delivery.d.ts +141 -13
  68. package/dist/agent-farm/servers/mailbox-delivery.d.ts.map +1 -1
  69. package/dist/agent-farm/servers/mailbox-delivery.js +172 -16
  70. package/dist/agent-farm/servers/mailbox-delivery.js.map +1 -1
  71. package/dist/agent-farm/servers/mailbox-wiring.d.ts +24 -2
  72. package/dist/agent-farm/servers/mailbox-wiring.d.ts.map +1 -1
  73. package/dist/agent-farm/servers/mailbox-wiring.js +92 -32
  74. package/dist/agent-farm/servers/mailbox-wiring.js.map +1 -1
  75. package/dist/agent-farm/servers/message-write.d.ts +58 -18
  76. package/dist/agent-farm/servers/message-write.d.ts.map +1 -1
  77. package/dist/agent-farm/servers/message-write.js +73 -28
  78. package/dist/agent-farm/servers/message-write.js.map +1 -1
  79. package/dist/agent-farm/servers/overview.d.ts +19 -0
  80. package/dist/agent-farm/servers/overview.d.ts.map +1 -1
  81. package/dist/agent-farm/servers/overview.js +78 -1
  82. package/dist/agent-farm/servers/overview.js.map +1 -1
  83. package/dist/agent-farm/servers/render-gate.d.ts +13 -0
  84. package/dist/agent-farm/servers/render-gate.d.ts.map +1 -1
  85. package/dist/agent-farm/servers/render-gate.js +21 -0
  86. package/dist/agent-farm/servers/render-gate.js.map +1 -1
  87. package/dist/agent-farm/servers/session-submit.d.ts +221 -28
  88. package/dist/agent-farm/servers/session-submit.d.ts.map +1 -1
  89. package/dist/agent-farm/servers/session-submit.js +334 -43
  90. package/dist/agent-farm/servers/session-submit.js.map +1 -1
  91. package/dist/agent-farm/servers/tower-routes.d.ts.map +1 -1
  92. package/dist/agent-farm/servers/tower-routes.js +269 -64
  93. package/dist/agent-farm/servers/tower-routes.js.map +1 -1
  94. package/dist/agent-farm/servers/tower-server.js +25 -3
  95. package/dist/agent-farm/servers/tower-server.js.map +1 -1
  96. package/dist/agent-farm/servers/tower-terminals.d.ts +6 -0
  97. package/dist/agent-farm/servers/tower-terminals.d.ts.map +1 -1
  98. package/dist/agent-farm/servers/tower-terminals.js +30 -5
  99. package/dist/agent-farm/servers/tower-terminals.js.map +1 -1
  100. package/dist/agent-farm/servers/tower-tunnel.d.ts.map +1 -1
  101. package/dist/agent-farm/servers/tower-tunnel.js +14 -0
  102. package/dist/agent-farm/servers/tower-tunnel.js.map +1 -1
  103. package/dist/agent-farm/servers/tower-websocket.d.ts +6 -1
  104. package/dist/agent-farm/servers/tower-websocket.d.ts.map +1 -1
  105. package/dist/agent-farm/servers/tower-websocket.js +90 -37
  106. package/dist/agent-farm/servers/tower-websocket.js.map +1 -1
  107. package/dist/agent-farm/types.d.ts +37 -2
  108. package/dist/agent-farm/types.d.ts.map +1 -1
  109. package/dist/agent-farm/utils/harness.d.ts +3 -3
  110. package/dist/agent-farm/utils/harness.js +1 -1
  111. package/dist/agent-farm/utils/message-format.d.ts +67 -3
  112. package/dist/agent-farm/utils/message-format.d.ts.map +1 -1
  113. package/dist/agent-farm/utils/message-format.js +127 -14
  114. package/dist/agent-farm/utils/message-format.js.map +1 -1
  115. package/dist/agent-farm/utils/server-utils.d.ts +86 -4
  116. package/dist/agent-farm/utils/server-utils.d.ts.map +1 -1
  117. package/dist/agent-farm/utils/server-utils.js +303 -5
  118. package/dist/agent-farm/utils/server-utils.js.map +1 -1
  119. package/dist/commands/porch/context-refresh.d.ts +181 -0
  120. package/dist/commands/porch/context-refresh.d.ts.map +1 -0
  121. package/dist/commands/porch/context-refresh.js +270 -0
  122. package/dist/commands/porch/context-refresh.js.map +1 -0
  123. package/dist/commands/porch/index.d.ts +6 -0
  124. package/dist/commands/porch/index.d.ts.map +1 -1
  125. package/dist/commands/porch/index.js +46 -0
  126. package/dist/commands/porch/index.js.map +1 -1
  127. package/dist/commands/porch/next.d.ts +0 -9
  128. package/dist/commands/porch/next.d.ts.map +1 -1
  129. package/dist/commands/porch/next.js +133 -4
  130. package/dist/commands/porch/next.js.map +1 -1
  131. package/dist/commands/porch/protocol.d.ts.map +1 -1
  132. package/dist/commands/porch/protocol.js +102 -0
  133. package/dist/commands/porch/protocol.js.map +1 -1
  134. package/dist/commands/porch/types.d.ts +61 -0
  135. package/dist/commands/porch/types.d.ts.map +1 -1
  136. package/dist/lib/forge-contracts.d.ts +9 -0
  137. package/dist/lib/forge-contracts.d.ts.map +1 -1
  138. package/dist/lib/forge-contracts.js.map +1 -1
  139. package/dist/lib/github.d.ts +15 -1
  140. package/dist/lib/github.d.ts.map +1 -1
  141. package/dist/lib/github.js +17 -0
  142. package/dist/lib/github.js.map +1 -1
  143. package/dist/lib/self-refresh-invocation.d.ts +60 -0
  144. package/dist/lib/self-refresh-invocation.d.ts.map +1 -0
  145. package/dist/lib/self-refresh-invocation.js +64 -0
  146. package/dist/lib/self-refresh-invocation.js.map +1 -0
  147. package/dist/lib/test-env.d.ts +50 -0
  148. package/dist/lib/test-env.d.ts.map +1 -1
  149. package/dist/lib/test-env.js +76 -0
  150. package/dist/lib/test-env.js.map +1 -1
  151. package/dist/terminal/attach-replay.d.ts +39 -0
  152. package/dist/terminal/attach-replay.d.ts.map +1 -0
  153. package/dist/terminal/attach-replay.js +76 -0
  154. package/dist/terminal/attach-replay.js.map +1 -0
  155. package/dist/terminal/pty-manager.d.ts.map +1 -1
  156. package/dist/terminal/pty-manager.js +35 -17
  157. package/dist/terminal/pty-manager.js.map +1 -1
  158. package/dist/terminal/pty-session.d.ts +81 -12
  159. package/dist/terminal/pty-session.d.ts.map +1 -1
  160. package/dist/terminal/pty-session.js +113 -25
  161. package/dist/terminal/pty-session.js.map +1 -1
  162. package/dist/terminal/session-screen.d.ts +25 -0
  163. package/dist/terminal/session-screen.d.ts.map +1 -1
  164. package/dist/terminal/session-screen.js +53 -8
  165. package/dist/terminal/session-screen.js.map +1 -1
  166. package/package.json +10 -7
  167. package/scripts/forge/gitea/pr-view.sh +4 -1
  168. package/scripts/forge/github/pr-view.sh +1 -1
  169. package/scripts/forge/gitlab/pr-view.sh +4 -1
  170. package/skeleton/.claude/skills/afx/SKILL.md +54 -0
  171. package/skeleton/.claude/skills/arch-save/SKILL.md +8 -2
  172. package/skeleton/.claude/skills/builder-refresh/SKILL.md +138 -0
  173. package/skeleton/.codex/skills/afx/SKILL.md +54 -0
  174. package/skeleton/.codex/skills/arch-save/SKILL.md +8 -2
  175. package/skeleton/.codex/skills/builder-refresh/SKILL.md +138 -0
  176. package/skeleton/protocol-schema.json +17 -0
  177. package/skeleton/protocols/air/protocol.json +1 -1
  178. package/skeleton/protocols/aspir/protocol.json +5 -1
  179. package/skeleton/protocols/bugfix/protocol.json +1 -1
  180. package/skeleton/protocols/experiment/protocol.json +1 -1
  181. package/skeleton/protocols/maintain/protocol.json +1 -1
  182. package/skeleton/protocols/pir/builder-prompt.md +3 -2
  183. package/skeleton/protocols/pir/prompts/implement.md +1 -1
  184. package/skeleton/protocols/pir/prompts/plan.md +1 -1
  185. package/skeleton/protocols/pir/prompts/review.md +1 -1
  186. package/skeleton/protocols/pir/protocol.json +1 -1
  187. package/skeleton/protocols/pir/protocol.md +4 -4
  188. package/skeleton/protocols/protocol-schema.json +17 -0
  189. package/skeleton/protocols/research/protocol.json +1 -1
  190. package/skeleton/protocols/spike/protocol.json +1 -1
  191. package/skeleton/protocols/spir/protocol.json +5 -1
  192. package/skeleton/resources/commands/agent-farm.md +14 -11
  193. package/skeleton/roles/builder.md +3 -4
  194. package/templates/3d-viewer.html +32 -4
  195. package/templates/open.html +58 -12
  196. package/templates/tower.html +26 -7
  197. package/templates/vendor/three-3MFLoader.js +1478 -0
  198. package/templates/vendor/three-STLLoader.js +410 -0
  199. package/templates/vendor/three-TrackballControls.js +828 -0
  200. package/templates/vendor/three-fflate.module.js +2474 -0
  201. package/templates/vendor/three.module.js +53044 -0
  202. package/dashboard-dist/assets/index-JCbkpAcV.js.map +0 -1
@@ -0,0 +1,636 @@
1
+ /**
2
+ * Self-refresh orchestrator — the tail of the refresh machine, run by the
3
+ * builder on itself (Spec 1470).
4
+ *
5
+ * ## Why this exists separately from `runReset`
6
+ *
7
+ * `afx refresh` cannot be invoked by its own target. It sends a save request and
8
+ * then POLLS for the receipt while waiting for the terminal to fall quiet — but
9
+ * a builder running it would be mid-turn for the whole poll, so it could never
10
+ * answer its own request. The run would burn the 300 s receipt timeout and
11
+ * abort. The receipt and quiescence gates structurally require a driver outside
12
+ * the turn.
13
+ *
14
+ * So this module is the TAIL of that machine, not a second copy of it: verify an
15
+ * already-written save → assemble the re-orientation → schedule the re-entry →
16
+ * clear. The verification and assembly are the SAME modules `runReset` uses
17
+ * ({@link verifyReceipt}, {@link assembleReorientation}), which a structural test
18
+ * pins. What is deliberately different is the save REQUEST text — see
19
+ * {@link buildBoundarySaveRequest}.
20
+ *
21
+ * ## The two-step handshake, and why it is not one call
22
+ *
23
+ * `verifyReceipt` requires the nonce to be INSIDE the state file. In the self
24
+ * path the builder writes that file before invoking the command, so a nonce
25
+ * minted at invocation could never be in a file already on disk — every run
26
+ * would abort `wrong-nonce`. Hence:
27
+ *
28
+ * begin → mint nonce, write the challenge file, return the save request
29
+ * (builder writes .builder-state.md, reproducing the nonce)
30
+ * execute → verify against THAT nonce, assemble, schedule, clear, consume
31
+ * the challenge
32
+ *
33
+ * The challenge is deleted on use and overwritten by every `begin`, which is
34
+ * what stops a stale `.builder-state.md` from an EARLIER boundary passing the
35
+ * gate. The driven path gets that freshness for free from being externally
36
+ * driven; the self path has to earn it.
37
+ *
38
+ * ## Ordering, and the one place it differs from `/arch-save`
39
+ *
40
+ * Schedule the re-entry FIRST, clear SECOND. `/arch-save` does the reverse. The
41
+ * two failure directions are not symmetric:
42
+ *
43
+ * - schedule fails, no clear sent → builder keeps everything. Recoverable.
44
+ * - clear sent, schedule fails → context destroyed, nobody coming back.
45
+ *
46
+ * Only the second is unrecoverable, so the destructive step goes last. Baked
47
+ * Decision 4 requires the automatic path to be MORE conservative than the manual
48
+ * one; this is that, made concrete rather than asserted.
49
+ *
50
+ * ## Everything is a port, and every action is logged before it happens
51
+ *
52
+ * Same discipline as `runReset`, for the same reason: every safety property here
53
+ * is an ORDERING property ("the clear never happens before X"), and ordering is
54
+ * what is hardest to prove by reading. Actions go through injected ports and the
55
+ * invariant tests assert over an ordered step log rather than over mocks, so "no
56
+ * clear without a verified save" becomes something a test can fail on.
57
+ *
58
+ * The IRREVERSIBLE action is logged BEFORE it is attempted (`clear-attempted`),
59
+ * with a second entry (`clear`) only on success. That asymmetry is deliberate:
60
+ * `sendRaw` can succeed on the wire and still throw, and a log written only on
61
+ * success would then claim no clear occurred about a context that no longer
62
+ * exists — with every `expectNoClear` assertion agreeing. Reversible steps are
63
+ * logged after they succeed, where a failure is genuinely a non-event.
64
+ * (`runReset` logs its clear after the fact; that same weakness is noted for the
65
+ * review artifact rather than fixed here, since this phase does not own it.)
66
+ */
67
+ import { join } from 'node:path';
68
+ import { CHALLENGE_FILE_NAME, DEFAULT_CHALLENGE_MAX_AGE_MS, MIN_ALLOWED_CHALLENGE_MAX_AGE_MS, DEFAULT_MIN_BYTES, MAX_NONCE_HEX_CHARS, MIN_ALLOWED_MIN_BYTES, MIN_ALLOWED_REENTRY_DELAY_SECONDS, MIN_ALLOWED_STABILITY_WINDOW_MS, DEFAULT_REENTRY_DELAY_SECONDS, DEFAULT_STABILITY_WINDOW_MS, REORIENT_FILE_NAME, STATE_FILE_NAME, } from './constants.js';
69
+ import { nonceMarker, generateNonce, verifyReceipt, describeReceiptFailure, stateFilePath, } from './receipt.js';
70
+ import { assembleReorientation, ReorientationAssemblyError, } from './reorient.js';
71
+ import { enterBoundary } from '../../../commands/porch/context-refresh.js';
72
+ // ============================================================================
73
+ // The save request — deliberately NOT buildSaveRequest
74
+ // ============================================================================
75
+ /**
76
+ * The boundary-aware save request.
77
+ *
78
+ * This is the one place the self path deliberately diverges from the driven one.
79
+ * `buildSaveRequest` (receipt.ts) asks for a "complete working state" and says
80
+ * "do not summarise for brevity" — right for a MID-PHASE reset, where a builder
81
+ * may be holding a half-finished action nothing on disk records.
82
+ *
83
+ * A boundary save is a different problem. Spec 1470 fixes the boundaries at
84
+ * moments when the durable state is already externalised — the spec, the plan,
85
+ * `status.yaml`, the thread narrative and git — so what a cold reader actually
86
+ * needs is the residue those artifacts do NOT carry. Asking for "everything"
87
+ * here produces a long file that mostly restates the plan, and Baked Decision 2
88
+ * explicitly says to keep the builder save minimal.
89
+ *
90
+ * Hence: pointers over prose. Receipts with commit hashes, not narration of what
91
+ * the commits contain.
92
+ *
93
+ * At the REVIEW boundary there is one further exclusion, and it is the whole
94
+ * reason that boundary is a quality feature rather than only a context one: a
95
+ * builder entering review in a fresh context should read its own diff cold. If
96
+ * the save carries "I implemented X and it is correct because Y", the refresh
97
+ * hands back the very perspective it was supposed to remove.
98
+ */
99
+ export function buildBoundarySaveRequest(nonce, statePath, boundary) {
100
+ // Derived, not typed out. A bare `'enter:review'` here is uncoupled from
101
+ // `enterBoundary()`, which is what actually produces boundary ids — so a
102
+ // change to that format would silently delete the review-boundary constraint
103
+ // rather than failing. The constraint is the whole reason the review boundary
104
+ // is a quality feature and not just a context one.
105
+ const isReview = boundary === enterBoundary('review');
106
+ const lines = [
107
+ 'CONTEXT REFRESH — save your working state now.',
108
+ '',
109
+ boundary ? `Boundary: \`${boundary}\`` : 'Boundary: (unspecified)',
110
+ '',
111
+ `Write your working state to \`${statePath}\` (untracked; do not stage or commit it).`,
112
+ '',
113
+ 'The file MUST begin with this exact line, reproduced character for character:',
114
+ '',
115
+ nonceMarker(nonce),
116
+ '',
117
+ 'Write for a COLD READER — a competent agent that wakes up with your worktree,',
118
+ 'your branch, and no memory of this conversation.',
119
+ '',
120
+ 'This is a PROTOCOL BOUNDARY, so most of your state is already on disk. The spec,',
121
+ 'the plan, `status.yaml`, your thread narrative and git history all survive the',
122
+ 'refresh and will be re-read. Do not restate them.',
123
+ '',
124
+ 'Carry only what those artifacts do NOT contain:',
125
+ '',
126
+ '1. **Receipts** — what is done and *verified*, with paths and commit hashes.',
127
+ ' Distinguish "written" from "verified"; a cold reader cannot tell.',
128
+ '2. **Deviations** — where the implementation diverges from the plan, and why.',
129
+ '3. **Flaky or skipped tests** you hit, and what you did about them.',
130
+ '4. **Deferred work** — anything knowingly left, and the reason.',
131
+ '5. **Standing orders** from the architect you are still bound by, including',
132
+ ' anything you were told NOT to do.',
133
+ '6. **Next concrete action** — the single thing to do first after the refresh.',
134
+ ];
135
+ if (isReview) {
136
+ lines.push('', 'THIS IS THE REVIEW BOUNDARY. You are about to read your own diff with fresh', 'eyes, which is the point of refreshing here. So do NOT carry:', '', '- any assessment of whether your implementation is correct or good;', '- any defence or justification of choices you made;', '- any narrative of how the code came to be.', '', 'Facts and pointers only. Let the diff speak for itself — if you tell the next', 'reader what to think of the code, you have handed back the exact perspective', 'this refresh exists to remove.');
137
+ }
138
+ lines.push('', `The save must be at least ${DEFAULT_MIN_BYTES} bytes to be accepted — not as a`, 'word count to pad out, but because a file below it is indistinguishable from a', 'stub. If you genuinely have less than that to say, you are probably omitting', 'receipts or standing orders. This boundary is refreshed AT MOST ONCE and is', 'never retried, so a save rejected here means the refresh simply does not happen.', '', 'Pointers, not prose. When the file is written, run the execute step.', '',
139
+ // Observed live (Spec 1470, third pass): a refusal-and-retry put real work
140
+ // between `--begin` and the execute step, and the save still said "phase 2
141
+ // not started" when phase 2 was by then DONE. The subject noticed and
142
+ // rewrote it; a cold reader following the stale text would have
143
+ // re-implemented completed work.
144
+ //
145
+ // No gate can catch this — the file is authentic, substantive, settled and
146
+ // inside its age window, and simply false. The mechanism-level fix needs
147
+ // design (see the follow-up issue), so what ships here is the instruction,
148
+ // which addresses the observed case directly rather than pretending a new
149
+ // check would.
150
+ 'IF YOU DID ANY WORK between the challenge step and this one — committed anything,', 'finished a phase, changed what is true — REWRITE THE SAVE FIRST. The gates check that', 'your file is authentic, substantial and settled; NOTHING checks that it is still', 'accurate. A save that was true when you wrote it and is stale when you clear on it', 'sends your successor to redo finished work.');
151
+ return lines.join('\n');
152
+ }
153
+ // ============================================================================
154
+ // Paths
155
+ // ============================================================================
156
+ /**
157
+ * `path.join`, not string concatenation — the same reasoning `stateFilePath`
158
+ * documents. These paths are shown to the builder verbatim and stat'd by the
159
+ * gate, so a hand-built separator would name one path in the instruction and
160
+ * check a different one on Windows. `join` also collapses a trailing slash.
161
+ */
162
+ export function challengeFilePath(worktreePath) {
163
+ return join(worktreePath, CHALLENGE_FILE_NAME);
164
+ }
165
+ export function reorientFilePath(worktreePath) {
166
+ return join(worktreePath, REORIENT_FILE_NAME);
167
+ }
168
+ /**
169
+ * Mint a challenge and tell the builder what to write.
170
+ *
171
+ * Overwrites any existing challenge deliberately: a fresh `begin` must invalidate
172
+ * a previous boundary's nonce, or a stale `.builder-state.md` left on disk would
173
+ * satisfy the gate without the builder writing anything.
174
+ *
175
+ * Writes NOTHING destructive and touches no terminal. Nothing here can lose work.
176
+ */
177
+ export function beginSelfRefresh(options) {
178
+ const { fs, clock, worktree, boundary, makeNonce = generateNonce } = options;
179
+ const nonce = makeNonce();
180
+ const challengePath = challengeFilePath(worktree);
181
+ const statePath = stateFilePath(worktree, STATE_FILE_NAME);
182
+ const challenge = { nonce, issuedAt: clock.now(), boundary };
183
+ fs.write(challengePath, JSON.stringify(challenge, null, 2));
184
+ return {
185
+ nonce,
186
+ statePath,
187
+ challengePath,
188
+ saveRequest: buildBoundarySaveRequest(nonce, statePath, boundary),
189
+ };
190
+ }
191
+ // ============================================================================
192
+ // Step 2 — execute
193
+ // ============================================================================
194
+ /**
195
+ * Parse and FULLY validate a persisted challenge.
196
+ *
197
+ * `JSON.parse` returns `any`, and casting it to {@link Challenge} buys a
198
+ * compile-time guarantee about a runtime value that came off disk. That gap is
199
+ * exploitable, and not theoretically:
200
+ *
201
+ * `{"nonce": []}` survives a truthiness check, because `![]` is `false`. It is
202
+ * then handed to `verifyReceipt`, whose `content.includes(nonce)` coerces the
203
+ * array to `''` — and `String.includes('')` is true for EVERY string. The
204
+ * freshness gate does not merely weaken, it inverts: any file over the size
205
+ * floor passes as a fresh save.
206
+ *
207
+ * A non-numeric or `NaN` `issuedAt` defeats the age bound the same way, since
208
+ * every comparison with `NaN` is false. A FUTURE `issuedAt` yields a negative
209
+ * age, which is likewise never "too old".
210
+ *
211
+ * So the shape is checked here, at the trust boundary, rather than assumed. The
212
+ * driven path needs no equivalent because it mints its nonce in-process and
213
+ * never reads one back from disk; only the self path takes a challenge from a
214
+ * file, which is exactly where validation belongs.
215
+ */
216
+ /**
217
+ * Minimum nonce length, in hex characters.
218
+ *
219
+ * `generateNonce()` produces `randomBytes(6).toString('hex')` — 12 lowercase hex
220
+ * characters. Validating a FLOOR rather than an exact length keeps a future
221
+ * longer nonce valid while rejecting a short one.
222
+ *
223
+ * Length is a safety property here, not tidiness: freshness is proved by
224
+ * `content.includes(nonce)`, so a one-character nonce is found in nearly every
225
+ * state file ever written. A nonce short enough to collide is a nonce that
226
+ * proves nothing.
227
+ */
228
+ const MIN_NONCE_HEX_CHARS = 12;
229
+ const NONCE_PATTERN = new RegExp(`^[0-9a-f]{${MIN_NONCE_HEX_CHARS},${MAX_NONCE_HEX_CHARS}}$`);
230
+ export function parseChallenge(raw, now) {
231
+ let parsed;
232
+ try {
233
+ parsed = JSON.parse(raw);
234
+ }
235
+ catch {
236
+ return { ok: false, reason: 'is not valid JSON' };
237
+ }
238
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
239
+ return { ok: false, reason: 'is not a JSON object' };
240
+ }
241
+ const obj = parsed;
242
+ if (typeof obj.nonce !== 'string' || !NONCE_PATTERN.test(obj.nonce)) {
243
+ return {
244
+ ok: false,
245
+ reason: `carries a nonce that is not ${MIN_NONCE_HEX_CHARS}-${MAX_NONCE_HEX_CHARS} lowercase hex characters ` +
246
+ `(${JSON.stringify(obj.nonce)}). Freshness is proved by \`content.includes(nonce)\`, so a ` +
247
+ `short or non-hex nonce matches almost any file: "a" would be found in nearly every save, ` +
248
+ `and "" in all of them`,
249
+ };
250
+ }
251
+ if (typeof obj.issuedAt !== 'number' || !Number.isFinite(obj.issuedAt)) {
252
+ return {
253
+ ok: false,
254
+ reason: `carries a non-finite issuedAt (${JSON.stringify(obj.issuedAt)}); the age bound ` +
255
+ `cannot be evaluated against it`,
256
+ };
257
+ }
258
+ // A future timestamp yields a negative age, which no maximum can reject.
259
+ // Allow a small skew so an ordinary clock adjustment is not fatal.
260
+ const SKEW_MS = 60_000;
261
+ if (obj.issuedAt > now + SKEW_MS) {
262
+ return {
263
+ ok: false,
264
+ reason: `was issued in the future (issuedAt ${obj.issuedAt}, now ${now}); a future ` +
265
+ `timestamp cannot expire`,
266
+ };
267
+ }
268
+ if (obj.boundary !== undefined && typeof obj.boundary !== 'string') {
269
+ return { ok: false, reason: 'carries a non-string boundary' };
270
+ }
271
+ if (obj.consumedAt !== undefined && typeof obj.consumedAt !== 'number') {
272
+ return { ok: false, reason: 'carries a non-numeric consumedAt' };
273
+ }
274
+ return {
275
+ ok: true,
276
+ challenge: {
277
+ nonce: obj.nonce,
278
+ issuedAt: obj.issuedAt,
279
+ boundary: obj.boundary,
280
+ consumedAt: obj.consumedAt,
281
+ },
282
+ };
283
+ }
284
+ export async function runSelfRefresh(options) {
285
+ const { fs, clock, terminal, git, context, buildSpawnPrompt, buildResumeNotice, issue, addendum, minBytes = DEFAULT_MIN_BYTES, stabilityWindowMs = DEFAULT_STABILITY_WINDOW_MS, reentryDelaySeconds = DEFAULT_REENTRY_DELAY_SECONDS, challengeMaxAgeMs = DEFAULT_CHALLENGE_MAX_AGE_MS, expectedBoundary, dryRun = false, allowDirty = false, } = options;
286
+ const steps = [];
287
+ const step = (name, detail) => {
288
+ steps.push({ name, at: clock.now(), detail });
289
+ };
290
+ const worktree = context.worktree;
291
+ const statePath = stateFilePath(worktree, STATE_FILE_NAME);
292
+ const challengePath = challengeFilePath(worktree);
293
+ const reorientPath = reorientFilePath(worktree);
294
+ const abort = (failure, reason) => ({ outcome: 'aborted', steps, failure, reason, statePath });
295
+ // ------------------------------------------------------------------
296
+ // Gate 0 — the safety parameters themselves must be sane.
297
+ // ------------------------------------------------------------------
298
+ // Every one of these TUNES A GATE, so a bad value does not degrade the run —
299
+ // it silently disables a protection while everything still reports success.
300
+ // `minBytes: 0` accepts an empty save; `challengeMaxAgeMs: NaN` makes every
301
+ // comparison false so nothing ever expires; a non-positive stability window
302
+ // collapses two observations into one.
303
+ //
304
+ // Validated HERE rather than only at the CLI boundary: this function is the
305
+ // thing that clears a builder, and it should not depend on every future caller
306
+ // having remembered to check. `runReset` sets the same precedent for its own
307
+ // flags.
308
+ // Floors, not just positivity. "Finite and positive" is VALIDITY; these gates
309
+ // need SANITY, because a small positive value neuters the check it configures
310
+ // while everything still reports success. `--delay 0.001` is the dangerous
311
+ // one: the re-entry and the clear then race for the same clean prompt.
312
+ const parameterChecks = [
313
+ ['minBytes', minBytes, MIN_ALLOWED_MIN_BYTES],
314
+ ['stabilityWindowMs', stabilityWindowMs, MIN_ALLOWED_STABILITY_WINDOW_MS],
315
+ ['reentryDelaySeconds', reentryDelaySeconds, MIN_ALLOWED_REENTRY_DELAY_SECONDS],
316
+ ['challengeMaxAgeMs', challengeMaxAgeMs, MIN_ALLOWED_CHALLENGE_MAX_AGE_MS],
317
+ ];
318
+ for (const [name, value, floor] of parameterChecks) {
319
+ if (!Number.isFinite(value) || value < floor) {
320
+ return abort('invalid-parameters', `${name} must be a finite number >= ${floor} (got ${value}). Every safety parameter ` +
321
+ `here gates a check, so a too-small value disables a protection rather than ` +
322
+ `failing loudly — refusing instead.`);
323
+ }
324
+ }
325
+ // ------------------------------------------------------------------
326
+ // Gate 1 — the challenge must exist and be readable.
327
+ // ------------------------------------------------------------------
328
+ // Without it there is no nonce to verify against, so freshness is
329
+ // unprovable. Refusing here is what makes "run execute twice" safe: the
330
+ // first run consumes the challenge, the second finds nothing.
331
+ const rawChallenge = fs.read(challengePath);
332
+ if (rawChallenge === null) {
333
+ return abort('no-challenge', `No refresh challenge at ${challengePath}. Run the begin step first ` +
334
+ `(a challenge is consumed by each execute, so this also means "already refreshed").`);
335
+ }
336
+ const parsedChallenge = parseChallenge(rawChallenge, clock.now());
337
+ if (!parsedChallenge.ok) {
338
+ return abort('no-challenge', `Refresh challenge at ${challengePath} ${parsedChallenge.reason}. ` +
339
+ `Run the begin step again.`);
340
+ }
341
+ const challenge = parsedChallenge.challenge;
342
+ if (challenge.consumedAt !== undefined) {
343
+ // Burned by an earlier execute whose delete did not land. Refusing here is
344
+ // what makes the pre-clear mark sufficient on its own.
345
+ return abort('no-challenge', `Refresh challenge at ${challengePath} was already consumed. A challenge is ` +
346
+ `single-use; run the begin step again if you mean to refresh once more.`);
347
+ }
348
+ // A challenge is for ONE boundary at ONE moment. Both checks close the same
349
+ // hole from different sides: a run that aborted leaves its challenge on disk,
350
+ // and without these it would still satisfy the gate at a later boundary,
351
+ // against a state file describing work that has since moved on.
352
+ if (expectedBoundary !== undefined && challenge.boundary !== expectedBoundary) {
353
+ return abort('no-challenge', `Refresh challenge was issued for boundary '${challenge.boundary ?? '(none)'}' but this ` +
354
+ `run is for '${expectedBoundary}'. Run the begin step again — a challenge does not ` +
355
+ `carry across boundaries.`);
356
+ }
357
+ const age = clock.now() - challenge.issuedAt;
358
+ if (age > challengeMaxAgeMs) {
359
+ return abort('no-challenge', `Refresh challenge is ${Math.round(age / 1000)}s old (limit ` +
360
+ `${Math.round(challengeMaxAgeMs / 1000)}s). A save verified against a stale challenge ` +
361
+ `may describe work that has since moved on. Run the begin step again.`);
362
+ }
363
+ step('challenge-read', challenge.boundary ?? 'no-boundary');
364
+ // ------------------------------------------------------------------
365
+ // Gate 2 — the worktree must have no uncommitted tracked changes.
366
+ // ------------------------------------------------------------------
367
+ // A refresh fires at a protocol boundary, where porch has just committed.
368
+ // Uncommitted tracked work means we are NOT at that boundary — something is
369
+ // half-done, and the plan/spec/git triple that a refreshed builder re-reads
370
+ // would not describe it. Untracked `.builder-*` scaffold does not count; it
371
+ // is expected to be there.
372
+ if (!allowDirty && git.hasUncommittedTrackedChanges()) {
373
+ return abort('dirty-worktree', 'Worktree has uncommitted tracked changes. A refresh assumes a committed ' +
374
+ 'boundary, and a fresh context re-orients from artifacts and git — work ' +
375
+ 'that exists only in the working tree would be invisible to it. Commit ' +
376
+ 'first, or pass --allow-dirty if this is deliberate.');
377
+ }
378
+ step('worktree-checked');
379
+ // ------------------------------------------------------------------
380
+ // Gate 3 — the save must pass the SAME receipt gate the driven path uses.
381
+ // ------------------------------------------------------------------
382
+ // Two observations, not one: `verifyReceipt` returns `still-growing` whenever
383
+ // `previous` is null, because stability requires agreement across a real time
384
+ // gap. One read cannot distinguish a finished file from one mid-write.
385
+ const first = verifyReceipt({
386
+ fs,
387
+ statePath,
388
+ nonce: challenge.nonce,
389
+ minBytes,
390
+ previous: null,
391
+ msSincePrevious: 0,
392
+ stabilityWindowMs,
393
+ });
394
+ // Fail fast on the reasons a second look cannot change. Sleeping 2 s to
395
+ // re-confirm a missing file helps nobody.
396
+ if (first.status === 'missing' || first.status === 'wrong-nonce' || first.status === 'too-small') {
397
+ return abort('receipt-rejected', describeReceiptFailure(first, statePath, minBytes));
398
+ }
399
+ const readMonotonic = () => (clock.monotonicNow ? clock.monotonicNow() : clock.now());
400
+ const beforeSleep = readMonotonic();
401
+ await clock.sleep(stabilityWindowMs);
402
+ // MEASURE the gap rather than asserting it. Passing `stabilityWindowMs` on
403
+ // faith would keep the gate passing even if the sleep did not actually
404
+ // advance the clock — the stability check would then be comparing two reads
405
+ // taken back to back while claiming they were seconds apart.
406
+ const elapsed = readMonotonic() - beforeSleep;
407
+ const second = verifyReceipt({
408
+ fs,
409
+ statePath,
410
+ nonce: challenge.nonce,
411
+ minBytes,
412
+ previous: first,
413
+ msSincePrevious: elapsed,
414
+ stabilityWindowMs,
415
+ });
416
+ if (second.status !== 'accepted') {
417
+ return abort('receipt-rejected', describeReceiptFailure(second, statePath, minBytes));
418
+ }
419
+ step('receipt-accepted', `${second.bytes} bytes`);
420
+ // ------------------------------------------------------------------
421
+ // Gate 4 — assemble the re-orientation (R3: complete or throw).
422
+ // ------------------------------------------------------------------
423
+ let payload;
424
+ try {
425
+ payload = assembleReorientation({
426
+ context,
427
+ statePath,
428
+ addendum,
429
+ buildSpawnPrompt,
430
+ buildResumeNotice,
431
+ issue,
432
+ });
433
+ }
434
+ catch (err) {
435
+ const reason = err instanceof ReorientationAssemblyError
436
+ ? err.message
437
+ : `Re-orientation assembly failed: ${err instanceof Error ? err.message : String(err)}`;
438
+ return abort('assembly-failed', reason);
439
+ }
440
+ step('assemble');
441
+ if (dryRun) {
442
+ // Everything above is a read or a pure computation, so stopping here is a
443
+ // complete rehearsal: the caller learns whether this refresh WOULD proceed
444
+ // without any of it having happened. The challenge is left intact so the
445
+ // real run can still use it.
446
+ return {
447
+ outcome: 'dry-run',
448
+ steps,
449
+ reason: 'dry run — verified and assembled, nothing sent',
450
+ statePath,
451
+ reorientPath,
452
+ payload,
453
+ stateBytes: second.bytes,
454
+ };
455
+ }
456
+ // ------------------------------------------------------------------
457
+ // R1 — the re-orientation is ON DISK before anything destructive.
458
+ // ------------------------------------------------------------------
459
+ try {
460
+ fs.write(reorientPath, payload.longForm);
461
+ }
462
+ catch (err) {
463
+ return abort('reorient-write-failed', `Could not write ${reorientPath}: ${err instanceof Error ? err.message : String(err)}. ` +
464
+ `Refusing to clear without the re-orientation on disk (R1).`);
465
+ }
466
+ step('reorient-written');
467
+ // ------------------------------------------------------------------
468
+ // Schedule the re-entry BEFORE the clear. This is the ordering that
469
+ // differs from `/arch-save`, and the reason is asymmetric damage: a failed
470
+ // schedule with no clear is recoverable, a clear with no re-entry is not.
471
+ // ------------------------------------------------------------------
472
+ try {
473
+ await terminal.scheduleReentry(buildAutomaticReentryFrame(payload.inline), reentryDelaySeconds);
474
+ }
475
+ catch (err) {
476
+ return abort('reentry-failed', `Could not schedule the post-clear re-entry: ` +
477
+ `${err instanceof Error ? err.message : String(err)}. ` +
478
+ `NOT clearing — your context is intact. The re-orientation is saved at ` +
479
+ `${reorientPath} if you want to retry.`);
480
+ }
481
+ step('reentry-scheduled', `+${reentryDelaySeconds}s`);
482
+ // ------------------------------------------------------------------
483
+ // Burn the challenge BEFORE clearing, not after.
484
+ // ------------------------------------------------------------------
485
+ // Deleting it afterwards is not enough: if the delete fails, the challenge and
486
+ // the already-verified state file both remain, so a second `execute` would
487
+ // sail through every gate and clear the builder AGAIN — scheduling a second
488
+ // re-entry into a context that just lost the first one.
489
+ //
490
+ // Marking is a write rather than a delete, and it happens while aborting is
491
+ // still free. If it fails we stop here with nothing destroyed. The delete
492
+ // after the clear is then pure tidiness: the mark alone already makes the
493
+ // challenge unusable.
494
+ try {
495
+ fs.write(challengePath, JSON.stringify({ ...challenge, consumedAt: clock.now() }, null, 2));
496
+ }
497
+ catch (err) {
498
+ return abort('challenge-burn-failed', `Could not mark the refresh challenge consumed: ` +
499
+ `${err instanceof Error ? err.message : String(err)}. ` +
500
+ `NOT clearing — an unburnable challenge could be replayed into a second clear. ` +
501
+ `Your context is intact, but a re-entry message is ALREADY QUEUED from the ` +
502
+ `previous step and will arrive shortly; it can be ignored. Note that retrying ` +
503
+ `queues a second one.`);
504
+ }
505
+ step('challenge-marked');
506
+ // ------------------------------------------------------------------
507
+ // The irreversible step. Everything above had to succeed to get here.
508
+ // ------------------------------------------------------------------
509
+ // Logged BEFORE it is attempted: see `clear-attempted`.
510
+ step('clear-attempted');
511
+ try {
512
+ await terminal.sendRaw('/clear');
513
+ }
514
+ catch (err) {
515
+ // Deliberately NOT "your context is intact". `sendRaw` can fail after the
516
+ // write reached the terminal, so from here we genuinely cannot tell whether
517
+ // the clear landed. Saying it did not would be a guess presented as a fact,
518
+ // and the reader would act on it. The re-entry is queued either way, so both
519
+ // outcomes recover.
520
+ return abort('clear-failed', `Re-entry was scheduled but sending the clear reported an error: ` +
521
+ `${err instanceof Error ? err.message : String(err)}. ` +
522
+ `The clear MAY still have landed — this path cannot tell. Either way the ` +
523
+ `re-orientation is at ${reorientPath} and a re-entry message is already queued.`);
524
+ }
525
+ step('clear');
526
+ // Tidy up. Genuinely optional now: the challenge was marked consumed before
527
+ // the clear, so even if this delete fails the file cannot be replayed — a
528
+ // marked challenge is refused at the gate above. Never turn a completed
529
+ // refresh into a failure over housekeeping.
530
+ try {
531
+ fs.remove(challengePath);
532
+ step('challenge-consumed');
533
+ }
534
+ catch {
535
+ // Left on disk, already neutralised. The next `begin` overwrites it.
536
+ }
537
+ return {
538
+ outcome: 'completed',
539
+ steps,
540
+ statePath,
541
+ reorientPath,
542
+ payload,
543
+ stateBytes: second.bytes,
544
+ };
545
+ }
546
+ /**
547
+ * Mark the re-entry as an AUTOMATIC refresh before it is scheduled.
548
+ *
549
+ * A builder cannot tell its own scheduled message from an architect's. Verified
550
+ * by probe during the specify phase: `afx send <self> "..."` is rendered by the
551
+ * harness as `### [ARCHITECT INSTRUCTION | … ] ###`. So a frame that merely says
552
+ * "context refresh" arrives looking like an order from the architect, and a
553
+ * refreshed builder may treat re-orientation as a new instruction — or, worse,
554
+ * wait for a follow-up that will never come.
555
+ *
556
+ * Added HERE rather than in `assembleReorientation`, which is shared: on the
557
+ * driven path the message genuinely IS from an architect who typed
558
+ * `afx refresh`, so labelling it automatic there would be false. The
559
+ * discriminator belongs to the path that is actually automatic.
560
+ */
561
+ export function buildAutomaticReentryFrame(inline) {
562
+ return [
563
+ AUTOMATIC_REENTRY_MARKER,
564
+ '',
565
+ 'This message is from YOU, not from the architect. Your own `afx self-refresh`',
566
+ 'scheduled it before clearing your context at a protocol boundary. Nobody is',
567
+ 'waiting on a reply and there is no new instruction here — this is your',
568
+ 're-orientation, and the work to do next is whatever `porch next` returns.',
569
+ '',
570
+ inline,
571
+ ].join('\n');
572
+ }
573
+ /**
574
+ * The literal marker. Exported so tests pin the exact string a builder sees,
575
+ * rather than a paraphrase that could drift away from it.
576
+ */
577
+ export const AUTOMATIC_REENTRY_MARKER = '## AUTOMATIC CONTEXT REFRESH — re-entry (not an architect instruction)';
578
+ // ============================================================================
579
+ // Reporting
580
+ // ============================================================================
581
+ /** Render a result for a human. */
582
+ export function formatSelfRefreshReport(result) {
583
+ const lines = [];
584
+ const order = result.steps.map(s => s.name).join(' → ');
585
+ if (result.outcome === 'dry-run') {
586
+ // NOT "this refresh would proceed" — the rehearsal stops before the
587
+ // reorientation write, Tower scheduling, the challenge rewrite, the clear
588
+ // and the deletion, so it cannot speak for any of them. It establishes that
589
+ // the non-mutating preflight passes, which is genuinely useful and is not
590
+ // the same claim.
591
+ lines.push('Dry run — passed all non-mutating preflight checks.');
592
+ lines.push(' (verification and assembly only; nothing was written or sent)');
593
+ lines.push(` state file: ${result.statePath} (${result.stateBytes} bytes)`);
594
+ lines.push(` steps: ${order}`);
595
+ lines.push('');
596
+ lines.push('Nothing was sent, written or consumed.');
597
+ return lines.join('\n');
598
+ }
599
+ if (result.outcome === 'completed') {
600
+ lines.push('Context refresh complete.');
601
+ lines.push(` state file: ${result.statePath} (${result.stateBytes} bytes)`);
602
+ lines.push(` re-orientation: ${result.reorientPath}`);
603
+ lines.push(` steps: ${order}`);
604
+ lines.push('');
605
+ lines.push('Your next instruction will arrive shortly. It will tell you to run `porch next`.');
606
+ return lines.join('\n');
607
+ }
608
+ lines.push(`Context refresh ABORTED${result.failure ? ` (${result.failure})` : ''}.`);
609
+ lines.push(` reason: ${result.reason}`);
610
+ lines.push(` steps: ${order || '(none)'}`);
611
+ if (result.steps.some(s => s.name === 'clear-attempted')) {
612
+ lines.push('');
613
+ lines.push('A clear WAS attempted and may have landed. Do not assume your context survived.');
614
+ }
615
+ else {
616
+ lines.push('');
617
+ lines.push('No clear was attempted. Your context is intact.');
618
+ }
619
+ return lines.join('\n');
620
+ }
621
+ /**
622
+ * Did this run reach the irreversible step?
623
+ *
624
+ * True for a CONFIRMED clear and for an ATTEMPTED one, because an attempt whose
625
+ * send threw may still have landed. Anything asking "is this builder's context
626
+ * safe?" must treat both as unsafe; a helper that answered only about confirmed
627
+ * clears would be the optimistic reading of an ambiguous event.
628
+ */
629
+ export function didClear(result) {
630
+ return result.steps.some(s => s.name === 'clear' || s.name === 'clear-attempted');
631
+ }
632
+ /** Strictly confirmed: `sendRaw` returned successfully. */
633
+ export function didClearConfirmed(result) {
634
+ return result.steps.some(s => s.name === 'clear');
635
+ }
636
+ //# sourceMappingURL=self.js.map