@cspeach/cli 0.9.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.
- package/README.md +1 -1
- package/dist/agent/intent-system-prompt.js +1 -1
- package/dist/agent/loop.js +228 -26
- package/dist/agent/providers/license-gate.js +44 -0
- package/dist/agent/skill-checkpoint.js +1 -1
- package/dist/agent/tool-dispatch.js +15 -0
- package/dist/approvals/canonical.js +91 -0
- package/dist/approvals/jwt.js +39 -2
- package/dist/approvals/op-labels.js +124 -0
- package/dist/approvals/render.js +42 -36
- package/dist/auth/org-anthropic-key.js +25 -0
- package/dist/classifier/client.js +18 -3
- package/dist/cli.js +15 -0
- package/dist/commands/compact.js +28 -2
- package/dist/commands/config-set.js +284 -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/login.js +31 -14
- 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-model-tier.js +83 -0
- package/dist/commands/plan-resume.js +728 -46
- package/dist/config/loader.js +223 -5
- package/dist/config/model-defaults.js +14 -0
- package/dist/cost/pricing.js +27 -1
- package/dist/doctor/checks/_http-probe.js +1 -0
- package/dist/doctor/checks/cert.js +14 -3
- package/dist/doctor/checks/sap.js +30 -8
- package/dist/doctor/checks/system-roles.js +41 -0
- package/dist/doctor/checks/zcspeach.js +19 -4
- 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 +76 -6
- package/dist/projects/answer-blockers.js +137 -0
- package/dist/projects/extract-cca.js +111 -17
- package/dist/projects/extract-modernize.js +4 -2
- package/dist/projects/extract-plan.js +184 -37
- package/dist/projects/extract-spec-gap.js +34 -7
- package/dist/projects/extract-test-coverage.js +4 -2
- package/dist/projects/extract-upgrade.js +116 -23
- package/dist/projects/handover-md.js +195 -0
- package/dist/projects/index.js +5 -2
- package/dist/projects/merge-cca.js +292 -0
- package/dist/projects/merge-upgrade.js +173 -0
- package/dist/projects/migration.js +103 -1
- package/dist/projects/output-paths.js +27 -0
- package/dist/projects/plan-run.js +285 -27
- package/dist/projects/plan-schema.js +136 -3
- package/dist/projects/promote-command.js +25 -2
- package/dist/projects/promote.js +128 -0
- package/dist/projects/run-lease.js +157 -0
- package/dist/projects/save-command.js +259 -21
- package/dist/projects/status.js +3 -1
- package/dist/projects/validate.js +1 -1
- package/dist/projects/workspace.js +164 -20
- package/dist/renderer/notices.js +64 -0
- package/dist/renderer/progress-chatter.js +8 -0
- 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 +55 -4
- package/dist/renderer/tty.js +43 -4
- package/dist/renderer/verify-chain.js +77 -0
- package/dist/repl/at-picker.js +60 -7
- package/dist/repl/bracketed-paste.js +28 -19
- package/dist/repl/builtin-commands.js +42 -0
- package/dist/repl/current-transport.js +10 -0
- package/dist/repl/early-line-buffer.js +68 -0
- package/dist/repl/history.js +86 -0
- package/dist/repl/ink-stdin-guard.js +64 -0
- package/dist/repl/inquirer-guard.js +70 -5
- package/dist/repl/mode-ceiling.js +16 -0
- package/dist/repl/mode-cycle.js +104 -0
- package/dist/repl/numbered-menu.js +131 -0
- package/dist/repl/post-turn-status.js +26 -6
- package/dist/repl/rule8-detector.js +17 -2
- package/dist/repl/safety-confirm.js +111 -2
- package/dist/repl/safety-mode-state.js +19 -3
- package/dist/repl/slash-completer.js +5 -0
- package/dist/repl/slash-picker.js +10 -15
- package/dist/repl.js +1232 -95
- 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/router/classifier.js +150 -6
- package/dist/sap/capability-matrix.js +20 -0
- package/dist/sap/capability-matrix.json +11236 -0
- package/dist/sap/capability.js +146 -0
- package/dist/sap/connection-manager.js +19 -1
- package/dist/sap/onboarding.js +42 -4
- package/dist/session/audit-export.js +459 -0
- package/dist/session/context-report.js +163 -0
- package/dist/session/pending.js +27 -0
- package/dist/session/recap.js +160 -0
- package/dist/skill-catalog.js +51 -40
- package/dist/skills/bundled-skills.js +272 -1
- package/dist/skills/promotion-dispatch.js +23 -0
- package/dist/tools/_command-shared.js +36 -12
- package/dist/tools/_filesystem-shared.js +139 -4
- package/dist/tools/_flag.js +25 -0
- package/dist/tools/approval.js +177 -26
- package/dist/tools/ask-question.js +400 -7
- package/dist/tools/capability/tool.js +74 -0
- package/dist/tools/dispatch-skill.js +22 -1
- package/dist/tools/extend-model/anchored-insert.js +1414 -0
- package/dist/tools/extend-model/tool.js +340 -0
- package/dist/tools/filesystem/extract-document.js +57 -0
- package/dist/tools/filesystem/file-edit.js +12 -2
- package/dist/tools/filesystem/file-read.js +2 -2
- package/dist/tools/filesystem/file-write.js +11 -2
- package/dist/tools/filesystem/glob.js +11 -0
- package/dist/tools/filesystem/grep.js +10 -0
- package/dist/tools/filesystem/read-document.js +107 -0
- package/dist/tools/fiori/apply.js +50 -0
- package/dist/tools/fiori/bin.js +3 -0
- package/dist/tools/fiori/catalog/index.js +27 -0
- package/dist/tools/fiori/catalog/value-help.js +230 -0
- package/dist/tools/fiori/catalog/viz-chart.js +177 -0
- package/dist/tools/fiori/cli.js +71 -0
- package/dist/tools/fiori/deploy-config.js +73 -0
- package/dist/tools/fiori/fe-extend.js +76 -0
- package/dist/tools/fiori/fe-scaffold.js +71 -0
- package/dist/tools/fiori/floorplan-map.js +19 -0
- package/dist/tools/fiori/i18n.js +39 -0
- package/dist/tools/fiori/manifest.js +70 -0
- package/dist/tools/fiori/render.js +77 -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/scaffold.js +39 -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 +681 -0
- package/dist/tools/fiori/types.js +1 -0
- package/dist/tools/local-build.js +86 -0
- package/dist/tools/local-files.js +31 -0
- package/dist/tools/project/_merge-shared.js +68 -0
- package/dist/tools/project/cca_merge.js +164 -0
- package/dist/tools/project/playbook_get.js +1 -1
- package/dist/tools/project/upgrade_merge_progress.js +206 -0
- package/dist/tools/sap-read.js +132 -20
- package/dist/tools/sap-write.js +550 -21
- package/dist/tools/shell/shell_exec.js +41 -6
- package/dist/tools/snapshot.js +63 -14
- package/dist/tools/subagent/agent_run.js +27 -3
- package/dist/tools/subagent/background_run.js +17 -1
- package/dist/tools/todo.js +144 -0
- package/dist/tools/transport-resolution.js +86 -0
- package/dist/tools/transport.js +224 -5
- package/dist/tools/write-mode.js +4 -0
- package/dist/ui/app.js +378 -21
- package/dist/ui/approval-modal.js +49 -16
- package/dist/ui/ask-question-emitter.js +14 -0
- package/dist/ui/body.js +13 -0
- package/dist/ui/context-grid.js +108 -0
- package/dist/ui/footer.js +120 -27
- package/dist/ui/header.js +7 -0
- package/dist/ui/line-resolution.js +35 -8
- 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/session-timeline.js +1 -0
- package/dist/ui/status-line.js +43 -0
- package/dist/ui/text-input.js +214 -0
- 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 +21 -8
- package/package.json +22 -3
- package/bench/README.md +0 -78
- package/bench/prompts/abap-document-cds.md +0 -44
- package/bench/prompts/abap-explain-bdef-handler.md +0 -57
- package/bench/prompts/abap-test-method.md +0 -42
- package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
- package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
- package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
- package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
- package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
- package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
- package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
- package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
- package/bench/results/abap-test-method/claude-sonnet-4-6.md +0 -234
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// src/commands/plan-continue.ts
|
|
2
|
+
// C1 mid-phase-continue (2026-07-14) — re-attach a router-dispatched bare
|
|
3
|
+
// "continue" turn to the post-phase chain (audit + next-phase offer) via the
|
|
4
|
+
// persisted session.plan_in_flight marker. Design + seam analysis:
|
|
5
|
+
// .superpowers/sdd/midphase-continue-report.md §4 (approved). ALL continuation
|
|
6
|
+
// logic lives here so the repl.tsx wiring stays thin and mirror-safe; the
|
|
7
|
+
// existing repl seam pins (finishPlanResume ×4, runPostPhaseChain ×2) hold
|
|
8
|
+
// because this module — not repl.tsx — calls those functions.
|
|
9
|
+
import { basename } from 'node:path';
|
|
10
|
+
import { existsSync } from 'node:fs';
|
|
11
|
+
import { leasePathFor, acquireRunLease } from '../projects/run-lease.js';
|
|
12
|
+
import { readProjectFile } from '../projects/status.js';
|
|
13
|
+
import { parsePlanContent, phaseWrites } from '../projects/plan-schema.js';
|
|
14
|
+
import { statusesFromItems } from '../projects/plan-run.js';
|
|
15
|
+
import { findNewestVersion } from './plan-resume.js';
|
|
16
|
+
import { collectTurnAssistantText } from '../agent/turn-assistant-text.js';
|
|
17
|
+
import { finishPlanResume } from './plan-resume.js';
|
|
18
|
+
import { runPostPhaseChain } from './plan-chain.js';
|
|
19
|
+
/**
|
|
20
|
+
* The same block shape loop.ts's manifest-on-interrupt hook scans for.
|
|
21
|
+
* E2 dual-read: accept both the legacy `csforge:` and current `cspeach:` prefixes.
|
|
22
|
+
*/
|
|
23
|
+
const MANIFEST_BLOCK_RE = /<!--\s*(?:csforge|cspeach):plan-manifest\s*\n([\s\S]*?)\n\s*-->/g;
|
|
24
|
+
/**
|
|
25
|
+
* Build the marker from the prepared resume the phase turn is about to run.
|
|
26
|
+
* `family` mirrors leasePathFor's family-base convention (version suffix
|
|
27
|
+
* stripped) — display/diagnostic identity, the lease itself re-derives it.
|
|
28
|
+
*/
|
|
29
|
+
export function buildPlanInFlight(prepared, attemptStartedAt) {
|
|
30
|
+
const family = basename(leasePathFor(prepared.path)).replace(/\.cspeach\.lock$/, '');
|
|
31
|
+
return {
|
|
32
|
+
path: prepared.path,
|
|
33
|
+
family,
|
|
34
|
+
phaseId: prepared.nextPhaseId,
|
|
35
|
+
attemptStartedAt,
|
|
36
|
+
...(prepared.mode ? { mode: prepared.mode } : {}),
|
|
37
|
+
...(prepared.effectiveMode ? { effectiveMode: prepared.effectiveMode } : {}),
|
|
38
|
+
...(prepared.auditFlag ? { auditFlag: prepared.auditFlag } : {}),
|
|
39
|
+
...(prepared.auditMode ? { auditMode: prepared.auditMode } : {}),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Foreign-manifest guard. Every plan manifest (full AND compact) carries a
|
|
44
|
+
* `title`; a differing title means the turn produced a NEW plan, not this
|
|
45
|
+
* continuation — the caller must NOT attach it to the in-flight phase (it
|
|
46
|
+
* runs the generic save instead so the artifact isn't lost).
|
|
47
|
+
* - 'none' — no manifest block: phase still in flight, nothing to persist.
|
|
48
|
+
* - 'match' — attach: same title, titleless compact manifest, or unparseable
|
|
49
|
+
* JSON (finishPlanResume owns loud extract failures).
|
|
50
|
+
* - 'foreign' — a different plan's manifest.
|
|
51
|
+
* The LAST block wins, matching collectTurnAssistantText/extractPlan reading
|
|
52
|
+
* the final manifest of a turn.
|
|
53
|
+
*/
|
|
54
|
+
export function classifyContinuationText(text, envelopeTitle) {
|
|
55
|
+
const blocks = [...text.matchAll(MANIFEST_BLOCK_RE)];
|
|
56
|
+
if (blocks.length === 0)
|
|
57
|
+
return 'none';
|
|
58
|
+
const raw = blocks[blocks.length - 1][1];
|
|
59
|
+
try {
|
|
60
|
+
const parsed = JSON.parse(raw);
|
|
61
|
+
if (typeof parsed.title === 'string' && parsed.title.trim().length > 0
|
|
62
|
+
&& envelopeTitle.trim().length > 0 && parsed.title.trim() !== envelopeTitle.trim()) {
|
|
63
|
+
return 'foreign';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// Unparseable → 'match': hand it to finishPlanResume, whose extract
|
|
68
|
+
// failure path reports PHASE RESULT NOT PERSISTED loudly.
|
|
69
|
+
}
|
|
70
|
+
return 'match';
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Resolve the in-flight marker to a live plan + phase and re-acquire the
|
|
74
|
+
* family lease (it was released when the interrupted turn ended). Order
|
|
75
|
+
* matters: file + phase are validated BEFORE the lease so a stale marker
|
|
76
|
+
* never leaves a fresh lock behind. Every null return means "run a normal
|
|
77
|
+
* turn"; `stale` additionally tells the caller to clear the marker.
|
|
78
|
+
*/
|
|
79
|
+
export function preparePlanContinuation(args) {
|
|
80
|
+
const { marker } = args;
|
|
81
|
+
let path = marker.path;
|
|
82
|
+
if (!existsSync(path))
|
|
83
|
+
return { prepared: null, stale: true };
|
|
84
|
+
let envelope;
|
|
85
|
+
try {
|
|
86
|
+
envelope = readProjectFile(path);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return { prepared: null, stale: true };
|
|
90
|
+
}
|
|
91
|
+
// The marker may predate later phase-save rotations — same newest-version
|
|
92
|
+
// redirect preparePlanResume performs after its @token resolution.
|
|
93
|
+
const newer = findNewestVersion(path, envelope);
|
|
94
|
+
if (newer)
|
|
95
|
+
({ path, envelope } = newer);
|
|
96
|
+
const parsed = parsePlanContent(envelope.content);
|
|
97
|
+
if (!parsed.ok)
|
|
98
|
+
return { prepared: null, stale: true };
|
|
99
|
+
const phase = parsed.content.phases.find((p) => p.id === marker.phaseId);
|
|
100
|
+
if (!phase)
|
|
101
|
+
return { prepared: null, stale: true };
|
|
102
|
+
// Lease LAST: a stale marker (missing file / unknown phase) must never leave
|
|
103
|
+
// a fresh lock behind. A LIVE other-session holder is contention, NOT stale —
|
|
104
|
+
// the marker stays so the user can finish there or take over via --resume.
|
|
105
|
+
const lease = acquireRunLease(path, args.sessionId, { log: (l) => args.log(l) });
|
|
106
|
+
if (!lease.acquired) {
|
|
107
|
+
const holder = lease.holder;
|
|
108
|
+
args.log(`[plan] in-flight phase ${marker.phaseId} not re-attached — another session holds the run lease`
|
|
109
|
+
+ (holder ? ` (session ${holder.sessionId}, pid ${holder.pid})` : '')
|
|
110
|
+
+ '. Finish there or /abap-plan --resume here to take over.');
|
|
111
|
+
return { prepared: null, stale: false };
|
|
112
|
+
}
|
|
113
|
+
const prepared = {
|
|
114
|
+
path,
|
|
115
|
+
envelope,
|
|
116
|
+
statuses: statusesFromItems(envelope.interaction.items, parsed.content.phases),
|
|
117
|
+
nextPhaseId: marker.phaseId,
|
|
118
|
+
nextDelegateTo: phase.delegateTo,
|
|
119
|
+
nextPhaseWrites: phaseWrites(phase),
|
|
120
|
+
llmPrompt: '',
|
|
121
|
+
...(marker.mode ? { mode: marker.mode } : {}),
|
|
122
|
+
...(marker.effectiveMode ? { effectiveMode: marker.effectiveMode } : {}),
|
|
123
|
+
...(marker.auditFlag ? { auditFlag: marker.auditFlag } : {}),
|
|
124
|
+
...(marker.auditMode ? { auditMode: marker.auditMode } : {}),
|
|
125
|
+
};
|
|
126
|
+
return { prepared };
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* The continuation counterpart of the repl's finishPlanResume call sites:
|
|
130
|
+
* classify the turn text first (foreign-manifest guard), then reuse
|
|
131
|
+
* finishPlanResume verbatim — pending-audit injection, revision build,
|
|
132
|
+
* validation, handover projection all behave exactly as on a --resume turn.
|
|
133
|
+
* attemptStartedAt comes from the MARKER (the original phase turn), so the
|
|
134
|
+
* audit evidence window spans the interrupt.
|
|
135
|
+
*/
|
|
136
|
+
export async function finishPlanContinuation(args) {
|
|
137
|
+
const text = args.textOverride ?? collectTurnAssistantText(args.messages, args.messagesStart);
|
|
138
|
+
const cls = classifyContinuationText(text, args.prepared.envelope.title);
|
|
139
|
+
if (cls === 'none')
|
|
140
|
+
return { kind: 'none' };
|
|
141
|
+
if (cls === 'foreign') {
|
|
142
|
+
args.log(`[plan] this turn's manifest belongs to a different plan — NOT attached to in-flight phase ${args.marker.phaseId}.`);
|
|
143
|
+
return { kind: 'foreign' };
|
|
144
|
+
}
|
|
145
|
+
const outcome = await finishPlanResume({
|
|
146
|
+
prepared: args.prepared,
|
|
147
|
+
messages: args.messages,
|
|
148
|
+
messagesStart: args.messagesStart,
|
|
149
|
+
sessionId: args.sessionId,
|
|
150
|
+
attemptStartedAt: args.marker.attemptStartedAt,
|
|
151
|
+
auditMode: args.prepared.auditMode,
|
|
152
|
+
log: args.log,
|
|
153
|
+
...(args.textOverride !== undefined ? { textOverride: args.textOverride } : {}),
|
|
154
|
+
});
|
|
155
|
+
return outcome ? { kind: 'saved', outcome } : { kind: 'not-persisted' };
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The continuation counterpart of the repl's runPostPhaseChain call sites —
|
|
159
|
+
* executedPhaseId comes from the marker, run semantics (mode/audit flags)
|
|
160
|
+
* from the prepared object the marker rebuilt. Kept HERE so repl.tsx's
|
|
161
|
+
* runPostPhaseChain call-site count (structural seam pin ×2) is unchanged.
|
|
162
|
+
*/
|
|
163
|
+
export async function runContinuationChain(args) {
|
|
164
|
+
await runPostPhaseChain({
|
|
165
|
+
savedPath: args.outcome.savedPath,
|
|
166
|
+
executedPhaseId: args.marker.phaseId,
|
|
167
|
+
sessionId: args.sessionId,
|
|
168
|
+
cwd: args.cwd,
|
|
169
|
+
mode: args.prepared.effectiveMode ?? 'step',
|
|
170
|
+
modeFlag: args.prepared.mode,
|
|
171
|
+
auditMode: args.prepared.auditMode,
|
|
172
|
+
auditFlag: args.prepared.auditFlag,
|
|
173
|
+
attemptStartedAt: args.marker.attemptStartedAt,
|
|
174
|
+
subagentDispatches: args.subagentDispatches,
|
|
175
|
+
prompt: args.prompt,
|
|
176
|
+
queueDispatch: args.queueDispatch,
|
|
177
|
+
log: args.log,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// cspeach-cli/src/commands/plan-gate.ts
|
|
2
|
+
//
|
|
3
|
+
// Guarded phase-chaining gate (2026-07-03 agentic-flow design) — the PURE
|
|
4
|
+
// decision of what happens between two plan phases. No fs, no config, no
|
|
5
|
+
// I/O: the effective mode (config default, overridden by per-run
|
|
6
|
+
// --guarded/--step flags — see parsePlanResumeFlags in plan-resume.ts)
|
|
7
|
+
// arrives as an argument, and the caller (Task 6 wiring in the REPL's
|
|
8
|
+
// offerNextPhase path) renders the returned decision.
|
|
9
|
+
//
|
|
10
|
+
// Semantics (binding, from the design doc):
|
|
11
|
+
// step ⇒ always `prompt` — today's behaviour, one consent per phase.
|
|
12
|
+
// guarded ⇒ `stop` when the PREVIOUS phase's audit is unresolved
|
|
13
|
+
// ('audit-infra' for infra_failed, else 'audit-failed');
|
|
14
|
+
// `prompt` with 'write-phase' when the NEXT phase writes to SAP
|
|
15
|
+
// (phaseWrites is fail-safe: undeclared ⇒ writes). This
|
|
16
|
+
// prompt IS the stop — consent to RUN the phase; it does
|
|
17
|
+
// NOT pre-approve the writes inside it;
|
|
18
|
+
// `auto` otherwise (declared non-write phase, clean prior audit).
|
|
19
|
+
//
|
|
20
|
+
// Precedence: the audit stop wins over the write-phase prompt when both
|
|
21
|
+
// apply — a hard stop must never be downgraded to a consent prompt.
|
|
22
|
+
// Legacy envelopes (phases with no audit field) never stop the chain:
|
|
23
|
+
// isAuditResolved(undefined) === true.
|
|
24
|
+
import { phaseWrites } from '../projects/plan-schema.js';
|
|
25
|
+
import { isAuditResolved } from '../projects/plan-run.js';
|
|
26
|
+
/* ── guarded-run auto_approve override (Task 6, 2026-07-03) ────────────────
|
|
27
|
+
* Module-level flag consulted by tools/approval.ts BEFORE its auto-approve
|
|
28
|
+
* branch. The REPL sets it at guarded-chain start (right before the phase's
|
|
29
|
+
* runTurn) and clears it in a finally — so during a guarded auto-chained
|
|
30
|
+
* phase, every request_approval falls through to the interactive prompt even
|
|
31
|
+
* when the user's config says auto_approve. Rationale: auto_approve was
|
|
32
|
+
* consented for hand-driven turns; an auto-dispatched chain must not inherit
|
|
33
|
+
* that consent. When the flag is off, approval.ts behaviour is byte-identical
|
|
34
|
+
* (skill-mode compatibility). */
|
|
35
|
+
let guardedRunActive = false;
|
|
36
|
+
export function setGuardedRunActive(active) {
|
|
37
|
+
guardedRunActive = active;
|
|
38
|
+
}
|
|
39
|
+
export function isGuardedRunActive() {
|
|
40
|
+
return guardedRunActive;
|
|
41
|
+
}
|
|
42
|
+
/* ── plan-phase read-only enforcement (Task 7, 2026-07-03) ─────────────────
|
|
43
|
+
* Module-level flag consulted by tools/subagent/agent_run.ts. The REPL sets
|
|
44
|
+
* it around EVERY plan-resume phase turn (guarded AND step — unlike the
|
|
45
|
+
* guarded flag above) and clears it in the same finally — so any subagent
|
|
46
|
+
* dispatched from inside a plan phase is FORCED onto the read-only tool
|
|
47
|
+
* filter regardless of the model's `read_only` arg. Governance rule: inside
|
|
48
|
+
* plan phases, subagents can read, never write — the model cannot opt out.
|
|
49
|
+
* When the flag is off, agent_run's opt-in read_only behaviour is
|
|
50
|
+
* byte-identical (skill-mode compatibility). */
|
|
51
|
+
let planPhaseActive = false;
|
|
52
|
+
export function setPlanPhaseActive(active) {
|
|
53
|
+
planPhaseActive = active;
|
|
54
|
+
}
|
|
55
|
+
export function isPlanPhaseActive() {
|
|
56
|
+
return planPhaseActive;
|
|
57
|
+
}
|
|
58
|
+
/* Task 7 — per-phase subagent dispatch counter. agent_run increments it on
|
|
59
|
+
* every dispatch made while a plan phase is active; the REPL reads it
|
|
60
|
+
* (read+reset) in the runTurn finally that clears the plan-phase flag and
|
|
61
|
+
* threads the value to runPostPhaseChain, so the phase-end report can name
|
|
62
|
+
* how many subagents the phase used (fix-wave: consuming it in the chain
|
|
63
|
+
* alone leaked counts when the chain was skipped on error paths — the
|
|
64
|
+
* chain's read+reset is now only a fallback for turn-less callers). Lives
|
|
65
|
+
* HERE (not in agent_run.ts) so plan-chain never has to import the tool
|
|
66
|
+
* module — plan-gate is already the shared, pure, I/O-free home for
|
|
67
|
+
* plan-phase module state. */
|
|
68
|
+
let planPhaseSubagentDispatches = 0;
|
|
69
|
+
export function notePlanPhaseSubagentDispatch() {
|
|
70
|
+
planPhaseSubagentDispatches += 1;
|
|
71
|
+
}
|
|
72
|
+
export function getAndResetPlanPhaseSubagentDispatches() {
|
|
73
|
+
const n = planPhaseSubagentDispatches;
|
|
74
|
+
planPhaseSubagentDispatches = 0;
|
|
75
|
+
return n;
|
|
76
|
+
}
|
|
77
|
+
/* ── Task 10 — deviation backstop state (2026-07-03) ───────────────────────
|
|
78
|
+
* currentPhaseWrites: the DECLARED `writes` field of the plan phase executing
|
|
79
|
+
* THIS turn (null = outside phases). Set by the repl wiring alongside
|
|
80
|
+
* setPlanPhaseActive at both call sites, cleared to null in the same finally.
|
|
81
|
+
* tools/approval.ts consults it BEFORE rendering anything: guarded run +
|
|
82
|
+
* writes === false + a request_approval arriving anyway = a plan deviation —
|
|
83
|
+
* the write is DENIED without prompting (an unattended chain must never hang
|
|
84
|
+
* on an interactive approval), and the deviation flag below is raised.
|
|
85
|
+
* Fail-safe by construction: only an EXPLICIT false ever denies. */
|
|
86
|
+
let currentPhaseWrites = null;
|
|
87
|
+
export function setCurrentPhaseWrites(w) {
|
|
88
|
+
currentPhaseWrites = w;
|
|
89
|
+
}
|
|
90
|
+
export function getCurrentPhaseWrites() {
|
|
91
|
+
return currentPhaseWrites;
|
|
92
|
+
}
|
|
93
|
+
/* Task 10 — the "deviation occurred" flag, same read+reset pattern as the
|
|
94
|
+
* subagent counter above: approval.ts raises it (with the detail string) when
|
|
95
|
+
* it denies an undeclared write; plan-chain.ts reads+resets it at post-phase-
|
|
96
|
+
* chain start and turns it into blocked + lastStop:'deviation' + STOPPED
|
|
97
|
+
* banner. Lives HERE so plan-chain never imports the tool module. */
|
|
98
|
+
/**
|
|
99
|
+
* The one detail string a deviation carries — shared so approval.ts (the
|
|
100
|
+
* raiser), plan-chain.ts (the persister) and the tests can never drift.
|
|
101
|
+
*/
|
|
102
|
+
export const PLAN_DEVIATION_DETAIL = 'phase declared writes:false but requested a write approval — chain stopped';
|
|
103
|
+
let planDeviationDetail = null;
|
|
104
|
+
export function notePlanDeviation(detail) {
|
|
105
|
+
planDeviationDetail = detail;
|
|
106
|
+
}
|
|
107
|
+
export function getAndResetPlanDeviation() {
|
|
108
|
+
const d = planDeviationDetail;
|
|
109
|
+
planDeviationDetail = null;
|
|
110
|
+
return d;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Banner shown for every non-auto guarded outcome. The resume hint uses the
|
|
114
|
+
* literal `@<plan>` placeholder on purpose — this module is pure and doesn't
|
|
115
|
+
* know the saved path; the caller may substitute the real basename.
|
|
116
|
+
*/
|
|
117
|
+
const banner = (reason, next) => `\n■ STOPPED — ${reason}\n Resume with: /abap-plan --resume @<plan> (next phase: ${next})\n`;
|
|
118
|
+
export function decideNextPhaseGate(args) {
|
|
119
|
+
if (args.mode === 'step') {
|
|
120
|
+
// Step mode is today's behaviour verbatim — a plain consent prompt with
|
|
121
|
+
// no guarded decorations (no banner, no stopReason).
|
|
122
|
+
return { action: 'prompt' };
|
|
123
|
+
}
|
|
124
|
+
// Guarded. Hard stop first: an unresolved prior audit must never be
|
|
125
|
+
// downgraded to a runnable prompt by the write-phase branch below.
|
|
126
|
+
if (!isAuditResolved(args.prevPhaseAudit)) {
|
|
127
|
+
if (args.prevPhaseAudit?.state === 'infra_failed') {
|
|
128
|
+
return {
|
|
129
|
+
action: 'stop',
|
|
130
|
+
stopReason: 'audit-infra',
|
|
131
|
+
banner: banner('auditor infrastructure failure — verdict unknown (not a code finding)', args.nextPhase.id),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
action: 'stop',
|
|
136
|
+
stopReason: 'audit-failed',
|
|
137
|
+
banner: banner('prior phase audit is unresolved — fix the findings or explicitly waive them', args.nextPhase.id),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
if (phaseWrites(args.nextPhase)) {
|
|
141
|
+
// The prompt IS the stop: consent to run the write phase. Writes inside
|
|
142
|
+
// the phase still go through their own approval flow — this never
|
|
143
|
+
// pre-approves them.
|
|
144
|
+
return {
|
|
145
|
+
action: 'prompt',
|
|
146
|
+
stopReason: 'write-phase',
|
|
147
|
+
// Task 3 review carry-over: the banner's resume hint already names the
|
|
148
|
+
// phase (`next phase: <id>`), so the reason string stays generic —
|
|
149
|
+
// naming it twice read as a rendering bug.
|
|
150
|
+
banner: banner('next phase writes to SAP — consent required to run it', args.nextPhase.id),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
return { action: 'auto' };
|
|
154
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// A4 (2026-06-11) — per-phase model tiering for the plan runner.
|
|
2
|
+
//
|
|
3
|
+
// Cost-control Phase 3, pre-approved by the 2026-05-18 model bench
|
|
4
|
+
// (cspeach-cli/scripts/bench-models.ts): Sonnet matches Opus on
|
|
5
|
+
// explain/document/test/design-shaped ABAP work at ~85% lower cost, while
|
|
6
|
+
// Haiku is UNSAFE on ABAP content (hallucinates SAP terms — bench memo) and
|
|
7
|
+
// is therefore NEVER a tiering target here.
|
|
8
|
+
//
|
|
9
|
+
// FLAG-GATED, OFF BY DEFAULT. Two switches, env wins when set:
|
|
10
|
+
// - config: plan_model_tiering = true (~/.cspeach/config.toml)
|
|
11
|
+
// - env: CSPEACH_PLAN_MODEL_TIERING=on (also 1/true; off/0/false force-disable)
|
|
12
|
+
//
|
|
13
|
+
// Classification is STRUCTURAL, not id-string parsing: each plan phase
|
|
14
|
+
// carries a `delegateTo` skill (Zod enum PLAN_DELEGATE_SKILLS in
|
|
15
|
+
// projects/plan-schema.ts). Only the read/reason-shaped skills the bench
|
|
16
|
+
// cleared go to Sonnet; every write/codegen delegate — and anything
|
|
17
|
+
// ambiguous or unknown — stays on the session's default model.
|
|
18
|
+
//
|
|
19
|
+
// Extra guard: tiering only fires when the session default is Opus-family.
|
|
20
|
+
// A user who deliberately set default_model to Sonnet (or anything cheaper)
|
|
21
|
+
// must never be silently moved to a different model by this feature.
|
|
22
|
+
//
|
|
23
|
+
// Wiring (single read path): repl.tsx computes the choice right after
|
|
24
|
+
// preparePlanResume succeeds (both Ink and classic paths), prints the dim
|
|
25
|
+
// notice for cost auditability, and threads the model into runTurn via
|
|
26
|
+
// RunTurnParams.modelOverride — which also feeds the per-turn cost-log
|
|
27
|
+
// entry so ~/.cspeach/sessions/<id>-cost.jsonl records the ACTUAL model.
|
|
28
|
+
/** The only model this feature ever tiers down to. Never Haiku. */
|
|
29
|
+
export const PLAN_TIER_SONNET_MODEL = 'claude-sonnet-4-6';
|
|
30
|
+
/**
|
|
31
|
+
* Delegate skills whose phases are design/test/document-shaped — the shapes
|
|
32
|
+
* the 2026-05-18 bench cleared for Sonnet. `abap-document` is not in
|
|
33
|
+
* PLAN_DELEGATE_SKILLS today; included so an enum extension is covered
|
|
34
|
+
* without touching this file. Everything else (abap-data-model, abap-rap,
|
|
35
|
+
* abap-eml, abap-generate, abap-segw, abap-fiori-build) writes SAP objects
|
|
36
|
+
* or code and stays on the default model.
|
|
37
|
+
*/
|
|
38
|
+
export const SONNET_TIER_DELEGATES = new Set([
|
|
39
|
+
'abap-design',
|
|
40
|
+
'abap-test',
|
|
41
|
+
'abap-document',
|
|
42
|
+
]);
|
|
43
|
+
const ENV_FLAG = 'CSPEACH_PLAN_MODEL_TIERING';
|
|
44
|
+
/**
|
|
45
|
+
* Resolve the feature flag. Env var (when set) wins over config so a single
|
|
46
|
+
* shell can A/B the feature without editing config.toml; the config flag is
|
|
47
|
+
* the persistent opt-in. Strict `=== true` on the config value — TOML is
|
|
48
|
+
* hand-edited, and a typo'd string must read as OFF, never ON.
|
|
49
|
+
*/
|
|
50
|
+
export function isPlanModelTieringEnabled(cfg, env = process.env) {
|
|
51
|
+
const raw = env[ENV_FLAG];
|
|
52
|
+
if (raw !== undefined && raw !== '') {
|
|
53
|
+
const v = raw.trim().toLowerCase();
|
|
54
|
+
return v === 'on' || v === '1' || v === 'true';
|
|
55
|
+
}
|
|
56
|
+
return cfg.plan_model_tiering === true;
|
|
57
|
+
}
|
|
58
|
+
const NO_OVERRIDE = { model: null, notice: null };
|
|
59
|
+
/**
|
|
60
|
+
* Pure decision function: which model should this plan phase run on?
|
|
61
|
+
*
|
|
62
|
+
* Returns an override ONLY when all of:
|
|
63
|
+
* - the flag is on,
|
|
64
|
+
* - the phase's delegateTo is a bench-cleared design/test/document shape,
|
|
65
|
+
* - the session default is an Opus-family model (we only tier DOWN from
|
|
66
|
+
* Opus — never sideways/up from a deliberately cheaper default).
|
|
67
|
+
*
|
|
68
|
+
* Anything ambiguous resolves to "no override". The returned model is
|
|
69
|
+
* claude-sonnet-4-6 or nothing — Haiku is unreachable by construction
|
|
70
|
+
* (pinned in plan-model-tier.test.ts).
|
|
71
|
+
*/
|
|
72
|
+
export function selectPlanPhaseModel(args) {
|
|
73
|
+
if (!args.enabled)
|
|
74
|
+
return NO_OVERRIDE;
|
|
75
|
+
if (!args.delegateTo || !SONNET_TIER_DELEGATES.has(args.delegateTo))
|
|
76
|
+
return NO_OVERRIDE;
|
|
77
|
+
if (!/opus/i.test(args.defaultModel))
|
|
78
|
+
return NO_OVERRIDE;
|
|
79
|
+
return {
|
|
80
|
+
model: PLAN_TIER_SONNET_MODEL,
|
|
81
|
+
notice: `phase ${args.phaseId} → ${PLAN_TIER_SONNET_MODEL} (plan model tiering)`,
|
|
82
|
+
};
|
|
83
|
+
}
|