@cluesmith/codev 3.3.1 → 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.
- package/dashboard-dist/assets/index-D3XaKpZW.js.map +1 -1
- package/dist/agent-farm/cli.d.ts.map +1 -1
- package/dist/agent-farm/cli.js +52 -1
- package/dist/agent-farm/cli.js.map +1 -1
- package/dist/agent-farm/commands/reset/constants.d.ts +118 -0
- package/dist/agent-farm/commands/reset/constants.d.ts.map +1 -1
- package/dist/agent-farm/commands/reset/constants.js +118 -0
- package/dist/agent-farm/commands/reset/constants.js.map +1 -1
- package/dist/agent-farm/commands/reset/context.d.ts +18 -0
- package/dist/agent-farm/commands/reset/context.d.ts.map +1 -1
- package/dist/agent-farm/commands/reset/context.js +41 -0
- package/dist/agent-farm/commands/reset/context.js.map +1 -1
- package/dist/agent-farm/commands/reset/receipt.d.ts.map +1 -1
- package/dist/agent-farm/commands/reset/receipt.js +16 -5
- package/dist/agent-farm/commands/reset/receipt.js.map +1 -1
- package/dist/agent-farm/commands/reset/reorient.d.ts +1 -1
- package/dist/agent-farm/commands/reset/reorient.d.ts.map +1 -1
- package/dist/agent-farm/commands/reset/reorient.js +8 -1
- package/dist/agent-farm/commands/reset/reorient.js.map +1 -1
- package/dist/agent-farm/commands/reset/self.d.ts +335 -0
- package/dist/agent-farm/commands/reset/self.d.ts.map +1 -0
- package/dist/agent-farm/commands/reset/self.js +636 -0
- package/dist/agent-farm/commands/reset/self.js.map +1 -0
- package/dist/agent-farm/commands/reset.d.ts.map +1 -1
- package/dist/agent-farm/commands/reset.js +23 -18
- package/dist/agent-farm/commands/reset.js.map +1 -1
- package/dist/agent-farm/commands/self-refresh.d.ts +60 -0
- package/dist/agent-farm/commands/self-refresh.d.ts.map +1 -0
- package/dist/agent-farm/commands/self-refresh.js +506 -0
- package/dist/agent-farm/commands/self-refresh.js.map +1 -0
- package/dist/agent-farm/commands/send.d.ts.map +1 -1
- package/dist/agent-farm/commands/send.js +27 -2
- package/dist/agent-farm/commands/send.js.map +1 -1
- package/dist/agent-farm/commands/spawn.d.ts +7 -0
- package/dist/agent-farm/commands/spawn.d.ts.map +1 -1
- package/dist/agent-farm/commands/spawn.js +13 -1
- package/dist/agent-farm/commands/spawn.js.map +1 -1
- package/dist/agent-farm/lib/tower-client.d.ts +12 -0
- package/dist/agent-farm/lib/tower-client.d.ts.map +1 -1
- package/dist/agent-farm/lib/tower-client.js +12 -0
- package/dist/agent-farm/lib/tower-client.js.map +1 -1
- package/dist/agent-farm/servers/mailbox-delivery.d.ts +141 -13
- package/dist/agent-farm/servers/mailbox-delivery.d.ts.map +1 -1
- package/dist/agent-farm/servers/mailbox-delivery.js +172 -16
- package/dist/agent-farm/servers/mailbox-delivery.js.map +1 -1
- package/dist/agent-farm/servers/mailbox-wiring.d.ts +23 -1
- package/dist/agent-farm/servers/mailbox-wiring.d.ts.map +1 -1
- package/dist/agent-farm/servers/mailbox-wiring.js +91 -31
- package/dist/agent-farm/servers/mailbox-wiring.js.map +1 -1
- package/dist/agent-farm/servers/message-write.d.ts +58 -18
- package/dist/agent-farm/servers/message-write.d.ts.map +1 -1
- package/dist/agent-farm/servers/message-write.js +73 -28
- package/dist/agent-farm/servers/message-write.js.map +1 -1
- package/dist/agent-farm/servers/render-gate.d.ts +13 -0
- package/dist/agent-farm/servers/render-gate.d.ts.map +1 -1
- package/dist/agent-farm/servers/render-gate.js +21 -0
- package/dist/agent-farm/servers/render-gate.js.map +1 -1
- package/dist/agent-farm/servers/session-submit.d.ts +220 -27
- package/dist/agent-farm/servers/session-submit.d.ts.map +1 -1
- package/dist/agent-farm/servers/session-submit.js +333 -42
- package/dist/agent-farm/servers/session-submit.js.map +1 -1
- package/dist/agent-farm/servers/tower-routes.d.ts.map +1 -1
- package/dist/agent-farm/servers/tower-routes.js +114 -25
- package/dist/agent-farm/servers/tower-routes.js.map +1 -1
- package/dist/agent-farm/servers/tower-tunnel.d.ts.map +1 -1
- package/dist/agent-farm/servers/tower-tunnel.js +14 -0
- package/dist/agent-farm/servers/tower-tunnel.js.map +1 -1
- package/dist/agent-farm/types.d.ts +36 -1
- package/dist/agent-farm/types.d.ts.map +1 -1
- package/dist/agent-farm/utils/message-format.d.ts +60 -4
- package/dist/agent-farm/utils/message-format.d.ts.map +1 -1
- package/dist/agent-farm/utils/message-format.js +115 -22
- package/dist/agent-farm/utils/message-format.js.map +1 -1
- package/dist/agent-farm/utils/server-utils.d.ts +8 -2
- package/dist/agent-farm/utils/server-utils.d.ts.map +1 -1
- package/dist/agent-farm/utils/server-utils.js +19 -2
- package/dist/agent-farm/utils/server-utils.js.map +1 -1
- package/dist/commands/porch/context-refresh.d.ts +181 -0
- package/dist/commands/porch/context-refresh.d.ts.map +1 -0
- package/dist/commands/porch/context-refresh.js +270 -0
- package/dist/commands/porch/context-refresh.js.map +1 -0
- package/dist/commands/porch/index.d.ts +6 -0
- package/dist/commands/porch/index.d.ts.map +1 -1
- package/dist/commands/porch/index.js +46 -0
- package/dist/commands/porch/index.js.map +1 -1
- package/dist/commands/porch/next.d.ts +0 -9
- package/dist/commands/porch/next.d.ts.map +1 -1
- package/dist/commands/porch/next.js +133 -4
- package/dist/commands/porch/next.js.map +1 -1
- package/dist/commands/porch/protocol.d.ts.map +1 -1
- package/dist/commands/porch/protocol.js +102 -0
- package/dist/commands/porch/protocol.js.map +1 -1
- package/dist/commands/porch/types.d.ts +61 -0
- package/dist/commands/porch/types.d.ts.map +1 -1
- package/dist/lib/self-refresh-invocation.d.ts +60 -0
- package/dist/lib/self-refresh-invocation.d.ts.map +1 -0
- package/dist/lib/self-refresh-invocation.js +64 -0
- package/dist/lib/self-refresh-invocation.js.map +1 -0
- package/dist/lib/test-env.d.ts +50 -0
- package/dist/lib/test-env.d.ts.map +1 -1
- package/dist/lib/test-env.js +76 -0
- package/dist/lib/test-env.js.map +1 -1
- package/package.json +4 -4
- package/skeleton/.claude/skills/arch-save/SKILL.md +8 -2
- package/skeleton/.claude/skills/builder-refresh/SKILL.md +138 -0
- package/skeleton/.codex/skills/arch-save/SKILL.md +8 -2
- package/skeleton/.codex/skills/builder-refresh/SKILL.md +138 -0
- package/skeleton/protocol-schema.json +17 -0
- package/skeleton/protocols/air/protocol.json +1 -1
- package/skeleton/protocols/aspir/protocol.json +5 -1
- package/skeleton/protocols/bugfix/protocol.json +1 -1
- package/skeleton/protocols/experiment/protocol.json +1 -1
- package/skeleton/protocols/maintain/protocol.json +1 -1
- package/skeleton/protocols/pir/protocol.json +1 -1
- package/skeleton/protocols/protocol-schema.json +17 -0
- package/skeleton/protocols/research/protocol.json +1 -1
- package/skeleton/protocols/spike/protocol.json +1 -1
- package/skeleton/protocols/spir/protocol.json +5 -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
|