@cspeach/cli 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/dist/agent/loop.js +22 -9
  2. package/dist/approvals/op-labels.js +124 -0
  3. package/dist/approvals/render.js +42 -36
  4. package/dist/cli.js +15 -0
  5. package/dist/commands/compact.js +28 -2
  6. package/dist/commands/config-set.js +189 -0
  7. package/dist/commands/config-show.js +20 -0
  8. package/dist/commands/export-audit.js +43 -0
  9. package/dist/commands/help.js +5 -0
  10. package/dist/commands/plan-audit-evidence.js +266 -0
  11. package/dist/commands/plan-audit.js +692 -0
  12. package/dist/commands/plan-chain.js +671 -0
  13. package/dist/commands/plan-continue.js +179 -0
  14. package/dist/commands/plan-gate.js +154 -0
  15. package/dist/commands/plan-resume.js +588 -33
  16. package/dist/config/loader.js +128 -4
  17. package/dist/config/model-defaults.js +14 -0
  18. package/dist/cost/pricing.js +27 -1
  19. package/dist/doctor/checks/system-roles.js +41 -0
  20. package/dist/doctor/run.js +2 -0
  21. package/dist/models/resolve.js +61 -0
  22. package/dist/models/server-config.js +155 -0
  23. package/dist/one-shot.js +25 -3
  24. package/dist/projects/extract-cca.js +3 -1
  25. package/dist/projects/extract-modernize.js +3 -1
  26. package/dist/projects/extract-plan.js +60 -6
  27. package/dist/projects/extract-test-coverage.js +3 -1
  28. package/dist/projects/extract-upgrade.js +3 -1
  29. package/dist/projects/handover-md.js +195 -0
  30. package/dist/projects/index.js +1 -1
  31. package/dist/projects/plan-run.js +137 -13
  32. package/dist/projects/plan-schema.js +73 -0
  33. package/dist/projects/run-lease.js +157 -0
  34. package/dist/projects/save-command.js +26 -15
  35. package/dist/renderer/status-footer.js +22 -12
  36. package/dist/renderer/thinking-heartbeat.js +64 -8
  37. package/dist/renderer/todo-block.js +51 -0
  38. package/dist/renderer/tool-widget.js +37 -0
  39. package/dist/repl/bracketed-paste.js +28 -19
  40. package/dist/repl/builtin-commands.js +5 -0
  41. package/dist/repl/current-transport.js +10 -0
  42. package/dist/repl/history.js +86 -0
  43. package/dist/repl/ink-stdin-guard.js +64 -0
  44. package/dist/repl/mode-ceiling.js +16 -0
  45. package/dist/repl/mode-cycle.js +104 -0
  46. package/dist/repl/post-turn-status.js +24 -4
  47. package/dist/repl/slash-completer.js +5 -0
  48. package/dist/repl.js +954 -83
  49. package/dist/rewind/candidates.js +194 -0
  50. package/dist/rewind/cli.js +137 -0
  51. package/dist/rewind/format.js +27 -0
  52. package/dist/rewind/restore.js +245 -0
  53. package/dist/session/audit-export.js +459 -0
  54. package/dist/session/context-report.js +163 -0
  55. package/dist/session/recap.js +160 -0
  56. package/dist/skill-catalog.js +9 -3
  57. package/dist/skills/bundled-skills.js +59 -66
  58. package/dist/tools/approval.js +115 -7
  59. package/dist/tools/ask-question.js +304 -3
  60. package/dist/tools/extend-model/anchored-insert.js +604 -0
  61. package/dist/tools/extend-model/tool.js +162 -10
  62. package/dist/tools/fiori/fe-extend.js +76 -0
  63. package/dist/tools/fiori/fe-scaffold.js +29 -3
  64. package/dist/tools/fiori/floorplan-map.js +19 -0
  65. package/dist/tools/fiori/samples/data/index.json +13602 -0
  66. package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
  67. package/dist/tools/fiori/samples/loader.js +248 -0
  68. package/dist/tools/fiori/samples/search.js +63 -0
  69. package/dist/tools/fiori/samples/types.js +2 -0
  70. package/dist/tools/fiori/smoke/assertions.js +74 -0
  71. package/dist/tools/fiori/smoke/browser.js +52 -0
  72. package/dist/tools/fiori/smoke/driver.js +89 -0
  73. package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
  74. package/dist/tools/fiori/smoke/run-smoke.js +149 -0
  75. package/dist/tools/fiori/tools.js +328 -3
  76. package/dist/tools/local-build.js +11 -1
  77. package/dist/tools/sap-read.js +79 -11
  78. package/dist/tools/sap-write.js +24 -4
  79. package/dist/tools/snapshot.js +27 -1
  80. package/dist/tools/subagent/agent_run.js +27 -3
  81. package/dist/tools/todo.js +144 -0
  82. package/dist/ui/app.js +372 -19
  83. package/dist/ui/approval-modal.js +49 -16
  84. package/dist/ui/ask-question-emitter.js +14 -0
  85. package/dist/ui/context-grid.js +108 -0
  86. package/dist/ui/footer.js +109 -30
  87. package/dist/ui/header.js +7 -0
  88. package/dist/ui/line-resolution.js +18 -2
  89. package/dist/ui/rewind-emitter.js +10 -0
  90. package/dist/ui/rewind-panel.js +81 -0
  91. package/dist/ui/sap-state-store.js +1 -0
  92. package/dist/ui/status-line.js +43 -0
  93. package/dist/ui/text-input.js +72 -8
  94. package/dist/ui/todo-emitter.js +25 -0
  95. package/dist/ui/todo-panel.js +64 -0
  96. package/dist/ui/turn-status-emitter.js +50 -4
  97. package/dist/ui/turn-status.js +18 -3
  98. package/dist/ui/widgets/ask-form.js +242 -0
  99. package/dist/ui/widgets/ask-question-modal.js +17 -7
  100. package/package.json +4 -1
package/dist/repl.js CHANGED
@@ -6,9 +6,6 @@ import chalk from 'chalk';
6
6
  // callback-module's InterfaceConstructor signature or import from
7
7
  // 'node:readline' directly for that specific operation.
8
8
  import * as readline from 'node:readline/promises';
9
- import * as fs from 'node:fs';
10
- import * as path from 'node:path';
11
- import * as os from 'node:os';
12
9
  import { select, password as promptPassword } from '@inquirer/prompts';
13
10
  import { loadConfig, resolveLocalBuild } from './config/loader.js';
14
11
  import { getStore } from './auth/api-key.js';
@@ -18,20 +15,36 @@ import { formatLoginBanner } from './ui/login-banner.js';
18
15
  import { printStartupBanner, printKeytarFallbackBanner } from './renderer/banners.js';
19
16
  import { runAddSapWizard } from './sap/onboarding.js';
20
17
  import { getConnection } from './sap/connection-manager.js';
21
- import { AdtClient } from '@cspeach/sap-client';
18
+ import { AdtClient, snapshots } from '@cspeach/sap-client';
22
19
  import { makeSessionId, saveSession } from './session/store.js';
23
20
  import { newSession } from './session/schema.js';
21
+ import { resolveModelRole } from './models/resolve.js';
22
+ import { loadServerModelConfig } from './models/server-config.js';
24
23
  import { computeCost, formatCost } from './cost/pricing.js';
25
24
  import { createProviderForMode } from './agent/providers/factory.js';
26
25
  import { assertModeLicensed, CspeachLicenseError } from './agent/providers/license-gate.js';
27
26
  import { runTurn } from './agent/loop.js';
28
27
  import { renderTurnError } from './agent/turn-error-ux.js';
28
+ // BUG-2b (2026-07-05 live smoke) — pure (no-Ink) modules, safe to import
29
+ // statically even though App itself is lazy-loaded to keep Ink out of
30
+ // classic mode. Used by the single-flight dispatch guard below.
31
+ import { turnStatusEmitter } from './ui/turn-status-emitter.js';
32
+ import { enqueueSteering } from './agent/steering-queue.js';
29
33
  import { classifyPrompt, parseShortcut, rejectUnknownSkill } from './router/classifier.js';
30
34
  import { decideRouting } from './router/routing-decision.js';
31
35
  import { coachingPickerClassic } from './ui/coaching-picker-classic.js';
32
36
  import { coachingPickerEmitter } from './ui/coaching-picker-emitter.js';
33
37
  import { globalStore as sapStateStore } from './ui/sap-state-store.js';
38
+ import { todoEmitter } from './ui/todo-emitter.js';
34
39
  import { PICK_CANCELLED } from './ui/skill-picker.js';
40
+ // Task 3 (ux-wave3) — rewind. rewindEmitter + approvalEmitter are pure
41
+ // EventEmitters; rewind/* and approvals/render are ink-free, so these are safe
42
+ // static imports in the classic path too (only the Ink render block below
43
+ // actually mounts a panel).
44
+ import { rewindEmitter } from './ui/rewind-emitter.js';
45
+ import { approvalEmitter } from './ui/approval-emitter.js';
46
+ import { collectRewindCandidates } from './rewind/candidates.js';
47
+ import { executeRestore, runClassicRewind } from './rewind/cli.js';
35
48
  import { registerReadline, withInquirer } from './repl/inquirer-guard.js';
36
49
  import { maybePromptWorkspaceMigration, maybeOfferAbapforgeMigration } from './projects/migration.js';
37
50
  import { findProjectRoot } from './projects/workspace.js';
@@ -39,6 +52,7 @@ import { slashCompleter } from './repl/slash-completer.js';
39
52
  import { openSlashPicker, isExactSlashMatch } from './repl/slash-picker.js';
40
53
  import { openAtPicker, pendingAtTokenPrefix } from './repl/at-picker.js';
41
54
  import { atCompleter } from './repl/at-completer.js';
55
+ import { appendHistoryEntry, readHistoryEntries } from './repl/history.js';
42
56
  /**
43
57
  * Pre-fill the readline buffer with `text`, cursor at end, and trigger
44
58
  * a redraw. Bypasses rl.write() — which simulates per-character
@@ -60,6 +74,7 @@ function prefillReadline(rl, text) {
60
74
  internal._refreshLine?.();
61
75
  }
62
76
  import { BracketedPasteDecoder, enableBracketedPaste, disableBracketedPaste, } from './repl/bracketed-paste.js';
77
+ import { InkStdinPasteGuard } from './repl/ink-stdin-guard.js';
63
78
  import { printHelp } from './commands/help.js';
64
79
  import { shouldUseInk, setRenderingOverride } from './renderer/tty.js';
65
80
  import { createClassicOutputEmitter } from './renderer/notices.js';
@@ -70,6 +85,7 @@ import { SKILL_CATALOG } from './skill-catalog.js';
70
85
  import { replPreviewHook } from './repl/update-method-preview-hook.js';
71
86
  import { getCurrentTransport, handleTransportCommand } from './repl/current-transport.js';
72
87
  import { matchSafetyModeCommand } from './repl/safety-mode-state.js';
88
+ import { getEffectiveWriteMode, runModeCommand } from './repl/mode-cycle.js';
73
89
  import { maybeExpandNumberSelection, lastAssistantText } from './repl/numbered-menu.js';
74
90
  import { attachEarlyLineBuffer, noopEarlyLineBuffer } from './repl/early-line-buffer.js';
75
91
  import { snapshotTurnStart, pushPostTurnStatus } from './repl/post-turn-status.js';
@@ -83,6 +99,7 @@ import './tools/approval.js';
83
99
  import './tools/snapshot.js';
84
100
  import './tools/ask-question.js';
85
101
  import './tools/dispatch-skill.js';
102
+ import './tools/todo.js';
86
103
  import './tools/filesystem/file-read.js';
87
104
  import './tools/filesystem/read-document.js';
88
105
  import './tools/filesystem/file-edit.js';
@@ -106,37 +123,20 @@ import './tools/subagent/monitor_emit.js';
106
123
  import './tools/subagent/schedule_draft_create.js';
107
124
  import './tools/subagent/agent_run.js';
108
125
  const PEACH = '#FFCBA4';
109
- const HISTORY_FILE = path.join(os.homedir(), '.cspeach', 'history');
110
126
  const HISTORY_SIZE = 500;
127
+ // Persistence (read + dedup-guarded append) lives in ./repl/history.ts —
128
+ // the single writer shared with the Ink Footer (Task 8, ux-wave1).
111
129
  function loadHistory() {
112
- try {
113
- const raw = fs.readFileSync(HISTORY_FILE, 'utf-8');
114
- return raw
115
- .split('\n')
116
- .map((l) => l.trim())
117
- .filter((l) => l.length > 0)
118
- .slice(-HISTORY_SIZE)
119
- // readline expects history[0] to be the MOST RECENT entry (arrow-up
120
- // walks from index 0 backwards in time). The on-disk file is
121
- // chronologically appended — oldest at top, newest at bottom — so
122
- // after slicing the last N lines we need to reverse them. Without
123
- // this, arrow-up showed the user the oldest 500 entries first; new
124
- // ones (correctly persisted to disk) sat at the far end of the buffer
125
- // and were unreachable.
126
- .reverse();
127
- }
128
- catch {
129
- return [];
130
- }
131
- }
132
- function appendHistory(line) {
133
- try {
134
- fs.mkdirSync(path.dirname(HISTORY_FILE), { recursive: true });
135
- fs.appendFileSync(HISTORY_FILE, line + '\n', 'utf-8');
136
- }
137
- catch {
138
- // Non-fatal — history persistence failure must never crash the REPL.
139
- }
130
+ return (readHistoryEntries()
131
+ .slice(-HISTORY_SIZE)
132
+ // readline expects history[0] to be the MOST RECENT entry (arrow-up
133
+ // walks from index 0 backwards in time). The on-disk file is
134
+ // chronologically appended — oldest at top, newest at bottom — so
135
+ // after slicing the last N lines we need to reverse them. Without
136
+ // this, arrow-up showed the user the oldest 500 entries first; new
137
+ // ones (correctly persisted to disk) sat at the far end of the buffer
138
+ // and were unreachable.
139
+ .reverse());
140
140
  }
141
141
  /** Short user inputs that almost always mean "continue what we were doing"
142
142
  * rather than "start a new task." When the previous turn left a skill
@@ -378,16 +378,30 @@ export async function runRepl(opts) {
378
378
  await runAddSapWizard();
379
379
  cfg = await loadConfig();
380
380
  }
381
+ // model-governance step 2d — fire-and-forget fetch of the served model-config
382
+ // (managed mode only). NEVER blocks startup: a turn that starts before the
383
+ // fetch lands simply uses the built-ins (fail-safe). Precedence when it does
384
+ // land: env > local config > server > built-in.
385
+ void loadServerModelConfig(cfg);
381
386
  // Task C.10 — surface the current write_mode prominently. advisory-only gets
382
387
  // a bold yellow banner (no-writes is a surprising-enough state that the user
383
388
  // should not miss it). Other modes get a quiet one-liner so the state is
384
389
  // always visible without competing with the peach banner above.
385
- if (cfg.write_mode === 'advisory-only') {
386
- console.log(chalk.yellow.bold('⚠ Write mode: ADVISORY (writes proposed, never executed)'));
387
- console.log(chalk.yellow(' Change via: cspeach config set write_mode approval-gated\n'));
388
- }
389
- else {
390
- console.log(chalk.gray(`Write mode: ${cfg.write_mode}\n`));
390
+ //
391
+ // Task 3 (ux-wave1) — the banner must announce the EFFECTIVE mode (config
392
+ // clamped to the alias role's ceiling), but the role isn't known until the
393
+ // alias is picked below. With no role configured anywhere the clamp is the
394
+ // identity, so print here exactly as before (byte-identical output); when
395
+ // any alias declares a role, defer the banner until the alias is resolved.
396
+ const anyRoleConfigured = Object.values(cfg.sap).some((s) => s.role !== undefined);
397
+ if (!anyRoleConfigured) {
398
+ if (cfg.write_mode === 'advisory-only') {
399
+ console.log(chalk.yellow.bold('⚠ Write mode: ADVISORY (writes proposed, never executed)'));
400
+ console.log(chalk.yellow(' Change via: cspeach config set write_mode approval-gated\n'));
401
+ }
402
+ else {
403
+ console.log(chalk.gray(`Write mode: ${cfg.write_mode}\n`));
404
+ }
391
405
  }
392
406
  // local_build toggle — when persisted-on, force-enable the flag-gated
393
407
  // filesystem tools AND shell_exec BEFORE the first listTools()/turn (listTools
@@ -422,21 +436,54 @@ export async function runRepl(opts) {
422
436
  choices: aliases.map((a) => ({ value: a, name: a })),
423
437
  });
424
438
  }
439
+ // Task 3 (ux-wave1) — deferred write-mode banner: now that the alias is
440
+ // known, announce the EFFECTIVE mode and flag when the role clamped it.
441
+ // Only reached when a role is configured (no-role path printed above,
442
+ // byte-identical to pre-role behavior).
443
+ const aliasRole = cfg.sap[selectedAlias]?.role;
444
+ const startupWriteMode = getEffectiveWriteMode(cfg.write_mode, aliasRole);
445
+ if (anyRoleConfigured) {
446
+ const capped = startupWriteMode !== cfg.write_mode
447
+ ? ` (capped by sap.${selectedAlias}.role = ${aliasRole})`
448
+ : '';
449
+ if (startupWriteMode === 'advisory-only') {
450
+ console.log(chalk.yellow.bold(`⚠ Write mode: ADVISORY (writes proposed, never executed)${capped}`));
451
+ // The config hint only makes sense when config CAUSED advisory —
452
+ // a role-capped advisory can't be lifted by a config change.
453
+ if (capped === '') {
454
+ console.log(chalk.yellow(' Change via: cspeach config set write_mode approval-gated\n'));
455
+ }
456
+ else {
457
+ console.log('');
458
+ }
459
+ }
460
+ else {
461
+ console.log(chalk.gray(`Write mode: ${startupWriteMode}${capped}\n`));
462
+ }
463
+ }
425
464
  const conn = await getConnection(selectedAlias, cfg.sap[selectedAlias]);
426
465
  const adt = new AdtClient(conn);
427
466
  // Phase A #3 (2026-05-16) — sidebar SAP-state wire, trigger 1 of 4.
428
467
  // Push system + user the moment we know the alias. Before this commit
429
468
  // the sidebar showed "no system / user: —" forever because the store
430
469
  // had .update() methods but no callers.
470
+ // Task 3 (ux-wave1) — seed writeMode with the startup-EFFECTIVE mode so
471
+ // the Task 4 statusline chip is correct before any cycle happens.
472
+ // Task 4 review fix (2026-07-04) — seed the BARE alias, not
473
+ // `alias (host)`: an FQDN host pushed the statusline past 100 cols and
474
+ // wrapped it. store.system has exactly two readers — StatusLine and the
475
+ // superseded (unmounted) header.tsx — so nothing else loses the host;
476
+ // the connection banner above already printed it once to scrollback.
431
477
  sapStateStore.update({
432
- system: `${selectedAlias} (${cfg.sap[selectedAlias].host})`,
478
+ system: selectedAlias,
433
479
  user: cfg.sap[selectedAlias].username,
480
+ writeMode: startupWriteMode,
434
481
  });
435
482
  // v0.6 — when resuming, adopt the on-disk SessionState verbatim. This
436
483
  // preserves messages[], usage, last_skill, awaitingSkillAnswer, and the
437
484
  // turnInterrupted flag from the prior run. Skipping newSession() avoids
438
485
  // overwriting the saved file with an empty fresh one.
439
- const session = opts?.resumedSession ?? newSession(makeSessionId(), selectedAlias, null, cfg.default_model);
486
+ const session = opts?.resumedSession ?? newSession(makeSessionId(), selectedAlias, null, resolveModelRole('session_default', cfg));
440
487
  if (!opts?.resumedSession) {
441
488
  await saveSession(session);
442
489
  }
@@ -467,6 +514,21 @@ export async function runRepl(opts) {
467
514
  console.log(chalk.yellow(`⚠ Last turn was interrupted (${reason}).`));
468
515
  console.log(chalk.yellow(` Type \"continue\" to ask the model to finish where it left off.`));
469
516
  }
517
+ // Task 6 (ux-wave2) — session recap: three dim lines (last session ·
518
+ // open todos · unsatisfied plan) so the returning developer is oriented
519
+ // without scrolling. Covers BOTH resume entries — the `cspeach --resume`
520
+ // picker (session/resume.ts) and any direct --resume <id> — since both
521
+ // land here with opts.resumedSession set. Best-effort: buildRecap never
522
+ // throws, and a workspace-resolution failure just drops the plan line.
523
+ try {
524
+ const { buildRecap } = await import('./session/recap.js');
525
+ const { resolveWorkspacePath } = await import('./projects/workspace.js');
526
+ const recapWs = await resolveWorkspacePath().catch(() => undefined);
527
+ for (const recapLine of buildRecap(session, process.cwd(), { workspaceDir: recapWs })) {
528
+ console.log(chalk.dim(recapLine));
529
+ }
530
+ }
531
+ catch { /* recap is best-effort — never block resume */ }
470
532
  console.log(chalk.gray(`Type /exit to quit.\n`));
471
533
  }
472
534
  else {
@@ -511,10 +573,13 @@ export async function runRepl(opts) {
511
573
  // Things this delete-line cleanup removes (vs the alt-screen path):
512
574
  // - enterAltScreen / leaveAltScreen calls
513
575
  // - hideCursor interval workaround
514
- // - bracketed-paste enable (now handled the classic-mode way below)
515
576
  // - PgUp/PgDn/Ctrl+B scroll state in App + Body
516
577
  // - termHeight + SIGWINCH resize tracking
517
578
  // - exitOnCtrlC: false render option (default behaviour is fine)
579
+ //
580
+ // Bracketed paste: NOT removed anymore. Wave1 live-fix (Bug 2)
581
+ // enables DEC 2004 for the Ink path too and routes stdin through
582
+ // InkStdinPasteGuard — see the render() wiring below.
518
583
  // Hide the terminal's native cursor while Ink owns the frame. Ink's
519
584
  // dynamic frame ends with a newline, parking the real cursor on the
520
585
  // line BELOW the Footer ("booger cursor"). ink-text-input's
@@ -546,11 +611,19 @@ export async function runRepl(opts) {
546
611
  // it. When null, we're at idle and should exit on Ctrl+C.
547
612
  let currentTurnAbort = null;
548
613
  let interruptCount = 0;
549
- const handleSubmit = async (line) => {
614
+ // BUG-2b — the actual dispatch. Wrapped by handleSubmit (below) which
615
+ // owns the single-flight reserve()/release() lifecycle. All re-entrant
616
+ // callers (the setImmediate auto-dispatch / re-audit legs) go through the
617
+ // WRAPPER so they are guarded and reserved too.
618
+ const dispatchSubmit = async (line) => {
550
619
  const trimmed = line.trim();
551
620
  if (!trimmed)
552
621
  return;
553
- appendHistory(trimmed);
622
+ // Task 8 (ux-wave1) — shared dedup-guarded writer. On the Ink path
623
+ // the Footer has already persisted this submission (flattened), so
624
+ // the consecutive-dup guard makes this a no-op instead of the old
625
+ // double append.
626
+ appendHistoryEntry(trimmed);
554
627
  // Phase A** (2026-05-16) — echo the user's prompt into Body so it
555
628
  // appears in the conversation history. Plus a dim divider above
556
629
  // the echo so consecutive turns have a clear visual break (closes
@@ -586,16 +659,79 @@ export async function runRepl(opts) {
586
659
  await runCostCommand(session.id);
587
660
  return;
588
661
  }
662
+ // Task 4 (ux-wave3) — /export audit [filename]: write the signable
663
+ // session audit record (who did what, where, under which approval, at
664
+ // what cost) to a markdown file in cwd and print the path.
665
+ if (trimmed === '/export' || trimmed.startsWith('/export ')) {
666
+ const { parseExportArgs, runExportAuditCommand } = await import('./commands/export-audit.js');
667
+ const { sub, filename } = parseExportArgs(trimmed.slice('/export'.length));
668
+ if (sub !== 'audit') {
669
+ chunkEmitter.emit('chunk', chalk.dim('\nUsage: /export audit [filename]\n'));
670
+ return;
671
+ }
672
+ await runExportAuditCommand({
673
+ session,
674
+ transport: getCurrentTransport(),
675
+ filename,
676
+ emit: (l) => chunkEmitter.emit('chunk', l + '\n'),
677
+ });
678
+ return;
679
+ }
680
+ // Task 5 (ux-wave3) — /context: the composition grid. What fills the
681
+ // model window right now (summary block · recent turns · tool results ·
682
+ // todos · system+skill+tools), one proportional bar each. Read-only.
683
+ if (trimmed === '/context' || trimmed.startsWith('/context ')) {
684
+ // M4 — args are not a thing; one usage line instead of the classifier
685
+ // fallthrough treating '/context foo' as an unknown-skill prompt.
686
+ if (trimmed !== '/context') {
687
+ chunkEmitter.emit('chunk', chalk.dim('/context takes no arguments — it shows what fills the model window right now\n'));
688
+ return;
689
+ }
690
+ const { runContextCommand } = await import('./ui/context-grid.js');
691
+ await runContextCommand({
692
+ session,
693
+ color: true,
694
+ emit: (l) => chunkEmitter.emit('chunk', l + '\n'),
695
+ });
696
+ return;
697
+ }
698
+ // Task 3 (ux-wave1) — /mode: view-by-cycling / jump the SESSION write
699
+ // mode (config untouched). Bare cycles; advisory|gated|auto jumps
700
+ // (still clamped to the alias role's ceiling). Same core as Shift+Tab.
701
+ if (trimmed === '/mode' || trimmed.startsWith('/mode ')) {
702
+ const modeArg = trimmed === '/mode' ? undefined : (trimmed.slice('/mode '.length).trim() || undefined);
703
+ const { line: modeLine, mode } = runModeCommand(modeArg, cfg.write_mode, cfg.sap[selectedAlias]?.role, selectedAlias);
704
+ chunkEmitter.emit('chunk', chalk.dim(modeLine) + '\n');
705
+ sapStateStore.update({ writeMode: mode });
706
+ return;
707
+ }
708
+ // Task 6 (ux-wave2) — /recap: reprint the orientation recap (session ·
709
+ // todos · plan) for the CURRENT session. Same builder as the
710
+ // resume-time banner; empty recap → one dim placeholder line.
711
+ if (trimmed === '/recap') {
712
+ const { buildRecap } = await import('./session/recap.js');
713
+ const { resolveWorkspacePath } = await import('./projects/workspace.js');
714
+ const recapWs = await resolveWorkspacePath().catch(() => undefined);
715
+ const recapLines = buildRecap(session, process.cwd(), { workspaceDir: recapWs });
716
+ const body = recapLines.length === 0
717
+ ? chalk.dim('nothing to recap yet')
718
+ : recapLines.map((l) => chalk.dim(l)).join('\n');
719
+ chunkEmitter.emit('chunk', '\n' + body + '\n');
720
+ return;
721
+ }
589
722
  // Phase 2 conversation hygiene (2026-05-16) — /compact summarises
590
723
  // older turns via Haiku so subsequent turns ingest ~5k tokens of
591
724
  // context instead of 500k+. Saves \$3-8 per follow-up turn.
592
725
  if (trimmed === '/compact') {
593
- const { runCompact, formatCompactResult, buildSummarisationPrompt, serialiseForSummariser, COMPACTION_MODEL } = await import('./commands/compact.js');
726
+ const { runCompact, formatCompactResult, buildSummarisationPrompt, serialiseForSummariser } = await import('./commands/compact.js');
594
727
  const { summariseViaProvider } = await import('./agent/summarise-via-provider.js');
595
728
  const { loadConfig } = await import('./config/loader.js');
596
729
  const compactCfg = (await loadConfig()).compact;
597
730
  const summarise = async (toSummarise) => {
598
- return await summariseViaProvider(provider, buildSummarisationPrompt(), serialiseForSummariser(toSummarise), { model: COMPACTION_MODEL, maxTokens: 4000, headers: { 'X-CSForge-Skill': 'compact' } });
731
+ return await summariseViaProvider(provider, buildSummarisationPrompt(), serialiseForSummariser(toSummarise),
732
+ // model-governance step 2d — compact model resolves env > local >
733
+ // server > built-in (== COMPACTION_MODEL when nothing set).
734
+ { model: resolveModelRole('compact', cfg), maxTokens: 4000, headers: { 'X-CSForge-Skill': 'compact' } });
599
735
  };
600
736
  try {
601
737
  chunkEmitter.emit('chunk', chalk.dim('\nCompacting older turns into a summary...\n'));
@@ -678,19 +814,18 @@ export async function runRepl(opts) {
678
814
  chunkEmitter.emit('chunk', chalk.dim(`\n↪ Rerouting "${preview}" → /${rr.skill}\n`));
679
815
  if (rr.warning)
680
816
  chunkEmitter.emit('chunk', chalk.dim(` (${rr.warning})\n`));
681
- const { turnStatusEmitter: rerouteStatus } = await import('./ui/turn-status-emitter.js');
682
- rerouteStatus.start(`/${rr.skill}`);
817
+ turnStatusEmitter.start(`/${rr.skill}`);
683
818
  try {
684
819
  await runTurn({
685
820
  provider,
686
821
  userMessage: rr.prompt,
687
822
  skill: rr.skill,
688
- ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
823
+ ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
689
824
  chunkEmitter,
690
825
  });
691
826
  }
692
827
  finally {
693
- rerouteStatus.stop();
828
+ turnStatusEmitter.stop();
694
829
  }
695
830
  return;
696
831
  }
@@ -709,6 +844,20 @@ export async function runRepl(opts) {
709
844
  chunkEmitter.emit('chunk', chalk.cyan(`\n${msg}\n`));
710
845
  return;
711
846
  }
847
+ // Task 3 (ux-wave3) — /rewind: open the Ink rewind panel. <App> is a
848
+ // separate React tree, so signal it through rewindEmitter (Esc-Esc opens
849
+ // the same panel from inside App). Deps + candidates are wired at the App
850
+ // render call below; the panel handles the empty-list case itself.
851
+ if (trimmed === '/rewind' || trimmed.startsWith('/rewind ')) {
852
+ // M5 — args are not a thing; one usage line instead of the classifier
853
+ // fallthrough treating '/rewind foo' as an unknown-skill prompt.
854
+ if (trimmed !== '/rewind') {
855
+ chunkEmitter.emit('chunk', chalk.dim('/rewind takes no arguments — it opens the session rewind list (Esc Esc does too)\n'));
856
+ return;
857
+ }
858
+ rewindEmitter.emit('open');
859
+ return;
860
+ }
712
861
  // /safety-mode on|off — structured Forge Rule confirmation cards.
713
862
  // UX-5 #1 (2026-06-12): handleSafetyModeCommand existed (unit-tested)
714
863
  // but no REPL path ever routed to it — the toggle was unreachable at
@@ -876,14 +1025,33 @@ export async function runRepl(opts) {
876
1025
  // persists the version-N+1 REVISION after the turn.
877
1026
  let planResume = null;
878
1027
  let planResumeMsgStart = 0;
1028
+ // D-B — the wall-clock start of THIS phase attempt's turn, threaded to
1029
+ // the post-phase audit as the evidence-window cutoff (so an audit never
1030
+ // sees an earlier attempt's tool-call rows in the shared session JSONL).
1031
+ let planPhaseStartedAt = '';
879
1032
  // A4 — set only when plan model tiering picks Sonnet for this phase.
880
1033
  let planModelOverride;
1034
+ // Task 6 — true while the guarded auto_approve override is active for
1035
+ // this turn; cleared in the runTurn finally (fail-safe: never sticky).
1036
+ let guardedRunFlagSet = false;
1037
+ // Task 7 — true while the plan-phase subagent read-only enforcement is
1038
+ // active for this turn; same lifecycle, cleared in the same finally.
1039
+ let planPhaseFlagSet = false;
881
1040
  if (skill === 'abap-plan' && /(^|\s)--resume(\s|$)/.test(prompt)) {
882
1041
  try {
883
1042
  const { preparePlanResume } = await import('./commands/plan-resume.js');
1043
+ const { inkAwarePrompt } = await import('./agent/loop.js');
884
1044
  planResume = await preparePlanResume({
885
1045
  body: prompt,
886
1046
  cwd: process.cwd(),
1047
+ configMode: cfg.plan_mode,
1048
+ // Audit off-switch (2026-07-06, owner) — config default; per-run
1049
+ // --audit/--no-audit flags in the body override it (parsed inside).
1050
+ configAudit: cfg.plan_audit,
1051
+ // Task 9 — run-lease identity + takeover prompt (concurrent
1052
+ // resume guard; inkAwarePrompt answers '' in headless = decline).
1053
+ sessionId: session.id,
1054
+ prompt: inkAwarePrompt,
887
1055
  log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
888
1056
  });
889
1057
  }
@@ -895,6 +1063,55 @@ export async function runRepl(opts) {
895
1063
  session.last_object = extractedObject ?? previousObject ?? null;
896
1064
  return;
897
1065
  }
1066
+ // Task 6 (agentic-flow) — re-audit-on-resume: the last executed phase
1067
+ // has an unresolved audit (crash before the verdict write, auditor
1068
+ // infra outage, or a guarded-mode hand-edit). NO model turn this
1069
+ // dispatch — run the audit chain instead; it re-judges the phase,
1070
+ // persists the verdict, and offers/queues the next phase itself.
1071
+ if (planResume.reauditPhaseId) {
1072
+ try {
1073
+ const { runReauditPass } = await import('./commands/plan-chain.js');
1074
+ const { inkAwarePrompt } = await import('./agent/loop.js');
1075
+ await runReauditPass({
1076
+ savedPath: planResume.path,
1077
+ phaseIds: planResume.reauditPhaseIds ?? [planResume.reauditPhaseId],
1078
+ sessionId: session.id,
1079
+ cwd: process.cwd(),
1080
+ mode: planResume.effectiveMode ?? 'step',
1081
+ modeFlag: planResume.mode,
1082
+ // Off-switch (I3) — footer honesty on re-audit boards; (I2) the
1083
+ // per-run audit flag survives onto chain-built resume commands.
1084
+ auditMode: planResume.auditMode,
1085
+ auditFlag: planResume.auditFlag,
1086
+ prompt: inkAwarePrompt,
1087
+ queueDispatch: (cmd) => pendingDispatchAccessor.set(cmd),
1088
+ log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
1089
+ });
1090
+ }
1091
+ catch (err) {
1092
+ chunkEmitter.emit('chunk', chalk.red(`\n[plan] re-audit failed: ${err.message}\n`));
1093
+ }
1094
+ session.last_skill = previousSkill ?? null;
1095
+ session.last_object = extractedObject ?? previousObject ?? null;
1096
+ // Same consume-the-slot mechanics as the post-turn dispatch below.
1097
+ const reauditDispatched = pendingDispatchAccessor.get();
1098
+ // Task 9 (review fix) — release ONLY when the re-audit leg queued
1099
+ // no follow-up. A queued dispatch continues the chain: the lease
1100
+ // is HELD across the gap (the re-entry's same-session re-acquire
1101
+ // is idempotent), or a second session could grab the family lock
1102
+ // mid-chain. A crash before this line leaves a lease that goes
1103
+ // stale with the pid.
1104
+ {
1105
+ const { releaseRunLeaseUnlessChained } = await import('./projects/run-lease.js');
1106
+ releaseRunLeaseUnlessChained(planResume.path, reauditDispatched);
1107
+ }
1108
+ if (reauditDispatched) {
1109
+ pendingDispatchAccessor.set(null);
1110
+ chunkEmitter.emit('chunk', chalk.dim(`↪ Auto-routed: ${reauditDispatched}\n`));
1111
+ setImmediate(() => { void handleSubmit(reauditDispatched); });
1112
+ }
1113
+ return;
1114
+ }
898
1115
  // A1 (defect D23) — true bounded phases: EVERY plan-resume turn
899
1116
  // (harness-dispatched or manually typed) starts from reset session
900
1117
  // messages. The envelope + bounded prompt carry all needed state;
@@ -907,7 +1124,16 @@ export async function runRepl(opts) {
907
1124
  }
908
1125
  }
909
1126
  planResumeMsgStart = session.messages.length;
1127
+ planPhaseStartedAt = new Date().toISOString(); // D-B evidence-window cutoff
910
1128
  prompt = planResume.llmPrompt;
1129
+ // C1 mid-phase-continue — record the phase now in flight. Cleared
1130
+ // post-turn the moment a revision is persisted; if this turn is
1131
+ // interrupted first, the marker lets a later bare "continue" re-attach
1132
+ // the audit + next-phase chain (plan-continue.ts).
1133
+ {
1134
+ const { buildPlanInFlight } = await import('./commands/plan-continue.js');
1135
+ session.plan_in_flight = buildPlanInFlight(planResume, planPhaseStartedAt);
1136
+ }
911
1137
  // A4 (2026-06-11) — flag-gated per-phase model tiering: design/test/
912
1138
  // document-shaped phases (structural signal: the phase's delegateTo
913
1139
  // skill) run on Sonnet; everything else — and the flag-off default —
@@ -919,7 +1145,9 @@ export async function runRepl(opts) {
919
1145
  enabled: isPlanModelTieringEnabled(cfg),
920
1146
  phaseId: planResume.nextPhaseId,
921
1147
  delegateTo: planResume.nextDelegateTo,
922
- defaultModel: cfg.default_model,
1148
+ // model-governance step 2d — the session default now resolves
1149
+ // env > local > server > built-in (== cfg.default_model when nothing set).
1150
+ defaultModel: resolveModelRole('session_default', cfg),
923
1151
  });
924
1152
  if (choice.model) {
925
1153
  planModelOverride = choice.model;
@@ -927,6 +1155,34 @@ export async function runRepl(opts) {
927
1155
  }
928
1156
  }
929
1157
  }
1158
+ // C1 mid-phase-continue — a router-dispatched abap-plan turn (bare
1159
+ // "continue" after an interrupted phase) with an in-flight marker:
1160
+ // re-attach this turn to the interrupted phase so the post-phase chain
1161
+ // (audit + [y/N] offer) runs instead of the generic save hook. The
1162
+ // --resume branch above and this one are mutually exclusive by the
1163
+ // --resume regex (report §4, no-double-fire guarantee). The user's
1164
+ // prompt is NOT replaced and the context is NOT reset — "continue"
1165
+ // needs the accumulated transcript.
1166
+ let planContinue = null;
1167
+ if (skill === 'abap-plan' && planResume === null && session.plan_in_flight) {
1168
+ const marker = session.plan_in_flight;
1169
+ const { preparePlanContinuation } = await import('./commands/plan-continue.js');
1170
+ const r = preparePlanContinuation({
1171
+ marker,
1172
+ sessionId: session.id,
1173
+ log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
1174
+ });
1175
+ if (r.prepared) {
1176
+ planContinue = { prepared: r.prepared, marker };
1177
+ planPhaseStartedAt = marker.attemptStartedAt;
1178
+ planResumeMsgStart = session.messages.length;
1179
+ chunkEmitter.emit('chunk', chalk.dim(`↪ re-attaching to in-flight plan phase ${marker.phaseId} (${r.prepared.path})\n`));
1180
+ }
1181
+ else if (r.stale) {
1182
+ session.plan_in_flight = null; // plan/phase gone for good — marker is noise
1183
+ }
1184
+ }
1185
+ const planContinueForHook = planContinue;
930
1186
  // 2026-06-08 — manifest-on-interrupt. Set true once the mid-turn
931
1187
  // onPlanManifest hook has persisted the revision, so the post-turn
932
1188
  // finishPlanResume below doesn't write it a second time.
@@ -934,6 +1190,11 @@ export async function runRepl(opts) {
934
1190
  // A1 — outcome of the persisted revision (exact saved path + the
935
1191
  // harness-built resume command). Drives the post-turn next-phase offer.
936
1192
  let planOutcome = null;
1193
+ // Fix-wave (item 1) — the phase's subagent-dispatch count, read+reset
1194
+ // in the runTurn finally below (the chain can be skipped on error
1195
+ // paths, so the counter must be consumed HERE) and threaded to
1196
+ // runPostPhaseChain for the phase-end report line.
1197
+ let planSubagentDispatches;
937
1198
  const planResumeForHook = planResume;
938
1199
  // Phase A** wiring fix (2026-05-16) — snapshot pre-turn counters so
939
1200
  // pushPostTurnStatus can compute per-turn deltas. The classic-mode
@@ -951,17 +1212,50 @@ export async function runRepl(opts) {
951
1212
  // 2026-06-07 — persistent status row lifecycle. Started here (not in
952
1213
  // loop.ts) so the try/finally GUARANTEES the row clears even when the
953
1214
  // turn throws; loop.ts only narrates activity changes in between.
954
- const { turnStatusEmitter } = await import('./ui/turn-status-emitter.js');
1215
+ // (turnStatusEmitter is the module-level singleton import — BUG-2b.)
955
1216
  turnStatusEmitter.start(`/${skill}`);
956
1217
  try {
1218
+ // Task 6 (review M-2) — guarded chain start: announce and disable
1219
+ // auto_approve for this phase's turn (request_approval consults the
1220
+ // flag and falls through to interactive prompts). Set INSIDE the try
1221
+ // so nothing between set and runTurn can skip the finally-clear.
1222
+ // C1 — a continuation turn re-attaches to the interrupted phase, so
1223
+ // the SAME enforcement (read-only subagents, guarded gate, writes
1224
+ // declaration, audit-off honesty line) is active for it too.
1225
+ const planPhaseCtx = planResume ?? planContinue?.prepared;
1226
+ if (planPhaseCtx) {
1227
+ const { setPlanPhaseActive, setGuardedRunActive, setCurrentPhaseWrites, getAndResetPlanDeviation } = await import('./commands/plan-gate.js');
1228
+ // Task 7 — plan-phase subagent read-only enforcement: set for EVERY
1229
+ // plan-resume phase turn regardless of mode (guarded AND step);
1230
+ // agent_run consults the flag and forces its read-only tool filter.
1231
+ setPlanPhaseActive(true);
1232
+ // Task 10 — the writes declaration of the phase EXECUTING this turn
1233
+ // (fail-safe: undefined ⇒ true, the deviation backstop never fires).
1234
+ // request_approval consults it in guarded mode to deny undeclared
1235
+ // writes. Discard any stale deviation flag from a chain leg that
1236
+ // never reached its post-phase chain so it can't block THIS phase.
1237
+ setCurrentPhaseWrites(planPhaseCtx.nextPhaseWrites ?? true);
1238
+ getAndResetPlanDeviation();
1239
+ planPhaseFlagSet = true;
1240
+ if ((planPhaseCtx.effectiveMode ?? 'step') === 'guarded') {
1241
+ setGuardedRunActive(true);
1242
+ guardedRunFlagSet = true;
1243
+ chunkEmitter.emit('chunk', chalk.dim('▶ guarded mode — auto_approve disabled for this run\n'));
1244
+ }
1245
+ // Off-switch (2026-07-06, owner) — announce plainly that this run's
1246
+ // phases are NOT audited (honesty: off is a choice, never a lie).
1247
+ if ((planPhaseCtx.auditMode ?? 'on') === 'off') {
1248
+ chunkEmitter.emit('chunk', chalk.dim('plan audits: off\n'));
1249
+ }
1250
+ }
957
1251
  await runTurn({
958
1252
  provider,
959
1253
  userMessage: prompt,
960
1254
  skill: skill,
961
- ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
1255
+ ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
962
1256
  chunkEmitter,
963
1257
  signal: currentTurnAbort.signal,
964
- suppressSaveHook: planResume !== null,
1258
+ suppressSaveHook: planResume !== null || planContinue !== null,
965
1259
  // A4 — undefined on every non-plan turn (and when tiering is off).
966
1260
  modelOverride: planModelOverride,
967
1261
  // Persist the plan revision the instant its manifest is finalised
@@ -974,6 +1268,9 @@ export async function runRepl(opts) {
974
1268
  prepared: planResumeForHook,
975
1269
  messages: session.messages,
976
1270
  messagesStart: planResumeMsgStart,
1271
+ sessionId: session.id,
1272
+ attemptStartedAt: planPhaseStartedAt || undefined, // D-B item 1
1273
+ auditMode: planResumeForHook.auditMode, // off-switch — no pending injected when 'off'
977
1274
  log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
978
1275
  textOverride: assistantText,
979
1276
  });
@@ -983,7 +1280,34 @@ export async function runRepl(opts) {
983
1280
  chunkEmitter.emit('chunk', chalk.red(`\n[plan] early revision save failed: ${err.message}\n`));
984
1281
  }
985
1282
  }
986
- : undefined,
1283
+ : planContinueForHook
1284
+ ? async (assistantText) => {
1285
+ // C1 — early-persist the continuation revision the moment the
1286
+ // manifest is finalised (same crash-safety as the --resume hook).
1287
+ try {
1288
+ const { finishPlanContinuation } = await import('./commands/plan-continue.js');
1289
+ const r = await finishPlanContinuation({
1290
+ prepared: planContinueForHook.prepared,
1291
+ marker: planContinueForHook.marker,
1292
+ messages: session.messages,
1293
+ messagesStart: planResumeMsgStart,
1294
+ sessionId: session.id,
1295
+ textOverride: assistantText,
1296
+ log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
1297
+ });
1298
+ if (r.kind === 'saved') {
1299
+ planOutcome = r.outcome;
1300
+ planSavedEarly = true;
1301
+ }
1302
+ // 'foreign'/'none'/'not-persisted': leave both flags unset —
1303
+ // the post-turn fallback re-classifies the full turn text and
1304
+ // owns the foreign save / quiet-none handling.
1305
+ }
1306
+ catch (err) {
1307
+ chunkEmitter.emit('chunk', chalk.red(`\n[plan] early continuation save failed: ${err.message}\n`));
1308
+ }
1309
+ }
1310
+ : undefined,
987
1311
  });
988
1312
  }
989
1313
  catch (err) {
@@ -997,6 +1321,25 @@ export async function runRepl(opts) {
997
1321
  finally {
998
1322
  turnStatusEmitter.stop();
999
1323
  currentTurnAbort = null;
1324
+ // Task 6/7/10 — neither the guarded auto_approve override, the
1325
+ // plan-phase enforcement flag, nor the phase-writes declaration may
1326
+ // outlive the phase turn. (The deviation flag DOES survive this
1327
+ // finally on purpose — runPostPhaseChain below reads+resets it.)
1328
+ if (guardedRunFlagSet || planPhaseFlagSet) {
1329
+ const { setGuardedRunActive, setPlanPhaseActive, setCurrentPhaseWrites, getAndResetPlanPhaseSubagentDispatches } = await import('./commands/plan-gate.js');
1330
+ if (guardedRunFlagSet)
1331
+ setGuardedRunActive(false);
1332
+ if (planPhaseFlagSet) {
1333
+ setPlanPhaseActive(false);
1334
+ setCurrentPhaseWrites(null);
1335
+ // Fix-wave (item 1) — consume the subagent counter in the SAME
1336
+ // finally: the post-phase chain may never run (turn throw / no
1337
+ // manifest ⇒ null planOutcome), and an unconsumed count would
1338
+ // leak into the NEXT phase's report line. The captured value is
1339
+ // threaded to runPostPhaseChain below.
1340
+ planSubagentDispatches = getAndResetPlanPhaseSubagentDispatches();
1341
+ }
1342
+ }
1000
1343
  }
1001
1344
  // B3 — persist the plan revision from the resume turn's manifest
1002
1345
  // block (runs even after a turn error: partial assistant content is
@@ -1013,6 +1356,9 @@ export async function runRepl(opts) {
1013
1356
  prepared: planResume,
1014
1357
  messages: session.messages,
1015
1358
  messagesStart: planResumeMsgStart,
1359
+ sessionId: session.id,
1360
+ attemptStartedAt: planPhaseStartedAt || undefined, // D-B item 1
1361
+ auditMode: planResume.auditMode, // off-switch — no pending injected when 'off'
1016
1362
  log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
1017
1363
  });
1018
1364
  }
@@ -1020,27 +1366,121 @@ export async function runRepl(opts) {
1020
1366
  chunkEmitter.emit('chunk', chalk.red(`\n[plan] revision save failed: ${err.message}\n`));
1021
1367
  }
1022
1368
  }
1023
- // A1 (defect D23) — harness-owned continuation. The model's turn ended
1024
- // at the manifest; if the persisted revision has a next eligible phase,
1025
- // the HARNESS asks for consent (Rule 8) and queues the exact
1026
- // `/abap-plan --resume @<saved-file>` it just built — the model never
1027
- // authors this command. The pendingDispatch consumer below re-enters
1028
- // it, and the resume turn itself resets session messages.
1029
- if (planResume && planOutcome?.nextPhaseId) {
1369
+ // A1 (defect D23) + Task 6 — harness-owned continuation, now the full
1370
+ // post-phase chain: audit the just-persisted phase against tool-call
1371
+ // evidence, persist the verdict, handle failures (re-run/waive/block),
1372
+ // gate the transition (step/guarded), then offer or auto-queue the
1373
+ // exact `/abap-plan --resume @<saved-file>` the harness built — the
1374
+ // model never authors this command. The pendingDispatch consumer below
1375
+ // re-enters it, and the resume turn itself resets session messages.
1376
+ if (planResume && planOutcome) {
1030
1377
  try {
1031
- const { offerNextPhaseAutoRun } = await import('./commands/plan-resume.js');
1378
+ const { runPostPhaseChain } = await import('./commands/plan-chain.js');
1032
1379
  const { inkAwarePrompt } = await import('./agent/loop.js');
1033
- await offerNextPhaseAutoRun({
1380
+ await runPostPhaseChain({
1381
+ savedPath: planOutcome.savedPath,
1382
+ executedPhaseId: planResume.nextPhaseId,
1383
+ sessionId: session.id,
1384
+ cwd: process.cwd(),
1385
+ mode: planResume.effectiveMode ?? 'step',
1386
+ modeFlag: planResume.mode,
1387
+ // Off-switch — skips the normal post-phase audit when 'off'; the
1388
+ // per-run flag survives onto chain-built resume commands (I2).
1389
+ auditMode: planResume.auditMode,
1390
+ auditFlag: planResume.auditFlag,
1391
+ // D-B — this attempt's turn-start, so the audit windows the evidence.
1392
+ attemptStartedAt: planPhaseStartedAt || undefined,
1393
+ // Fix-wave (item 1) — counter already consumed in the runTurn
1394
+ // finally; thread the captured value for the report line.
1395
+ subagentDispatches: planSubagentDispatches,
1396
+ prompt: inkAwarePrompt,
1397
+ queueDispatch: (cmd) => pendingDispatchAccessor.set(cmd),
1398
+ log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
1399
+ });
1400
+ }
1401
+ catch (err) {
1402
+ chunkEmitter.emit('chunk', chalk.red(`\n[plan] post-phase chain failed: ${err.message}\n`));
1403
+ }
1404
+ }
1405
+ // C1 — continuation post-turn: fallback-persist (hook may not have
1406
+ // fired), then re-attach the chain. Mirrors the --resume blocks above.
1407
+ if (planContinue && !planSavedEarly) {
1408
+ try {
1409
+ const { finishPlanContinuation } = await import('./commands/plan-continue.js');
1410
+ const r = await finishPlanContinuation({
1411
+ prepared: planContinue.prepared,
1412
+ marker: planContinue.marker,
1413
+ messages: session.messages,
1414
+ messagesStart: planResumeMsgStart,
1415
+ sessionId: session.id,
1416
+ log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
1417
+ });
1418
+ if (r.kind === 'saved') {
1419
+ planOutcome = r.outcome;
1420
+ }
1421
+ else if (r.kind === 'foreign') {
1422
+ // The turn produced a DIFFERENT plan's manifest and the save hook
1423
+ // was suppressed — run the generic save now so it isn't lost. The
1424
+ // in-flight marker stays: that phase is still unresolved.
1425
+ const { maybeOfferSave } = await import('./agent/loop.js');
1426
+ const { collectTurnAssistantText } = await import('./agent/turn-assistant-text.js');
1427
+ await maybeOfferSave({
1428
+ skill: 'abap-plan',
1429
+ assistantText: collectTurnAssistantText(session.messages, planResumeMsgStart),
1430
+ userMessage: prompt,
1431
+ tokensUsed: 0,
1432
+ model: session.model,
1433
+ emit: (line) => chunkEmitter.emit('chunk', line + '\n'),
1434
+ });
1435
+ }
1436
+ else if (r.kind === 'none') {
1437
+ chunkEmitter.emit('chunk', chalk.dim(`phase ${planContinue.marker.phaseId} still in flight — no completion manifest this turn\n`));
1438
+ }
1439
+ }
1440
+ catch (err) {
1441
+ chunkEmitter.emit('chunk', chalk.red(`\n[plan] continuation save failed: ${err.message}\n`));
1442
+ }
1443
+ }
1444
+ if (planContinue && planOutcome) {
1445
+ try {
1446
+ const { runContinuationChain } = await import('./commands/plan-continue.js');
1447
+ const { inkAwarePrompt } = await import('./agent/loop.js');
1448
+ await runContinuationChain({
1449
+ prepared: planContinue.prepared,
1450
+ marker: planContinue.marker,
1034
1451
  outcome: planOutcome,
1452
+ sessionId: session.id,
1453
+ cwd: process.cwd(),
1454
+ subagentDispatches: planSubagentDispatches,
1035
1455
  prompt: inkAwarePrompt,
1036
1456
  queueDispatch: (cmd) => pendingDispatchAccessor.set(cmd),
1037
1457
  log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
1038
1458
  });
1039
1459
  }
1040
1460
  catch (err) {
1041
- chunkEmitter.emit('chunk', chalk.red(`\n[plan] next-phase offer failed: ${err.message}\n`));
1461
+ chunkEmitter.emit('chunk', chalk.red(`\n[plan] continuation chain failed: ${err.message}\n`));
1042
1462
  }
1043
1463
  }
1464
+ // C1 — the phase reached a resolved terminal (revision persisted, chain
1465
+ // ran or is queued): the marker's job is done. Applies to --resume AND
1466
+ // continuation dispatches; a null outcome keeps the marker (still in flight).
1467
+ if (planOutcome)
1468
+ session.plan_in_flight = null;
1469
+ // Task 9 — the chain leg for this dispatch truly ends here (verdict
1470
+ // writes + failure prompts above still ran under the lease; every
1471
+ // block between runTurn's finally and this point is exception-wrapped,
1472
+ // so this line is reached on success AND on turn error). Release it —
1473
+ // UNLESS a next-phase dispatch is queued (review fix): the auto-chain
1474
+ // must hold the family lock across the gap to the re-entry (which
1475
+ // re-acquires same-session idempotently), or a second session could
1476
+ // grab it mid-chain. Chosen over releasing in the runTurn finally,
1477
+ // which would leave finishPlanResume/runPostPhaseChain envelope writes
1478
+ // unleased. A hard crash skips this — the leftover lease goes stale by
1479
+ // pid-liveness, so no manual cleanup is ever needed.
1480
+ if (planResume || planContinue) {
1481
+ const { releaseRunLeaseUnlessChained } = await import('./projects/run-lease.js');
1482
+ releaseRunLeaseUnlessChained((planResume ?? planContinue.prepared).path, pendingDispatchAccessor.get());
1483
+ }
1044
1484
  // A4 — turn-cost line must price at the model that actually ran the
1045
1485
  // turn (planModelOverride on tiered plan phases), not session.model.
1046
1486
  pushPostTurnStatus({
@@ -1073,6 +1513,40 @@ export async function runRepl(opts) {
1073
1513
  setImmediate(() => { void handleSubmit(dispatched); });
1074
1514
  }
1075
1515
  };
1516
+ // BUG-2b (2026-07-05 live smoke) — single-flight dispatch guard.
1517
+ //
1518
+ // dispatchSubmit does a large chunk of async pre-turn work (classifier
1519
+ // round-trip, plan-resume prepare, the guarded-mode inquirer gate) BEFORE
1520
+ // it calls turnStatusEmitter.start(). During that window .active was
1521
+ // false, so the Footer's submit guard (App.beginSubmit, which now reads
1522
+ // isBusy()) let a second Enter — a history recall, a live palette pick —
1523
+ // dispatch a SECOND concurrent turn: the twin "Routed to /abap-plan",
1524
+ // twin tracker board, and interleaved tool ⏺/⎿ lines the user saw.
1525
+ //
1526
+ // reserve() marks the dispatch busy synchronously, BEFORE any await, so
1527
+ // isBusy() covers the whole prep window; release() in the finally ends it
1528
+ // (after the post-phase chain + auto-dispatch scheduling). The isBusy()
1529
+ // check here is a backstop for any direct caller that bypasses
1530
+ // beginSubmit's guard — it queues the line as steering, exactly like
1531
+ // beginSubmit does mid-turn, instead of starting a twin.
1532
+ const handleSubmit = async (line) => {
1533
+ if (turnStatusEmitter.isBusy()) {
1534
+ const t = line.trim();
1535
+ if (t.length > 0) {
1536
+ enqueueSteering(t);
1537
+ const echo = t.split('\n')[0].slice(0, 60);
1538
+ chunkEmitter.emit('chunk', chalk.cyan(`↳ queued ▸ ${echo}${t.length > echo.length ? '…' : ''}\n`));
1539
+ }
1540
+ return;
1541
+ }
1542
+ turnStatusEmitter.reserve();
1543
+ try {
1544
+ await dispatchSubmit(line);
1545
+ }
1546
+ finally {
1547
+ turnStatusEmitter.release();
1548
+ }
1549
+ };
1076
1550
  // Phase D5 (2026-05-17) — Ctrl+C handler routed through Ink.
1077
1551
  // First press during turn: abort the turn (preserves partial work).
1078
1552
  // Second press during the SAME turn: hard-exit (escape hatch in case
@@ -1101,15 +1575,72 @@ export async function runRepl(opts) {
1101
1575
  resolveDone();
1102
1576
  }
1103
1577
  };
1578
+ // Wave1 live-fix (Bug 2) — bracketed paste for the Ink path. Windows
1579
+ // Terminal delivers pasted newlines as `\r`, and a `\r`-only chunk
1580
+ // parses as key.return (ink parse-keypress.js:145-148) → every pasted
1581
+ // line submitted immediately (the live garbled-dispatch bug). With
1582
+ // DEC 2004 on, pastes arrive marker-wrapped; the guard strips the
1583
+ // markers BEFORE Ink's parser sees them (use-input.js:73-75 would
1584
+ // mangle `\x1b[200~` into literal "[200~" text) and re-emits the
1585
+ // whole paste as ONE newline-preserving chunk, which text-input's
1586
+ // Task-7 paste branch buffers without submitting. Cleanup mirrors
1587
+ // classic mode: explicit disable after unmount + a process-exit hook
1588
+ // for the force-exit(130) and crash paths.
1589
+ enableBracketedPaste();
1590
+ process.on('exit', () => disableBracketedPaste());
1591
+ const inkStdin = new InkStdinPasteGuard();
1592
+ process.stdin.pipe(inkStdin);
1593
+ // Task 3 (ux-wave3) — rewind wiring, shared with the classic path through
1594
+ // executeRestore (which holds the single-flight reserve for the FULL
1595
+ // restore — review I1a: a restore counts as busy, submits become steering —
1596
+ // and catches any residual throw, review I2). The Ink approval bridge
1597
+ // produces the exact ApprovalRequest shape render.ts emits onto
1598
+ // approvalEmitter and awaits the resolve — <App> already consumes that
1599
+ // emitter to mount <ApprovalModal>.
1600
+ const inkRequestApproval = (change, risk, transport) => new Promise((resolve) => {
1601
+ approvalEmitter.emit('request', { change, effectiveRisk: risk, transport, resolve });
1602
+ });
1603
+ const runInkRestore = (c) => executeRestore(c, {
1604
+ adt, cfg, alias: selectedAlias,
1605
+ toolCtx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
1606
+ suppliedTransport: currentTransportAccessor.get() ?? undefined,
1607
+ requestApproval: inkRequestApproval,
1608
+ // deps.log streams each pipeline step dim to scrollback live; the final
1609
+ // ✓ / ↩ cancelled / ✗ summary comes from executeRestore's print.
1610
+ log: (line) => chunkEmitter.emit('chunk', chalk.dim(line) + '\n'),
1611
+ }, (line) => chunkEmitter.emit('chunk', line + '\n'));
1104
1612
  const { unmount } = render(React.createElement(App, {
1105
1613
  chunkEmitter,
1106
1614
  onSubmit: (line) => { void handleSubmit(line); },
1107
1615
  onSlashTrigger: () => { },
1108
1616
  onHistoryTrigger: () => { },
1109
1617
  getToolCalls: () => session.toolCalls,
1110
- writeMode: cfg.write_mode,
1618
+ // Task 5 (ux-wave2) — todo-panel seed accessor (getToolCalls
1619
+ // pattern): App reads persisted todos on mount and on every
1620
+ // Ctrl+T open, so a resumed session shows its list before any
1621
+ // todo_set fires in this process.
1622
+ getTodos: () => session.todos ?? [],
1623
+ // Task 3 (ux-wave3) — rewind panel accessors. Candidates join
1624
+ // session.toolCalls with the on-disk snapshot list (async fs read),
1625
+ // re-evaluated each open; onRewindRestore fires the gated executor.
1626
+ getRewindCandidates: async () => collectRewindCandidates(session.toolCalls, await snapshots.list()),
1627
+ onRewindRestore: (c) => { void runInkRestore(c); },
1628
+ // Task 4 (ux-wave1) — no writeMode prop: App's mode-cycle handler
1629
+ // reads its base from the SapStateStore, seeded with the startup-
1630
+ // EFFECTIVE mode above (before this render — same function, no
1631
+ // other Ink mount path exists). Clamp idempotency makes the
1632
+ // effective mode a byte-compatible base for runModeCommand.
1633
+ // Task 3 (ux-wave1) — Shift+Tab / Alt+M mode cycling needs the alias
1634
+ // role (ceiling) + name (refusal line) inside App's useInput handler.
1635
+ sapRole: cfg.sap[selectedAlias]?.role,
1636
+ sapAlias: selectedAlias,
1111
1637
  onInterrupt,
1112
1638
  }), {
1639
+ // Bug 2 (wave1 live-fix) — Ink reads from the paste guard, not
1640
+ // process.stdin directly. The guard satisfies Ink's full stdin
1641
+ // surface (isTTY / setEncoding / ref / unref / setRawMode /
1642
+ // 'readable' + read()) — pinned by ink-stdin-guard.test.ts.
1643
+ stdin: inkStdin,
1113
1644
  // exitOnCtrlC: false — Ink would otherwise call process.exit()
1114
1645
  // on Ctrl+C before onInterrupt can decide whether to abort the
1115
1646
  // in-flight turn or exit cleanly.
@@ -1126,6 +1657,11 @@ export async function runRepl(opts) {
1126
1657
  });
1127
1658
  await donePromise;
1128
1659
  unmount();
1660
+ // Bug 2 (wave1 live-fix) — undo the paste plumbing so the user's
1661
+ // shell doesn't keep receiving marker-wrapped pastes after exit.
1662
+ process.stdin.unpipe(inkStdin);
1663
+ inkStdin.destroy();
1664
+ disableBracketedPaste();
1129
1665
  // Restore the terminal cursor we hid on Ink mount.
1130
1666
  process.stdout.write('\x1b[?25h');
1131
1667
  // Print session summary + resume hint after Ink unmounts so the
@@ -1502,8 +2038,9 @@ export async function runRepl(opts) {
1502
2038
  const trimmed = line.trim();
1503
2039
  if (!trimmed)
1504
2040
  continue;
1505
- // Persist non-empty input to history file.
1506
- appendHistory(trimmed);
2041
+ // Persist non-empty input to history file (Task 8: consecutive
2042
+ // duplicates are skipped inside the shared writer).
2043
+ appendHistoryEntry(trimmed);
1507
2044
  // 2026-05-01 fix: rl.question() does NOT auto-populate readline's internal
1508
2045
  // history (only the 'line' event does). Without this manual push, arrow-up
1509
2046
  // cycles only through the file-loaded seed from session start — commands
@@ -1563,16 +2100,80 @@ export async function runRepl(opts) {
1563
2100
  await runCostCommand(session.id);
1564
2101
  continue;
1565
2102
  }
2103
+ // Task 4 (ux-wave3) — classic-mode /export audit [filename]. Mirrors the
2104
+ // Ink handler: build the signable audit record, write it to cwd, print path.
2105
+ if (trimmed === '/export' || trimmed.startsWith('/export ')) {
2106
+ const { parseExportArgs, runExportAuditCommand } = await import('./commands/export-audit.js');
2107
+ const { sub, filename } = parseExportArgs(trimmed.slice('/export'.length));
2108
+ if (sub !== 'audit') {
2109
+ console.log(chalk.dim('Usage: /export audit [filename]'));
2110
+ continue;
2111
+ }
2112
+ await runExportAuditCommand({
2113
+ session,
2114
+ transport: getCurrentTransport(),
2115
+ filename,
2116
+ emit: (l) => console.log(l),
2117
+ });
2118
+ continue;
2119
+ }
2120
+ // Task 5 (ux-wave3) — classic-mode /context. Mirrors the Ink handler:
2121
+ // build + print the composition grid (same lines, minus color).
2122
+ if (trimmed === '/context' || trimmed.startsWith('/context ')) {
2123
+ // M4 — one usage line instead of the classifier fallthrough.
2124
+ if (trimmed !== '/context') {
2125
+ console.log(chalk.dim('/context takes no arguments — it shows what fills the model window right now'));
2126
+ continue;
2127
+ }
2128
+ const { runContextCommand } = await import('./ui/context-grid.js');
2129
+ await runContextCommand({
2130
+ session,
2131
+ color: false,
2132
+ emit: (l) => console.log(l),
2133
+ });
2134
+ continue;
2135
+ }
2136
+ // Task 3 (ux-wave1) — classic-mode /mode. Mirrors the Ink handler:
2137
+ // bare cycles the SESSION write mode, advisory|gated|auto jumps
2138
+ // (clamped to the alias role's ceiling), config never touched.
2139
+ if (trimmed === '/mode' || trimmed.startsWith('/mode ')) {
2140
+ const modeArg = trimmed === '/mode' ? undefined : (trimmed.slice('/mode '.length).trim() || undefined);
2141
+ const { line: modeLine, mode } = runModeCommand(modeArg, cfg.write_mode, cfg.sap[selectedAlias]?.role, selectedAlias);
2142
+ console.log(chalk.dim(modeLine));
2143
+ sapStateStore.update({ writeMode: mode });
2144
+ continue;
2145
+ }
2146
+ // Task 6 (ux-wave2) — classic-mode /recap. Mirrors the Ink handler:
2147
+ // reprint the orientation recap (session · todos · plan) for the
2148
+ // CURRENT session; empty recap → one dim placeholder line.
2149
+ if (trimmed === '/recap') {
2150
+ const { buildRecap } = await import('./session/recap.js');
2151
+ const { resolveWorkspacePath } = await import('./projects/workspace.js');
2152
+ const recapWs = await resolveWorkspacePath().catch(() => undefined);
2153
+ const recapLines = buildRecap(session, process.cwd(), { workspaceDir: recapWs });
2154
+ console.log('');
2155
+ if (recapLines.length === 0) {
2156
+ console.log(chalk.dim('nothing to recap yet'));
2157
+ }
2158
+ else {
2159
+ for (const recapLine of recapLines)
2160
+ console.log(chalk.dim(recapLine));
2161
+ }
2162
+ continue;
2163
+ }
1566
2164
  // Phase 2 conversation hygiene (2026-05-16) — classic-mode /compact.
1567
2165
  // Summarises older turns via Haiku 4.5 so subsequent turns ingest ~5k
1568
2166
  // tokens of context instead of 500k+. Backs up session pre-mutation.
1569
2167
  if (trimmed === '/compact') {
1570
- const { runCompact, formatCompactResult, buildSummarisationPrompt, serialiseForSummariser, COMPACTION_MODEL } = await import('./commands/compact.js');
2168
+ const { runCompact, formatCompactResult, buildSummarisationPrompt, serialiseForSummariser } = await import('./commands/compact.js');
1571
2169
  const { summariseViaProvider } = await import('./agent/summarise-via-provider.js');
1572
2170
  const { loadConfig } = await import('./config/loader.js');
1573
2171
  const compactCfg = (await loadConfig()).compact;
1574
2172
  const summarise = async (toSummarise) => {
1575
- return await summariseViaProvider(provider, buildSummarisationPrompt(), serialiseForSummariser(toSummarise), { model: COMPACTION_MODEL, maxTokens: 4000, headers: { 'X-CSForge-Skill': 'compact' } });
2173
+ return await summariseViaProvider(provider, buildSummarisationPrompt(), serialiseForSummariser(toSummarise),
2174
+ // model-governance step 2d — compact model resolves env > local >
2175
+ // server > built-in (== COMPACTION_MODEL when nothing set).
2176
+ { model: resolveModelRole('compact', cfg), maxTokens: 4000, headers: { 'X-CSForge-Skill': 'compact' } });
1576
2177
  };
1577
2178
  try {
1578
2179
  console.log(chalk.dim('\nCompacting older turns into a summary...'));
@@ -1668,7 +2269,7 @@ export async function runRepl(opts) {
1668
2269
  provider,
1669
2270
  userMessage: rr.prompt,
1670
2271
  skill: rr.skill,
1671
- ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter: classicOutputEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
2272
+ ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter: classicOutputEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
1672
2273
  });
1673
2274
  continue;
1674
2275
  }
@@ -1686,6 +2287,26 @@ export async function runRepl(opts) {
1686
2287
  console.log(chalk.cyan(msg));
1687
2288
  continue;
1688
2289
  }
2290
+ // Task 3 (ux-wave3) — classic-mode /rewind: numbered inquirer list + confirm
2291
+ // + gated restore. Reuses the identical candidate collector and executor as
2292
+ // the Ink panel (executeRestore); only the surface differs.
2293
+ if (trimmed === '/rewind' || trimmed.startsWith('/rewind ')) {
2294
+ // M5 — one usage line instead of the classifier fallthrough.
2295
+ if (trimmed !== '/rewind') {
2296
+ console.log(chalk.dim('/rewind takes no arguments — it opens the session rewind list'));
2297
+ continue;
2298
+ }
2299
+ await runClassicRewind({
2300
+ toolCalls: session.toolCalls,
2301
+ adt,
2302
+ cfg,
2303
+ alias: selectedAlias,
2304
+ toolCtx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter: classicOutputEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
2305
+ suppliedTransport: currentTransportAccessor.get() ?? undefined,
2306
+ print: (line) => console.log(line),
2307
+ });
2308
+ continue;
2309
+ }
1689
2310
  // /safety-mode on|off — structured Forge Rule confirmation cards.
1690
2311
  // UX-5 #1 (2026-06-12): classic-path mirror of the Ink dispatch above.
1691
2312
  {
@@ -1958,14 +2579,28 @@ export async function runRepl(opts) {
1958
2579
  // envelope revision after the turn.
1959
2580
  let planResume = null;
1960
2581
  let planResumeMsgStart = 0;
2582
+ // D-B — see the Ink-path mirror above: this attempt's turn-start cutoff.
2583
+ let planPhaseStartedAt = '';
1961
2584
  // A4 — set only when plan model tiering picks Sonnet for this phase.
1962
2585
  let planModelOverride;
2586
+ // Task 6 — guarded auto_approve override lifetime (see Ink path).
2587
+ let guardedRunFlagSet = false;
2588
+ // Task 7 — plan-phase subagent read-only enforcement lifetime (see Ink path).
2589
+ let planPhaseFlagSet = false;
1963
2590
  if (skill === 'abap-plan' && /(^|\s)--resume(\s|$)/.test(prompt)) {
1964
2591
  try {
1965
2592
  const { preparePlanResume } = await import('./commands/plan-resume.js');
2593
+ const { inkAwarePrompt } = await import('./agent/loop.js');
1966
2594
  planResume = await preparePlanResume({
1967
2595
  body: prompt,
1968
2596
  cwd: process.cwd(),
2597
+ configMode: cfg.plan_mode,
2598
+ // Audit off-switch (2026-07-06, owner) — config default; per-run
2599
+ // --audit/--no-audit flags in the body override it (parsed inside).
2600
+ configAudit: cfg.plan_audit,
2601
+ // Task 9 — run-lease identity + takeover prompt (classic mirror).
2602
+ sessionId: session.id,
2603
+ prompt: inkAwarePrompt,
1969
2604
  log: (...lines) => lines.forEach((l) => console.log(l)),
1970
2605
  });
1971
2606
  }
@@ -1977,6 +2612,44 @@ export async function runRepl(opts) {
1977
2612
  session.last_object = extractedObject ?? previousObject ?? null;
1978
2613
  continue;
1979
2614
  }
2615
+ // Task 6 — re-audit-on-resume (classic mirror of the Ink branch; see
2616
+ // there for the design note). No model turn: the chain re-judges the
2617
+ // unresolved phase and queues any follow-up through pendingDispatch,
2618
+ // which the loop-top consumer picks up on `continue`.
2619
+ if (planResume.reauditPhaseId) {
2620
+ try {
2621
+ const { runReauditPass } = await import('./commands/plan-chain.js');
2622
+ const { inkAwarePrompt } = await import('./agent/loop.js');
2623
+ await runReauditPass({
2624
+ savedPath: planResume.path,
2625
+ phaseIds: planResume.reauditPhaseIds ?? [planResume.reauditPhaseId],
2626
+ sessionId: session.id,
2627
+ cwd: process.cwd(),
2628
+ mode: planResume.effectiveMode ?? 'step',
2629
+ modeFlag: planResume.mode,
2630
+ // Off-switch (I3/I2) — classic mirror; see the Ink re-audit site.
2631
+ auditMode: planResume.auditMode,
2632
+ auditFlag: planResume.auditFlag,
2633
+ prompt: inkAwarePrompt,
2634
+ queueDispatch: (cmd) => pendingDispatchAccessor.set(cmd),
2635
+ log: (...lines) => lines.forEach((l) => console.log(l)),
2636
+ });
2637
+ }
2638
+ catch (err) {
2639
+ console.log(chalk.red(`\n[plan] re-audit failed: ${err.message}\n`));
2640
+ }
2641
+ // Task 9 (review fix) — release ONLY when the re-audit leg queued no
2642
+ // follow-up (classic mirror; the loop-top pre-prompt consumer picks
2643
+ // up a queued dispatch on `continue`, so the slot still holds it
2644
+ // here). See the Ink branch for the hold-across-chain design note.
2645
+ {
2646
+ const { releaseRunLeaseUnlessChained } = await import('./projects/run-lease.js');
2647
+ releaseRunLeaseUnlessChained(planResume.path, pendingDispatchAccessor.get());
2648
+ }
2649
+ session.last_skill = previousSkill ?? null;
2650
+ session.last_object = extractedObject ?? previousObject ?? null;
2651
+ continue;
2652
+ }
1980
2653
  // A1 (defect D23) — see the Ink path: every plan-resume turn starts
1981
2654
  // from reset session messages (true bounded phases).
1982
2655
  {
@@ -1986,7 +2659,14 @@ export async function runRepl(opts) {
1986
2659
  }
1987
2660
  }
1988
2661
  planResumeMsgStart = session.messages.length;
2662
+ planPhaseStartedAt = new Date().toISOString(); // D-B evidence-window cutoff
1989
2663
  prompt = planResume.llmPrompt;
2664
+ // C1 mid-phase-continue — record the phase now in flight (classic
2665
+ // mirror of the Ink marker-set; see there for the full design note).
2666
+ {
2667
+ const { buildPlanInFlight } = await import('./commands/plan-continue.js');
2668
+ session.plan_in_flight = buildPlanInFlight(planResume, planPhaseStartedAt);
2669
+ }
1990
2670
  // A4 — flag-gated per-phase model tiering (classic-mode mirror of the
1991
2671
  // Ink path above; see that block for the design note).
1992
2672
  {
@@ -1995,7 +2675,9 @@ export async function runRepl(opts) {
1995
2675
  enabled: isPlanModelTieringEnabled(cfg),
1996
2676
  phaseId: planResume.nextPhaseId,
1997
2677
  delegateTo: planResume.nextDelegateTo,
1998
- defaultModel: cfg.default_model,
2678
+ // model-governance step 2d — the session default now resolves
2679
+ // env > local > server > built-in (== cfg.default_model when nothing set).
2680
+ defaultModel: resolveModelRole('session_default', cfg),
1999
2681
  });
2000
2682
  if (choice.model) {
2001
2683
  planModelOverride = choice.model;
@@ -2003,11 +2685,37 @@ export async function runRepl(opts) {
2003
2685
  }
2004
2686
  }
2005
2687
  }
2688
+ // C1 mid-phase-continue — continuation detect + prepare (classic mirror
2689
+ // of the Ink branch; see there for the full design note: mutually
2690
+ // exclusive with --resume, prompt NOT replaced, context NOT reset).
2691
+ let planContinue = null;
2692
+ if (skill === 'abap-plan' && planResume === null && session.plan_in_flight) {
2693
+ const marker = session.plan_in_flight;
2694
+ const { preparePlanContinuation } = await import('./commands/plan-continue.js');
2695
+ const r = preparePlanContinuation({
2696
+ marker,
2697
+ sessionId: session.id,
2698
+ log: (...lines) => lines.forEach((l) => console.log(l)),
2699
+ });
2700
+ if (r.prepared) {
2701
+ planContinue = { prepared: r.prepared, marker };
2702
+ planPhaseStartedAt = marker.attemptStartedAt;
2703
+ planResumeMsgStart = session.messages.length;
2704
+ console.log(chalk.dim(`↪ re-attaching to in-flight plan phase ${marker.phaseId} (${r.prepared.path})`));
2705
+ }
2706
+ else if (r.stale) {
2707
+ session.plan_in_flight = null; // plan/phase gone for good — marker is noise
2708
+ }
2709
+ }
2710
+ const planContinueForHook = planContinue;
2006
2711
  // 2026-06-08 — manifest-on-interrupt (classic path). See the Ink path for
2007
2712
  // the full rationale; persist the revision mid-turn so a Ctrl+C can't lose it.
2008
2713
  let planSavedEarly = false;
2009
2714
  // A1 — persisted-revision outcome; drives the post-turn next-phase offer.
2010
2715
  let planOutcome = null;
2716
+ // Fix-wave (item 1) — classic mirror of the Ink path: subagent counter
2717
+ // consumed in the runTurn finally, threaded to runPostPhaseChain.
2718
+ let planSubagentDispatches;
2011
2719
  const planResumeForHook = planResume;
2012
2720
  const turnStart = Date.now();
2013
2721
  // H1 — `session.usage` is session-LIFETIME (initialised once in
@@ -2057,13 +2765,40 @@ export async function runRepl(opts) {
2057
2765
  };
2058
2766
  rl.on('SIGINT', onSigint);
2059
2767
  try {
2768
+ // Task 6 (review M-2) — guarded chain start (classic mirror): set
2769
+ // INSIDE the try so nothing between set and runTurn can skip the
2770
+ // finally-clear.
2771
+ // C1 — a continuation turn re-attaches to the interrupted phase, so the
2772
+ // SAME enforcement is active for it too (classic mirror; see Ink note).
2773
+ const planPhaseCtx = planResume ?? planContinue?.prepared;
2774
+ if (planPhaseCtx) {
2775
+ const { setPlanPhaseActive, setGuardedRunActive, setCurrentPhaseWrites, getAndResetPlanDeviation } = await import('./commands/plan-gate.js');
2776
+ // Task 7 — plan-phase subagent read-only enforcement (classic mirror):
2777
+ // set for EVERY plan-resume phase turn regardless of mode.
2778
+ setPlanPhaseActive(true);
2779
+ // Task 10 — executing phase's writes declaration for the deviation
2780
+ // backstop + stale-flag discard (classic mirror; see Ink path note).
2781
+ setCurrentPhaseWrites(planPhaseCtx.nextPhaseWrites ?? true);
2782
+ getAndResetPlanDeviation();
2783
+ planPhaseFlagSet = true;
2784
+ if ((planPhaseCtx.effectiveMode ?? 'step') === 'guarded') {
2785
+ setGuardedRunActive(true);
2786
+ guardedRunFlagSet = true;
2787
+ console.log(chalk.dim('▶ guarded mode — auto_approve disabled for this run'));
2788
+ }
2789
+ // Off-switch (2026-07-06, owner) — announce plainly that this run's
2790
+ // phases are NOT audited (honesty: off is a choice, never a lie).
2791
+ if ((planPhaseCtx.auditMode ?? 'on') === 'off') {
2792
+ console.log(chalk.dim('plan audits: off'));
2793
+ }
2794
+ }
2060
2795
  await runTurn({
2061
2796
  provider,
2062
2797
  userMessage: prompt,
2063
2798
  skill: skill,
2064
- ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter: classicOutputEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
2799
+ ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter: classicOutputEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
2065
2800
  signal: turnAbort.signal,
2066
- suppressSaveHook: planResume !== null,
2801
+ suppressSaveHook: planResume !== null || planContinue !== null,
2067
2802
  // A4 — undefined on every non-plan turn (and when tiering is off).
2068
2803
  modelOverride: planModelOverride,
2069
2804
  onPlanManifest: planResumeForHook
@@ -2074,6 +2809,9 @@ export async function runRepl(opts) {
2074
2809
  prepared: planResumeForHook,
2075
2810
  messages: session.messages,
2076
2811
  messagesStart: planResumeMsgStart,
2812
+ sessionId: session.id,
2813
+ attemptStartedAt: planPhaseStartedAt || undefined, // D-B item 1
2814
+ auditMode: planResumeForHook.auditMode, // off-switch — no pending injected when 'off'
2077
2815
  log: (...lines) => lines.forEach((l) => console.log(l)),
2078
2816
  textOverride: assistantText,
2079
2817
  });
@@ -2083,7 +2821,34 @@ export async function runRepl(opts) {
2083
2821
  console.log(chalk.red(`\n[plan] early revision save failed: ${err.message}\n`));
2084
2822
  }
2085
2823
  }
2086
- : undefined,
2824
+ : planContinueForHook
2825
+ ? async (assistantText) => {
2826
+ // C1 — early-persist the continuation revision the moment the
2827
+ // manifest is finalised (classic mirror of the Ink hook).
2828
+ try {
2829
+ const { finishPlanContinuation } = await import('./commands/plan-continue.js');
2830
+ const r = await finishPlanContinuation({
2831
+ prepared: planContinueForHook.prepared,
2832
+ marker: planContinueForHook.marker,
2833
+ messages: session.messages,
2834
+ messagesStart: planResumeMsgStart,
2835
+ sessionId: session.id,
2836
+ textOverride: assistantText,
2837
+ log: (...lines) => lines.forEach((l) => console.log(l)),
2838
+ });
2839
+ if (r.kind === 'saved') {
2840
+ planOutcome = r.outcome;
2841
+ planSavedEarly = true;
2842
+ }
2843
+ // 'foreign'/'none'/'not-persisted': leave both flags unset —
2844
+ // the post-turn fallback re-classifies the full turn text and
2845
+ // owns the foreign save / quiet-none handling.
2846
+ }
2847
+ catch (err) {
2848
+ console.log(chalk.red(`\n[plan] early continuation save failed: ${err.message}\n`));
2849
+ }
2850
+ }
2851
+ : undefined,
2087
2852
  });
2088
2853
  }
2089
2854
  catch (err) {
@@ -2102,6 +2867,22 @@ export async function runRepl(opts) {
2102
2867
  finally {
2103
2868
  rl.removeListener('SIGINT', onSigint);
2104
2869
  turnInProgress = false;
2870
+ // Task 6/7/10 — neither the guarded auto_approve override, the
2871
+ // plan-phase enforcement flag, nor the phase-writes declaration may
2872
+ // outlive the phase turn. (The deviation flag survives on purpose —
2873
+ // runPostPhaseChain below reads+resets it.)
2874
+ if (guardedRunFlagSet || planPhaseFlagSet) {
2875
+ const { setGuardedRunActive, setPlanPhaseActive, setCurrentPhaseWrites, getAndResetPlanPhaseSubagentDispatches } = await import('./commands/plan-gate.js');
2876
+ if (guardedRunFlagSet)
2877
+ setGuardedRunActive(false);
2878
+ if (planPhaseFlagSet) {
2879
+ setPlanPhaseActive(false);
2880
+ setCurrentPhaseWrites(null);
2881
+ // Fix-wave (item 1) — consume the subagent counter here (classic
2882
+ // mirror; see the Ink finally for the leak rationale).
2883
+ planSubagentDispatches = getAndResetPlanPhaseSubagentDispatches();
2884
+ }
2885
+ }
2105
2886
  }
2106
2887
  // B3 — persist the plan revision from the resume turn's manifest block.
2107
2888
  // FALLBACK: skipped when the mid-turn onPlanManifest hook already saved it
@@ -2113,6 +2894,9 @@ export async function runRepl(opts) {
2113
2894
  prepared: planResume,
2114
2895
  messages: session.messages,
2115
2896
  messagesStart: planResumeMsgStart,
2897
+ sessionId: session.id,
2898
+ attemptStartedAt: planPhaseStartedAt || undefined, // D-B item 1
2899
+ auditMode: planResume.auditMode, // off-switch — no pending injected when 'off'
2116
2900
  log: (...lines) => lines.forEach((l) => console.log(l)),
2117
2901
  });
2118
2902
  }
@@ -2120,25 +2904,112 @@ export async function runRepl(opts) {
2120
2904
  console.log(chalk.red(`\n[plan] revision save failed: ${err.message}\n`));
2121
2905
  }
2122
2906
  }
2123
- // A1 (defect D23) — harness-owned continuation (classic-path mirror of
2124
- // the Ink block; see there for the design note). Consent via inquirer,
2125
- // exact command from the saved path, dispatched through the same
2126
- // pendingDispatch slot the pre-prompt consumer reads.
2127
- if (planResume && planOutcome?.nextPhaseId) {
2907
+ // A1 (defect D23) + Task 6 — harness-owned continuation, now the full
2908
+ // post-phase chain (classic-path mirror of the Ink block; see there for
2909
+ // the design note): audit → verdict persistence → failure handling →
2910
+ // guarded gate → offer/auto-queue through the same pendingDispatch slot
2911
+ // the pre-prompt consumer reads.
2912
+ if (planResume && planOutcome) {
2128
2913
  try {
2129
- const { offerNextPhaseAutoRun } = await import('./commands/plan-resume.js');
2914
+ const { runPostPhaseChain } = await import('./commands/plan-chain.js');
2130
2915
  const { inkAwarePrompt } = await import('./agent/loop.js');
2131
- await offerNextPhaseAutoRun({
2916
+ await runPostPhaseChain({
2917
+ savedPath: planOutcome.savedPath,
2918
+ executedPhaseId: planResume.nextPhaseId,
2919
+ sessionId: session.id,
2920
+ cwd: process.cwd(),
2921
+ mode: planResume.effectiveMode ?? 'step',
2922
+ modeFlag: planResume.mode,
2923
+ // Off-switch (I3/I2) — classic mirror of the Ink chain call: skip
2924
+ // the audit when 'off'; per-run flag survives onto resume commands.
2925
+ auditMode: planResume.auditMode,
2926
+ auditFlag: planResume.auditFlag,
2927
+ // D-B — this attempt's turn-start, so the audit windows the evidence.
2928
+ attemptStartedAt: planPhaseStartedAt || undefined,
2929
+ // Fix-wave (item 1) — counter consumed in the runTurn finally.
2930
+ subagentDispatches: planSubagentDispatches,
2931
+ prompt: inkAwarePrompt,
2932
+ queueDispatch: (cmd) => pendingDispatchAccessor.set(cmd),
2933
+ log: (...lines) => lines.forEach((l) => console.log(l)),
2934
+ });
2935
+ }
2936
+ catch (err) {
2937
+ console.log(chalk.red(`\n[plan] post-phase chain failed: ${err.message}\n`));
2938
+ }
2939
+ }
2940
+ // C1 — continuation post-turn: fallback-persist (hook may not have
2941
+ // fired), then re-attach the chain (classic mirror of the Ink blocks).
2942
+ if (planContinue && !planSavedEarly) {
2943
+ try {
2944
+ const { finishPlanContinuation } = await import('./commands/plan-continue.js');
2945
+ const r = await finishPlanContinuation({
2946
+ prepared: planContinue.prepared,
2947
+ marker: planContinue.marker,
2948
+ messages: session.messages,
2949
+ messagesStart: planResumeMsgStart,
2950
+ sessionId: session.id,
2951
+ log: (...lines) => lines.forEach((l) => console.log(l)),
2952
+ });
2953
+ if (r.kind === 'saved') {
2954
+ planOutcome = r.outcome;
2955
+ }
2956
+ else if (r.kind === 'foreign') {
2957
+ // The turn produced a DIFFERENT plan's manifest and the save hook
2958
+ // was suppressed — run the generic save now so it isn't lost. The
2959
+ // in-flight marker stays: that phase is still unresolved.
2960
+ const { maybeOfferSave } = await import('./agent/loop.js');
2961
+ const { collectTurnAssistantText } = await import('./agent/turn-assistant-text.js');
2962
+ await maybeOfferSave({
2963
+ skill: 'abap-plan',
2964
+ assistantText: collectTurnAssistantText(session.messages, planResumeMsgStart),
2965
+ userMessage: prompt,
2966
+ tokensUsed: 0,
2967
+ model: session.model,
2968
+ emit: (line) => console.log(line),
2969
+ });
2970
+ }
2971
+ else if (r.kind === 'none') {
2972
+ console.log(chalk.dim(`phase ${planContinue.marker.phaseId} still in flight — no completion manifest this turn`));
2973
+ }
2974
+ }
2975
+ catch (err) {
2976
+ console.log(chalk.red(`\n[plan] continuation save failed: ${err.message}\n`));
2977
+ }
2978
+ }
2979
+ if (planContinue && planOutcome) {
2980
+ try {
2981
+ const { runContinuationChain } = await import('./commands/plan-continue.js');
2982
+ const { inkAwarePrompt } = await import('./agent/loop.js');
2983
+ await runContinuationChain({
2984
+ prepared: planContinue.prepared,
2985
+ marker: planContinue.marker,
2132
2986
  outcome: planOutcome,
2987
+ sessionId: session.id,
2988
+ cwd: process.cwd(),
2989
+ subagentDispatches: planSubagentDispatches,
2133
2990
  prompt: inkAwarePrompt,
2134
2991
  queueDispatch: (cmd) => pendingDispatchAccessor.set(cmd),
2135
2992
  log: (...lines) => lines.forEach((l) => console.log(l)),
2136
2993
  });
2137
2994
  }
2138
2995
  catch (err) {
2139
- console.log(chalk.red(`\n[plan] next-phase offer failed: ${err.message}\n`));
2996
+ console.log(chalk.red(`\n[plan] continuation chain failed: ${err.message}\n`));
2140
2997
  }
2141
2998
  }
2999
+ // C1 — the phase reached a resolved terminal (revision persisted, chain
3000
+ // ran or is queued): the marker's job is done. Applies to --resume AND
3001
+ // continuation dispatches; a null outcome keeps the marker (still in flight).
3002
+ if (planOutcome)
3003
+ session.plan_in_flight = null;
3004
+ // Task 9 — chain leg truly ends here: release the run lease UNLESS a
3005
+ // next-phase dispatch is queued (review fix — the loop-top pre-prompt
3006
+ // consumer reads the slot after `continue`, so it still holds the
3007
+ // command here; the chain must keep the family lock across the gap).
3008
+ // Classic mirror of the Ink call site; see the design note there.
3009
+ if (planResume || planContinue) {
3010
+ const { releaseRunLeaseUnlessChained } = await import('./projects/run-lease.js');
3011
+ releaseRunLeaseUnlessChained((planResume ?? planContinue.prepared).path, pendingDispatchAccessor.get());
3012
+ }
2142
3013
  // Status footer after each turn.
2143
3014
  // tool_result messages also have role:'user' but content is an array —
2144
3015
  // filter them out so we count actual user prompts, not tool rounds.