@cspeach/cli 1.1.31 → 1.1.33

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 (66) hide show
  1. package/dist/agent/loop.js +16 -6
  2. package/dist/cli.js +13 -4
  3. package/dist/commands/cost.js +6 -2
  4. package/dist/commands/help.js +18 -2
  5. package/dist/commands/plan-chain.js +3 -0
  6. package/dist/commands/plan-gate.js +10 -1
  7. package/dist/commands/plan-resume.js +4 -0
  8. package/dist/commands/team-rules.js +187 -0
  9. package/dist/commands/team.js +632 -90
  10. package/dist/commands/usage.js +106 -0
  11. package/dist/cost/month-usage.js +45 -0
  12. package/dist/journey/chain.js +265 -15
  13. package/dist/journey/clean-core.js +25 -3
  14. package/dist/journey/flow.js +51 -1
  15. package/dist/journey/rebuild.js +464 -0
  16. package/dist/journey/run.js +213 -20
  17. package/dist/journey/session-deps.js +5 -1
  18. package/dist/lane/app-chain.js +12 -2
  19. package/dist/one-shot.js +24 -17
  20. package/dist/projects/clean-core-confirm.js +38 -5
  21. package/dist/projects/save-command.js +20 -3
  22. package/dist/register/baseline.js +39 -2
  23. package/dist/register/decision-rules.js +35 -0
  24. package/dist/register/discover.js +49 -19
  25. package/dist/register/export-files.js +58 -0
  26. package/dist/register/measure.js +191 -9
  27. package/dist/register/plan-import.js +6 -3
  28. package/dist/register/records.js +15 -3
  29. package/dist/register/rule-proposal.js +4 -0
  30. package/dist/register/rules-edit.js +257 -0
  31. package/dist/register/status-file/register-status-file.js +106 -108
  32. package/dist/register/status-file/report-model.js +569 -0
  33. package/dist/register/status-file/team-css.js +1 -1
  34. package/dist/register/status-file-sync.js +1 -0
  35. package/dist/register/status-html.js +25 -6
  36. package/dist/register/store.js +4 -3
  37. package/dist/register/variant.js +65 -0
  38. package/dist/register/workbooks.js +22 -56
  39. package/dist/register/write-whole.js +30 -2
  40. package/dist/renderer/tool-labels.js +2 -1
  41. package/dist/repl/builtin-commands.js +7 -0
  42. package/dist/repl/journey-entry.js +4 -2
  43. package/dist/repl/plan-hop.js +2 -9
  44. package/dist/repl/rules-menu.js +177 -0
  45. package/dist/repl/slash-completer.js +2 -0
  46. package/dist/repl/slash-picker.js +5 -4
  47. package/dist/repl.js +157 -86
  48. package/dist/router/classifier.js +8 -4
  49. package/dist/router/router-core.js +43 -7
  50. package/dist/router/routing-decision.js +1 -1
  51. package/dist/runs/bundled-runs.js +100 -0
  52. package/dist/runs/front-door.js +19 -0
  53. package/dist/runs/manifest-runs.js +50 -0
  54. package/dist/runs/run-command.js +24 -0
  55. package/dist/runs/runs.js +27 -0
  56. package/dist/runs/shadow.js +100 -0
  57. package/dist/runs/types.js +1 -0
  58. package/dist/session/queued-step.js +45 -0
  59. package/dist/skills/bundled-skills.js +53 -53
  60. package/dist/tools/dispatch-skill.js +8 -4
  61. package/dist/tools/lane/app-lane-resolve.js +1 -1
  62. package/dist/tools/project/rules_propose.js +59 -0
  63. package/dist/tools/project/team_findings.js +13 -2
  64. package/dist/upgrade-check.js +49 -4
  65. package/package.json +120 -120
  66. package/.cspeach/project.json +0 -4
@@ -94,6 +94,12 @@ export function setJourneyQueued(session, step) {
94
94
  f.queued = { ...step };
95
95
  else
96
96
  delete f.queued;
97
+ // The step the chain is at is now this object's (ruling on follow-up a).
98
+ if (step)
99
+ lastAt.set(session, { stage: step.stage, ...(step.key ? { key: step.key } : {}) });
100
+ // Review M2: a step queued is a step the chain got through: the stop count starts over.
101
+ if (step)
102
+ stops.delete(session);
97
103
  }
98
104
  /** End the journey: the flow goes, the guarded run ends; skips stay for the session. */
99
105
  export function clearJourneyFlow(session) {
@@ -113,6 +119,43 @@ export function pauseJourneyFlow(session, offset = 0) {
113
119
  paused.set(session, userTurnCount(session) + offset);
114
120
  }
115
121
  export const isJourneyPaused = (session) => !!session && paused.has(session);
122
+ /**
123
+ * The stage the chain last reached (set each round by `drive`). Kept for the
124
+ * session across a pause or an ended run: an error that stops the chain before
125
+ * it can read the folder again still belongs to the step it last reached.
126
+ */
127
+ const lastAt = new WeakMap();
128
+ /** A new stage drops the object; the same stage keeps it until another object is queued. */
129
+ export function setJourneyAt(session, stage) {
130
+ const cur = lastAt.get(session);
131
+ // Review M2: a new stage is progress: the stop count starts over.
132
+ if (cur?.stage !== stage)
133
+ stops.delete(session);
134
+ lastAt.set(session, cur?.stage === stage ? cur : { stage });
135
+ }
136
+ export const journeyAt = (session) => lastAt.get(session)?.stage;
137
+ /**
138
+ * The step an error stopped: the stage on the object it was working on (a FIX
139
+ * on ZCL_A is not a FIX on ZCL_B), or the stage alone when it has no object.
140
+ */
141
+ export function journeyStepKey(session) {
142
+ const at = lastAt.get(session);
143
+ return at ? (at.key ? `${at.stage} ${at.key}` : at.stage) : undefined;
144
+ }
145
+ /**
146
+ * Stops on an error, by step, for the session (must-fix follow-up a): kept
147
+ * across a pause and an ended run, so a step that fails again after "carry on"
148
+ * is told apart from a new failure. Counted only IN A ROW: a stop at another
149
+ * step, a new stage reached, or a step queued (the chain got through) starts over.
150
+ */
151
+ const stops = new WeakMap();
152
+ /** Count this stop: how many times in a row the chain has stopped at `step`. */
153
+ export function noteJourneyStop(session, step) {
154
+ const last = stops.get(session);
155
+ const count = last && last.step === step ? last.count + 1 : 1;
156
+ stops.set(session, { step, count });
157
+ return count;
158
+ }
116
159
  /** Paused, and no turn of the person's has run since the pause line. */
117
160
  export const isJourneyPausedJustNow = (session) => !!session && paused.has(session) && paused.get(session) === userTurnCount(session);
118
161
  /** The session skips as the stage machine reads them. */
@@ -126,6 +169,7 @@ export function journeySkips(session) {
126
169
  ...(s.fix.size ? { fix: new Set(s.fix) } : {}),
127
170
  ...(s.prove ? { prove: true } : {}),
128
171
  ...(s.report ? { report: true } : {}),
172
+ ...(s.rebuild?.size ? { rebuild: new Set(s.rebuild) } : {}),
129
173
  };
130
174
  }
131
175
  export function addJourneySkips(session, add) {
@@ -140,6 +184,11 @@ export function addJourneySkips(session, add) {
140
184
  s.prove = true;
141
185
  if (add.report)
142
186
  s.report = true;
187
+ if (add.rebuild?.size) {
188
+ s.rebuild ??= new Set();
189
+ for (const k of add.rebuild)
190
+ s.rebuild.add(k);
191
+ }
143
192
  skips.set(session, s);
144
193
  }
145
194
  /**
@@ -155,12 +204,13 @@ export function addJourneyLeftOut(session, keys) {
155
204
  s.leftOut.add(k);
156
205
  skips.set(session, s);
157
206
  }
158
- /** "Carry on" asks the check, the report and the rows the triage left out again; fixes and rows skipped stay put off. */
207
+ /** "Carry on" asks the check, the report, the rebuild hand-offs and the rows the triage left out again; fixes and rows skipped stay put off. */
159
208
  export function forgetNotNow(session) {
160
209
  const s = skips.get(session);
161
210
  if (!s)
162
211
  return;
163
212
  delete s.prove;
164
213
  delete s.report;
214
+ delete s.rebuild;
165
215
  delete s.leftOut;
166
216
  }
@@ -0,0 +1,464 @@
1
+ /**
2
+ * Step 2, Task 5 — the chained rebuild hand-off (spec §3.5, D3; reviews F1, F2,
3
+ * F4, F5, F6, F9, F17; plan review C5, I6, I7, cut 4).
4
+ *
5
+ * A rebuild never runs inside the clean-core fix loop. At the journey's end
6
+ * (DONE) CSPeach proposes the KIND per object (or per `app` label) on one card;
7
+ * Yes writes the `handed-off` row on the old object, ENDS the journey (F1),
8
+ * and only then queues the chain for that kind:
9
+ *
10
+ * app /abap-app @rebuilds/<APP>/rebuild-brief.md (the app lane routes RAP / CAP / SEGW)
11
+ * class /abap-modernize <TYPE> <NAME> — <reason>
12
+ * api /abap-modernize <TYPE> <NAME> — <reason>; API service, no UI (tonight, F2)
13
+ *
14
+ * Every command is built here from the register (names upper-cased and checked
15
+ * like refactorCommand, the reason through cleanReason); the model never
16
+ * authors one. The brief is written by code from the fixed F5 template: the
17
+ * card's "Why" is never copied into it.
18
+ *
19
+ * The `rebuild` row then moves on from the existing hooks, never from session
20
+ * memory alone: `started` when a plan is saved for a spec under
21
+ * `<project>/rebuilds/<APP>/` (afterPlanSave, I6) or — for /abap-modernize —
22
+ * on the first successful SAP write of its turns; `built` when that plan
23
+ * completes (afterPlanComplete, C5) or by `cspeach team rebuild built`.
24
+ * `proven` (a code-run ATC over the new objects) is not written tonight: the
25
+ * status stops at "built, not yet checked".
26
+ */
27
+ import fs from 'node:fs';
28
+ import path from 'node:path';
29
+ import { APP_LABEL, isAppLabel, objectKey, proposeRebuildKind, } from '@cspeach/register-core';
30
+ import { activeProject } from '../register/store.js';
31
+ import { registerFor } from '../register/picture.js';
32
+ import { writeRecord } from '../register/records.js';
33
+ import { writeWhole } from '../register/write-whole.js';
34
+ import { cspeachWritesThisSession } from '../register/offer.js';
35
+ import { shown } from '../register/atc-file.js';
36
+ import { cleanReason, OBJECT_NAME, OBJECT_TYPE, objectShown } from './run.js';
37
+ import { clearJourneyFlow } from './flow.js';
38
+ import { whoAmI } from '../commands/team.js';
39
+ import { setRebuildRunActive } from '../commands/plan-gate.js';
40
+ /* ── words ──────────────────────────────────────────────────────────────── */
41
+ export const KIND_WORDS = {
42
+ app: 'an app', class: 'a clean class on released APIs', api: 'an API service',
43
+ };
44
+ const KIND_CHOICES = [
45
+ { value: 'app', label: 'An app' }, { value: 'class', label: 'A clean class' }, { value: 'api', label: 'An API service' },
46
+ ];
47
+ /** F1: said once, as the journey ends for the rebuild. */
48
+ export const HAND_OFF_LINE = (name) => `${name}'s rebuild goes on in its own run. Say carry on with clean core later for the rest.`;
49
+ /** Audit M-2 — the queued rebuild on screen: CSPeach's own step, never "You › /abap-modernize …". */
50
+ export const REBUILD_STEP_LINE = (name) => `CSPeach is starting the rebuild of ${shown(name).trim()}…`;
51
+ export const UNSURE_LINE = 'CSPeach cannot tell the kind from what it read. Change it if it is wrong.';
52
+ /** Where the brief is written, relative to the project folder (the journey's cwd). */
53
+ export const REBUILD_BRIEF_REL = (app) => `rebuilds/${app}/rebuild-brief.md`;
54
+ const SID = /^[A-Z][A-Z0-9]{2}$/;
55
+ const PACKAGE = /^(\/[A-Z0-9_]{1,10}\/)?[A-Z0-9_$]{1,30}$/;
56
+ const LEVEL = /^[A-D]$/;
57
+ const MODERNIZE = 'abap-modernize';
58
+ const nameOk = (type, name) => OBJECT_TYPE.test(type) && OBJECT_NAME.test(name);
59
+ /* ── names and commands ─────────────────────────────────────────────────── */
60
+ /** The app label: the decision's, else the object name (a namespace folded in); null when it is not a label. */
61
+ export function appLabelFor(o) {
62
+ const own = o.decision?.app;
63
+ if (own && isAppLabel(own))
64
+ return own;
65
+ const name = o.name.trim().toUpperCase().replace(/^\/([A-Z0-9_]+)\//, '$1_');
66
+ return isAppLabel(name) ? name : null;
67
+ }
68
+ /** The command a hand-off queues, built here; null when a name or the label is not one CSPeach passes on. */
69
+ export function rebuildCommand(kind, o, app) {
70
+ const type = o.type.trim().toUpperCase();
71
+ const name = o.name.trim().toUpperCase();
72
+ if (!nameOk(type, name))
73
+ return null;
74
+ if (kind === 'app')
75
+ return isAppLabel(app) ? `/abap-app @${REBUILD_BRIEF_REL(app)}` : null;
76
+ const reason = cleanReason(o.reason);
77
+ if (kind === 'class')
78
+ return `/abap-modernize ${type} ${name}${reason ? ` — ${reason}` : ''}`;
79
+ return `/abap-modernize ${type} ${name} — ${reason ? `${reason}; ` : ''}API service, no UI`;
80
+ }
81
+ /**
82
+ * The fixed template (F5). The words report / list / ALV / download never
83
+ * stand as the ask; the card's "Why" is not copied. Every name is an ABAP
84
+ * name (checked by the caller); anything else here is cleaned or left out.
85
+ */
86
+ export function rebuildBrief(b) {
87
+ const objs = b.objects.map((o) => ({ type: o.type.toUpperCase(), name: o.name.toUpperCase(), pkg: o.package && PACKAGE.test(o.package.toUpperCase()) ? o.package.toUpperCase() : undefined }));
88
+ const sid = b.sid && SID.test(b.sid.toUpperCase()) ? b.sid.toUpperCase() : 'the SAP system';
89
+ const names = objs.map((o) => o.name).join(', ');
90
+ const why = screenedReason(b.reason);
91
+ const target = b.target && LEVEL.test(b.target) ? b.target : 'not set';
92
+ return [
93
+ `# Rebuild of ${objs.map((o) => `${o.name} (${o.type}${o.pkg ? `, package ${o.pkg}` : ''})`).join(', ')} on ${sid}`,
94
+ ...(b.kind === 'app' ? [`Build an app that replaces the old program ${names}. Users create, change and view the same data the old program handles today.`] : []),
95
+ ...(b.kind === 'api' ? [`Build an API service that replaces ${names}: no UI, OData service.`] : []),
96
+ ...objs.map((o) => `Read the old program's behaviour from the SAP system (${o.type} ${o.name}) before designing: its selection fields, its output columns, its checks.`),
97
+ `Target clean-core level: ${target}.`,
98
+ ...(why ? [`Why it was decided (context only, not a requirement): ${why.text}.`] : []),
99
+ ...(why?.marked ? [MARKED_NOTE] : []),
100
+ // Ruling (fix round 1 follow-up): no quotable "report" phrase anywhere in the ask.
101
+ b.kind === 'api'
102
+ ? 'The result is a new API service with no UI that replaces the old program; the app lane chooses how to build it.'
103
+ : 'The result is a new app with a modern UI that replaces the old program; the app lane chooses how to build it.',
104
+ `Do not change ${names}.`,
105
+ '',
106
+ ].join('\n');
107
+ }
108
+ /** Words that, as an ask, would send the app lane to a classic report (report_only). */
109
+ const REPORT_WORDS = /\b(reports?|reporting|lists?|listing|alv|downloads?|downloaded|downloading|write|writes|output)\b/gi;
110
+ export const MARKED_NOTE = 'Words marked * describe the old program today, not what to build.';
111
+ /**
112
+ * The decision's reason as context for the brief (review fix 2): cleaned to
113
+ * one line, and every report-like word kept but marked with `*`, so no quote
114
+ * of the reason as written ("download the ALV list") is found in the brief and
115
+ * the lane cannot read it as a report_only request.
116
+ */
117
+ export function screenedReason(reason) {
118
+ const text = cleanReason(reason).replace(/\*/g, '').replace(/[\s.]+$/, '');
119
+ if (!text)
120
+ return null;
121
+ let marked = false;
122
+ const out = text.replace(REPORT_WORDS, (w) => { marked = true; return `${w}*`; });
123
+ return { text: out, marked };
124
+ }
125
+ /** Write the brief whole, then rename, under `<projectDir>/rebuilds/<APP>/`. Returns the file. */
126
+ export function writeRebuildBrief(projectDir, app, text) {
127
+ if (!isAppLabel(app))
128
+ throw new Error('the app name cannot be used for a folder');
129
+ const file = path.join(projectDir, ...REBUILD_BRIEF_REL(app).split('/'));
130
+ fs.mkdirSync(path.dirname(file), { recursive: true });
131
+ writeWhole(file, text);
132
+ return file;
133
+ }
134
+ /* ── the record ─────────────────────────────────────────────────────────── */
135
+ /** The seat name: `whoAmI` over the author identity, as the journey's session deps read it. */
136
+ async function seatName() {
137
+ const { getAuthorIdentity } = await import('../agent/loop.js');
138
+ return whoAmI({ identity: getAuthorIdentity });
139
+ }
140
+ /** One evidence record: the `rebuild` row on each OLD object's key (additive; older readers drop it). */
141
+ export function writeRebuildRows(joined, keys, row, by, now = new Date()) {
142
+ const at = now.toISOString();
143
+ return writeRecord(joined, 'evidence', keys.map((key) => ({ key, rebuild: { ...(typeof row === 'function' ? row(key) : row), by, at } })), { name: by }, { now });
144
+ }
145
+ function registerNow(joined) {
146
+ return registerFor(joined.projectDir, joined.outboxDir).register;
147
+ }
148
+ /**
149
+ * "<NAME> → rebuild as <kind>." Why: <fact>. [Yes] [Change kind] [Later].
150
+ * Change kind shows the three kinds, then the card again with the new one.
151
+ */
152
+ export async function rebuildCard(confirm, g) {
153
+ let kind = g.kind;
154
+ let sure = g.sure;
155
+ const who = g.objects.length === 1 ? g.objects[0].name.toUpperCase() : g.app;
156
+ for (let tries = 0; tries < 5; tries++) {
157
+ const res = await confirm({
158
+ id: 'journey-rebuild',
159
+ tag: 'QUESTION',
160
+ title: `${who} → rebuild as ${KIND_WORDS[kind]}.`,
161
+ facts: [
162
+ ...(g.objects.length > 1 ? [`Old objects: ${g.objects.map((o) => o.name.toUpperCase()).join(', ')}.`] : []),
163
+ ...g.whys.map((w) => `Why: ${w}.`),
164
+ ...(sure ? [] : [UNSURE_LINE]),
165
+ 'The rebuild runs on its own, outside these clean-core steps.',
166
+ 'CSPeach asks before it writes to SAP.',
167
+ ],
168
+ choices: [{ value: 'yes', label: 'Yes' }, { value: 'change', label: 'Change kind' }, { value: 'later', label: 'Later' }],
169
+ initial: 0,
170
+ aliases: { y: 'yes', c: 'change', l: 'later' },
171
+ });
172
+ if (res.cancelled || res.value === null)
173
+ return { kind: 'cancel' };
174
+ if (res.value === 'later')
175
+ return { kind: 'later' };
176
+ if (res.value === 'yes')
177
+ return { kind: 'yes', rebuild: kind };
178
+ const pick = await confirm({
179
+ id: 'journey-rebuild-kind',
180
+ tag: 'QUESTION',
181
+ title: `Rebuild ${who} as?`,
182
+ choices: KIND_CHOICES,
183
+ initial: KIND_CHOICES.findIndex((c) => c.value === kind),
184
+ aliases: { a: 'app', c: 'class', s: 'api' },
185
+ });
186
+ if (pick.cancelled || pick.value === null)
187
+ return { kind: 'cancel' };
188
+ if (pick.value === 'app' || pick.value === 'class' || pick.value === 'api') {
189
+ kind = pick.value;
190
+ sure = true;
191
+ }
192
+ }
193
+ return { kind: 'cancel' };
194
+ }
195
+ /**
196
+ * My rebuild decisions with no `rebuild` row yet, in name order. The kind is
197
+ * the person's (`rebuildKind`) when one is recorded, else the proposal (F6,
198
+ * from the type and the newest check's rule ids). Objects that share an app
199
+ * label make ONE group only when their kind is `app` (one app replaces them
200
+ * all); a class or an API service is one card per object (review fix 3).
201
+ * `passed` names each rebuild CSPeach cannot hand off, with why.
202
+ */
203
+ export function rebuildGroups(mine, skip, rulesOf = () => []) {
204
+ const groups = new Map();
205
+ const passed = [];
206
+ for (const o of mine) {
207
+ if (o.decision?.classification !== 'redesign' || o.evidence.rebuild || skip?.has(o.key))
208
+ continue;
209
+ if (!nameOk(o.type.toUpperCase(), o.name.toUpperCase())) {
210
+ passed.push({ key: o.key, line: `${objectShown(o.name)}: the name is not an ABAP name, so CSPeach passed its rebuild over.` });
211
+ continue;
212
+ }
213
+ const app = appLabelFor(o);
214
+ if (!app) {
215
+ const folded = o.name.trim().toUpperCase().replace(/^\/([A-Z0-9_]+)\//, '$1_');
216
+ const why = APP_LABEL.test(folded)
217
+ ? 'the name is reserved by Windows and cannot be a folder, so CSPeach passed its rebuild over. Give it another app name.'
218
+ : 'the name is too long for a rebuild folder, so CSPeach passed its rebuild over. Give it a shorter app name.';
219
+ passed.push({ key: o.key, line: `${o.name.toUpperCase()}: ${why}` });
220
+ continue;
221
+ }
222
+ const p = proposeRebuildKind({ type: o.type, name: o.name, rules: rulesOf(o.key) });
223
+ const own = o.decision.rebuildKind;
224
+ const kind = own ?? p.kind;
225
+ const entry = {
226
+ type: o.type.toUpperCase(), name: o.name.toUpperCase(), key: o.key, package: o.package,
227
+ ...(o.decision.reason ? { reason: o.decision.reason } : {}), target: o.proof?.target?.level ?? null,
228
+ };
229
+ const id = kind === 'app' ? `app|${app}` : `one|${o.key}`;
230
+ const g = groups.get(id);
231
+ if (g) {
232
+ g.objects.push(entry);
233
+ g.whys.push(p.why);
234
+ continue;
235
+ }
236
+ groups.set(id, { app, objects: [entry], kind, sure: own ? true : p.sure, whys: [p.why] });
237
+ }
238
+ return Object.assign([...groups.values()], { passed });
239
+ }
240
+ /** Review fix 3: the objects of a label that a class/API hand-off leaves for later. */
241
+ export const STAY_PLANNED_LINE = (names) => names.length === 1 ? `${names[0]} stays planned — it comes back on the next card.` : `${names.join(', ')} stay planned — they come back on the next card.`;
242
+ /**
243
+ * Yes on the card: the brief (app/api), the `handed-off` row, the journey
244
+ * ENDED with the F1 line, then — only then — the command queued (never as a
245
+ * journey step: `flow.queued` is not set). Returns false when nothing was
246
+ * handed off (a name CSPeach does not pass on, a record that could not be
247
+ * written): the line is said, and the journey ends all the same.
248
+ */
249
+ export function handOffRebuild(h, g, kind) {
250
+ const joined = activeProject();
251
+ // One app replaces every object of the label; a class or an API is one object per run.
252
+ const objects = kind === 'app' ? g.objects : g.objects.slice(0, 1);
253
+ const rest = g.objects.slice(objects.length).map((o) => o.name);
254
+ const first = objects[0];
255
+ const command = rebuildCommand(kind, first, g.app);
256
+ const who = objects.length === 1 ? first.name : g.app;
257
+ if (!joined || !command) {
258
+ clearJourneyFlow(h.session);
259
+ h.emit(`${who}: the name cannot be used in a command, so nothing was handed off.`);
260
+ return false;
261
+ }
262
+ const now = (h.now ?? (() => new Date()))();
263
+ try {
264
+ if (kind !== 'class') {
265
+ writeRebuildBrief(joined.projectDir, g.app, rebuildBrief({
266
+ kind, ...(h.sid ? { sid: h.sid } : {}), target: first.target ?? null,
267
+ ...(first.reason ? { reason: first.reason } : {}), objects,
268
+ }));
269
+ }
270
+ const keys = objects.map((o) => o.key ?? objectKey(o.type, o.name));
271
+ const res = writeRebuildRows(joined, keys, { kind, app: g.app, status: 'handed-off' }, h.by, now);
272
+ if (!res.written)
273
+ throw new Error("the object is not in the project's object list");
274
+ if (kind !== 'app')
275
+ rememberModernizeHandOff(h.session, { keys, kind, app: g.app, ...(h.callsAt !== undefined ? { callsAt: h.callsAt } : {}) });
276
+ }
277
+ catch (e) {
278
+ clearJourneyFlow(h.session);
279
+ const why = shown(e instanceof Error ? e.message : String(e)).replace(/[\s.]+$/, '');
280
+ h.emit(`Not recorded: ${why || 'the record could not be written to this folder'}. Nothing was handed off.`);
281
+ return false;
282
+ }
283
+ // F1: the journey ends BEFORE the rebuild is queued.
284
+ clearJourneyFlow(h.session);
285
+ h.emit(HAND_OFF_LINE(who));
286
+ if (rest.length)
287
+ h.emit(STAY_PLANNED_LINE(rest));
288
+ if (h.queueNext) {
289
+ // Final review I2: the queued turn runs with no keypress, so auto_approve never applies to its SAP writes.
290
+ guardedHandOff.set(h.session, turnSeqOf(h.session));
291
+ setRebuildRunActive(true);
292
+ h.queueNext(command, { by: 'rebuild', line: REBUILD_STEP_LINE(who) });
293
+ }
294
+ else
295
+ h.emit(`Next: ${command}`);
296
+ return true;
297
+ }
298
+ /* ── started / built ────────────────────────────────────────────────────── */
299
+ const fold = (p) => (process.platform === 'win32' ? p.toLowerCase() : p);
300
+ /**
301
+ * I6 — in afterPlanSave, inside an app flow: when the flow's spec resolves
302
+ * (realpath) under `<joined project>/rebuilds/<APP>/`, write `started` with
303
+ * the saved plan's basename on every old object whose newest rebuild is for
304
+ * that APP. Never on a rebuild already built (a plan saved again after the
305
+ * build does not reopen it: `started` never downgrades `built`), and never
306
+ * twice for the same plan. A failure is one line; the app chain goes on.
307
+ */
308
+ export async function rebuildStartedAfterPlanSave(a) {
309
+ try {
310
+ const joined = activeProject();
311
+ if (!joined)
312
+ return;
313
+ const tok = a.specToken.trim().replace(/^@/, '');
314
+ let real;
315
+ let root;
316
+ try {
317
+ real = fs.realpathSync.native(path.resolve(a.cwd, tok));
318
+ root = fs.realpathSync.native(joined.projectDir) + path.sep + 'rebuilds' + path.sep;
319
+ }
320
+ catch {
321
+ return;
322
+ }
323
+ if (!fold(real).startsWith(fold(root)))
324
+ return;
325
+ const app = real.slice(root.length).split(path.sep)[0] ?? '';
326
+ if (!isAppLabel(app))
327
+ return;
328
+ const plan = path.basename(a.savedPath);
329
+ const old = registerNow(joined).objects.filter((o) => {
330
+ const r = o.evidence.rebuild;
331
+ return r?.app === app && (r.status === 'handed-off' || (r.status === 'started' && r.plan !== plan));
332
+ });
333
+ if (!old.length)
334
+ return;
335
+ const kinds = new Map(old.map((o) => [o.key, o.evidence.rebuild.kind]));
336
+ writeRebuildRows(joined, old.map((o) => o.key), (key) => ({ kind: kinds.get(key), app, status: 'started', plan }), a.by ?? await seatName(), a.now);
337
+ }
338
+ catch (e) {
339
+ a.emit(`The rebuild record could not be written: ${shown(e instanceof Error ? e.message : String(e))}`);
340
+ }
341
+ }
342
+ /**
343
+ * C5 — the plan at `planPath` finished (plan-chain's continuous run and
344
+ * finishPlanResume's live run; never a re-opened complete plan). Writes
345
+ * `built` only on old objects whose newest rebuild names this plan and is not
346
+ * built yet, so a second call writes nothing. No session state is read.
347
+ */
348
+ export async function afterPlanComplete(planPath, opts = {}) {
349
+ try {
350
+ const joined = activeProject();
351
+ if (!joined)
352
+ return;
353
+ const plan = path.basename(planPath);
354
+ const old = registerNow(joined).objects.filter((o) => o.evidence.rebuild?.plan === plan
355
+ && o.evidence.rebuild.status !== 'built' && o.evidence.rebuild.status !== 'proven');
356
+ if (!old.length)
357
+ return;
358
+ const of = new Map(old.map((o) => [o.key, o.evidence.rebuild]));
359
+ writeRebuildRows(joined, old.map((o) => o.key), (key) => ({ kind: of.get(key).kind, app: of.get(key).app, status: 'built', plan }), opts.by ?? await seatName(), opts.now);
360
+ }
361
+ catch { /* a completion line is never held up by a record */ }
362
+ }
363
+ /**
364
+ * The session's turn sequence (loop.ts `session.turn_seq`): one up at the
365
+ * start of every turn, never lowered by a compaction — unlike the message
366
+ * count, which /compact and auto-compact shrink (re-review N1). 0 when the
367
+ * session keeps none (it then never passes the hand-off's own turn).
368
+ */
369
+ const turnSeqOf = (session) => {
370
+ const t = session.turn_seq;
371
+ return typeof t === 'number' && Number.isFinite(t) ? t : 0;
372
+ };
373
+ const modernizing = new WeakMap();
374
+ /** The hand-off turn's sequence, while the queued rebuild turn runs under the guarded-run override (I2). */
375
+ const guardedHandOff = new WeakMap();
376
+ const callsOf = (session) => {
377
+ const c = session.toolCalls;
378
+ return Array.isArray(c) ? c : [];
379
+ };
380
+ /**
381
+ * A /abap-modernize hand-off waits for its first SAP write to read `started`
382
+ * (F9). The session's turn sequence is kept, so the turn the hand-off happened in
383
+ * (the refactor or triage turn whose post-turn hook showed the card) is never
384
+ * read as the rebuild's own turn (review fix 1).
385
+ */
386
+ export function rememberModernizeHandOff(session, p) {
387
+ modernizing.set(session, {
388
+ keys: [...p.keys], kind: p.kind, app: p.app, callsAt: p.callsAt ?? callsOf(session).length, turnAt: turnSeqOf(session),
389
+ });
390
+ }
391
+ /**
392
+ * After a top-level turn: while the session stays in /abap-modernize, the
393
+ * first successful SAP write since the hand-off writes `started` (once). The
394
+ * hand-off's own turn is passed over; any other skill after it forgets the
395
+ * hand-off, and the row stays `handed-off`.
396
+ */
397
+ export async function afterRebuildTurn(session, skill, opts = {}) {
398
+ const handedAt = guardedHandOff.get(session);
399
+ // The queued rebuild turn is done (whatever skill ran), or a turn of another session ran
400
+ // (a /clear before it): the guarded-run share ends (I2). The hand-off turn itself keeps it.
401
+ if (handedAt === undefined || turnSeqOf(session) > handedAt) {
402
+ guardedHandOff.delete(session);
403
+ setRebuildRunActive(false);
404
+ }
405
+ const p = modernizing.get(session);
406
+ if (!p)
407
+ return;
408
+ if (turnSeqOf(session) <= p.turnAt)
409
+ return; // the turn the hand-off happened in (N1: the turn sequence, not the messages)
410
+ if (skill !== MODERNIZE) {
411
+ modernizing.delete(session);
412
+ return;
413
+ }
414
+ if (cspeachWritesThisSession(callsOf(session).slice(p.callsAt)).size === 0)
415
+ return;
416
+ modernizing.delete(session);
417
+ try {
418
+ const joined = activeProject();
419
+ if (!joined)
420
+ return;
421
+ const objects = registerNow(joined).objects;
422
+ const keys = p.keys.filter((k) => {
423
+ const r = objects.find((o) => o.key === k)?.evidence.rebuild;
424
+ return r?.app === p.app && r.status === 'handed-off';
425
+ });
426
+ if (keys.length)
427
+ writeRebuildRows(joined, keys, { kind: p.kind, app: p.app, status: 'started' }, opts.by ?? await seatName(), opts.now);
428
+ }
429
+ catch { /* the rebuild goes on; the row stays handed-off */ }
430
+ }
431
+ /* ── cspeach team rebuild built ─────────────────────────────────────────── */
432
+ export const REBUILD_USAGE = 'Usage: cspeach team rebuild built <TYPE> <NAME> --app <APP>';
433
+ export const APP_WORDS = '--app is a name of letters, digits and underscores, for example AR_COCKPIT (not CON, NUL, AUX, PRN, COM1-9 or LPT1-9).';
434
+ /**
435
+ * F9 — a /abap-modernize rebuild reaches `built` by hand. Only for an object
436
+ * decided "rebuild", or with a rebuild row already. The kind is the newest
437
+ * rebuild row's, else the decision's, else the proposal. Returns the line to
438
+ * print, or throws with the reason.
439
+ */
440
+ export function recordRebuildBuilt(args, by, now = new Date()) {
441
+ const type = args.type.trim().toUpperCase();
442
+ const name = args.name.trim().toUpperCase();
443
+ const app = args.app.trim().toUpperCase();
444
+ if (!nameOk(type, name))
445
+ throw new Error(`${shown(type)} ${shown(name)} is not an ABAP object name.`);
446
+ if (!isAppLabel(app))
447
+ throw new Error(APP_WORDS);
448
+ const joined = activeProject();
449
+ if (!joined)
450
+ throw new Error('This laptop has not joined a team project. Run: cspeach team join <folder>');
451
+ const key = objectKey(type, name);
452
+ const o = registerNow(joined).objects.find((x) => x.key === key);
453
+ if (!o)
454
+ throw new Error(`${type} ${name} is not in this team project's object list.`);
455
+ const before = o.evidence.rebuild;
456
+ if (!before && o.decision?.classification !== 'redesign') {
457
+ throw new Error(`${type} ${name} is not marked for a rebuild. Record that first: cspeach team decide ${type} ${name} rebuild`);
458
+ }
459
+ const kind = before?.kind ?? o.decision?.rebuildKind ?? proposeRebuildKind({ type, name }).kind;
460
+ const res = writeRebuildRows(joined, [key], { kind, app, status: 'built', ...(before?.app === app && before.plan ? { plan: before.plan } : {}) }, by, now);
461
+ if (!res.written)
462
+ throw new Error(`${type} ${name} is not in this team project's object list.`);
463
+ return `✓ Recorded: ${type} ${name} — rebuilt as ${app}, built, not yet checked.`;
464
+ }