@autopilot-harness/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +8 -0
  3. package/dist/autopilot-ignore.d.ts +23 -0
  4. package/dist/autopilot-ignore.d.ts.map +1 -0
  5. package/dist/autopilot-ignore.js +271 -0
  6. package/dist/autopilot-ignore.js.map +1 -0
  7. package/dist/checklist-md.d.ts +62 -0
  8. package/dist/checklist-md.d.ts.map +1 -0
  9. package/dist/checklist-md.js +217 -0
  10. package/dist/checklist-md.js.map +1 -0
  11. package/dist/code-edit-detector.d.ts +10 -0
  12. package/dist/code-edit-detector.d.ts.map +1 -0
  13. package/dist/code-edit-detector.js +44 -0
  14. package/dist/code-edit-detector.js.map +1 -0
  15. package/dist/i18n-render.d.ts +3 -0
  16. package/dist/i18n-render.d.ts.map +1 -0
  17. package/dist/i18n-render.js +8 -0
  18. package/dist/i18n-render.js.map +1 -0
  19. package/dist/index.d.ts +20 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +20 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/list-tracks.d.ts +29 -0
  24. package/dist/list-tracks.d.ts.map +1 -0
  25. package/dist/list-tracks.js +232 -0
  26. package/dist/list-tracks.js.map +1 -0
  27. package/dist/migrate.d.ts +19 -0
  28. package/dist/migrate.d.ts.map +1 -0
  29. package/dist/migrate.js +101 -0
  30. package/dist/migrate.js.map +1 -0
  31. package/dist/phase-actions.d.ts +35 -0
  32. package/dist/phase-actions.d.ts.map +1 -0
  33. package/dist/phase-actions.js +480 -0
  34. package/dist/phase-actions.js.map +1 -0
  35. package/dist/project-config.d.ts +29 -0
  36. package/dist/project-config.d.ts.map +1 -0
  37. package/dist/project-config.js +301 -0
  38. package/dist/project-config.js.map +1 -0
  39. package/dist/project-path.d.ts +26 -0
  40. package/dist/project-path.d.ts.map +1 -0
  41. package/dist/project-path.js +104 -0
  42. package/dist/project-path.js.map +1 -0
  43. package/dist/review-engine.d.ts +263 -0
  44. package/dist/review-engine.d.ts.map +1 -0
  45. package/dist/review-engine.js +2273 -0
  46. package/dist/review-engine.js.map +1 -0
  47. package/dist/review-i18n.d.ts +29 -0
  48. package/dist/review-i18n.d.ts.map +1 -0
  49. package/dist/review-i18n.js +54 -0
  50. package/dist/review-i18n.js.map +1 -0
  51. package/dist/review-lenses.d.ts +13 -0
  52. package/dist/review-lenses.d.ts.map +1 -0
  53. package/dist/review-lenses.js +43 -0
  54. package/dist/review-lenses.js.map +1 -0
  55. package/dist/review-runtime.d.ts +12 -0
  56. package/dist/review-runtime.d.ts.map +1 -0
  57. package/dist/review-runtime.js +37 -0
  58. package/dist/review-runtime.js.map +1 -0
  59. package/dist/review-scope.d.ts +9 -0
  60. package/dist/review-scope.d.ts.map +1 -0
  61. package/dist/review-scope.js +92 -0
  62. package/dist/review-scope.js.map +1 -0
  63. package/dist/sqlite.d.ts +15 -0
  64. package/dist/sqlite.d.ts.map +1 -0
  65. package/dist/sqlite.js +38 -0
  66. package/dist/sqlite.js.map +1 -0
  67. package/dist/state-store.d.ts +277 -0
  68. package/dist/state-store.d.ts.map +1 -0
  69. package/dist/state-store.js +1091 -0
  70. package/dist/state-store.js.map +1 -0
  71. package/dist/track-slug.d.ts +4 -0
  72. package/dist/track-slug.d.ts.map +1 -0
  73. package/dist/track-slug.js +18 -0
  74. package/dist/track-slug.js.map +1 -0
  75. package/dist/transcript-followup.d.ts +59 -0
  76. package/dist/transcript-followup.d.ts.map +1 -0
  77. package/dist/transcript-followup.js +310 -0
  78. package/dist/transcript-followup.js.map +1 -0
  79. package/dist/trigger-parser.d.ts +54 -0
  80. package/dist/trigger-parser.d.ts.map +1 -0
  81. package/dist/trigger-parser.js +250 -0
  82. package/dist/trigger-parser.js.map +1 -0
  83. package/dist/verify-report.d.ts +52 -0
  84. package/dist/verify-report.d.ts.map +1 -0
  85. package/dist/verify-report.js +195 -0
  86. package/dist/verify-report.js.map +1 -0
  87. package/package.json +41 -0
@@ -0,0 +1,2273 @@
1
+ import { countUnchecked, effectiveReviewingItemId, firstUnchecked, parseAdvanceNextItemId, parseChecklist, resolveAdvanceTargets, secondUnchecked, } from "./checklist-md.js";
2
+ import { isRealpathInsideProject, normalizeProjectRoot } from "./project-path.js";
3
+ import { getLens } from "./review-lenses.js";
4
+ import { ensureAmbientReviewSession, isChecklistExecuting, sessionReviewRunnable, } from "./review-scope.js";
5
+ import { sanitizeSessionDisplayText, } from "./state-store.js";
6
+ import { isSafeTrackSlug } from "./track-slug.js";
7
+ import { automationFollowupPresent, BRIEFLY_PREFIX, followupInFlight, harnessTipBeforeTrailingTurnEnded, inFlightUserQuery, inRecoverDebounceWindow, latestUnresolvedTurnEndedErrorIndex, PENDING_REDELIVER_COOLDOWN_MS, pendingRedeliverAllowed, RECOVER_DEBOUNCE_MS, sleepSyncMs, readTranscriptTail, transcriptTipIsAssistant, userQueryText, } from "./transcript-followup.js";
8
+ import { firstSubstantiveLine, isRecoverFollowupMessage, isRecoverOrStuckFollowupMessage, substantivePromptBody, } from "./trigger-parser.js";
9
+ import { defaultVerifyReportPath, evaluateVerifyReport, hasNoCodeCompletionEvidence, } from "./verify-report.js";
10
+ function defaultRender(kind, vars) {
11
+ switch (kind) {
12
+ case "review.fix":
13
+ return (`Review fix round ${vars.round} (no hard cap; confirm needs ${vars.total} consecutive no-edit rounds). ` +
14
+ `Code changed this turn. Defect-first self-review and fix now: ` +
15
+ `1) when using git diff / git status, skip paths matching .autopilotignore, and skip untracked paths ignored by .gitignore; review only the remaining paths; ` +
16
+ `2) check correctness, null/boundaries, concurrency, security, regression, missing tests; ` +
17
+ `3) CRITICAL/HIGH must fix, MEDIUM preferably; ` +
18
+ `4) run relevant tests; ` +
19
+ `5) briefly state what you reviewed and changed (or "self-review clean"). ` +
20
+ `Do not commit/push. If no further code changes, next stop enters multi-lens confirm.`);
21
+ case "review.confirm":
22
+ return (`Review confirm ${vars.n}/${vars.total} (session round ${vars.sessionRound}; consecutive no-edit confirms, counted on the fix-round counter). ` +
23
+ `Lens 【${vars.lensTitle}】 (multi-lens confirm, not the same checklist again). ${vars.lensFocus} ` +
24
+ `Previous turn had no further code edits. Recheck under this lens only: ` +
25
+ `1) git diff / git status — no new edits vs prior turn (or only already-reviewed edits); likewise skip paths matching .autopilotignore and untracked paths ignored by .gitignore; judge only from remaining paths; ` +
26
+ `2) dig into this lens only; ban vague "fully rechecked, all good"; ` +
27
+ `3) CRITICAL/HIGH under this lens must fix; MEDIUM preferably; ` +
28
+ `4) if you edit, fix and run related tests; ` +
29
+ `5) close with: "Lens (${vars.lensTitle}): self-review clean" or a short list of fixes; if clean, do not edit further. ` +
30
+ `Do not commit/push.`);
31
+ case "review.confirm_final":
32
+ return (`Review confirm ${vars.n}/${vars.total} (session round ${vars.sessionRound}; consecutive no-edit confirms, counted on the fix-round counter). ` +
33
+ `Lens 【${vars.lensTitle}】 (multi-lens confirm, not the same checklist again). ${vars.lensFocus} ` +
34
+ `Previous turn had no further code edits. Recheck under this lens only: ` +
35
+ `1) git diff / git status — no new edits vs prior turn (or only already-reviewed edits); likewise skip paths matching .autopilotignore and untracked paths ignored by .gitignore; judge only from remaining paths; ` +
36
+ `2) dig into this lens only; ban vague "fully rechecked, all good"; ` +
37
+ `3) read-only: record CRITICAL/HIGH/missing tests — do not change code, add tests, or commit; if you already edited, accept returning to a fix round; never commit this turn; ` +
38
+ `4) do not run commands that mutate the repo; ` +
39
+ `5) close with: "Lens (${vars.lensTitle}): self-review clean" or list issues (no fixes); if clean, do not edit further. ` +
40
+ `Do not commit/push. Handoff (commit) and next checklist item are handled by Advance/Done after the chain — not this turn.`);
41
+ case "advance":
42
+ return (`Advance checklist: confirm chain passed cleanly (confirm rounds do not commit). ` +
43
+ `First mark the completed current item ${vars.currentId ?? ""} [x] in checklist.md ` +
44
+ `(do not mark the next item yet). ` +
45
+ `Never mark an item [x] while you are still implementing it — only this Advance/Done followup checks off the completed item. ` +
46
+ `Then, if the working tree still has uncommitted changes for this item ` +
47
+ `(including checklist.md when plans/ is committed), local conventional commit only: ` +
48
+ `git status/diff → stage only this checklist item's paths; never git add -A, never stage .env/secrets/.autopilot runtime; ` +
49
+ `one conventional commit; no push/--no-verify/amend/force unless the user explicitly asks. ` +
50
+ `If already clean after marking, skip commit. Then implement next: ${vars.nextId ?? ""} — ${vars.nextTitle ?? ""}.`);
51
+ case "done":
52
+ return (`All checklist items done. Confirm chain passed (confirm rounds do not commit). ` +
53
+ `Mark the last item [x]. If the working tree still has uncommitted changes for this item ` +
54
+ `(including checklist.md when plans/ is committed), local conventional commit only ` +
55
+ `(never stage .env/secrets/.autopilot runtime; no push unless the user asks); if clean, just confirm briefly. Phase is done.`);
56
+ case "recover":
57
+ return `Recover: the previous turn ended with an error. Continue the current task.`;
58
+ case "recover_planning":
59
+ return `Recover: the previous turn ended with an error. Continue planning; do not RUN or write product code.`;
60
+ case "recover_ambient":
61
+ return `Recover: the previous turn ended with an error. Continue your current work; Autopilot RUN is not active.`;
62
+ case "review_complete":
63
+ return (`Review complete. All ${vars.total ?? 5} confirm rounds passed; the review chain has ended. ` +
64
+ `If the working tree still has uncommitted changes from this session, ` +
65
+ `local commit only per the safe checklist (never stage .env/secrets/.autopilot runtime; ` +
66
+ `no push unless the user asks); if clean, briefly confirm only.`);
67
+ case "stuck":
68
+ return `Stuck: no progress for several stops. Change strategy or send Autopilot RESUME after fixing.`;
69
+ case "verify_fix":
70
+ return `Verify failed (${vars.reason ?? "unknown"}). Fix verify commands and rewrite verify-last.json; do not advance.`;
71
+ default:
72
+ return "";
73
+ }
74
+ }
75
+ export class ReviewEngine {
76
+ store;
77
+ config;
78
+ constructor(store, config) {
79
+ this.store = store;
80
+ this.config = config;
81
+ }
82
+ render(kind, vars) {
83
+ return (this.config.renderFollowup ?? defaultRender)(kind, vars);
84
+ }
85
+ lens(roundIndex) {
86
+ const rounds = this.config.confirmRounds;
87
+ return (this.config.resolveLens ?? getLens)(roundIndex, rounds);
88
+ }
89
+ /**
90
+ * FS trust root: StateStore is authoritative.
91
+ * config.projectRoot must not widen the boundary (mismatched/evil config).
92
+ */
93
+ trustedProjectRoot() {
94
+ return (normalizeProjectRoot(this.store.projectRoot) ??
95
+ normalizeProjectRoot(this.config.projectRoot));
96
+ }
97
+ /**
98
+ * Parse session checklist only when realpath stays under the project root.
99
+ * O_NOFOLLOW alone cannot stop intermediate directory symlink escapes or a
100
+ * poisoned absolute checklist_path in the session row.
101
+ */
102
+ parseSessionChecklist(session) {
103
+ const checklistPath = session.checklist_path;
104
+ if (!checklistPath)
105
+ return null;
106
+ // Trust store root only — never let config or session.project_root
107
+ // redefine the containment boundary.
108
+ const root = this.trustedProjectRoot();
109
+ if (!root || !isRealpathInsideProject(root, checklistPath)) {
110
+ return null;
111
+ }
112
+ try {
113
+ const cl = parseChecklist(checklistPath, { projectRoot: root });
114
+ return {
115
+ unchecked: countUnchecked(cl),
116
+ currentItem: firstUnchecked(cl),
117
+ followingItem: secondUnchecked(cl),
118
+ checklist: cl,
119
+ };
120
+ }
121
+ catch {
122
+ return null;
123
+ }
124
+ }
125
+ reviewChainResetFields() {
126
+ return {
127
+ confirm_left: null,
128
+ fix_round: 0,
129
+ code_edited: 0,
130
+ item_confirm_complete: 0,
131
+ reviewing_item_id: null,
132
+ };
133
+ }
134
+ renderAdvanceOrDone(targets) {
135
+ const isAdvance = targets.next != null;
136
+ const message = isAdvance
137
+ ? this.render("advance", {
138
+ currentId: targets.current?.id ?? "",
139
+ currentTitle: targets.current?.title ?? "",
140
+ nextId: targets.next?.id ?? "",
141
+ nextTitle: targets.next?.title ?? "",
142
+ })
143
+ : this.render("done", {});
144
+ return {
145
+ kind: isAdvance ? "advance" : "done",
146
+ message,
147
+ loop: true,
148
+ };
149
+ }
150
+ /** Resolve sticky reviewing id from chain, or fall back to hint / firstUnchecked. */
151
+ resolveReviewingItemId(chain, checklist, hintItemId) {
152
+ const sticky = chain?.reviewing_item_id?.trim() || "";
153
+ if (sticky) {
154
+ if (!checklist)
155
+ return sticky;
156
+ const usable = effectiveReviewingItemId(checklist, sticky);
157
+ if (usable)
158
+ return usable;
159
+ // Sticky points past an earlier open item (advance seeded next too early).
160
+ }
161
+ const hint = (hintItemId ?? "").trim();
162
+ if (hint) {
163
+ if (!checklist)
164
+ return hint;
165
+ const usableHint = effectiveReviewingItemId(checklist, hint);
166
+ if (usableHint)
167
+ return usableHint;
168
+ }
169
+ if (!checklist)
170
+ return null;
171
+ return firstUnchecked(checklist)?.id ?? null;
172
+ }
173
+ /** E1: afterFileEdit product code → code_edited=1 (+ sticky reviewing_item_id). */
174
+ onCodeEdited(conversationId) {
175
+ // Checklist FS read stays outside the write lock; only pending/sticky arming
176
+ // uses the live chain row under the lock (neutralize/advance TOCTOU).
177
+ const session = this.store.getSession(conversationId);
178
+ const parsed = session ? this.parseSessionChecklist(session) : null;
179
+ this.store.markCodeEdited(conversationId, (chain) => {
180
+ const fromPending = parseAdvanceNextItemId(chain.pending_followup);
181
+ if (parsed?.checklist) {
182
+ if (fromPending &&
183
+ effectiveReviewingItemId(parsed.checklist, fromPending)) {
184
+ return fromPending;
185
+ }
186
+ return parsed.currentItem?.id ?? null;
187
+ }
188
+ return fromPending;
189
+ });
190
+ }
191
+ handleStop(input) {
192
+ // Corrupt/legacy ids must not reach error-stop upserts (or ensure).
193
+ if (!this.store.isConversationIdOk(input.conversationId)) {
194
+ return null;
195
+ }
196
+ try {
197
+ let session = this.store.getSession(input.conversationId);
198
+ // Host sometimes writes turn_ended status=error into the transcript but
199
+ // delivers stop as completed (e.g. usage-limit ActionRequiredError). Treat
200
+ // that orphan transcript error like a real error stop for recover — unless
201
+ // a recover tip is already unanswered (return null; do not bump error_count).
202
+ // One transcript snapshot for classify — avoids split-read TOCTOU.
203
+ const orphanClass = input.status === "completed"
204
+ ? this.classifyCompletedOrphan(input.transcriptPath)
205
+ : "none";
206
+ // User Stop / host abort must not bootstrap ambient sessions or recover.
207
+ // Genuine error stops — and completed stops with orphan transcript errors —
208
+ // may ensure + inject recover.
209
+ if (!session &&
210
+ (input.status === "error" || orphanClass === "salvage") &&
211
+ this.config.reviewScope === "project") {
212
+ ensureAmbientReviewSession(this.store, input.conversationId, this.config.projectRoot, this.config.reviewScope);
213
+ session = this.store.getSession(input.conversationId);
214
+ }
215
+ if (!session)
216
+ return null;
217
+ if (input.status === "aborted") {
218
+ return this.handleAbortedStop(session);
219
+ }
220
+ if (input.status === "error") {
221
+ return this.handleErrorStop(session, input);
222
+ }
223
+ // Orphan transcript error on completed → same path as status=error.
224
+ // Unanswered recover tip: return null (do not re-enter handleErrorStop).
225
+ // Non-recoverable sessions skip salvage; completed below is gated by
226
+ // sessionReviewRunnable (paused/disarmed usually return null before
227
+ // pending redelivery).
228
+ if (orphanClass === "skip_recover_tip") {
229
+ return null;
230
+ }
231
+ if (orphanClass === "salvage" &&
232
+ this.sessionErrorRecoverable(session)) {
233
+ return this.handleErrorStop(session, {
234
+ ...input,
235
+ status: "error",
236
+ });
237
+ }
238
+ // Precondition: review chain may run (executing_only → RUN; project → ambient/planning/executing)
239
+ if (!sessionReviewRunnable(session, this.config.reviewScope)) {
240
+ return null;
241
+ }
242
+ const chain = this.store.ensureReviewChain(input.conversationId);
243
+ this.maybeResetErrorCountOnItemChange(session);
244
+ const transcriptPath = input.transcriptPath?.trim() || undefined;
245
+ const events = transcriptPath ? readTranscriptTail(transcriptPath) : [];
246
+ // Code-edit fix wins over pending redelivery (marker-before-pending).
247
+ if (chain.code_edited === 1) {
248
+ return this.e2Fix(session, chain);
249
+ }
250
+ // Pending redelivery / in-flight: never advance confirm_left while prior undelivered.
251
+ const redelivered = this.tryRedeliverPending(session.conversation_id, chain, events, transcriptPath);
252
+ if (redelivered)
253
+ return redelivered;
254
+ if (events.length > 0 && followupInFlight(events)) {
255
+ return null;
256
+ }
257
+ if (this.pendingBlocksAdvance(session.conversation_id, chain, events, transcriptPath)) {
258
+ return null;
259
+ }
260
+ // Order: E4 → E5 → E3 → E0 (E2 handled above)
261
+ if (chain.confirm_left !== null && chain.confirm_left > 0) {
262
+ return this.e4Confirm(session, chain);
263
+ }
264
+ if (chain.confirm_left === 0 ||
265
+ (chain.item_confirm_complete === 1 && chain.confirm_left === null)) {
266
+ return this.e5Gate(session, chain);
267
+ }
268
+ // loopCount alone must not re-arm after a hard halt neutralize (fix_round
269
+ // cleared). Keep E8 recovery for checklist executing only: after
270
+ // clearChainPending mid-fix, fix_round>0 + loopCount still reaches E3.
271
+ // Do NOT use that path for ambient/planning — leftover fix_round after an
272
+ // error recover (armChain=false) would otherwise open a phantom confirm
273
+ // chain with no new code_edited (seen with project-scope ambient).
274
+ const inChain = chain.chain_pending === 1 ||
275
+ (input.loopCount > 0 &&
276
+ chain.fix_round > 0 &&
277
+ isChecklistExecuting(session));
278
+ if (chain.confirm_left === null &&
279
+ chain.item_confirm_complete === 0 &&
280
+ inChain) {
281
+ return this.e3ArmConfirm(session, chain);
282
+ }
283
+ // E0': no product-code edit — checklist continue via verify / soft evidence.
284
+ return this.e0NoCodeContinue(session, chain);
285
+ }
286
+ catch (err) {
287
+ // Purge races / late id throws from upsert|updateReviewChain must not crash the hook.
288
+ const msg = err instanceof Error ? err.message : String(err);
289
+ if (msg.includes("No session for conversation") ||
290
+ msg.includes("Invalid conversation id")) {
291
+ return null;
292
+ }
293
+ throw err;
294
+ }
295
+ }
296
+ pendingBlocksAdvance(conversationId, chain, events, transcriptPath) {
297
+ const pending = chain.pending_followup?.trim();
298
+ if (!pending)
299
+ return false;
300
+ if (events.length > 0 && automationFollowupPresent(events, pending)) {
301
+ try {
302
+ // Only clear the snapshot needle — a concurrent replace must survive.
303
+ const cleared = this.store.clearPendingFollowupIf(conversationId, (m) => m.trim() === pending);
304
+ if (!cleared) {
305
+ // Live pending was replaced (e.g. error-recover claim). Keep blocking
306
+ // so stale confirm_left cannot advance and overwrite the new pending.
307
+ const live = this.store.getReviewChain(conversationId)?.pending_followup?.trim() ??
308
+ "";
309
+ if (live &&
310
+ !(events.length > 0 && automationFollowupPresent(events, live))) {
311
+ return true;
312
+ }
313
+ }
314
+ }
315
+ catch {
316
+ /* already delivered — clearing stamp is best-effort */
317
+ }
318
+ return false;
319
+ }
320
+ // No transcript_path → unit tests / older ports: fail-open.
321
+ if (!transcriptPath)
322
+ return false;
323
+ // Path provided: undelivered pending always blocks (even if transcript empty).
324
+ return true;
325
+ }
326
+ tryRedeliverPending(conversationId, chain, events, transcriptPath) {
327
+ const pending = chain.pending_followup?.trim();
328
+ if (!pending)
329
+ return null;
330
+ // Without transcript_path, skip redelivery (avoid duplicate spam in unit tests).
331
+ if (!transcriptPath)
332
+ return null;
333
+ // Recover claim arms pending_redeliver_at for ~debounceMs so completed-stop
334
+ // cannot inject during claim sleep (would race CAS for a second recover).
335
+ // CAS emit clears that hold → host-drop redelivery is immediate again.
336
+ if (events.length > 0 && automationFollowupPresent(events, pending)) {
337
+ try {
338
+ const cleared = this.store.clearPendingFollowupIf(conversationId, (m) => m.trim() === pending);
339
+ // Snapshot looked delivered but live was replaced — fall through to the
340
+ // locked live-row path instead of dropping the new pending.
341
+ if (cleared)
342
+ return null;
343
+ }
344
+ catch {
345
+ /* already delivered — clearing stamp is best-effort */
346
+ }
347
+ }
348
+ // Apply even after a delivered-snapshot miss (replaced live pending): do not
349
+ // inject while another harness followup still owns the transcript tip.
350
+ if (events.length > 0 && followupInFlight(events)) {
351
+ return null;
352
+ }
353
+ // Cooldown + touch under one write lock so we never honor a stale
354
+ // chain.pending_redeliver_at snapshot from before a concurrent recover claim.
355
+ try {
356
+ return this.store.exclusiveWrite(() => {
357
+ if (!this.sessionRunnable(conversationId)) {
358
+ return { commit: false, value: null };
359
+ }
360
+ const liveRow = this.store.getReviewChain(conversationId);
361
+ const livePending = liveRow?.pending_followup?.trim() ?? "";
362
+ if (!livePending) {
363
+ return { commit: false, value: null };
364
+ }
365
+ if (!pendingRedeliverAllowed(liveRow?.pending_redeliver_at ?? null)) {
366
+ return { commit: false, value: null };
367
+ }
368
+ // Live row may differ from the outer snapshot — do not re-inject a tip
369
+ // already present on the transcript.
370
+ if (events.length > 0 &&
371
+ automationFollowupPresent(events, livePending)) {
372
+ this.store.clearPendingFollowupIf(conversationId, (m) => m.trim() === livePending);
373
+ return { commit: true, value: null };
374
+ }
375
+ try {
376
+ this.store.touchPendingRedeliver(conversationId);
377
+ }
378
+ catch {
379
+ // Still attempt redeliver — cooldown stamp is best-effort.
380
+ }
381
+ // Commit after touch: a nested clear/neutralize (or concurrent-looking
382
+ // mock) must not be rolled back by commit:false, or the outer stop
383
+ // would keep a stale confirm_left and skip-a-lens / re-emit.
384
+ if (!this.sessionRunnable(conversationId)) {
385
+ return { commit: true, value: null };
386
+ }
387
+ const after = this.store.getReviewChain(conversationId)?.pending_followup?.trim() ??
388
+ "";
389
+ if (!after) {
390
+ return { commit: true, value: null };
391
+ }
392
+ if (events.length > 0 && automationFollowupPresent(events, after)) {
393
+ this.store.clearPendingFollowupIf(conversationId, (m) => m.trim() === after);
394
+ return { commit: true, value: null };
395
+ }
396
+ return {
397
+ commit: true,
398
+ value: {
399
+ kind: this.inferPendingKind(after),
400
+ message: after,
401
+ loop: true,
402
+ meta: { redeliver: true },
403
+ },
404
+ };
405
+ });
406
+ }
407
+ catch {
408
+ return null;
409
+ }
410
+ }
411
+ inferPendingKind(message) {
412
+ const m = message.trim();
413
+ if (this.isFixFollowupMessage(m))
414
+ return "review.fix";
415
+ if ((m.startsWith("Review confirm") || m.startsWith("自审确认")) &&
416
+ (m.includes(`/${this.config.confirmRounds}`) || m.includes("终审") || m.includes("Read-only") || m.includes("只读"))) {
417
+ // Prefer final when n===total appears as N/N and N is last — heuristic via confirmRounds.
418
+ const finalRe = new RegExp(`(?:Review confirm|自审确认)\\s*${this.config.confirmRounds}/${this.config.confirmRounds}`);
419
+ if (finalRe.test(m) || m.includes("read-only") || m.includes("Read-only") || m.includes("只读终审") || m.includes("本轮只读")) {
420
+ return "review.confirm_final";
421
+ }
422
+ }
423
+ if (m.startsWith("Review confirm") || m.startsWith("自审确认"))
424
+ return "review.confirm";
425
+ if (m.startsWith("Advance") || m.startsWith("推进"))
426
+ return "advance";
427
+ if (m.startsWith("Review complete") || m.startsWith("自审完成"))
428
+ return "review_complete";
429
+ if (m.startsWith("All checklist") || m.startsWith("全部完成"))
430
+ return "done";
431
+ if (m.startsWith("Recover") || m.startsWith("恢复"))
432
+ return "recover";
433
+ if (m.startsWith("Stuck") || m.startsWith("卡住"))
434
+ return "stuck";
435
+ if (m.startsWith("Verify failed") || m.startsWith("校验失败"))
436
+ return "verify_fix";
437
+ return "review.confirm";
438
+ }
439
+ emit(conversationId, action) {
440
+ try {
441
+ const armChain = action.kind !== "recover" &&
442
+ action.kind !== "recover_planning" &&
443
+ action.kind !== "recover_ambient";
444
+ this.store.savePendingFollowup(conversationId, action.message, { armChain });
445
+ }
446
+ catch {
447
+ // Hook can still deliver once; pending redelivery is best-effort.
448
+ }
449
+ return action;
450
+ }
451
+ /**
452
+ * Cursor Stop button (and similar host interrupts) arrive as status=aborted.
453
+ * Do not inject recover — that fights the user and loops with loop_limit:null.
454
+ * Drop recover/stuck pending so transcript revert cannot redeliver them later.
455
+ * Clear sticky code_edited so Stop→revert→resend cannot open a phantom fix
456
+ * chain on the next completed stop (disk may already be clean).
457
+ * Leave fix/confirm pending alone (delivery retry still valid if inject raced).
458
+ * Both clears share one exclusiveWrite so a mid-abort failure cannot leave
459
+ * recover gone while code_edited sticky (or the reverse).
460
+ */
461
+ handleAbortedStop(session) {
462
+ try {
463
+ const cid = session.conversation_id;
464
+ this.store.exclusiveWrite(() => {
465
+ this.store.clearPendingFollowupIf(cid, isRecoverOrStuckFollowupMessage);
466
+ this.store.clearCodeEdited(cid);
467
+ return { commit: true, value: undefined };
468
+ });
469
+ }
470
+ catch {
471
+ /* best-effort */
472
+ }
473
+ return null;
474
+ }
475
+ handleErrorStop(session, input) {
476
+ const nextCount = session.error_count + 1;
477
+ const maxErrors = this.config.maxErrorsBeforePause;
478
+ // maxErrors <= 0 → unlimited: never pause for repeated_errors
479
+ const shouldPause = maxErrors > 0 && nextCount >= maxErrors;
480
+ try {
481
+ if (shouldPause) {
482
+ this.store.upsertSession({
483
+ conversation_id: session.conversation_id,
484
+ project_root: session.project_root,
485
+ code_root: session.code_root,
486
+ error_count: nextCount,
487
+ last_error: input.status,
488
+ paused: 1,
489
+ paused_reason: "repeated_errors",
490
+ armed: 0,
491
+ });
492
+ return null;
493
+ }
494
+ this.store.upsertSession({
495
+ conversation_id: session.conversation_id,
496
+ project_root: session.project_root,
497
+ code_root: session.code_root,
498
+ error_count: nextCount,
499
+ last_error: input.status,
500
+ });
501
+ }
502
+ catch {
503
+ // Count/pause bookkeeping is best-effort.
504
+ if (shouldPause) {
505
+ // Pause row write failed but threshold was hit — do not silently keep
506
+ // looping armed; surface stuck so the hook can halt the agent.
507
+ if (session.armed === 1 &&
508
+ session.phase === "executing" &&
509
+ session.paused === 0) {
510
+ // One-shot halt text — do NOT savePendingFollowup (emit): a later
511
+ // redeliver path always uses loop:true and would spin while still armed.
512
+ // Prefer one IMMEDIATE txn for pause+neutralize+disarm (no partial dirty
513
+ // state). Do NOT catch per-step inside the txn — a failed statement
514
+ // poisons SQLite's open transaction. On any failure, ROLLBACK then
515
+ // fall back to independent best-effort writes.
516
+ try {
517
+ this.store.exclusiveWrite(() => {
518
+ this.store.pauseSessionForRepeatedErrors(session.conversation_id, nextCount, input.status);
519
+ this.store.neutralizeReviewChain(session.conversation_id);
520
+ this.store.disarmSession(session.conversation_id);
521
+ return { commit: true, value: undefined };
522
+ });
523
+ }
524
+ catch {
525
+ try {
526
+ this.store.pauseSessionForRepeatedErrors(session.conversation_id, nextCount, input.status);
527
+ }
528
+ catch {
529
+ /* best-effort */
530
+ }
531
+ try {
532
+ this.store.neutralizeReviewChain(session.conversation_id);
533
+ }
534
+ catch {
535
+ /* best-effort */
536
+ }
537
+ try {
538
+ this.store.disarmSession(session.conversation_id);
539
+ }
540
+ catch {
541
+ /* best-effort */
542
+ }
543
+ }
544
+ return {
545
+ kind: "stuck",
546
+ message: this.render("stuck", {}),
547
+ loop: false,
548
+ };
549
+ }
550
+ return null;
551
+ }
552
+ }
553
+ // Re-read: concurrent halt may have paused after our snapshot was taken.
554
+ const fresh = this.store.getSession(session.conversation_id);
555
+ if (fresh && this.sessionErrorRecoverable(fresh)) {
556
+ return this.debouncedErrorRecover(fresh, input);
557
+ }
558
+ return null;
559
+ }
560
+ resolveRecoverDebounceMs() {
561
+ const n = this.config.recoverDebounceMs;
562
+ if (n === undefined)
563
+ return RECOVER_DEBOUNCE_MS;
564
+ if (!Number.isFinite(n) || n < 0)
565
+ return RECOVER_DEBOUNCE_MS;
566
+ return Math.min(Math.floor(n), 60_000);
567
+ }
568
+ sleepRecoverDebounce(ms) {
569
+ try {
570
+ (this.config.sleepSync ?? sleepSyncMs)(ms);
571
+ }
572
+ catch {
573
+ // Sleep is best-effort; claim/coalesce still hold. Always continue to finish.
574
+ }
575
+ }
576
+ /**
577
+ * Error recover: claim under exclusiveWrite → sleep debounce → emit at most
578
+ * once per window. Concurrent error stops in the same window coalesce to null.
579
+ * Outside the window, if still dead, redeliver once (new window).
580
+ * Emit ownership is CAS'd on `pending_followup_at` so claimer+redeliver
581
+ * cannot both return followup_message after overlapping sleeps.
582
+ */
583
+ debouncedErrorRecover(session, input) {
584
+ const recoverKind = this.recoverKindForPhase(session.phase);
585
+ const message = this.render(recoverKind, {}).trim();
586
+ // Blank/NUL must not claim: ambient soft-reset would wipe the chain with
587
+ // nothing redeliverable (savePendingFollowup no-ops on empty/NUL).
588
+ if (!message || message.includes("\0")) {
589
+ return null;
590
+ }
591
+ const action = {
592
+ kind: "recover",
593
+ message,
594
+ loop: true,
595
+ };
596
+ const cid = session.conversation_id;
597
+ const ambient = !isChecklistExecuting(session);
598
+ const debounceMs = this.resolveRecoverDebounceMs();
599
+ const transcriptPath = input.transcriptPath?.trim() || undefined;
600
+ // Genuine error stop: always attempt recover claim. A dead non-recover
601
+ // harness tip (fix / confirm / Briefly) at claim time must still inject —
602
+ // suppressing left the session stuck with no followup. Recover tip falls
603
+ // through so coalesce/CAS can dedupe (finish suppresses double-inject when
604
+ // recover is already the unanswered tip).
605
+ let recoverAnsweredAtClaim = false;
606
+ /**
607
+ * Unanswered non-recover harness tip text at claim time (if any). Finish
608
+ * may emit over the *same* dead tip; a *different* harness tip that landed
609
+ * during sleep must not get a stacked recover inject.
610
+ */
611
+ let deadHarnessTipAtClaim = null;
612
+ /** Claim-time tip was a fix followup (re-arm even if pending was cleared). */
613
+ let fixTipAtClaim = false;
614
+ if (transcriptPath) {
615
+ const tipEvents = readTranscriptTail(transcriptPath);
616
+ // Peek past trailing turn_ended so a killed fix/confirm tip still counts
617
+ // for resumeFix / tip-swap; inFlightUserQuery correctly returns null there.
618
+ const inflightAtClaim = harnessTipBeforeTrailingTurnEnded(tipEvents);
619
+ if (inflightAtClaim &&
620
+ !isRecoverFollowupMessage(inflightAtClaim)) {
621
+ deadHarnessTipAtClaim = inflightAtClaim;
622
+ if (this.isFixFollowupMessage(inflightAtClaim)) {
623
+ fixTipAtClaim = true;
624
+ }
625
+ }
626
+ // Snapshot before claim: historical Recover+assistant must not be treated
627
+ // as "became alive during sleep" for a brand-new error (same template text).
628
+ recoverAnsweredAtClaim =
629
+ tipEvents.length > 0 &&
630
+ transcriptTipIsAssistant(tipEvents) &&
631
+ automationFollowupPresent(tipEvents, message);
632
+ }
633
+ const claim = this.tryClaimErrorRecoverWindow(cid, message, debounceMs, ambient, recoverAnsweredAtClaim, fixTipAtClaim);
634
+ if (claim.role === "coalesced") {
635
+ return null;
636
+ }
637
+ if (claim.role === "failed") {
638
+ // Claim txn failed. Compensate like legacy handleErrorStop, but never wipe
639
+ // an existing recover pending (unlocked neutralize would race a claimer).
640
+ return this.compensateFailedErrorRecoverClaim(cid, message, action, ambient);
641
+ }
642
+ this.sleepRecoverDebounce(debounceMs);
643
+ return this.finishErrorRecoverAfterDebounce(cid, message, transcriptPath, claim.stamp, recoverAnsweredAtClaim, deadHarnessTipAtClaim);
644
+ }
645
+ /**
646
+ * Claim the recover inject slot for this error storm.
647
+ * - coalesced: another stop already owns the window → do not emit
648
+ * - claimer/redeliver: pending written; `stamp` is pending_followup_at for CAS emit
649
+ * - failed: exclusiveWrite threw
650
+ */
651
+ tryClaimErrorRecoverWindow(conversationId, message, debounceMs, ambient, recoverAnsweredAtClaim = false, fixTipAtClaim = false) {
652
+ try {
653
+ return this.store.exclusiveWrite(() => {
654
+ const session = this.store.getSession(conversationId);
655
+ if (!session || !this.sessionErrorRecoverable(session)) {
656
+ return { commit: false, value: { role: "failed" } };
657
+ }
658
+ this.store.ensureReviewChain(conversationId);
659
+ const chain = this.store.getReviewChain(conversationId);
660
+ let pending = chain?.pending_followup?.trim() ?? "";
661
+ const inWindow = isRecoverFollowupMessage(pending) &&
662
+ inRecoverDebounceWindow(chain?.pending_followup_at, debounceMs);
663
+ // Stale recover pending after the agent already answered that recover:
664
+ // drop only when outside the window, then fall through to fresh-claim
665
+ // for this new error. Never wipe an in-window claimer, and never
666
+ // coalesce-return after the drop (that swallowed the new storm).
667
+ if (recoverAnsweredAtClaim &&
668
+ isRecoverFollowupMessage(pending) &&
669
+ !inWindow) {
670
+ this.store.clearPendingFollowupIf(conversationId, (m) => isRecoverFollowupMessage(m));
671
+ pending = "";
672
+ }
673
+ if (inWindow) {
674
+ return { commit: true, value: { role: "coalesced" } };
675
+ }
676
+ if (isRecoverFollowupMessage(pending)) {
677
+ // Outside window, still dead — refresh stamp so peers coalesce.
678
+ this.store.savePendingFollowup(conversationId, message, {
679
+ armChain: false,
680
+ });
681
+ const stamp = this.store.getReviewChain(conversationId)?.pending_followup_at ?? "";
682
+ if (!stamp) {
683
+ return { commit: false, value: { role: "failed" } };
684
+ }
685
+ // Recover must not keep a leftover fix chain_pending (phantom E3).
686
+ this.store.clearChainPending(conversationId);
687
+ // Hold completed-stop redeliver through claim sleep; CAS clears it.
688
+ this.armRecoverClaimRedeliverHold(conversationId, debounceMs);
689
+ return {
690
+ commit: true,
691
+ value: { role: "redeliver", stamp },
692
+ };
693
+ }
694
+ if (ambient) {
695
+ this.applySoftResetAmbientChainForErrorRecover(conversationId);
696
+ // Soft-reset already chose code_edited for pending/chain. Only force
697
+ // when the dead transcript tip was fix but pending was empty/desynced —
698
+ // and never during mid-confirm / E5 (would phantom-arm E2 over E4/E5).
699
+ const live = this.store.getReviewChain(conversationId);
700
+ if (fixTipAtClaim &&
701
+ live &&
702
+ !this.isMidConfirmOrE5(live)) {
703
+ this.store.updateReviewChain(conversationId, { code_edited: 1 });
704
+ }
705
+ }
706
+ else {
707
+ // Executing skips ambient soft-reset; re-arm fix for dead fix tip /
708
+ // pending, but not while confirm_left says mid-confirm or E5-ready.
709
+ const resumeFix = !this.isMidConfirmOrE5(chain) &&
710
+ (this.isFixFollowupMessage(pending) ||
711
+ chain?.code_edited === 1 ||
712
+ fixTipAtClaim);
713
+ if (resumeFix) {
714
+ this.store.updateReviewChain(conversationId, { code_edited: 1 });
715
+ }
716
+ }
717
+ this.store.savePendingFollowup(conversationId, message, {
718
+ armChain: false,
719
+ });
720
+ const stamp = this.store.getReviewChain(conversationId)?.pending_followup_at ?? "";
721
+ if (!stamp) {
722
+ return { commit: false, value: { role: "failed" } };
723
+ }
724
+ // Executing claim skips ambient soft-reset — still disarm chain_pending.
725
+ // Ambient soft-reset already cleared chain_pending; clear again is idempotent.
726
+ this.store.clearChainPending(conversationId);
727
+ this.armRecoverClaimRedeliverHold(conversationId, debounceMs);
728
+ return {
729
+ commit: true,
730
+ value: { role: "claimer", stamp },
731
+ };
732
+ });
733
+ }
734
+ catch {
735
+ return { role: "failed" };
736
+ }
737
+ }
738
+ /**
739
+ * After debounce: skip inject if the session already revived or recover is
740
+ * already in-flight on the transcript; otherwise CAS-emit on claim stamp.
741
+ */
742
+ finishErrorRecoverAfterDebounce(conversationId, message, transcriptPath, expectedStamp, recoverAnsweredAtClaim = false, deadHarnessTipAtClaim = null) {
743
+ if (!expectedStamp) {
744
+ return null;
745
+ }
746
+ const fresh = this.store.getSession(conversationId);
747
+ if (!fresh || !this.sessionErrorRecoverable(fresh)) {
748
+ this.clearRecoverPendingBestEffort(conversationId);
749
+ return null;
750
+ }
751
+ const live = this.store.getReviewChain(conversationId)?.pending_followup?.trim() ?? "";
752
+ if (!isRecoverFollowupMessage(live)) {
753
+ return null;
754
+ }
755
+ const needle = live || message;
756
+ const events = transcriptPath ? readTranscriptTail(transcriptPath) : [];
757
+ if (events.length > 0) {
758
+ const inflight = inFlightUserQuery(events);
759
+ if (inflight && isRecoverFollowupMessage(inflight)) {
760
+ // Keep recover pending for host-drop / later redeliver, but drop THIS
761
+ // stop's claim hold only (stamp-matched). Sleep may have been no-op;
762
+ // an uncleared hold would block redelivery for the full debounceMs.
763
+ // Must not wipe a peer redeliver's newly armed hold after the window.
764
+ try {
765
+ this.store.clearPendingRedeliverHoldIfStamp(conversationId, expectedStamp);
766
+ }
767
+ catch {
768
+ /* best-effort */
769
+ }
770
+ return null;
771
+ }
772
+ // Tip race during sleep: a *new* non-recover harness landed after claim.
773
+ // Do not stack a recover inject on top — keep pending for redelivery.
774
+ // Exception: finish tip is still the same dead harness tip from claim
775
+ // (this error killed that turn) → must emit recover or the session stays stuck.
776
+ // Peek past trailing turn_ended so claim/finish compare the same tip surface.
777
+ const tipForSwap = harnessTipBeforeTrailingTurnEnded(events) ?? inflight;
778
+ if (tipForSwap &&
779
+ !isRecoverFollowupMessage(tipForSwap) &&
780
+ !this.isSameDeadHarnessTip(deadHarnessTipAtClaim, tipForSwap)) {
781
+ try {
782
+ this.store.clearPendingRedeliverHoldIfStamp(conversationId, expectedStamp);
783
+ }
784
+ catch {
785
+ /* best-effort */
786
+ }
787
+ return null;
788
+ }
789
+ if (!recoverAnsweredAtClaim &&
790
+ transcriptTipIsAssistant(events) &&
791
+ automationFollowupPresent(events, needle)) {
792
+ // Became answered during debounce sleep — do not double-emit.
793
+ // If Recover+assistant was already true at claim time, this is a new
794
+ // error after a prior revive (same template); fall through to CAS.
795
+ this.clearRecoverPendingBestEffort(conversationId);
796
+ return null;
797
+ }
798
+ // Tip is unanswered user text, same dead harness from claim, or prior
799
+ // assistant without our recover — still dead; emit recover.
800
+ }
801
+ const emitted = this.tryCasRecoverEmit(conversationId, expectedStamp, needle);
802
+ if (!emitted)
803
+ return null;
804
+ // Pause may land after CAS commit — drop inject + clear recover pending.
805
+ return this.recoverActionIfStillRunnable(conversationId, emitted);
806
+ }
807
+ /**
808
+ * True when finish-time inflight is the same dead harness tip snapped at claim.
809
+ * Requires the same harness kind first (fix vs confirm must not match), then
810
+ * exact match. Only fix tips allow finish starting with claim's 48-char prefix
811
+ * (round digits appear early in en/zh). Confirm is exact-only: English confirm
812
+ * puts {lensTitle} after ~100 chars, so a 48-prefix would treat different
813
+ * lenses as the same tip. Advance/Briefly stay exact-only for the same
814
+ * shared-lead-in reason.
815
+ */
816
+ isSameDeadHarnessTip(claimTip, finishTip) {
817
+ if (!claimTip)
818
+ return false;
819
+ // Full substantive bodies — first-line-only would conflate multiline tips that
820
+ // share a lead-in (e.g. confirm lens on line 2). Strip <user_query>/<timestamp>.
821
+ const a = substantivePromptBody(claimTip) || claimTip.trim();
822
+ const b = substantivePromptBody(finishTip) || finishTip.trim();
823
+ if (!a || !b)
824
+ return false;
825
+ const kind = this.harnessFollowupKind(a);
826
+ if (kind !== this.harnessFollowupKind(b))
827
+ return false;
828
+ if (a === b)
829
+ return true;
830
+ // Prefix matching only for fix (round index diverges within 48 chars).
831
+ if (kind !== "fix") {
832
+ return false;
833
+ }
834
+ const prefix = a.slice(0, 48);
835
+ if (prefix && b.startsWith(prefix))
836
+ return true;
837
+ // Truncated re-read of the same tip (finish shorter than claim prefix).
838
+ if (b.length < prefix.length && a.startsWith(b))
839
+ return true;
840
+ return false;
841
+ }
842
+ /**
843
+ * Classify completed-stop orphan transcript errors from one tail read.
844
+ * - none: no unresolved turn_ended error
845
+ * - skip_recover_tip: unresolved error AND a recover tip after that error
846
+ * (do not re-bump error_count / re-enter handleErrorStop)
847
+ * - salvage: unresolved error with no post-error recover tip yet
848
+ *
849
+ * Invariant: a newer turn_ended error after an old unanswered recover tip must
850
+ * salvage again — the prior tip does not cover the new failure.
851
+ */
852
+ classifyCompletedOrphan(transcriptPath) {
853
+ const path = transcriptPath?.trim();
854
+ if (!path)
855
+ return "none";
856
+ try {
857
+ const events = readTranscriptTail(path);
858
+ const errIdx = latestUnresolvedTurnEndedErrorIndex(events);
859
+ if (errIdx < 0)
860
+ return "none";
861
+ // Only a recover tip *after* this error owns the orphan (skip). A recover
862
+ // tip left over from an earlier failure must not suppress salvage.
863
+ for (let i = events.length - 1; i > errIdx; i--) {
864
+ const ev = events[i];
865
+ if (ev.role === "assistant")
866
+ return "none";
867
+ if (ev.role !== "user")
868
+ continue;
869
+ const q = userQueryText(ev);
870
+ if (!q)
871
+ continue;
872
+ if (!substantivePromptBody(q))
873
+ continue;
874
+ if (isRecoverFollowupMessage(q))
875
+ return "skip_recover_tip";
876
+ return "salvage";
877
+ }
878
+ return "salvage";
879
+ }
880
+ catch {
881
+ return "none";
882
+ }
883
+ }
884
+ /** Coarse kind bucket so fix/confirm/advance tips cannot share a prefix match. */
885
+ harnessFollowupKind(m) {
886
+ const line = firstSubstantiveLine(m) || m.trim();
887
+ if (this.isFixFollowupMessage(line))
888
+ return "fix";
889
+ if (line.startsWith("Review confirm") || line.startsWith("自审确认")) {
890
+ return "confirm";
891
+ }
892
+ if (line.startsWith("Advance checklist") || line.startsWith("推进下一项")) {
893
+ return "advance";
894
+ }
895
+ if (line.startsWith(BRIEFLY_PREFIX))
896
+ return "briefly";
897
+ if (line.startsWith("Stuck:") || line.startsWith("卡住:") || line.startsWith("卡住:")) {
898
+ return "stuck";
899
+ }
900
+ if (line.startsWith("Verify failed") || line.startsWith("校验失败")) {
901
+ return "verify";
902
+ }
903
+ if (line.startsWith("All checklist") ||
904
+ line.startsWith("全部完成") ||
905
+ line.startsWith("Review complete") ||
906
+ line.startsWith("自审完成")) {
907
+ return "terminal";
908
+ }
909
+ return "other";
910
+ }
911
+ /**
912
+ * Atomically take emit ownership: only the stop whose claim stamp still
913
+ * matches may return followup. Winner bumps pending_followup_at so a racing
914
+ * redeliver lands in-window and coalesces instead of double-injecting.
915
+ */
916
+ tryCasRecoverEmit(conversationId, expectedStamp, fallbackMessage) {
917
+ try {
918
+ return this.store.exclusiveWrite(() => {
919
+ const chain = this.store.getReviewChain(conversationId);
920
+ const live = chain?.pending_followup?.trim() ?? "";
921
+ if (!isRecoverFollowupMessage(live)) {
922
+ return { commit: false, value: null };
923
+ }
924
+ if (chain?.pending_followup_at !== expectedStamp) {
925
+ return { commit: false, value: null };
926
+ }
927
+ const session = this.store.getSession(conversationId);
928
+ if (!session || !this.sessionErrorRecoverable(session)) {
929
+ this.store.clearPendingFollowupIf(conversationId, (m) => isRecoverFollowupMessage(m));
930
+ return { commit: true, value: null };
931
+ }
932
+ // True CAS: only one stop bumps the stamp; savePending no-op cannot
933
+ // leave two winners with the same expectedStamp.
934
+ if (!this.store.casBumpPendingFollowupAt(conversationId, expectedStamp)) {
935
+ return { commit: false, value: null };
936
+ }
937
+ // Defense: recover emit must not leave chain_pending armed.
938
+ this.store.clearChainPending(conversationId);
939
+ return {
940
+ commit: true,
941
+ value: {
942
+ kind: "recover",
943
+ message: live || fallbackMessage,
944
+ loop: true,
945
+ },
946
+ };
947
+ });
948
+ }
949
+ catch {
950
+ return null;
951
+ }
952
+ }
953
+ clearRecoverPendingBestEffort(conversationId) {
954
+ try {
955
+ this.store.clearPendingFollowupIf(conversationId, (m) => isRecoverFollowupMessage(m));
956
+ }
957
+ catch {
958
+ /* best-effort */
959
+ }
960
+ }
961
+ /**
962
+ * Block completed-stop recover redelivery for ~holdMs using pending_redeliver_at
963
+ * against {@link PENDING_REDELIVER_COOLDOWN_MS}. CAS emit nulls the hold so a
964
+ * host-dropped inject can redeliver immediately (unlike gating on
965
+ * pending_followup_at, which CAS refreshes and would extend the block).
966
+ *
967
+ * When holdMs > cooldown, `at` is in the future so
968
+ * {@link pendingRedeliverAllowed} stays false until ~holdMs elapses — do not
969
+ * clamp hold to cooldown (that would unblock mid-sleep for long debounce).
970
+ */
971
+ armRecoverClaimRedeliverHold(conversationId, holdMs) {
972
+ if (!(holdMs > 0) || !Number.isFinite(holdMs))
973
+ return;
974
+ const cooldown = PENDING_REDELIVER_COOLDOWN_MS;
975
+ // Match resolveRecoverDebounceMs cap — keep hold covering the full sleep.
976
+ const hold = Math.min(Math.floor(holdMs), 60_000);
977
+ if (!(hold > 0))
978
+ return;
979
+ const at = new Date(Date.now() - (cooldown - hold)).toISOString();
980
+ // Column-only: merge updateReviewChain would rewrite confirm_left/pending.
981
+ this.store.setPendingRedeliverHold(conversationId, at);
982
+ }
983
+ /**
984
+ * Last-resort when tryClaimErrorRecoverWindow's exclusiveWrite failed.
985
+ * Retries ambient soft-reset+pending up to 3×; then column-only disarm +
986
+ * emitRecover (never unlocked neutralize/soft-reset, never full-neutralize
987
+ * after transient lock failures — that wiped mid-confirm confirm_left).
988
+ * Skip wipe/emit when recover pending already exists.
989
+ */
990
+ compensateFailedErrorRecoverClaim(conversationId, message, action, ambient) {
991
+ const recoverPending = () => {
992
+ try {
993
+ const pending = this.store.getReviewChain(conversationId)?.pending_followup?.trim() ??
994
+ "";
995
+ return isRecoverFollowupMessage(pending);
996
+ }
997
+ catch {
998
+ return false;
999
+ }
1000
+ };
1001
+ // Paused/disarmed after claim failure — do not inject recover.
1002
+ if (!this.sessionStillErrorRecoverable(conversationId)) {
1003
+ return null;
1004
+ }
1005
+ // Peer already owns recover — coalesce; do not neutralize or double-emit.
1006
+ if (recoverPending()) {
1007
+ return null;
1008
+ }
1009
+ if (ambient) {
1010
+ for (let i = 0; i < 3; i++) {
1011
+ const committed = this.tryCommitAmbientErrorRecover(conversationId, message);
1012
+ if (committed === "ok") {
1013
+ return this.recoverActionIfStillRunnable(conversationId, action);
1014
+ }
1015
+ if (committed === "exists")
1016
+ return null;
1017
+ }
1018
+ // tryCommit may have observed a concurrent pause — do not unlock-wipe
1019
+ // mid-confirm/fix state on an already-halted session.
1020
+ if (!this.sessionStillErrorRecoverable(conversationId)) {
1021
+ return null;
1022
+ }
1023
+ if (recoverPending()) {
1024
+ return null;
1025
+ }
1026
+ // Soft-reset txns failed (lock or stamp). Never full-neutralize — that wiped
1027
+ // mid-confirm confirm_left after transient tryCommit lock failures.
1028
+ // Column-only disarm so leftover chain_pending cannot phantom-E3.
1029
+ try {
1030
+ this.store.clearChainPending(conversationId);
1031
+ }
1032
+ catch {
1033
+ /* fall through to emit */
1034
+ }
1035
+ }
1036
+ if (!this.sessionStillErrorRecoverable(conversationId)) {
1037
+ return null;
1038
+ }
1039
+ // Peer may have claimed recover during unlocked disarm — do not emit over it.
1040
+ const emitted = this.emitRecoverAfterFailedClaim(conversationId, action, {
1041
+ ambientSoftReset: ambient,
1042
+ });
1043
+ if (!emitted)
1044
+ return null;
1045
+ // Match tryCommit: drop stamped recover if pause won after the write.
1046
+ return this.recoverActionIfStillRunnable(conversationId, emitted);
1047
+ }
1048
+ /** After a successful compensate write, drop recover if session paused mid-flight. */
1049
+ recoverActionIfStillRunnable(conversationId, action) {
1050
+ if (this.sessionStillErrorRecoverable(conversationId)) {
1051
+ return action;
1052
+ }
1053
+ this.clearRecoverPendingBestEffort(conversationId);
1054
+ return null;
1055
+ }
1056
+ sessionStillErrorRecoverable(conversationId) {
1057
+ try {
1058
+ const s = this.store.getSession(conversationId);
1059
+ return !!s && this.sessionErrorRecoverable(s);
1060
+ }
1061
+ catch {
1062
+ return false;
1063
+ }
1064
+ }
1065
+ /**
1066
+ * Compensating emit after claim failure: never overwrite a peer recover pending.
1067
+ * Ambient: soft-reset under the same lock before stamp (resume mid-fix /
1068
+ * preserve mid-confirm). Never unlocked soft-reset on the legacy fallback.
1069
+ * If exclusiveWrite is unavailable, fall back to legacy {@link emit} only when
1070
+ * no recover pending is visible and a recover row was stamped.
1071
+ */
1072
+ emitRecoverAfterFailedClaim(conversationId, action, opts) {
1073
+ const ambientSoftReset = opts?.ambientSoftReset === true;
1074
+ try {
1075
+ return this.store.exclusiveWrite(() => {
1076
+ const session = this.store.getSession(conversationId);
1077
+ if (!session || !this.sessionErrorRecoverable(session)) {
1078
+ return { commit: false, value: null };
1079
+ }
1080
+ const pending = this.store.getReviewChain(conversationId)?.pending_followup?.trim() ??
1081
+ "";
1082
+ if (isRecoverFollowupMessage(pending)) {
1083
+ return { commit: false, value: null };
1084
+ }
1085
+ if (ambientSoftReset) {
1086
+ this.applySoftResetAmbientChainForErrorRecover(conversationId);
1087
+ }
1088
+ this.store.savePendingFollowup(conversationId, action.message, {
1089
+ armChain: false,
1090
+ });
1091
+ const live = this.store.getReviewChain(conversationId)?.pending_followup?.trim() ??
1092
+ "";
1093
+ // Under lock, require a stamped recover row — silent savePending no-op
1094
+ // must not return action (two compensators could both "win").
1095
+ if (!isRecoverFollowupMessage(live)) {
1096
+ return { commit: false, value: null };
1097
+ }
1098
+ // armChain=false does not clear a leftover fix chain_pending=1 → phantom E3.
1099
+ this.store.clearChainPending(conversationId);
1100
+ return { commit: true, value: action };
1101
+ });
1102
+ }
1103
+ catch {
1104
+ if (!this.sessionStillErrorRecoverable(conversationId)) {
1105
+ return null;
1106
+ }
1107
+ try {
1108
+ const pending = this.store.getReviewChain(conversationId)?.pending_followup?.trim() ??
1109
+ "";
1110
+ if (isRecoverFollowupMessage(pending)) {
1111
+ return null;
1112
+ }
1113
+ }
1114
+ catch {
1115
+ /* fall through to legacy emit */
1116
+ }
1117
+ // Legacy path: never unlocked soft-reset (confirm/fix pending desync).
1118
+ this.emit(conversationId, action);
1119
+ try {
1120
+ this.store.clearChainPending(conversationId);
1121
+ }
1122
+ catch {
1123
+ /* best-effort */
1124
+ }
1125
+ // Must own a stamped recover row. Returning action after a failed
1126
+ // savePending (lock storm) double-injects with a claimer that still
1127
+ // holds the window and will CAS-emit after debounce.
1128
+ try {
1129
+ const live = this.store.getReviewChain(conversationId)?.pending_followup?.trim() ??
1130
+ "";
1131
+ if (isRecoverFollowupMessage(live)) {
1132
+ return action;
1133
+ }
1134
+ }
1135
+ catch {
1136
+ /* ignore */
1137
+ }
1138
+ return null;
1139
+ }
1140
+ }
1141
+ /**
1142
+ * Ambient error recover write: soft-reset + recover pending under one
1143
+ * exclusiveWrite. "exists" = peer already claimed recover (do not overwrite).
1144
+ */
1145
+ tryCommitAmbientErrorRecover(conversationId, message) {
1146
+ try {
1147
+ return this.store.exclusiveWrite(() => {
1148
+ const session = this.store.getSession(conversationId);
1149
+ if (!session || !this.sessionErrorRecoverable(session)) {
1150
+ return { commit: false, value: "failed" };
1151
+ }
1152
+ const pending = this.store.getReviewChain(conversationId)?.pending_followup?.trim() ??
1153
+ "";
1154
+ if (isRecoverFollowupMessage(pending)) {
1155
+ return { commit: false, value: "exists" };
1156
+ }
1157
+ this.applySoftResetAmbientChainForErrorRecover(conversationId);
1158
+ this.store.savePendingFollowup(conversationId, message, {
1159
+ armChain: false,
1160
+ });
1161
+ // savePending may no-op (missing session / blank). Do not report ok or
1162
+ // commit a soft-reset that left nothing redeliverable.
1163
+ const live = this.store.getReviewChain(conversationId)?.pending_followup?.trim() ??
1164
+ "";
1165
+ const stamp = this.store.getReviewChain(conversationId)?.pending_followup_at ?? "";
1166
+ if (!stamp || !isRecoverFollowupMessage(live)) {
1167
+ return { commit: false, value: "failed" };
1168
+ }
1169
+ return { commit: true, value: "ok" };
1170
+ });
1171
+ }
1172
+ catch {
1173
+ return "failed";
1174
+ }
1175
+ }
1176
+ /**
1177
+ * Ambient/planning error recover: drop confirm/pending arming so recover
1178
+ * (armChain=false) cannot leave chain_pending=1 → phantom E3.
1179
+ *
1180
+ * Call only under exclusiveWrite (with savePendingFollowup). Compensate must
1181
+ * not call this unlocked: clearing undelivered confirm/fix pending while
1182
+ * confirm_left is already post-decrement desyncs the next E4 lens.
1183
+ *
1184
+ * Preserve mid-confirm / E5-ready counters so the completed stop after
1185
+ * recover continues E4/E5. Ready-for-E3 (fix done, chain_pending only)
1186
+ * advances into confirm via confirm_left=confirmRounds (E4 emits 1/N) —
1187
+ * do not regress to another fix. Ready-for-E3 requires empty pending (not
1188
+ * merely !fixPending). Force code_edited only for an active fix
1189
+ * (code_edited / undelivered fix pending / chain_pending with pending still
1190
+ * set). Bare leftover fix_round is ignored (ambient phantom residue).
1191
+ */
1192
+ applySoftResetAmbientChainForErrorRecover(conversationId) {
1193
+ this.store.ensureReviewChain(conversationId);
1194
+ const chain = this.store.getReviewChain(conversationId);
1195
+ if (!chain)
1196
+ return;
1197
+ // Never clear a live recover claim (unlocked compensate / TOCTOU defense).
1198
+ if (isRecoverFollowupMessage(chain.pending_followup?.trim() ?? "")) {
1199
+ return;
1200
+ }
1201
+ const atE5 = chain.confirm_left === 0 ||
1202
+ (chain.item_confirm_complete === 1 && chain.confirm_left === null);
1203
+ const midConfirm = chain.confirm_left !== null && chain.confirm_left > 0;
1204
+ const pending = chain.pending_followup?.trim() ?? "";
1205
+ const fixPending = this.isFixFollowupMessage(pending);
1206
+ // Ready-for-E3 only when there is no undelivered followup left. Using
1207
+ // !fixPending alone is too wide: a non-prefix/custom pending would look
1208
+ // "ready" and skip an in-flight fix into confirm.
1209
+ const readyForE3 = !atE5 &&
1210
+ !midConfirm &&
1211
+ chain.chain_pending === 1 &&
1212
+ chain.code_edited === 0 &&
1213
+ pending.length === 0;
1214
+ const resumeFix = !atE5 &&
1215
+ !midConfirm &&
1216
+ !readyForE3 &&
1217
+ (chain.chain_pending === 1 ||
1218
+ chain.code_edited === 1 ||
1219
+ fixPending);
1220
+ const rounds = this.config.confirmRounds;
1221
+ this.store.softResetAmbientChainUnlessRecover(conversationId, {
1222
+ confirm_left: atE5 || midConfirm
1223
+ ? chain.confirm_left
1224
+ : readyForE3 && rounds > 0
1225
+ ? rounds
1226
+ : null,
1227
+ item_confirm_complete: atE5 ? chain.item_confirm_complete : 0,
1228
+ chain_pending: 0,
1229
+ // Preserve an in-flight edit marker (E2 wins over E4); else force only for mid-fix.
1230
+ code_edited: resumeFix || chain.code_edited === 1 ? 1 : 0,
1231
+ });
1232
+ }
1233
+ /** Shared with inferPendingKind — locale templates must keep these prefixes. */
1234
+ isFixFollowupMessage(message) {
1235
+ const line = firstSubstantiveLine(message) || message.trim();
1236
+ return line.startsWith("Review fix") || line.startsWith("自审修复");
1237
+ }
1238
+ /** Mid-confirm or E5-ready — must not phantom-arm code_edited over E4/E5. */
1239
+ isMidConfirmOrE5(chain) {
1240
+ if (!chain)
1241
+ return false;
1242
+ if (chain.confirm_left !== null && chain.confirm_left > 0)
1243
+ return true;
1244
+ return (chain.confirm_left === 0 ||
1245
+ (chain.item_confirm_complete === 1 && chain.confirm_left === null));
1246
+ }
1247
+ /** True when a stop may still advance the review chain (re-check under write lock). */
1248
+ sessionRunnable(conversationId) {
1249
+ const s = this.store.getSession(conversationId);
1250
+ return !!s && sessionReviewRunnable(s, this.config.reviewScope);
1251
+ }
1252
+ /** Genuine error stop may inject recover (planning, project ambient, or armed executing). */
1253
+ sessionErrorRecoverable(session) {
1254
+ if (session.paused !== 0)
1255
+ return false;
1256
+ if (session.phase === "planning")
1257
+ return true;
1258
+ if (session.phase === "idle" &&
1259
+ session.armed === 1 &&
1260
+ this.config.reviewScope === "project") {
1261
+ return true;
1262
+ }
1263
+ return session.phase === "executing" && session.armed === 1;
1264
+ }
1265
+ recoverKindForPhase(phase) {
1266
+ if (phase === "planning")
1267
+ return "recover_planning";
1268
+ if (phase === "idle")
1269
+ return "recover_ambient";
1270
+ return "recover";
1271
+ }
1272
+ /** completed stop → reset error_count */
1273
+ noteCompletedOk(session) {
1274
+ if (session.error_count > 0) {
1275
+ this.store.upsertSession({
1276
+ conversation_id: session.conversation_id,
1277
+ project_root: session.project_root,
1278
+ code_root: session.code_root,
1279
+ error_count: 0,
1280
+ last_error: null,
1281
+ });
1282
+ }
1283
+ }
1284
+ maybeResetErrorCountOnItemChange(_session) {
1285
+ // error_count reset on item id change is handled by callers tracking last item;
1286
+ // E5b always zeroes error_count on advance. Do not existsSync untrusted
1287
+ // checklist_path here (symlink / outside probe).
1288
+ }
1289
+ /**
1290
+ * E0': checklist executing, not in fix/confirm — skip lenses when there is no
1291
+ * product code edit. Soft/verified success advances in one IMMEDIATE txn with
1292
+ * evidence re-check (never arm at-E5 first). Only required-verify *fail*
1293
+ * arms at-E5 to reuse E5c verify_fix / stuck.
1294
+ */
1295
+ e0NoCodeContinue(session, chain) {
1296
+ if (!isChecklistExecuting(session))
1297
+ return null;
1298
+ if (chain.code_edited === 1 ||
1299
+ chain.confirm_left !== null ||
1300
+ chain.item_confirm_complete === 1 ||
1301
+ chain.chain_pending === 1) {
1302
+ return null;
1303
+ }
1304
+ const parsed = this.parseSessionChecklist(session);
1305
+ if (!parsed?.checklist)
1306
+ return null;
1307
+ const reviewingId = this.resolveReviewingItemId(chain, parsed.checklist);
1308
+ const currentItem = (reviewingId &&
1309
+ parsed.checklist.items.find((i) => i.id === reviewingId)) ||
1310
+ parsed.currentItem;
1311
+ if (!currentItem)
1312
+ return null;
1313
+ const trustRoot = this.trustedProjectRoot();
1314
+ const reportPath = this.config.verifyReportPath ??
1315
+ defaultVerifyReportPath(trustRoot ?? "");
1316
+ const checklistPath = session.checklist_path || "";
1317
+ const evalResult = evaluateVerifyReport({
1318
+ enabled: this.config.verifyEnabled,
1319
+ commands: this.config.verifyCommands,
1320
+ reportPath,
1321
+ currentItem,
1322
+ checklistPath,
1323
+ projectRoot: trustRoot ?? undefined,
1324
+ });
1325
+ if (evalResult.outcome === "skip") {
1326
+ // Soft evidence + advance/done in one write — never arm E5 first.
1327
+ return this.e0DirectAdvance(session, reportPath, currentItem.id, {
1328
+ kind: "soft",
1329
+ });
1330
+ }
1331
+ if (evalResult.outcome === "pass") {
1332
+ // Required verify already passed — advance in one write. Do NOT arm at-E5
1333
+ // first: a crash between arm and e5Gate would leave stranded at-E5, and a
1334
+ // later skip-configured stop could advance without soft evidence.
1335
+ return this.e0DirectAdvance(session, reportPath, currentItem.id, {
1336
+ kind: "verified",
1337
+ checklistPath,
1338
+ });
1339
+ }
1340
+ // fail (required verify armed): arm at-E5 and reuse E5c verify_fix / stuck.
1341
+ if (!this.armAtE5ForNoCode(session.conversation_id))
1342
+ return null;
1343
+ const fresh = this.store.getReviewChain(session.conversation_id);
1344
+ const sess = this.store.getSession(session.conversation_id);
1345
+ if (!fresh || !sess) {
1346
+ this.disarmE5NoCode(session.conversation_id);
1347
+ return null;
1348
+ }
1349
+ const action = this.e5Gate(sess, fresh);
1350
+ if (!action) {
1351
+ // Do not leave a stranded at-E5 state that would skip soft evidence next stop.
1352
+ this.disarmE5NoCode(session.conversation_id);
1353
+ return null;
1354
+ }
1355
+ // E5b advance keeps chain_pending=0; clear stray pending if any (fail→fixed→pass).
1356
+ if (action.kind === "advance") {
1357
+ const cid = session.conversation_id;
1358
+ try {
1359
+ this.store.exclusiveWrite(() => {
1360
+ const live = this.store.getReviewChain(cid);
1361
+ if (!live ||
1362
+ live.chain_pending !== 1 ||
1363
+ live.code_edited === 1 ||
1364
+ live.confirm_left !== null ||
1365
+ live.item_confirm_complete !== 0) {
1366
+ return { commit: false, value: undefined };
1367
+ }
1368
+ this.store.updateReviewChain(cid, { chain_pending: 0 });
1369
+ return { commit: true, value: undefined };
1370
+ });
1371
+ }
1372
+ catch {
1373
+ /* best-effort */
1374
+ }
1375
+ }
1376
+ return action;
1377
+ }
1378
+ /**
1379
+ * E0 direct advance/done: re-check soft or verified evidence under one
1380
+ * IMMEDIATE write — never enters the confirm/E5 state machine.
1381
+ */
1382
+ e0DirectAdvance(session, reportPath, expectedItemId, evidence) {
1383
+ const cid = session.conversation_id;
1384
+ const trustRoot = this.trustedProjectRoot();
1385
+ const action = this.store.exclusiveWrite(() => {
1386
+ if (!this.sessionRunnable(cid)) {
1387
+ return { commit: false, value: null };
1388
+ }
1389
+ const lockedSession = this.store.getSession(cid);
1390
+ if (!lockedSession || !isChecklistExecuting(lockedSession)) {
1391
+ return { commit: false, value: null };
1392
+ }
1393
+ const fresh = this.store.getReviewChain(cid);
1394
+ if (!fresh ||
1395
+ fresh.code_edited === 1 ||
1396
+ fresh.confirm_left !== null ||
1397
+ fresh.item_confirm_complete === 1 ||
1398
+ fresh.chain_pending === 1) {
1399
+ return { commit: false, value: null };
1400
+ }
1401
+ const refreshed = this.parseSessionChecklist(lockedSession);
1402
+ if (!refreshed?.checklist) {
1403
+ return { commit: false, value: null };
1404
+ }
1405
+ const reviewingId = this.resolveReviewingItemId(fresh, refreshed.checklist, expectedItemId);
1406
+ const targets = resolveAdvanceTargets(refreshed.checklist, reviewingId);
1407
+ // Soft/verified E0 still requires the expected item to be the one we advance.
1408
+ if (!targets.current || targets.current.id !== expectedItemId) {
1409
+ return { commit: false, value: null };
1410
+ }
1411
+ if (evidence.kind === "soft") {
1412
+ if (!hasNoCodeCompletionEvidence({
1413
+ reportPath,
1414
+ currentItemId: targets.current.id,
1415
+ projectRoot: trustRoot ?? undefined,
1416
+ })) {
1417
+ return { commit: false, value: null };
1418
+ }
1419
+ }
1420
+ else {
1421
+ // Prefer checklist path from the locked session row (not the pre-lock snapshot).
1422
+ const lockedChecklistPath = lockedSession.checklist_path?.trim() || evidence.checklistPath;
1423
+ const lockedEval = evaluateVerifyReport({
1424
+ enabled: this.config.verifyEnabled,
1425
+ commands: this.config.verifyCommands,
1426
+ reportPath,
1427
+ currentItem: targets.current,
1428
+ checklistPath: lockedChecklistPath,
1429
+ projectRoot: trustRoot ?? undefined,
1430
+ });
1431
+ if (lockedEval.outcome !== "pass") {
1432
+ return { commit: false, value: null };
1433
+ }
1434
+ }
1435
+ if (targets.unchecked <= 0 && !targets.current) {
1436
+ return { commit: false, value: null };
1437
+ }
1438
+ const out = this.renderAdvanceOrDone(targets);
1439
+ const isAdvance = out.kind === "advance";
1440
+ const chainReset = this.reviewChainResetFields();
1441
+ if (isAdvance) {
1442
+ this.store.upsertSession({
1443
+ conversation_id: cid,
1444
+ project_root: lockedSession.project_root,
1445
+ code_root: lockedSession.code_root,
1446
+ error_count: 0,
1447
+ idle_stop_count: 0,
1448
+ last_error: null,
1449
+ });
1450
+ // chain_pending stays 0 (same as E5b advance). Soft/verified E0
1451
+ // advance has no product diff to confirm; leaving pending=1 would force
1452
+ // E3 on the next no-code item and skip E0 soft evidence.
1453
+ this.store.updateReviewChain(cid, {
1454
+ ...chainReset,
1455
+ // Stick the *next* item immediately so premature `[x]` before the
1456
+ // first product edit cannot retarget via firstUnchecked.
1457
+ reviewing_item_id: targets.next?.id ?? null,
1458
+ pending_followup: out.message,
1459
+ pending_followup_at: new Date().toISOString(),
1460
+ pending_redeliver_at: null,
1461
+ chain_pending: 0,
1462
+ });
1463
+ }
1464
+ else {
1465
+ this.store.upsertSession({
1466
+ conversation_id: cid,
1467
+ project_root: lockedSession.project_root,
1468
+ code_root: lockedSession.code_root,
1469
+ phase: "done",
1470
+ armed: 0,
1471
+ error_count: 0,
1472
+ idle_stop_count: 0,
1473
+ last_error: null,
1474
+ });
1475
+ this.store.updateReviewChain(cid, {
1476
+ ...chainReset,
1477
+ pending_followup: out.message,
1478
+ pending_followup_at: new Date().toISOString(),
1479
+ pending_redeliver_at: null,
1480
+ chain_pending: 0,
1481
+ });
1482
+ }
1483
+ return { commit: true, value: out };
1484
+ });
1485
+ if (action) {
1486
+ this.afterFollowupCommitted(session, {});
1487
+ }
1488
+ return action;
1489
+ }
1490
+ /** Arm confirm_left=0 + ICC=1 only when still idle on the no-code path. */
1491
+ armAtE5ForNoCode(conversationId) {
1492
+ return (this.store.exclusiveWrite(() => {
1493
+ if (!this.sessionRunnable(conversationId)) {
1494
+ return { commit: false, value: false };
1495
+ }
1496
+ const sess = this.store.getSession(conversationId);
1497
+ if (!sess || !isChecklistExecuting(sess)) {
1498
+ return { commit: false, value: false };
1499
+ }
1500
+ const fresh = this.store.getReviewChain(conversationId);
1501
+ if (!fresh ||
1502
+ fresh.code_edited === 1 ||
1503
+ fresh.confirm_left !== null ||
1504
+ fresh.item_confirm_complete === 1 ||
1505
+ fresh.chain_pending === 1) {
1506
+ return { commit: false, value: false };
1507
+ }
1508
+ this.store.updateReviewChain(conversationId, {
1509
+ confirm_left: 0,
1510
+ item_confirm_complete: 1,
1511
+ chain_pending: 0,
1512
+ });
1513
+ return { commit: true, value: true };
1514
+ }) === true);
1515
+ }
1516
+ /** Undo a failed arm so the next stop cannot skip soft evidence via E5. */
1517
+ disarmE5NoCode(conversationId) {
1518
+ try {
1519
+ this.store.exclusiveWrite(() => {
1520
+ const fresh = this.store.getReviewChain(conversationId);
1521
+ if (!fresh ||
1522
+ fresh.code_edited === 1 ||
1523
+ fresh.confirm_left !== 0 ||
1524
+ fresh.item_confirm_complete !== 1) {
1525
+ return { commit: false, value: undefined };
1526
+ }
1527
+ // Only clear a pristine post-arm state (no pending followup yet).
1528
+ if (fresh.pending_followup?.trim()) {
1529
+ return { commit: false, value: undefined };
1530
+ }
1531
+ this.store.updateReviewChain(conversationId, {
1532
+ confirm_left: null,
1533
+ item_confirm_complete: 0,
1534
+ chain_pending: 0,
1535
+ });
1536
+ return { commit: true, value: undefined };
1537
+ });
1538
+ }
1539
+ catch {
1540
+ /* best-effort */
1541
+ }
1542
+ }
1543
+ /** Session-monotonic round counter (fix + confirm share fix_round; no hard cap). */
1544
+ nextSessionRound(chain) {
1545
+ return chain.fix_round + 1;
1546
+ }
1547
+ e2Fix(session, _chain) {
1548
+ const rounds = this.config.confirmRounds;
1549
+ const cid = session.conversation_id;
1550
+ // Chain counters + pending in one IMMEDIATE txn — crash must not leave
1551
+ // "code_edited cleared / round bumped" without the fix message to redeliver.
1552
+ const action = this.store.exclusiveWrite(() => {
1553
+ // Concurrent error-halt may have paused/disarmed after the outer check.
1554
+ if (!this.sessionRunnable(cid)) {
1555
+ return { commit: false, value: null };
1556
+ }
1557
+ const fresh = this.store.getReviewChain(cid);
1558
+ if (!fresh || fresh.code_edited !== 1) {
1559
+ return { commit: false, value: null };
1560
+ }
1561
+ const fixRound = this.nextSessionRound(fresh);
1562
+ const message = this.render("review.fix", { round: fixRound, total: rounds });
1563
+ const out = {
1564
+ kind: "review.fix",
1565
+ message,
1566
+ loop: true,
1567
+ meta: { fixRound },
1568
+ };
1569
+ this.store.updateReviewChain(cid, {
1570
+ fix_round: fixRound,
1571
+ code_edited: 0,
1572
+ confirm_left: null,
1573
+ chain_pending: 1,
1574
+ // item_confirm_complete preserved (E2 path)
1575
+ pending_followup: message,
1576
+ pending_followup_at: new Date().toISOString(),
1577
+ pending_redeliver_at: null,
1578
+ });
1579
+ return { commit: true, value: out };
1580
+ });
1581
+ if (action) {
1582
+ this.afterFollowupCommitted(session, {});
1583
+ }
1584
+ return action;
1585
+ }
1586
+ e3ArmConfirm(session, _chain) {
1587
+ const rounds = this.config.confirmRounds;
1588
+ const lens = this.lens(1);
1589
+ const left = rounds - 1; // inject 1st then set left = rounds-1
1590
+ const kind = rounds === 1 ? "review.confirm_final" : "review.confirm";
1591
+ const cid = session.conversation_id;
1592
+ const action = this.store.exclusiveWrite(() => {
1593
+ if (!this.sessionRunnable(cid)) {
1594
+ return { commit: false, value: null };
1595
+ }
1596
+ const fresh = this.store.getReviewChain(cid);
1597
+ // Lost the race to another stop (already confirming, at E5, or code edit).
1598
+ if (!fresh ||
1599
+ fresh.code_edited === 1 ||
1600
+ fresh.confirm_left !== null ||
1601
+ fresh.item_confirm_complete === 1) {
1602
+ return { commit: false, value: null };
1603
+ }
1604
+ const sessionRound = this.nextSessionRound(fresh);
1605
+ const message = this.render(kind, {
1606
+ n: 1,
1607
+ total: rounds,
1608
+ sessionRound,
1609
+ lensTitle: lens.title,
1610
+ lensFocus: lens.focus,
1611
+ });
1612
+ const out = {
1613
+ kind,
1614
+ message,
1615
+ loop: true,
1616
+ meta: { n: 1, total: rounds, sessionRound },
1617
+ };
1618
+ this.store.updateReviewChain(cid, {
1619
+ confirm_left: left,
1620
+ chain_pending: 1,
1621
+ fix_round: sessionRound,
1622
+ pending_followup: message,
1623
+ pending_followup_at: new Date().toISOString(),
1624
+ pending_redeliver_at: null,
1625
+ });
1626
+ return { commit: true, value: out };
1627
+ });
1628
+ if (action) {
1629
+ this.afterFollowupCommitted(session, { confirm_left: left });
1630
+ }
1631
+ return action;
1632
+ }
1633
+ e4Confirm(session, chain) {
1634
+ const rounds = this.config.confirmRounds;
1635
+ const expectedLeft = chain.confirm_left;
1636
+ if (expectedLeft === null || expectedLeft <= 0)
1637
+ return null;
1638
+ // lensIndex = rounds - left + 1 (before decrement)
1639
+ const n = rounds - expectedLeft + 1;
1640
+ const lens = this.lens(n);
1641
+ const newLeft = expectedLeft - 1;
1642
+ const isFinal = n === rounds;
1643
+ const kind = isFinal ? "review.confirm_final" : "review.confirm";
1644
+ const cid = session.conversation_id;
1645
+ // MUST return from handleStop after this — never continue to E5 same stop.
1646
+ // Atomic confirm_left decrement + pending avoids skip-a-lens on crash/concurrent stop.
1647
+ const action = this.store.exclusiveWrite(() => {
1648
+ if (!this.sessionRunnable(cid)) {
1649
+ return { commit: false, value: null };
1650
+ }
1651
+ const fresh = this.store.getReviewChain(cid);
1652
+ if (!fresh || fresh.code_edited === 1 || fresh.confirm_left !== expectedLeft) {
1653
+ return { commit: false, value: null };
1654
+ }
1655
+ const sessionRound = this.nextSessionRound(fresh);
1656
+ const message = this.render(kind, {
1657
+ n,
1658
+ total: rounds,
1659
+ sessionRound,
1660
+ lensTitle: lens.title,
1661
+ lensFocus: lens.focus,
1662
+ });
1663
+ const out = {
1664
+ kind,
1665
+ message,
1666
+ loop: true,
1667
+ meta: { n, total: rounds, confirm_left: newLeft, sessionRound },
1668
+ };
1669
+ this.store.updateReviewChain(cid, {
1670
+ confirm_left: newLeft,
1671
+ chain_pending: 1,
1672
+ fix_round: sessionRound,
1673
+ pending_followup: message,
1674
+ pending_followup_at: new Date().toISOString(),
1675
+ pending_redeliver_at: null,
1676
+ });
1677
+ return { commit: true, value: out };
1678
+ });
1679
+ if (action) {
1680
+ this.afterFollowupCommitted(session, { confirm_left: newLeft });
1681
+ }
1682
+ return action;
1683
+ }
1684
+ e5Gate(session, chain) {
1685
+ // Project-scope ambient/planning: never verify/advance checklist — end with review_complete.
1686
+ // Otherwise a leftover checklist_path (e.g. after ON) + verify fail / unreadable path
1687
+ // would return null and stall the chain forever.
1688
+ if (this.config.reviewScope === "project" &&
1689
+ !isChecklistExecuting(session)) {
1690
+ return this.e5bAdvance(session, {
1691
+ unchecked: 0,
1692
+ next: null,
1693
+ verifiedPass: false,
1694
+ });
1695
+ }
1696
+ // E5c first
1697
+ const checklistPath = session.checklist_path;
1698
+ let currentItem = null;
1699
+ // Single checklist read for verify + advance/done — avoids TOCTOU between
1700
+ // e5Gate and e5bAdvance, and keeps FS I/O outside the write lock.
1701
+ let unchecked = 0;
1702
+ if (checklistPath) {
1703
+ const parsed = this.parseSessionChecklist(session);
1704
+ if (!parsed) {
1705
+ // Unreadable / missing / unsafe checklist — do not advance/done or verify-fail spuriously.
1706
+ return null;
1707
+ }
1708
+ unchecked = parsed.unchecked;
1709
+ const checklistMd = parsed.checklist;
1710
+ const reviewingId = this.resolveReviewingItemId(chain, checklistMd);
1711
+ currentItem =
1712
+ (reviewingId &&
1713
+ checklistMd.items.find((i) => i.id === reviewingId)) ||
1714
+ parsed.currentItem;
1715
+ }
1716
+ // No open unchecked rows: still allow done via sticky reviewing item when present.
1717
+ // Otherwise verifyEnabled + required cmds treat currentItem=null as fail and
1718
+ // loop verify_fix forever instead of emitting done.
1719
+ if (unchecked === 0) {
1720
+ return this.e5bAdvance(session, {
1721
+ unchecked: 0,
1722
+ next: currentItem,
1723
+ verifiedPass: false,
1724
+ });
1725
+ }
1726
+ // Defensive: countUnchecked>0 must yield a resolvable item (first or sticky).
1727
+ if (!currentItem) {
1728
+ return null;
1729
+ }
1730
+ const trustRoot = this.trustedProjectRoot();
1731
+ const reportPath = this.config.verifyReportPath ??
1732
+ defaultVerifyReportPath(trustRoot ?? "");
1733
+ const evalResult = evaluateVerifyReport({
1734
+ enabled: this.config.verifyEnabled,
1735
+ commands: this.config.verifyCommands,
1736
+ reportPath,
1737
+ currentItem,
1738
+ checklistPath: checklistPath || "",
1739
+ projectRoot: trustRoot ?? undefined,
1740
+ });
1741
+ if (evalResult.outcome === "fail") {
1742
+ // E5c: ICC + confirm_left + pending (+ idle/stuck) in one IMMEDIATE txn so a
1743
+ // crash cannot leave "at E5 with ICC" and no redeliverable verify_fix/stuck.
1744
+ const reason = evalResult.reason ?? "fail";
1745
+ const cid = session.conversation_id;
1746
+ return this.store.exclusiveWrite(() => {
1747
+ const fresh = this.store.getReviewChain(cid);
1748
+ const atE5 = !!fresh &&
1749
+ (fresh.confirm_left === 0 ||
1750
+ (fresh.item_confirm_complete === 1 && fresh.confirm_left === null));
1751
+ if (!atE5) {
1752
+ return { commit: false, value: null };
1753
+ }
1754
+ const sess = this.store.getSession(cid);
1755
+ if (!sess ||
1756
+ !isChecklistExecuting(sess)) {
1757
+ return { commit: false, value: null };
1758
+ }
1759
+ // Refresh checklist + verify under the write lock — concurrent edits or a
1760
+ // flipped report must not ICC against a stale open item.
1761
+ let lockedItem = currentItem;
1762
+ const lockedPath = checklistPath || "";
1763
+ if (checklistPath) {
1764
+ const locked = this.parseSessionChecklist(sess);
1765
+ if (!locked) {
1766
+ return { commit: false, value: null };
1767
+ }
1768
+ if (locked.unchecked === 0) {
1769
+ // Nothing left to verify — let the next stop take the done path.
1770
+ // Do not require empty sticky: orphan/stale reviewing_item_id must
1771
+ // not keep E5c fail looping when the checklist is fully checked.
1772
+ return { commit: false, value: null };
1773
+ }
1774
+ const reviewingId = this.resolveReviewingItemId(fresh, locked.checklist);
1775
+ const nextItem = (reviewingId &&
1776
+ locked.checklist?.items.find((i) => i.id === reviewingId)) ||
1777
+ locked.currentItem;
1778
+ if (!nextItem) {
1779
+ return { commit: false, value: null };
1780
+ }
1781
+ lockedItem = nextItem;
1782
+ }
1783
+ const lockedEval = evaluateVerifyReport({
1784
+ enabled: this.config.verifyEnabled,
1785
+ commands: this.config.verifyCommands,
1786
+ reportPath,
1787
+ currentItem: lockedItem,
1788
+ checklistPath: lockedPath,
1789
+ projectRoot: this.trustedProjectRoot() ?? undefined,
1790
+ });
1791
+ if (lockedEval.outcome !== "fail") {
1792
+ return { commit: false, value: null };
1793
+ }
1794
+ const failReason = lockedEval.reason ?? reason;
1795
+ const nextIdle = sess.idle_stop_count + 1;
1796
+ const nowStuck = nextIdle >= this.config.maxIdleStops;
1797
+ if (nowStuck) {
1798
+ this.store.upsertSession({
1799
+ conversation_id: cid,
1800
+ project_root: sess.project_root,
1801
+ code_root: sess.code_root,
1802
+ idle_stop_count: nextIdle,
1803
+ paused: 1,
1804
+ paused_reason: "stuck",
1805
+ armed: 0,
1806
+ });
1807
+ }
1808
+ else {
1809
+ this.store.upsertSession({
1810
+ conversation_id: cid,
1811
+ project_root: sess.project_root,
1812
+ code_root: sess.code_root,
1813
+ idle_stop_count: nextIdle,
1814
+ });
1815
+ }
1816
+ const kind = nowStuck ? "stuck" : "verify_fix";
1817
+ const message = this.render(kind, nowStuck ? {} : { reason: failReason });
1818
+ this.store.updateReviewChain(cid, {
1819
+ confirm_left: 0,
1820
+ item_confirm_complete: 1,
1821
+ chain_pending: 1,
1822
+ pending_followup: message,
1823
+ pending_followup_at: new Date().toISOString(),
1824
+ pending_redeliver_at: null,
1825
+ });
1826
+ const out = {
1827
+ kind,
1828
+ message,
1829
+ loop: true,
1830
+ meta: { reason: failReason },
1831
+ };
1832
+ return { commit: true, value: out };
1833
+ });
1834
+ }
1835
+ // E5a+E5b: reset chain and persist advance/done pending in one write txn
1836
+ // so a crash cannot leave "reset, no pending" (would re-arm confirm or stall).
1837
+ return this.e5bAdvance(session, {
1838
+ unchecked,
1839
+ next: currentItem,
1840
+ verifiedPass: evalResult.outcome === "pass",
1841
+ });
1842
+ }
1843
+ e5bAdvance(session, checklist) {
1844
+ const cid = session.conversation_id;
1845
+ const chainReset = this.reviewChainResetFields();
1846
+ return this.store.exclusiveWrite(() => {
1847
+ // Re-check E5 gate under the write lock — a concurrent stop may have
1848
+ // already advanced/reset the chain (confirm_left null, ICC 0).
1849
+ const fresh = this.store.getReviewChain(cid);
1850
+ const atE5 = !!fresh &&
1851
+ (fresh.confirm_left === 0 ||
1852
+ (fresh.item_confirm_complete === 1 && fresh.confirm_left === null));
1853
+ if (!atE5) {
1854
+ return { commit: false, value: null };
1855
+ }
1856
+ const lockedSession = this.store.getSession(cid);
1857
+ if (!lockedSession ||
1858
+ !sessionReviewRunnable(lockedSession, this.config.reviewScope)) {
1859
+ return { commit: false, value: null };
1860
+ }
1861
+ // Refresh checklist under the lock — pass-path callers may hold a stale
1862
+ // unchecked/next from before BEGIN IMMEDIATE (same class as E5c fail).
1863
+ let unchecked = checklist.unchecked;
1864
+ let next = checklist.next;
1865
+ let targets = null;
1866
+ const path = lockedSession.checklist_path?.trim() ?? "";
1867
+ const onChecklistPath = isChecklistExecuting(lockedSession) && path.length > 0;
1868
+ if (onChecklistPath) {
1869
+ const refreshed = this.parseSessionChecklist(lockedSession);
1870
+ if (!refreshed?.checklist) {
1871
+ return { commit: false, value: null };
1872
+ }
1873
+ const reviewingId = this.resolveReviewingItemId(fresh, refreshed.checklist, checklist.next?.id);
1874
+ targets = resolveAdvanceTargets(refreshed.checklist, reviewingId);
1875
+ unchecked = targets.unchecked;
1876
+ next = targets.current;
1877
+ }
1878
+ else {
1879
+ unchecked = 0;
1880
+ next = null;
1881
+ targets = null;
1882
+ }
1883
+ // Project-scope ambient/planning: end chain without checklist advance/done.
1884
+ if (!onChecklistPath) {
1885
+ if (isChecklistExecuting(lockedSession)) {
1886
+ // Executing with empty/missing checklist — legacy done path below.
1887
+ }
1888
+ else if (this.config.reviewScope === "project") {
1889
+ const completeMsg = this.render("review_complete", {
1890
+ total: this.config.confirmRounds,
1891
+ });
1892
+ const completeAction = {
1893
+ kind: "review_complete",
1894
+ message: completeMsg,
1895
+ loop: true,
1896
+ };
1897
+ this.store.upsertSession({
1898
+ conversation_id: cid,
1899
+ project_root: lockedSession.project_root,
1900
+ code_root: lockedSession.code_root,
1901
+ error_count: 0,
1902
+ idle_stop_count: 0,
1903
+ last_error: null,
1904
+ });
1905
+ this.store.updateReviewChain(cid, {
1906
+ ...chainReset,
1907
+ pending_followup: completeMsg,
1908
+ pending_followup_at: new Date().toISOString(),
1909
+ pending_redeliver_at: null,
1910
+ chain_pending: 0,
1911
+ });
1912
+ return { commit: true, value: completeAction };
1913
+ }
1914
+ else {
1915
+ return { commit: false, value: null };
1916
+ }
1917
+ }
1918
+ // When verify is armed (enabled + required cmds):
1919
+ // - foresaw done but items reappeared → abort (must re-enter e5Gate)
1920
+ // - verifiedPass for a specific item that changed/vanished → abort
1921
+ // Skip/disabled verify may freely refresh counts under the lock.
1922
+ const verifyArmed = this.config.verifyEnabled &&
1923
+ this.config.verifyCommands.some((c) => c.required === true);
1924
+ if (verifyArmed) {
1925
+ const foresawDone = checklist.unchecked === 0;
1926
+ if (foresawDone && unchecked > 0) {
1927
+ return { commit: false, value: null };
1928
+ }
1929
+ // verifiedPass foresaw checklist.next (sticky reviewing from e5Gate).
1930
+ const foresawId = checklist.next?.id;
1931
+ const activeId = targets?.current?.id ?? next?.id;
1932
+ if (checklist.verifiedPass &&
1933
+ foresawId &&
1934
+ activeId !== foresawId) {
1935
+ return { commit: false, value: null };
1936
+ }
1937
+ }
1938
+ const resolved = targets ??
1939
+ {
1940
+ current: next,
1941
+ next: null,
1942
+ unchecked,
1943
+ };
1944
+ // nextId/Title = item after the reviewing item (sticky), not bare secondUnchecked.
1945
+ const action = this.renderAdvanceOrDone(resolved);
1946
+ const isAdvance = action.kind === "advance";
1947
+ if (isAdvance) {
1948
+ this.store.upsertSession({
1949
+ conversation_id: cid,
1950
+ project_root: session.project_root,
1951
+ code_root: session.code_root,
1952
+ error_count: 0,
1953
+ idle_stop_count: 0,
1954
+ last_error: null,
1955
+ });
1956
+ // chain_pending=0 (same as E0 soft advance): product edits on the next
1957
+ // item still arm via afterFileEdit → code_edited. Leaving pending=1
1958
+ // forced E3 confirm on docs-only / ignore-only turns (phantom confirm).
1959
+ this.store.updateReviewChain(cid, {
1960
+ ...chainReset,
1961
+ // Stick the *next* item immediately so premature `[x]` before the
1962
+ // first product edit cannot retarget via firstUnchecked.
1963
+ reviewing_item_id: resolved.next?.id ?? null,
1964
+ pending_followup: action.message,
1965
+ pending_followup_at: new Date().toISOString(),
1966
+ pending_redeliver_at: null,
1967
+ chain_pending: 0,
1968
+ });
1969
+ }
1970
+ else {
1971
+ this.store.upsertSession({
1972
+ conversation_id: cid,
1973
+ project_root: session.project_root,
1974
+ code_root: session.code_root,
1975
+ phase: "done",
1976
+ armed: 0,
1977
+ error_count: 0,
1978
+ idle_stop_count: 0,
1979
+ last_error: null,
1980
+ });
1981
+ // pending kept for first-delivery / diagnostics; chain_pending must stay 0
1982
+ // so a later path cannot treat the chain as still active.
1983
+ this.store.updateReviewChain(cid, {
1984
+ ...chainReset,
1985
+ pending_followup: action.message,
1986
+ pending_followup_at: new Date().toISOString(),
1987
+ pending_redeliver_at: null,
1988
+ chain_pending: 0,
1989
+ });
1990
+ }
1991
+ return { commit: true, value: action };
1992
+ });
1993
+ }
1994
+ bumpProgress(session, _changed) {
1995
+ if (session.idle_stop_count > 0) {
1996
+ this.store.upsertSession({
1997
+ conversation_id: session.conversation_id,
1998
+ project_root: session.project_root,
1999
+ code_root: session.code_root,
2000
+ idle_stop_count: 0,
2001
+ });
2002
+ }
2003
+ }
2004
+ /**
2005
+ * Best-effort session bookkeeping after a followup was already committed
2006
+ * (pending_followup in exclusiveWrite). Must not throw into handleStop's
2007
+ * soft-fail catch — that would drop the committed action from the hook reply.
2008
+ */
2009
+ afterFollowupCommitted(session, changed) {
2010
+ try {
2011
+ this.bumpProgress(session, changed);
2012
+ this.noteCompletedOk(session);
2013
+ }
2014
+ catch {
2015
+ /* keep committed followup deliverable */
2016
+ }
2017
+ }
2018
+ incrementIdle(session) {
2019
+ const next = session.idle_stop_count + 1;
2020
+ if (next >= this.config.maxIdleStops) {
2021
+ this.store.upsertSession({
2022
+ conversation_id: session.conversation_id,
2023
+ project_root: session.project_root,
2024
+ code_root: session.code_root,
2025
+ idle_stop_count: next,
2026
+ paused: 1,
2027
+ paused_reason: "stuck",
2028
+ armed: 0,
2029
+ });
2030
+ // Same stop inject stuck once — caller gets verify_fix first in E5c;
2031
+ // for pure idle stuck, expose via checkStuckAfterStop
2032
+ }
2033
+ else {
2034
+ this.store.upsertSession({
2035
+ conversation_id: session.conversation_id,
2036
+ project_root: session.project_root,
2037
+ code_root: session.code_root,
2038
+ idle_stop_count: next,
2039
+ });
2040
+ }
2041
+ }
2042
+ /** After a no-progress stop that didn't inject, check stuck threshold. */
2043
+ checkStuck(session) {
2044
+ const fresh = this.store.getSession(session.conversation_id);
2045
+ if (!fresh)
2046
+ return null;
2047
+ if (fresh.paused === 1 && fresh.paused_reason === "stuck") {
2048
+ return {
2049
+ kind: "stuck",
2050
+ message: this.render("stuck", {}),
2051
+ loop: true,
2052
+ };
2053
+ }
2054
+ return null;
2055
+ }
2056
+ }
2057
+ /** Apply OFF trigger side effects (v0.1). */
2058
+ export function applyOff(store, conversationId) {
2059
+ const session = store.getSession(conversationId);
2060
+ if (!session)
2061
+ return null;
2062
+ if (session.phase === "done") {
2063
+ return store.upsertSession({
2064
+ conversation_id: conversationId,
2065
+ project_root: session.project_root,
2066
+ code_root: session.code_root,
2067
+ phase: "idle",
2068
+ armed: 0,
2069
+ paused: 0,
2070
+ paused_reason: null,
2071
+ });
2072
+ }
2073
+ // planning / executing checklist, or project-scope ambient (idle+armed)
2074
+ const ambientArmed = session.phase === "idle" && session.armed === 1 && session.paused === 0;
2075
+ if (session.phase === "planning" ||
2076
+ session.phase === "executing" ||
2077
+ ambientArmed) {
2078
+ const wasPaused = session.paused === 1;
2079
+ let pausedReason = session.paused_reason;
2080
+ if (!wasPaused) {
2081
+ pausedReason =
2082
+ session.phase === "executing" || session.phase === "idle"
2083
+ ? "human_gate"
2084
+ : null;
2085
+ }
2086
+ // already paused → keep original paused_reason
2087
+ return store.upsertSession({
2088
+ conversation_id: conversationId,
2089
+ project_root: session.project_root,
2090
+ code_root: session.code_root,
2091
+ armed: 0,
2092
+ paused: 1,
2093
+ paused_reason: pausedReason,
2094
+ // phase unchanged; review chain untouched
2095
+ });
2096
+ }
2097
+ return session;
2098
+ }
2099
+ /** Apply ON trigger side effects (v0.1). */
2100
+ export function applyOn(store, conversationId, projectRoot, opts) {
2101
+ const root = normalizeProjectRoot(store.projectRoot) ??
2102
+ normalizeProjectRoot(projectRoot);
2103
+ if (!root) {
2104
+ return { ok: false, userMessage: "Invalid project root." };
2105
+ }
2106
+ projectRoot = root;
2107
+ const session = store.getSession(conversationId);
2108
+ if (session?.phase === "executing") {
2109
+ return {
2110
+ ok: false,
2111
+ userMessage: "Autopilot is executing. Send Autopilot OFF, REPLAN, or RESUME before ON.",
2112
+ };
2113
+ }
2114
+ // Explicit slug (incl. "") must pass the shared gate — do not use truthiness
2115
+ // (empty string would otherwise skip validation and bind track_id "").
2116
+ if (opts?.slug !== undefined && !isSafeTrackSlug(opts.slug)) {
2117
+ const raw = typeof opts.slug === "string" ? opts.slug : String(opts.slug);
2118
+ return {
2119
+ ok: false,
2120
+ userMessage: `Invalid track slug "${sanitizeSessionDisplayText(raw).slice(0, 64)}".`,
2121
+ };
2122
+ }
2123
+ const trackId = opts?.slug ?? session?.track_id ?? "_pending";
2124
+ if (session?.phase === "done") {
2125
+ const s = store.upsertSession({
2126
+ conversation_id: conversationId,
2127
+ project_root: projectRoot,
2128
+ code_root: projectRoot,
2129
+ phase: "planning",
2130
+ armed: 0,
2131
+ paused: 0,
2132
+ paused_reason: null,
2133
+ track_id: opts?.slug ?? session.track_id,
2134
+ platform: session.platform,
2135
+ });
2136
+ return { ok: true, session: s };
2137
+ }
2138
+ const s = store.upsertSession({
2139
+ conversation_id: conversationId,
2140
+ project_root: projectRoot,
2141
+ code_root: projectRoot,
2142
+ platform: session?.platform ?? "cursor",
2143
+ phase: "planning",
2144
+ armed: 0,
2145
+ paused: 0,
2146
+ paused_reason: null,
2147
+ track_id: trackId,
2148
+ checklist_path: session?.checklist_path ?? "",
2149
+ });
2150
+ return { ok: true, session: s };
2151
+ }
2152
+ function finishLocalResume(store, conversationId, session) {
2153
+ // Speculative read + checklist I/O outside the write lock (never block other
2154
+ // writers on disk). Re-check live row under IMMEDIATE before applying.
2155
+ const snap = store.getSession(conversationId);
2156
+ if (!snap) {
2157
+ return null;
2158
+ }
2159
+ let armedOnUnpauseExecuting;
2160
+ if (snap.paused === 1 && snap.phase === "executing") {
2161
+ armedOnUnpauseExecuting = 0;
2162
+ if (snap.checklist_path) {
2163
+ const root = normalizeProjectRoot(store.projectRoot);
2164
+ if (root && isRealpathInsideProject(root, snap.checklist_path)) {
2165
+ try {
2166
+ armedOnUnpauseExecuting =
2167
+ countUnchecked(parseChecklist(snap.checklist_path, {
2168
+ projectRoot: root,
2169
+ })) > 0
2170
+ ? 1
2171
+ : 0;
2172
+ }
2173
+ catch {
2174
+ armedOnUnpauseExecuting = 0;
2175
+ }
2176
+ }
2177
+ }
2178
+ }
2179
+ return store.exclusiveWrite(() => {
2180
+ const live = store.getSession(conversationId);
2181
+ if (!live) {
2182
+ return { commit: false, value: null };
2183
+ }
2184
+ const patch = {
2185
+ conversation_id: conversationId,
2186
+ project_root: live.project_root || session.project_root,
2187
+ code_root: live.code_root || session.code_root,
2188
+ };
2189
+ const phase = live.phase;
2190
+ const paused = live.paused;
2191
+ if (paused === 1) {
2192
+ patch.paused = 0;
2193
+ patch.paused_reason = null;
2194
+ patch.error_count = 0;
2195
+ patch.idle_stop_count = 0;
2196
+ if (phase === "executing") {
2197
+ // Use precomputed armed only when phase/checklist still match the snap;
2198
+ // otherwise fail closed (armed=0) rather than parsing under the lock.
2199
+ if (armedOnUnpauseExecuting !== undefined &&
2200
+ snap.phase === "executing" &&
2201
+ snap.checklist_path === live.checklist_path) {
2202
+ patch.armed = armedOnUnpauseExecuting;
2203
+ }
2204
+ else {
2205
+ patch.armed = 0;
2206
+ }
2207
+ }
2208
+ if (phase === "idle") {
2209
+ patch.armed = 1;
2210
+ }
2211
+ }
2212
+ if (phase === "planning") {
2213
+ patch.armed = 0;
2214
+ }
2215
+ const updated = store.upsertSession(patch);
2216
+ try {
2217
+ store.clearPendingFollowupIf(conversationId, isRecoverOrStuckFollowupMessage);
2218
+ }
2219
+ catch {
2220
+ /* best-effort */
2221
+ }
2222
+ return { commit: true, value: updated };
2223
+ });
2224
+ }
2225
+ /**
2226
+ * Apply RESUME: unpause local session, or claim another conversation's track
2227
+ * onto this conversation_id (preserving review_chains).
2228
+ */
2229
+ export function applyResume(store, conversationId, opts) {
2230
+ if (!store.isConversationIdOk(conversationId)) {
2231
+ return { ok: false, userMessage: "Invalid conversation id." };
2232
+ }
2233
+ let session = store.getSession(conversationId);
2234
+ const slug = opts?.slug?.trim() || undefined;
2235
+ // Fail closed on illegal slug before localMatches / claim (idempotent retry-safe).
2236
+ if (slug && !isSafeTrackSlug(slug)) {
2237
+ return {
2238
+ ok: false,
2239
+ userMessage: `Invalid track slug "${sanitizeSessionDisplayText(slug).slice(0, 64)}".`,
2240
+ };
2241
+ }
2242
+ // Only planning/executing "own" the conversation for local resume.
2243
+ // idle (incl. ambient revive with a stale track_id) and done go through
2244
+ // claimSessionInto so a foreign executing session for the same slug can win.
2245
+ const localMatches = !!session &&
2246
+ (session.phase === "executing" || session.phase === "planning") &&
2247
+ (!slug || session.track_id === slug);
2248
+ if (!localMatches) {
2249
+ const claimed = store.claimSessionInto(conversationId, { slug });
2250
+ if (!claimed.ok)
2251
+ return claimed;
2252
+ session = claimed.session;
2253
+ }
2254
+ if (!session) {
2255
+ return { ok: true, session: null };
2256
+ }
2257
+ const updated = finishLocalResume(store, conversationId, session);
2258
+ if (!updated) {
2259
+ return {
2260
+ ok: false,
2261
+ userMessage: "Session moved concurrently; retry Autopilot RESUME.",
2262
+ };
2263
+ }
2264
+ return {
2265
+ ok: true,
2266
+ session: updated,
2267
+ };
2268
+ }
2269
+ /** resume_review: only chain_pending=1 */
2270
+ export function applyResumeReview(store, conversationId) {
2271
+ store.setChainPending(conversationId);
2272
+ }
2273
+ //# sourceMappingURL=review-engine.js.map