@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.
- package/README.md +1 -1
- package/dist/agent/intent-system-prompt.js +1 -1
- package/dist/agent/loop.js +330 -36
- package/dist/agent/providers/license-gate.js +44 -0
- package/dist/agent/skill-checkpoint.js +1 -1
- package/dist/agent/steering-queue.js +27 -0
- package/dist/agent/tool-dispatch.js +15 -0
- package/dist/approvals/canonical.js +91 -0
- package/dist/approvals/jwt.js +84 -7
- package/dist/approvals/render.js +38 -0
- package/dist/auth/org-anthropic-key.js +25 -0
- package/dist/classifier/client.js +24 -5
- package/dist/commands/config-set.js +95 -0
- package/dist/commands/login.js +31 -14
- package/dist/commands/plan-model-tier.js +83 -0
- package/dist/commands/plan-resume.js +435 -0
- package/dist/config/loader.js +102 -3
- package/dist/cost/pricing.js +14 -5
- 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/zcspeach.js +19 -4
- package/dist/one-shot.js +52 -4
- package/dist/projects/answer-blockers.js +137 -0
- package/dist/projects/email-template.js +2 -0
- package/dist/projects/extract-cca.js +108 -16
- package/dist/projects/extract-modernize.js +1 -1
- package/dist/projects/extract-plan.js +178 -0
- package/dist/projects/extract-spec-gap.js +34 -7
- package/dist/projects/extract-test-coverage.js +1 -1
- package/dist/projects/extract-upgrade.js +113 -22
- package/dist/projects/index.js +7 -1
- 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 +254 -0
- package/dist/projects/plan-schema.js +210 -0
- package/dist/projects/promote-command.js +26 -2
- package/dist/projects/promote.js +128 -0
- package/dist/projects/save-command.js +263 -20
- package/dist/projects/status.js +22 -0
- package/dist/projects/validate.js +2 -0
- 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/syntax.js +16 -1
- package/dist/renderer/thinking-heartbeat.js +13 -1
- package/dist/renderer/tool-widget.js +18 -4
- package/dist/renderer/tty.js +43 -4
- package/dist/renderer/verify-chain.js +77 -0
- package/dist/repl/at-picker.js +93 -21
- package/dist/repl/builtin-commands.js +37 -0
- package/dist/repl/early-line-buffer.js +68 -0
- package/dist/repl/inquirer-guard.js +70 -5
- package/dist/repl/numbered-menu.js +131 -0
- package/dist/repl/post-turn-status.js +2 -2
- 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-picker.js +25 -19
- package/dist/repl.js +470 -22
- 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/pending.js +27 -0
- package/dist/skill-catalog.js +54 -43
- package/dist/skills/bundled-skills.js +279 -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 +64 -21
- package/dist/tools/ask-question.js +96 -4
- package/dist/tools/capability/tool.js +74 -0
- package/dist/tools/dispatch-skill.js +22 -1
- package/dist/tools/extend-model/anchored-insert.js +810 -0
- package/dist/tools/extend-model/tool.js +188 -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-scaffold.js +45 -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/scaffold.js +39 -0
- package/dist/tools/fiori/tools.js +356 -0
- package/dist/tools/fiori/types.js +1 -0
- package/dist/tools/local-build.js +76 -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 +53 -9
- package/dist/tools/sap-write.js +530 -21
- package/dist/tools/shell/shell_exec.js +41 -6
- package/dist/tools/snapshot.js +37 -14
- package/dist/tools/subagent/background_run.js +17 -1
- 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 +84 -11
- package/dist/ui/body.js +13 -0
- package/dist/ui/command-palette.js +46 -10
- package/dist/ui/file-palette.js +44 -0
- package/dist/ui/footer.js +28 -11
- package/dist/ui/line-resolution.js +92 -0
- package/dist/ui/session-timeline.js +1 -0
- package/dist/ui/text-input.js +150 -0
- package/dist/ui/turn-status-emitter.js +52 -0
- package/dist/ui/turn-status.js +59 -0
- package/dist/ui/widgets/ask-question-modal.js +30 -2
- package/package.json +23 -4
- 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,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
|
+
}
|
package/dist/config/loader.js
CHANGED
|
@@ -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-
|
|
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
|
-
|
|
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
|
-
|
|
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');
|