@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,47 +1,58 @@
1
1
  import { shown, shownUpTo } from '../register/atc-file.js';
2
+ import { withoutRuleId } from '../register/records.js';
2
3
  import { STEPS, stepOf } from '../journey/clean-core.js';
3
- const WORD = {
4
- keep: 'keep', exception: 'accepted exception', fix: 'fix', rebuild: 'rebuild beside the core', retire: 'retire',
5
- };
6
- export function rowWords(r) {
7
- const parts = [WORD[r.disposition]];
8
- if (r.level)
9
- parts.push(`level ${r.level}`);
10
- // Step 2 (F3): a target is said only when the proposal named one; the record keeps the legacy A, which is no target.
11
- if (r.disposition === 'fix' || r.disposition === 'rebuild')
12
- parts.push(r.targetLevel ? `target ${r.targetLevel}` : 'no target set');
13
- return `${parts.join(', ')}${r.reason ? `, because: ${r.reason}` : ''}`;
4
+ import { proposalOf, viewWords } from '@cspeach/register-core';
5
+ const NO_TARGET = { level: null, from: 'none', by: null };
6
+ /**
7
+ * The row in words: the project view's proposal words (project view Task 5),
8
+ * then the proposal's reason. The card never says the target the model
9
+ * proposed — a Yes does not set one. It says the object's target as the view
10
+ * reads it (`team decide --target`, or the lead's `team target`), else "no target set".
11
+ */
12
+ export function rowWords(r, target) {
13
+ return `${viewWords.proposal(proposalOf(r), target ?? NO_TARGET)}${r.reason ? `, because: ${r.reason}` : ''}`;
14
14
  }
15
+ async function retireYes(r, reason, w) {
16
+ if (w.lead === true && w.rule) {
17
+ try {
18
+ await w.rule({ type: r.objectType.trim().toUpperCase(), name: r.objectName.trim().toUpperCase(), reason });
19
+ return { kind: 'ruled' };
20
+ }
21
+ catch (e) {
22
+ return { kind: 'failed', line: `Not recorded for ${shown(r.objectType)} ${shown(r.objectName)}: ${shownUpTo(e instanceof Error ? e.message : String(e), 200)}` };
23
+ }
24
+ }
25
+ return { kind: 'candidate', reason };
26
+ }
27
+ const ruledLine = (type, name) => `✓ Recorded: ${type} ${name} — retired, reason recorded.`;
28
+ const candidateLine = (type, name) => `✓ Recorded: ${type} ${name} — retire candidate, your reason recorded.`;
15
29
  const DECIDE_HINT = 'cspeach team decide <TYPE> <NAME> <decision>';
16
- /** What a recorded reason keeps (records.ts). */
17
- const MAX_ROW_REASON = 300;
18
30
  const RULE_ROW = {
19
31
  keep: 'keep', fix: 'fix', rebuild: 'rebuild', 'retire-candidate': 'retire',
20
32
  };
21
- /** A "Rule <id>: " the reason starts with — written by the model, so code checks it. */
22
- const RULE_PREFIX = /^\s*rule\s+[A-Za-z0-9_-]{1,20}\s*:\s*/i;
33
+ /** The fact a row that agrees with its rule carries: the rule is said by its note, never as an id inside the reason. */
34
+ export const matchesRuleLine = (note) => `Matches your rule: ${note}`;
23
35
  /**
24
- * The row read against the rule that decides its object. Agrees (same
25
- * disposition): the reason starts "Rule <id>: " (the rule's note when the row
26
- * gave none). Differs: the row stays the model's, and `differs` is the fact
27
- * line "Differs from rule <id>: <why>". No rule: a "Rule <id>:" the model
28
- * wrote is taken off — the row is CSPeach's judgement. The person still
36
+ * The row read against the rule that decides its object. The reason is always
37
+ * the proposal's own words: a "Rule <id>:" the model wrote is taken off, and
38
+ * no rule id is put in (project view Task 4 — the recorded reason is the
39
+ * proposal's text, marked as such). Agrees (same disposition): `matches` is
40
+ * the fact line "Matches your rule: <note>". Differs: `differs` is the fact
41
+ * line "Differs from rule <id>: <why>". No rule: neither. The person still
29
42
  * answers the card either way.
30
43
  */
31
44
  export function ruleRow(r, p) {
32
- const own = (r.reason ?? '').replace(RULE_PREFIX, '').trim();
45
+ const own = withoutRuleId(r.reason ?? '').trim();
46
+ const row = own === r.reason ? r : { ...r, reason: own };
33
47
  if (!p)
34
- return { row: own === r.reason ? r : { ...r, reason: own } };
35
- const id = shown(p.ruleId);
48
+ return { row };
36
49
  const why = shownUpTo(p.why, 200);
37
50
  // Agrees = same disposition, and the same target when the rule itself names one (owner ruling, review 9 #7).
38
51
  const targetDiffers = p.targetFrom === 'rule' && p.target !== undefined && r.targetLevel !== p.target;
39
52
  if (RULE_ROW[p.disposition] !== r.disposition || targetDiffers) {
40
- return { row: own === r.reason ? r : { ...r, reason: own }, differs: `Differs from rule ${id}: ${why}` };
53
+ return { row, differs: `Differs from rule ${shown(p.ruleId)}: ${why}` };
41
54
  }
42
- // Prefix + reason stay within the 300 the record keeps (records.ts would cut the end); cut by code point.
43
- const head = `Rule ${id}: `;
44
- return { row: { ...r, reason: head + Array.from(own || why).slice(0, Math.max(0, MAX_ROW_REASON - head.length)).join('') } };
55
+ return { row, matches: matchesRuleLine(why) };
45
56
  }
46
57
  export async function confirmCleanCoreRows(ex, a) {
47
58
  if (!a.joined) {
@@ -54,12 +65,56 @@ export async function confirmCleanCoreRows(ex, a) {
54
65
  return ex;
55
66
  }
56
67
  const rows = [];
68
+ let ruled = 0;
57
69
  for (const given of ex.content.adjudications) {
58
- const { row: r, differs } = a.rules ? ruleRow(given, a.rules(keyOf(given))) : { row: given, differs: undefined };
59
- const answer = await a.confirm(`Record ${r.objectType} ${r.objectName}: ${rowWords(r)}?${differs ? ` (${differs})` : ''}`, { default: 'n' });
60
- rows.push({ ...r, confirmed: answer === 'y' });
70
+ const { row: r, differs, matches } = a.rules ? ruleRow(given, a.rules(keyOf(given))) : { row: given, differs: undefined, matches: undefined };
71
+ const note = differs ?? matches;
72
+ const question = `Record ${r.objectType} ${r.objectName}: ${rowWords(r, a.targets?.(keyOf(r)))}?${note ? ` (${note})` : ''}`;
73
+ if (r.disposition !== 'retire') {
74
+ const answer = await a.confirm(question, { default: 'n' });
75
+ // A Yes accepts the proposal's words (project view §3.2): marked so, never as the person's.
76
+ rows.push(answer === 'y' ? { ...r, reasonBy: 'proposal', confirmed: true } : { ...r, confirmed: false });
77
+ continue;
78
+ }
79
+ // Task 6 round 1 (B4): a retire Yes asks what the person checked; the model's words are never recorded.
80
+ let done = { ...r, confirmed: false };
81
+ for (let tries = 0; tries < 5; tries++) {
82
+ if ((await a.confirm(question, { default: 'n' })) !== 'y')
83
+ break;
84
+ let said = '';
85
+ if (a.ask) {
86
+ try {
87
+ said = await a.ask(RETIRE_QUESTION);
88
+ }
89
+ catch {
90
+ said = '';
91
+ }
92
+ }
93
+ const reason = cleanKeepReason(said);
94
+ if (!reason) {
95
+ a.log(retireNeedsLine(shown(r.objectType), shown(r.objectName)));
96
+ if (!a.ask)
97
+ break; // nothing can ask: never the same card over and over
98
+ continue;
99
+ }
100
+ const out = await retireYes(r, reason, a);
101
+ if (out.kind === 'failed') {
102
+ a.log(out.line);
103
+ break;
104
+ }
105
+ if (out.kind === 'ruled') {
106
+ ruled += 1;
107
+ a.log(ruledLine(shown(r.objectType), shown(r.objectName)));
108
+ break;
109
+ }
110
+ done = { ...r, reason: out.reason, reasonBy: 'person', confirmed: true };
111
+ a.log(candidateLine(shown(r.objectType), shown(r.objectName)));
112
+ break;
113
+ }
114
+ rows.push(done);
61
115
  }
62
- const yes = rows.filter((r) => r.confirmed).length;
116
+ // A ruling is recorded too (in the lead's own folder), so it counts as confirmed.
117
+ const yes = rows.filter((r) => r.confirmed).length + ruled;
63
118
  a.log(`${yes} of ${rows.length} rows confirmed. To record a row differently: ${DECIDE_HINT}`);
64
119
  const byId = new Map(rows.map((r) => [r.id, r.confirmed]));
65
120
  return {
@@ -69,6 +124,10 @@ export async function confirmCleanCoreRows(ex, a) {
69
124
  };
70
125
  }
71
126
  export const KEEP_QUESTION = 'Why keep it?';
127
+ /** Task 6 (B4): asked on a retire row's Yes, and on the lead's retire card. */
128
+ export const RETIRE_QUESTION = 'What did you check?';
129
+ /** A retire Yes with nothing typed: nothing is recorded, and the card comes again. */
130
+ export const retireNeedsLine = (type, name) => `A retire needs what you checked. Nothing was recorded for ${type} ${name} yet.`;
72
131
  /** "Yes to the remaining n shown" is offered once this many rows are answered. */
73
132
  const OFFER_REST_AFTER = 3;
74
133
  const MAX_KEEP_REASON = 300;
@@ -81,31 +140,44 @@ export function cleanKeepReason(raw) {
81
140
  return Array.from(shownUpTo(raw ?? '', Number.POSITIVE_INFINITY)).slice(0, MAX_KEEP_REASON).join('').trim();
82
141
  }
83
142
  /** The proposal in words, without the reason (the reason is a fact line on the card). */
84
- function proposalWords(r) {
85
- return rowWords({ ...r, reason: '' });
143
+ function proposalWords(r, target) {
144
+ return rowWords({ ...r, reason: '' }, target);
86
145
  }
87
146
  export async function decideCleanCoreRows(ex, j) {
88
147
  const all = ex.content.adjudications;
89
- const ruled = all.map((r) => (j.rules ? ruleRow(r, j.rules(keyOf(r))) : { row: r, differs: undefined }));
148
+ const ruled = all.map((r) => (j.rules ? ruleRow(r, j.rules(keyOf(r))) : { row: r, differs: undefined, matches: undefined }));
90
149
  const rows = ruled.map(({ row }) => ({ ...row, confirmed: false }));
91
- const tally = { total: all.length, yes: [], kept: [], skipped: [], cancelled: false };
150
+ const tally = { total: all.length, yes: [], kept: [], skipped: [], cancelled: false, ruled: [], candidates: [] };
92
151
  const footnote = `Step ${stepOf('DECIDE')} of ${STEPS}`;
152
+ const isRetire = (r) => r.disposition === 'retire';
153
+ /** "Yes to the remaining n shown" said: the rows that are not a retire are recorded as proposed; each retire is still asked. */
154
+ let restYes = false;
93
155
  let answered = 0;
94
156
  rowLoop: for (let i = 0; i < rows.length; i++) {
95
157
  const r = rows[i];
158
+ // Recorded already, when "Yes to the remaining n" was said.
159
+ if (restYes && !isRetire(r))
160
+ continue;
96
161
  const type = shown(r.objectType);
97
162
  const name = shown(r.objectName);
98
- const left = rows.length - i;
163
+ // Task 6: "the remaining n" counts only what it records — a retire is always asked on its own.
164
+ const left = rows.slice(i).filter((x) => !isRetire(x)).length;
99
165
  const why = shownUpTo(r.reason ?? '', MAX_KEEP_REASON);
100
166
  let canKeep = true;
101
167
  let settled = false;
102
168
  for (let tries = 0; tries < 20 && !settled; tries++) {
103
- const offerRest = answered >= OFFER_REST_AFTER && left >= 2;
169
+ const offerRest = !restYes && answered >= OFFER_REST_AFTER && left >= 2;
104
170
  const res = await j.card({
105
171
  id: 'journey-row',
106
172
  tag: 'QUESTION',
107
- title: `Record ${type} ${name}: ${proposalWords({ ...r, objectType: type, objectName: name })}?`,
108
- facts: [...(why ? [`Why: ${why}`] : []), ...(ruled[i].differs ? [ruled[i].differs] : []), `Row ${i + 1} of ${rows.length}.`],
173
+ // Project view Task 5: what was proposed, in the view's words; the reason is the proposal's, never the person's.
174
+ title: `Record ${type} ${name} as proposed: ${proposalWords({ ...r, objectType: type, objectName: name }, j.targets?.(keyOf(r)))}?`,
175
+ facts: [
176
+ ...(why ? [viewWords.cardReason({ text: why, from: 'proposal' })] : []),
177
+ ...(ruled[i].differs ? [ruled[i].differs] : []),
178
+ ...(ruled[i].matches ? [ruled[i].matches] : []),
179
+ `Row ${i + 1} of ${rows.length}.`,
180
+ ],
109
181
  choices: [
110
182
  { value: 'yes', label: 'Yes' },
111
183
  ...(canKeep ? [{ value: 'keep', label: 'Keep as is' }] : []),
@@ -123,14 +195,55 @@ export async function decideCleanCoreRows(ex, j) {
123
195
  break rowLoop;
124
196
  }
125
197
  if (res.value === 'all' && offerRest) {
198
+ // Round 1: every row from here that is not a retire is recorded now, so a card put away later keeps them.
199
+ restYes = true;
126
200
  for (let k = i; k < rows.length; k++) {
127
- rows[k] = { ...rows[k], confirmed: true };
201
+ if (isRetire(rows[k]))
202
+ continue;
203
+ rows[k] = { ...rows[k], reasonBy: 'proposal', confirmed: true };
128
204
  tally.yes.push(keyOf(rows[k]));
129
205
  }
130
- break rowLoop;
206
+ // This row is a retire: its own card again, without the offer.
207
+ if (isRetire(r))
208
+ continue;
209
+ settled = true;
210
+ continue;
211
+ }
212
+ if (res.value === 'yes' && isRetire(r)) {
213
+ // Task 6 (B4): what the person checked is the reason recorded — never the model's words.
214
+ let said;
215
+ try {
216
+ said = await j.ask(RETIRE_QUESTION);
217
+ }
218
+ catch {
219
+ tally.cancelled = true;
220
+ break rowLoop;
221
+ }
222
+ const reason = cleanKeepReason(said);
223
+ if (!reason) {
224
+ j.log(retireNeedsLine(type, name));
225
+ continue;
226
+ }
227
+ // The lead's Yes is the ruling; the row stays unconfirmed, so no writer row is added.
228
+ const out = await retireYes(r, reason, j);
229
+ if (out.kind === 'failed') {
230
+ j.log(out.line);
231
+ tally.skipped.push(keyOf(r));
232
+ j.skip([keyOf(r)]);
233
+ }
234
+ else if (out.kind === 'ruled') {
235
+ tally.ruled.push(keyOf(r));
236
+ }
237
+ else {
238
+ rows[i] = { ...r, reason: out.reason, reasonBy: 'person', confirmed: true };
239
+ tally.yes.push(keyOf(r));
240
+ tally.candidates.push(keyOf(r));
241
+ }
242
+ settled = true;
243
+ continue;
131
244
  }
132
245
  if (res.value === 'yes') {
133
- rows[i] = { ...r, confirmed: true };
246
+ rows[i] = { ...r, reasonBy: 'proposal', confirmed: true };
134
247
  tally.yes.push(keyOf(r));
135
248
  settled = true;
136
249
  continue;
@@ -160,7 +273,7 @@ export async function decideCleanCoreRows(ex, j) {
160
273
  j.log(`A keep needs a reason. Nothing was recorded for ${type} ${name} yet.`);
161
274
  continue;
162
275
  }
163
- rows[i] = { ...r, disposition: 'exception', targetLevel: null, reason, confirmed: true };
276
+ rows[i] = { ...r, disposition: 'exception', targetLevel: null, reason, reasonBy: 'person', confirmed: true };
164
277
  tally.kept.push({ key: keyOf(r), type: r.objectType.toUpperCase(), name: r.objectName.toUpperCase(), level: r.level });
165
278
  settled = true;
166
279
  }
@@ -191,9 +304,18 @@ export async function decideCleanCoreRows(ex, j) {
191
304
  export function journeyRowLines(t, refused) {
192
305
  const out = new Set(refused.map((k) => k.trim().toUpperCase()));
193
306
  const kept = t.kept.filter((k) => !out.has(k.key));
194
- const yes = t.yes.filter((k) => !out.has(k.toUpperCase())).length;
307
+ const ruled = t.ruled ?? [];
308
+ const candidates = (t.candidates ?? []).filter((k) => !out.has(k.toUpperCase()));
309
+ // A ruling is recorded too (in the lead's own folder; never refused here).
310
+ const yes = t.yes.filter((k) => !out.has(k.toUpperCase())).length + ruled.length;
311
+ const said = (key) => {
312
+ const [type = '', name = ''] = key.split('|');
313
+ return [shown(type), shown(name)];
314
+ };
195
315
  return [
196
316
  ...kept.map((k) => `✓ Recorded: ${shown(k.type)} ${shown(k.name)} — kept at ${k.level}, reason recorded.`),
317
+ ...ruled.map((k) => ruledLine(...said(k))),
318
+ ...candidates.map((k) => candidateLine(...said(k))),
197
319
  `${yes} of ${t.total} rows recorded; ${kept.length} kept by decision; ${t.skipped.length} skipped.`,
198
320
  ];
199
321
  }
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Parser for the `<!-- csforge:cca-manifest ... -->` block that
3
- * /abap-cca and /abap-cca-merge emit at the end of their markdown
4
- * output. Hidden from human renderers, structured enough for a
5
- * deterministic extractor.
3
+ * /abap-cca emitted at the end of its markdown output (an old saved one
4
+ * still loads; the register reads it for decision records). Hidden from
5
+ * human renderers, structured enough for a deterministic extractor.
6
6
  *
7
7
  * Format mirrors the upgrade-manifest pattern (see extract-upgrade.ts):
8
8
  *
@@ -104,9 +104,10 @@ function asConfidence(raw) {
104
104
  /**
105
105
  * Parse the optional 6th table cell on a merged row:
106
106
  * `conflict=<label>:<cls>;<label>:<cls>`
107
- * (rendered by merge-cca.ts:renderCcaMergeManifest). Returns undefined
108
- * for absent / unrecognisable cells; invalid classification values are
109
- * skipped pair-wise so one typo doesn't drop the whole record.
107
+ * (rendered by the former merge-cca.ts, cut 2026-10-20; files it saved
108
+ * still load). Returns undefined for absent / unrecognisable cells;
109
+ * invalid classification values are skipped pair-wise so one typo
110
+ * doesn't drop the whole record.
110
111
  */
111
112
  function parseConflictCell(raw) {
112
113
  if (!raw)
@@ -165,7 +166,7 @@ export function extractCcaAssessment(markdown) {
165
166
  // Battery S4 caught manifests whose counts contradicted their own rows
166
167
  // (`retire: 0` next to 7 retire rows) and tables silently truncated
167
168
  // (27/104 rows, no marker). These manifests are CHAIN INPUT for
168
- // /abap-cca-merge — a quiet save corrupts the consolidated estate
169
+ // the register's decision records — a quiet save corrupts the estate
169
170
  // numbers, so a contradiction fails the extract LOUDLY instead (same
170
171
  // philosophy as extract-plan's loud-failure path in save-command.ts).
171
172
  const truncated = kv.classifications_truncated === 'true';
@@ -6,7 +6,6 @@ export { canonicalSha256 } from './canonicalize.js';
6
6
  export { titleSlug, shortId, projectFilename } from './filename.js';
7
7
  export { runSaveCommand, detectArtifactSkill, peekPlanManifestTitle, resolvePriorPlanEnvelope } from './save-command.js';
8
8
  export { offerAnswerBlockers, openBlockers, applyBlockerAnswers } from './answer-blockers.js';
9
- export { mergeCcaAssessments, renderCcaMergeManifest, parseScopePatterns, scopePatternsIntersect, CCA_MANIFEST_ROW_CAP } from './merge-cca.js';
10
9
  export { mergeUpgradeProgress, renderUpgradeMergeManifest } from './merge-upgrade.js';
11
10
  export { extractDesign } from './extract-design.js';
12
11
  export { extractEstimate } from './extract-estimate.js';
@@ -3,8 +3,8 @@
3
3
  * into ONE consolidated upgrade-progress (the /abap-upgrade-merge
4
4
  * skill's engine).
5
5
  *
6
- * WHY CODE, NOT PROSE: see merge-cca.ts header — battery S4 (defects
7
- * D6/D7) showed the model cannot keep counts and rows consistent when
6
+ * WHY CODE, NOT PROSE: battery S4 (docs/research/battery/
7
+ * s4-cca-scorecard.md, defects D6/D7) showed the model cannot keep counts and rows consistent when
8
8
  * reconciling envelopes by hand. This module owns the arithmetic.
9
9
  *
10
10
  * Rules implemented (mirrors .claude/skills/abap-upgrade-merge/SKILL.md):
@@ -25,7 +25,8 @@ const real = (p) => { try {
25
25
  catch {
26
26
  return path.resolve(p);
27
27
  } };
28
- const key = (p) => { const r = real(p).replace(/[\\/]+$/, ''); return process.platform === 'win32' ? r.toLowerCase() : r; };
28
+ /** A folder as the file system opens it: links followed, no trailing separator, case-folded on Windows. */
29
+ export const key = (p) => { const r = real(p).replace(/[\\/]+$/, ''); return process.platform === 'win32' ? r.toLowerCase() : r; };
29
30
  /** The team project id when `cwd` holds a valid cspeach-team.json, else null. */
30
31
  export function teamProjectId(cwd) {
31
32
  try {
@@ -72,14 +72,6 @@ const SKILL_REGISTRY = {
72
72
  itemNoun: 'classifications',
73
73
  manifestMarker: 'cca-manifest',
74
74
  },
75
- 'abap-cca-merge': {
76
- // Merge produces a NEW cca-assessment (consolidated) — same shape as
77
- // a single consultant's slice, just reconciled across N parallel reviews.
78
- artefactType: 'cca-assessment',
79
- extract: (md) => extractCcaAssessment(md),
80
- itemNoun: 'classifications',
81
- manifestMarker: 'cca-manifest',
82
- },
83
75
  'abap-modernize': {
84
76
  artefactType: 'modernize-result',
85
77
  extract: (md) => extractModernizeResult(md),
@@ -314,8 +306,8 @@ export async function runSaveCommand(args) {
314
306
  // extractor throws, and bailing here would silently lose an artifact that
315
307
  // carries a complete, recognizable manifest. If the manifest resolves to
316
308
  // a DIFFERENT registry skill, retry the save under that skill. Skipped
317
- // when both skills share the same manifest-marker family (abap-cca-merge
318
- // ↔ abap-cca, abap-upgrade-merge ↔ abap-upgrade-fix): the family
309
+ // when both skills share the same manifest-marker family
310
+ // (abap-upgrade-merge ↔ abap-upgrade-fix): the family
319
311
  // extractor already ran and failed, and re-dispatching would only launder
320
312
  // merge provenance onto the non-merge skill — keep the label's failure.
321
313
  const detected = detectArtifactSkill(args.skillOutput);
@@ -401,7 +393,9 @@ export async function runSaveCommand(args) {
401
393
  // the row cards go through `confirm` only (never `prompt`), and a headless
402
394
  // run confirms no row. Any further question routed through `prompt` would
403
395
  // silently receive 'y' too: give maybeOfferSave's headless prompt a
404
- // question-aware answer first.
396
+ // question-aware answer first. (Task 6 round 1: a retire row's "What did
397
+ // you check?" uses `prompt`, but only when `confirm` is given — which
398
+ // maybeOfferSave never does headless — so the blanket 'y' never reaches it.)
405
399
  // Inside a journey a triage is saved without the question: the journey needs the rows.
406
400
  const journey = spec.artefactType === 'clean-core-decision' ? args.journey : undefined;
407
401
  const answer = journey ? 'y' : await askYesNo({ question: 'Save as project file?', confirm: args.confirm, prompt: args.prompt });
@@ -412,6 +406,8 @@ export async function runSaveCommand(args) {
412
406
  // 2026-10-06 — a clean-core triage becomes team project decisions only row by
413
407
  // row, each on its own Yes. Without a joined team project nothing is asked.
414
408
  let tally;
409
+ /** Task 6: rulings the lead's retire Yes wrote during this save (no writer row beside them). */
410
+ let ruledHere = 0;
415
411
  if (spec.artefactType === 'clean-core-decision') {
416
412
  const { confirmCleanCoreRows, decideCleanCoreRows } = await import('./clean-core-confirm.js');
417
413
  const { activeProject } = await import('../register/store.js');
@@ -433,10 +429,48 @@ export async function runSaveCommand(args) {
433
429
  rules = () => null;
434
430
  }
435
431
  }
432
+ // Task 6 (B4): the lead's retire Yes is the ruling `team rule` writes, run in process —
433
+ // on the journey's row cards and on the Yes/No cards alike. Unreadable project file →
434
+ // not the lead: a retire stays a candidate. Round 1: the card says the object's
435
+ // target as the project view reads it (a person or the lead set it), never the
436
+ // model's; unreadable → "no target set".
437
+ let retire = {};
438
+ let targets;
439
+ if (project) {
440
+ const { isLead, ruleObject } = await import('../commands/team.js');
441
+ const teamDeps = { print: () => { }, identity: () => ({ name: project.name ?? args.author.name }) };
442
+ try {
443
+ const { readProjectFile } = await import('../register/store.js');
444
+ if (isLead(readProjectFile(project.projectDir), teamDeps)) {
445
+ retire = {
446
+ lead: true,
447
+ rule: async (x) => {
448
+ ruleObject({ type: x.type, name: x.name, classification: 'retire', reason: x.reason }, teamDeps);
449
+ ruledHere += 1;
450
+ },
451
+ };
452
+ }
453
+ }
454
+ catch {
455
+ retire = {};
456
+ }
457
+ try {
458
+ // Project view Task 5: the view is built once here and answers each card's target.
459
+ const { registerFor } = await import('../register/picture.js');
460
+ const { readProjectFile } = await import('../register/store.js');
461
+ const { projectView } = await import('@cspeach/register-core');
462
+ const view = projectView(registerFor(project.projectDir, project.outboxDir).register, readProjectFile(project.projectDir));
463
+ targets = (key) => view.object(key.trim().toUpperCase())?.target;
464
+ }
465
+ catch {
466
+ targets = undefined;
467
+ }
468
+ }
436
469
  if (journey && joined) {
437
470
  // Guided journey: [Yes] [Keep as is] [Skip] per row.
438
471
  const r = await decideCleanCoreRows(extract, {
439
472
  card: journey.card, ask: journey.ask, skip: journey.skip, log: args.log, ...(rules ? { rules } : {}),
473
+ ...retire, ...(targets ? { targets } : {}),
440
474
  });
441
475
  extract = r.extract;
442
476
  tally = r.tally;
@@ -449,7 +483,9 @@ export async function runSaveCommand(args) {
449
483
  }
450
484
  else {
451
485
  extract = await confirmCleanCoreRows(extract, {
452
- ...(args.confirm ? { confirm: args.confirm } : {}), log: args.log, joined, ...(rules ? { rules } : {}),
486
+ // B5: the free-text "What did you check?" goes with `confirm` only — never the headless blanket-'y' prompt.
487
+ ...(args.confirm ? { confirm: args.confirm, ask: args.prompt } : {}), log: args.log, joined, ...(rules ? { rules } : {}),
488
+ ...retire, ...(targets ? { targets } : {}),
453
489
  });
454
490
  }
455
491
  }
@@ -540,7 +576,8 @@ export async function runSaveCommand(args) {
540
576
  }
541
577
  else if (res?.file)
542
578
  args.log(`Recorded for team project ${joined.projectId}: ${res.written} objects → ${basename(res.file)}`);
543
- if (res?.file) {
579
+ // A ruling made on a row card changes the list too, with no writer row beside it (Task 6).
580
+ if (res?.file || ruledHere > 0) {
544
581
  const { regenerateWorkingList } = await import('../register/workbooks.js');
545
582
  // The name the team project knows this laptop by (init --lead), as the team verbs use.
546
583
  const sheet = regenerateWorkingList(joined, joined.name ?? args.author.name);
@@ -578,7 +615,7 @@ export async function runSaveCommand(args) {
578
615
  else if (journey.page?.length) {
579
616
  // One triage per page: a row of the page the triage left out is put off
580
617
  // until "carry on", so the journey moves on instead of asking it again — and says so.
581
- const answered = new Set([...tally.yes, ...tally.kept.map((k) => k.key), ...tally.skipped]);
618
+ const answered = new Set([...tally.yes, ...tally.kept.map((k) => k.key), ...tally.skipped, ...tally.ruled]);
582
619
  const left = journey.page.filter((k) => !answered.has(k.toUpperCase()));
583
620
  if (left.length) {
584
621
  journey.leaveForLater(left);
@@ -606,6 +606,8 @@ function shortHint(filename) {
606
606
  * char boundary because we slice the decoded string, not the bytes.
607
607
  */
608
608
  const MAX_TEXT_FILE_CHARS = 64 * 1024;
609
+ /** The note after a cut attachment (also refused in SAP writes: tools/cut-markers.ts). */
610
+ export const ATTACHMENT_TRUNCATED_NOTE = '... [truncated, file exceeds 64 KB]';
609
611
  /**
610
612
  * Scan `userMessage` for `@<token>` references that resolve to .txt /
611
613
  * .md files in the workspace, read their content, and substitute the
@@ -690,9 +692,10 @@ export async function expandTextFileAttachments(userMessage, cwd, log) {
690
692
  try {
691
693
  const buf = readFileSync(path);
692
694
  const ext = path.toLowerCase().endsWith('.pdf') ? '.pdf' : '.docx';
693
- const { extractDocument } = await import('../tools/filesystem/extract-document.js');
695
+ const { extractDocument, splitLongLines, DOC_MAX_LINE_CHARS } = await import('../tools/filesystem/extract-document.js');
694
696
  const res = await extractDocument(buf, ext, { maxPages: 150 });
695
- content = res.text;
697
+ // 2026-10-22 — long lines split as in read_document; the 64 KB cap below is the total.
698
+ content = splitLongLines(res.text, DOC_MAX_LINE_CHARS);
696
699
  if (log)
697
700
  for (const w of res.warnings)
698
701
  log(`(${filename}: ${w})`);
@@ -714,7 +717,7 @@ export async function expandTextFileAttachments(userMessage, cwd, log) {
714
717
  }
715
718
  }
716
719
  if (content.length > MAX_TEXT_FILE_CHARS) {
717
- content = content.slice(0, MAX_TEXT_FILE_CHARS) + '\n... [truncated, file exceeds 64 KB]';
720
+ content = content.slice(0, MAX_TEXT_FILE_CHARS) + '\n' + ATTACHMENT_TRUNCATED_NOTE;
718
721
  }
719
722
  const replacement = `<attached file="${filename}">\n${content}\n</attached>`;
720
723
  substitutions.push({ index, matchLen: match.length, replacement });
@@ -2,7 +2,8 @@
2
2
  * Decision rules for the joined team project (step 2, D6): which rule decides
3
3
  * each object, read from the same register and findings team_findings shows,
4
4
  * with the project's own rules or the defaults. The row cards use it to say
5
- * "Rule <id>: …" or "Differs from rule <id>: …". Read only; never writes the
5
+ * "Matches your rule: <note>" or "Differs from rule <id>: …" (never a rule id
6
+ * inside a recorded reason, project view Task 4). Read only; never writes the
6
7
  * rules to the file.
7
8
  */
8
9
  import { evaluateRules, readDecisionRules } from '@cspeach/register-core';
@@ -9,6 +9,7 @@
9
9
  * — set by `cspeach team export --always` or the journey's report card. No
10
10
  * hidden state. Default off.
11
11
  */
12
+ import fs from 'node:fs';
12
13
  import path from 'node:path';
13
14
  import { csvText, exportSet } from '@cspeach/register-core';
14
15
  import { HELD } from './workbooks.js';
@@ -21,6 +22,23 @@ export function exportsOn(project) {
21
22
  const e = project.exports;
22
23
  return !!e && typeof e === 'object' && e.csv === true;
23
24
  }
25
+ /** True only when the switch was turned off on purpose (`team export --never`): files already there stay as they are. */
26
+ export function exportsOff(project) {
27
+ const e = project.exports;
28
+ return !!e && typeof e === 'object' && e.csv === false;
29
+ }
30
+ /**
31
+ * G5: an export already in `dir` (its manifest.json is there). A report then
32
+ * rewrites it, so the CSV files a person's tools read never go stale.
33
+ */
34
+ export function exportsPresent(dir) {
35
+ try {
36
+ return fs.statSync(path.join(dir, 'manifest.json')).isFile();
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ }
24
42
  /** Set the switch on or off: the whole project file, then renamed. Every other field stays as it is. */
25
43
  export function setExports(projectDir, on) {
26
44
  const project = readProjectFile(projectDir);
@@ -1,14 +1,14 @@
1
1
  import path from 'node:path';
2
2
  import { buildRegister, narrowedLanes } from '@cspeach/register-core';
3
3
  import { collectRecords, readBaseline, readEstate, readLanes, readProjectFile } from './store.js';
4
- export function registerFor(projectDir, outboxDir) {
5
- const project = readProjectFile(projectDir);
4
+ /** `project`: the folder's project file when the caller has already read it (one read, one answer). */
5
+ export function registerFor(projectDir, outboxDir, project = readProjectFile(projectDir)) {
6
6
  const collected = collectRecords(projectDir);
7
7
  // This laptop's own outbox counts too, so a developer sees their work before
8
8
  // handing it in. It is also the one way this picture can legitimately differ
9
9
  // from the team project page's, so `status` says out loud when it counted them.
10
10
  const own = outboxDir && path.resolve(outboxDir) !== path.resolve(projectDir) ? collectRecords(outboxDir) : { records: [], unreadable: 0, files: 0 };
11
- const baseline = readBaseline(projectDir);
11
+ const baseline = readBaseline(projectDir, project);
12
12
  const lanes = readLanes(projectDir);
13
13
  // The newest discovery, when the folder holds one: the estate around the team project.
14
14
  const estate = readEstate(projectDir)?.estate;