@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.
Files changed (195) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/intent-system-prompt.js +1 -1
  3. package/dist/agent/loop.js +228 -26
  4. package/dist/agent/providers/license-gate.js +44 -0
  5. package/dist/agent/skill-checkpoint.js +1 -1
  6. package/dist/agent/tool-dispatch.js +15 -0
  7. package/dist/approvals/canonical.js +91 -0
  8. package/dist/approvals/jwt.js +39 -2
  9. package/dist/approvals/op-labels.js +124 -0
  10. package/dist/approvals/render.js +42 -36
  11. package/dist/auth/org-anthropic-key.js +25 -0
  12. package/dist/classifier/client.js +18 -3
  13. package/dist/cli.js +15 -0
  14. package/dist/commands/compact.js +28 -2
  15. package/dist/commands/config-set.js +284 -0
  16. package/dist/commands/config-show.js +20 -0
  17. package/dist/commands/export-audit.js +43 -0
  18. package/dist/commands/help.js +5 -0
  19. package/dist/commands/login.js +31 -14
  20. package/dist/commands/plan-audit-evidence.js +266 -0
  21. package/dist/commands/plan-audit.js +692 -0
  22. package/dist/commands/plan-chain.js +671 -0
  23. package/dist/commands/plan-continue.js +179 -0
  24. package/dist/commands/plan-gate.js +154 -0
  25. package/dist/commands/plan-model-tier.js +83 -0
  26. package/dist/commands/plan-resume.js +728 -46
  27. package/dist/config/loader.js +223 -5
  28. package/dist/config/model-defaults.js +14 -0
  29. package/dist/cost/pricing.js +27 -1
  30. package/dist/doctor/checks/_http-probe.js +1 -0
  31. package/dist/doctor/checks/cert.js +14 -3
  32. package/dist/doctor/checks/sap.js +30 -8
  33. package/dist/doctor/checks/system-roles.js +41 -0
  34. package/dist/doctor/checks/zcspeach.js +19 -4
  35. package/dist/doctor/run.js +2 -0
  36. package/dist/models/resolve.js +61 -0
  37. package/dist/models/server-config.js +155 -0
  38. package/dist/one-shot.js +76 -6
  39. package/dist/projects/answer-blockers.js +137 -0
  40. package/dist/projects/extract-cca.js +111 -17
  41. package/dist/projects/extract-modernize.js +4 -2
  42. package/dist/projects/extract-plan.js +184 -37
  43. package/dist/projects/extract-spec-gap.js +34 -7
  44. package/dist/projects/extract-test-coverage.js +4 -2
  45. package/dist/projects/extract-upgrade.js +116 -23
  46. package/dist/projects/handover-md.js +195 -0
  47. package/dist/projects/index.js +5 -2
  48. package/dist/projects/merge-cca.js +292 -0
  49. package/dist/projects/merge-upgrade.js +173 -0
  50. package/dist/projects/migration.js +103 -1
  51. package/dist/projects/output-paths.js +27 -0
  52. package/dist/projects/plan-run.js +285 -27
  53. package/dist/projects/plan-schema.js +136 -3
  54. package/dist/projects/promote-command.js +25 -2
  55. package/dist/projects/promote.js +128 -0
  56. package/dist/projects/run-lease.js +157 -0
  57. package/dist/projects/save-command.js +259 -21
  58. package/dist/projects/status.js +3 -1
  59. package/dist/projects/validate.js +1 -1
  60. package/dist/projects/workspace.js +164 -20
  61. package/dist/renderer/notices.js +64 -0
  62. package/dist/renderer/progress-chatter.js +8 -0
  63. package/dist/renderer/status-footer.js +22 -12
  64. package/dist/renderer/thinking-heartbeat.js +64 -8
  65. package/dist/renderer/todo-block.js +51 -0
  66. package/dist/renderer/tool-widget.js +55 -4
  67. package/dist/renderer/tty.js +43 -4
  68. package/dist/renderer/verify-chain.js +77 -0
  69. package/dist/repl/at-picker.js +60 -7
  70. package/dist/repl/bracketed-paste.js +28 -19
  71. package/dist/repl/builtin-commands.js +42 -0
  72. package/dist/repl/current-transport.js +10 -0
  73. package/dist/repl/early-line-buffer.js +68 -0
  74. package/dist/repl/history.js +86 -0
  75. package/dist/repl/ink-stdin-guard.js +64 -0
  76. package/dist/repl/inquirer-guard.js +70 -5
  77. package/dist/repl/mode-ceiling.js +16 -0
  78. package/dist/repl/mode-cycle.js +104 -0
  79. package/dist/repl/numbered-menu.js +131 -0
  80. package/dist/repl/post-turn-status.js +26 -6
  81. package/dist/repl/rule8-detector.js +17 -2
  82. package/dist/repl/safety-confirm.js +111 -2
  83. package/dist/repl/safety-mode-state.js +19 -3
  84. package/dist/repl/slash-completer.js +5 -0
  85. package/dist/repl/slash-picker.js +10 -15
  86. package/dist/repl.js +1232 -95
  87. package/dist/rewind/candidates.js +194 -0
  88. package/dist/rewind/cli.js +137 -0
  89. package/dist/rewind/format.js +27 -0
  90. package/dist/rewind/restore.js +245 -0
  91. package/dist/router/classifier.js +150 -6
  92. package/dist/sap/capability-matrix.js +20 -0
  93. package/dist/sap/capability-matrix.json +11236 -0
  94. package/dist/sap/capability.js +146 -0
  95. package/dist/sap/connection-manager.js +19 -1
  96. package/dist/sap/onboarding.js +42 -4
  97. package/dist/session/audit-export.js +459 -0
  98. package/dist/session/context-report.js +163 -0
  99. package/dist/session/pending.js +27 -0
  100. package/dist/session/recap.js +160 -0
  101. package/dist/skill-catalog.js +51 -40
  102. package/dist/skills/bundled-skills.js +272 -1
  103. package/dist/skills/promotion-dispatch.js +23 -0
  104. package/dist/tools/_command-shared.js +36 -12
  105. package/dist/tools/_filesystem-shared.js +139 -4
  106. package/dist/tools/_flag.js +25 -0
  107. package/dist/tools/approval.js +177 -26
  108. package/dist/tools/ask-question.js +400 -7
  109. package/dist/tools/capability/tool.js +74 -0
  110. package/dist/tools/dispatch-skill.js +22 -1
  111. package/dist/tools/extend-model/anchored-insert.js +1414 -0
  112. package/dist/tools/extend-model/tool.js +340 -0
  113. package/dist/tools/filesystem/extract-document.js +57 -0
  114. package/dist/tools/filesystem/file-edit.js +12 -2
  115. package/dist/tools/filesystem/file-read.js +2 -2
  116. package/dist/tools/filesystem/file-write.js +11 -2
  117. package/dist/tools/filesystem/glob.js +11 -0
  118. package/dist/tools/filesystem/grep.js +10 -0
  119. package/dist/tools/filesystem/read-document.js +107 -0
  120. package/dist/tools/fiori/apply.js +50 -0
  121. package/dist/tools/fiori/bin.js +3 -0
  122. package/dist/tools/fiori/catalog/index.js +27 -0
  123. package/dist/tools/fiori/catalog/value-help.js +230 -0
  124. package/dist/tools/fiori/catalog/viz-chart.js +177 -0
  125. package/dist/tools/fiori/cli.js +71 -0
  126. package/dist/tools/fiori/deploy-config.js +73 -0
  127. package/dist/tools/fiori/fe-extend.js +76 -0
  128. package/dist/tools/fiori/fe-scaffold.js +71 -0
  129. package/dist/tools/fiori/floorplan-map.js +19 -0
  130. package/dist/tools/fiori/i18n.js +39 -0
  131. package/dist/tools/fiori/manifest.js +70 -0
  132. package/dist/tools/fiori/render.js +77 -0
  133. package/dist/tools/fiori/samples/data/index.json +13602 -0
  134. package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
  135. package/dist/tools/fiori/samples/loader.js +248 -0
  136. package/dist/tools/fiori/samples/search.js +63 -0
  137. package/dist/tools/fiori/samples/types.js +2 -0
  138. package/dist/tools/fiori/scaffold.js +39 -0
  139. package/dist/tools/fiori/smoke/assertions.js +74 -0
  140. package/dist/tools/fiori/smoke/browser.js +52 -0
  141. package/dist/tools/fiori/smoke/driver.js +89 -0
  142. package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
  143. package/dist/tools/fiori/smoke/run-smoke.js +149 -0
  144. package/dist/tools/fiori/tools.js +681 -0
  145. package/dist/tools/fiori/types.js +1 -0
  146. package/dist/tools/local-build.js +86 -0
  147. package/dist/tools/local-files.js +31 -0
  148. package/dist/tools/project/_merge-shared.js +68 -0
  149. package/dist/tools/project/cca_merge.js +164 -0
  150. package/dist/tools/project/playbook_get.js +1 -1
  151. package/dist/tools/project/upgrade_merge_progress.js +206 -0
  152. package/dist/tools/sap-read.js +132 -20
  153. package/dist/tools/sap-write.js +550 -21
  154. package/dist/tools/shell/shell_exec.js +41 -6
  155. package/dist/tools/snapshot.js +63 -14
  156. package/dist/tools/subagent/agent_run.js +27 -3
  157. package/dist/tools/subagent/background_run.js +17 -1
  158. package/dist/tools/todo.js +144 -0
  159. package/dist/tools/transport-resolution.js +86 -0
  160. package/dist/tools/transport.js +224 -5
  161. package/dist/tools/write-mode.js +4 -0
  162. package/dist/ui/app.js +378 -21
  163. package/dist/ui/approval-modal.js +49 -16
  164. package/dist/ui/ask-question-emitter.js +14 -0
  165. package/dist/ui/body.js +13 -0
  166. package/dist/ui/context-grid.js +108 -0
  167. package/dist/ui/footer.js +120 -27
  168. package/dist/ui/header.js +7 -0
  169. package/dist/ui/line-resolution.js +35 -8
  170. package/dist/ui/rewind-emitter.js +10 -0
  171. package/dist/ui/rewind-panel.js +81 -0
  172. package/dist/ui/sap-state-store.js +1 -0
  173. package/dist/ui/session-timeline.js +1 -0
  174. package/dist/ui/status-line.js +43 -0
  175. package/dist/ui/text-input.js +214 -0
  176. package/dist/ui/todo-emitter.js +25 -0
  177. package/dist/ui/todo-panel.js +64 -0
  178. package/dist/ui/turn-status-emitter.js +50 -4
  179. package/dist/ui/turn-status.js +18 -3
  180. package/dist/ui/widgets/ask-form.js +242 -0
  181. package/dist/ui/widgets/ask-question-modal.js +21 -8
  182. package/package.json +22 -3
  183. package/bench/README.md +0 -78
  184. package/bench/prompts/abap-document-cds.md +0 -44
  185. package/bench/prompts/abap-explain-bdef-handler.md +0 -57
  186. package/bench/prompts/abap-test-method.md +0 -42
  187. package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
  188. package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
  189. package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
  190. package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
  191. package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
  192. package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
  193. package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
  194. package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
  195. 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
+ }