@cspeach/cli 0.9.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/agent/intent-system-prompt.js +1 -1
- package/dist/agent/loop.js +228 -26
- package/dist/agent/providers/license-gate.js +44 -0
- package/dist/agent/skill-checkpoint.js +1 -1
- package/dist/agent/tool-dispatch.js +15 -0
- package/dist/approvals/canonical.js +91 -0
- package/dist/approvals/jwt.js +39 -2
- package/dist/approvals/op-labels.js +124 -0
- package/dist/approvals/render.js +42 -36
- package/dist/auth/org-anthropic-key.js +25 -0
- package/dist/classifier/client.js +18 -3
- package/dist/cli.js +15 -0
- package/dist/commands/compact.js +28 -2
- package/dist/commands/config-set.js +284 -0
- package/dist/commands/config-show.js +20 -0
- package/dist/commands/export-audit.js +43 -0
- package/dist/commands/help.js +5 -0
- package/dist/commands/login.js +31 -14
- package/dist/commands/plan-audit-evidence.js +266 -0
- package/dist/commands/plan-audit.js +692 -0
- package/dist/commands/plan-chain.js +671 -0
- package/dist/commands/plan-continue.js +179 -0
- package/dist/commands/plan-gate.js +154 -0
- package/dist/commands/plan-model-tier.js +83 -0
- package/dist/commands/plan-resume.js +728 -46
- package/dist/config/loader.js +223 -5
- package/dist/config/model-defaults.js +14 -0
- package/dist/cost/pricing.js +27 -1
- package/dist/doctor/checks/_http-probe.js +1 -0
- package/dist/doctor/checks/cert.js +14 -3
- package/dist/doctor/checks/sap.js +30 -8
- package/dist/doctor/checks/system-roles.js +41 -0
- package/dist/doctor/checks/zcspeach.js +19 -4
- package/dist/doctor/run.js +2 -0
- package/dist/models/resolve.js +61 -0
- package/dist/models/server-config.js +155 -0
- package/dist/one-shot.js +76 -6
- package/dist/projects/answer-blockers.js +137 -0
- package/dist/projects/extract-cca.js +111 -17
- package/dist/projects/extract-modernize.js +4 -2
- package/dist/projects/extract-plan.js +184 -37
- package/dist/projects/extract-spec-gap.js +34 -7
- package/dist/projects/extract-test-coverage.js +4 -2
- package/dist/projects/extract-upgrade.js +116 -23
- package/dist/projects/handover-md.js +195 -0
- package/dist/projects/index.js +5 -2
- package/dist/projects/merge-cca.js +292 -0
- package/dist/projects/merge-upgrade.js +173 -0
- package/dist/projects/migration.js +103 -1
- package/dist/projects/output-paths.js +27 -0
- package/dist/projects/plan-run.js +285 -27
- package/dist/projects/plan-schema.js +136 -3
- package/dist/projects/promote-command.js +25 -2
- package/dist/projects/promote.js +128 -0
- package/dist/projects/run-lease.js +157 -0
- package/dist/projects/save-command.js +259 -21
- package/dist/projects/status.js +3 -1
- package/dist/projects/validate.js +1 -1
- package/dist/projects/workspace.js +164 -20
- package/dist/renderer/notices.js +64 -0
- package/dist/renderer/progress-chatter.js +8 -0
- package/dist/renderer/status-footer.js +22 -12
- package/dist/renderer/thinking-heartbeat.js +64 -8
- package/dist/renderer/todo-block.js +51 -0
- package/dist/renderer/tool-widget.js +55 -4
- package/dist/renderer/tty.js +43 -4
- package/dist/renderer/verify-chain.js +77 -0
- package/dist/repl/at-picker.js +60 -7
- package/dist/repl/bracketed-paste.js +28 -19
- package/dist/repl/builtin-commands.js +42 -0
- package/dist/repl/current-transport.js +10 -0
- package/dist/repl/early-line-buffer.js +68 -0
- package/dist/repl/history.js +86 -0
- package/dist/repl/ink-stdin-guard.js +64 -0
- package/dist/repl/inquirer-guard.js +70 -5
- package/dist/repl/mode-ceiling.js +16 -0
- package/dist/repl/mode-cycle.js +104 -0
- package/dist/repl/numbered-menu.js +131 -0
- package/dist/repl/post-turn-status.js +26 -6
- package/dist/repl/rule8-detector.js +17 -2
- package/dist/repl/safety-confirm.js +111 -2
- package/dist/repl/safety-mode-state.js +19 -3
- package/dist/repl/slash-completer.js +5 -0
- package/dist/repl/slash-picker.js +10 -15
- package/dist/repl.js +1232 -95
- package/dist/rewind/candidates.js +194 -0
- package/dist/rewind/cli.js +137 -0
- package/dist/rewind/format.js +27 -0
- package/dist/rewind/restore.js +245 -0
- package/dist/router/classifier.js +150 -6
- package/dist/sap/capability-matrix.js +20 -0
- package/dist/sap/capability-matrix.json +11236 -0
- package/dist/sap/capability.js +146 -0
- package/dist/sap/connection-manager.js +19 -1
- package/dist/sap/onboarding.js +42 -4
- package/dist/session/audit-export.js +459 -0
- package/dist/session/context-report.js +163 -0
- package/dist/session/pending.js +27 -0
- package/dist/session/recap.js +160 -0
- package/dist/skill-catalog.js +51 -40
- package/dist/skills/bundled-skills.js +272 -1
- package/dist/skills/promotion-dispatch.js +23 -0
- package/dist/tools/_command-shared.js +36 -12
- package/dist/tools/_filesystem-shared.js +139 -4
- package/dist/tools/_flag.js +25 -0
- package/dist/tools/approval.js +177 -26
- package/dist/tools/ask-question.js +400 -7
- package/dist/tools/capability/tool.js +74 -0
- package/dist/tools/dispatch-skill.js +22 -1
- package/dist/tools/extend-model/anchored-insert.js +1414 -0
- package/dist/tools/extend-model/tool.js +340 -0
- package/dist/tools/filesystem/extract-document.js +57 -0
- package/dist/tools/filesystem/file-edit.js +12 -2
- package/dist/tools/filesystem/file-read.js +2 -2
- package/dist/tools/filesystem/file-write.js +11 -2
- package/dist/tools/filesystem/glob.js +11 -0
- package/dist/tools/filesystem/grep.js +10 -0
- package/dist/tools/filesystem/read-document.js +107 -0
- package/dist/tools/fiori/apply.js +50 -0
- package/dist/tools/fiori/bin.js +3 -0
- package/dist/tools/fiori/catalog/index.js +27 -0
- package/dist/tools/fiori/catalog/value-help.js +230 -0
- package/dist/tools/fiori/catalog/viz-chart.js +177 -0
- package/dist/tools/fiori/cli.js +71 -0
- package/dist/tools/fiori/deploy-config.js +73 -0
- package/dist/tools/fiori/fe-extend.js +76 -0
- package/dist/tools/fiori/fe-scaffold.js +71 -0
- package/dist/tools/fiori/floorplan-map.js +19 -0
- package/dist/tools/fiori/i18n.js +39 -0
- package/dist/tools/fiori/manifest.js +70 -0
- package/dist/tools/fiori/render.js +77 -0
- package/dist/tools/fiori/samples/data/index.json +13602 -0
- package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
- package/dist/tools/fiori/samples/loader.js +248 -0
- package/dist/tools/fiori/samples/search.js +63 -0
- package/dist/tools/fiori/samples/types.js +2 -0
- package/dist/tools/fiori/scaffold.js +39 -0
- package/dist/tools/fiori/smoke/assertions.js +74 -0
- package/dist/tools/fiori/smoke/browser.js +52 -0
- package/dist/tools/fiori/smoke/driver.js +89 -0
- package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
- package/dist/tools/fiori/smoke/run-smoke.js +149 -0
- package/dist/tools/fiori/tools.js +681 -0
- package/dist/tools/fiori/types.js +1 -0
- package/dist/tools/local-build.js +86 -0
- package/dist/tools/local-files.js +31 -0
- package/dist/tools/project/_merge-shared.js +68 -0
- package/dist/tools/project/cca_merge.js +164 -0
- package/dist/tools/project/playbook_get.js +1 -1
- package/dist/tools/project/upgrade_merge_progress.js +206 -0
- package/dist/tools/sap-read.js +132 -20
- package/dist/tools/sap-write.js +550 -21
- package/dist/tools/shell/shell_exec.js +41 -6
- package/dist/tools/snapshot.js +63 -14
- package/dist/tools/subagent/agent_run.js +27 -3
- package/dist/tools/subagent/background_run.js +17 -1
- package/dist/tools/todo.js +144 -0
- package/dist/tools/transport-resolution.js +86 -0
- package/dist/tools/transport.js +224 -5
- package/dist/tools/write-mode.js +4 -0
- package/dist/ui/app.js +378 -21
- package/dist/ui/approval-modal.js +49 -16
- package/dist/ui/ask-question-emitter.js +14 -0
- package/dist/ui/body.js +13 -0
- package/dist/ui/context-grid.js +108 -0
- package/dist/ui/footer.js +120 -27
- package/dist/ui/header.js +7 -0
- package/dist/ui/line-resolution.js +35 -8
- package/dist/ui/rewind-emitter.js +10 -0
- package/dist/ui/rewind-panel.js +81 -0
- package/dist/ui/sap-state-store.js +1 -0
- package/dist/ui/session-timeline.js +1 -0
- package/dist/ui/status-line.js +43 -0
- package/dist/ui/text-input.js +214 -0
- package/dist/ui/todo-emitter.js +25 -0
- package/dist/ui/todo-panel.js +64 -0
- package/dist/ui/turn-status-emitter.js +50 -4
- package/dist/ui/turn-status.js +18 -3
- package/dist/ui/widgets/ask-form.js +242 -0
- package/dist/ui/widgets/ask-question-modal.js +21 -8
- package/package.json +22 -3
- package/bench/README.md +0 -78
- package/bench/prompts/abap-document-cds.md +0 -44
- package/bench/prompts/abap-explain-bdef-handler.md +0 -57
- package/bench/prompts/abap-test-method.md +0 -42
- package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
- package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
- package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
- package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
- package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
- package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
- package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
- package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
- package/bench/results/abap-test-method/claude-sonnet-4-6.md +0 -234
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// model-governance step 2d (2026-07-10) — the CLI-side reader for the served
|
|
2
|
+
// model-governance config.
|
|
3
|
+
//
|
|
4
|
+
// The proxy serves `GET {proxy_url}/v1/model-config` (Task 3):
|
|
5
|
+
// { roles: { session_default, audit, compact, router },
|
|
6
|
+
// pricing: Record<model, { inputPer1M, outputPer1M, cacheReadPer1M, cacheCreatePer1M? }>,
|
|
7
|
+
// updated_at }
|
|
8
|
+
// top-level keys snake_case, pricing entries camelCase, Cache-Control 60s, no auth.
|
|
9
|
+
//
|
|
10
|
+
// This module fetches that config ONCE at startup (fire-and-forget from repl.tsx
|
|
11
|
+
// / one-shot.ts) and holds it in a synchronous module snapshot that
|
|
12
|
+
// resolveModelRole (resolve.ts) reads. Precedence-wise the served config sits
|
|
13
|
+
// BELOW env + local config and ABOVE the built-in constants — so nothing set ⇒
|
|
14
|
+
// built-ins ⇒ byte-identical to before this feature.
|
|
15
|
+
//
|
|
16
|
+
// Fail-safe contract (locked):
|
|
17
|
+
// - managed mode ONLY — non-managed modes (byok/local/ai-hub) never fetch.
|
|
18
|
+
// - startup is NEVER blocked on the fetch: a turn that starts before the fetch
|
|
19
|
+
// lands simply uses the built-ins.
|
|
20
|
+
// - on ANY failure (timeout, network, non-2xx, malformed) fall back to a disk
|
|
21
|
+
// cache written by the last good fetch, but only if it is < 24h old; else
|
|
22
|
+
// leave the snapshot null (built-ins win).
|
|
23
|
+
// - loadServerModelConfig NEVER throws.
|
|
24
|
+
import fs from 'node:fs/promises';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import { cspeachRoot } from '../config/paths.js';
|
|
27
|
+
// ── Synchronous module snapshot ───────────────────────────────────────────────
|
|
28
|
+
let snapshot = null;
|
|
29
|
+
/** The current served-config snapshot, or null (⇒ built-ins win). Pure sync read. */
|
|
30
|
+
export function getServerModelConfig() {
|
|
31
|
+
return snapshot;
|
|
32
|
+
}
|
|
33
|
+
/** Test-only setter for the module snapshot. */
|
|
34
|
+
export function __setServerModelConfigForTests(c) {
|
|
35
|
+
snapshot = c;
|
|
36
|
+
}
|
|
37
|
+
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
38
|
+
const FETCH_TIMEOUT_MS = 5_000;
|
|
39
|
+
const CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24h
|
|
40
|
+
function cacheFilePath() {
|
|
41
|
+
return path.join(cspeachRoot(), 'model-config.json');
|
|
42
|
+
}
|
|
43
|
+
const ROLE_KEYS = ['session_default', 'audit', 'compact', 'router'];
|
|
44
|
+
// ── Coercion ──────────────────────────────────────────────────────────────────
|
|
45
|
+
/** Keep only string, non-empty role entries; unknown keys are dropped. */
|
|
46
|
+
function coerceRoles(raw) {
|
|
47
|
+
const out = {};
|
|
48
|
+
if (!raw || typeof raw !== 'object')
|
|
49
|
+
return out;
|
|
50
|
+
const r = raw;
|
|
51
|
+
for (const k of ROLE_KEYS) {
|
|
52
|
+
const v = r[k];
|
|
53
|
+
if (typeof v === 'string' && v.trim().length > 0)
|
|
54
|
+
out[k] = v.trim();
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
function coercePricing(raw) {
|
|
59
|
+
return raw && typeof raw === 'object' && !Array.isArray(raw)
|
|
60
|
+
? raw
|
|
61
|
+
: {};
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Map a served (or cached) document to a ServerModelConfig, or null when the
|
|
65
|
+
* shape is unusable. `fetchedAt`, when provided, is preserved (disk-cache read);
|
|
66
|
+
* otherwise a fresh client timestamp is stamped (live fetch). A document with
|
|
67
|
+
* NO recognised role is treated as unusable (null) — there is nothing to serve.
|
|
68
|
+
*/
|
|
69
|
+
function coerceDocument(body, fetchedAt) {
|
|
70
|
+
if (!body || typeof body !== 'object')
|
|
71
|
+
return null;
|
|
72
|
+
const b = body;
|
|
73
|
+
const roles = coerceRoles(b.roles);
|
|
74
|
+
if (Object.keys(roles).length === 0)
|
|
75
|
+
return null;
|
|
76
|
+
return {
|
|
77
|
+
roles,
|
|
78
|
+
pricing: coercePricing(b.pricing),
|
|
79
|
+
fetchedAt: typeof fetchedAt === 'string' && fetchedAt.length > 0 ? fetchedAt : new Date().toISOString(),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
// ── Disk cache (net-new; whole-document atomic tmp+rename, same as session/store) ─
|
|
83
|
+
async function writeDiskCache(c) {
|
|
84
|
+
const dir = cspeachRoot();
|
|
85
|
+
await fs.mkdir(dir, { recursive: true });
|
|
86
|
+
const file = cacheFilePath();
|
|
87
|
+
const tmp = `${file}.tmp`;
|
|
88
|
+
await fs.writeFile(tmp, JSON.stringify(c, null, 2), 'utf-8');
|
|
89
|
+
await fs.rename(tmp, file);
|
|
90
|
+
}
|
|
91
|
+
/** Read the disk cache, returning it ONLY when parseable AND < 24h old. */
|
|
92
|
+
async function readFreshDiskCache() {
|
|
93
|
+
let parsed;
|
|
94
|
+
try {
|
|
95
|
+
const raw = await fs.readFile(cacheFilePath(), 'utf-8');
|
|
96
|
+
parsed = JSON.parse(raw);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return null; // absent or corrupt/unparseable = no cache
|
|
100
|
+
}
|
|
101
|
+
const fetchedAt = parsed && typeof parsed === 'object'
|
|
102
|
+
? parsed.fetchedAt
|
|
103
|
+
: undefined;
|
|
104
|
+
const doc = coerceDocument(parsed, typeof fetchedAt === 'string' ? fetchedAt : undefined);
|
|
105
|
+
if (!doc)
|
|
106
|
+
return null;
|
|
107
|
+
const age = Date.now() - Date.parse(doc.fetchedAt);
|
|
108
|
+
if (!Number.isFinite(age) || age < 0 || age >= CACHE_MAX_AGE_MS)
|
|
109
|
+
return null;
|
|
110
|
+
return doc;
|
|
111
|
+
}
|
|
112
|
+
// ── Public entrypoint ─────────────────────────────────────────────────────────
|
|
113
|
+
/**
|
|
114
|
+
* Fetch the served model-config and update the module snapshot. Managed mode
|
|
115
|
+
* ONLY. Fire-and-forget from startup; NEVER throws.
|
|
116
|
+
*/
|
|
117
|
+
export async function loadServerModelConfig(cfg) {
|
|
118
|
+
// Non-managed modes bypass the proxy for inference and must never fetch.
|
|
119
|
+
if (cfg.llm?.mode !== 'managed')
|
|
120
|
+
return;
|
|
121
|
+
try {
|
|
122
|
+
// URL construction lives INSIDE the try: a degenerate config (e.g. a
|
|
123
|
+
// non-string proxy_url that loader.ts never coerced) would otherwise throw
|
|
124
|
+
// synchronously out of this fire-and-forget call and become an unhandled
|
|
125
|
+
// rejection that crashes startup. Keep it caught like every other failure.
|
|
126
|
+
const url = `${cfg.proxy_url.replace(/\/$/, '')}/v1/model-config`;
|
|
127
|
+
const controller = new AbortController();
|
|
128
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
129
|
+
timer.unref?.();
|
|
130
|
+
let body;
|
|
131
|
+
try {
|
|
132
|
+
const r = await fetch(url, { signal: controller.signal });
|
|
133
|
+
if (!r.ok)
|
|
134
|
+
throw new Error(`model-config fetch: status ${r.status}`);
|
|
135
|
+
body = await r.json();
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
clearTimeout(timer);
|
|
139
|
+
}
|
|
140
|
+
const doc = coerceDocument(body);
|
|
141
|
+
if (!doc)
|
|
142
|
+
throw new Error('model-config fetch: malformed payload');
|
|
143
|
+
snapshot = doc;
|
|
144
|
+
// Best-effort cache write — a cache-write failure must not fail the fetch.
|
|
145
|
+
await writeDiskCache(doc).catch(() => { });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
// On ANY failure fall back to a fresh (<24h) disk cache; else leave the
|
|
150
|
+
// snapshot as-is (null at startup) so the built-ins win.
|
|
151
|
+
const cached = await readFreshDiskCache();
|
|
152
|
+
if (cached)
|
|
153
|
+
snapshot = cached;
|
|
154
|
+
}
|
|
155
|
+
}
|
package/dist/one-shot.js
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import { loadConfig } from './config/loader.js';
|
|
2
|
+
import { loadConfig, resolveLocalBuild } from './config/loader.js';
|
|
3
3
|
import { getStore } from './auth/api-key.js';
|
|
4
4
|
import { getConnection } from './sap/connection-manager.js';
|
|
5
5
|
import { AdtClient } from '@cspeach/sap-client';
|
|
6
6
|
import { makeSessionId, saveSession } from './session/store.js';
|
|
7
7
|
import { newSession } from './session/schema.js';
|
|
8
|
+
import { resolveModelRole } from './models/resolve.js';
|
|
9
|
+
import { loadServerModelConfig } from './models/server-config.js';
|
|
8
10
|
import { createProviderForMode } from './agent/providers/factory.js';
|
|
11
|
+
import { assertModeLicensed, CspeachLicenseError } from './agent/providers/license-gate.js';
|
|
9
12
|
import { runTurn } from './agent/loop.js';
|
|
10
|
-
import { classifyPrompt, parseShortcut } from './router/classifier.js';
|
|
11
|
-
import { setRenderingOverride } from './renderer/tty.js';
|
|
13
|
+
import { classifyPrompt, parseShortcut, rejectUnknownSkill } from './router/classifier.js';
|
|
14
|
+
import { setRenderingOverride, setHeadlessOverride } from './renderer/tty.js';
|
|
12
15
|
import { alwaysDeclinePreviewHook } from './repl/update-method-preview-hook.js';
|
|
16
|
+
import { createClassicOutputEmitter } from './renderer/notices.js';
|
|
17
|
+
import { todoEmitter } from './ui/todo-emitter.js';
|
|
18
|
+
import { applyLocalBuildConfig } from './tools/local-build.js';
|
|
19
|
+
import { getEffectiveWriteMode } from './repl/mode-cycle.js';
|
|
13
20
|
// Side-effect imports for tool registration.
|
|
14
21
|
import './tools/sap-read.js';
|
|
15
22
|
import './tools/sap-write.js';
|
|
@@ -18,17 +25,24 @@ import './tools/approval.js';
|
|
|
18
25
|
import './tools/snapshot.js';
|
|
19
26
|
import './tools/ask-question.js';
|
|
20
27
|
import './tools/dispatch-skill.js';
|
|
28
|
+
import './tools/todo.js';
|
|
21
29
|
import './tools/filesystem/file-read.js';
|
|
30
|
+
import './tools/filesystem/read-document.js';
|
|
22
31
|
import './tools/filesystem/file-edit.js';
|
|
23
32
|
import './tools/filesystem/file-write.js';
|
|
24
33
|
import './tools/filesystem/glob.js';
|
|
25
34
|
import './tools/filesystem/grep.js';
|
|
26
35
|
import './tools/shell/shell_exec.js';
|
|
36
|
+
import './tools/fiori/tools.js';
|
|
37
|
+
import './tools/extend-model/tool.js';
|
|
38
|
+
import './tools/capability/tool.js';
|
|
27
39
|
import './tools/web/web_fetch.js';
|
|
28
40
|
import './tools/web/web_search.js';
|
|
29
41
|
import './tools/project/project_context_get.js';
|
|
30
42
|
import './tools/project/playbook_get.js';
|
|
31
43
|
import './tools/project/convention_get.js';
|
|
44
|
+
import './tools/project/cca_merge.js';
|
|
45
|
+
import './tools/project/upgrade_merge_progress.js';
|
|
32
46
|
import './tools/subagent/_background-shared.js';
|
|
33
47
|
import './tools/subagent/background_run.js';
|
|
34
48
|
import './tools/subagent/monitor_emit.js';
|
|
@@ -37,12 +51,40 @@ import './tools/subagent/agent_run.js';
|
|
|
37
51
|
export async function runOneShot(prompt, opts) {
|
|
38
52
|
const cfg = await loadConfig();
|
|
39
53
|
setRenderingOverride(cfg.ui.rendering);
|
|
54
|
+
// model-governance step 2d — fire-and-forget fetch of the served model-config
|
|
55
|
+
// (managed mode only, never blocks). A turn that starts before it lands uses
|
|
56
|
+
// the built-ins (fail-safe).
|
|
57
|
+
void loadServerModelConfig(cfg);
|
|
58
|
+
// B5 (2026-06-11) — one-shot is headless by design: a single turn, no
|
|
59
|
+
// REPL, often piped (CI / battery). Any stdin prompt would hang forever
|
|
60
|
+
// (defect D1). The headless answer policy (ask_question auto-answers,
|
|
61
|
+
// save hook defaults YES, approvals fail fast) keys off this flag.
|
|
62
|
+
setHeadlessOverride(true);
|
|
40
63
|
// Task C.10 — advisory-only is the only mode the one-shot announces, because
|
|
41
64
|
// non-interactive output should stay minimal. The user needs to know that
|
|
42
65
|
// writes will NOT be executed before a skill that normally writes runs.
|
|
43
|
-
|
|
66
|
+
//
|
|
67
|
+
// Task 3 (ux-wave1) — gate on the EFFECTIVE mode: one-shot's alias choice is
|
|
68
|
+
// deterministic (--sap-alias or first configured), so the role clamp can be
|
|
69
|
+
// computed here. A prd alias makes the run advisory even when the config
|
|
70
|
+
// says gated/auto — the banner must say so. No role ⇒ effective === config
|
|
71
|
+
// (byte-identical). Alias absent (no SAP configured) ⇒ error out below,
|
|
72
|
+
// same as before.
|
|
73
|
+
const oneShotAlias = opts?.sapAlias ?? Object.keys(cfg.sap)[0];
|
|
74
|
+
const oneShotRole = oneShotAlias !== undefined ? cfg.sap[oneShotAlias]?.role : undefined;
|
|
75
|
+
if (getEffectiveWriteMode(cfg.write_mode, oneShotRole) === 'advisory-only') {
|
|
44
76
|
console.log(chalk.yellow('⚠ Advisory mode: writes will be proposed, not executed.'));
|
|
45
77
|
}
|
|
78
|
+
// local_build toggle — force-enable the flag-gated filesystem tools AND
|
|
79
|
+
// shell_exec BEFORE the (single) turn when persisted-on. Resolve with backward
|
|
80
|
+
// compat (legacy `local_files = on` still applies). Announce so headless
|
|
81
|
+
// transcripts record that local file access + command execution were live
|
|
82
|
+
// (trust-visibility / auditability).
|
|
83
|
+
{
|
|
84
|
+
const announce = applyLocalBuildConfig(resolveLocalBuild(cfg), process.cwd());
|
|
85
|
+
if (announce)
|
|
86
|
+
console.log(chalk.dim(announce));
|
|
87
|
+
}
|
|
46
88
|
const store = await getStore();
|
|
47
89
|
if (!(await store.get())) {
|
|
48
90
|
console.error('No API key configured. Run `cspeach` (interactive) first to set one.');
|
|
@@ -60,12 +102,20 @@ export async function runOneShot(prompt, opts) {
|
|
|
60
102
|
}
|
|
61
103
|
const conn = await getConnection(alias, cfg.sap[alias]);
|
|
62
104
|
const adt = new AdtClient(conn);
|
|
63
|
-
const session = newSession(makeSessionId(), alias, null, cfg
|
|
105
|
+
const session = newSession(makeSessionId(), alias, null, resolveModelRole('session_default', cfg));
|
|
64
106
|
await saveSession(session);
|
|
65
107
|
const parsed = parseShortcut(prompt);
|
|
66
108
|
const body = parsed.prompt;
|
|
67
109
|
let skill;
|
|
68
110
|
if (parsed.skill) {
|
|
111
|
+
// D35 (2026-06-11) — explicit slash naming a skill that isn't in the
|
|
112
|
+
// catalog. One-shot has no picker, so fail fast with the notice ("did
|
|
113
|
+
// you mean" / builtin-with-args) instead of dispatching to a
|
|
114
|
+
// nonexistent skill (and NEVER falling through to the classifier with
|
|
115
|
+
// the slash embedded). Shared gate: rejectUnknownSkill in classifier.ts.
|
|
116
|
+
if (rejectUnknownSkill(parsed, (m) => console.error(chalk.red(m)))) {
|
|
117
|
+
return 1;
|
|
118
|
+
}
|
|
69
119
|
skill = parsed.skill;
|
|
70
120
|
}
|
|
71
121
|
else {
|
|
@@ -94,6 +144,19 @@ export async function runOneShot(prompt, opts) {
|
|
|
94
144
|
}
|
|
95
145
|
skill = c.skill;
|
|
96
146
|
}
|
|
147
|
+
// License gate: non-managed modes (byok/local/ai-hub) bypass our proxy for
|
|
148
|
+
// inference, so without this anyone who never logged in could use the bundled
|
|
149
|
+
// skills untracked. Require a valid CSPeach login. (See license-gate.ts.)
|
|
150
|
+
try {
|
|
151
|
+
await assertModeLicensed(cfg.llm.mode, async () => (await store.get()) ?? '');
|
|
152
|
+
}
|
|
153
|
+
catch (e) {
|
|
154
|
+
if (e instanceof CspeachLicenseError) {
|
|
155
|
+
console.error(chalk.yellow(`\n${e.message}\n`));
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
throw e;
|
|
159
|
+
}
|
|
97
160
|
const provider = createProviderForMode(cfg, async () => {
|
|
98
161
|
const key = await store.get();
|
|
99
162
|
if (!key)
|
|
@@ -104,7 +167,14 @@ export async function runOneShot(prompt, opts) {
|
|
|
104
167
|
provider,
|
|
105
168
|
userMessage: body,
|
|
106
169
|
skill,
|
|
107
|
-
|
|
170
|
+
// B2 fix (2026-06-12): one-shot runs render tool notices ('warn'/'info'
|
|
171
|
+
// — Rule 9 transport overrides, snapshot lines) to stdout instead of
|
|
172
|
+
// dropping them. Headless transcripts need these for auditability.
|
|
173
|
+
// Task 5 (ux-wave2): todoEmitter wired for the uniform top-level-ctx
|
|
174
|
+
// contract — no listener exists in one-shot, so emits are no-ops, but
|
|
175
|
+
// "top-level ctxs carry it / subagent ctxs omit it" stays the single
|
|
176
|
+
// rule (see ui/todo-emitter.ts).
|
|
177
|
+
ctx: { adt, sapAlias: alias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: alwaysDeclinePreviewHook, chunkEmitter: createClassicOutputEmitter(), todoEmitter },
|
|
108
178
|
});
|
|
109
179
|
return 0;
|
|
110
180
|
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { writeFileSync } from 'node:fs';
|
|
2
|
+
import { readProjectFile } from './status.js';
|
|
3
|
+
import { validateEnvelope } from './validate.js';
|
|
4
|
+
/**
|
|
5
|
+
* The still-open BLOCKER-severity questions of a spec-gap envelope, in item
|
|
6
|
+
* order. Returns [] for any non-spec-gap artefact (only spec-gap has gaps with
|
|
7
|
+
* a severity + an `answered` status). Carries any candidate `options` so the
|
|
8
|
+
* answer UI can offer choices instead of a bare text box.
|
|
9
|
+
*/
|
|
10
|
+
export function openBlockers(env) {
|
|
11
|
+
if (env.artefactType !== 'spec-gap')
|
|
12
|
+
return [];
|
|
13
|
+
const gapById = new Map();
|
|
14
|
+
for (const s of env.content.sections) {
|
|
15
|
+
for (const g of s.gaps)
|
|
16
|
+
gapById.set(g.id, g);
|
|
17
|
+
}
|
|
18
|
+
const out = [];
|
|
19
|
+
for (const it of env.interaction.items) {
|
|
20
|
+
if (it.status !== 'open')
|
|
21
|
+
continue;
|
|
22
|
+
const g = gapById.get(it.id);
|
|
23
|
+
if (g?.severity !== 'BLOCKER')
|
|
24
|
+
continue;
|
|
25
|
+
const ob = { id: it.id, text: g.text };
|
|
26
|
+
if (g.options && g.options.length > 0) {
|
|
27
|
+
ob.options = g.options;
|
|
28
|
+
if (g.optionsMultiSelect)
|
|
29
|
+
ob.multiSelect = true;
|
|
30
|
+
}
|
|
31
|
+
out.push(ob);
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Return a copy of `env` with each item in `answers` marked answered. Keys with
|
|
37
|
+
* a blank value are ignored (treated as skipped). version + history are left
|
|
38
|
+
* untouched (invariant version === history.length holds); lastEditedAt/By move
|
|
39
|
+
* to the answerer to reflect who filled them.
|
|
40
|
+
*/
|
|
41
|
+
export function applyBlockerAnswers(env, answers, answerer, nowIso) {
|
|
42
|
+
const items = env.interaction.items.map((it) => {
|
|
43
|
+
const a = answers.get(it.id);
|
|
44
|
+
if (a === undefined || a.trim().length === 0)
|
|
45
|
+
return it;
|
|
46
|
+
return {
|
|
47
|
+
...it,
|
|
48
|
+
status: 'answered',
|
|
49
|
+
answer: a,
|
|
50
|
+
answeredBy: answerer,
|
|
51
|
+
answeredAt: nowIso,
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
return {
|
|
55
|
+
...env,
|
|
56
|
+
interaction: { items },
|
|
57
|
+
lastEditedAt: nowIso,
|
|
58
|
+
lastEditedBy: answerer,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function affirmative(reply) {
|
|
62
|
+
const v = reply.trim().toLowerCase();
|
|
63
|
+
return v === 'y' || v === 'yes';
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Offer to answer the open blockers of the just-saved spec-gap file inline.
|
|
67
|
+
* No-op (no prompt) when the file isn't a spec-gap or has no open blockers, so
|
|
68
|
+
* it is always safe to call after any save. In headless runs the prompt returns
|
|
69
|
+
* '' → declined → nothing happens.
|
|
70
|
+
*
|
|
71
|
+
* Returns the count recorded, or null when nothing was offered/written.
|
|
72
|
+
*/
|
|
73
|
+
export async function offerAnswerBlockers(args) {
|
|
74
|
+
let env;
|
|
75
|
+
try {
|
|
76
|
+
env = readProjectFile(args.path);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
const blockers = openBlockers(env);
|
|
82
|
+
if (blockers.length === 0)
|
|
83
|
+
return null;
|
|
84
|
+
const reply = await args.prompt(`Answer the ${blockers.length} unanswered blocker(s) now? [y/N]: `);
|
|
85
|
+
if (!affirmative(reply))
|
|
86
|
+
return null;
|
|
87
|
+
const answers = new Map();
|
|
88
|
+
for (let i = 0; i < blockers.length; i++) {
|
|
89
|
+
const b = blockers[i];
|
|
90
|
+
args.log('');
|
|
91
|
+
args.log(`Blocker ${i + 1}/${blockers.length}: ${b.text}`);
|
|
92
|
+
// Choices first, where the question has them and a picker is available.
|
|
93
|
+
// 'write' falls through to the text box; 'skip' leaves the blocker open.
|
|
94
|
+
let askText = true;
|
|
95
|
+
if (b.options && b.options.length > 0 && args.choose) {
|
|
96
|
+
const c = await args.choose({
|
|
97
|
+
question: b.text,
|
|
98
|
+
options: b.options,
|
|
99
|
+
multiSelect: !!b.multiSelect,
|
|
100
|
+
index: i,
|
|
101
|
+
total: blockers.length,
|
|
102
|
+
});
|
|
103
|
+
if (c.kind === 'picked') {
|
|
104
|
+
if (c.answer.trim().length > 0)
|
|
105
|
+
answers.set(b.id, c.answer.trim());
|
|
106
|
+
askText = false;
|
|
107
|
+
}
|
|
108
|
+
else if (c.kind === 'skip') {
|
|
109
|
+
askText = false;
|
|
110
|
+
}
|
|
111
|
+
// c.kind === 'write' → askText stays true
|
|
112
|
+
}
|
|
113
|
+
if (askText) {
|
|
114
|
+
const a = (await args.prompt(' Your answer (Enter to skip): ')).trim();
|
|
115
|
+
if (a.length > 0)
|
|
116
|
+
answers.set(b.id, a);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (answers.size === 0) {
|
|
120
|
+
args.log('');
|
|
121
|
+
args.log('No answers entered — all blockers left open for the viewer.');
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
const nowIso = (args.now ?? (() => new Date().toISOString()))();
|
|
125
|
+
const revised = applyBlockerAnswers(env, answers, args.identity, nowIso);
|
|
126
|
+
const check = validateEnvelope(JSON.parse(JSON.stringify(revised)));
|
|
127
|
+
if (!check.ok) {
|
|
128
|
+
args.log(`[answer] revision invalid (${check.error.message}) — answers NOT saved.`);
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
writeFileSync(args.path, JSON.stringify(revised, null, 2), 'utf8');
|
|
132
|
+
const stillOpen = blockers.length - answers.size;
|
|
133
|
+
args.log('');
|
|
134
|
+
args.log(`Recorded ${answers.size} answer(s) in the file`
|
|
135
|
+
+ (stillOpen > 0 ? ` — ${stillOpen} blocker(s) still open for the viewer.` : '.'));
|
|
136
|
+
return answers.size;
|
|
137
|
+
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* <!-- csforge:cca-manifest
|
|
10
10
|
* artefact: cca-assessment
|
|
11
11
|
* title: ZFI / ZSD — S/4HANA 2023 estate
|
|
12
|
-
* detail_path: .
|
|
12
|
+
* detail_path: .cspeach/cca/projects/zfi-zsd-2026-04/project.json
|
|
13
13
|
* project_id: zfi-zsd-2026-04
|
|
14
14
|
* scope: ZFI* + ZSD*
|
|
15
15
|
* atc_variant: S4HANA_READINESS_2023
|
|
@@ -30,7 +30,9 @@
|
|
|
30
30
|
* Parsing is permissive — missing optional fields produce defaults; a
|
|
31
31
|
* missing manifest block throws so callers know to surface the error.
|
|
32
32
|
*/
|
|
33
|
-
|
|
33
|
+
// E2 dual-read: accept both the legacy `csforge:` and current `cspeach:`
|
|
34
|
+
// prefixes (legacy acceptance is permanent — old saved artifacts carry it).
|
|
35
|
+
const MANIFEST_RE = /<!--\s*(?:csforge|cspeach):cca-manifest\s*\n([\s\S]*?)\n\s*-->/;
|
|
34
36
|
function parseManifestBlock(markdown) {
|
|
35
37
|
const m = MANIFEST_RE.exec(markdown);
|
|
36
38
|
if (!m) {
|
|
@@ -78,6 +80,13 @@ function intOr(kv, key, fallback) {
|
|
|
78
80
|
const n = Number.parseInt(v, 10);
|
|
79
81
|
return Number.isFinite(n) ? n : fallback;
|
|
80
82
|
}
|
|
83
|
+
function optionalInt(kv, key) {
|
|
84
|
+
const v = kv[key];
|
|
85
|
+
if (v === undefined)
|
|
86
|
+
return undefined;
|
|
87
|
+
const n = Number.parseInt(v, 10);
|
|
88
|
+
return Number.isFinite(n) ? n : undefined;
|
|
89
|
+
}
|
|
81
90
|
const CLASSIFICATIONS = ['keep', 'fix', 'retire', 'redesign'];
|
|
82
91
|
const CONFIDENCES = ['high', 'medium', 'low'];
|
|
83
92
|
function asClassification(raw) {
|
|
@@ -92,6 +101,36 @@ function asConfidence(raw) {
|
|
|
92
101
|
? v
|
|
93
102
|
: 'medium';
|
|
94
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* Parse the optional 6th table cell on a merged row:
|
|
106
|
+
* `conflict=<label>:<cls>;<label>:<cls>`
|
|
107
|
+
* (rendered by merge-cca.ts:renderCcaMergeManifest). Returns undefined
|
|
108
|
+
* for absent / unrecognisable cells; invalid classification values are
|
|
109
|
+
* skipped pair-wise so one typo doesn't drop the whole record.
|
|
110
|
+
*/
|
|
111
|
+
function parseConflictCell(raw) {
|
|
112
|
+
if (!raw)
|
|
113
|
+
return undefined;
|
|
114
|
+
const m = /^conflict=(.+)$/.exec(raw.trim());
|
|
115
|
+
if (!m)
|
|
116
|
+
return undefined;
|
|
117
|
+
const values = {};
|
|
118
|
+
for (const pair of m[1].split(';')) {
|
|
119
|
+
const idx = pair.lastIndexOf(':');
|
|
120
|
+
if (idx === -1)
|
|
121
|
+
continue;
|
|
122
|
+
const label = pair.slice(0, idx).trim();
|
|
123
|
+
const cls = pair.slice(idx + 1).trim().toLowerCase();
|
|
124
|
+
if (label.length === 0)
|
|
125
|
+
continue;
|
|
126
|
+
if (!CLASSIFICATIONS.includes(cls))
|
|
127
|
+
continue;
|
|
128
|
+
values[label] = cls;
|
|
129
|
+
}
|
|
130
|
+
if (Object.keys(values).length === 0)
|
|
131
|
+
return undefined;
|
|
132
|
+
return { values, rule: 'most-restrictive' };
|
|
133
|
+
}
|
|
95
134
|
export function extractCcaAssessment(markdown) {
|
|
96
135
|
const { kv, tables } = parseManifestBlock(markdown);
|
|
97
136
|
if (kv.artefact !== 'cca-assessment') {
|
|
@@ -103,27 +142,82 @@ export function extractCcaAssessment(markdown) {
|
|
|
103
142
|
if (!kv.project_id) {
|
|
104
143
|
throw new Error("cca-assessment manifest missing required 'project_id'");
|
|
105
144
|
}
|
|
106
|
-
const classifications = (tables.classifications ?? []).map((row, i) =>
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
145
|
+
const classifications = (tables.classifications ?? []).map((row, i) => {
|
|
146
|
+
const conflict = parseConflictCell(row[5]);
|
|
147
|
+
return {
|
|
148
|
+
id: row[0] ?? `item-${String(i + 1).padStart(3, '0')}`,
|
|
149
|
+
objectName: row[1] ?? '',
|
|
150
|
+
objectType: row[2] ?? '',
|
|
151
|
+
classification: asClassification(row[3] ?? 'keep'),
|
|
152
|
+
confidence: asConfidence(row[4] ?? 'medium'),
|
|
153
|
+
...(conflict ? { conflict } : {}),
|
|
154
|
+
};
|
|
155
|
+
});
|
|
156
|
+
const summary = {
|
|
157
|
+
totalObjects: intOr(kv, 'total_objects', classifications.length),
|
|
158
|
+
keep: intOr(kv, 'keep', 0),
|
|
159
|
+
fix: intOr(kv, 'fix', 0),
|
|
160
|
+
retire: intOr(kv, 'retire', 0),
|
|
161
|
+
redesign: intOr(kv, 'redesign', 0),
|
|
162
|
+
uncategorized: intOr(kv, 'uncategorized', 0),
|
|
163
|
+
};
|
|
164
|
+
// ── D6/D7 manifest integrity (2026-06-12) ───────────────────────────────
|
|
165
|
+
// Battery S4 caught manifests whose counts contradicted their own rows
|
|
166
|
+
// (`retire: 0` next to 7 retire rows) and tables silently truncated
|
|
167
|
+
// (27/104 rows, no marker). These manifests are CHAIN INPUT for
|
|
168
|
+
// /abap-cca-merge — a quiet save corrupts the consolidated estate
|
|
169
|
+
// numbers, so a contradiction fails the extract LOUDLY instead (same
|
|
170
|
+
// philosophy as extract-plan's loud-failure path in save-command.ts).
|
|
171
|
+
const truncated = kv.classifications_truncated === 'true';
|
|
172
|
+
const shown = optionalInt(kv, 'classifications_shown');
|
|
173
|
+
const totalCls = optionalInt(kv, 'classifications_total');
|
|
174
|
+
const rowTally = {
|
|
175
|
+
keep: 0, fix: 0, retire: 0, redesign: 0,
|
|
176
|
+
};
|
|
177
|
+
for (const c of classifications)
|
|
178
|
+
rowTally[c.classification]++;
|
|
179
|
+
if (truncated) {
|
|
180
|
+
if (shown !== undefined && shown !== classifications.length) {
|
|
181
|
+
throw new Error(`cca-assessment manifest contradiction: classifications_shown is ${shown} but the table has ${classifications.length} rows.`);
|
|
182
|
+
}
|
|
183
|
+
for (const cls of ['keep', 'fix', 'retire', 'redesign']) {
|
|
184
|
+
if (rowTally[cls] > summary[cls]) {
|
|
185
|
+
throw new Error(`cca-assessment manifest contradiction: ${rowTally[cls]} '${cls}' rows in the table but the summary says ${cls}: ${summary[cls]}. `
|
|
186
|
+
+ `Counts must cover every row even when the table is truncated.`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (totalCls !== undefined && totalCls < classifications.length) {
|
|
190
|
+
throw new Error(`cca-assessment manifest contradiction: classifications_total (${totalCls}) is smaller than the ${classifications.length} rows shown.`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
for (const cls of ['keep', 'fix', 'retire', 'redesign']) {
|
|
195
|
+
if (rowTally[cls] !== summary[cls]) {
|
|
196
|
+
throw new Error(`cca-assessment manifest contradiction: summary says ${cls}: ${summary[cls]} but the table has ${rowTally[cls]} '${cls}' rows. `
|
|
197
|
+
+ `Counts must equal rows — if the table was capped, mark it with classifications_truncated: true + classifications_shown + classifications_total.`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// total_objects must equal the class-count sum in BOTH branches — the
|
|
202
|
+
// summary counts cover the full estate even when the table is capped.
|
|
203
|
+
const expectedTotal = summary.keep + summary.fix + summary.retire + summary.redesign + summary.uncategorized;
|
|
204
|
+
if (summary.totalObjects !== expectedTotal) {
|
|
205
|
+
throw new Error(`cca-assessment manifest contradiction: total_objects is ${summary.totalObjects} but keep+fix+retire+redesign+uncategorized = ${expectedTotal}.`);
|
|
206
|
+
}
|
|
113
207
|
const content = {
|
|
114
208
|
detailPath: kv.detail_path,
|
|
115
209
|
projectId: kv.project_id,
|
|
116
210
|
scope: kv.scope ?? '',
|
|
117
211
|
atcVariant: kv.atc_variant ?? '',
|
|
118
|
-
summary
|
|
119
|
-
totalObjects: intOr(kv, 'total_objects', classifications.length),
|
|
120
|
-
keep: intOr(kv, 'keep', 0),
|
|
121
|
-
fix: intOr(kv, 'fix', 0),
|
|
122
|
-
retire: intOr(kv, 'retire', 0),
|
|
123
|
-
redesign: intOr(kv, 'redesign', 0),
|
|
124
|
-
uncategorized: intOr(kv, 'uncategorized', 0),
|
|
125
|
-
},
|
|
212
|
+
summary,
|
|
126
213
|
classifications,
|
|
214
|
+
...(truncated
|
|
215
|
+
? {
|
|
216
|
+
classificationsTruncated: true,
|
|
217
|
+
...(shown !== undefined ? { classificationsShown: shown } : {}),
|
|
218
|
+
...(totalCls !== undefined ? { classificationsTotal: totalCls } : {}),
|
|
219
|
+
}
|
|
220
|
+
: {}),
|
|
127
221
|
};
|
|
128
222
|
// One InteractionItem per classification entry, default status 'open'
|
|
129
223
|
// (auto-classified by the AI, awaiting human review).
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* <!-- csforge:modernize-manifest
|
|
12
12
|
* artefact: modernize-result
|
|
13
13
|
* title: ZDPR_PAYMENTS — modernization
|
|
14
|
-
* detail_path: .
|
|
14
|
+
* detail_path: .cspeach/modernize/zdpr-payments/project.json
|
|
15
15
|
* baseline_path: <project-relative path>
|
|
16
16
|
* baseline_sha256: <hex>
|
|
17
17
|
* transport: <id or ->
|
|
@@ -27,7 +27,9 @@
|
|
|
27
27
|
* Parsing is permissive — missing optional fields produce defaults; a
|
|
28
28
|
* missing manifest block throws so callers know to surface the error.
|
|
29
29
|
*/
|
|
30
|
-
|
|
30
|
+
// E2 dual-read: accept both the legacy `csforge:` and current `cspeach:`
|
|
31
|
+
// prefixes (legacy acceptance is permanent — old saved artifacts carry it).
|
|
32
|
+
const MANIFEST_RE = /<!--\s*(?:csforge|cspeach):modernize-manifest\s*\n([\s\S]*?)\n\s*-->/;
|
|
31
33
|
function parseManifestBlock(markdown) {
|
|
32
34
|
const m = MANIFEST_RE.exec(markdown);
|
|
33
35
|
if (!m) {
|