@cspeach/cli 0.8.0 → 1.0.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 (141) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/intent-system-prompt.js +1 -1
  3. package/dist/agent/loop.js +330 -36
  4. package/dist/agent/providers/license-gate.js +44 -0
  5. package/dist/agent/skill-checkpoint.js +1 -1
  6. package/dist/agent/steering-queue.js +27 -0
  7. package/dist/agent/tool-dispatch.js +15 -0
  8. package/dist/approvals/canonical.js +91 -0
  9. package/dist/approvals/jwt.js +84 -7
  10. package/dist/approvals/render.js +38 -0
  11. package/dist/auth/org-anthropic-key.js +25 -0
  12. package/dist/classifier/client.js +24 -5
  13. package/dist/commands/config-set.js +95 -0
  14. package/dist/commands/login.js +31 -14
  15. package/dist/commands/plan-model-tier.js +83 -0
  16. package/dist/commands/plan-resume.js +435 -0
  17. package/dist/config/loader.js +102 -3
  18. package/dist/cost/pricing.js +14 -5
  19. package/dist/doctor/checks/_http-probe.js +1 -0
  20. package/dist/doctor/checks/cert.js +14 -3
  21. package/dist/doctor/checks/sap.js +30 -8
  22. package/dist/doctor/checks/zcspeach.js +19 -4
  23. package/dist/one-shot.js +52 -4
  24. package/dist/projects/answer-blockers.js +137 -0
  25. package/dist/projects/email-template.js +2 -0
  26. package/dist/projects/extract-cca.js +108 -16
  27. package/dist/projects/extract-modernize.js +1 -1
  28. package/dist/projects/extract-plan.js +178 -0
  29. package/dist/projects/extract-spec-gap.js +34 -7
  30. package/dist/projects/extract-test-coverage.js +1 -1
  31. package/dist/projects/extract-upgrade.js +113 -22
  32. package/dist/projects/index.js +7 -1
  33. package/dist/projects/merge-cca.js +292 -0
  34. package/dist/projects/merge-upgrade.js +173 -0
  35. package/dist/projects/migration.js +103 -1
  36. package/dist/projects/output-paths.js +27 -0
  37. package/dist/projects/plan-run.js +254 -0
  38. package/dist/projects/plan-schema.js +210 -0
  39. package/dist/projects/promote-command.js +26 -2
  40. package/dist/projects/promote.js +128 -0
  41. package/dist/projects/save-command.js +263 -20
  42. package/dist/projects/status.js +22 -0
  43. package/dist/projects/validate.js +2 -0
  44. package/dist/projects/workspace.js +164 -20
  45. package/dist/renderer/notices.js +64 -0
  46. package/dist/renderer/progress-chatter.js +8 -0
  47. package/dist/renderer/syntax.js +16 -1
  48. package/dist/renderer/thinking-heartbeat.js +13 -1
  49. package/dist/renderer/tool-widget.js +18 -4
  50. package/dist/renderer/tty.js +43 -4
  51. package/dist/renderer/verify-chain.js +77 -0
  52. package/dist/repl/at-picker.js +93 -21
  53. package/dist/repl/builtin-commands.js +37 -0
  54. package/dist/repl/early-line-buffer.js +68 -0
  55. package/dist/repl/inquirer-guard.js +70 -5
  56. package/dist/repl/numbered-menu.js +131 -0
  57. package/dist/repl/post-turn-status.js +2 -2
  58. package/dist/repl/rule8-detector.js +17 -2
  59. package/dist/repl/safety-confirm.js +111 -2
  60. package/dist/repl/safety-mode-state.js +19 -3
  61. package/dist/repl/slash-picker.js +25 -19
  62. package/dist/repl.js +470 -22
  63. package/dist/router/classifier.js +150 -6
  64. package/dist/sap/capability-matrix.js +20 -0
  65. package/dist/sap/capability-matrix.json +11236 -0
  66. package/dist/sap/capability.js +146 -0
  67. package/dist/sap/connection-manager.js +19 -1
  68. package/dist/sap/onboarding.js +42 -4
  69. package/dist/session/pending.js +27 -0
  70. package/dist/skill-catalog.js +54 -43
  71. package/dist/skills/bundled-skills.js +279 -1
  72. package/dist/skills/promotion-dispatch.js +23 -0
  73. package/dist/tools/_command-shared.js +36 -12
  74. package/dist/tools/_filesystem-shared.js +139 -4
  75. package/dist/tools/_flag.js +25 -0
  76. package/dist/tools/approval.js +64 -21
  77. package/dist/tools/ask-question.js +96 -4
  78. package/dist/tools/capability/tool.js +74 -0
  79. package/dist/tools/dispatch-skill.js +22 -1
  80. package/dist/tools/extend-model/anchored-insert.js +810 -0
  81. package/dist/tools/extend-model/tool.js +188 -0
  82. package/dist/tools/filesystem/extract-document.js +57 -0
  83. package/dist/tools/filesystem/file-edit.js +12 -2
  84. package/dist/tools/filesystem/file-read.js +2 -2
  85. package/dist/tools/filesystem/file-write.js +11 -2
  86. package/dist/tools/filesystem/glob.js +11 -0
  87. package/dist/tools/filesystem/grep.js +10 -0
  88. package/dist/tools/filesystem/read-document.js +107 -0
  89. package/dist/tools/fiori/apply.js +50 -0
  90. package/dist/tools/fiori/bin.js +3 -0
  91. package/dist/tools/fiori/catalog/index.js +27 -0
  92. package/dist/tools/fiori/catalog/value-help.js +230 -0
  93. package/dist/tools/fiori/catalog/viz-chart.js +177 -0
  94. package/dist/tools/fiori/cli.js +71 -0
  95. package/dist/tools/fiori/deploy-config.js +73 -0
  96. package/dist/tools/fiori/fe-scaffold.js +45 -0
  97. package/dist/tools/fiori/i18n.js +39 -0
  98. package/dist/tools/fiori/manifest.js +70 -0
  99. package/dist/tools/fiori/render.js +77 -0
  100. package/dist/tools/fiori/scaffold.js +39 -0
  101. package/dist/tools/fiori/tools.js +356 -0
  102. package/dist/tools/fiori/types.js +1 -0
  103. package/dist/tools/local-build.js +76 -0
  104. package/dist/tools/local-files.js +31 -0
  105. package/dist/tools/project/_merge-shared.js +68 -0
  106. package/dist/tools/project/cca_merge.js +164 -0
  107. package/dist/tools/project/playbook_get.js +1 -1
  108. package/dist/tools/project/upgrade_merge_progress.js +206 -0
  109. package/dist/tools/sap-read.js +53 -9
  110. package/dist/tools/sap-write.js +530 -21
  111. package/dist/tools/shell/shell_exec.js +41 -6
  112. package/dist/tools/snapshot.js +37 -14
  113. package/dist/tools/subagent/background_run.js +17 -1
  114. package/dist/tools/transport-resolution.js +86 -0
  115. package/dist/tools/transport.js +224 -5
  116. package/dist/tools/write-mode.js +4 -0
  117. package/dist/ui/app.js +84 -11
  118. package/dist/ui/body.js +13 -0
  119. package/dist/ui/command-palette.js +46 -10
  120. package/dist/ui/file-palette.js +44 -0
  121. package/dist/ui/footer.js +28 -11
  122. package/dist/ui/line-resolution.js +92 -0
  123. package/dist/ui/session-timeline.js +1 -0
  124. package/dist/ui/text-input.js +150 -0
  125. package/dist/ui/turn-status-emitter.js +52 -0
  126. package/dist/ui/turn-status.js +59 -0
  127. package/dist/ui/widgets/ask-question-modal.js +30 -2
  128. package/package.json +23 -4
  129. package/bench/README.md +0 -78
  130. package/bench/prompts/abap-document-cds.md +0 -44
  131. package/bench/prompts/abap-explain-bdef-handler.md +0 -57
  132. package/bench/prompts/abap-test-method.md +0 -42
  133. package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
  134. package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
  135. package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
  136. package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
  137. package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
  138. package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
  139. package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
  140. package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
  141. package/bench/results/abap-test-method/claude-sonnet-4-6.md +0 -234
@@ -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
+ }
@@ -0,0 +1,435 @@
1
+ // cspeach-cli/src/commands/plan-resume.ts
2
+ //
3
+ // `/abap-plan --resume @<plan-file>` — B3 (2026-06-06).
4
+ //
5
+ // Split around the model turn:
6
+ //
7
+ // preparePlanResume (token-free, BEFORE runTurn)
8
+ // resolve @token → load + validate envelope → compute next eligible
9
+ // phase → print the tracker board → inline the phase's declared rule
10
+ // files → return the bounded LLM prompt. Terminal states (plan
11
+ // complete / everything blocked) print and return null — no model
12
+ // turn happens at all.
13
+ //
14
+ // finishPlanResume (AFTER runTurn)
15
+ // collect the turn's assistant text (ALL messages — the manifest may
16
+ // precede a closing ask_question, see turn-assistant-text.ts) →
17
+ // extract the LAST csforge:plan-manifest block → build the version
18
+ // N+1 revision (same id, history appended) → save → print the
19
+ // updated tracker + next-step hint → return the saved path + the
20
+ // HARNESS-built resume command for the next phase (A1, 2026-06-10).
21
+ //
22
+ // A1 (defect D23) — true bounded phases: phase end = turn end.
23
+ // The model's resume turn ends at the manifest block. It must NOT ask a
24
+ // continuation question, NOT call dispatch_skill, and NOT execute a
25
+ // second phase in-turn (the old contract allowed in-session "continue",
26
+ // which snowballed a 6-phase run into one 3.24M-token-context session).
27
+ // Continuation is harness-owned: repl.tsx calls offerNextPhaseAutoRun
28
+ // with the PlanResumeOutcome; on consent it queues the EXACT command
29
+ // buildResumeCommand produced from the just-saved path (the model once
30
+ // hallucinated a wrong base when it authored this text itself), and
31
+ // every plan-resume turn starts from reset session messages
32
+ // (resetContextForPlanResume).
33
+ //
34
+ // The save hook inside runTurn is suppressed for resume turns
35
+ // (RunTurnParams.suppressSaveHook) — it would otherwise offer to save a
36
+ // duplicate NEW envelope with a fresh id, breaking the version chain.
37
+ // Resume persistence is unconditional: the envelope is the only state
38
+ // that survives the session, so losing the write-back breaks the plan.
39
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
40
+ import { basename, dirname, join } from 'node:path';
41
+ import { readProjectFile } from '../projects/status.js';
42
+ import { resolveAtTokenAsync, formatProjectFileList, ensureWorkspace } from '../projects/workspace.js';
43
+ import { parsePlanContent } from '../projects/plan-schema.js';
44
+ import { statusesFromItems, computeNextPhase, renderPlanTracker, buildPlanRevision, isPhaseSatisfied, planCompletionLines, } from '../projects/plan-run.js';
45
+ import { extractPlan } from '../projects/extract-plan.js';
46
+ import { saveProject } from '../projects/save.js';
47
+ import { validateEnvelope } from '../projects/validate.js';
48
+ import { collectTurnAssistantText } from '../agent/turn-assistant-text.js';
49
+ import { getAuthorIdentity } from '../agent/loop.js';
50
+ /**
51
+ * A1 (defect D23) — the harness-owned continuation contract, stated to the
52
+ * model verbatim in every resume prompt: the manifest ends the turn; the
53
+ * model must not call dispatch_skill or ask a continuation question — the
54
+ * CLI dispatches the next phase itself. Exported as a named constant so
55
+ * tests pin the prompt to this exact block instead of brittle prose
56
+ * regexes (the old contract let the model keep executing phases in-turn —
57
+ * a 6-phase run once snowballed to a 3.24M-token context).
58
+ */
59
+ export const PLAN_RESUME_HARNESS_OVERRIDE = 'HARNESS OVERRIDE of the skill\'s Mode 2 step 8: do NOT ask a continuation question, do NOT call dispatch_skill, and NEVER start another phase in this turn. After your manifest is persisted, the CLI itself asks the user whether to run the next phase and dispatches it with the exact saved file in a fresh bounded context. Phase end = turn end.';
60
+ export async function preparePlanResume(args) {
61
+ const tokens = args.body
62
+ .split(/\s+/)
63
+ .filter((tok) => tok.startsWith('@'))
64
+ .map((tok) => tok.slice(1))
65
+ .filter((tok) => tok.length > 0);
66
+ if (tokens.length === 0) {
67
+ args.log('Usage: /abap-plan --resume @<plan>.cspeach.json');
68
+ args.log('Bare names work too — e.g. @hr-extract-plan — resolved from the workspace folder.');
69
+ return null;
70
+ }
71
+ const resolved = await resolveAtTokenAsync(tokens[0], args.cwd);
72
+ let path;
73
+ if (resolved.kind === 'path') {
74
+ path = resolved.path;
75
+ }
76
+ else if (resolved.kind === 'notFound') {
77
+ args.log(`--resume: no file matching '${resolved.token}' in workspace ${resolved.workspace}`);
78
+ args.log('Run /files to see available .cspeach.json files.');
79
+ return null;
80
+ }
81
+ else {
82
+ // Version-less resume: a base name / short id matches every version of one
83
+ // plan family. Resolve to the newest instead of erroring — the user (and
84
+ // the auto-fire dispatch) should never have to name a version number.
85
+ const newestOfFamily = pickNewestPlanVersion(resolved.matches.map((m) => m.path));
86
+ if (newestOfFamily) {
87
+ args.log(`↪ resolved @${tokens[0]} to the latest version ${basename(newestOfFamily)}`);
88
+ path = newestOfFamily;
89
+ }
90
+ else {
91
+ args.log(`--resume: '${tokens[0]}' is ambiguous — multiple matches:`);
92
+ args.log(formatProjectFileList(resolved.matches, ''));
93
+ args.log('Use a more specific @<fragment> to disambiguate.');
94
+ return null;
95
+ }
96
+ }
97
+ let envelope;
98
+ try {
99
+ envelope = readProjectFile(path);
100
+ }
101
+ catch (e) {
102
+ args.log(`--resume: ${e instanceof Error ? e.message : String(e)}`);
103
+ return null;
104
+ }
105
+ // 2026-06-06 (live-smoke UX): users should never have to remember which
106
+ // vN file is current — resuming a stale version re-runs already-validated
107
+ // phases. If a newer version of the SAME envelope (matching id) exists
108
+ // next to the picked file, silently redirect to it with a note.
109
+ const newest = findNewestVersion(path, envelope);
110
+ if (newest) {
111
+ args.log(`↪ newer version found — resuming ${basename(newest.path)} (you picked ${basename(path)})`);
112
+ path = newest.path;
113
+ envelope = newest.envelope;
114
+ }
115
+ if (envelope.artefactType !== 'plan') {
116
+ args.log(`--resume expects a plan envelope; got ${envelope.artefactType}.`);
117
+ args.log(`For ${envelope.artefactType} files use --status / --from instead.`);
118
+ return null;
119
+ }
120
+ // Defense-in-depth: top-level validation passed in readProjectFile, but
121
+ // the content payload is only checked by the Zod schema.
122
+ const pc = parsePlanContent(envelope.content);
123
+ if (!pc.ok) {
124
+ args.log('--resume: plan content failed validation — fix the file before resuming:');
125
+ for (const e of pc.errors)
126
+ args.log(` ${e}`);
127
+ return null;
128
+ }
129
+ const content = pc.content;
130
+ const statuses = statusesFromItems(envelope.interaction.items, content.phases);
131
+ const next = computeNextPhase(content.phases, statuses);
132
+ args.log('');
133
+ args.log(renderPlanTracker({
134
+ title: envelope.title,
135
+ version: envelope.version,
136
+ content,
137
+ statuses,
138
+ currentId: next?.id ?? null,
139
+ }));
140
+ args.log('');
141
+ if (!next) {
142
+ // C1 (D30 waiver): 'validated-with-waiver' is satisfied — a plan whose
143
+ // last gate was explicitly waived is complete, not stuck.
144
+ const allValidated = content.phases.every((p) => isPhaseSatisfied(statuses[p.id]));
145
+ if (allValidated) {
146
+ // C2 (D24): a finished UI-less backend stack chains to /abap-fiori-build
147
+ // with the exact binding name — the marketed idea→app story must not
148
+ // silently end at the service binding.
149
+ args.log(...planCompletionLines(content.phases));
150
+ }
151
+ else {
152
+ const blocked = content.phases.filter((p) => statuses[p.id] === 'blocked').map((p) => p.id);
153
+ args.log(`No eligible phase. Blocked: ${blocked.join(', ') || '(none)'} — remaining phases wait on them.`);
154
+ args.log('Unblock (fix + edit the envelope status back to todo) and resume again.');
155
+ }
156
+ return null;
157
+ }
158
+ // Inline the phase's declared rule files (coarse v1: whole files).
159
+ // Paths resolve against cwd — dogfood runs sit inside a repo carrying
160
+ // .claude/rules/. A missing file is noted, not fatal: the skill falls
161
+ // back to its built-in Forge defaults.
162
+ const ruleBlocks = [];
163
+ for (const rel of next.manifest.rules) {
164
+ try {
165
+ const text = readFileSync(join(args.cwd, rel), 'utf8');
166
+ ruleBlocks.push(`<rule file="${rel}">\n${text}\n</rule>`);
167
+ }
168
+ catch {
169
+ ruleBlocks.push(`<rule file="${rel}" missing="true"/> <!-- not found locally — apply built-in Forge defaults -->`);
170
+ }
171
+ }
172
+ const planState = JSON.stringify({ title: envelope.title, version: envelope.version, statuses, content }, null, 2);
173
+ // A2 (defects D23/D30) — compact write-back: the model re-emitting the
174
+ // full plan content every phase cost ~6–10k output tokens/phase at Opus
175
+ // pricing for data the envelope already holds. The resume turn emits
176
+ // statuses + a `changed` entry for the executed phase only; extractPlan
177
+ // merges it into the prior content. The example is built with the REAL
178
+ // phase ids and current statuses so the model copies, not reconstructs.
179
+ const compactExample = [
180
+ '<!-- csforge:plan-manifest',
181
+ JSON.stringify({
182
+ title: envelope.title,
183
+ statuses: { ...statuses, [next.id]: '<validated | validated-with-waiver | blocked>' },
184
+ changed: {
185
+ [next.id]: {
186
+ work: {
187
+ generated: ['<object names created/changed>'],
188
+ transport: '<transport number — omit the key if none>',
189
+ // C2 (D24): the service phase records the published SRVB name in
190
+ // work.binding — the plan-complete message hands it to
191
+ // /abap-fiori-build. Only shown when this turn IS the service phase.
192
+ ...(next.layer === 'service'
193
+ ? { binding: '<published service binding name, e.g. ZUI_MAINTREQ_O4>' }
194
+ : {}),
195
+ notes: '<decisions, substitutions, blocker details worth keeping — omit if none>',
196
+ },
197
+ },
198
+ },
199
+ }, null, 2),
200
+ '-->',
201
+ ].join('\n');
202
+ const llmPrompt = [
203
+ `Resume execution of the project plan "${envelope.title}" (envelope v${envelope.version}).`,
204
+ '',
205
+ `Execute Mode 2 of the abap-plan skill for phase "${next.id}" ONLY — it is the computed next eligible phase. Never execute any other phase in this turn; the CLI itself offers and dispatches the next phase (in a fresh bounded context) after this one is persisted.`,
206
+ '',
207
+ '<plan_state>',
208
+ planState,
209
+ '</plan_state>',
210
+ '',
211
+ '<phase_rules>',
212
+ ...ruleBlocks,
213
+ '</phase_rules>',
214
+ '',
215
+ // A1 (2026-06-10, defect D23): the old contract let the model ask a
216
+ // continuation question and keep executing phases in-turn — a 6-phase
217
+ // run snowballed to a 3.24M-token context because the turn never
218
+ // ended. The manifest now ENDS the turn; the harness owns continuation
219
+ // (offerNextPhaseAutoRun) and dispatches the next phase itself with
220
+ // the exact saved path, in reset context.
221
+ 'CRITICAL — the write-back ENDS the turn: emit ONE COMPACT <!-- csforge:plan-manifest --> block as the LAST thing in your output, then END THE TURN. Compact shape = "title" + a "statuses" entry for EVERY phase + a "changed" map carrying ONLY the phase(s) you touched this turn (normally exactly this one). Each "changed" entry holds the phase\'s COMPLETE updated "work" (generated / transport / snapshot / notes — put the compact decision register in work.notes when the phase produced decisions rather than SAP objects); it replaces that phase\'s prior work wholesale. Do NOT re-emit "content" or the full phase list — the CLI already holds the full plan, merges your "changed" entries into it, and recomputes the summary. Exact shape for this turn:',
222
+ '',
223
+ compactExample,
224
+ '',
225
+ // C1 (D30 waiver) — the waiver status exists so an explicitly-waived exit
226
+ // gate is recorded as what it is, instead of being laundered into a
227
+ // "validated" the gate never earned (the c1.test AUnit-gap incident).
228
+ 'Status rules: "validated" ONLY when the exit gate actually held and was verified. If the gate could NOT be met but the user EXPLICITLY waived it this turn (e.g. "skip the test gate, continue anyway"), use "validated-with-waiver" and record what was waived and why in work.notes — never mark an unmet gate "validated", and never use the waiver status without an explicit user waiver. Otherwise the phase is "blocked".',
229
+ '',
230
+ 'Only if the plan itself must change structurally (a phase added, removed, or re-sequenced) fall back to the full shape with "content" — the CLI accepts both. The manifest block is how the result is persisted; a turn that ends without it LOSES the phase.',
231
+ '',
232
+ PLAN_RESUME_HARNESS_OVERRIDE,
233
+ ].join('\n');
234
+ return { path, envelope, statuses, nextPhaseId: next.id, nextDelegateTo: next.delegateTo, llmPrompt };
235
+ }
236
+ /**
237
+ * Given a set of candidate file paths (e.g. the matches from an ambiguous
238
+ * @token resolution), return the newest version IF they are all versions of
239
+ * ONE plan family (`<base>-v<N>[-<sub>].cspeach.json`), else null. Lets a
240
+ * version-less resume token (`@<base>` or a short id matching every version)
241
+ * resolve to the current file instead of erroring "ambiguous". Pure —
242
+ * deterministic ordering, no fs / mtime.
243
+ */
244
+ export function pickNewestPlanVersion(paths) {
245
+ // Sibling: findNewestVersion (below) uses the same filename convention with a
246
+ // different capture shape (prefix-based + mtime) for the post-read redirect.
247
+ const re = /^(.+)-v(\d+)(?:-(\d+))?\.cspeach\.json$/;
248
+ const parsed = paths.map((p) => {
249
+ const m = re.exec(basename(p));
250
+ return m ? { path: p, base: m[1], version: Number(m[2]), sub: m[3] ? Number(m[3]) : 0 } : null;
251
+ });
252
+ if (parsed.length === 0 || parsed.some((x) => x === null))
253
+ return null;
254
+ const items = parsed;
255
+ const base0 = items[0].base;
256
+ if (items.some((x) => x.base !== base0))
257
+ return null;
258
+ items.sort((a, b) =>
259
+ // Tiebreak: lexically-greater path wins — deterministic, arbitrary, never
260
+ // reached in practice (version+sub are always distinct siblings from saveProject).
261
+ b.version - a.version || b.sub - a.sub || (a.path < b.path ? 1 : -1));
262
+ return items[0].path;
263
+ }
264
+ /**
265
+ * True when a queued dispatch command is a `/abap-plan --resume …`.
266
+ * Consumer: dispatch_skill REJECTS model-authored plan-resume dispatches
267
+ * (A1 — the harness owns that command; the model once hallucinated a wrong
268
+ * base token). The transcript clear for resume turns is owned solely by
269
+ * resetContextForPlanResume, called from the REPL's plan-resume prepare
270
+ * branch after preparePlanResume succeeds.
271
+ */
272
+ export function isPlanResumeCommand(cmd) {
273
+ const c = cmd.trim();
274
+ return /^\/abap-plan\b/.test(c) && /(^|\s)--resume(\s|$)/.test(c);
275
+ }
276
+ /**
277
+ * A1 (defect D23) — the HARNESS builds the next-phase resume command from
278
+ * the exact path finishPlanResume just saved. Single construction site:
279
+ * the model never authors this text (it once invented `@…-plan-c1` when
280
+ * the real file was `…-plan-c9b3-v14.cspeach.json`). The basename keeps
281
+ * the version suffix — it resolves to that exact file, and the
282
+ * newest-version redirect in preparePlanResume still protects against a
283
+ * concurrent later save.
284
+ */
285
+ export function buildResumeCommand(savedPath) {
286
+ return `/abap-plan --resume @${basename(savedPath)}`;
287
+ }
288
+ /**
289
+ * A1 — every plan-resume turn starts from RESET session messages: the
290
+ * envelope + the bounded preparePlanResume prompt carry all needed state,
291
+ * so prior-phase (or prior-chat) transcript is pure cost. Called by the
292
+ * REPL right after preparePlanResume succeeds, which covers BOTH the
293
+ * harness-dispatched auto-run path and a manually typed `--resume`.
294
+ * Returns true when messages were actually cleared (caller logs a notice).
295
+ * Property reassignment (not splice) — session.messages consumers always
296
+ * re-read the property.
297
+ */
298
+ export function resetContextForPlanResume(session) {
299
+ if (session.messages.length === 0)
300
+ return false;
301
+ session.messages = [];
302
+ return true;
303
+ }
304
+ export async function offerNextPhaseAutoRun(args) {
305
+ if (!args.outcome.nextPhaseId)
306
+ return false;
307
+ const delegate = args.outcome.nextDelegateTo ? ` (${args.outcome.nextDelegateTo})` : '';
308
+ const answer = (await args.prompt(`Run next phase ${args.outcome.nextPhaseId}${delegate} now in a fresh context? [y/N]: `)).trim().toLowerCase();
309
+ if (answer !== 'y' && answer !== 'yes') {
310
+ args.log(`Exiting — resume later with: ${args.outcome.resumeCommand}`);
311
+ return false;
312
+ }
313
+ args.queueDispatch(args.outcome.resumeCommand);
314
+ return true;
315
+ }
316
+ /**
317
+ * Find the newest sibling version of the envelope at `path` — same
318
+ * filename family (`<slug>-<shortid>-vN[...]`) AND same envelope id (the
319
+ * filename check is just a cheap pre-filter; slug collisions are settled
320
+ * by the id). Returns null when `path` is already the newest.
321
+ */
322
+ export function findNewestVersion(path, picked) {
323
+ const fam = /^(.*-v)(\d+)(?:-\d+)?\.cspeach\.json$/.exec(basename(path));
324
+ if (!fam)
325
+ return null;
326
+ const familyPrefix = fam[1];
327
+ const dir = dirname(path);
328
+ let candidates;
329
+ try {
330
+ candidates = readdirSync(dir)
331
+ .map((name) => {
332
+ const m = new RegExp(`^${familyPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\d+)(?:-\\d+)?\\.cspeach\\.json$`).exec(name);
333
+ if (!m)
334
+ return null;
335
+ const full = join(dir, name);
336
+ return { full, version: Number(m[1]), mtimeMs: statSync(full).mtimeMs };
337
+ })
338
+ .filter((c) => c !== null)
339
+ .sort((a, b) => b.version - a.version || b.mtimeMs - a.mtimeMs);
340
+ }
341
+ catch {
342
+ return null;
343
+ }
344
+ for (const c of candidates) {
345
+ if (c.full === path)
346
+ break; // picked file is already the newest valid one
347
+ try {
348
+ const env = readProjectFile(c.full);
349
+ if (env.artefactType === 'plan' && env.id === picked.id && env.version > picked.version) {
350
+ return { path: c.full, envelope: env };
351
+ }
352
+ }
353
+ catch {
354
+ // Unreadable sibling — skip, keep looking.
355
+ }
356
+ }
357
+ return null;
358
+ }
359
+ export async function finishPlanResume(args) {
360
+ const text = args.textOverride ?? collectTurnAssistantText(args.messages, args.messagesStart);
361
+ if (text.trim().length === 0) {
362
+ args.log('');
363
+ args.log('[plan] turn produced no assistant output — plan envelope UNCHANGED.');
364
+ args.log(`[plan] re-run: /abap-plan --resume @${basename(args.prepared.path)}`);
365
+ return null;
366
+ }
367
+ let extract;
368
+ try {
369
+ // A2 — pass the prior content so a COMPACT manifest (statuses + changed
370
+ // map) can be merged into it. prepared.envelope.content already passed
371
+ // parsePlanContent in preparePlanResume; re-parsing here hands extractPlan
372
+ // the normalised PlanContent without widening PreparedPlanResume. If the
373
+ // parse somehow fails, prior stays undefined and a compact manifest fails
374
+ // loudly below (full manifests are unaffected).
375
+ const prior = parsePlanContent(args.prepared.envelope.content);
376
+ extract = extractPlan(text, prior.ok ? prior.content : undefined);
377
+ }
378
+ catch (e) {
379
+ // Loud by design: the phase may have built real SAP objects, but the
380
+ // envelope write-back failed — the user must know state and file have
381
+ // diverged before the next resume.
382
+ args.log('');
383
+ args.log(`[plan] PHASE RESULT NOT PERSISTED — ${e instanceof Error ? e.message : String(e)}`);
384
+ args.log('[plan] The plan envelope is unchanged. Check what the phase actually built');
385
+ args.log('[plan] (transport, activated objects), update the envelope statuses by hand or');
386
+ args.log(`[plan] re-run: /abap-plan --resume @${basename(args.prepared.path)}`);
387
+ return null;
388
+ }
389
+ const revision = buildPlanRevision(args.prepared.envelope, extract, getAuthorIdentity(), new Date().toISOString());
390
+ // JSON round-trip simulates disk serialization for the validator.
391
+ const check = validateEnvelope(JSON.parse(JSON.stringify(revision)));
392
+ if (!check.ok) {
393
+ args.log('');
394
+ args.log(`[plan] PHASE RESULT NOT PERSISTED — revision failed validation: ${check.error.message}`);
395
+ return null;
396
+ }
397
+ let outDir;
398
+ try {
399
+ outDir = await ensureWorkspace();
400
+ }
401
+ catch {
402
+ outDir = process.cwd();
403
+ }
404
+ const savedPath = await saveProject(revision, { cwd: outDir });
405
+ const statuses = extract.statuses;
406
+ const next = computeNextPhase(extract.content.phases, statuses);
407
+ args.log('');
408
+ args.log(`Plan updated: ${savedPath}`);
409
+ args.log('');
410
+ args.log(renderPlanTracker({
411
+ title: revision.title,
412
+ version: revision.version,
413
+ content: extract.content,
414
+ statuses,
415
+ currentId: null,
416
+ }));
417
+ args.log('');
418
+ if (next) {
419
+ args.log(`Next: ${next.id} (${next.delegateTo}) — run /abap-plan --resume @${basename(savedPath)} in a fresh session.`);
420
+ }
421
+ else if (extract.content.phases.every((p) => isPhaseSatisfied(statuses[p.id]))) {
422
+ // C2 (D24): same chain as preparePlanResume — single source in plan-run.ts.
423
+ args.log(...planCompletionLines(extract.content.phases));
424
+ }
425
+ else {
426
+ const blocked = extract.content.phases.filter((p) => statuses[p.id] === 'blocked').map((p) => p.id);
427
+ args.log(`No eligible next phase. Blocked: ${blocked.join(', ')}. Unblock, then resume again.`);
428
+ }
429
+ return {
430
+ savedPath,
431
+ resumeCommand: buildResumeCommand(savedPath),
432
+ nextPhaseId: next?.id ?? null,
433
+ nextDelegateTo: next?.delegateTo ?? null,
434
+ };
435
+ }
@@ -11,7 +11,7 @@ export function isValidWriteMode(v) {
11
11
  }
12
12
  const DEFAULT_CONFIG = {
13
13
  proxy_url: 'https://api.cspeach.dev',
14
- default_model: 'claude-opus-4-7',
14
+ default_model: 'claude-opus-4-8',
15
15
  effort: 'xhigh',
16
16
  telemetry: 'minimal',
17
17
  sap: {},
@@ -26,6 +26,13 @@ const DEFAULT_CONFIG = {
26
26
  write_mode: 'approval-gated',
27
27
  // Default `managed` — lowest-friction entry point; uses the cspeach.dev proxy.
28
28
  llm: { mode: 'managed' },
29
+ // local_build is intentionally NOT defaulted here. If it were set to `false`,
30
+ // the `...DEFAULT_CONFIG` spread would leave merged.local_build defined even
31
+ // when the file omits the key — which permanently defeats resolveLocalBuild's
32
+ // `local_build ?? local_files` fallback (the legacy `local_files = on` case
33
+ // could never fire). Absence must stay `undefined` so the fallback can read
34
+ // the legacy key. The effective default-off is enforced by resolveLocalBuild
35
+ // (returns false when both are undefined). See loadConfig coercion below.
29
36
  // Defaults tuned 2026-05-16 from session 0618a42d evidence — see CompactConfig doc.
30
37
  compact: {
31
38
  keep_recent_turns: 5,
@@ -62,10 +69,34 @@ function sanitiseCompact(raw) {
62
69
  min_turns_between_auto: intInRange(r.min_turns_between_auto, d.min_turns_between_auto, 1, 100),
63
70
  };
64
71
  }
72
+ /**
73
+ * Resolve the effective TLS options for a SAP system, applying the legacy
74
+ * `sslVerify: false` → insecure mapping. A configured `ca_cert_path` always
75
+ * wins (verification on). Pure — no I/O. Shared by the connection-manager and
76
+ * doctor checks so they agree on the posture.
77
+ */
78
+ export function resolveSapTls(sys) {
79
+ if (sys.ca_cert_path) {
80
+ return { mode: 'ca-trusted', caCertPath: sys.ca_cert_path, insecureSkipTlsVerify: false };
81
+ }
82
+ // Explicit insecure opt-in, OR the legacy sslVerify:false mapping.
83
+ const insecure = sys.insecure_skip_tls_verify === true || sys.sslVerify === false;
84
+ if (insecure) {
85
+ return { mode: 'insecure', insecureSkipTlsVerify: true };
86
+ }
87
+ return { mode: 'verify', insecureSkipTlsVerify: false };
88
+ }
89
+ /** Tracks whether the one-time legacy-sslVerify deprecation note has printed. */
90
+ let legacySslVerifyNoticeShown = false;
65
91
  export async function loadConfig() {
66
92
  try {
67
93
  const raw = await fs.readFile(configFile(), 'utf-8');
68
- const parsed = toml.parse(raw);
94
+ // Strip a leading UTF-8 BOM (U+FEFF). Node's 'utf-8' read does NOT remove it,
95
+ // and @iarna/toml throws "Unknown character 65279" on a BOM at row 1 col 1.
96
+ // PowerShell's Set-Content / Out-File add a BOM by default, so a config edited
97
+ // on Windows can crash the REPL on next launch — tolerate it instead.
98
+ const noBom = raw.charCodeAt(0) === 0xFEFF ? raw.slice(1) : raw;
99
+ const parsed = toml.parse(noBom);
69
100
  // Shallow merge top-level, but deep-merge `ui` and `classifier` so a user
70
101
  // config that omits the new `ui.rendering` key still receives the default.
71
102
  // Sanitize the optional shell_exec.allow array. The interface types it as
@@ -82,9 +113,27 @@ export async function loadConfig() {
82
113
  else {
83
114
  allow = [];
84
115
  }
85
- return {
116
+ const merged = {
86
117
  ...DEFAULT_CONFIG,
87
118
  ...parsed,
119
+ // Coerce defensively, but PRESERVE the absent→undefined distinction so the
120
+ // legacy fallback in resolveLocalBuild can fire:
121
+ // - explicit `true` → true
122
+ // - explicit `false`/off → false (defined; overrides legacy)
123
+ // - present-but-invalid (string) → false (defined; fails safe, can't
124
+ // grant access via a typo, but DOES override a stale legacy on)
125
+ // - absent / undefined → undefined (legacy fallback applies)
126
+ // A defined `false` (whether explicit or typo) must win the
127
+ // `local_build ?? local_files` resolution so an explicit off is honoured.
128
+ local_build: parsed.local_build === true
129
+ ? true
130
+ : parsed.local_build === undefined
131
+ ? undefined
132
+ : false,
133
+ // Preserve a legacy `local_files` value (read-only backward compat) ONLY
134
+ // when it's strictly true — a string/typo collapses to "absent" so it
135
+ // can't win the `local_build ?? local_files` fallback.
136
+ local_files: parsed.local_files === true ? true : undefined,
88
137
  write_mode: isValidWriteMode(parsed.write_mode) ? parsed.write_mode : DEFAULT_CONFIG.write_mode,
89
138
  llm: { ...DEFAULT_CONFIG.llm, ...(parsed.llm ?? {}) },
90
139
  classifier: { ...DEFAULT_CONFIG.classifier, ...(parsed.classifier ?? {}) },
@@ -95,6 +144,37 @@ export async function loadConfig() {
95
144
  compact: sanitiseCompact(parsed.compact),
96
145
  shell_exec: parsed.shell_exec === undefined ? undefined : { allow },
97
146
  };
147
+ // Drop the legacy `local_files` key entirely when it isn't a genuine on
148
+ // (the `...parsed` spread above can leave a `local_files = false` behind,
149
+ // and our explicit coercion sets it to `undefined`). Removing the key keeps
150
+ // `config show` and saveConfig output clean — no `local_files = undefined`.
151
+ if (merged.local_files === undefined)
152
+ delete merged.local_files;
153
+ // Same for `local_build`: when the key is absent from disk we coerce it to
154
+ // `undefined` (so resolveLocalBuild's legacy fallback can fire). Delete the
155
+ // key so it doesn't serialise as `local_build = undefined` and so callers
156
+ // see a clean "absent" shape. resolveLocalBuild treats missing === undefined.
157
+ if (merged.local_build === undefined)
158
+ delete merged.local_build;
159
+ // One-time deprecation note: a SAP system still relies on the legacy
160
+ // `sslVerify` toggle and has not adopted either new key. It KEEPS WORKING
161
+ // (resolveSapTls maps sslVerify:false → insecure), but we nudge the user
162
+ // once per process toward `ca_cert_path` (preferred) or the explicit
163
+ // insecure flag. Only fires when sslVerify is present AND no new key is set.
164
+ if (!legacySslVerifyNoticeShown) {
165
+ const legacyAlias = Object.entries(merged.sap ?? {}).find(([, s]) => typeof s?.sslVerify === 'boolean' &&
166
+ s.ca_cert_path === undefined &&
167
+ s.insecure_skip_tls_verify === undefined);
168
+ if (legacyAlias) {
169
+ legacySslVerifyNoticeShown = true;
170
+ const [alias, sys] = legacyAlias;
171
+ const suggestion = sys.sslVerify === false
172
+ ? `set 'ca_cert_path' to your corporate CA (keeps verification ON), or set 'insecure_skip_tls_verify = true' to keep verification off (dev only, logs a warning)`
173
+ : `the default is now verify-ON; you can drop 'sslVerify' or set 'ca_cert_path' for internal-CA systems`;
174
+ process.stderr.write(`[cspeach] note: [sap.${alias}] uses the deprecated 'sslVerify' key — ${suggestion}.\n`);
175
+ }
176
+ }
177
+ return merged;
98
178
  }
99
179
  catch (err) {
100
180
  if (err?.code === 'ENOENT')
@@ -102,6 +182,25 @@ export async function loadConfig() {
102
182
  throw err;
103
183
  }
104
184
  }
185
+ /**
186
+ * Resolve the effective "local build tools" toggle from a loaded config.
187
+ *
188
+ * Backward compat (2026-06-13 rename): the new key is `local_build`; the legacy
189
+ * key is `local_files`. A user who set `local_files = on` before the rename
190
+ * must keep working without re-running `config set`. The rule is:
191
+ *
192
+ * effective = local_build ?? local_files (default false)
193
+ *
194
+ * `local_build` always wins when present (so `config set local_build off`
195
+ * overrides a stale legacy `local_files = on`). Only when `local_build` is
196
+ * undefined does the legacy value apply. Both keys are already strictly coerced
197
+ * to `true | undefined` by loadConfig, so this is a pure read.
198
+ */
199
+ export function resolveLocalBuild(cfg) {
200
+ if (cfg.local_build !== undefined)
201
+ return cfg.local_build === true;
202
+ return cfg.local_files === true;
203
+ }
105
204
  export async function saveConfig(config) {
106
205
  await fs.mkdir(cspeachRoot(), { recursive: true });
107
206
  await fs.writeFile(configFile(), toml.stringify(config), 'utf-8');