@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.
- package/dist/agent/cold-prune.js +1 -1
- package/dist/agent/conversation-prompt.js +52 -0
- package/dist/agent/loop.js +83 -2
- package/dist/agent/oversized-results.js +204 -0
- package/dist/agent/turn-error-ux.js +14 -0
- package/dist/commands/plan-continue-gate.js +11 -0
- package/dist/cost/cost-log.js +4 -0
- package/dist/cost/system-block.js +25 -0
- package/dist/models/server-config.js +13 -0
- package/dist/projects/workspace.js +6 -3
- package/dist/repl.js +16 -6
- package/dist/session/fresh-start.js +15 -0
- package/dist/tools/_filesystem-shared.js +7 -0
- package/dist/tools/cut-markers.js +47 -0
- package/dist/tools/filesystem/extract-document.js +50 -2
- package/dist/tools/filesystem/file-read.js +2 -2
- package/dist/tools/filesystem/read-document.js +20 -4
- package/dist/tools/tool-output-read.js +5 -1
- package/dist/tools/write-pipeline.js +5 -0
- package/package.json +121 -121
- package/dist/commands/project.js +0 -301
- package/dist/doctor/checks/zcspeach.js +0 -96
- package/dist/projects/email-template.js +0 -56
- package/dist/projects/merge-cca.js +0 -292
- package/dist/tools/local-files.js +0 -31
- package/dist/tools/project/cca_merge.js +0 -164
- package/dist/tools/subagent/schedule_create.js +0 -131
- package/dist/ui/error-emitter.js +0 -21
- package/dist/ui/header.js +0 -41
- package/dist/ui/ink-classifier-route.js +0 -19
- package/dist/ui/rich-error-box.js +0 -9
- package/dist/ui/sidebar.js +0 -12
- package/dist/ui/status-emitter.js +0 -19
- package/dist/ui/status-line.js +0 -62
- package/dist/ui/status-row.js +0 -38
- package/dist/ui/todo-panel.js +0 -85
- package/dist/ui/widgets/ask-form.js +0 -250
- package/dist/ui/widgets/ask-question-modal.js +0 -178
- package/fiori/zlas-inspection-lr/package.json +0 -26
- package/fiori/zlas-inspection-lr/ui5-local.yaml +0 -45
- package/fiori/zlas-inspection-lr/ui5.yaml +0 -41
- package/fiori/zlas-inspection-lr/webapp/Component.js +0 -12
- package/fiori/zlas-inspection-lr/webapp/i18n/i18n.properties +0 -9
- package/fiori/zlas-inspection-lr/webapp/index.html +0 -35
- package/fiori/zlas-inspection-lr/webapp/manifest.json +0 -145
- 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
|
-
});
|
package/dist/ui/error-emitter.js
DELETED
|
@@ -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
|
-
}
|