@cspeach/cli 1.1.49 → 1.1.50

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 (46) hide show
  1. package/dist/agent/cold-prune.js +1 -1
  2. package/dist/agent/conversation-prompt.js +52 -0
  3. package/dist/agent/loop.js +83 -2
  4. package/dist/agent/oversized-results.js +204 -0
  5. package/dist/agent/turn-error-ux.js +14 -0
  6. package/dist/commands/plan-continue-gate.js +11 -0
  7. package/dist/cost/cost-log.js +4 -0
  8. package/dist/cost/system-block.js +25 -0
  9. package/dist/models/server-config.js +13 -0
  10. package/dist/projects/workspace.js +6 -3
  11. package/dist/repl.js +16 -6
  12. package/dist/session/fresh-start.js +15 -0
  13. package/dist/tools/_filesystem-shared.js +7 -0
  14. package/dist/tools/cut-markers.js +47 -0
  15. package/dist/tools/filesystem/extract-document.js +50 -2
  16. package/dist/tools/filesystem/file-read.js +2 -2
  17. package/dist/tools/filesystem/read-document.js +20 -4
  18. package/dist/tools/tool-output-read.js +5 -1
  19. package/dist/tools/write-pipeline.js +5 -0
  20. package/package.json +121 -121
  21. package/dist/commands/project.js +0 -301
  22. package/dist/doctor/checks/zcspeach.js +0 -96
  23. package/dist/projects/email-template.js +0 -56
  24. package/dist/projects/merge-cca.js +0 -292
  25. package/dist/tools/local-files.js +0 -31
  26. package/dist/tools/project/cca_merge.js +0 -164
  27. package/dist/tools/subagent/schedule_create.js +0 -131
  28. package/dist/ui/error-emitter.js +0 -21
  29. package/dist/ui/header.js +0 -41
  30. package/dist/ui/ink-classifier-route.js +0 -19
  31. package/dist/ui/rich-error-box.js +0 -9
  32. package/dist/ui/sidebar.js +0 -12
  33. package/dist/ui/status-emitter.js +0 -19
  34. package/dist/ui/status-line.js +0 -62
  35. package/dist/ui/status-row.js +0 -38
  36. package/dist/ui/todo-panel.js +0 -85
  37. package/dist/ui/widgets/ask-form.js +0 -250
  38. package/dist/ui/widgets/ask-question-modal.js +0 -178
  39. package/fiori/zlas-inspection-lr/package.json +0 -26
  40. package/fiori/zlas-inspection-lr/ui5-local.yaml +0 -45
  41. package/fiori/zlas-inspection-lr/ui5.yaml +0 -41
  42. package/fiori/zlas-inspection-lr/webapp/Component.js +0 -12
  43. package/fiori/zlas-inspection-lr/webapp/i18n/i18n.properties +0 -9
  44. package/fiori/zlas-inspection-lr/webapp/index.html +0 -35
  45. package/fiori/zlas-inspection-lr/webapp/manifest.json +0 -145
  46. package/live-check-2026-10-17-1343.txt +0 -41
@@ -1,292 +0,0 @@
1
- /**
2
- * Deterministic merge of N parallel /abap-cca assessment slices into ONE
3
- * consolidated cca-assessment (the /abap-cca-merge skill's engine).
4
- *
5
- * WHY CODE, NOT PROSE: battery scenario S4 (docs/research/battery/
6
- * s4-cca-scorecard.md, defects D6/D7) proved the model cannot keep
7
- * manifest counts consistent with manifest rows when reconciling by
8
- * hand — `retire: 0` next to 7 retire rows, 27/104 rows silently
9
- * dropped. This module does the arithmetic; the model presents the
10
- * result and adds judgment commentary, nothing else.
11
- *
12
- * Rules implemented (mirrors .claude/skills/abap-cca-merge/SKILL.md):
13
- * - identity: same projectId across inputs, or opts.allowCrossProject
14
- * - atcVariant must match across inputs (hard error — classifications
15
- * produced under different ATC variants are not comparable)
16
- * - scope-overlap detection: intersecting scope patterns AND duplicate
17
- * object names across inputs both produce warnings
18
- * - per-object union keyed by objectName|objectType (item ids are
19
- * PER-INPUT local — item-001 exists in every slice — so ids cannot
20
- * be the union key; merged rows are re-numbered)
21
- * - conflict rule: most-restrictive wins (retire > redesign > fix >
22
- * keep), recorded BOTH as a warning AND on the merged row
23
- * (`conflict: { values: {label: cls}, rule: 'most-restrictive' }`)
24
- * - counts RECOMPUTED from merged rows — never trusted from inputs
25
- * - truncation-aware: an input marked classificationsTruncated yields
26
- * a warning that the merge covers manifest rows only and the detail
27
- * file must be consulted for full coverage
28
- */
29
- import { OUTPUT_ROOT } from './output-paths.js';
30
- /** Higher = more restrictive. Conflict resolution picks the max. */
31
- const RESTRICTIVENESS = {
32
- retire: 3,
33
- redesign: 2,
34
- fix: 1,
35
- keep: 0,
36
- };
37
- /**
38
- * Manifest row-priority sort (matches abap-cca SKILL.md: "redesign first,
39
- * then fix, then retire, then keep, ordered by confidence DESC within
40
- * each group"). This is WORK priority, not restrictiveness.
41
- */
42
- const ROW_PRIORITY = {
43
- redesign: 0,
44
- fix: 1,
45
- retire: 2,
46
- keep: 3,
47
- };
48
- const CONFIDENCE_RANK = {
49
- high: 0,
50
- medium: 1,
51
- low: 2,
52
- };
53
- /* ── scope expression helpers ───────────────────────────────────────────── */
54
- /**
55
- * Split a scope expression like "ZFI* + ZSD*" or "ZFI*, ZSD*" into
56
- * individual patterns. Exported for tests.
57
- */
58
- export function parseScopePatterns(scope) {
59
- return scope
60
- .split(/[+,]/)
61
- .map((s) => s.trim())
62
- .filter((s) => s.length > 0);
63
- }
64
- /**
65
- * Conservative intersection check for the prefix-glob patterns the
66
- * --scope flag accepts (`ZFI*`, exact names). Two prefix globs intersect
67
- * when one stem is a prefix of the other (`ZF*` ⊇ `ZFI*`). Patterns we
68
- * cannot reason about (internal `*`) only match when identical —
69
- * deliberately under-reports rather than spamming false overlaps.
70
- * Exported for tests.
71
- */
72
- export function scopePatternsIntersect(a, b) {
73
- const A = a.trim().toUpperCase();
74
- const B = b.trim().toUpperCase();
75
- if (A === B)
76
- return true;
77
- const aStar = A.endsWith('*') && !A.slice(0, -1).includes('*');
78
- const bStar = B.endsWith('*') && !B.slice(0, -1).includes('*');
79
- const aStem = aStar ? A.slice(0, -1) : A;
80
- const bStem = bStar ? B.slice(0, -1) : B;
81
- if (A.includes('*') && !aStar)
82
- return false; // unparseable — identical-only
83
- if (B.includes('*') && !bStar)
84
- return false;
85
- if (aStar && bStar)
86
- return aStem.startsWith(bStem) || bStem.startsWith(aStem);
87
- if (aStar)
88
- return B.startsWith(aStem);
89
- if (bStar)
90
- return A.startsWith(bStem);
91
- return false; // two distinct exact names
92
- }
93
- /* ── merge ──────────────────────────────────────────────────────────────── */
94
- function todayUtc() {
95
- return new Date().toISOString().slice(0, 10);
96
- }
97
- export function mergeCcaAssessments(inputs, opts = {}) {
98
- if (inputs.length < 2) {
99
- throw new Error(`cca merge requires at least 2 inputs (got ${inputs.length}) — a single assessment needs no merge`);
100
- }
101
- const warnings = [];
102
- const first = inputs[0];
103
- // ── identity validation ──
104
- for (const inp of inputs.slice(1)) {
105
- if (inp.content.projectId !== first.content.projectId && !opts.allowCrossProject) {
106
- throw new Error(`projectId mismatch: '${inp.label}' is project '${inp.content.projectId}' but '${first.label}' is '${first.content.projectId}'. `
107
- + `Merging different projects silently corrupts the estate view — pass allowCrossProject only for disjoint-scope slices of the same system.`);
108
- }
109
- }
110
- for (const inp of inputs.slice(1)) {
111
- if (inp.content.atcVariant !== first.content.atcVariant) {
112
- throw new Error(`atcVariant mismatch: '${inp.label}' was classified under '${inp.content.atcVariant}' but '${first.label}' under '${first.content.atcVariant}'. `
113
- + `Classifications from different ATC variants are not comparable — re-run one slice with the matching variant.`);
114
- }
115
- }
116
- // ── scope-overlap detection (warning, not blocker) ──
117
- for (let i = 0; i < inputs.length; i++) {
118
- for (let j = i + 1; j < inputs.length; j++) {
119
- const a = inputs[i];
120
- const b = inputs[j];
121
- for (const pa of parseScopePatterns(a.content.scope)) {
122
- for (const pb of parseScopePatterns(b.content.scope)) {
123
- if (scopePatternsIntersect(pa, pb)) {
124
- warnings.push(`SCOPE OVERLAP: '${a.label}' scope '${pa}' intersects '${b.label}' scope '${pb}' — slices were not disjoint; duplicate objects are reconciled most-restrictive.`);
125
- }
126
- }
127
- }
128
- }
129
- }
130
- // ── truncation awareness ──
131
- for (const inp of inputs) {
132
- if (inp.content.classificationsTruncated) {
133
- const shown = inp.content.classificationsShown ?? inp.content.classifications.length;
134
- const total = inp.content.classificationsTotal ?? '?';
135
- warnings.push(`TRUNCATED INPUT: '${inp.label}' manifest carries only ${shown} of ${total} classifications — `
136
- + `this merge covers the manifest rows only. For full coverage merge from the detail file at ${inp.content.detailPath}.`);
137
- }
138
- }
139
- // ── per-object union (key: objectName|objectType) ──
140
- const byKey = new Map();
141
- const keyOrder = [];
142
- for (const inp of inputs) {
143
- for (const row of inp.content.classifications) {
144
- const key = `${row.objectName.toUpperCase()}|${row.objectType.toUpperCase()}`;
145
- if (!byKey.has(key)) {
146
- byKey.set(key, []);
147
- keyOrder.push(key);
148
- }
149
- byKey.get(key).push({
150
- label: inp.label,
151
- classification: row.classification,
152
- confidence: row.confidence,
153
- objectName: row.objectName,
154
- objectType: row.objectType,
155
- });
156
- }
157
- }
158
- const mergedRows = [];
159
- for (const key of keyOrder) {
160
- const sources = byKey.get(key);
161
- const base = sources[0];
162
- const distinct = new Set(sources.map((s) => s.classification));
163
- if (sources.length > 1 && distinct.size === 1) {
164
- warnings.push(`DUPLICATE OBJECT: ${base.objectName} (${base.objectType}) appears in ${sources.length} inputs `
165
- + `(${sources.map((s) => s.label).join(', ')}) with the same classification '${base.classification}' — overlapping slices, merged into one row.`);
166
- }
167
- if (distinct.size > 1) {
168
- // conflict — most-restrictive wins
169
- const winner = sources.reduce((best, s) => RESTRICTIVENESS[s.classification] > RESTRICTIVENESS[best.classification] ? s : best);
170
- const values = {};
171
- for (const s of sources)
172
- values[s.label] = s.classification;
173
- warnings.push(`CLASSIFICATION CONFLICT: ${base.objectName} (${base.objectType}) — `
174
- + sources.map((s) => `${s.label}: ${s.classification} [${s.confidence}]`).join('; ')
175
- + ` → resolved: ${winner.classification} (most-restrictive rule). Review with the consultants who classified before remediation planning.`);
176
- mergedRows.push({
177
- id: '', // re-numbered after sort
178
- objectName: base.objectName,
179
- objectType: base.objectType,
180
- classification: winner.classification,
181
- confidence: winner.confidence,
182
- conflict: { values, rule: 'most-restrictive' },
183
- });
184
- }
185
- else {
186
- mergedRows.push({
187
- id: '',
188
- objectName: base.objectName,
189
- objectType: base.objectType,
190
- classification: base.classification,
191
- confidence: base.confidence,
192
- });
193
- }
194
- }
195
- // ── manifest row-priority sort + re-number ──
196
- mergedRows.sort((a, b) => {
197
- const p = ROW_PRIORITY[a.classification] - ROW_PRIORITY[b.classification];
198
- if (p !== 0)
199
- return p;
200
- const c = CONFIDENCE_RANK[a.confidence] - CONFIDENCE_RANK[b.confidence];
201
- if (c !== 0)
202
- return c;
203
- return a.objectName.localeCompare(b.objectName);
204
- });
205
- mergedRows.forEach((row, i) => {
206
- row.id = `item-${String(i + 1).padStart(3, '0')}`;
207
- });
208
- // ── counts RECOMPUTED from rows — never trusted from inputs ──
209
- const tally = { keep: 0, fix: 0, retire: 0, redesign: 0 };
210
- for (const row of mergedRows)
211
- tally[row.classification]++;
212
- // uncategorized objects have no classification rows, so they cannot be
213
- // recomputed — summed across inputs (slices are disjoint by contract;
214
- // overlap is already warned about above).
215
- const uncategorized = inputs.reduce((sum, inp) => sum + (inp.content.summary.uncategorized || 0), 0);
216
- const date = opts.mergedDate ?? todayUtc();
217
- const mergedProjectId = `${first.content.projectId}_merged_${date}`;
218
- const scopes = [];
219
- for (const inp of inputs) {
220
- const s = inp.content.scope.trim();
221
- if (s.length > 0 && !scopes.includes(s))
222
- scopes.push(s);
223
- }
224
- const merged = {
225
- detailPath: `${OUTPUT_ROOT}/cca/projects/${mergedProjectId}/project.json`,
226
- projectId: mergedProjectId,
227
- scope: scopes.join(', '),
228
- atcVariant: first.content.atcVariant,
229
- summary: {
230
- totalObjects: mergedRows.length + uncategorized,
231
- keep: tally.keep,
232
- fix: tally.fix,
233
- retire: tally.retire,
234
- redesign: tally.redesign,
235
- uncategorized,
236
- },
237
- classifications: mergedRows,
238
- };
239
- return { merged, warnings };
240
- }
241
- /* ── manifest rendering ─────────────────────────────────────────────────── */
242
- /** Manifest table cap — full rows live in the merged detail data. */
243
- export const CCA_MANIFEST_ROW_CAP = 50;
244
- function renderConflictCell(conflict) {
245
- const pairs = Object.entries(conflict.values)
246
- .map(([label, cls]) => `${label.replace(/[|;:]/g, '_')}:${cls}`)
247
- .join(';');
248
- return `conflict=${pairs}`;
249
- }
250
- /**
251
- * Render the EXACT `<!-- csforge:cca-manifest ... -->` block for a merged
252
- * assessment. The model must paste this VERBATIM — every number is
253
- * computed here, and extract-cca.ts re-validates counts-vs-rows on save,
254
- * so a hand-edited block fails loudly instead of saving silently wrong.
255
- *
256
- * `omitDetailPath` is for the detail-write-failed path in cca_merge:
257
- * a manifest must never point at a file that does not exist, so the
258
- * line is dropped (the extractor then refuses the save loudly).
259
- */
260
- export function renderCcaMergeManifest(merged, title, opts = {}) {
261
- const rows = merged.classifications.slice(0, CCA_MANIFEST_ROW_CAP);
262
- const truncated = merged.classifications.length > CCA_MANIFEST_ROW_CAP;
263
- const lines = [
264
- '<!-- csforge:cca-manifest',
265
- 'artefact: cca-assessment',
266
- `title: ${title}`,
267
- ...(opts.omitDetailPath ? [] : [`detail_path: ${merged.detailPath}`]),
268
- `project_id: ${merged.projectId}`,
269
- `scope: ${merged.scope}`,
270
- `atc_variant: ${merged.atcVariant}`,
271
- `total_objects: ${merged.summary.totalObjects}`,
272
- `keep: ${merged.summary.keep}`,
273
- `fix: ${merged.summary.fix}`,
274
- `retire: ${merged.summary.retire}`,
275
- `redesign: ${merged.summary.redesign}`,
276
- `uncategorized: ${merged.summary.uncategorized}`,
277
- ];
278
- if (truncated) {
279
- lines.push('classifications_truncated: true');
280
- lines.push(`classifications_shown: ${rows.length}`);
281
- lines.push(`classifications_total: ${merged.classifications.length}`);
282
- }
283
- lines.push('classifications:');
284
- for (const row of rows) {
285
- const cells = [row.id, row.objectName, row.objectType, row.classification, row.confidence];
286
- if (row.conflict)
287
- cells.push(renderConflictCell(row.conflict));
288
- lines.push(` ${cells.join(' | ')}`);
289
- }
290
- lines.push('-->');
291
- return lines.join('\n');
292
- }
@@ -1,31 +0,0 @@
1
- // cspeach-cli/src/tools/local-files.ts
2
- /**
3
- * Startup wiring for the persisted `local_files` config toggle.
4
- *
5
- * When `cfg.local_files === true`, the otherwise flag-gated filesystem tools
6
- * (file_read/file_write/file_edit/glob/grep) are force-enabled via the
7
- * in-memory override in `_flag.ts`, BEFORE the first listTools()/turn. This is
8
- * what makes /abap-fiori-build runnable — it writes local app files.
9
- *
10
- * The toggle is PERSISTED (config.toml), not re-consented each session, so
11
- * `applyLocalFilesConfig` returns an announce line the entry point prints
12
- * whenever the tools are live — the user must always see that local file
13
- * access is on this session (trust-visibility requirement). The FS tools'
14
- * own cwd-sandbox (writes confined to the project root; .cspeach/.env/.git
15
- * denylisted) still applies regardless of this toggle.
16
- */
17
- import { enableToolFlags } from './_flag.js';
18
- /** The exact tool NAMEs the flag uses (file_read → CSPEACH_TOOL_FILE_READ). */
19
- export const LOCAL_FILE_TOOLS = ['file_read', 'file_write', 'file_edit', 'glob', 'grep'];
20
- /**
21
- * Apply the persisted `local_files` toggle. When on, force-enables the FS
22
- * tools and returns a one-line announce string (undefined when off, so the
23
- * caller prints nothing). `cwd` is surfaced so the user sees exactly which
24
- * directory tree read/write/edit operate under.
25
- */
26
- export function applyLocalFilesConfig(localFiles, cwd) {
27
- if (localFiles !== true)
28
- return undefined;
29
- enableToolFlags([...LOCAL_FILE_TOOLS]);
30
- return `local file tools enabled (read/write/edit under ${cwd}) — cspeach config set local_files off to disable`;
31
- }
@@ -1,164 +0,0 @@
1
- /**
2
- * cca_merge — deterministic reconciliation of N parallel /abap-cca
3
- * assessment envelopes (the /abap-cca-merge skill's execution seam).
4
- *
5
- * DESIGN (2026-06-12, team-chain): the skill used to ask the MODEL to
6
- * reconcile envelopes by hand; battery S4 (D6/D7) proved models can't
7
- * keep counts consistent with rows. This tool runs
8
- * src/projects/merge-cca.ts and returns the merged content, warnings,
9
- * AND a pre-rendered manifest block the model pastes VERBATIM. The
10
- * normal save hook then persists the envelope — persistence
11
- * deliberately stays on the one existing save path (confirm prompt,
12
- * workspace resolution, validation, version semantics) instead of the
13
- * tool minting envelopes through a second door. A merge is a NEW
14
- * artefact superseding its inputs (new identity is correct — there is
15
- * no prior chain to extend, unlike the plan-revision case in
16
- * save-command.ts), and the extractor's counts-vs-rows validation acts
17
- * as a tamper backstop if the model edits the pasted block.
18
- *
19
- * NOT flag-gated: the abap-cca-merge skill depends on this tool; gating
20
- * it off would silently break the multi-consultant chain.
21
- *
22
- * Stays isMutating: false despite writing the merged detail file: the
23
- * write is the merge's own local artifact under .cspeach/ (derived
24
- * purely from envelopes the user supplied), the same trust level as the
25
- * save hook persisting the envelope — no SAP or user-file mutation.
26
- */
27
- import { mkdirSync, writeFileSync } from 'node:fs';
28
- import { dirname, isAbsolute, join } from 'node:path';
29
- import { registerTool } from '../index.js';
30
- import { mergeCcaAssessments, renderCcaMergeManifest, } from '../../projects/merge-cca.js';
31
- import { loadEnvelope, MANIFEST_PASTE_INSTRUCTIONS } from './_merge-shared.js';
32
- export async function ccaMergeHandler(args, ctx) {
33
- if (!Array.isArray(args.inputs) || args.inputs.some((i) => typeof i !== 'string')) {
34
- return { content: 'error: inputs must be an array of envelope paths / @-tokens.', is_error: true };
35
- }
36
- const tokens = args.inputs;
37
- if (tokens.length === 1) {
38
- return {
39
- content: 'error: only one input supplied — a single assessment needs no merge. Use the file directly.',
40
- is_error: true,
41
- };
42
- }
43
- if (tokens.length < 2) {
44
- return { content: 'error: at least 2 input envelopes are required.', is_error: true };
45
- }
46
- if (args.allow_cross_project !== undefined && typeof args.allow_cross_project !== 'boolean') {
47
- return { content: 'error: allow_cross_project must be a boolean.', is_error: true };
48
- }
49
- // load + type-check every input before merging — fail with the precise file
50
- const inputs = [];
51
- const perInput = [];
52
- for (const token of tokens) {
53
- let resolved;
54
- try {
55
- resolved = await loadEnvelope(token, ctx.cwd, 'cca-assessment');
56
- }
57
- catch (e) {
58
- return { content: `error: ${e instanceof Error ? e.message : String(e)}`, is_error: true };
59
- }
60
- const content = resolved.envelope.content;
61
- inputs.push({ label: resolved.label, content });
62
- perInput.push({
63
- file: resolved.path,
64
- label: resolved.label,
65
- project_id: content.projectId,
66
- scope: content.scope,
67
- total_objects: content.summary.totalObjects,
68
- keep: content.summary.keep,
69
- fix: content.summary.fix,
70
- retire: content.summary.retire,
71
- redesign: content.summary.redesign,
72
- uncategorized: content.summary.uncategorized,
73
- truncated: content.classificationsTruncated === true,
74
- });
75
- }
76
- let merged;
77
- let warnings;
78
- try {
79
- ({ merged, warnings } = mergeCcaAssessments(inputs, {
80
- allowCrossProject: args.allow_cross_project === true,
81
- }));
82
- }
83
- catch (e) {
84
- return { content: `error: ${e instanceof Error ? e.message : String(e)}`, is_error: true };
85
- }
86
- const title = `${inputs[0].content.projectId} — Merged (${inputs.length} slices)`;
87
- // ── write the merged detail file BEFORE emitting the manifest ──────────
88
- // The manifest's detail_path must point at a REAL file (the rule this
89
- // branch added for /abap-cca emitters applies to the merge too). The
90
- // manifest table caps at 50 rows; this file always carries every merged
91
- // row. Minimal documented shape (a merge supersedes its inputs, so this
92
- // is a fresh consolidated detail, not a phase-state project.json):
93
- // {
94
- // generatedAt: ISO timestamp of the merge
95
- // projectId / scope / atcVariant / summary: as in the manifest
96
- // objects: ALL merged classification rows (uncapped)
97
- // mergedFrom: the input envelopes [{ file, label }]
98
- // }
99
- let detailWritten = true;
100
- const detailAbs = isAbsolute(merged.detailPath) ? merged.detailPath : join(ctx.cwd, merged.detailPath);
101
- try {
102
- mkdirSync(dirname(detailAbs), { recursive: true });
103
- writeFileSync(detailAbs, JSON.stringify({
104
- generatedAt: new Date().toISOString(),
105
- projectId: merged.projectId,
106
- scope: merged.scope,
107
- atcVariant: merged.atcVariant,
108
- summary: merged.summary,
109
- objects: merged.classifications,
110
- mergedFrom: perInput.map((p) => ({ file: p.file, label: p.label })),
111
- }, null, 2), 'utf8');
112
- }
113
- catch (e) {
114
- detailWritten = false;
115
- warnings.push(`DETAIL WRITE FAILED: could not write the merged detail file '${merged.detailPath}' `
116
- + `(${e instanceof Error ? e.message : String(e)}). The manifest block omits detail_path — `
117
- + `a manifest must never point at a file that does not exist — so the save will refuse it. `
118
- + `Fix the path/permissions and re-run the merge.`);
119
- }
120
- const manifestBlock = renderCcaMergeManifest(merged, title, { omitDetailPath: !detailWritten });
121
- return {
122
- content: JSON.stringify({
123
- merged_summary: merged.summary,
124
- merged_project_id: merged.projectId,
125
- merged_scope: merged.scope,
126
- atc_variant: merged.atcVariant,
127
- ...(detailWritten ? { detail_path: merged.detailPath } : {}),
128
- per_input: perInput,
129
- warnings,
130
- // full merged rows — the manifest block caps at 50; nothing is lost
131
- // from the transcript even for large estates.
132
- merged_classifications: merged.classifications,
133
- manifest_block: manifestBlock,
134
- instructions: MANIFEST_PASTE_INSTRUCTIONS,
135
- }, null, 2),
136
- };
137
- }
138
- registerTool({
139
- name: 'cca_merge',
140
- description: 'Deterministically merge 2+ parallel /abap-cca assessment envelopes (.cspeach.json) into one consolidated '
141
- + 'cca-assessment. Validates project identity and ATC variant, detects scope overlaps and classification '
142
- + 'conflicts (most-restrictive wins: retire > redesign > fix > keep), recomputes all counts from the merged '
143
- + 'rows, writes the full merged rows to a new detail file under .cspeach/cca/projects/, and returns warnings '
144
- + 'plus a ready-made csforge:cca-manifest block to paste verbatim. '
145
- + 'Inputs accept absolute/relative paths or bare workspace @-tokens.',
146
- isMutating: false,
147
- category: 'project',
148
- input_schema: {
149
- type: 'object',
150
- properties: {
151
- inputs: {
152
- type: 'array',
153
- items: { type: 'string' },
154
- description: '2+ cca-assessment envelope references (path or @-token), one per consultant slice.',
155
- },
156
- allow_cross_project: {
157
- type: 'boolean',
158
- description: 'Accept inputs with different projectId (disjoint --scope slices of the same system). Default false.',
159
- },
160
- },
161
- required: ['inputs'],
162
- },
163
- handler: (args, ctx) => ccaMergeHandler(args, ctx),
164
- });
@@ -1,131 +0,0 @@
1
- // cspeach-cli/src/tools/subagent/schedule_create.ts
2
- /**
3
- * schedule_create — Phase 3 / Chunk 3E.
4
- *
5
- * Persists a schedule envelope to ~/.cspeach/schedules/<uuid>.json.
6
- * Filename is ALWAYS crypto.randomUUID — never derived from user
7
- * input. Closes the path-injection class of attack on the schedules
8
- * directory.
9
- *
10
- * IMPORTANT: this tool persists schedules. It does NOT execute them.
11
- * A future chunk (Phase 4 or later) ships the actual runner. The
12
- * tool description and the response body both surface this caveat
13
- * so the agent doesn't assume the schedule will fire.
14
- *
15
- * Flag-gated: invisible to listTools() unless
16
- * CSPEACH_TOOL_SCHEDULE_CREATE=on. isMutating: true (writes filesystem).
17
- */
18
- import { promises as fs } from 'node:fs';
19
- import * as os from 'node:os';
20
- import * as path from 'node:path';
21
- import { randomUUID } from 'node:crypto';
22
- import { registerTool } from '../index.js';
23
- const NOT_EXECUTED_NOTE = 'Persisted only — the schedule runner has not shipped yet. ' +
24
- 'See Phase 4+ for execution.';
25
- // Test-overridable. Default to ~/.cspeach/schedules. Set null to reset.
26
- let schedulesDirOverride = null;
27
- /** Test-only: redirect the schedules dir so tests don't pollute the real home dir. */
28
- export function __setSchedulesDirForTests(dir) {
29
- schedulesDirOverride = dir;
30
- }
31
- function getSchedulesDir() {
32
- return schedulesDirOverride ?? path.join(os.homedir(), '.cspeach', 'schedules');
33
- }
34
- // Strict ISO-8601 UTC: YYYY-MM-DDTHH:MM:SS(.sss)?Z
35
- const ISO_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
36
- export async function scheduleCreateHandler(args, _ctx) {
37
- if (args.kind !== 'oneoff' && args.kind !== 'cron') {
38
- return { content: `error: kind must be 'oneoff' or 'cron' — got '${args.kind}'`, is_error: true };
39
- }
40
- if (!args.when || typeof args.when !== 'string' || args.when.trim().length === 0) {
41
- return { content: 'error: when is required (non-empty string)', is_error: true };
42
- }
43
- if (!args.command || typeof args.command !== 'string' || args.command.trim().length === 0) {
44
- return { content: 'error: command is required (non-empty string)', is_error: true };
45
- }
46
- if (args.kind === 'oneoff') {
47
- if (!ISO_PATTERN.test(args.when)) {
48
- return { content: `error: when must be an ISO-8601 UTC timestamp (e.g. 2099-06-01T10:00:00Z) — got '${args.when}'`, is_error: true };
49
- }
50
- const t = Date.parse(args.when);
51
- if (Number.isNaN(t)) {
52
- return { content: `error: when is not a parseable ISO timestamp: '${args.when}'`, is_error: true };
53
- }
54
- // Belt-and-braces against silent month/day rollover. Date.parse('2099-02-30T00:00:00Z')
55
- // returns a valid timestamp that round-trips to March 2 — without this check, the
56
- // envelope persists '2099-02-30' but the runner fires on March 2 (phantom job).
57
- const roundTrip = new Date(t).toISOString();
58
- // Normalise both sides: lowercase 'z', strip optional fractional seconds, compare.
59
- const norm = (s) => s.replace(/\.\d+Z$/, 'Z').toUpperCase();
60
- if (norm(roundTrip) !== norm(args.when)) {
61
- return { content: `error: when is not a valid calendar date: '${args.when}' (rolls to ${roundTrip})`, is_error: true };
62
- }
63
- if (t <= Date.now()) {
64
- return { content: `error: when is in the past — schedule must be in the future`, is_error: true };
65
- }
66
- }
67
- else {
68
- // cron: just check it has 5 whitespace-separated fields.
69
- const fields = args.when.trim().split(/\s+/);
70
- if (fields.length !== 5) {
71
- return { content: `error: cron expression must have 5 fields (min hour dom mon dow) — got ${fields.length}`, is_error: true };
72
- }
73
- }
74
- const dir = getSchedulesDir();
75
- await fs.mkdir(dir, { recursive: true });
76
- // Defence-in-depth: resolve the directory and verify it sits inside
77
- // the cspeach home (or the test override). Refuses if a symlink
78
- // points the dir at /etc/, /tmp/host-controlled/, etc.
79
- const realDir = await fs.realpath(dir);
80
- // In test mode the override dir IS the root (self-containment is trivial —
81
- // mkdtemp gives us a unique dir that acts as both schedules-dir and its own
82
- // root). In prod the schedules dir must sit UNDER ~/.cspeach/, so the root
83
- // is the parent. Asymmetric on purpose.
84
- const expectedRoot = schedulesDirOverride !== null
85
- ? path.resolve(schedulesDirOverride)
86
- : path.join(os.homedir(), '.cspeach');
87
- const rel = path.relative(expectedRoot, realDir);
88
- if (rel.startsWith('..') || path.isAbsolute(rel)) {
89
- return { content: `error: schedules directory '${realDir}' resolves outside expected root`, is_error: true };
90
- }
91
- const id = randomUUID();
92
- const envelope = {
93
- id,
94
- kind: args.kind,
95
- when: args.when,
96
- command: args.command,
97
- createdAt: new Date().toISOString(),
98
- note: NOT_EXECUTED_NOTE,
99
- };
100
- const target = path.join(realDir, `${id}.json`);
101
- await fs.writeFile(target, JSON.stringify(envelope, null, 2), { encoding: 'utf-8', mode: 0o600 });
102
- return {
103
- content: `schedule persisted (NOT YET EXECUTED — runner ships in a later chunk).\n` +
104
- `path: ${target}\n` +
105
- `id: ${id}\n` +
106
- `kind: ${args.kind}\n` +
107
- `when: ${args.when}\n` +
108
- `command: ${args.command}\n` +
109
- `note: ${NOT_EXECUTED_NOTE}`,
110
- };
111
- }
112
- registerTool({
113
- name: 'schedule_create',
114
- description: 'Persist a oneoff or cron schedule envelope to ~/.cspeach/schedules/<uuid>.json. ' +
115
- 'Filename is a fresh UUID — never derived from input. NOTE: this tool only PERSISTS the ' +
116
- 'schedule. No runner is shipped yet — the agent should not assume the command will fire ' +
117
- 'automatically. A future chunk will ship the runner.',
118
- isMutating: true,
119
- category: 'subagent',
120
- flagGated: true,
121
- input_schema: {
122
- type: 'object',
123
- properties: {
124
- kind: { type: 'string', enum: ['oneoff', 'cron'], description: 'oneoff = single ISO timestamp; cron = 5-field expression' },
125
- when: { type: 'string', description: 'ISO-8601 UTC timestamp for oneoff, or 5-field cron expression' },
126
- command: { type: 'string', description: 'The command the runner WOULD execute (free-form string for now)' },
127
- },
128
- required: ['kind', 'when', 'command'],
129
- },
130
- handler: (args, ctx) => scheduleCreateHandler(args, ctx),
131
- });
@@ -1,21 +0,0 @@
1
- import { EventEmitter } from 'node:events';
2
- import { shouldUseInk } from '../renderer/tty.js';
3
- import chalk from 'chalk';
4
- export const errorEmitter = new EventEmitter();
5
- /**
6
- * Emit an error. In Ink mode, the main App subscribes and pushes the error
7
- * into its errorStack state for overlay rendering. In non-TTY / one-shot,
8
- * we fall back to chalk.red on stderr — never mount a second Ink root.
9
- */
10
- export function emitError(err) {
11
- if (shouldUseInk()) {
12
- errorEmitter.emit('error', err);
13
- }
14
- else {
15
- console.error(chalk.red(`[${err.code}] ${err.title}`));
16
- if (err.body)
17
- console.error(chalk.red(` ${err.body}`));
18
- if (err.hint)
19
- console.error(chalk.dim(` ${err.hint}`));
20
- }
21
- }