@cspeach/cli 1.0.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/loop.js +22 -9
- package/dist/approvals/op-labels.js +124 -0
- package/dist/approvals/render.js +42 -36
- package/dist/cli.js +15 -0
- package/dist/commands/compact.js +28 -2
- package/dist/commands/config-set.js +189 -0
- package/dist/commands/config-show.js +20 -0
- package/dist/commands/export-audit.js +43 -0
- package/dist/commands/help.js +5 -0
- package/dist/commands/plan-audit-evidence.js +266 -0
- package/dist/commands/plan-audit.js +692 -0
- package/dist/commands/plan-chain.js +671 -0
- package/dist/commands/plan-continue.js +179 -0
- package/dist/commands/plan-gate.js +154 -0
- package/dist/commands/plan-resume.js +588 -33
- package/dist/config/loader.js +128 -4
- package/dist/config/model-defaults.js +14 -0
- package/dist/cost/pricing.js +27 -1
- package/dist/doctor/checks/system-roles.js +41 -0
- package/dist/doctor/run.js +2 -0
- package/dist/models/resolve.js +61 -0
- package/dist/models/server-config.js +155 -0
- package/dist/one-shot.js +25 -3
- package/dist/projects/extract-cca.js +3 -1
- package/dist/projects/extract-modernize.js +3 -1
- package/dist/projects/extract-plan.js +60 -6
- package/dist/projects/extract-test-coverage.js +3 -1
- package/dist/projects/extract-upgrade.js +3 -1
- package/dist/projects/handover-md.js +195 -0
- package/dist/projects/index.js +1 -1
- package/dist/projects/plan-run.js +137 -13
- package/dist/projects/plan-schema.js +73 -0
- package/dist/projects/run-lease.js +157 -0
- package/dist/projects/save-command.js +26 -15
- package/dist/renderer/status-footer.js +22 -12
- package/dist/renderer/thinking-heartbeat.js +64 -8
- package/dist/renderer/todo-block.js +51 -0
- package/dist/renderer/tool-widget.js +37 -0
- package/dist/repl/bracketed-paste.js +28 -19
- package/dist/repl/builtin-commands.js +5 -0
- package/dist/repl/current-transport.js +10 -0
- package/dist/repl/history.js +86 -0
- package/dist/repl/ink-stdin-guard.js +64 -0
- package/dist/repl/mode-ceiling.js +16 -0
- package/dist/repl/mode-cycle.js +104 -0
- package/dist/repl/post-turn-status.js +24 -4
- package/dist/repl/slash-completer.js +5 -0
- package/dist/repl.js +954 -83
- package/dist/rewind/candidates.js +194 -0
- package/dist/rewind/cli.js +137 -0
- package/dist/rewind/format.js +27 -0
- package/dist/rewind/restore.js +245 -0
- package/dist/session/audit-export.js +459 -0
- package/dist/session/context-report.js +163 -0
- package/dist/session/recap.js +160 -0
- package/dist/skill-catalog.js +9 -3
- package/dist/skills/bundled-skills.js +71 -78
- package/dist/tools/approval.js +115 -7
- package/dist/tools/ask-question.js +304 -3
- package/dist/tools/extend-model/anchored-insert.js +604 -0
- package/dist/tools/extend-model/tool.js +162 -10
- package/dist/tools/fiori/fe-extend.js +76 -0
- package/dist/tools/fiori/fe-scaffold.js +29 -3
- package/dist/tools/fiori/floorplan-map.js +19 -0
- package/dist/tools/fiori/samples/data/index.json +13602 -0
- package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
- package/dist/tools/fiori/samples/loader.js +248 -0
- package/dist/tools/fiori/samples/search.js +63 -0
- package/dist/tools/fiori/samples/types.js +2 -0
- package/dist/tools/fiori/smoke/assertions.js +74 -0
- package/dist/tools/fiori/smoke/browser.js +52 -0
- package/dist/tools/fiori/smoke/driver.js +89 -0
- package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
- package/dist/tools/fiori/smoke/run-smoke.js +149 -0
- package/dist/tools/fiori/tools.js +328 -3
- package/dist/tools/local-build.js +11 -1
- package/dist/tools/sap-read.js +79 -11
- package/dist/tools/sap-write.js +24 -4
- package/dist/tools/snapshot.js +27 -1
- package/dist/tools/subagent/agent_run.js +27 -3
- package/dist/tools/todo.js +144 -0
- package/dist/ui/app.js +372 -19
- package/dist/ui/approval-modal.js +49 -16
- package/dist/ui/ask-question-emitter.js +14 -0
- package/dist/ui/context-grid.js +108 -0
- package/dist/ui/footer.js +109 -30
- package/dist/ui/header.js +7 -0
- package/dist/ui/line-resolution.js +18 -2
- package/dist/ui/rewind-emitter.js +10 -0
- package/dist/ui/rewind-panel.js +81 -0
- package/dist/ui/sap-state-store.js +1 -0
- package/dist/ui/status-line.js +43 -0
- package/dist/ui/text-input.js +72 -8
- package/dist/ui/todo-emitter.js +25 -0
- package/dist/ui/todo-panel.js +64 -0
- package/dist/ui/turn-status-emitter.js +50 -4
- package/dist/ui/turn-status.js +18 -3
- package/dist/ui/widgets/ask-form.js +242 -0
- package/dist/ui/widgets/ask-question-modal.js +17 -7
- 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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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),
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
|
1024
|
-
//
|
|
1025
|
-
// the
|
|
1026
|
-
//
|
|
1027
|
-
//
|
|
1028
|
-
//
|
|
1029
|
-
|
|
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 {
|
|
1378
|
+
const { runPostPhaseChain } = await import('./commands/plan-chain.js');
|
|
1032
1379
|
const { inkAwarePrompt } = await import('./agent/loop.js');
|
|
1033
|
-
await
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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),
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
|
2124
|
-
// the Ink block; see there for
|
|
2125
|
-
//
|
|
2126
|
-
//
|
|
2127
|
-
|
|
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 {
|
|
2914
|
+
const { runPostPhaseChain } = await import('./commands/plan-chain.js');
|
|
2130
2915
|
const { inkAwarePrompt } = await import('./agent/loop.js');
|
|
2131
|
-
await
|
|
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]
|
|
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.
|