@cspeach/cli 1.1.30 → 1.1.32

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 (128) hide show
  1. package/README.md +11 -1
  2. package/dist/agent/loop.js +132 -9
  3. package/dist/cli.js +49 -18
  4. package/dist/commands/config-set.js +1 -1
  5. package/dist/commands/help.js +16 -1
  6. package/dist/commands/plan-chain.js +3 -0
  7. package/dist/commands/plan-continue.js +1 -1
  8. package/dist/commands/plan-gate.js +17 -1
  9. package/dist/commands/plan-resume.js +28 -18
  10. package/dist/commands/plan-startup.js +49 -6
  11. package/dist/commands/team-discover.js +195 -0
  12. package/dist/commands/team-plan.js +585 -0
  13. package/dist/commands/team-rules.js +187 -0
  14. package/dist/commands/team.js +971 -95
  15. package/dist/journey/chain.js +978 -0
  16. package/dist/journey/clean-core.js +235 -0
  17. package/dist/journey/flow.js +216 -0
  18. package/dist/journey/rebuild.js +464 -0
  19. package/dist/journey/run.js +628 -0
  20. package/dist/journey/session-deps.js +81 -0
  21. package/dist/journey/trigger.js +153 -0
  22. package/dist/lane/app-chain.js +21 -9
  23. package/dist/lane/chain-token.js +9 -5
  24. package/dist/lane/roots.js +97 -41
  25. package/dist/one-shot.js +28 -2
  26. package/dist/projects/clean-core-confirm.js +192 -3
  27. package/dist/projects/detail-path.js +9 -4
  28. package/dist/projects/extract-clean-core.js +3 -0
  29. package/dist/projects/migration.js +4 -105
  30. package/dist/projects/plan-lane.js +67 -17
  31. package/dist/projects/plan-sources.js +13 -7
  32. package/dist/projects/project-folder.js +131 -0
  33. package/dist/projects/promote-command.js +39 -0
  34. package/dist/projects/save-command.js +100 -12
  35. package/dist/projects/saved-lines.js +17 -3
  36. package/dist/projects/sort-command.js +499 -0
  37. package/dist/projects/sort-core.js +248 -0
  38. package/dist/projects/workspace-retire.js +48 -0
  39. package/dist/projects/workspace.js +182 -121
  40. package/dist/register/baseline.js +39 -2
  41. package/dist/register/decision-rules.js +35 -0
  42. package/dist/register/discover.js +931 -0
  43. package/dist/register/export-files.js +58 -0
  44. package/dist/register/findings.js +5 -2
  45. package/dist/register/measure.js +191 -9
  46. package/dist/register/offer.js +100 -8
  47. package/dist/register/picture.js +6 -2
  48. package/dist/register/plan-import.js +328 -0
  49. package/dist/register/records.js +40 -3
  50. package/dist/register/rule-proposal.js +4 -0
  51. package/dist/register/rules-edit.js +257 -0
  52. package/dist/register/status-file/estate-parts.js +168 -0
  53. package/dist/register/status-file/plan-parts.js +68 -0
  54. package/dist/register/status-file/progress-chart.js +110 -0
  55. package/dist/register/status-file/register-map.js +11 -3
  56. package/dist/register/status-file/register-model.js +40 -6
  57. package/dist/register/status-file/register-parts.js +57 -11
  58. package/dist/register/status-file/register-status-file.js +106 -90
  59. package/dist/register/status-file/report-model.js +569 -0
  60. package/dist/register/status-file/team-css.js +1 -1
  61. package/dist/register/status-file-sync.js +4 -0
  62. package/dist/register/status-html.js +27 -6
  63. package/dist/register/store.js +44 -5
  64. package/dist/register/variant.js +65 -0
  65. package/dist/register/workbooks.js +28 -42
  66. package/dist/register/write-whole.js +30 -2
  67. package/dist/register/xlsx.js +13 -3
  68. package/dist/register/xref.js +117 -0
  69. package/dist/renderer/startup-lines.js +11 -0
  70. package/dist/renderer/tool-labels.js +3 -1
  71. package/dist/renderer/tool-widget.js +15 -1
  72. package/dist/repl/at-completer.js +6 -2
  73. package/dist/repl/at-picker.js +1 -1
  74. package/dist/repl/builtin-commands.js +4 -0
  75. package/dist/repl/journey-entry.js +27 -0
  76. package/dist/repl/plan-hop.js +2 -9
  77. package/dist/repl/rules-menu.js +177 -0
  78. package/dist/repl/slash-completer.js +1 -0
  79. package/dist/repl/slash-picker.js +5 -4
  80. package/dist/repl.js +236 -59
  81. package/dist/router/classifier.js +8 -4
  82. package/dist/router/router-core.js +43 -7
  83. package/dist/router/routing-decision.js +1 -1
  84. package/dist/runs/bundled-runs.js +100 -0
  85. package/dist/runs/front-door.js +19 -0
  86. package/dist/runs/manifest-runs.js +50 -0
  87. package/dist/runs/run-command.js +24 -0
  88. package/dist/runs/runs.js +27 -0
  89. package/dist/runs/shadow.js +100 -0
  90. package/dist/runs/types.js +1 -0
  91. package/dist/session/backfill.js +342 -0
  92. package/dist/session/project-match.js +63 -0
  93. package/dist/session/queued-step.js +45 -0
  94. package/dist/session/resume.js +72 -24
  95. package/dist/session/schema.js +3 -1
  96. package/dist/session/store.js +96 -51
  97. package/dist/skill-catalog.js +2 -2
  98. package/dist/skills/bundled-skills.js +53 -53
  99. package/dist/tools/_filesystem-shared.js +174 -12
  100. package/dist/tools/_team-work-path.js +133 -0
  101. package/dist/tools/approval.js +7 -1
  102. package/dist/tools/dispatch-skill.js +8 -4
  103. package/dist/tools/filesystem/extract-xlsx.js +27 -3
  104. package/dist/tools/filesystem/file-edit.js +12 -4
  105. package/dist/tools/filesystem/file-read.js +8 -4
  106. package/dist/tools/filesystem/file-write.js +12 -4
  107. package/dist/tools/filesystem/glob.js +33 -8
  108. package/dist/tools/filesystem/grep.js +9 -4
  109. package/dist/tools/filesystem/read-document.js +6 -3
  110. package/dist/tools/lane/app-lane-resolve.js +1 -1
  111. package/dist/tools/project/cca_merge.js +8 -2
  112. package/dist/tools/project/rules_propose.js +59 -0
  113. package/dist/tools/project/team_estate.js +91 -0
  114. package/dist/tools/project/team_findings.js +89 -5
  115. package/dist/tools/project/upgrade_merge_progress.js +8 -2
  116. package/dist/tools/sap-read.js +21 -4
  117. package/dist/tools/sap-write.js +200 -10
  118. package/dist/tools/subagent/read_agent.js +3 -0
  119. package/dist/tools/tool-loading.js +1 -1
  120. package/dist/tools/transport-fit-check.js +117 -0
  121. package/dist/tools/transport-fit.js +15 -7
  122. package/dist/tools/transport.js +57 -2
  123. package/dist/ui/app.js +2 -2
  124. package/dist/ui/approval-modal.js +74 -6
  125. package/dist/ui/widgets/confirm-card.js +68 -5
  126. package/dist/upgrade-check.js +49 -4
  127. package/dist/util/open-in-browser.js +143 -0
  128. package/package.json +3 -3
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The CSV export on disk (step 2, Task 4; spec 2026-10-08 §3.4, D2). The
3
+ * tables come from register-core (`exportSet`, pure); this file writes them:
4
+ * each file whole, then renamed, so a sync tool never picks up half of one;
5
+ * CSV as UTF-8 with a BOM (Excel then reads umlauts right), CRLF line ends,
6
+ * RFC 4180 quoting (`csvText`).
7
+ *
8
+ * The switch is visible JSON in the team project file — `"exports": { "csv": true }`
9
+ * — set by `cspeach team export --always` or the journey's report card. No
10
+ * hidden state. Default off.
11
+ */
12
+ import path from 'node:path';
13
+ import { csvText, exportSet } from '@cspeach/register-core';
14
+ import { HELD } from './workbooks.js';
15
+ import { readProjectFile, TEAM_FILE } from './store.js';
16
+ import { writeWhole } from './write-whole.js';
17
+ /** The folder the export goes into, under the reports folder (or the folder `--out` named for a report). */
18
+ export const EXPORTS_DIR = 'exports';
19
+ /** True only when the team project file says exactly `"exports": { "csv": true }`. */
20
+ export function exportsOn(project) {
21
+ const e = project.exports;
22
+ return !!e && typeof e === 'object' && e.csv === true;
23
+ }
24
+ /** Set the switch on or off: the whole project file, then renamed. Every other field stays as it is. */
25
+ export function setExports(projectDir, on) {
26
+ const project = readProjectFile(projectDir);
27
+ const had = project.exports && typeof project.exports === 'object' ? project.exports : {};
28
+ const next = { ...project, exports: { ...had, csv: on } };
29
+ writeWhole(path.join(projectDir, TEAM_FILE), `${JSON.stringify(next, null, 2)}\n`);
30
+ }
31
+ /** Turn the switch on (`team export --always`, the card's "Every time"). */
32
+ export const setExportsAlways = (projectDir) => setExports(projectDir, true);
33
+ /**
34
+ * Every file of the export into `dir`. Each file is tried on its own: one held
35
+ * open in Excel never costs the others, and the caller names it.
36
+ */
37
+ export function writeExportFiles(register, project, dir, generatedAt) {
38
+ const set = exportSet(register, project, { generatedAt });
39
+ const files = [
40
+ ...set.tables.map((t) => [t.file, csvText(t)]),
41
+ ['manifest.json', `${JSON.stringify(set.manifest, null, 2)}\n`],
42
+ ['SCHEMA.md', set.schema],
43
+ ];
44
+ const saved = [];
45
+ const held = [];
46
+ for (const [name, text] of files) {
47
+ try {
48
+ writeWhole(path.join(dir, name), Buffer.from(text, 'utf8'));
49
+ saved.push(name);
50
+ }
51
+ catch (e) {
52
+ if (!HELD.has(e?.code ?? ''))
53
+ throw e;
54
+ held.push(name);
55
+ }
56
+ }
57
+ return { saved, held };
58
+ }
@@ -20,7 +20,7 @@ const basisOf = (v) => {
20
20
  const w = String(v).trim().toLowerCase();
21
21
  return w === '' || w === 'system' ? 'system' : 'file';
22
22
  };
23
- export function findingsByObject(projectDir, outboxDir) {
23
+ export function findingsByObject(projectDir, outboxDir, opts = {}) {
24
24
  const project = readProjectFile(projectDir);
25
25
  const ours = variantKey(project.atcVariant);
26
26
  const records = [
@@ -56,13 +56,16 @@ export function findingsByObject(projectDir, outboxDir) {
56
56
  // handed-over file, and it goes to a terminal and to the model's tools.
57
57
  const message = named && typeof f.message === 'string' ? shown(f.message) : '';
58
58
  const had = seen.rules.get(rule);
59
+ const line = opts.lines && named && Number.isInteger(f.line) && f.line > 0 ? f.line : undefined;
59
60
  if (had) {
60
61
  had.count += 1;
61
62
  if (!had.message && message)
62
63
  had.message = message;
64
+ if (line !== undefined && (had.line === undefined || line < had.line))
65
+ had.line = line;
63
66
  }
64
67
  else
65
- seen.rules.set(rule, { rule, message, count: 1 });
68
+ seen.rules.set(rule, { rule, message, count: 1, ...(line !== undefined ? { line } : {}) });
66
69
  }
67
70
  byKey.set(key, list);
68
71
  }
@@ -98,18 +98,169 @@ function apiNameIn(text) {
98
98
  *
99
99
  * The TITLE is read first: it is where SAP names the API, while the long text
100
100
  * can quote source code and would otherwise hand a token like `TABLE` to the
101
- * record. The wording is STILL ASSUMED: the 2026-09-20 capture ran DEFAULT and
102
- * S4HANA_READINESS_2022 only, and neither produced an unreleased-API finding
103
- * (README answer 9, still open). This function is the only place that wording
104
- * is read, and it must be re-checked against a clean-core variant run.
101
+ * record.
102
+ *
103
+ * READINESS WORDING: VERIFIED-NO-API-NAME (live, S4H, 2026-10-08). On a real
104
+ * ABAP_CLOUD_READINESS worklist the message TEXT is empty on every finding and
105
+ * the title says only "Usage of not released ABAP Platform APIs." — SAP's
106
+ * wording names no API (`__tests__/fixtures/atc-readiness-worklist.xml`). So
107
+ * this rule names nothing on a real system, and it is KEPT ONLY AS A FALLBACK
108
+ * for a text that ever does name one. The API of a USAGE_OF_RELEASED_APIS
109
+ * finding comes from the finding itself instead: its REF_OBJ_NAME /
110
+ * REF_OBJ_TYPE tags, or its document (`apiNamesFor`), and it only feeds
111
+ * `uses` — the finding keeps its check's own rule id.
105
112
  */
106
113
  export function unreleasedApiRule(f) {
114
+ const name = unreleasedApiName(f);
115
+ return name ? `UNRELEASED_API:${name}` : null;
116
+ }
117
+ /** The API an unreleased-API finding names, by the same (unverified) reading as `unreleasedApiRule`. */
118
+ function unreleasedApiName(f) {
107
119
  const title = f.messageTitle ?? '';
108
120
  const body = f.messageText ?? '';
109
121
  if (!NOT_RELEASED.test(`${title} ${body}`))
110
122
  return null;
111
- const name = apiNameIn(title) ?? apiNameIn(body);
112
- return name ? `UNRELEASED_API:${name}` : null;
123
+ return apiNameIn(title) ?? apiNameIn(body);
124
+ }
125
+ /**
126
+ * A finding of SAP's "Usage of Released APIs" check (USAGE_OF_RELEASED_APIS),
127
+ * read by its rule id exactly the way register-core's `classifyFinding` reads
128
+ * it. Only such a finding's referenced object is "the API it uses": the syntax
129
+ * check also tags a REF_OBJ_NAME, and that names a statement.
130
+ */
131
+ export function isReleasedApiFinding(f) {
132
+ return [ruleIdFromAtc(f), ...ruleAliasesFromAtc(f)]
133
+ .some((id) => normalizeRuleId(id).split(':')[0] === 'USAGE_OF_RELEASED_APIS');
134
+ }
135
+ const apiRef = (o) => {
136
+ const name = typeof o?.name === 'string' ? o.name.trim().toUpperCase() : '';
137
+ const type = typeof o?.type === 'string' ? o.type.trim().toUpperCase() : '';
138
+ return name ? { name, ...(type ? { type } : {}) } : undefined;
139
+ };
140
+ /** Finding documents one run reads, at most. The rest are left without `uses`, counted and said. */
141
+ export const MAX_API_DOCUMENTS = 200;
142
+ /** Finding documents read at the same time. */
143
+ export const API_DOCUMENT_CONCURRENCY = 4;
144
+ /**
145
+ * The API each USAGE_OF_RELEASED_APIS finding uses, by finding.
146
+ *
147
+ * The worklist's own REF_OBJ_NAME / REF_OBJ_TYPE tags first — they cost
148
+ * nothing. A finding without them has its document read (distinct links, at
149
+ * most `API_DOCUMENT_CONCURRENCY` at a time, at most `cap` per run). A
150
+ * document that fails, names nothing, or was past the cap leaves its finding
151
+ * without a name: no `uses`, so it stays open for B. Never a guess.
152
+ */
153
+ async function apiNamesFor(findings, read, cap, signal) {
154
+ const named = new Map();
155
+ const needDoc = new Map();
156
+ let namedNone = 0;
157
+ let notLookedUp = 0;
158
+ for (const f of findings) {
159
+ const tag = apiRef(f.referencedObject);
160
+ if (tag) {
161
+ named.set(f, tag);
162
+ continue;
163
+ }
164
+ if (!f.documentation)
165
+ namedNone += 1;
166
+ else if (!read)
167
+ notLookedUp += 1;
168
+ else
169
+ needDoc.set(f.documentation, [...(needDoc.get(f.documentation) ?? []), f]);
170
+ }
171
+ const hrefs = [...needDoc.keys()];
172
+ const toRead = hrefs.slice(0, Math.max(0, cap));
173
+ const overCap = hrefs.slice(toRead.length).reduce((n, h) => n + needDoc.get(h).length, 0);
174
+ let unreadable = 0;
175
+ let documents = 0;
176
+ const started = Date.now();
177
+ let next = 0;
178
+ const worker = async () => {
179
+ while (next < toRead.length && !signal?.aborted) {
180
+ const href = toRead[next++];
181
+ documents += 1;
182
+ try {
183
+ const ref = apiRef((await read(href))?.referencedObject);
184
+ if (ref)
185
+ for (const f of needDoc.get(href))
186
+ named.set(f, ref);
187
+ else
188
+ namedNone += needDoc.get(href).length;
189
+ }
190
+ catch {
191
+ unreadable += needDoc.get(href).length;
192
+ }
193
+ }
194
+ };
195
+ await Promise.all(Array.from({ length: Math.min(API_DOCUMENT_CONCURRENCY, toRead.length) }, worker));
196
+ // Within the cap but never asked for: the run was stopped.
197
+ const stopped = toRead.slice(next).reduce((n, h) => n + needDoc.get(h).length, 0);
198
+ return {
199
+ named,
200
+ report: {
201
+ findings: findings.length, named: named.size, documents, ms: documents ? Date.now() - started : 0,
202
+ unnamed: findings.length - named.size, overCap, unreadable, namedNone, stopped, notLookedUp,
203
+ },
204
+ };
205
+ }
206
+ /* ── `uses`: what SAP says about the API a finding names (step 2, F8) ────── */
207
+ /** API names per `ARS_W_API_STATE` read. */
208
+ export const ARS_CHUNK = 50;
209
+ /** At most this many type/state pairs are kept for one API name. */
210
+ export const MAX_USES = 10;
211
+ /**
212
+ * Rows asked for per ARS read. ARS holds a row per sub-object too, so a read
213
+ * that comes back FULL may have been cut, and the row cut off may be the one
214
+ * that is not stable: a full page is read as unreadable (review 3).
215
+ */
216
+ export const ARS_MAX_ROWS = ARS_CHUNK * 20;
217
+ /**
218
+ * One batched read of `ARS_W_API_STATE` per run, by `OBJECT_NAME`, in chunks
219
+ * of 50 distinct names (F8). Every distinct type/state SAP returns for a name
220
+ * is kept, as SAP spells it, so one entry that is not stable keeps the finding
221
+ * open for B (register-core `stateOfUses`). A name SAP returns nothing for
222
+ * gets no entry: the finding then has no `uses` and stays open — honest, not
223
+ * guessed. A read that fails reads nothing, and the measurement is written as
224
+ * it always was.
225
+ *
226
+ * The names come from the USAGE_OF_RELEASED_APIS findings themselves
227
+ * (`apiNamesFor`) and, as a fallback, from a message text that names an API
228
+ * (`unreleasedApiRule`): `uses` is written only on a finding one of them named.
229
+ */
230
+ async function apiStatesOf(sql, names) {
231
+ const out = new Map();
232
+ // A name goes into the statement only when it can hold no quote or bracket.
233
+ const safe = [...new Set(names)].filter((n) => SAP_NAME.test(n));
234
+ for (let i = 0; i < safe.length; i += ARS_CHUNK) {
235
+ const chunk = safe.slice(i, i + ARS_CHUNK);
236
+ let rows;
237
+ try {
238
+ ({ rows } = await sql(`SELECT DISTINCT OBJECT_TYPE, OBJECT_NAME, RELEASE_STATE FROM ARS_W_API_STATE WHERE OBJECT_NAME IN (${chunk.map((n) => `'${n}'`).join(',')})`, ARS_MAX_ROWS));
239
+ }
240
+ catch {
241
+ return new Map(); // unreadable: no `uses` at all, never half of them
242
+ }
243
+ const list = Array.isArray(rows) ? rows : [];
244
+ if (list.length >= ARS_MAX_ROWS)
245
+ return new Map(); // possibly cut: no `uses` at all, never a wrong state
246
+ for (const r of list) {
247
+ const name = String(r?.OBJECT_NAME ?? '').trim().toUpperCase();
248
+ const type = String(r?.OBJECT_TYPE ?? '').trim().toUpperCase();
249
+ const state = String(r?.RELEASE_STATE ?? '').trim().toUpperCase();
250
+ if (!name || !type || !state || !chunk.includes(name))
251
+ continue;
252
+ const seen = out.get(name) ?? [];
253
+ if (!seen.some((u) => u.type === type && u.state === state))
254
+ seen.push({ type, name, state });
255
+ out.set(name, seen);
256
+ }
257
+ }
258
+ // More pairs than a finding carries: the name gets none, never a cut list whose
259
+ // missing entry could be the one that is not stable (review 3).
260
+ for (const [name, list] of out)
261
+ if (list.length > MAX_USES)
262
+ out.delete(name);
263
+ return out;
113
264
  }
114
265
  /**
115
266
  * SAP writes some check names as `ID / text` — `SLIN_DB / SELECT without ORDER
@@ -144,7 +295,11 @@ function halvesOf(src) {
144
295
  * `coverage.absent` goes to 0, an absent-clean row renders a plain "Clean", and
145
296
  * nothing anywhere says that nothing actually looked at the object.
146
297
  */
147
- export function findingsToRows(measured, findings, projectType) {
298
+ export function findingsToRows(measured, findings, projectType,
299
+ /** What `ARS_W_API_STATE` said about each API name (`apiStatesOf`). Absent: no `uses` are written. */
300
+ usesByName,
301
+ /** The API each USAGE_OF_RELEASED_APIS finding uses (`apiNamesFor`), by finding. */
302
+ apiNameOf) {
148
303
  const byKey = new Map();
149
304
  const evidenceOf = new Map();
150
305
  for (const o of measured) {
@@ -160,7 +315,13 @@ export function findingsToRows(measured, findings, projectType) {
160
315
  if (!list)
161
316
  continue; // a finding for an object that was not measured is never written
162
317
  const atcRule = ruleIdFromAtc(f);
163
- const api = projectType === 'clean-core' ? unreleasedApiRule(f) : null;
318
+ const apiName = projectType === 'clean-core' ? unreleasedApiName(f) : null;
319
+ const api = apiName ? `UNRELEASED_API:${apiName}` : null;
320
+ // The finding's own referenced object first; the text's name only as a fallback.
321
+ // When SAP said the TYPE, only ARS rows of that type are its state: ARS is read by
322
+ // name, and a function group SCAL is not the table SCAL (live, 2026-10-08).
323
+ const ref = (projectType === 'clean-core' ? apiNameOf?.get(f) : undefined) ?? (apiName ? { name: apiName } : undefined);
324
+ const uses = ref ? usesByName?.get(ref.name)?.filter((u) => !ref.type || u.type === ref.type) : undefined;
164
325
  // A finding nobody can name still counts: dropping it would make the object look cleaner than it is.
165
326
  const rule = api ?? (atcRule || UNNAMED_FINDING);
166
327
  const aliases = [...new Set([...(api && atcRule ? [atcRule] : []), ...ruleAliasesFromAtc(f), ...halvesOf(f)])]
@@ -182,6 +343,8 @@ export function findingsToRows(measured, findings, projectType) {
182
343
  ...(f.line ? { line: f.line } : {}),
183
344
  // The title only. The long text can quote source, and the record promises: no source code.
184
345
  ...(title ? { message: title.slice(0, 200) } : {}),
346
+ // F8: only on a finding whose API was named, and only what SAP returned for that name.
347
+ ...(uses?.length ? { uses: uses.map((u) => ({ ...u })) } : {}),
185
348
  });
186
349
  }
187
350
  return [...byKey.entries()].map(([key, list]) => {
@@ -408,7 +571,25 @@ export async function measure(input) {
408
571
  };
409
572
  if (!rowRefs.length)
410
573
  return { ...counts, file: null, withFindings: 0, refusedRows };
411
- const rows = findingsToRows(rowRefs, [...outcome.findings, ...ownFindings], input.project.type);
574
+ const allFindings = [...outcome.findings, ...ownFindings];
575
+ // F8: the APIs the findings of measured objects use, read once for the whole run.
576
+ let usesByName;
577
+ let apiNameOf;
578
+ let apiNames;
579
+ if (input.sql && input.project.type === 'clean-core') {
580
+ const rowKeys = new Set(rowRefs.map((o) => objectKey(o.type, o.name)));
581
+ const inRows = allFindings.filter((f) => typeof f?.key === 'string' && rowKeys.has(normalizeKey(f.key)));
582
+ const releasedApi = inRows.filter(isReleasedApiFinding);
583
+ if (releasedApi.length) {
584
+ const got = await apiNamesFor(releasedApi, input.atc.findingDocument?.bind(input.atc), input.apiDocumentCap ?? MAX_API_DOCUMENTS, input.signal);
585
+ apiNameOf = got.named;
586
+ apiNames = got.report;
587
+ }
588
+ const names = [...[...(apiNameOf?.values() ?? [])].map((r) => r.name), ...inRows.map((f) => unreleasedApiName(f)).filter((n) => !!n)];
589
+ if (names.length)
590
+ usesByName = await apiStatesOf(input.sql, names);
591
+ }
592
+ const rows = findingsToRows(rowRefs, allFindings, input.project.type, usesByName, apiNameOf);
412
593
  const res = writeRecord(input.joined, 'measurement', rows, input.by, {
413
594
  ...(input.now ? { now: input.now } : {}), ...(input.dir ? { dir: input.dir } : {}),
414
595
  ...(input.fileName ? { fileName: input.fileName } : {}), ...(input.rand ? { rand: input.rand } : {}),
@@ -435,5 +616,6 @@ export async function measure(input) {
435
616
  return {
436
617
  ...counts, ...coverageNow(), file: res.file, refusedRows,
437
618
  withFindings: rows.filter((r) => r.findings.length > 0).length,
619
+ ...(apiNames ? { apiNames } : {}),
438
620
  };
439
621
  }
@@ -1,12 +1,63 @@
1
1
  /**
2
- * The one line CSPeach prints after it activated, and verified, an object of
3
- * the team project this laptop has joined (spec §4.3). It records nothing:
4
- * "CSPeach activated it" and "the developer says it is fixed" are two different
5
- * sentences, and only the second is a claim.
2
+ * The line CSPeach prints after it activated, and verified, an object of the
3
+ * team project this laptop has joined (spec §4.3).
4
+ *
5
+ * 1.1.31 (owner, 2026-10-07: "why would developers claim it when CSPeach is
6
+ * working?"): when CSPeach itself wrote the object's source in this session,
7
+ * the fix is recorded for the developer — the claim covers every rule open at
8
+ * the object's last measurement, with the transport the write used. A claim is
9
+ * still not proof: only the next measurement moves it to verified.
10
+ * An object CSPeach activated but did not write (someone changed it in Eclipse
11
+ * or SE80) only gets the one-line offer; the developer claims that one.
6
12
  */
7
13
  import { objectKey } from '@cspeach/register-core';
8
14
  import { activeProject, readBaseline } from './store.js';
9
- export function teamFixOffers(objects) {
15
+ import { findingsByObject } from './findings.js';
16
+ import { writeRecord } from './records.js';
17
+ import { regenerateWorkingList } from './workbooks.js';
18
+ /** The source-writing tools, and the argument that names the object each one wrote. */
19
+ const SOURCE_WRITES = {
20
+ sap_set_source: 'name',
21
+ sap_update_method: 'class_name',
22
+ sap_set_class_include: 'className',
23
+ };
24
+ /**
25
+ * Objects whose source CSPeach wrote successfully in this session (upper-case
26
+ * name → the transport the write used, '' when none is known).
27
+ */
28
+ export function cspeachWritesThisSession(toolCalls) {
29
+ const out = new Map();
30
+ for (const c of toolCalls ?? []) {
31
+ if (!c.completed || c.is_error)
32
+ continue;
33
+ const field = SOURCE_WRITES[c.tool];
34
+ if (!field)
35
+ continue;
36
+ const args = (c.args ?? {});
37
+ const name = String(args[field] ?? '').trim().toUpperCase();
38
+ if (!name)
39
+ continue;
40
+ let transport = typeof args['transport'] === 'string' ? args['transport'] : '';
41
+ try {
42
+ // sap_set_source / sap_update_method report the request they used as transport_used.
43
+ const r = JSON.parse(c.result);
44
+ if (typeof r?.transport_used === 'string' && r.transport_used)
45
+ transport = r.transport_used;
46
+ else if (typeof r?.transport === 'string' && r.transport)
47
+ transport = r.transport;
48
+ }
49
+ catch { /* a plain-text result carries no transport */ }
50
+ out.set(name, transport.trim().toUpperCase());
51
+ }
52
+ return out;
53
+ }
54
+ /** Fixes this process already recorded (a write that activated, then an explicit activate, record once). */
55
+ const recordedThisRun = new Set();
56
+ /** Tests only. */
57
+ export function resetRecordedFixes() { recordedThisRun.clear(); }
58
+ export function teamFixOffers(objects, opts = {}) {
59
+ // A guided journey is on (journey/flow.ts): the same records, in solo words.
60
+ const solo = opts.wording === 'journey';
10
61
  try {
11
62
  const joined = activeProject();
12
63
  if (!joined)
@@ -17,15 +68,56 @@ export function teamFixOffers(objects) {
17
68
  for (const o of baseline)
18
69
  byName.set(o.name.toUpperCase(), [...(byName.get(o.name.toUpperCase()) ?? []), o]);
19
70
  const lines = [];
71
+ const done = new Set();
20
72
  for (const o of objects) {
21
73
  const type = String(o?.type ?? '').split('/')[0].trim().toUpperCase();
22
74
  const name = String(o?.name ?? '').trim().toUpperCase();
23
75
  const same = byName.get(name) ?? [];
24
76
  const hit = byKey.get(objectKey(type, name)) ?? (same.length === 1 ? same[0] : undefined);
25
- if (hit)
26
- lines.push(`This object is in your team project. Mark it fixed? cspeach team fixed ${hit.type.toUpperCase()} ${hit.name.toUpperCase()}`);
77
+ if (!hit)
78
+ continue;
79
+ const key = objectKey(hit.type, hit.name);
80
+ if (done.has(key))
81
+ continue;
82
+ done.add(key);
83
+ const label = `${hit.type.toUpperCase()} ${hit.name.toUpperCase()}`;
84
+ const transport = opts.written?.get(hit.name.toUpperCase());
85
+ if (transport === undefined) {
86
+ lines.push(solo
87
+ ? `${label} is one of your objects, but CSPeach did not write it, so no fix was recorded.`
88
+ : `This object is in your team project. Mark it fixed? cspeach team fixed ${label}`);
89
+ continue;
90
+ }
91
+ const runKey = `${joined.projectId}|${key}|${transport}`;
92
+ if (recordedThisRun.has(runKey))
93
+ continue;
94
+ const f = findingsByObject(joined.projectDir, joined.outboxDir).get(key);
95
+ if (!f?.latestAt) {
96
+ lines.push(solo
97
+ ? `${label} has not been checked yet, so no fix was recorded. The next check looks at it.`
98
+ : `${label} has not been measured yet, so no fix was recorded. Run cspeach team refresh to measure it.`);
99
+ continue;
100
+ }
101
+ if (!f.latest.length) {
102
+ lines.push(solo
103
+ ? `${label} had nothing open at its last check, so no fix was recorded. The next check looks at it.`
104
+ : `${label} had nothing open at its last measurement, so no fix was recorded. The next cspeach team refresh checks it.`);
105
+ continue;
106
+ }
107
+ // The name this person records under in this team project, as team verbs use it.
108
+ const who = opts.who ?? joined.name ?? process.env['CSPEACH_AUTHOR_NAME'] ?? process.env['USER'] ?? process.env['USERNAME'] ?? 'consultant';
109
+ const res = writeRecord(joined, 'fix', f.latest.map((r) => ({ key, rule: r.rule, status: 'fixed', ...(transport ? { transport } : {}) })), { name: who });
110
+ if (!res.file)
111
+ continue;
112
+ recordedThisRun.add(runKey);
113
+ const by = `✓ Recorded: ${label} fixed by CSPeach${transport ? ` in ${transport}` : ''}.`;
114
+ lines.push(solo ? `${by} Not yet checked — the next check proves it.` : `${by} A claim is not proof — the next cspeach team refresh checks it.`);
115
+ try {
116
+ regenerateWorkingList(joined, who);
117
+ }
118
+ catch { /* the record is kept; the list catches up on the next run */ }
27
119
  }
28
- return [...new Set(lines)];
120
+ return lines;
29
121
  }
30
122
  catch {
31
123
  return []; // a folder halfway through a sync must never touch an activation
@@ -1,6 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import { buildRegister, narrowedLanes } from '@cspeach/register-core';
3
- import { collectRecords, readBaseline, readLanes, readProjectFile } from './store.js';
3
+ import { collectRecords, readBaseline, readEstate, readLanes, readProjectFile } from './store.js';
4
4
  export function registerFor(projectDir, outboxDir) {
5
5
  const project = readProjectFile(projectDir);
6
6
  const collected = collectRecords(projectDir);
@@ -10,7 +10,11 @@ export function registerFor(projectDir, outboxDir) {
10
10
  const own = outboxDir && path.resolve(outboxDir) !== path.resolve(projectDir) ? collectRecords(outboxDir) : { records: [], unreadable: 0, files: 0 };
11
11
  const baseline = readBaseline(projectDir);
12
12
  const lanes = readLanes(projectDir);
13
- const register = buildRegister({ project, baseline, lanes, records: [...collected.records, ...own.records] });
13
+ // The newest discovery, when the folder holds one: the estate around the team project.
14
+ const estate = readEstate(projectDir)?.estate;
15
+ const register = buildRegister({
16
+ project, baseline, lanes, records: [...collected.records, ...own.records], ...(estate !== undefined ? { estate } : {}),
17
+ });
14
18
  return {
15
19
  register, files: collected.files + own.files,
16
20
  unreadable: collected.unreadable + own.unreadable, ownFiles: own.files,