@cspeach/cli 1.1.26 → 1.1.28

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 (74) hide show
  1. package/dist/agent/loop.js +41 -5
  2. package/dist/agent/provider-shape.js +9 -2
  3. package/dist/agent/providers/ai-hub-provider.js +2 -2
  4. package/dist/agent/providers/byok-provider.js +2 -2
  5. package/dist/agent/providers/local-provider.js +4 -4
  6. package/dist/agent/system-prompt.js +34 -7
  7. package/dist/agent/tool-dispatch.js +20 -1
  8. package/dist/approvals/grant-claims.js +70 -0
  9. package/dist/approvals/jwt.js +21 -1
  10. package/dist/approvals/plan-grant.js +297 -0
  11. package/dist/commands/config-set.js +12 -0
  12. package/dist/commands/deploy-card.js +10 -5
  13. package/dist/commands/export-audit.js +1 -1
  14. package/dist/commands/plan-audit-evidence.js +47 -1
  15. package/dist/commands/plan-audit.js +23 -8
  16. package/dist/commands/plan-batch-audit.js +349 -0
  17. package/dist/commands/plan-boundary.js +88 -0
  18. package/dist/commands/plan-chain.js +409 -566
  19. package/dist/commands/plan-continue.js +21 -7
  20. package/dist/commands/plan-gate.js +28 -49
  21. package/dist/commands/plan-reground.js +174 -0
  22. package/dist/commands/plan-resume.js +227 -103
  23. package/dist/commands/plan-sap-pause.js +105 -0
  24. package/dist/commands/plan-startup.js +218 -0
  25. package/dist/commands/whoami.js +1 -1
  26. package/dist/config/loader.js +9 -0
  27. package/dist/doctor/checks/forge-rules.js +67 -85
  28. package/dist/doctor/checks/skill.js +60 -11
  29. package/dist/doctor/run.js +4 -6
  30. package/dist/lane/app-chain.js +116 -9
  31. package/dist/one-shot.js +1 -0
  32. package/dist/projects/extract-design.js +4 -3
  33. package/dist/projects/extract-estimate.js +4 -3
  34. package/dist/projects/extract-plan.js +34 -1
  35. package/dist/projects/extract-spec-gap.js +28 -5
  36. package/dist/projects/handover-md.js +1 -1
  37. package/dist/projects/plan-objects.js +75 -0
  38. package/dist/projects/plan-run.js +74 -18
  39. package/dist/projects/plan-schema.js +6 -0
  40. package/dist/projects/plan-sources.js +199 -0
  41. package/dist/projects/promote-command.js +1 -1
  42. package/dist/projects/save-command.js +15 -14
  43. package/dist/projects/saved-lines.js +36 -0
  44. package/dist/projects/title-heading.js +36 -0
  45. package/dist/renderer/checklist-format.js +5 -5
  46. package/dist/renderer/machine-blocks.js +141 -0
  47. package/dist/renderer/thinking-heartbeat.js +2 -1
  48. package/dist/renderer/tool-labels.js +3 -0
  49. package/dist/repl/plan-hop.js +22 -0
  50. package/dist/repl/rule8-detector.js +16 -1
  51. package/dist/repl.js +320 -55
  52. package/dist/router/router-core.js +2 -2
  53. package/dist/sap/reachability-state.js +25 -0
  54. package/dist/sap/unreachable.js +4 -1
  55. package/dist/session/audit-export.js +10 -16
  56. package/dist/session/recap.js +16 -26
  57. package/dist/skills/bundled-skills.js +1 -1
  58. package/dist/tools/_filesystem-shared.js +37 -0
  59. package/dist/tools/approval.js +48 -2
  60. package/dist/tools/dispatch-skill.js +3 -2
  61. package/dist/tools/filesystem/file-read.js +5 -3
  62. package/dist/tools/filesystem/glob.js +27 -4
  63. package/dist/tools/fiori/tools.js +6 -0
  64. package/dist/tools/lane/app-lane-resolve.js +1 -1
  65. package/dist/tools/sap-read.js +37 -3
  66. package/dist/tools/sap-write.js +65 -0
  67. package/dist/tools/text-elements/pool.js +250 -0
  68. package/dist/tools/text-elements/tools.js +433 -0
  69. package/dist/tools/tool-loading.js +3 -0
  70. package/dist/tools/transport.js +26 -5
  71. package/dist/tools/write-mode.js +4 -3
  72. package/package.json +3 -4
  73. package/dist/doctor/checks/zcspeach.js +0 -96
  74. package/dist/projects/email-template.js +0 -56
@@ -2,20 +2,23 @@
2
2
  //
3
3
  // Task 6 (agentic-flow, 2026-07-03) — the shared post-phase sequence, called
4
4
  // by BOTH repl.tsx plan-resume call sites (Ink + classic) so neither
5
- // duplicates the block. Exact order (the crash-safety design):
5
+ // duplicates the block.
6
6
  //
7
- // finishPlanResume persisted the phase WITH audit:'pending' (caller)
8
- // → runPhaseAudit (fresh-context, tool-less judge turn)
9
- // → applyAuditResult (verdict persisted as revision N+1)
10
- // → verdict handling (failed ⇒ re-run/waive/block; infra ⇒ stop,
11
- // 2nd consecutive infra ⇒ retry/waive/block)
12
- // → decideNextPhaseGate (step/guarded transition decision)
13
- // → offerNextPhaseAutoRun (prompt, or auto-queue in guarded mode)
7
+ // 2026-10-04 (continuous run, spec §3.1–§3.4) — one approval, then one run:
14
8
  //
15
- // A crash anywhere before applyAuditResult leaves 'pending' on disk;
16
- // preparePlanResume's re-audit-on-resume then runs this chain FIRST on the
17
- // next --resume (executedPhaseId = the pending phase, forceAudit) instead of
18
- // executing a new phase — the audit is never skipped.
9
+ // finishPlanResume persisted the step WITH audit:'pending' (caller)
10
+ // → decideNextPhaseGate guarded: chain on (write steps included);
11
+ // step: ask before each step
12
+ // → deploy card always, before a deploy step
13
+ // → queue the next step (the harness-built resume command)
14
+ //
15
+ // …and when the run ends without queueing a next step (last step, the user
16
+ // said No, a stop), the independent audit runs ONCE over every step still
17
+ // 'pending' (runBatchAudit), then the completion lines.
18
+ //
19
+ // A crash before the end-of-run audit leaves 'pending' on disk; the next
20
+ // resume in a new session checks those steps first (preparePlanResume →
21
+ // runReauditPass), so the audit is never skipped.
19
22
  //
20
23
  // Everything I/O-heavy or interactive is injectable via PlanChainDeps so the
21
24
  // chain is unit-testable without a provider, Ink, or inquirer.
@@ -23,69 +26,264 @@ import { basename } from 'node:path';
23
26
  import chalk from 'chalk';
24
27
  import { readProjectFile } from '../projects/status.js';
25
28
  import { parsePlanContent } from '../projects/plan-schema.js';
26
- import { statusesFromItems, computeNextPhase, isPhaseSatisfied, isAuditResolved, planCompletionLines, renderPlanTracker, } from '../projects/plan-run.js';
29
+ import { statusesFromItems, computeNextPhaseForRun, isPhaseSatisfiedForRun, planCompletionLines, phaseDisplayTitle, } from '../projects/plan-run.js';
30
+ import { savedLocation } from '../projects/saved-lines.js';
31
+ import { clearPlanGrant, getPlanGrant, getGrantTransport, installPlanGrant, noteDeltaOffered, offeredDeltaObjects, planGrantAllowed, } from '../approvals/plan-grant.js';
32
+ import { collectPlannedObjects, plannedObjectFacts } from '../projects/plan-objects.js';
27
33
  import { isDeployPhase } from '../projects/plan-lane.js';
28
34
  import { askDeployCard } from './deploy-card.js';
29
35
  import { decideNextPhaseGate, getAndResetPlanPhaseSubagentDispatches, getAndResetPlanDeviation, } from './plan-gate.js';
30
- import { runPhaseAudit } from './plan-audit.js';
31
36
  import { applyAuditResult, applyPhaseResolution, buildResumeCommand, offerNextPhaseAutoRun, } from './plan-resume.js';
32
- import { appendCostLine, buildEntry } from '../cost/cost-log.js';
33
- import { getGlyphs } from '../renderer/glyphs.js';
34
- import { formatFailure, formatAttention, formatInfo } from '../renderer/notice-shape.js';
35
- import { paint } from '../renderer/theme.js';
37
+ import { formatFailure, formatInfo } from '../renderer/notice-shape.js';
36
38
  import { clean } from '../renderer/tool-labels.js';
37
- import { startThinkingHeartbeat, AUDIT_HEARTBEAT_THRESHOLDS } from '../renderer/thinking-heartbeat.js';
38
- import { computeCost, formatCost } from '../cost/pricing.js';
39
- import { creditsActive } from '../cost/credits.js';
40
- /** Two consecutive failed verdicts on the same phase force status 'blocked'. */
41
- export const MAX_CONSECUTIVE_AUDIT_FAILURES = 2;
42
- /** Infra error strings are model/HTTP noise — clamp when rendering (Task 5 review). */
43
- const INFRA_RENDER_CHARS = 200;
39
+ import { runBatchAudit, pendingAuditPhaseIds, selectPlanCardAction, } from './plan-batch-audit.js';
40
+ // The audit menus and the failure cap live in plan-batch-audit.ts; re-exported
41
+ // for existing callers.
42
+ export { MAX_CONSECUTIVE_AUDIT_FAILURES, defaultSelectFailureAction, defaultSelectWaiveCategory, } from './plan-batch-audit.js';
43
+ /** After this many "Fix it now" on one step in one run, the card offers only Skip / Stop. */
44
+ export const MAX_FAILED_CHECK_FIXES = 2;
45
+ /**
46
+ * runBatchAudit can throw (a revision failed validation, the save failed).
47
+ * The chain shows one plain line and stops; whatever was not recorded stays
48
+ * 'pending', so the next resume checks it again.
49
+ */
50
+ async function batchAuditOrNull(args) {
51
+ try {
52
+ return await runBatchAudit(args);
53
+ }
54
+ catch {
55
+ args.log(formatFailure({
56
+ what: 'The independent check could not finish',
57
+ detail: ['the steps it did not record stay unchecked — CSPeach checks them on the next resume'],
58
+ }).trimEnd());
59
+ return null;
60
+ }
61
+ }
62
+ /**
63
+ * The checked steps whose check FAILED and got no answer: the audit says
64
+ * failed while the step still claims done. A step the user marked blocked
65
+ * (status blocked) or chose to re-run (status todo) is not one of them.
66
+ */
67
+ function unansweredFailures(content, statuses, ids) {
68
+ const want = new Set(ids);
69
+ return content.phases.filter((p) => want.has(p.id) && p.audit?.state === 'failed'
70
+ && (statuses[p.id] === 'validated' || statuses[p.id] === 'validated-with-waiver'));
71
+ }
72
+ /**
73
+ * "Fix it now" count per step for this run (in memory). Keyed by the plan's
74
+ * stable id + the step id; cleared when the run ends without queueing a step.
75
+ */
76
+ const failedCheckFixes = new Map();
77
+ /** Test seam — forget every fix count. */
78
+ export function resetFailedCheckFixAttempts() {
79
+ failedCheckFixes.clear();
80
+ }
81
+ /** One short display line from model text. */
82
+ function shortLine(s, max = 200) {
83
+ const c = clean(s);
84
+ return c.length > max ? `${c.slice(0, max - 1)}…` : c;
85
+ }
86
+ /**
87
+ * The card's facts for a step that ended blocked by its own check: what failed
88
+ * (the reason the step saved — an explicit blocked reason, else its notes,
89
+ * else its own check), the snapshot when one was recorded, and that nothing
90
+ * after it has run. Never field names.
91
+ */
92
+ export function failedCheckFacts(p) {
93
+ const work = (p.work ?? {});
94
+ const reason = typeof work.blockedReason === 'string' && work.blockedReason.trim()
95
+ ? work.blockedReason
96
+ : work.notes?.trim()
97
+ ? work.notes
98
+ : null;
99
+ const facts = [reason
100
+ ? `What failed: ${shortLine(reason)}`
101
+ : `What failed: its own check did not hold — ${shortLine(p.exitGate)}`];
102
+ if (work.snapshot?.trim())
103
+ facts.push(`Snapshot: ${shortLine(work.snapshot, 120)}`);
104
+ facts.push('Nothing after this step has run.');
105
+ return facts;
106
+ }
107
+ /** Production failed-check card (same surface as the audit menus). */
108
+ export async function defaultSelectFailedCheckAction(a) {
109
+ const choices = [
110
+ ...(a.canFix ? [{ value: 'fix', label: 'Fix it now (CSPeach runs this step again)' }] : []),
111
+ { value: 'skip', label: 'Skip this step (record a one-line reason)' },
112
+ { value: 'stop', label: 'Stop' },
113
+ ];
114
+ const picked = await selectPlanCardAction({
115
+ id: 'plan-failed-check',
116
+ question: `${a.title} could not finish`,
117
+ facts: a.facts,
118
+ choices,
119
+ footnote: 'Plan run',
120
+ });
121
+ return picked === 'none' ? 'stop' : picked;
122
+ }
123
+ /**
124
+ * 2026-10-04 — a design step may name objects the approved list did not have.
125
+ * Ask ONCE to add them (instead of a card per write). Only objects not yet
126
+ * offered for this plan in this session are asked about: a No (or no answer)
127
+ * is remembered, so the same card never comes back at the next step; those
128
+ * writes ask one by one, as any off-list write does. Only extends a live
129
+ * grant of THIS plan and session, and never where a grant may not be minted
130
+ * (production, advisory-only, per-write approvals).
131
+ */
132
+ export async function offerObjectListDelta(a) {
133
+ const g = getPlanGrant();
134
+ if (!g || g.planId !== a.planId || g.sessionId !== a.sessionId)
135
+ return 'none';
136
+ const listed = new Set(g.objects.map((o) => `${o.type}:${o.name}`));
137
+ const offered = offeredDeltaObjects(a.planId);
138
+ const added = collectPlannedObjects(a.content)
139
+ .filter((o) => !listed.has(`${o.type}:${o.name}`) && !offered.has(`${o.type}:${o.name}`));
140
+ if (added.length === 0)
141
+ return 'none';
142
+ if (!(await planGrantAllowed(a.sapAlias)))
143
+ return 'none';
144
+ noteDeltaOffered(a.planId, added);
145
+ const confirm = a.confirm ?? (async (req) => (await import('../ui/confirm-request.js')).askConfirm(req));
146
+ const one = added.length === 1;
147
+ const res = await confirm({
148
+ id: 'plan-object-delta',
149
+ tag: 'QUESTION',
150
+ title: `The design added ${added.length} object${one ? '' : 's'} to the plan. Add ${one ? 'it' : 'them'} to the approved list?`,
151
+ facts: plannedObjectFacts(added),
152
+ choices: [{ value: 'yes', label: 'Yes' }, { value: 'no', label: 'No — ask before each of these writes' }],
153
+ initial: 0,
154
+ aliases: { y: 'yes', n: 'no' },
155
+ });
156
+ if (res.value !== 'yes' || res.cancelled)
157
+ return 'declined';
158
+ const transport = getGrantTransport();
159
+ const ok = await installPlanGrant({
160
+ planId: g.planId, sessionId: g.sessionId, objects: [...g.objects, ...added],
161
+ ...(transport ? { transport } : {}), ...(a.sapAlias ? { sapAlias: a.sapAlias } : {}),
162
+ }).catch(() => false);
163
+ if (!ok) {
164
+ a.log(`The added object${one ? '' : 's'} could not be approved. Each SAP write to ${one ? 'it' : 'them'} asks first.`);
165
+ return 'declined';
166
+ }
167
+ a.log(formatInfo(`${added.length} object${one ? '' : 's'} added to the approved list`).trimEnd());
168
+ return 'added';
169
+ }
44
170
  /**
45
171
  * Run the post-phase chain. Returns the LATEST saved envelope path (the
46
172
  * caller's savedPath when nothing was written), or null when the chain could
47
173
  * not even load the envelope.
48
174
  */
49
175
  export async function runPostPhaseChain(args) {
176
+ // 2026-10-04 (continuous run, spec §3.2) — the plan object-list grant lives
177
+ // for one run: when the chain ends without queueing a next step (plan
178
+ // complete, a stop, the user said No, or the chain failed), it is cleared,
179
+ // so a later resume asks again.
180
+ let queuedNext = false;
181
+ const tracked = {
182
+ ...args,
183
+ queueDispatch: (cmd) => { queuedNext = true; args.queueDispatch(cmd); },
184
+ };
185
+ try {
186
+ return await postPhaseChain(tracked);
187
+ }
188
+ finally {
189
+ if (!queuedNext && !args.suppressOffer)
190
+ clearPlanGrant();
191
+ }
192
+ }
193
+ async function postPhaseChain(args) {
50
194
  let path = args.savedPath;
51
195
  let loaded = loadPlan(path, args.log);
52
196
  if (!loaded)
53
197
  return null;
54
- let phase = loaded.content.phases.find((p) => p.id === args.executedPhaseId);
198
+ const phase = loaded.content.phases.find((p) => p.id === args.executedPhaseId);
55
199
  if (!phase) {
56
- args.log(formatFailure({ what: 'Plan phase not found', object: args.executedPhaseId, detail: [`${basename(path)} · follow-up steps skipped`] }).trimEnd());
200
+ args.log(formatFailure({ what: 'Plan step not found', detail: [`${basename(path)} · follow-up steps skipped`] }).trimEnd());
57
201
  return path;
58
202
  }
59
203
  // Per-run flags survive the chain: an explicitly flagged run keeps its mode
60
- // AND its audit setting across every harness-built resume command (I2 — the
61
- // audit flag mirrors modeFlag; --audit maps to itself, 'off' to --no-audit).
204
+ // AND its audit setting across every harness-built resume command.
62
205
  const flagSuffix = (args.modeFlag ? ` --${args.modeFlag}` : '')
63
206
  + (args.auditFlag === 'off' ? ' --no-audit' : args.auditFlag === 'on' ? ' --audit' : '');
64
207
  const resumeCmd = (p) => buildResumeCommand(p) + flagSuffix;
65
- let chainWrote = false;
66
- // Task 7 — phase-end subagent report. agent_run counted every dispatch
67
- // made while the plan-phase flag was on (all forced read-only). The REPL
68
- // reads+resets the counter in its runTurn finally and threads the value
69
- // here (fix-wave, item 1) — this chain can be skipped on error paths, so
70
- // consuming the counter here alone would let counts leak into the next
71
- // phase's report line. The read+reset below is only the fallback for
72
- // callers that ran no phase turn (re-audit passes, direct test calls) —
73
- // zero there in practice, so nothing is printed.
208
+ let queued = false;
209
+ let stopShown = false;
210
+ const queue = (cmd) => { queued = true; args.queueDispatch(cmd); };
211
+ const titleOf = (p) => phaseDisplayTitle(p, loaded?.content.phases ?? []);
212
+ const batchArgs = (p, phaseIds, force) => ({
213
+ savedPath: p, phaseIds, sessionId: args.sessionId, cwd: args.cwd,
214
+ ...(force ? { force: true } : {}),
215
+ prompt: args.prompt, log: args.log, ...(args.deps ? { deps: args.deps } : {}),
216
+ });
217
+ // The run ended without queueing a next step: audit every step still
218
+ // 'pending' once (spec §3.4), then the completion lines + where the file is.
219
+ const endOfRun = async (p) => {
220
+ if (queued)
221
+ return p;
222
+ let cur = p;
223
+ const l = loadPlan(cur, args.log);
224
+ if (!l)
225
+ return cur;
226
+ // Under skipEndAudit only the steps runReauditPass just checked are
227
+ // skipped — any other step still pending is checked here, so "Plan
228
+ // complete" never prints over an unchecked step.
229
+ const justChecked = new Set(args.skipEndAudit ? args.checkedPhaseIds ?? [] : []);
230
+ const ids = pendingAuditPhaseIds(l.content).filter((id) => !justChecked.has(id));
231
+ let autoBlocked = args.reauditFailed === true;
232
+ if (ids.length > 0) {
233
+ const r = await batchAuditOrNull(batchArgs(cur, ids));
234
+ if (!r)
235
+ return cur;
236
+ cur = r.path;
237
+ if (r.rerun) {
238
+ queue(resumeCmd(cur));
239
+ return cur;
240
+ }
241
+ autoBlocked = autoBlocked || r.blocked.length > 0;
242
+ }
243
+ const after = loadPlan(cur, args.log);
244
+ if (after) {
245
+ const st = statusesFromItems(after.envelope.interaction.items, after.content.phases);
246
+ const auditFailed = autoBlocked
247
+ || unansweredFailures(after.content, st, [...ids, ...(args.checkedPhaseIds ?? [])]).length > 0;
248
+ // F25b — a step whose audit is still deferred ('pending': the check
249
+ // could not run) counts as built; the batch audit already said so.
250
+ const built = after.content.phases.every((q) => isPhaseSatisfiedForRun(st[q.id], q.audit));
251
+ const nextAfter = computeNextPhaseForRun(after.content.phases, st);
252
+ if (built && !nextAfter) {
253
+ args.log('', ...planCompletionLines(after.content.phases));
254
+ args.log(`Plan file: ${savedLocation(cur, args.cwd)}`);
255
+ }
256
+ else if (!built && auditFailed && !stopShown) {
257
+ // A failed check left unresolved (blocked, or no answer): say how to go on.
258
+ args.log(stoppedBanner('a step failed its independent check', resumeCmd(cur)));
259
+ }
260
+ else if (!built && !nextAfter && !stopShown) {
261
+ // Nothing left to run, but a step is blocked (marked blocked, or
262
+ // auto-blocked earlier) and the other steps finished: never end in
263
+ // silence. Name the blocked step(s) and how to go on.
264
+ const phases = after.content.phases;
265
+ const blocked = phases.filter((q) => st[q.id] === 'blocked');
266
+ const open = blocked.length > 0 ? blocked : phases.filter((q) => !isPhaseSatisfiedForRun(st[q.id], q.audit));
267
+ const names = open.map((q) => phaseDisplayTitle(q, phases)).join(', ');
268
+ args.log(stoppedBanner(`${names} ${open.length === 1 ? 'is' : 'are'} blocked; every other step it could run is done`, resumeCmd(cur)));
269
+ }
270
+ }
271
+ // The run is over: the next run starts with fresh fix counts.
272
+ if (!queued)
273
+ failedCheckFixes.clear();
274
+ return cur;
275
+ };
276
+ // Task 7 — phase-end helper report (all forced read-only inside plan steps).
74
277
  const subagentDispatches = args.subagentDispatches ?? getAndResetPlanPhaseSubagentDispatches();
75
278
  if (subagentDispatches > 0) {
76
- args.log(chalk.dim(`[phase] ${args.executedPhaseId}: ${subagentDispatches} subagent dispatch(es) — read-only enforced`));
279
+ args.log(chalk.dim(`${titleOf(phase)}: ${subagentDispatches} read-only helper${subagentDispatches === 1 ? '' : 's'} used`));
77
280
  }
78
- /* ── 0. deviation backstop (Task 10) ──────────────────────────────────────
79
- * approval.ts raised the flag mid-turn: this phase declared writes:false
80
- * but requested a write approval anyway — the write was DENIED without
81
- * prompting; here the chain records why and stops. Design choice (per the
82
- * task's simpler-path option): SKIP the audit — the phase didn't complete
83
- * honestly and was deliberately stopped, so auditing it wastes a turn. The
84
- * finishPlanResume-injected 'pending' audit is dropped in the same write
85
- * (clearAudit) or the next resume would re-audit the blocked phase. Same
86
- * read+reset pattern as the subagent counter — the flag never leaks into
87
- * the next chain leg. Guarded-only by construction: approval.ts only ever
88
- * raises it while isGuardedRunActive(). */
281
+ /* ── 0. deviation backstop (Task 10, unchanged semantics) ────────────────
282
+ * approval.ts raised the flag mid-turn: this step declared writes:false but
283
+ * requested a write approval anyway — the write was DENIED without
284
+ * prompting; here the chain records why and stops. The injected 'pending'
285
+ * audit is dropped in the same write (clearAudit): the step did not finish
286
+ * honestly, so there is nothing to check. */
89
287
  const deviationDetail = getAndResetPlanDeviation();
90
288
  if (deviationDetail) {
91
289
  path = await applyPhaseResolution(path, phase.id, {
@@ -95,295 +293,119 @@ export async function runPostPhaseChain(args) {
95
293
  clearAudit: true,
96
294
  log: args.log,
97
295
  });
98
- args.log(formatFailure({ what: 'Plan deviation', object: `phase ${phase.id}`, detail: [deviationDetail] }).trimEnd(), stoppedBanner(`plan deviation — phase ${phase.id} blocked (undeclared write denied)`, resumeCmd(path)));
296
+ args.log(formatFailure({ what: 'Step went off plan', object: titleOf(phase), detail: ['it asked to write to SAP, but the plan says this step only reads — the write was refused'] }).trimEnd(), stoppedBanner(`${titleOf(phase)} stopped — it asked for a write the plan did not declare`, resumeCmd(path)));
99
297
  ringBell();
100
- return path;
298
+ stopShown = true;
299
+ return endOfRun(path);
101
300
  }
102
- /* ── 1. audit (only when unresolved-pending/infra, or forced) ─────────── */
103
- // Off-switch: audits off skips the NORMAL post-phase audit (no injected
104
- // pending exists to catch anyway). A forced re-audit still runs — a persisted
105
- // pending/infra record from an earlier ON run must resolve regardless.
106
- const auditState = phase.audit?.state;
107
- const auditsOff = args.auditMode === 'off';
108
- // audit-infra-continue (2026-07-09): 'infra_failed' is now RESOLVED and no
109
- // longer auto-re-audited — an audit that could not run already let the chain
110
- // continue, so it must not spontaneously re-run on a later pass. Only a
111
- // persisted 'pending' (crash between phase-save and verdict-save) or an
112
- // explicit forceAudit (hand-edit re-verification) triggers an audit here.
113
- const needsAudit = args.forceAudit
114
- || (!auditsOff && auditState === 'pending');
115
- if (needsAudit) {
116
- args.log('', chalk.dim(`[audit] independent audit of phase ${phase.id} — fresh context, judged against the tool-call evidence…`));
117
- // Evidence lives with the session that EXECUTED the phase; on a re-audit
118
- // in a fresh CLI session that id was recorded on the pending audit.
119
- const evidenceSessionId = phase.audit?.sessionId ?? args.sessionId;
120
- const auditFn = args.deps?.runPhaseAuditFn ?? runPhaseAudit;
121
- let cost;
122
- // D-B — window the evidence to THIS attempt only. Live post-phase audit:
123
- // the repl threads the turn-start it captured (attemptStartedAt).
124
- // RE-audit (review item 1): the OLD session's JSONL can hold MULTIPLE
125
- // attempts (fail → rerun in the same process → die before verdict →
126
- // resume re-audits), so an unwindowed re-audit re-creates the exact
127
- // cross-attempt false-ordering on the standard recovery path — use the
128
- // window finishPlanResume persisted on the audit record
129
- // (audit.attemptStartedAt, carried through verdict writes). A legacy
130
- // record without the field runs unwindowed (fail-open, no window claim
131
- // is made to the auditor either — see buildAuditPrompt).
132
- const sinceIso = args.attemptStartedAt ?? phase.audit?.attemptStartedAt;
133
- // D-F item 4 — liveness. The audit is a single fresh-context model turn
134
- // (10-40s) with no streaming into this chain, so the [audit] header sat
135
- // silent. Reuse the house-style heartbeat (same primitive loop.ts uses),
136
- // routed through args.log so it works under both Ink and classic.
137
- // audit-timeout (2026-07-06): use the audit-specific schedule — the audit
138
- // is a PROXY call (api.cspeach.dev), so the generic "check the VPN" 5m copy
139
- // is the wrong diagnosis. runPhaseAudit now hard-caps each attempt at 120s,
140
- // so this can never sit stuck forever. Attempt transitions are surfaced via
141
- // onRetry below. Always stopped in finally.
142
- // audit-lean FIX #2 — a LIVE token count threaded from the auditor's stream
143
- // into the heartbeat suffix, so a stall (`· 0 tokens`) reads differently
144
- // from slow-but-working progress. The suffix closure reads the latest value
145
- // at each heartbeat tick; onProgress updates it as chunks arrive.
146
- let auditTokens = 0;
147
- const heartbeat = startThinkingHeartbeat({
148
- thresholds: AUDIT_HEARTBEAT_THRESHOLDS,
149
- emit: (line) => args.log(line),
150
- suffix: () => ` · ${auditTokens.toLocaleString()} token${auditTokens === 1 ? '' : 's'}`,
151
- });
152
- let verdict;
153
- try {
154
- verdict = await auditFn({
155
- envelope: loaded.envelope,
156
- phaseId: phase.id,
157
- sessionId: evidenceSessionId,
158
- cwd: args.cwd,
159
- ...(sinceIso ? { sinceIso } : {}),
160
- onCost: (c) => { cost = c; },
161
- // Best-effort: the heartbeat reads auditTokens at each tick.
162
- onProgress: (n) => { auditTokens = n; },
163
- // audit-timeout — an honest attempt-transition line when a prior
164
- // attempt hit its deadline (or otherwise failed) and we retry in a
165
- // fresh session. Keeps the timeline truthful against the 120s×3 ceiling.
166
- onRetry: (r) => {
167
- const why = r.reason === 'timeout' ? 'previous attempt timed out'
168
- : r.reason === 'unparseable' ? 'previous attempt returned no verdict'
169
- : 'previous attempt errored';
170
- args.log(chalk.dim(` [audit] attempt ${r.attempt} of ${r.totalAttempts} (${why})`));
171
- },
172
- });
173
- }
174
- finally {
175
- heartbeat.stop();
301
+ /* ── 1. forced re-check (hand-edit re-verification) ──────────────────── */
302
+ if (args.forceAudit) {
303
+ const r = await batchAuditOrNull(batchArgs(path, [phase.id], true));
304
+ if (!r)
305
+ return path;
306
+ path = r.path;
307
+ if (r.rerun) {
308
+ queue(resumeCmd(path));
309
+ return path;
176
310
  }
177
- if (cost)
178
- renderAuditCost(args, cost);
179
- // Fix-wave (item 10a) — a persisted 'failed' reaching a re-audit means
180
- // the session died AT the failure menu (verdict written, resolution
181
- // never was). A failed verdict then RE-CONFIRMS the same failure: hold
182
- // the counter instead of incrementing, and re-render the menu — the
183
- // developer must still get the re-run/waive/block choice they lost.
184
- // Delta-review edge: an interleaved infra outage launders the same case
185
- // — failed(1) → menu death → re-audit hits infra (the counter is carried
186
- // onto infra_failed) → the NEXT re-audit's failed verdict would count as
187
- // consecutive and silently force-block. A persisted infra_failed that
188
- // CARRIES a failure counter is therefore also a reconfirmation: the
189
- // menu was owed and never shown.
190
- const reconfirmingFailed = auditState === 'failed'
191
- || (auditState === 'infra_failed' && (phase.audit?.consecutiveFailures ?? 0) >= 1);
192
- // Consecutive-failure decision happens BEFORE the write so the forced
193
- // block lands in the SAME revision as the verdict.
194
- const priorFailures = phase.audit?.consecutiveFailures ?? 0;
195
- const countedFailures = reconfirmingFailed
196
- ? Math.max(priorFailures, 1)
197
- : priorFailures + 1;
198
- const forceBlock = verdict.state === 'failed'
199
- && countedFailures >= MAX_CONSECUTIVE_AUDIT_FAILURES;
200
- const nowIso = new Date().toISOString();
201
- // Only a real 'failed' verdict records a stop. 'infra_failed' no longer
202
- // stops (audit-infra-continue) — like warn/pass it leaves no lastStop, so
203
- // applyAuditResult clears any stale one (see its resolved-verdict branch).
204
- const lastStop = verdict.state === 'failed'
205
- ? { phaseId: phase.id, reason: 'audit-failed', detail: clampInfra(verdict.findings[0] ?? ''), at: nowIso }
206
- : undefined;
207
- // Confidence-tiered redesign (2026-07-08) — the GATE. A `warn` verdict is
208
- // NON-BLOCKING: it persists LITERALLY (state:'warn', findings carried),
209
- // clears the failure counter (a warn is not a failure), and the chain
210
- // continues exactly like a pass — no menu, no lastStop, forceBlock can
211
- // never fire on it (forceBlock keys off verdict.state === 'failed' above).
212
- // fail / infra_failed / pass persistence is byte-unchanged.
213
- path = await applyAuditResult(path, phase.id, {
214
- state: verdict.state,
215
- findings: verdict.findings,
216
- }, {
217
- lastStop,
218
- ...(forceBlock ? { statusOverride: 'blocked' } : {}),
219
- ...(reconfirmingFailed ? { reconfirmedFailure: true } : {}),
220
- log: args.log,
221
- });
222
- chainWrote = true;
223
311
  loaded = loadPlan(path, args.log);
224
312
  if (!loaded)
225
313
  return path;
226
- phase = loaded.content.phases.find((p) => p.id === args.executedPhaseId);
227
- // D-E — the progress board renders HERE, AFTER the verdict lands, so the
228
- // tally is audit-aware and never contradicts a later verdict. (finishPlanResume
229
- // now prints only a neutral "audit pending…" line; the board it used to
230
- // print counted the just-run phase as done because it erased the pending
231
- // audit for display — a validated phase with a pending audit is NOT
232
- // satisfied, so the pre-audit board read "2/3" for what was really "1/3".)
233
- // Suppressed on mid-pass re-audit legs (runReauditPass renders once at the
234
- // end via its final, unsuppressed leg).
235
- if (!args.suppressOffer) {
236
- const boardStatuses = statusesFromItems(loaded.envelope.interaction.items, loaded.content.phases);
237
- args.log('', renderPlanTracker({
238
- title: loaded.envelope.title,
239
- version: loaded.envelope.version,
240
- content: loaded.content,
241
- statuses: boardStatuses,
242
- currentId: null,
243
- auditsOff,
244
- }), '');
245
- }
246
- if (verdict.state === 'infra_failed') {
247
- // audit-infra-continue (2026-07-09): the audit COULD NOT RUN — after the
248
- // in-turn 3 attempts (runPhaseAudit's 120s×3 ceiling) the model call
249
- // returned no output. That is an audit-INFRASTRUCTURE failure, not a
250
- // finding against the phase, whose own exit gate (activation + ATC)
251
- // already passed. So we do NOT hard-stop verified work: exactly like a
252
- // warn/pass, render a LOUD, DISTINCT "could not run / NOT verified" note,
253
- // keep the infra_failed state recorded (tracker keeps "! audit not run";
254
- // the final summary lists it as unverified) and CONTINUE to the gate +
255
- // offer below. isAuditResolved treats infra_failed as resolved, so
256
- // dependants unlock and the failure menu is never shown.
257
- renderAuditNotRun(args.log, phase.id, clampInfra(verdict.findings[0] ?? 'auditor infrastructure failure'), resumeCmd(path));
258
- }
259
- else if (verdict.state === 'failed') {
260
- renderFindings(args.log, phase.id, verdict.findings);
261
- if (forceBlock) {
262
- args.log(formatFailure({ what: `Phase ${phase.id} failed its audit ${countedFailures} times in a row`, detail: ['marked blocked'] }).trimEnd(), stoppedBanner('two consecutive failed audits — phase blocked', resumeCmd(path)));
263
- ringBell();
264
- return path;
265
- }
266
- const select = args.deps?.selectFailureAction ?? defaultSelectFailureAction;
267
- const action = await select({ phaseId: phase.id, findings: verdict.findings });
268
- if (action === 'none') {
269
- // D-C — fail CLOSED. 'none' is headless OR an interactive decline/cancel
270
- // (Esc, no listener, or an unrecognised menu answer). The phase must NOT
271
- // auto-re-run — a re-run is an explicit menu choice, never a default.
272
- // Stop with the banner; resuming re-audits and re-offers the menu (the
273
- // persisted 'failed' on a still-satisfied phase is re-caught by
274
- // preparePlanResume's died-at-menu scan).
275
- args.log(stoppedBanner(`audit failed for phase ${phase.id} — resolve interactively (re-run / waive / block)`, resumeCmd(path)));
276
- ringBell();
277
- return path;
278
- }
279
- if (action === 'blocked') {
280
- path = await applyPhaseResolution(path, phase.id, {
281
- status: 'blocked',
282
- lastStop: { phaseId: phase.id, reason: 'audit-failed', detail: 'user marked the phase blocked after a failed audit', at: new Date().toISOString() },
283
- summary: `audit ${phase.id}: failed — user marked phase blocked`,
284
- log: args.log,
285
- });
286
- args.log(stoppedBanner(`phase ${phase.id} marked blocked after failed audit`, resumeCmd(path)));
287
- ringBell();
288
- return path;
289
- }
290
- if (action === 'rerun') {
291
- // Status back to 'todo' — computeNextPhase re-picks it, and the flow
292
- // ALWAYS audits an executed phase, so a re-run is re-audited by
293
- // construction. The failed audit + counter stay on the phase until
294
- // the re-run's finishPlanResume replaces them with 'pending'.
295
- path = await applyPhaseResolution(path, phase.id, {
296
- status: 'todo',
297
- summary: `audit ${phase.id}: failed — user chose re-run (status → todo)`,
298
- log: args.log,
299
- });
300
- args.log(formatInfo(`Re-running phase ${phase.id} · it will be re-audited after execution.`).trimEnd());
301
- args.queueDispatch(resumeCmd(path));
302
- return path;
303
- }
304
- // action === 'waive'
305
- const reasonRaw = (await args.prompt(`Waive reason for ${phase.id} (one line): `)).trim();
306
- const reason = reasonRaw.length > 0 ? reasonRaw : 'waived by user (no reason given)';
307
- // §10 Q2 — a light 2-choice category alongside the reason: was the audit
308
- // WRONG (false-positive) or is the finding real-but-accepted
309
- // (accepted-risk)? Persisted so the audit's FP rate is measurable.
310
- // Headless / cancel → accepted-risk (never a false-positive unasserted).
311
- const category = await (args.deps?.selectWaiveCategory ?? defaultSelectWaiveCategory)({ phaseId: phase.id });
312
- path = await applyAuditResult(path, phase.id, { state: 'waived', waivedReason: reason, waiveCategory: category }, { log: args.log });
313
- loaded = loadPlan(path, args.log);
314
- if (!loaded)
315
- return path;
316
- phase = loaded.content.phases.find((p) => p.id === args.executedPhaseId);
317
- args.log(paint('dim', `${getGlyphs().info} audit waived (${category}) for ${phase.id} — reason recorded in the envelope.`));
318
- }
319
- else if (verdict.state === 'warn') {
320
- // Non-blocking tier: ALWAYS shown, concise (Open-Q decision — warns are
321
- // never swallowed). The chain continues to the gate + offer below just
322
- // like a pass; the notes are already recorded on phase.audit.findings.
323
- renderWarnings(args.log, phase.id, verdict.findings);
324
- }
325
- else {
326
- args.log(paint('ok', `${getGlyphs().done} audit passed · ${phase.id} verified against the evidence.`));
327
- }
328
314
  }
329
- /* ── 2. gate + offer ──────────────────────────────────────────────────── */
330
- // Review I-2 — mid-pass re-audits stop after the verdict: the pass owner
331
- // (runReauditPass) issues one offer at the end.
332
315
  if (args.suppressOffer)
333
316
  return path;
334
- const statuses = statusesFromItems(loaded.envelope.interaction.items, loaded.content.phases);
335
- const next = computeNextPhase(loaded.content.phases, statuses);
336
- if (!next) {
337
- // Terminal messages only when this chain changed something — otherwise
338
- // finishPlanResume already printed the same conclusion (no duplicates).
339
- if (chainWrote) {
340
- if (loaded.content.phases.every((p) => isPhaseSatisfied(statuses[p.id], p.audit))) {
341
- args.log(...planCompletionLines(loaded.content.phases));
317
+ /* ── 1b. failed-check stop card (spec §3.3 #3, §5) ──────────────────────
318
+ * The step that just ran ended blocked: its own check failed after its own
319
+ * fix-and-recheck. Only on the normal post-phase call — a step blocked by
320
+ * the independent check (re-audit pass / forced re-check) has its own menu. */
321
+ if (!args.checkedPhaseIds && !args.forceAudit) {
322
+ const st0 = statusesFromItems(loaded.envelope.interaction.items, loaded.content.phases);
323
+ const ran = loaded.content.phases.find((p) => p.id === args.executedPhaseId);
324
+ if (ran && st0[ran.id] === 'blocked') {
325
+ const title = titleOf(ran);
326
+ const key = `${loaded.envelope.id}:${ran.id}`;
327
+ const fixes = failedCheckFixes.get(key) ?? 0;
328
+ const select = args.deps?.selectFailedCheckAction ?? defaultSelectFailedCheckAction;
329
+ const action = await select({ phaseId: ran.id, title, facts: failedCheckFacts(ran), canFix: fixes < MAX_FAILED_CHECK_FIXES });
330
+ if (action === 'fix' && fixes < MAX_FAILED_CHECK_FIXES) {
331
+ failedCheckFixes.set(key, fixes + 1);
332
+ path = await applyPhaseResolution(path, ran.id, {
333
+ status: 'todo', summary: `${ran.id}: blocked — user chose fix it now (status → todo)`, log: args.log,
334
+ });
335
+ args.log(formatInfo(`Running ${title} again`).trimEnd());
336
+ queue(resumeCmd(path));
337
+ return path;
338
+ }
339
+ let skipped = false;
340
+ if (action === 'skip') {
341
+ const reason = (await args.prompt(`Why skip ${title}? (one line): `)).trim();
342
+ if (reason.length > 0) {
343
+ // Recorded as a waiver, like the audit waiver: never shown as done.
344
+ path = await applyAuditResult(path, ran.id, { state: 'waived', waivedReason: reason, waiveCategory: 'accepted-risk' }, {
345
+ statusOverride: 'validated-with-waiver', log: args.log,
346
+ });
347
+ args.log(chalk.dim(`${title} skipped — reason recorded in the plan file.`));
348
+ skipped = true;
349
+ const re = loadPlan(path, args.log);
350
+ if (!re)
351
+ return path;
352
+ loaded = re;
353
+ }
342
354
  }
343
- else {
344
- const blocked = loaded.content.phases.filter((p) => statuses[p.id] === 'blocked').map((p) => p.id);
345
- const unresolved = loaded.content.phases
346
- .filter((p) => isPhaseSatisfied(statuses[p.id]) && !isAuditResolved(p.audit))
347
- .map((p) => `${p.id} (audit ${p.audit?.state ?? 'unknown'})`);
348
- const unresolvedNote = unresolved.length > 0 ? ` Unresolved audits: ${unresolved.join(', ')}.` : '';
349
- args.log(`No eligible next phase. Blocked: ${blocked.join(', ') || '(none)'}.${unresolvedNote}`);
355
+ if (!skipped) {
356
+ // Stop: check the steps done, then say how to go on.
357
+ stopShown = true;
358
+ const out = await endOfRun(path);
359
+ if (!queued) {
360
+ args.log(stoppedBanner(`${title} could not finish`, resumeCmd(out)));
361
+ ringBell();
362
+ }
363
+ return out;
350
364
  }
351
365
  }
352
- return path;
353
366
  }
367
+ /* ── 1c. the design added objects to the approved list (ask once) ───── */
368
+ await offerObjectListDelta({
369
+ content: loaded.content, planId: loaded.envelope.id, sessionId: args.sessionId, log: args.log,
370
+ ...(args.sapAlias ? { sapAlias: args.sapAlias } : {}),
371
+ ...(args.deps?.confirmCard ? { confirm: args.deps.confirmCard } : {}),
372
+ });
373
+ /* ── 2. gate ─────────────────────────────────────────────────────────── */
374
+ const statuses = statusesFromItems(loaded.envelope.interaction.items, loaded.content.phases);
375
+ const next = computeNextPhaseForRun(loaded.content.phases, statuses);
376
+ if (!next)
377
+ return endOfRun(path);
378
+ // After a batch check (re-audit pass / forced re-check) the gate looks at
379
+ // EVERY step that was checked: the first one left failed with no answer
380
+ // stops the run, whatever its place in the list. A user-chosen 'blocked'
381
+ // does not stop it. Otherwise the gate judges the step that just ran.
382
+ const checked = args.checkedPhaseIds ?? (args.forceAudit ? [phase.id] : null);
383
+ const stopStep = checked
384
+ ? unansweredFailures(loaded.content, statuses, checked)[0]
385
+ : loaded.content.phases.find((p) => p.id === args.executedPhaseId);
354
386
  const decision = decideNextPhaseGate({
355
387
  mode: args.mode,
356
- nextPhase: { id: next.id, writes: next.writes },
357
- prevPhaseAudit: phase?.audit,
388
+ nextPhase: { id: next.id, writes: next.writes, title: titleOf(next) },
389
+ prevPhaseAudit: stopStep?.audit,
358
390
  });
359
391
  if (decision.action === 'stop') {
360
- path = await applyPhaseResolution(path, args.executedPhaseId, {
361
- lastStop: {
362
- phaseId: args.executedPhaseId,
363
- reason: decision.stopReason === 'audit-infra' ? 'audit-infra' : 'audit-failed',
364
- at: new Date().toISOString(),
365
- },
366
- summary: `guarded chain stopped: ${decision.stopReason}`,
367
- log: args.log,
368
- });
369
- args.log(substitutePlanToken(decision.banner ?? stoppedBanner('guarded chain stopped', resumeCmd(path)), path));
370
- ringBell();
371
- return path;
372
- }
373
- if (decision.action === 'prompt' && decision.stopReason === 'write-phase') {
374
- // The prompt IS the stop (guarded semantics) — record it crash-safe
375
- // BEFORE blocking on the user, then render the banner + bell.
376
- path = await applyPhaseResolution(path, args.executedPhaseId, {
377
- lastStop: { phaseId: next.id, reason: 'write-phase', at: new Date().toISOString() },
378
- summary: `guarded chain stopped: write-phase consent for ${next.id}`,
379
- log: args.log,
380
- });
381
- args.log(substitutePlanToken(decision.banner ?? '', path));
392
+ const stopId = stopStep?.id ?? args.executedPhaseId;
393
+ // The batch check already recorded this stop with the finding text — keep
394
+ // it (no extra revision, no lost detail).
395
+ const recorded = loaded.content.lastStop;
396
+ if (!(recorded?.phaseId === stopId && recorded.reason === 'audit-failed')) {
397
+ path = await applyPhaseResolution(path, stopId, {
398
+ lastStop: { phaseId: stopId, reason: 'audit-failed', at: new Date().toISOString() },
399
+ summary: 'chain stopped: an earlier step failed its independent check',
400
+ log: args.log,
401
+ });
402
+ }
403
+ args.log(substitutePlanToken(decision.banner ?? stoppedBanner('stopped', resumeCmd(path)), path));
382
404
  ringBell();
405
+ stopShown = true;
406
+ return endOfRun(path);
383
407
  }
384
- // 2026-10-02 — deploy is a chained step of a UI plan. Before ui.deploy the
385
- // chain stops on the CLI-owned deploy card (deploy-card.ts), in step AND
386
- // guarded mode, instead of the plain "run the next phase?" question.
408
+ /* ── 3. deploy card (always — spec §3.2) ─────────────────────────────── */
387
409
  if (isDeployPhase(next)) {
388
410
  const card = await askDeployCard({
389
411
  phases: loaded.content.phases,
@@ -400,91 +422,88 @@ export async function runPostPhaseChain(args) {
400
422
  },
401
423
  });
402
424
  if (card.outcome === 'blocked') {
403
- args.log(`${next.id} cannot run here: ${card.reason}. Resume later with: ${resumeCmd(path)}`);
404
- return path;
425
+ args.log(`${titleOf(next)} cannot run here: ${card.reason}. To continue later: open CSPeach in this folder and choose Continue.`);
426
+ return endOfRun(path);
405
427
  }
406
428
  if (card.outcome === 'no') {
407
- args.log(`Not deployed — resume later with: ${resumeCmd(path)}`);
408
- return path;
429
+ args.log('Not deployed. To continue later: open CSPeach in this folder and choose Continue.');
430
+ return endOfRun(path);
409
431
  }
410
- args.queueDispatch(resumeCmd(path));
432
+ queue(resumeCmd(path));
411
433
  return path;
412
434
  }
413
- await offerNextPhaseAutoRun({
414
- outcome: {
415
- savedPath: path,
416
- resumeCommand: resumeCmd(path),
417
- nextPhaseId: next.id,
418
- nextDelegateTo: next.delegateTo,
419
- },
435
+ /* ── 4. continue ─────────────────────────────────────────────────────── */
436
+ if (decision.action === 'auto') {
437
+ args.log(formatInfo(`Next: ${titleOf(next)}`).trimEnd());
438
+ queue(resumeCmd(path));
439
+ return path;
440
+ }
441
+ // step mode — one consent per step
442
+ const consented = await offerNextPhaseAutoRun({
443
+ outcome: { savedPath: path, resumeCommand: resumeCmd(path), nextPhaseId: next.id, nextDelegateTo: next.delegateTo, nextTitle: titleOf(next) },
420
444
  prompt: args.prompt,
421
445
  confirm: args.confirm,
422
- queueDispatch: args.queueDispatch,
446
+ queueDispatch: queue,
423
447
  log: args.log,
424
448
  gate: decision,
425
449
  });
426
- return path;
450
+ // The offer already said how to continue later: one continue line only.
451
+ if (!consented)
452
+ stopShown = true;
453
+ return consented ? path : endOfRun(path);
427
454
  }
428
455
  /**
429
- * Review I-2 — a re-audit PASS over multiple phases in ONE dispatch. Used by
430
- * the repl's re-audit-on-resume branch: usually a single pending/infra phase,
431
- * but a guarded hand-edit re-verification lists EVERY hand-edited satisfied
432
- * phase (design: "an audit pass over hand-edited statuses"). Each phase runs
433
- * the full chain with the offer suppressed except on the last; the saved path
434
- * feeds forward so every verdict lands on the latest revision.
435
- *
436
- * Abort semantics: when a chain iteration queues a dispatch (the user picked
437
- * re-run, or the final offer auto/consented), the pass stops — continuing to
438
- * audit while a dispatch is pending would race the re-entry. Any phases left
439
- * un-audited are named in a warning: hand-edit detection is history-author
440
- * based and the pass's own writes wash it away, so they will NOT be
441
- * re-detected automatically on the next resume. Stops (blocked/infra) do NOT
442
- * abort the pass — infra persists as `infra_failed` (re-caught by the
443
- * pending/infra scan next resume) and auditing the remaining phases is still
444
- * useful information.
456
+ * The resume path's audit pass: steps left 'pending' by a run that died, a
457
+ * 'failed' check whose menu was never answered, or (force) every step a hand
458
+ * edit invalidated. All listed steps are checked in one batch (quiet, titles
459
+ * only), then the run continues from where the plan stands — without a
460
+ * second end-of-run audit over the same steps.
445
461
  */
446
462
  export async function runReauditPass(args) {
447
- // Task 10 review follow-up — a re-audit dispatch runs NO model turn, so a
448
- // raised deviation flag here is never legitimate: it can only be stale
449
- // residue from a chain leg that crashed before consuming it (possibly for
450
- // a DIFFERENT plan in the same process). Discard it — mirroring the repl's
451
- // phase-turn-start discard — or the deviation step inside runPostPhaseChain
452
- // would wrongly block the re-audited phase and clearAudit would destroy
453
- // the very 'pending' audit crash-safety depends on.
463
+ // 2026-10-04 — a re-audit leg that queues nothing ends the run (nothing to
464
+ // check, the check could not run, or the chain stopped): the plan
465
+ // object-list grant ends with it.
466
+ let queuedNext = false;
467
+ const tracked = { ...args, queueDispatch: (cmd) => { queuedNext = true; args.queueDispatch(cmd); } };
468
+ try {
469
+ return await reauditPass(tracked);
470
+ }
471
+ finally {
472
+ if (!queuedNext)
473
+ clearPlanGrant();
474
+ }
475
+ }
476
+ async function reauditPass(args) {
477
+ // A re-audit dispatch runs NO model turn, so a raised deviation flag here
478
+ // can only be stale residue from a chain leg that crashed before consuming
479
+ // it. Discard it, or the chain below would wrongly block a checked step.
454
480
  getAndResetPlanDeviation();
455
- const { phaseIds, ...chainArgs } = args;
481
+ const { phaseIds, force, ...chainArgs } = args;
456
482
  if (phaseIds.length === 0)
457
483
  return args.savedPath;
458
- if (phaseIds.length > 1) {
459
- args.log(chalk.dim(`[audit] re-audit pass over ${phaseIds.length} phases: ${phaseIds.join(', ')}`));
460
- }
461
- let path = args.savedPath;
462
- let queued = false;
463
- const queueDispatch = (cmd) => { queued = true; args.queueDispatch(cmd); };
464
- for (let i = 0; i < phaseIds.length; i++) {
465
- const result = await runPostPhaseChain({
466
- ...chainArgs,
467
- savedPath: path,
468
- executedPhaseId: phaseIds[i],
469
- forceAudit: true,
470
- suppressOffer: i < phaseIds.length - 1,
471
- queueDispatch,
472
- });
473
- if (result)
474
- path = result;
475
- if (queued) {
476
- const remaining = phaseIds.slice(i + 1);
477
- if (remaining.length > 0) {
478
- args.log(formatAttention({
479
- text: `${remaining.length} hand-edited phase(s) not re-audited yet`,
480
- fact: remaining.join(', '),
481
- next: 'they are not auto-detected after this write; verify them (or re-run their phases) once the queued dispatch completes',
482
- }).trimEnd());
483
- }
484
- break;
485
- }
484
+ const r = await batchAuditOrNull({
485
+ savedPath: args.savedPath, phaseIds, sessionId: args.sessionId, cwd: args.cwd,
486
+ ...(force ? { force: true } : {}), prompt: args.prompt, log: args.log,
487
+ ...(args.deps ? { deps: args.deps } : {}),
488
+ });
489
+ if (!r)
490
+ return args.savedPath;
491
+ if (r.rerun) {
492
+ const suffix = (args.modeFlag ? ` --${args.modeFlag}` : '')
493
+ + (args.auditFlag === 'off' ? ' --no-audit' : args.auditFlag === 'on' ? ' --audit' : '');
494
+ args.queueDispatch(buildResumeCommand(r.path) + suffix);
495
+ return r.path;
486
496
  }
487
- return path;
497
+ const out = await runPostPhaseChain({
498
+ ...chainArgs,
499
+ savedPath: r.path,
500
+ executedPhaseId: phaseIds[phaseIds.length - 1],
501
+ subagentDispatches: 0,
502
+ skipEndAudit: true,
503
+ checkedPhaseIds: phaseIds,
504
+ ...(r.blocked.length > 0 ? { reauditFailed: true } : {}),
505
+ });
506
+ return out ?? r.path;
488
507
  }
489
508
  /* ── helpers ───────────────────────────────────────────────────────────── */
490
509
  function loadPlan(path, log) {
@@ -516,188 +535,12 @@ function ringBell() {
516
535
  }
517
536
  /** STOPPED banner in the plan-gate style, with the REAL resume command. */
518
537
  function stoppedBanner(reason, resumeCommand) {
519
- return `\n■ STOPPED — ${reason}\n Resume with: ${resumeCommand}\n`;
538
+ return `\n■ STOPPED — ${reason}\n To continue: open CSPeach in this folder and choose Continue, or type ${resumeCommand}\n`;
520
539
  }
521
540
  /**
522
- * Task 3 left the gate banner's `@<plan>` placeholder literal (the pure
523
- * module doesn't know the saved path) — substitute the real basename here.
541
+ * The gate banner leaves its `@<plan>` placeholder literal (the pure module
542
+ * doesn't know the saved path) — substitute the real basename here.
524
543
  */
525
544
  export function substitutePlanToken(banner, savedPath) {
526
545
  return banner.replace(/@<plan>/g, `@${basename(savedPath)}`);
527
546
  }
528
- function clampInfra(msg) {
529
- const flat = msg.replace(/\s+/g, ' ').trim();
530
- return flat.length > INFRA_RENDER_CHARS ? flat.slice(0, INFRA_RENDER_CHARS - 1).trimEnd() + '…' : flat;
531
- }
532
- function renderFindings(log, phaseId, findings) {
533
- log('', paint('bad', `${getGlyphs().failed} audit FAILED for phase ${phaseId}:`));
534
- const shown = findings.length > 0 ? findings : ['(auditor returned no findings text)'];
535
- shown.forEach((f, i) => log(` ${i + 1}. ${f}`));
536
- }
537
- /**
538
- * Confidence-tiered redesign (2026-07-08) — the concise, always-shown render
539
- * for a NON-BLOCKING warn verdict. Mirrors renderFindings' shape but frames the
540
- * phase as passed-with-notes (the chain continues). Each finding is already
541
- * formatted "[W1] detail" by the auditor (plan-audit.renderFinding).
542
- */
543
- function renderWarnings(log, phaseId, findings) {
544
- const n = findings.length;
545
- log('', paint('warn', `${getGlyphs().attention} audit passed with ${n} note${n === 1 ? '' : 's'} for phase ${phaseId}:`));
546
- const shown = n > 0 ? findings : ['(auditor returned a warn verdict with no note detail)'];
547
- shown.forEach((f, i) => log(` ${i + 1}. ${f}`));
548
- }
549
- /**
550
- * audit-infra-continue (2026-07-09) — the LOUD, always-shown note for an
551
- * audit that COULD NOT RUN. Distinct from warn's "passed with N notes": this
552
- * frames the phase as unverified-but-proceeding, is explicit that the audit
553
- * INFRASTRUCTURE failed (not the work), reassures that the phase's own exit
554
- * gate passed, and points at the off-switch for a persistent outage — a
555
- * pointer, not a forced prompt. Never swallowed (the chain continues, so
556
- * nothing else surfaces this).
557
- */
558
- function renderAuditNotRun(log, phaseId, reason, resumeCommand) {
559
- log('', paint('warn', `${getGlyphs().attention} audit could not run for phase ${phaseId} (audit infrastructure failed after 3 attempts — the model call returned no output).`), paint('warn', ' This phase is NOT audit-verified; its own exit gate (activation + ATC) passed. Proceeding.'), paint('dim', ` reason: ${reason}`), paint('dim', ` If the auditor keeps failing, run with --no-audit (or set audit off in config) to stop retrying; resume as usual: ${resumeCommand}`));
560
- }
561
- /**
562
- * Shared menu surface for the audit-resolution selects. Mirrors
563
- * safety-confirm.ts: headless → no decision (fail-closed, nothing written);
564
- * Ink → confirm card via askConfirm (piece 2 §5); classic → inquirer select.
565
- */
566
- async function selectAuditAction(args) {
567
- const { isHeadless, shouldUseInk } = await import('../renderer/tty.js');
568
- if (isHeadless())
569
- return 'none';
570
- if (shouldUseInk()) {
571
- // D-C — this card opens in the POST-PHASE chain. A leaked keystroke used
572
- // to auto-select choice 1 ("Re-run phase") and silently re-run the
573
- // phase. The card slot claims input focus (spinner stopped, status row
574
- // paused, restored after), digits only move the cursor, and typed-ahead
575
- // keys are dropped — so no stray key can pick a choice.
576
- const { askConfirm } = await import('../ui/confirm-request.js');
577
- const res = await askConfirm({
578
- id: args.id,
579
- tag: 'SAFETY CHECK',
580
- title: args.question,
581
- facts: args.facts,
582
- choices: args.choices,
583
- initial: 0,
584
- footnote: 'Plan audit',
585
- });
586
- if (res.cancelled || res.value == null)
587
- return 'none';
588
- return coerceAction(res.value, args.choices);
589
- }
590
- const { select } = await import('@inquirer/prompts');
591
- const { withInquirer } = await import('../repl/inquirer-guard.js');
592
- const { inquirerTheme } = await import('../repl/inquirer-theme.js');
593
- const picked = await withInquirer(() => select({
594
- message: args.question,
595
- theme: inquirerTheme,
596
- choices: args.choices.map((c) => ({ value: c.value, name: c.label })),
597
- }));
598
- return coerceAction(picked, args.choices);
599
- }
600
- /**
601
- * D-C — fail CLOSED on any answer that is not one of this menu's own choice
602
- * values. `'' ?? 'none'` did NOT catch an empty-string answer (nullish
603
- * coalescing only guards null/undefined), so a stray/blank answer used to fall
604
- * through the runPostPhaseChain switch to a silent default. Anything the menu
605
- * did not explicitly offer maps to 'none' (stop, no auto-action).
606
- */
607
- function coerceAction(answer, choices) {
608
- return choices.some((c) => c.value === answer) ? answer : 'none';
609
- }
610
- /** Production failed-verdict menu (see selectAuditAction for the surface).
611
- * The findings are already printed above by renderFindings; the card
612
- * repeats only the count and the first three. */
613
- export async function defaultSelectFailureAction(a) {
614
- const n = a.findings.length;
615
- const facts = [
616
- `${n} ${n === 1 ? 'finding' : 'findings'}, listed above`,
617
- // Findings are model text: display-clean them at the source (ANSI and
618
- // control characters out, one line each), like the safety-card rows.
619
- ...a.findings.slice(0, 3).map((f, i) => `${i + 1}. ${clean(f)}`),
620
- ];
621
- if (n > 3)
622
- facts.push(`${getGlyphs().more} ${n - 3} more`);
623
- return selectAuditAction({
624
- id: 'plan-audit-failed',
625
- question: `Audit failed for phase ${a.phaseId} — what now?`,
626
- facts,
627
- choices: [
628
- { value: 'rerun', label: 'Re-run phase (re-audited after execution)' },
629
- { value: 'waive', label: 'Waive audit (record a one-line reason)' },
630
- { value: 'blocked', label: 'Mark phase blocked' },
631
- ],
632
- });
633
- }
634
- /**
635
- * §10 Q2 (audit-confidence redesign) — production waive-category menu. A LIGHT
636
- * 2-choice shown right after the waive-reason prompt so the audit's real
637
- * false-positive rate is measurable. Same surface as selectAuditAction, but
638
- * headless / cancel / stray answer → 'accepted-risk' (the CONSERVATIVE default:
639
- * a waive no human explicitly labelled a false-positive is recorded as
640
- * accepted-risk, so the measured FP rate never over-counts). Never hangs.
641
- */
642
- /** Exported for the piece-2 behaviour pins (plan-chain-focus-pin.test.ts). */
643
- export async function defaultSelectWaiveCategory(a) {
644
- const { isHeadless, shouldUseInk } = await import('../renderer/tty.js');
645
- if (isHeadless())
646
- return 'accepted-risk';
647
- const question = `Waiving ${a.phaseId} — why? (measures the audit's false-positive rate)`;
648
- const choices = [
649
- { value: 'accepted-risk', label: 'Accepted risk — the finding is real, I accept it and proceed' },
650
- { value: 'false-positive', label: 'False positive — the audit was wrong, the work is actually fine' },
651
- ];
652
- const coerce = (answer) => choices.some((c) => c.value === answer) ? answer : 'accepted-risk';
653
- if (shouldUseInk()) {
654
- // Same input-focus discipline as selectAuditAction (askConfirm).
655
- const { askConfirm } = await import('../ui/confirm-request.js');
656
- const res = await askConfirm({
657
- id: 'plan-waive-category',
658
- tag: 'QUESTION',
659
- title: question,
660
- choices,
661
- initial: 0,
662
- footnote: 'Plan audit',
663
- });
664
- if (res.cancelled || res.value == null)
665
- return 'accepted-risk';
666
- return coerce(res.value);
667
- }
668
- const { select } = await import('@inquirer/prompts');
669
- const { withInquirer } = await import('../repl/inquirer-guard.js');
670
- const { inquirerTheme } = await import('../repl/inquirer-theme.js');
671
- const picked = await withInquirer(() => select({
672
- message: question,
673
- theme: inquirerTheme,
674
- choices: choices.map((c) => ({ value: c.value, name: c.label })),
675
- }));
676
- return coerce(picked);
677
- }
678
- /**
679
- * Audit spend surfaces twice: a dim line next to the turn footer (the
680
- * `audit` entry) and a labelled turn-0 line in the session cost JSONL —
681
- * the audit runs in its own isolated session, so without this its tokens
682
- * would be invisible to the user's cost trail. Best-effort, never throws.
683
- */
684
- function renderAuditCost(args, cost) {
685
- try {
686
- const tok = cost.tokens.input + cost.tokens.output;
687
- const usd = computeCost(cost.model, cost.tokens);
688
- // Credits mode: the audit turn's USD is our COGS. There is no per-audit
689
- // credit figure to substitute (the ledger charges the turn, not this
690
- // sub-step), so the dim line drops the cost fragment and keeps tokens —
691
- // never a dollar on a managed customer's screen.
692
- args.log(chalk.dim(creditsActive()
693
- ? `[audit] ${tok.toLocaleString()} tok (${cost.model})`
694
- : `[audit] ${tok.toLocaleString()} tok · ${formatCost(usd)} (${cost.model})`));
695
- void appendCostLine(args.sessionId, {
696
- ...buildEntry({ turn: 0, model: cost.model, tokens: cost.tokens, duration_ms: cost.durationMs }),
697
- label: 'audit',
698
- });
699
- }
700
- catch {
701
- // observability only
702
- }
703
- }