@cspeach/cli 1.1.48 → 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 (93) hide show
  1. package/dist/agent/cold-prune.js +1 -1
  2. package/dist/agent/conversation-prompt.js +52 -0
  3. package/dist/agent/level.js +1 -1
  4. package/dist/agent/loop.js +90 -8
  5. package/dist/agent/oversized-results.js +204 -0
  6. package/dist/agent/sap-connection-adapter.js +2 -0
  7. package/dist/agent/system-prompt.js +2 -2
  8. package/dist/agent/tool-dispatch.js +13 -1
  9. package/dist/agent/turn-error-ux.js +14 -0
  10. package/dist/approvals/op-labels.js +49 -10
  11. package/dist/approvals/render.js +14 -23
  12. package/dist/approvals/risk-floor.js +196 -19
  13. package/dist/approvals/visible-changes.js +35 -0
  14. package/dist/commands/help.js +1 -1
  15. package/dist/commands/plan-continue-gate.js +11 -0
  16. package/dist/commands/team-plan.js +16 -6
  17. package/dist/commands/team.js +295 -68
  18. package/dist/cost/cost-log.js +4 -0
  19. package/dist/cost/system-block.js +25 -0
  20. package/dist/journey/chain.js +489 -61
  21. package/dist/journey/clean-core.js +61 -36
  22. package/dist/journey/flow.js +75 -2
  23. package/dist/journey/people.js +37 -0
  24. package/dist/journey/rebuild.js +7 -6
  25. package/dist/journey/run.js +129 -42
  26. package/dist/journey/trigger.js +16 -10
  27. package/dist/models/server-config.js +13 -0
  28. package/dist/one-shot.js +1 -1
  29. package/dist/project-context/render.js +7 -0
  30. package/dist/projects/clean-core-confirm.js +166 -44
  31. package/dist/projects/extract-cca.js +8 -7
  32. package/dist/projects/index.js +0 -1
  33. package/dist/projects/merge-upgrade.js +2 -2
  34. package/dist/projects/project-folder.js +2 -1
  35. package/dist/projects/save-command.js +51 -14
  36. package/dist/projects/workspace.js +6 -3
  37. package/dist/register/decision-rules.js +2 -1
  38. package/dist/register/export-files.js +18 -0
  39. package/dist/register/picture.js +3 -3
  40. package/dist/register/project-transport.js +125 -0
  41. package/dist/register/records.js +25 -13
  42. package/dist/register/rules-edit.js +5 -0
  43. package/dist/register/status-file/progress-chart.js +17 -6
  44. package/dist/register/status-file/register-map.js +8 -5
  45. package/dist/register/status-file/register-model.js +55 -36
  46. package/dist/register/status-file/register-parts.js +97 -82
  47. package/dist/register/status-file/register-status-file.js +16 -7
  48. package/dist/register/status-file/report-model.js +293 -205
  49. package/dist/register/status-file/team-css.js +1 -1
  50. package/dist/register/status-html.js +11 -4
  51. package/dist/register/store.js +2 -2
  52. package/dist/register/workbooks.js +24 -13
  53. package/dist/renderer/footer-line.js +10 -2
  54. package/dist/renderer/tool-labels.js +2 -2
  55. package/dist/repl/builtin-commands.js +1 -1
  56. package/dist/repl/continue-skill.js +5 -0
  57. package/dist/repl/level-command.js +6 -15
  58. package/dist/repl.js +37 -14
  59. package/dist/router/router-core.js +5 -3
  60. package/dist/session/fresh-start.js +15 -0
  61. package/dist/skill-catalog.js +4 -7
  62. package/dist/tools/_filesystem-shared.js +7 -0
  63. package/dist/tools/_team-work-path.js +53 -0
  64. package/dist/tools/approval.js +97 -19
  65. package/dist/tools/cut-markers.js +47 -0
  66. package/dist/tools/filesystem/extract-document.js +50 -2
  67. package/dist/tools/filesystem/file-edit.js +8 -1
  68. package/dist/tools/filesystem/file-read.js +2 -2
  69. package/dist/tools/filesystem/file-write.js +8 -1
  70. package/dist/tools/filesystem/read-document.js +20 -4
  71. package/dist/tools/project/_merge-shared.js +2 -2
  72. package/dist/tools/project/_team-tool-shared.js +5 -0
  73. package/dist/tools/project/rules_propose.js +2 -1
  74. package/dist/tools/project/team_findings.js +81 -28
  75. package/dist/tools/project/team_lane_move.js +127 -0
  76. package/dist/tools/project/upgrade_merge_progress.js +8 -4
  77. package/dist/tools/text-elements/tools.js +7 -1
  78. package/dist/tools/tool-loading.js +1 -1
  79. package/dist/tools/tool-output-read.js +5 -1
  80. package/dist/tools/update-method-intercept.js +18 -1
  81. package/dist/tools/write-pipeline.js +24 -5
  82. package/dist/ui/approval-modal.js +6 -3
  83. package/dist/ui/footer-line.js +19 -1
  84. package/package.json +3 -3
  85. package/dist/projects/merge-cca.js +0 -311
  86. package/dist/tools/project/cca_merge.js +0 -174
  87. package/test-harness/fe-draft/app.ts +0 -188
  88. package/test-harness/fe-draft/fe-draft.e2e.test.ts +0 -461
  89. package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +0 -51
  90. package/test-harness/fe-draft/routes.ts +0 -224
  91. package/test-harness/fe-draft/routes.unit.test.ts +0 -45
  92. package/test-harness/fe-draft/server.ts +0 -225
  93. package/test-harness/fe-draft/variants.ts +0 -196
@@ -1,311 +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
- import { isWindowsReservedName } from '../tools/_filesystem-shared.js';
31
- /** Higher = more restrictive. Conflict resolution picks the max. */
32
- const RESTRICTIVENESS = {
33
- retire: 3,
34
- redesign: 2,
35
- fix: 1,
36
- keep: 0,
37
- };
38
- /**
39
- * Manifest row-priority sort (matches abap-cca SKILL.md: "redesign first,
40
- * then fix, then retire, then keep, ordered by confidence DESC within
41
- * each group"). This is WORK priority, not restrictiveness.
42
- */
43
- const ROW_PRIORITY = {
44
- redesign: 0,
45
- fix: 1,
46
- retire: 2,
47
- keep: 3,
48
- };
49
- const CONFIDENCE_RANK = {
50
- high: 0,
51
- medium: 1,
52
- low: 2,
53
- };
54
- /* ── scope expression helpers ───────────────────────────────────────────── */
55
- /**
56
- * Split a scope expression like "ZFI* + ZSD*" or "ZFI*, ZSD*" into
57
- * individual patterns. Exported for tests.
58
- */
59
- export function parseScopePatterns(scope) {
60
- return scope
61
- .split(/[+,]/)
62
- .map((s) => s.trim())
63
- .filter((s) => s.length > 0);
64
- }
65
- /**
66
- * Conservative intersection check for the prefix-glob patterns the
67
- * --scope flag accepts (`ZFI*`, exact names). Two prefix globs intersect
68
- * when one stem is a prefix of the other (`ZF*` ⊇ `ZFI*`). Patterns we
69
- * cannot reason about (internal `*`) only match when identical —
70
- * deliberately under-reports rather than spamming false overlaps.
71
- * Exported for tests.
72
- */
73
- export function scopePatternsIntersect(a, b) {
74
- const A = a.trim().toUpperCase();
75
- const B = b.trim().toUpperCase();
76
- if (A === B)
77
- return true;
78
- const aStar = A.endsWith('*') && !A.slice(0, -1).includes('*');
79
- const bStar = B.endsWith('*') && !B.slice(0, -1).includes('*');
80
- const aStem = aStar ? A.slice(0, -1) : A;
81
- const bStem = bStar ? B.slice(0, -1) : B;
82
- if (A.includes('*') && !aStar)
83
- return false; // unparseable — identical-only
84
- if (B.includes('*') && !bStar)
85
- return false;
86
- if (aStar && bStar)
87
- return aStem.startsWith(bStem) || bStem.startsWith(aStem);
88
- if (aStar)
89
- return B.startsWith(aStem);
90
- if (bStar)
91
- return A.startsWith(bStem);
92
- return false; // two distinct exact names
93
- }
94
- /* ── merge ──────────────────────────────────────────────────────────────── */
95
- function todayUtc() {
96
- return new Date().toISOString().slice(0, 10);
97
- }
98
- /** A folder/file-name slug: `^[A-Za-z0-9._-]+$`, no `..`, not `.`, no device name. */
99
- export function assertMergeSlug(label, value) {
100
- const ok = typeof value === 'string'
101
- && value.length > 0 && value.length <= 100
102
- && /^[A-Za-z0-9._-]+$/.test(value)
103
- && !value.includes('..') && value !== '.'
104
- && !/[. ]$/.test(value)
105
- && !isWindowsReservedName(value);
106
- if (!ok) {
107
- throw new Error(`${label} ${JSON.stringify(value)} cannot name a folder — use letters, digits, ".", "_" and "-" only `
108
- + '(no "..", no device names such as CON or NUL).');
109
- }
110
- }
111
- export function mergeCcaAssessments(inputs, opts = {}) {
112
- if (inputs.length < 2) {
113
- throw new Error(`cca merge requires at least 2 inputs (got ${inputs.length}) — a single assessment needs no merge`);
114
- }
115
- const warnings = [];
116
- const first = inputs[0];
117
- // ── identity validation ──
118
- for (const inp of inputs.slice(1)) {
119
- if (inp.content.projectId !== first.content.projectId && !opts.allowCrossProject) {
120
- throw new Error(`projectId mismatch: '${inp.label}' is project '${inp.content.projectId}' but '${first.label}' is '${first.content.projectId}'. `
121
- + `Merging different projects silently corrupts the estate view — pass allowCrossProject only for disjoint-scope slices of the same system.`);
122
- }
123
- }
124
- for (const inp of inputs.slice(1)) {
125
- if (inp.content.atcVariant !== first.content.atcVariant) {
126
- throw new Error(`atcVariant mismatch: '${inp.label}' was classified under '${inp.content.atcVariant}' but '${first.label}' under '${first.content.atcVariant}'. `
127
- + `Classifications from different ATC variants are not comparable — re-run one slice with the matching variant.`);
128
- }
129
- }
130
- // ── scope-overlap detection (warning, not blocker) ──
131
- for (let i = 0; i < inputs.length; i++) {
132
- for (let j = i + 1; j < inputs.length; j++) {
133
- const a = inputs[i];
134
- const b = inputs[j];
135
- for (const pa of parseScopePatterns(a.content.scope)) {
136
- for (const pb of parseScopePatterns(b.content.scope)) {
137
- if (scopePatternsIntersect(pa, pb)) {
138
- warnings.push(`SCOPE OVERLAP: '${a.label}' scope '${pa}' intersects '${b.label}' scope '${pb}' — slices were not disjoint; duplicate objects are reconciled most-restrictive.`);
139
- }
140
- }
141
- }
142
- }
143
- }
144
- // ── truncation awareness ──
145
- for (const inp of inputs) {
146
- if (inp.content.classificationsTruncated) {
147
- const shown = inp.content.classificationsShown ?? inp.content.classifications.length;
148
- const total = inp.content.classificationsTotal ?? '?';
149
- warnings.push(`TRUNCATED INPUT: '${inp.label}' manifest carries only ${shown} of ${total} classifications — `
150
- + `this merge covers the manifest rows only. For full coverage merge from the detail file at ${inp.content.detailPath}.`);
151
- }
152
- }
153
- // ── per-object union (key: objectName|objectType) ──
154
- const byKey = new Map();
155
- const keyOrder = [];
156
- for (const inp of inputs) {
157
- for (const row of inp.content.classifications) {
158
- const key = `${row.objectName.toUpperCase()}|${row.objectType.toUpperCase()}`;
159
- if (!byKey.has(key)) {
160
- byKey.set(key, []);
161
- keyOrder.push(key);
162
- }
163
- byKey.get(key).push({
164
- label: inp.label,
165
- classification: row.classification,
166
- confidence: row.confidence,
167
- objectName: row.objectName,
168
- objectType: row.objectType,
169
- });
170
- }
171
- }
172
- const mergedRows = [];
173
- for (const key of keyOrder) {
174
- const sources = byKey.get(key);
175
- const base = sources[0];
176
- const distinct = new Set(sources.map((s) => s.classification));
177
- if (sources.length > 1 && distinct.size === 1) {
178
- warnings.push(`DUPLICATE OBJECT: ${base.objectName} (${base.objectType}) appears in ${sources.length} inputs `
179
- + `(${sources.map((s) => s.label).join(', ')}) with the same classification '${base.classification}' — overlapping slices, merged into one row.`);
180
- }
181
- if (distinct.size > 1) {
182
- // conflict — most-restrictive wins
183
- const winner = sources.reduce((best, s) => RESTRICTIVENESS[s.classification] > RESTRICTIVENESS[best.classification] ? s : best);
184
- const values = {};
185
- for (const s of sources)
186
- values[s.label] = s.classification;
187
- warnings.push(`CLASSIFICATION CONFLICT: ${base.objectName} (${base.objectType}) — `
188
- + sources.map((s) => `${s.label}: ${s.classification} [${s.confidence}]`).join('; ')
189
- + ` → resolved: ${winner.classification} (most-restrictive rule). Review with the consultants who classified before remediation planning.`);
190
- mergedRows.push({
191
- id: '', // re-numbered after sort
192
- objectName: base.objectName,
193
- objectType: base.objectType,
194
- classification: winner.classification,
195
- confidence: winner.confidence,
196
- conflict: { values, rule: 'most-restrictive' },
197
- });
198
- }
199
- else {
200
- mergedRows.push({
201
- id: '',
202
- objectName: base.objectName,
203
- objectType: base.objectType,
204
- classification: base.classification,
205
- confidence: base.confidence,
206
- });
207
- }
208
- }
209
- // ── manifest row-priority sort + re-number ──
210
- mergedRows.sort((a, b) => {
211
- const p = ROW_PRIORITY[a.classification] - ROW_PRIORITY[b.classification];
212
- if (p !== 0)
213
- return p;
214
- const c = CONFIDENCE_RANK[a.confidence] - CONFIDENCE_RANK[b.confidence];
215
- if (c !== 0)
216
- return c;
217
- return a.objectName.localeCompare(b.objectName);
218
- });
219
- mergedRows.forEach((row, i) => {
220
- row.id = `item-${String(i + 1).padStart(3, '0')}`;
221
- });
222
- // ── counts RECOMPUTED from rows — never trusted from inputs ──
223
- const tally = { keep: 0, fix: 0, retire: 0, redesign: 0 };
224
- for (const row of mergedRows)
225
- tally[row.classification]++;
226
- // uncategorized objects have no classification rows, so they cannot be
227
- // recomputed — summed across inputs (slices are disjoint by contract;
228
- // overlap is already warned about above).
229
- const uncategorized = inputs.reduce((sum, inp) => sum + (inp.content.summary.uncategorized || 0), 0);
230
- const date = opts.mergedDate ?? todayUtc();
231
- // SEC (review round 2): projectId comes from INSIDE an envelope (model-
232
- // writable) and becomes a folder name under .cspeach/cca/projects/. Only a
233
- // plain slug may do that — never `../`, a drive, a stream or a device name.
234
- assertMergeSlug('projectId', first.content.projectId);
235
- assertMergeSlug('merged date', date);
236
- const mergedProjectId = `${first.content.projectId}_merged_${date}`;
237
- const scopes = [];
238
- for (const inp of inputs) {
239
- const s = inp.content.scope.trim();
240
- if (s.length > 0 && !scopes.includes(s))
241
- scopes.push(s);
242
- }
243
- const merged = {
244
- detailPath: `${OUTPUT_ROOT}/cca/projects/${mergedProjectId}/project.json`,
245
- projectId: mergedProjectId,
246
- scope: scopes.join(', '),
247
- atcVariant: first.content.atcVariant,
248
- summary: {
249
- totalObjects: mergedRows.length + uncategorized,
250
- keep: tally.keep,
251
- fix: tally.fix,
252
- retire: tally.retire,
253
- redesign: tally.redesign,
254
- uncategorized,
255
- },
256
- classifications: mergedRows,
257
- };
258
- return { merged, warnings };
259
- }
260
- /* ── manifest rendering ─────────────────────────────────────────────────── */
261
- /** Manifest table cap — full rows live in the merged detail data. */
262
- export const CCA_MANIFEST_ROW_CAP = 50;
263
- function renderConflictCell(conflict) {
264
- const pairs = Object.entries(conflict.values)
265
- .map(([label, cls]) => `${label.replace(/[|;:]/g, '_')}:${cls}`)
266
- .join(';');
267
- return `conflict=${pairs}`;
268
- }
269
- /**
270
- * Render the EXACT `<!-- csforge:cca-manifest ... -->` block for a merged
271
- * assessment. The model must paste this VERBATIM — every number is
272
- * computed here, and extract-cca.ts re-validates counts-vs-rows on save,
273
- * so a hand-edited block fails loudly instead of saving silently wrong.
274
- *
275
- * `omitDetailPath` is for the detail-write-failed path in cca_merge:
276
- * a manifest must never point at a file that does not exist, so the
277
- * line is dropped (the extractor then refuses the save loudly).
278
- */
279
- export function renderCcaMergeManifest(merged, title, opts = {}) {
280
- const rows = merged.classifications.slice(0, CCA_MANIFEST_ROW_CAP);
281
- const truncated = merged.classifications.length > CCA_MANIFEST_ROW_CAP;
282
- const lines = [
283
- '<!-- csforge:cca-manifest',
284
- 'artefact: cca-assessment',
285
- `title: ${title}`,
286
- ...(opts.omitDetailPath ? [] : [`detail_path: ${merged.detailPath}`]),
287
- `project_id: ${merged.projectId}`,
288
- `scope: ${merged.scope}`,
289
- `atc_variant: ${merged.atcVariant}`,
290
- `total_objects: ${merged.summary.totalObjects}`,
291
- `keep: ${merged.summary.keep}`,
292
- `fix: ${merged.summary.fix}`,
293
- `retire: ${merged.summary.retire}`,
294
- `redesign: ${merged.summary.redesign}`,
295
- `uncategorized: ${merged.summary.uncategorized}`,
296
- ];
297
- if (truncated) {
298
- lines.push('classifications_truncated: true');
299
- lines.push(`classifications_shown: ${rows.length}`);
300
- lines.push(`classifications_total: ${merged.classifications.length}`);
301
- }
302
- lines.push('classifications:');
303
- for (const row of rows) {
304
- const cells = [row.id, row.objectName, row.objectType, row.classification, row.confidence];
305
- if (row.conflict)
306
- cells.push(renderConflictCell(row.conflict));
307
- lines.push(` ${cells.join(' | ')}`);
308
- }
309
- lines.push('-->');
310
- return lines.join('\n');
311
- }
@@ -1,174 +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
- import { assertWritable } from '../_filesystem-shared.js';
33
- import { pointsIntoSharedCspeach, SHARED_CSPEACH_REFUSAL, teamWorkTarget } from '../_team-work-path.js';
34
- export async function ccaMergeHandler(args, ctx) {
35
- if (!Array.isArray(args.inputs) || args.inputs.some((i) => typeof i !== 'string')) {
36
- return { content: 'error: inputs must be an array of envelope paths / @-tokens.', is_error: true };
37
- }
38
- const tokens = args.inputs;
39
- if (tokens.length === 1) {
40
- return {
41
- content: 'error: only one input supplied — a single assessment needs no merge. Use the file directly.',
42
- is_error: true,
43
- };
44
- }
45
- if (tokens.length < 2) {
46
- return { content: 'error: at least 2 input envelopes are required.', is_error: true };
47
- }
48
- if (args.allow_cross_project !== undefined && typeof args.allow_cross_project !== 'boolean') {
49
- return { content: 'error: allow_cross_project must be a boolean.', is_error: true };
50
- }
51
- // load + type-check every input before merging — fail with the precise file
52
- const inputs = [];
53
- const perInput = [];
54
- for (const token of tokens) {
55
- let resolved;
56
- try {
57
- resolved = await loadEnvelope(token, ctx.cwd, 'cca-assessment');
58
- }
59
- catch (e) {
60
- return { content: `error: ${e instanceof Error ? e.message : String(e)}`, is_error: true };
61
- }
62
- const content = resolved.envelope.content;
63
- inputs.push({ label: resolved.label, content });
64
- perInput.push({
65
- file: resolved.path,
66
- label: resolved.label,
67
- project_id: content.projectId,
68
- scope: content.scope,
69
- total_objects: content.summary.totalObjects,
70
- keep: content.summary.keep,
71
- fix: content.summary.fix,
72
- retire: content.summary.retire,
73
- redesign: content.summary.redesign,
74
- uncategorized: content.summary.uncategorized,
75
- truncated: content.classificationsTruncated === true,
76
- });
77
- }
78
- let merged;
79
- let warnings;
80
- try {
81
- ({ merged, warnings } = mergeCcaAssessments(inputs, {
82
- allowCrossProject: args.allow_cross_project === true,
83
- }));
84
- }
85
- catch (e) {
86
- return { content: `error: ${e instanceof Error ? e.message : String(e)}`, is_error: true };
87
- }
88
- const title = `${inputs[0].content.projectId} — Merged (${inputs.length} slices)`;
89
- // ── write the merged detail file BEFORE emitting the manifest ──────────
90
- // The manifest's detail_path must point at a REAL file (the rule this
91
- // branch added for /abap-cca emitters applies to the merge too). The
92
- // manifest table caps at 50 rows; this file always carries every merged
93
- // row. Minimal documented shape (a merge supersedes its inputs, so this
94
- // is a fresh consolidated detail, not a phase-state project.json):
95
- // {
96
- // generatedAt: ISO timestamp of the merge
97
- // projectId / scope / atcVariant / summary: as in the manifest
98
- // objects: ALL merged classification rows (uncapped)
99
- // mergedFrom: the input envelopes [{ file, label }]
100
- // }
101
- let detailWritten = true;
102
- // Task 15 — in a team folder the detail file is a working file: the seat's.
103
- const t = teamWorkTarget(ctx.cwd, merged.detailPath);
104
- const detailAbs = isAbsolute(t.path) ? t.path : join(t.root, t.path);
105
- try {
106
- // SEC (review round 2): the real landing path must stay in the project and
107
- // not be protected (a junctioned .cspeach/<domain> -> .git is refused).
108
- const realDetail = await assertWritable(detailAbs, t.root);
109
- // Final fix wave, item 3 — never into the shared .cspeach of a team folder.
110
- if (pointsIntoSharedCspeach(ctx.cwd, realDetail))
111
- throw new Error(SHARED_CSPEACH_REFUSAL);
112
- mkdirSync(dirname(detailAbs), { recursive: true });
113
- writeFileSync(detailAbs, JSON.stringify({
114
- generatedAt: new Date().toISOString(),
115
- projectId: merged.projectId,
116
- scope: merged.scope,
117
- atcVariant: merged.atcVariant,
118
- summary: merged.summary,
119
- objects: merged.classifications,
120
- mergedFrom: perInput.map((p) => ({ file: p.file, label: p.label })),
121
- }, null, 2), 'utf8');
122
- }
123
- catch (e) {
124
- detailWritten = false;
125
- warnings.push(`DETAIL WRITE FAILED: could not write the merged detail file '${merged.detailPath}' `
126
- + `(${e instanceof Error ? e.message : String(e)}). The manifest block omits detail_path — `
127
- + `a manifest must never point at a file that does not exist — so the save will refuse it. `
128
- + `Fix the path/permissions and re-run the merge.`);
129
- }
130
- const manifestBlock = renderCcaMergeManifest(merged, title, { omitDetailPath: !detailWritten });
131
- return {
132
- content: JSON.stringify({
133
- merged_summary: merged.summary,
134
- merged_project_id: merged.projectId,
135
- merged_scope: merged.scope,
136
- atc_variant: merged.atcVariant,
137
- ...(detailWritten ? { detail_path: merged.detailPath } : {}),
138
- per_input: perInput,
139
- warnings,
140
- // full merged rows — the manifest block caps at 50; nothing is lost
141
- // from the transcript even for large estates.
142
- merged_classifications: merged.classifications,
143
- manifest_block: manifestBlock,
144
- instructions: MANIFEST_PASTE_INSTRUCTIONS,
145
- }, null, 2),
146
- };
147
- }
148
- registerTool({
149
- name: 'cca_merge',
150
- description: 'Deterministically merge 2+ parallel /abap-cca assessment envelopes (.cspeach.json) into one consolidated '
151
- + 'cca-assessment. Validates project identity and ATC variant, detects scope overlaps and classification '
152
- + 'conflicts (most-restrictive wins: retire > redesign > fix > keep), recomputes all counts from the merged '
153
- + 'rows, writes the full merged rows to a new detail file under .cspeach/cca/projects/, and returns warnings '
154
- + 'plus a ready-made csforge:cca-manifest block to paste verbatim. '
155
- + 'Inputs accept absolute/relative paths or bare workspace @-tokens.',
156
- isMutating: false,
157
- category: 'project',
158
- input_schema: {
159
- type: 'object',
160
- properties: {
161
- inputs: {
162
- type: 'array',
163
- items: { type: 'string' },
164
- description: '2+ cca-assessment envelope references (path or @-token), one per consultant slice.',
165
- },
166
- allow_cross_project: {
167
- type: 'boolean',
168
- description: 'Accept inputs with different projectId (disjoint --scope slices of the same system). Default false.',
169
- },
170
- },
171
- required: ['inputs'],
172
- },
173
- handler: (args, ctx) => ccaMergeHandler(args, ctx),
174
- });
@@ -1,188 +0,0 @@
1
- // cspeach-cli/test-harness/fe-draft/app.ts
2
- //
3
- // L1-2b — the apps the offline harness serves, built with the PRODUCT's own
4
- // tools (fiori_scaffold_fe, fiori_fe_extend handlers) so a scaffold regression
5
- // shows up here, not in a sitting.
6
- //
7
- // good — an LROP app over the captured draft service, exactly as
8
- // fiori_scaffold_fe writes it, plus one harmless custom table
9
- // action ("Ping") so the good app has something to press.
10
- // broken — the same app whose custom table action ("Boom") is wired to a
11
- // handler that THROWS. A smoke that runs the press exercise must
12
- // fail `exercises` on it.
13
- // brokenPress — the same app with the custom action exactly as
14
- // fiori_fe_extend writes it when asked for `eventHandler: true`:
15
- // `"press": ""`. Pressing it makes UI5 try to load "/.js".
16
- import { readFile, writeFile, mkdir } from 'node:fs/promises';
17
- import * as path from 'node:path';
18
- import { fileURLToPath } from 'node:url';
19
- import { fioriScaffoldFeHandler, feExtendHandler } from '../../src/tools/fiori/tools.js';
20
- import type { ToolContext } from '../../src/tools/index.js';
21
- import { openUrlFromStartScript } from './routes.js';
22
-
23
- const HERE = path.dirname(fileURLToPath(import.meta.url));
24
-
25
- /** The captured draft service (ZUI_LAS_INSPECTIONLOG_O4) — read in place by the mockserver. */
26
- export const DRAFT_METADATA = path.resolve(
27
- HERE,
28
- '../../src/tools/fiori/__tests__/fixtures/metadata/v4-draft-main.xml',
29
- );
30
- export const DRAFT_SERVICE_PATH =
31
- '/sap/opu/odata4/sap/zui_las_inspectionlog_o4/srvd/sap/zui_las_inspectionlog_o4/0001/';
32
- export const DRAFT_MAIN_ENTITY = 'InspectionLog';
33
- /** Fully-qualified draft actions, as `SAP__common.DraftRoot` names them in the capture. */
34
- export const DRAFT_NS = 'com.sap.gateway.srvd.zui_las_inspectionlog_o4.v0001';
35
-
36
- /** The action button id suffix FE V4 renders for a manifest custom action. */
37
- export const customActionSelector = (name: string): string => `[id$="::CustomAction::${name}"]`;
38
-
39
- /**
40
- * Fixed active rows for the draft root. Invented values, US sample names; user
41
- * fields are MJOHNSON (the fixture convention). Status / Severity are the raw
42
- * one-letter codes the captured service really carries with no text — the shape
43
- * L1-3's no-raw-codes check has to catch.
44
- */
45
- const row = (n: number, equipment: string, inspector: string, severity: string, status: string) => ({
46
- InspectionUUID: `00000000-0000-4000-8000-00000000000${n}`,
47
- EquipmentName: equipment,
48
- InspectionDate: '2026-09-0' + n,
49
- Inspector: inspector,
50
- Severity: severity,
51
- SeverityCriticality: severity === 'H' ? 1 : severity === 'M' ? 2 : 3,
52
- Status: status,
53
- Note: '',
54
- CreatedBy: 'MJOHNSON',
55
- LocalLastChangedBy: 'MJOHNSON',
56
- IsActiveEntity: true,
57
- HasActiveEntity: false,
58
- HasDraftEntity: false,
59
- });
60
- export const INSPECTION_ROWS = [
61
- row(1, 'Pump P-100', 'Mike Johnson', 'H', 'O'),
62
- row(2, 'Valve V-200', 'Sarah Davis', 'M', 'C'),
63
- row(3, 'Motor M-300', 'David Carter', 'L', 'O'),
64
- ];
65
- export const HARNESS_MOCK_ROWS = { [DRAFT_MAIN_ENTITY]: INSPECTION_ROWS };
66
-
67
- export const BROKEN_MARKER = 'CSP-HARNESS-BROKEN: the Boom handler threw';
68
-
69
- function ctxFor(root: string): ToolContext {
70
- return {
71
- adt: {} as ToolContext['adt'],
72
- sapAlias: 'HARNESS',
73
- session: {} as ToolContext['session'],
74
- cwd: root,
75
- };
76
- }
77
-
78
- async function must(r: { content: string; is_error?: boolean }, what: string): Promise<void> {
79
- if (r.is_error) throw new Error(`${what} failed: ${r.content}`);
80
- }
81
-
82
- export interface HarnessApp {
83
- /** Absolute app dir (holds webapp/). */
84
- dir: string;
85
- appId: string;
86
- /** The page the scaffold's `npm start` opens (FLP sandbox + intent), app-relative. */
87
- startEntry: string;
88
- }
89
-
90
- /** The FLP sandbox entry `npm start` opens for this app. */
91
- export const flpEntry = (app: HarnessApp): string => app.startEntry;
92
-
93
- /** Scaffold the LROP app through the fiori_scaffold_fe handler. `ui5Version` undefined = the product default. */
94
- async function scaffold(root: string, rel: string, appId: string, ui5Version?: string): Promise<HarnessApp> {
95
- await must(
96
- await fioriScaffoldFeHandler(
97
- {
98
- basePath: rel,
99
- appId,
100
- appTitle: 'Inspection Log (harness)',
101
- template: 'lrop',
102
- serviceUrl: 'http://localhost',
103
- servicePath: DRAFT_SERVICE_PATH,
104
- mainEntity: DRAFT_MAIN_ENTITY,
105
- ...(ui5Version ? { ui5Version } : {}),
106
- },
107
- ctxFor(root),
108
- ),
109
- 'fiori_scaffold_fe',
110
- );
111
- const dir = path.join(root, rel);
112
- const pkg = JSON.parse(await readFile(path.join(dir, 'package.json'), 'utf8'));
113
- return { dir, appId, startEntry: openUrlFromStartScript(pkg.scripts.start) };
114
- }
115
-
116
- /** Add a custom table action through the fiori_fe_extend handler. */
117
- async function addTableAction(root: string, rel: string, name: string, settings: Record<string, unknown>): Promise<void> {
118
- await must(
119
- await feExtendHandler(
120
- {
121
- basePath: rel,
122
- op: 'custom-action',
123
- params: {
124
- name,
125
- target: { page: `${DRAFT_MAIN_ENTITY}List`, control: '@com.sap.vocabularies.UI.v1.LineItem' },
126
- settings: { text: name, ...settings },
127
- },
128
- },
129
- ctxFor(root),
130
- ),
131
- 'fiori_fe_extend custom-action',
132
- );
133
- }
134
-
135
- /**
136
- * Point a custom action's `press` at `ext/<Name>.js#onPress` and write that
137
- * module with a known body (the good app's harmless handler, the broken app's
138
- * throwing one), replacing the stub fiori_fe_extend generated.
139
- */
140
- async function wireHandler(app: HarnessApp, name: string, body: string): Promise<void> {
141
- const manifestPath = path.join(app.dir, 'webapp', 'manifest.json');
142
- const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
143
- const action =
144
- manifest['sap.ui5'].routing.targets[`${DRAFT_MAIN_ENTITY}List`].options.settings.controlConfiguration[
145
- '@com.sap.vocabularies.UI.v1.LineItem'
146
- ].actions[name];
147
- action.press = `${app.appId}.ext.${name}.onPress`;
148
- await writeFile(manifestPath, JSON.stringify(manifest, null, 2));
149
- await mkdir(path.join(app.dir, 'webapp', 'ext'), { recursive: true });
150
- await writeFile(
151
- path.join(app.dir, 'webapp', 'ext', `${name}.js`),
152
- `sap.ui.define([], function () {\n 'use strict';\n return { onPress: function () { ${body} } };\n});\n`,
153
- );
154
- }
155
-
156
- /** The good app: scaffold as the product writes it, plus a harmless "Ping" action. */
157
- export async function buildGoodApp(root: string, ui5Version?: string): Promise<HarnessApp> {
158
- const app = await scaffold(root, 'good', 'z.harness.good', ui5Version);
159
- await addTableAction(root, 'good', 'Ping', {});
160
- await wireHandler(app, 'Ping', 'return;');
161
- return app;
162
- }
163
-
164
- /** The broken app: the "Boom" action's handler throws. */
165
- export async function buildBrokenApp(root: string, ui5Version?: string): Promise<HarnessApp> {
166
- const app = await scaffold(root, 'broken', 'z.harness.broken', ui5Version);
167
- await addTableAction(root, 'broken', 'Boom', {});
168
- await wireHandler(app, 'Boom', `throw new Error(${JSON.stringify(BROKEN_MARKER)});`);
169
- return app;
170
- }
171
-
172
- /**
173
- * The broken-press app: a custom action whose press names a handler module that
174
- * does not exist (a renamed or deleted ext file). fiori_fe_extend no longer
175
- * writes an empty press — it always generates a handler — so the realistic
176
- * broken case is a dangling reference.
177
- */
178
- export async function buildBrokenPressApp(root: string, ui5Version?: string): Promise<HarnessApp> {
179
- const app = await scaffold(root, 'broken-press', 'z.harness.brokenpress', ui5Version);
180
- await addTableAction(root, 'broken-press', 'Boom', {});
181
- const manifestPath = path.join(app.dir, 'webapp', 'manifest.json');
182
- const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
183
- manifest['sap.ui5'].routing.targets[`${DRAFT_MAIN_ENTITY}List`].options.settings.controlConfiguration[
184
- '@com.sap.vocabularies.UI.v1.LineItem'
185
- ].actions.Boom.press = `${app.appId}.ext.missing.Missing.onPress`;
186
- await writeFile(manifestPath, JSON.stringify(manifest, null, 2));
187
- return app;
188
- }