@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
package/dist/repl.js CHANGED
@@ -109,7 +109,7 @@ function prefillReadline(rl, text) {
109
109
  import { BracketedPasteDecoder, enableBracketedPaste, disableBracketedPaste, } from './repl/bracketed-paste.js';
110
110
  import { InkStdinPasteGuard } from './repl/ink-stdin-guard.js';
111
111
  import { printHelp } from './commands/help.js';
112
- import { shouldUseInk, setOneShotOverride } from './renderer/tty.js';
112
+ import { shouldUseInk, setOneShotOverride, isHeadless } from './renderer/tty.js';
113
113
  import { applyStartupRendering } from './renderer/rendering-mode.js';
114
114
  import { createClassicOutputEmitter } from './renderer/notices.js';
115
115
  import { saveConfig } from './config/loader.js';
@@ -118,6 +118,10 @@ import { existsSync } from 'node:fs';
118
118
  import { join } from 'node:path';
119
119
  import { streamsRoot } from './agent/turn-stream.js';
120
120
  import { parseAndValidateReroute } from './commands/reroute.js';
121
+ import { DEFAULT_PLAN_MODE } from './commands/plan-gate.js';
122
+ import { autoRoutedNotice } from './commands/plan-resume.js';
123
+ import { makeRegroundDeps } from './commands/plan-reground.js';
124
+ import { createPlanHopTracker } from './repl/plan-hop.js';
121
125
  import { emitPlanTurnEnd } from './repl/plan-turn-end.js';
122
126
  import { SKILL_CATALOG } from './skill-catalog.js';
123
127
  import { replPreviewHook, inkPreviewHook } from './repl/update-method-preview-hook.js';
@@ -156,6 +160,7 @@ import './tools/cap/sap-link-tool.js';
156
160
  import './tools/cap/link-smoke.js';
157
161
  import './tools/lane/app-lane-resolve.js';
158
162
  import './tools/extend-model/tool.js';
163
+ import './tools/text-elements/tools.js';
159
164
  import './tools/capability/tool.js';
160
165
  import './tools/web/web_fetch.js';
161
166
  import './tools/web/web_search.js';
@@ -419,6 +424,7 @@ export async function runRepl(opts) {
419
424
  // flow below when the file is absent (back-compat for pre-`cspeach login`
420
425
  // users).
421
426
  let signIn;
427
+ let signedInAs;
422
428
  try {
423
429
  const authFile = await readAuthFile();
424
430
  if (authFile) {
@@ -437,6 +443,7 @@ export async function runRepl(opts) {
437
443
  console.log(note.trimEnd());
438
444
  }
439
445
  else {
446
+ signedInAs = meResult.profile.email;
440
447
  const banner = formatLoginBanner(meResult.profile);
441
448
  if (getLook() === 'panels') {
442
449
  signIn = signInFragment(meResult.profile);
@@ -811,8 +818,8 @@ export async function runRepl(opts) {
811
818
  ? formatAttention({ text: 'Last turn was stopped by you', next: 'type continue to finish where it left off' })
812
819
  : formatAttention({ text: 'Last turn was interrupted', fact: readableInterruptReason(session.turnInterruptedReason), next: 'type continue to finish where it left off' }));
813
820
  }
814
- if (recap.plan)
815
- startupNotices.push(formatAttention({ text: `Unfinished plan ${recap.plan.text}`, next: recap.plan.next }));
821
+ // 2026-10-04 — no passive "Unfinished plan" line: the startup
822
+ // "Continue '<plan>'?" offer (commands/plan-startup.ts) replaces it.
816
823
  process.stdout.write(panelsStartTail({ status: panelsStatus, third: panelsResumeLine(recap.last, turnCount), notices: startupNotices }));
817
824
  }
818
825
  else {
@@ -857,6 +864,11 @@ export async function runRepl(opts) {
857
864
  get: () => pendingDispatchValue,
858
865
  set: (cmd) => { pendingDispatchValue = cmd; },
859
866
  };
867
+ // Task 13 fix round — the plan chain queues through planHops.queue, which
868
+ // marks the command as an in-run hop (one progress line, no guarded
869
+ // notice). Anything else in the slot (the /abap-app start, a skill switch)
870
+ // is not a hop. Shared by the Ink and readline branches like the slot.
871
+ const planHops = createPlanHopTracker(pendingDispatchAccessor);
860
872
  // v0.3: Ink-mode REPL branch. When running under a real TTY in REPL mode
861
873
  // (not one-shot, not CI), mount the <App /> frame instead of the readline
862
874
  // loop. The non-Ink path below is preserved byte-for-byte for piped/CI
@@ -947,7 +959,9 @@ export async function runRepl(opts) {
947
959
  chunkEmitter.emit('chunk', '\n' + formatInfo(line));
948
960
  };
949
961
  /** Auto-dispatch of the next plan phase, unless a post-turn cancel stopped it. */
950
- const autoDispatch = (cmd) => {
962
+ // Task 13 fix round — `planHop` travels WITH the command into handleSubmit,
963
+ // so a refused submit can never leave a stale hop mark behind.
964
+ const autoDispatch = (cmd, planHop = false) => {
951
965
  // Round 4 item 9b — the exit is already under way: start nothing new.
952
966
  // Final review B-I2 — `/exit` typed mid-turn (exitAfterTurn) stops it too.
953
967
  const decision = autoDispatchDecision({ done, exitRequested: exitAfterTurn, cancelPending: pendingCancel.pending }, cmd);
@@ -958,14 +972,17 @@ export async function runRepl(opts) {
958
972
  chunkEmitter.emit('chunk', formatInfo(decision.line));
959
973
  return;
960
974
  }
961
- chunkEmitter.emit('chunk', formatInfo(`Auto-routed: ${cmd}`));
962
- setImmediate(() => { void handleSubmit(cmd); });
975
+ // Task 13 — no echo for a plan hop: the chain already said "Next: <title>".
976
+ const notice = autoRoutedNotice(cmd);
977
+ if (notice)
978
+ chunkEmitter.emit('chunk', formatInfo(notice));
979
+ setImmediate(() => { void handleSubmit(cmd, { planHop }); });
963
980
  };
964
981
  // BUG-2b — the actual dispatch. Wrapped by handleSubmit (below) which
965
982
  // owns the single-flight reserve()/release() lifecycle. All re-entrant
966
983
  // callers (the setImmediate auto-dispatch / re-audit legs) go through the
967
984
  // WRAPPER so they are guarded and reserved too.
968
- const dispatchSubmit = async (line) => {
985
+ const dispatchSubmit = async (line, submitOpts) => {
969
986
  const trimmed = line.trim();
970
987
  if (!trimmed)
971
988
  return;
@@ -1026,6 +1043,7 @@ export async function runRepl(opts) {
1026
1043
  }
1027
1044
  await runExportAuditCommand({
1028
1045
  session,
1046
+ operator: signedInAs,
1029
1047
  transport: getCurrentTransport(),
1030
1048
  filename,
1031
1049
  emit: (l) => chunkEmitter.emit('chunk', l + '\n'),
@@ -1453,6 +1471,8 @@ export async function runRepl(opts) {
1453
1471
  // persists the version-N+1 REVISION after the turn.
1454
1472
  let planResume = null;
1455
1473
  let planResumeMsgStart = 0;
1474
+ // Task 13 — this dispatch is an in-run hop of a plan run (queued by the chain).
1475
+ let planHop = false;
1456
1476
  // D-B — the wall-clock start of THIS phase attempt's turn, threaded to
1457
1477
  // the post-phase audit as the evidence-window cutoff (so an audit never
1458
1478
  // sees an earlier attempt's tool-call rows in the shared session JSONL).
@@ -1469,9 +1489,12 @@ export async function runRepl(opts) {
1469
1489
  try {
1470
1490
  const { preparePlanResume } = await import('./commands/plan-resume.js');
1471
1491
  const { inkAwarePrompt, inkAwareConfirm } = await import('./agent/loop.js');
1492
+ planHop = submitOpts?.planHop === true;
1472
1493
  planResume = await preparePlanResume({
1473
1494
  body: prompt,
1474
1495
  cwd: process.cwd(),
1496
+ // Task 13 — an in-run hop prints one progress line, not the board.
1497
+ boardStyle: planHop ? 'line' : 'full',
1475
1498
  configMode: cfg.plan_mode,
1476
1499
  // Audit off-switch (2026-07-06, owner) — config default; per-run
1477
1500
  // --audit/--no-audit flags in the body override it (parsed inside).
@@ -1481,6 +1504,9 @@ export async function runRepl(opts) {
1481
1504
  sessionId: session.id,
1482
1505
  // 2026-10-02 — deploy card on a direct resume into ui.deploy.
1483
1506
  sapAlias: session.sap_system ?? undefined,
1507
+ // 2026-10-04 — re-check SAP before the step (skipped without a SAP system).
1508
+ reground: standalone ? undefined : makeRegroundDeps(adt),
1509
+ interactive: !isHeadless(),
1484
1510
  prompt: inkAwarePrompt,
1485
1511
  confirm: inkAwareConfirm,
1486
1512
  log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
@@ -1490,10 +1516,33 @@ export async function runRepl(opts) {
1490
1516
  chunkEmitter.emit('chunk', '\n' + formatFailure({ what: 'Could not resume the plan', detail: [err.message] }));
1491
1517
  }
1492
1518
  if (!planResume) {
1519
+ // 2026-10-04 — the resume could not start: the run is over, and the
1520
+ // plan object-list grant ends with it.
1521
+ {
1522
+ const { clearPlanGrant } = await import('./approvals/plan-grant.js');
1523
+ clearPlanGrant();
1524
+ }
1493
1525
  session.last_skill = previousSkill ?? null;
1494
1526
  session.last_object = extractedObject ?? previousObject ?? null;
1495
1527
  return;
1496
1528
  }
1529
+ // 2026-10-04 (F13) — a typed resume in guarded mode with no grant for
1530
+ // this plan shows the object list once before the step. Yes approves
1531
+ // it; No runs the step with a card per write. Never on a chain hop.
1532
+ if (!isHeadless()) {
1533
+ try {
1534
+ const { offerObjectListOnResume, inkStartupChooser } = await import('./commands/plan-startup.js');
1535
+ await offerObjectListOnResume({
1536
+ prepared: planResume,
1537
+ planHop,
1538
+ sessionId: session.id,
1539
+ sapAlias: session.sap_system ?? undefined,
1540
+ choose: inkStartupChooser(),
1541
+ log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
1542
+ });
1543
+ }
1544
+ catch { /* best-effort: each write asks */ }
1545
+ }
1497
1546
  // Task 6 (agentic-flow) — re-audit-on-resume: the last executed phase
1498
1547
  // has an unresolved audit (crash before the verdict write, auditor
1499
1548
  // infra outage, or a guarded-mode hand-edit). NO model turn this
@@ -1506,10 +1555,11 @@ export async function runRepl(opts) {
1506
1555
  await runReauditPass({
1507
1556
  savedPath: planResume.path,
1508
1557
  phaseIds: planResume.reauditPhaseIds ?? [planResume.reauditPhaseId],
1558
+ force: planResume.reauditForce,
1509
1559
  sessionId: session.id,
1510
1560
  cwd: process.cwd(),
1511
1561
  sapAlias: session.sap_system ?? undefined,
1512
- mode: planResume.effectiveMode ?? 'step',
1562
+ mode: planResume.effectiveMode ?? DEFAULT_PLAN_MODE,
1513
1563
  modeFlag: planResume.mode,
1514
1564
  // Off-switch (I3) — footer honesty on re-audit boards; (I2) the
1515
1565
  // per-run audit flag survives onto chain-built resume commands.
@@ -1517,7 +1567,7 @@ export async function runRepl(opts) {
1517
1567
  auditFlag: planResume.auditFlag,
1518
1568
  prompt: inkAwarePrompt,
1519
1569
  confirm: inkAwareConfirm,
1520
- queueDispatch: (cmd) => pendingDispatchAccessor.set(cmd),
1570
+ queueDispatch: (cmd) => planHops.queue(cmd),
1521
1571
  log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
1522
1572
  });
1523
1573
  }
@@ -1538,22 +1588,30 @@ export async function runRepl(opts) {
1538
1588
  const { releaseRunLeaseUnlessChained } = await import('./projects/run-lease.js');
1539
1589
  releaseRunLeaseUnlessChained(planResume.path, reauditDispatched);
1540
1590
  }
1591
+ // 2026-10-04 — the re-audit leg queued nothing: the run is over and
1592
+ // the plan object-list grant ends with it.
1593
+ if (!reauditDispatched) {
1594
+ const { clearPlanGrant } = await import('./approvals/plan-grant.js');
1595
+ clearPlanGrant();
1596
+ }
1541
1597
  if (reauditDispatched) {
1542
1598
  pendingDispatchAccessor.set(null);
1543
- autoDispatch(reauditDispatched);
1599
+ autoDispatch(reauditDispatched, planHops.take(reauditDispatched));
1544
1600
  }
1545
1601
  return;
1546
1602
  }
1547
- // A1 (defect D23) — true bounded phases: EVERY plan-resume turn
1548
- // (harness-dispatched or manually typed) starts from reset session
1549
- // messages. The envelope + bounded prompt carry all needed state;
1550
- // prior transcript is pure context cost (a 6-phase run once
1551
- // snowballed to a 3.24M-token cache read per call).
1603
+ // 2026-10-04 (continuous run) — a phase boundary is save + COMPACT:
1604
+ // the previous phase becomes one summary message (spec-gap answers,
1605
+ // decisions, transport survive). Falls back to today's reset if the
1606
+ // summariser fails, so the run never dead-ends. Still bounded: prior
1607
+ // transcript never snowballs (A1 / defect D23).
1552
1608
  {
1553
- const { resetContextForPlanResume } = await import('./commands/plan-resume.js');
1554
- if (resetContextForPlanResume(session)) {
1555
- chunkEmitter.emit('chunk', formatInfo('Plan phase starts in a fresh bounded context · earlier conversation cleared'));
1556
- }
1609
+ const { beginPlanPhaseContext, makePhaseBoundarySummariser } = await import('./commands/plan-boundary.js');
1610
+ await beginPlanPhaseContext({
1611
+ session,
1612
+ summarise: makePhaseBoundarySummariser(provider, resolveModelRole('compact', cfg)),
1613
+ log: (line) => chunkEmitter.emit('chunk', line + '\n'),
1614
+ });
1557
1615
  }
1558
1616
  planResumeMsgStart = session.messages.length;
1559
1617
  planPhaseStartedAt = new Date().toISOString(); // D-B evidence-window cutoff
@@ -1616,7 +1674,8 @@ export async function runRepl(opts) {
1616
1674
  planContinue = { prepared: r.prepared, marker };
1617
1675
  planPhaseStartedAt = marker.attemptStartedAt;
1618
1676
  planResumeMsgStart = session.messages.length;
1619
- chunkEmitter.emit('chunk', formatInfo(`Re-attaching to in-flight plan phase ${marker.phaseId} (${r.prepared.path})`));
1677
+ const { continuationNotice } = await import('./commands/plan-continue.js');
1678
+ chunkEmitter.emit('chunk', formatInfo(continuationNotice(r.prepared)));
1620
1679
  }
1621
1680
  else if (r.stale) {
1622
1681
  session.plan_in_flight = null; // plan/phase gone for good — marker is noise
@@ -1674,7 +1733,7 @@ export async function runRepl(opts) {
1674
1733
  // Task 7 — plan-phase subagent read-only enforcement: set for EVERY
1675
1734
  // plan-resume phase turn regardless of mode (guarded AND step);
1676
1735
  // agent_run consults the flag and forces its read-only tool filter.
1677
- setPlanPhaseActive(true);
1736
+ setPlanPhaseActive(true, planPhaseCtx.envelope.id);
1678
1737
  // Task 10 — the writes declaration of the phase EXECUTING this turn
1679
1738
  // (fail-safe: undefined ⇒ true, the deviation backstop never fires).
1680
1739
  // request_approval consults it in guarded mode to deny undeclared
@@ -1682,11 +1741,15 @@ export async function runRepl(opts) {
1682
1741
  // never reached its post-phase chain so it can't block THIS phase.
1683
1742
  setCurrentPhaseWrites(planPhaseCtx.nextPhaseWrites ?? true);
1684
1743
  getAndResetPlanDeviation();
1744
+ // Stale "SAP unreachable" from an earlier turn must not pause THIS step.
1745
+ (await import('./sap/reachability-state.js')).getAndResetSapUnreachable();
1685
1746
  planPhaseFlagSet = true;
1686
- if ((planPhaseCtx.effectiveMode ?? 'step') === 'guarded') {
1747
+ if ((planPhaseCtx.effectiveMode ?? DEFAULT_PLAN_MODE) === 'guarded') {
1687
1748
  setGuardedRunActive(true);
1688
1749
  guardedRunFlagSet = true;
1689
- chunkEmitter.emit('chunk', formatInfo('Guarded mode · auto-approve is off for this run'));
1750
+ // Task 13 — once per run (first hop only), in plain words.
1751
+ if (!planHop)
1752
+ chunkEmitter.emit('chunk', formatInfo('Continuous build · auto-approve is off; each write still runs snapshot, syntax check, activate, verify'));
1690
1753
  }
1691
1754
  // Off-switch (2026-07-06, owner) — announce plainly that this run's
1692
1755
  // phases are NOT audited (honesty: off is a choice, never a lie).
@@ -1694,6 +1757,8 @@ export async function runRepl(opts) {
1694
1757
  chunkEmitter.emit('chunk', chalk.dim('plan audits: off\n'));
1695
1758
  }
1696
1759
  }
1760
+ // 2026-10-04 — the phase's expert skill rides along in the system prompt.
1761
+ const planPhaseDelegate = planPhaseCtx?.nextDelegateTo;
1697
1762
  await runTurn({
1698
1763
  provider,
1699
1764
  userMessage: prompt,
@@ -1707,6 +1772,7 @@ export async function runRepl(opts) {
1707
1772
  // Task 19 — auto-compact at turn end, except plan resume/continue
1708
1773
  // turns (their post-turn persist indexes messages from turn start).
1709
1774
  autoCompact: planResume === null && planContinue === null,
1775
+ delegateSkill: planPhaseDelegate,
1710
1776
  // A4 — undefined on every non-plan turn (and when tiering is off).
1711
1777
  modelOverride: planModelOverride,
1712
1778
  // Task 9 — a tier-down, never the session role (proxy must not enforce it).
@@ -1849,7 +1915,7 @@ export async function runRepl(opts) {
1849
1915
  sessionId: session.id,
1850
1916
  cwd: process.cwd(),
1851
1917
  sapAlias: session.sap_system ?? undefined,
1852
- mode: planResume.effectiveMode ?? 'step',
1918
+ mode: planResume.effectiveMode ?? DEFAULT_PLAN_MODE,
1853
1919
  modeFlag: planResume.mode,
1854
1920
  // Off-switch — skips the normal post-phase audit when 'off'; the
1855
1921
  // per-run flag survives onto chain-built resume commands (I2).
@@ -1862,7 +1928,7 @@ export async function runRepl(opts) {
1862
1928
  subagentDispatches: planSubagentDispatches,
1863
1929
  prompt: inkAwarePrompt,
1864
1930
  confirm: inkAwareConfirm,
1865
- queueDispatch: (cmd) => pendingDispatchAccessor.set(cmd),
1931
+ queueDispatch: (cmd) => planHops.queue(cmd),
1866
1932
  log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
1867
1933
  });
1868
1934
  }
@@ -1901,8 +1967,9 @@ export async function runRepl(opts) {
1901
1967
  emit: (line) => chunkEmitter.emit('chunk', line + '\n'),
1902
1968
  });
1903
1969
  }
1904
- else if (r.kind === 'none') {
1905
- chunkEmitter.emit('chunk', chalk.dim(`phase ${planContinue.marker.phaseId} still in flight — no completion manifest this turn\n`));
1970
+ else if (r.kind === 'none' && !(await import('./sap/reachability-state.js')).isSapUnreachableNoted()) {
1971
+ const { stillInFlightLine } = await import('./commands/plan-continue.js');
1972
+ chunkEmitter.emit('chunk', chalk.dim(`${stillInFlightLine(planContinue.prepared.envelope, planContinue.marker.phaseId)}\n`));
1906
1973
  }
1907
1974
  }
1908
1975
  catch (err) {
@@ -1923,7 +1990,7 @@ export async function runRepl(opts) {
1923
1990
  subagentDispatches: planSubagentDispatches,
1924
1991
  prompt: inkAwarePrompt,
1925
1992
  confirm: inkAwareConfirm,
1926
- queueDispatch: (cmd) => pendingDispatchAccessor.set(cmd),
1993
+ queueDispatch: (cmd) => planHops.queue(cmd),
1927
1994
  log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
1928
1995
  });
1929
1996
  }
@@ -1934,6 +2001,35 @@ export async function runRepl(opts) {
1934
2001
  // C1 — the phase reached a resolved terminal (revision persisted, chain
1935
2002
  // ran or is queued): the marker's job is done. Applies to --resume AND
1936
2003
  // continuation dispatches; a null outcome keeps the marker (still in flight).
2004
+ // 2026-10-04 (spec §3.5) — SAP went unreachable during the step and
2005
+ // nothing was saved: pause, Enter to retry, then run the SAME step
2006
+ // again as an in-run hop. A queued re-run keeps the lease and the plan
2007
+ // grant (the run goes on); a stop queues nothing, so the run-end
2008
+ // release + grant clear below apply as for any other stop.
2009
+ if ((planResume || planContinue) && !pendingDispatchAccessor.get()) {
2010
+ try {
2011
+ const { handleSapUnreachableAfterPlanTurn, makeSapProbe } = await import('./commands/plan-sap-pause.js');
2012
+ const { inkAwarePrompt } = await import('./agent/loop.js');
2013
+ const { isHeadless } = await import('./renderer/tty.js');
2014
+ await handleSapUnreachableAfterPlanTurn({
2015
+ prepared: planResume ?? planContinue.prepared,
2016
+ persisted: planOutcome !== null,
2017
+ probe: makeSapProbe(adt),
2018
+ prompt: inkAwarePrompt,
2019
+ interactive: !isHeadless(),
2020
+ queue: (cmd) => planHops.queue(cmd),
2021
+ log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
2022
+ });
2023
+ }
2024
+ catch (err) {
2025
+ // The plan file is already safe; the user still gets one instruction.
2026
+ try {
2027
+ const { reportSapPauseFailure } = await import('./commands/plan-sap-pause.js');
2028
+ reportSapPauseFailure(planResume ?? planContinue.prepared, err, (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'));
2029
+ }
2030
+ catch { /* nothing more can be shown */ }
2031
+ }
2032
+ }
1937
2033
  if (planOutcome)
1938
2034
  session.plan_in_flight = null;
1939
2035
  // Task 9 — the chain leg for this dispatch truly ends here (verdict
@@ -1950,6 +2046,28 @@ export async function runRepl(opts) {
1950
2046
  if (planResume || planContinue) {
1951
2047
  const { releaseRunLeaseUnlessChained } = await import('./projects/run-lease.js');
1952
2048
  releaseRunLeaseUnlessChained((planResume ?? planContinue.prepared).path, pendingDispatchAccessor.get());
2049
+ // 2026-10-04 — the run is over (no next step queued, e.g. the turn
2050
+ // failed before the chain ran): the plan object-list grant ends with it.
2051
+ if (pendingDispatchAccessor.get() === null) {
2052
+ const { clearPlanGrant } = await import('./approvals/plan-grant.js');
2053
+ clearPlanGrant();
2054
+ }
2055
+ }
2056
+ // 2026-10-04 — auto-compact is ON for plan turns: after the revision is
2057
+ // saved, and only when no next step is queued (that step compacts itself).
2058
+ if (planResume || planContinue) {
2059
+ try {
2060
+ const { maybeAutoCompactAfterPlanTurn, makeAutoCompactSummariser } = await import('./commands/plan-boundary.js');
2061
+ const { resolveAutoCompactThreshold } = await import('./models/resolve.js');
2062
+ await maybeAutoCompactAfterPlanTurn({
2063
+ session,
2064
+ config: { ...cfg.compact, auto_threshold_tokens: resolveAutoCompactThreshold(cfg) },
2065
+ queued: pendingDispatchAccessor.get() !== null,
2066
+ summarise: makeAutoCompactSummariser(provider, resolveModelRole('compact', cfg)),
2067
+ emit: (line) => chunkEmitter.emit('chunk', line + '\n'),
2068
+ });
2069
+ }
2070
+ catch { /* auto-compact must never fail a finished turn */ }
1953
2071
  }
1954
2072
  // A4 — turn-cost line must price at the model that actually ran the
1955
2073
  // turn (planModelOverride on tiered plan phases), not session.model.
@@ -1972,16 +2090,16 @@ export async function runRepl(opts) {
1972
2090
  const dispatched = pendingDispatchAccessor.get();
1973
2091
  if (dispatched) {
1974
2092
  pendingDispatchAccessor.set(null);
1975
- // No context clear here: resetContextForPlanResume (called in the
1976
- // plan-resume prepare branch of handleSubmit) is the single owner of
1977
- // the transcript reset — it runs for typed AND dispatched resumes,
2093
+ // No context clear here: beginPlanPhaseContext (plan-boundary.ts,
2094
+ // called in the plan-resume prepare branch of handleSubmit) is the
2095
+ // single owner of the phase-boundary compaction — it runs for typed AND dispatched resumes,
1978
2096
  // and only AFTER preparePlanResume succeeds, so a failed prepare
1979
2097
  // never destroys the session context.
1980
2098
  // Schedule on next tick so the current handleSubmit's promise
1981
2099
  // resolves cleanly before the next one starts. Recursion via
1982
2100
  // setImmediate keeps the call stack flat across long chains.
1983
2101
  // (autoDispatch skips it when a post-turn Ctrl+C / /exit asked to stop.)
1984
- autoDispatch(dispatched);
2102
+ autoDispatch(dispatched, planHops.take(dispatched));
1985
2103
  }
1986
2104
  };
1987
2105
  // BUG-2b (2026-07-05 live smoke) — single-flight dispatch guard.
@@ -2002,7 +2120,7 @@ export async function runRepl(opts) {
2002
2120
  // beginSubmit does mid-turn, instead of starting a twin.
2003
2121
  /** Round 4 item 9b — the in-flight (or last) dispatch; settled before unmount on exit. */
2004
2122
  let dispatchTail = null;
2005
- const handleSubmit = async (line) => {
2123
+ const handleSubmit = async (line, submitOpts) => {
2006
2124
  if (turnStatusEmitter.isBusy()) {
2007
2125
  const t = line.trim();
2008
2126
  // Fix round 3 (item 5): slash commands are never steering text.
@@ -2027,7 +2145,7 @@ export async function runRepl(opts) {
2027
2145
  postTurnMayChain = false;
2028
2146
  try {
2029
2147
  // Round 4 item 9b — the exit path waits (capped) for this to finish.
2030
- const run = dispatchSubmit(line);
2148
+ const run = dispatchSubmit(line, submitOpts);
2031
2149
  dispatchTail = run;
2032
2150
  await run;
2033
2151
  }
@@ -2232,6 +2350,28 @@ export async function runRepl(opts) {
2232
2350
  // in commit 349ff75.
2233
2351
  patchConsole: true,
2234
2352
  });
2353
+ // 2026-10-04 (spec §3.5) — "Continue '<plan>'?" when an unfinished plan
2354
+ // sits in this folder. Best-effort: never blocks the prompt.
2355
+ if (!isHeadless()) {
2356
+ void (async () => {
2357
+ try {
2358
+ const { offerContinueAtStartup, inkStartupChooser } = await import('./commands/plan-startup.js');
2359
+ const { resolveWorkspacePath } = await import('./projects/workspace.js');
2360
+ const ws = await resolveWorkspacePath().catch(() => undefined);
2361
+ if (!ws)
2362
+ return;
2363
+ await offerContinueAtStartup({
2364
+ workspaceDir: ws,
2365
+ sessionId: session.id,
2366
+ sapAlias: session.sap_system ?? undefined,
2367
+ choose: inkStartupChooser(),
2368
+ submit: (cmd) => { void handleSubmit(cmd); },
2369
+ log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
2370
+ });
2371
+ }
2372
+ catch { /* the offer is best-effort */ }
2373
+ })();
2374
+ }
2235
2375
  await donePromise;
2236
2376
  // Round 4 item 9b — a second idle Ctrl+C right after a turn resolved the
2237
2377
  // exit while post-turn work (saving, audit) still ran. Let it finish,
@@ -2556,6 +2696,29 @@ export async function runRepl(opts) {
2556
2696
  })),
2557
2697
  log: (line) => console.log(chalk.dim(line)),
2558
2698
  });
2699
+ // 2026-10-04 (spec §3.5) — "Continue '<plan>'?" (classic: numbered
2700
+ // choices, Enter = the first). Continue queues the resume for the loop.
2701
+ if (!isHeadless()) {
2702
+ try {
2703
+ const { offerContinueAtStartup, classicStartupChooser } = await import('./commands/plan-startup.js');
2704
+ const { inkAwarePrompt } = await import('./agent/loop.js');
2705
+ const { resolveWorkspacePath } = await import('./projects/workspace.js');
2706
+ const ws = await resolveWorkspacePath().catch(() => undefined);
2707
+ if (ws) {
2708
+ await offerContinueAtStartup({
2709
+ workspaceDir: ws,
2710
+ sessionId: session.id,
2711
+ sapAlias: session.sap_system ?? undefined,
2712
+ choose: classicStartupChooser(inkAwarePrompt, (...l) => l.forEach((x) => console.log(x))),
2713
+ submit: (cmd) => pendingDispatchAccessor.set(cmd),
2714
+ log: (...l) => l.forEach((x) => console.log(x)),
2715
+ });
2716
+ }
2717
+ }
2718
+ catch { /* best-effort */ }
2719
+ }
2720
+ // Task 13 — classic in-run hop flag: set by the loop-top consumer from planHops.take().
2721
+ let classicPlanHop = false;
2559
2722
  while (true) {
2560
2723
  // Re-attach the bare-Esc detector every iteration. Inquirer prompts
2561
2724
  // (slash-picker, approval, ask_question, safety-confirm) remove
@@ -2568,15 +2731,22 @@ export async function runRepl(opts) {
2568
2731
  // wait and feed that command to the loop directly — the user's
2569
2732
  // consent was the picker pick, no Enter required. Cleared
2570
2733
  // immediately so a stale value never re-fires.
2734
+ classicPlanHop = false; // a typed line is never an in-run hop
2571
2735
  const dispatchedCmd = pendingDispatchAccessor.get();
2572
2736
  if (dispatchedCmd) {
2573
2737
  pendingDispatchAccessor.set(null);
2574
- // No context clear here: resetContextForPlanResume (called in the
2575
- // plan-resume prepare branch below) is the single owner of the
2576
- // transcript reset — it runs for typed AND dispatched resumes, and
2738
+ // No context clear here: beginPlanPhaseContext (plan-boundary.ts,
2739
+ // called in the plan-resume prepare branch below) is the single owner
2740
+ // of the phase-boundary compaction — it runs for typed AND dispatched resumes, and
2577
2741
  // only AFTER preparePlanResume succeeds, so a failed prepare never
2578
2742
  // destroys the session context.
2579
- console.log(formatInfo(`Auto-routed: ${dispatchedCmd}`).trimEnd());
2743
+ // Task 13 — no echo for a plan hop: the chain already said "Next: <title>".
2744
+ {
2745
+ const n = autoRoutedNotice(dispatchedCmd);
2746
+ if (n)
2747
+ console.log(formatInfo(n).trimEnd());
2748
+ classicPlanHop = planHops.take(dispatchedCmd);
2749
+ }
2580
2750
  line = dispatchedCmd;
2581
2751
  }
2582
2752
  else if (earlyLines.size() > 0) {
@@ -2713,6 +2883,7 @@ export async function runRepl(opts) {
2713
2883
  }
2714
2884
  await runExportAuditCommand({
2715
2885
  session,
2886
+ operator: signedInAs,
2716
2887
  transport: getCurrentTransport(),
2717
2888
  filename,
2718
2889
  emit: (l) => console.log(l),
@@ -3221,6 +3392,8 @@ export async function runRepl(opts) {
3221
3392
  // envelope revision after the turn.
3222
3393
  let planResume = null;
3223
3394
  let planResumeMsgStart = 0;
3395
+ // Task 13 — in-run hop flag (see Ink path).
3396
+ let planHop = false;
3224
3397
  // D-B — see the Ink-path mirror above: this attempt's turn-start cutoff.
3225
3398
  let planPhaseStartedAt = '';
3226
3399
  // A4 — set only when plan model tiering picks Sonnet for this phase.
@@ -3233,9 +3406,13 @@ export async function runRepl(opts) {
3233
3406
  try {
3234
3407
  const { preparePlanResume } = await import('./commands/plan-resume.js');
3235
3408
  const { inkAwarePrompt, inkAwareConfirm } = await import('./agent/loop.js');
3409
+ planHop = classicPlanHop;
3410
+ classicPlanHop = false;
3236
3411
  planResume = await preparePlanResume({
3237
3412
  body: prompt,
3238
3413
  cwd: process.cwd(),
3414
+ // Task 13 — an in-run hop prints one progress line, not the board.
3415
+ boardStyle: planHop ? 'line' : 'full',
3239
3416
  configMode: cfg.plan_mode,
3240
3417
  // Audit off-switch (2026-07-06, owner) — config default; per-run
3241
3418
  // --audit/--no-audit flags in the body override it (parsed inside).
@@ -3244,6 +3421,9 @@ export async function runRepl(opts) {
3244
3421
  sessionId: session.id,
3245
3422
  // 2026-10-02 — deploy card on a direct resume into ui.deploy.
3246
3423
  sapAlias: session.sap_system ?? undefined,
3424
+ // 2026-10-04 — re-check SAP before the step (skipped without a SAP system).
3425
+ reground: standalone ? undefined : makeRegroundDeps(adt),
3426
+ interactive: !isHeadless(),
3247
3427
  prompt: inkAwarePrompt,
3248
3428
  confirm: inkAwareConfirm,
3249
3429
  log: (...lines) => lines.forEach((l) => console.log(l)),
@@ -3253,10 +3433,31 @@ export async function runRepl(opts) {
3253
3433
  console.log('\n' + formatFailure({ what: 'Could not resume the plan', detail: [err.message] }));
3254
3434
  }
3255
3435
  if (!planResume) {
3436
+ // 2026-10-04 — resume could not start: the grant ends with the run (classic mirror).
3437
+ {
3438
+ const { clearPlanGrant } = await import('./approvals/plan-grant.js');
3439
+ clearPlanGrant();
3440
+ }
3256
3441
  session.last_skill = previousSkill ?? null;
3257
3442
  session.last_object = extractedObject ?? previousObject ?? null;
3258
3443
  continue;
3259
3444
  }
3445
+ // 2026-10-04 (F13) — classic mirror: the object list once before a typed guarded step.
3446
+ if (!isHeadless()) {
3447
+ try {
3448
+ const { offerObjectListOnResume, classicStartupChooser } = await import('./commands/plan-startup.js');
3449
+ const { inkAwarePrompt } = await import('./agent/loop.js');
3450
+ await offerObjectListOnResume({
3451
+ prepared: planResume,
3452
+ planHop,
3453
+ sessionId: session.id,
3454
+ sapAlias: session.sap_system ?? undefined,
3455
+ choose: classicStartupChooser(inkAwarePrompt, (...l) => l.forEach((x) => console.log(x))),
3456
+ log: (...l) => l.forEach((x) => console.log(x)),
3457
+ });
3458
+ }
3459
+ catch { /* best-effort: each write asks */ }
3460
+ }
3260
3461
  // Task 6 — re-audit-on-resume (classic mirror of the Ink branch; see
3261
3462
  // there for the design note). No model turn: the chain re-judges the
3262
3463
  // unresolved phase and queues any follow-up through pendingDispatch,
@@ -3268,17 +3469,18 @@ export async function runRepl(opts) {
3268
3469
  await runReauditPass({
3269
3470
  savedPath: planResume.path,
3270
3471
  phaseIds: planResume.reauditPhaseIds ?? [planResume.reauditPhaseId],
3472
+ force: planResume.reauditForce,
3271
3473
  sessionId: session.id,
3272
3474
  cwd: process.cwd(),
3273
3475
  sapAlias: session.sap_system ?? undefined,
3274
- mode: planResume.effectiveMode ?? 'step',
3476
+ mode: planResume.effectiveMode ?? DEFAULT_PLAN_MODE,
3275
3477
  modeFlag: planResume.mode,
3276
3478
  // Off-switch (I3/I2) — classic mirror; see the Ink re-audit site.
3277
3479
  auditMode: planResume.auditMode,
3278
3480
  auditFlag: planResume.auditFlag,
3279
3481
  prompt: inkAwarePrompt,
3280
3482
  confirm: inkAwareConfirm,
3281
- queueDispatch: (cmd) => pendingDispatchAccessor.set(cmd),
3483
+ queueDispatch: (cmd) => planHops.queue(cmd),
3282
3484
  log: (...lines) => lines.forEach((l) => console.log(l)),
3283
3485
  });
3284
3486
  }
@@ -3293,17 +3495,24 @@ export async function runRepl(opts) {
3293
3495
  const { releaseRunLeaseUnlessChained } = await import('./projects/run-lease.js');
3294
3496
  releaseRunLeaseUnlessChained(planResume.path, pendingDispatchAccessor.get());
3295
3497
  }
3498
+ // 2026-10-04 — re-audit leg queued nothing: grant ends with the run (classic mirror).
3499
+ if (pendingDispatchAccessor.get() === null) {
3500
+ const { clearPlanGrant } = await import('./approvals/plan-grant.js');
3501
+ clearPlanGrant();
3502
+ }
3296
3503
  session.last_skill = previousSkill ?? null;
3297
3504
  session.last_object = extractedObject ?? previousObject ?? null;
3298
3505
  continue;
3299
3506
  }
3300
- // A1 (defect D23) — see the Ink path: every plan-resume turn starts
3301
- // from reset session messages (true bounded phases).
3507
+ // 2026-10-04 (continuous run) — see the Ink path: a phase boundary is
3508
+ // save + COMPACT, falling back to today's reset if the summariser fails.
3302
3509
  {
3303
- const { resetContextForPlanResume } = await import('./commands/plan-resume.js');
3304
- if (resetContextForPlanResume(session)) {
3305
- console.log(formatInfo('Plan phase starts in a fresh bounded context · earlier conversation cleared').trimEnd());
3306
- }
3510
+ const { beginPlanPhaseContext, makePhaseBoundarySummariser } = await import('./commands/plan-boundary.js');
3511
+ await beginPlanPhaseContext({
3512
+ session,
3513
+ summarise: makePhaseBoundarySummariser(provider, resolveModelRole('compact', cfg)),
3514
+ log: (line) => console.log(line),
3515
+ });
3307
3516
  }
3308
3517
  planResumeMsgStart = session.messages.length;
3309
3518
  planPhaseStartedAt = new Date().toISOString(); // D-B evidence-window cutoff
@@ -3355,7 +3564,8 @@ export async function runRepl(opts) {
3355
3564
  planContinue = { prepared: r.prepared, marker };
3356
3565
  planPhaseStartedAt = marker.attemptStartedAt;
3357
3566
  planResumeMsgStart = session.messages.length;
3358
- console.log(formatInfo(`Re-attaching to in-flight plan phase ${marker.phaseId} (${r.prepared.path})`).trimEnd());
3567
+ const { continuationNotice } = await import('./commands/plan-continue.js');
3568
+ console.log(formatInfo(continuationNotice(r.prepared)).trimEnd());
3359
3569
  }
3360
3570
  else if (r.stale) {
3361
3571
  session.plan_in_flight = null; // plan/phase gone for good — marker is noise
@@ -3431,16 +3641,20 @@ export async function runRepl(opts) {
3431
3641
  const { setPlanPhaseActive, setGuardedRunActive, setCurrentPhaseWrites, getAndResetPlanDeviation } = await import('./commands/plan-gate.js');
3432
3642
  // Task 7 — plan-phase subagent read-only enforcement (classic mirror):
3433
3643
  // set for EVERY plan-resume phase turn regardless of mode.
3434
- setPlanPhaseActive(true);
3644
+ setPlanPhaseActive(true, planPhaseCtx.envelope.id);
3435
3645
  // Task 10 — executing phase's writes declaration for the deviation
3436
3646
  // backstop + stale-flag discard (classic mirror; see Ink path note).
3437
3647
  setCurrentPhaseWrites(planPhaseCtx.nextPhaseWrites ?? true);
3438
3648
  getAndResetPlanDeviation();
3649
+ // Stale "SAP unreachable" from an earlier turn must not pause THIS step (classic mirror).
3650
+ (await import('./sap/reachability-state.js')).getAndResetSapUnreachable();
3439
3651
  planPhaseFlagSet = true;
3440
- if ((planPhaseCtx.effectiveMode ?? 'step') === 'guarded') {
3652
+ if ((planPhaseCtx.effectiveMode ?? DEFAULT_PLAN_MODE) === 'guarded') {
3441
3653
  setGuardedRunActive(true);
3442
3654
  guardedRunFlagSet = true;
3443
- console.log(formatInfo('Guarded mode · auto-approve is off for this run').trimEnd());
3655
+ // Task 13 — once per run (first hop only), in plain words.
3656
+ if (!planHop)
3657
+ console.log(formatInfo('Continuous build · auto-approve is off; each write still runs snapshot, syntax check, activate, verify').trimEnd());
3444
3658
  }
3445
3659
  // Off-switch (2026-07-06, owner) — announce plainly that this run's
3446
3660
  // phases are NOT audited (honesty: off is a choice, never a lie).
@@ -3448,6 +3662,8 @@ export async function runRepl(opts) {
3448
3662
  console.log(chalk.dim('plan audits: off'));
3449
3663
  }
3450
3664
  }
3665
+ // 2026-10-04 — the phase's expert skill rides along in the system prompt.
3666
+ const planPhaseDelegate = planPhaseCtx?.nextDelegateTo;
3451
3667
  await runTurn({
3452
3668
  provider,
3453
3669
  userMessage: prompt,
@@ -3460,6 +3676,7 @@ export async function runRepl(opts) {
3460
3676
  // Task 19 — auto-compact at turn end, except plan resume/continue
3461
3677
  // turns (their post-turn persist indexes messages from turn start).
3462
3678
  autoCompact: planResume === null && planContinue === null,
3679
+ delegateSkill: planPhaseDelegate,
3463
3680
  // A4 — undefined on every non-plan turn (and when tiering is off).
3464
3681
  modelOverride: planModelOverride,
3465
3682
  // Task 9 — a tier-down, never the session role (proxy must not enforce it).
@@ -3583,7 +3800,7 @@ export async function runRepl(opts) {
3583
3800
  sessionId: session.id,
3584
3801
  cwd: process.cwd(),
3585
3802
  sapAlias: session.sap_system ?? undefined,
3586
- mode: planResume.effectiveMode ?? 'step',
3803
+ mode: planResume.effectiveMode ?? DEFAULT_PLAN_MODE,
3587
3804
  modeFlag: planResume.mode,
3588
3805
  // Off-switch (I3/I2) — classic mirror of the Ink chain call: skip
3589
3806
  // the audit when 'off'; per-run flag survives onto resume commands.
@@ -3595,7 +3812,7 @@ export async function runRepl(opts) {
3595
3812
  subagentDispatches: planSubagentDispatches,
3596
3813
  prompt: inkAwarePrompt,
3597
3814
  confirm: inkAwareConfirm,
3598
- queueDispatch: (cmd) => pendingDispatchAccessor.set(cmd),
3815
+ queueDispatch: (cmd) => planHops.queue(cmd),
3599
3816
  log: (...lines) => lines.forEach((l) => console.log(l)),
3600
3817
  });
3601
3818
  }
@@ -3634,8 +3851,9 @@ export async function runRepl(opts) {
3634
3851
  emit: (line) => console.log(line),
3635
3852
  });
3636
3853
  }
3637
- else if (r.kind === 'none') {
3638
- console.log(chalk.dim(`phase ${planContinue.marker.phaseId} still in flight — no completion manifest this turn`));
3854
+ else if (r.kind === 'none' && !(await import('./sap/reachability-state.js')).isSapUnreachableNoted()) {
3855
+ const { stillInFlightLine } = await import('./commands/plan-continue.js');
3856
+ console.log(chalk.dim(stillInFlightLine(planContinue.prepared.envelope, planContinue.marker.phaseId)));
3639
3857
  }
3640
3858
  }
3641
3859
  catch (err) {
@@ -3656,7 +3874,7 @@ export async function runRepl(opts) {
3656
3874
  subagentDispatches: planSubagentDispatches,
3657
3875
  prompt: inkAwarePrompt,
3658
3876
  confirm: inkAwareConfirm,
3659
- queueDispatch: (cmd) => pendingDispatchAccessor.set(cmd),
3877
+ queueDispatch: (cmd) => planHops.queue(cmd),
3660
3878
  log: (...lines) => lines.forEach((l) => console.log(l)),
3661
3879
  });
3662
3880
  }
@@ -3667,6 +3885,32 @@ export async function runRepl(opts) {
3667
3885
  // C1 — the phase reached a resolved terminal (revision persisted, chain
3668
3886
  // ran or is queued): the marker's job is done. Applies to --resume AND
3669
3887
  // continuation dispatches; a null outcome keeps the marker (still in flight).
3888
+ // 2026-10-04 (spec §3.5) — SAP unreachable mid-step: pause, Enter to
3889
+ // retry, re-run the SAME step as an in-run hop (classic mirror of the Ink site).
3890
+ if ((planResume || planContinue) && !pendingDispatchAccessor.get()) {
3891
+ try {
3892
+ const { handleSapUnreachableAfterPlanTurn, makeSapProbe } = await import('./commands/plan-sap-pause.js');
3893
+ const { inkAwarePrompt } = await import('./agent/loop.js');
3894
+ const { isHeadless } = await import('./renderer/tty.js');
3895
+ await handleSapUnreachableAfterPlanTurn({
3896
+ prepared: planResume ?? planContinue.prepared,
3897
+ persisted: planOutcome !== null,
3898
+ probe: makeSapProbe(adt),
3899
+ prompt: inkAwarePrompt,
3900
+ interactive: !isHeadless(),
3901
+ queue: (cmd) => planHops.queue(cmd),
3902
+ log: (...lines) => lines.forEach((l) => console.log(l)),
3903
+ });
3904
+ }
3905
+ catch (err) {
3906
+ // The plan file is already safe; the user still gets one instruction (classic mirror).
3907
+ try {
3908
+ const { reportSapPauseFailure } = await import('./commands/plan-sap-pause.js');
3909
+ reportSapPauseFailure(planResume ?? planContinue.prepared, err, (...lines) => lines.forEach((l) => console.log(l)));
3910
+ }
3911
+ catch { /* nothing more can be shown */ }
3912
+ }
3913
+ }
3670
3914
  if (planOutcome)
3671
3915
  session.plan_in_flight = null;
3672
3916
  // Task 9 — chain leg truly ends here: release the run lease UNLESS a
@@ -3677,6 +3921,27 @@ export async function runRepl(opts) {
3677
3921
  if (planResume || planContinue) {
3678
3922
  const { releaseRunLeaseUnlessChained } = await import('./projects/run-lease.js');
3679
3923
  releaseRunLeaseUnlessChained((planResume ?? planContinue.prepared).path, pendingDispatchAccessor.get());
3924
+ // 2026-10-04 — run over: the plan object-list grant ends with it (classic mirror).
3925
+ if (pendingDispatchAccessor.get() === null) {
3926
+ const { clearPlanGrant } = await import('./approvals/plan-grant.js');
3927
+ clearPlanGrant();
3928
+ }
3929
+ }
3930
+ // 2026-10-04 — auto-compact is ON for plan turns (classic mirror of the
3931
+ // Ink site): after the revision is saved, only when no next step is queued.
3932
+ if (planResume || planContinue) {
3933
+ try {
3934
+ const { maybeAutoCompactAfterPlanTurn, makeAutoCompactSummariser } = await import('./commands/plan-boundary.js');
3935
+ const { resolveAutoCompactThreshold } = await import('./models/resolve.js');
3936
+ await maybeAutoCompactAfterPlanTurn({
3937
+ session,
3938
+ config: { ...cfg.compact, auto_threshold_tokens: resolveAutoCompactThreshold(cfg) },
3939
+ queued: pendingDispatchAccessor.get() !== null,
3940
+ summarise: makeAutoCompactSummariser(provider, resolveModelRole('compact', cfg)),
3941
+ emit: (line) => console.log(line),
3942
+ });
3943
+ }
3944
+ catch { /* auto-compact must never fail a finished turn */ }
3680
3945
  }
3681
3946
  // Status footer after each turn.
3682
3947
  // tool_result messages also have role:'user' but content is an array —