@cspeach/cli 1.1.29 → 1.1.31

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 (105) hide show
  1. package/.cspeach/project.json +4 -0
  2. package/README.md +11 -1
  3. package/dist/agent/loop.js +127 -11
  4. package/dist/auth/auth-file.js +2 -2
  5. package/dist/cli.js +39 -13
  6. package/dist/commands/config-set.js +1 -1
  7. package/dist/commands/login.js +1 -1
  8. package/dist/commands/logout.js +1 -1
  9. package/dist/commands/plan-continue.js +1 -1
  10. package/dist/commands/plan-gate.js +8 -1
  11. package/dist/commands/plan-resume.js +24 -18
  12. package/dist/commands/plan-startup.js +49 -6
  13. package/dist/commands/team-discover.js +195 -0
  14. package/dist/commands/team-plan.js +585 -0
  15. package/dist/commands/team.js +652 -46
  16. package/dist/commands/whoami.js +1 -1
  17. package/dist/config/paths.js +15 -1
  18. package/dist/journey/chain.js +728 -0
  19. package/dist/journey/clean-core.js +213 -0
  20. package/dist/journey/flow.js +166 -0
  21. package/dist/journey/run.js +435 -0
  22. package/dist/journey/session-deps.js +77 -0
  23. package/dist/journey/trigger.js +153 -0
  24. package/dist/lane/app-chain.js +9 -7
  25. package/dist/lane/chain-token.js +9 -5
  26. package/dist/lane/roots.js +97 -41
  27. package/dist/one-shot.js +21 -1
  28. package/dist/projects/clean-core-confirm.js +192 -0
  29. package/dist/projects/detail-path.js +9 -4
  30. package/dist/projects/extract-clean-core.js +82 -0
  31. package/dist/projects/migration.js +4 -105
  32. package/dist/projects/plan-lane.js +67 -17
  33. package/dist/projects/plan-sources.js +13 -7
  34. package/dist/projects/project-folder.js +131 -0
  35. package/dist/projects/promote-command.js +39 -0
  36. package/dist/projects/save-command.js +115 -15
  37. package/dist/projects/saved-lines.js +17 -3
  38. package/dist/projects/sort-command.js +499 -0
  39. package/dist/projects/sort-core.js +248 -0
  40. package/dist/projects/validate.js +2 -1
  41. package/dist/projects/workspace-retire.js +48 -0
  42. package/dist/projects/workspace.js +182 -120
  43. package/dist/register/atc-file.js +9 -1
  44. package/dist/register/discover.js +901 -0
  45. package/dist/register/findings.js +82 -0
  46. package/dist/register/measure.js +60 -1
  47. package/dist/register/offer.js +125 -0
  48. package/dist/register/picture.js +25 -0
  49. package/dist/register/plan-import.js +325 -0
  50. package/dist/register/records.js +57 -0
  51. package/dist/register/status-file/estate-parts.js +168 -0
  52. package/dist/register/status-file/plan-parts.js +68 -0
  53. package/dist/register/status-file/progress-chart.js +110 -0
  54. package/dist/register/status-file/register-map.js +221 -0
  55. package/dist/register/status-file/register-model.js +341 -0
  56. package/dist/register/status-file/register-parts.js +555 -0
  57. package/dist/register/status-file/register-status-file.js +189 -0
  58. package/dist/register/status-file/team-css.js +5 -0
  59. package/dist/register/status-file-sync.js +22 -0
  60. package/dist/register/status-html.js +19 -0
  61. package/dist/register/store.js +40 -2
  62. package/dist/register/workbooks.js +124 -0
  63. package/dist/register/write-whole.js +22 -0
  64. package/dist/register/xlsx.js +127 -0
  65. package/dist/register/xref.js +117 -0
  66. package/dist/renderer/startup-lines.js +11 -0
  67. package/dist/renderer/tool-labels.js +3 -1
  68. package/dist/renderer/tool-widget.js +15 -1
  69. package/dist/repl/at-completer.js +6 -2
  70. package/dist/repl/at-picker.js +1 -1
  71. package/dist/repl/journey-entry.js +25 -0
  72. package/dist/repl.js +158 -39
  73. package/dist/session/backfill.js +342 -0
  74. package/dist/session/project-match.js +63 -0
  75. package/dist/session/resume.js +72 -24
  76. package/dist/session/schema.js +3 -1
  77. package/dist/session/store.js +96 -51
  78. package/dist/skill-catalog.js +2 -2
  79. package/dist/skills/bundled-skills.js +45 -45
  80. package/dist/tools/_filesystem-shared.js +190 -21
  81. package/dist/tools/_team-work-path.js +133 -0
  82. package/dist/tools/approval.js +7 -1
  83. package/dist/tools/filesystem/extract-xlsx.js +27 -3
  84. package/dist/tools/filesystem/file-edit.js +12 -4
  85. package/dist/tools/filesystem/file-read.js +8 -4
  86. package/dist/tools/filesystem/file-write.js +12 -4
  87. package/dist/tools/filesystem/glob.js +33 -8
  88. package/dist/tools/filesystem/grep.js +9 -4
  89. package/dist/tools/filesystem/read-document.js +6 -3
  90. package/dist/tools/project/cca_merge.js +8 -2
  91. package/dist/tools/project/team_estate.js +91 -0
  92. package/dist/tools/project/team_findings.js +145 -0
  93. package/dist/tools/project/upgrade_merge_progress.js +8 -2
  94. package/dist/tools/sap-read.js +21 -4
  95. package/dist/tools/sap-write.js +203 -5
  96. package/dist/tools/subagent/read_agent.js +3 -0
  97. package/dist/tools/tool-loading.js +2 -2
  98. package/dist/tools/transport-fit-check.js +117 -0
  99. package/dist/tools/transport-fit.js +15 -7
  100. package/dist/tools/transport.js +57 -2
  101. package/dist/ui/app.js +2 -2
  102. package/dist/ui/approval-modal.js +74 -6
  103. package/dist/ui/widgets/confirm-card.js +68 -5
  104. package/dist/util/open-in-browser.js +143 -0
  105. package/package.json +120 -120
@@ -19,7 +19,8 @@
19
19
  import { promises as fs } from 'node:fs';
20
20
  import * as path from 'node:path';
21
21
  import { registerTool } from '../index.js';
22
- import { resolveSafePath, assertRealPathContained, PathOutsideRootError, isDenylistedPath, isReadOnlyProjectFile, DENYLIST_DESCRIPTION, } from '../_filesystem-shared.js';
22
+ import { resolveSafePath, assertRealPathContained, PathOutsideRootError, isDenylistedPath, isReadOnlyProjectFile, isSeatReadOnlyFile, DENYLIST_DESCRIPTION, } from '../_filesystem-shared.js';
23
+ import { isReadOnlyRel, isWorkRel, teamWorkRoot, teamWorkTarget } from '../_team-work-path.js';
23
24
  const MAX_RESULTS = 500;
24
25
  const MAX_FILES_TRAVERSED = 10_000;
25
26
  const ALWAYS_SKIPPED_DIRS = new Set([
@@ -85,10 +86,14 @@ export async function globHandler(args, ctx) {
85
86
  if (!args.pattern || typeof args.pattern !== 'string') {
86
87
  return { content: 'error: pattern is required (non-empty string)', is_error: true };
87
88
  }
89
+ // Task 15 — in a team folder a search root under `.cspeach/<work-subdir>`
90
+ // is the seat's; `root` is then the seat work root for every check below.
91
+ const t = args.cwd ? teamWorkTarget(ctx.cwd, args.cwd) : { root: ctx.cwd, path: '' };
92
+ const root = t.root;
88
93
  // Resolve search root — defaults to ctx.cwd
89
94
  let searchRoot;
90
95
  try {
91
- searchRoot = args.cwd ? resolveSafePath(ctx.cwd, args.cwd) : path.resolve(ctx.cwd);
96
+ searchRoot = args.cwd ? resolveSafePath(root, t.path) : path.resolve(root);
92
97
  }
93
98
  catch (err) {
94
99
  if (err instanceof PathOutsideRootError) {
@@ -115,7 +120,7 @@ export async function globHandler(args, ctx) {
115
120
  const wantsCspeach = isCspeachPattern(args.pattern);
116
121
  const collected = [];
117
122
  let budget = MAX_FILES_TRAVERSED;
118
- async function walk(dir, relPrefix) {
123
+ async function walk(dir, relPrefix, denyRoot = root) {
119
124
  if (budget <= 0 || collected.length >= MAX_RESULTS * 4)
120
125
  return;
121
126
  let entries;
@@ -146,7 +151,7 @@ export async function globHandler(args, ctx) {
146
151
  // work subdirs); the per-file read check decides what is listed.
147
152
  if (ALWAYS_SKIPPED_DIRS.has(ent.name) && !intoCspeach && !inCspeach(relPrefix))
148
153
  continue;
149
- await walk(path.join(dir, ent.name), relPath);
154
+ await walk(path.join(dir, ent.name), relPath, denyRoot);
150
155
  }
151
156
  else if (ent.isFile()) {
152
157
  budget--;
@@ -154,8 +159,8 @@ export async function globHandler(args, ctx) {
154
159
  continue;
155
160
  // Denylist check on the matched path
156
161
  const abs = path.join(dir, ent.name);
157
- const denylist = isDenylistedPath(abs, ctx.cwd);
158
- if (denylist.blocked && !isReadOnlyProjectFile(abs, ctx.cwd))
162
+ const denylist = isDenylistedPath(abs, denyRoot);
163
+ if (denylist.blocked && !isReadOnlyProjectFile(abs, denyRoot))
159
164
  continue;
160
165
  let mtime = 0;
161
166
  try {
@@ -175,7 +180,7 @@ export async function globHandler(args, ctx) {
175
180
  // per-file denylist check below sees real paths too.
176
181
  let realRoot;
177
182
  try {
178
- realRoot = await assertRealPathContained(searchRoot, ctx.cwd);
183
+ realRoot = await assertRealPathContained(searchRoot, root);
179
184
  }
180
185
  catch (err) {
181
186
  if (err instanceof PathOutsideRootError) {
@@ -184,7 +189,7 @@ export async function globHandler(args, ctx) {
184
189
  return { content: `error: ${err instanceof Error ? err.message : String(err)}`, is_error: true };
185
190
  }
186
191
  for (const cand of [searchRoot, realRoot]) {
187
- const verdict = isDenylistedPath(cand, ctx.cwd);
192
+ const verdict = isDenylistedPath(cand, root);
188
193
  if (verdict.blocked) {
189
194
  return {
190
195
  content: `error: refusing to list protected path "${verdict.relFromRoot}" (denylist: ${DENYLIST_DESCRIPTION})`,
@@ -193,6 +198,26 @@ export async function globHandler(args, ctx) {
193
198
  }
194
199
  }
195
200
  await walk(realRoot, '');
201
+ // Task 15 + ruling 27 — a `.cspeach/…` pattern from a TEAM folder's root
202
+ // lists what a save and the skills left in the seat (working files, saved
203
+ // results `.cspeach/*.cspeach.json`, lane decisions `.cspeach/lane/*.json`),
204
+ // as a solo folder lists its own: the seat's `.cspeach` is walked as
205
+ // `.cspeach`, every file judged against the seat work root, so only those
206
+ // shapes are listed. The team folder's own copies of those shapes (left
207
+ // from before) are not listed — file_read would not reach them.
208
+ const seatRoot = wantsCspeach && searchRoot === path.resolve(ctx.cwd) ? teamWorkRoot(ctx.cwd) : null;
209
+ if (seatRoot !== null) {
210
+ for (let i = collected.length - 1; i >= 0; i--) {
211
+ const rel = collected[i].relPath;
212
+ if (isWorkRel(rel) || isReadOnlyRel(rel))
213
+ collected.splice(i, 1);
214
+ }
215
+ const seatCspeach = path.join(seatRoot, '.cspeach');
216
+ // The folder itself must be the seat's, spelled and real (no link on the way).
217
+ if (isSeatReadOnlyFile(path.join(seatCspeach, '_.cspeach.json'))) {
218
+ await walk(seatCspeach, '.cspeach', seatRoot);
219
+ }
220
+ }
196
221
  // Sort by mtime descending, cap at MAX_RESULTS
197
222
  collected.sort((a, b) => b.mtime - a.mtime);
198
223
  const sample = collected.slice(0, MAX_RESULTS).map((f) => f.relPath);
@@ -22,6 +22,7 @@ import * as path from 'node:path';
22
22
  import { registerTool } from '../index.js';
23
23
  import { resolveSafePath, assertRealPathContained, PathOutsideRootError, isDenylistedPath, DENYLIST_DESCRIPTION, } from '../_filesystem-shared.js';
24
24
  import { globToRegex } from './glob.js';
25
+ import { teamWorkTarget } from '../_team-work-path.js';
25
26
  const MAX_MATCHES = 250;
26
27
  const MAX_FILES_TRAVERSED = 10_000;
27
28
  const MAX_FILE_BYTES = 2_000_000;
@@ -40,9 +41,13 @@ export async function grepHandler(args, ctx) {
40
41
  catch (err) {
41
42
  return { content: `error: invalid regex — ${err.message}`, is_error: true };
42
43
  }
44
+ // Task 15 — in a team folder a search root under `.cspeach/<work-subdir>`
45
+ // is the seat's; `root` is then the seat work root for every check below.
46
+ const t = args.path ? teamWorkTarget(ctx.cwd, args.path) : { root: ctx.cwd, path: '' };
47
+ const root = t.root;
43
48
  let searchRoot;
44
49
  try {
45
- searchRoot = args.path ? resolveSafePath(ctx.cwd, args.path) : path.resolve(ctx.cwd);
50
+ searchRoot = args.path ? resolveSafePath(root, t.path) : path.resolve(root);
46
51
  }
47
52
  catch (err) {
48
53
  if (err instanceof PathOutsideRootError) {
@@ -98,7 +103,7 @@ export async function grepHandler(args, ctx) {
98
103
  if (fileFilter && !fileFilter.test(relPath))
99
104
  continue;
100
105
  const abs = path.join(dir, ent.name);
101
- const denylist = isDenylistedPath(abs, ctx.cwd);
106
+ const denylist = isDenylistedPath(abs, root);
102
107
  if (denylist.blocked)
103
108
  continue;
104
109
  let stat;
@@ -135,7 +140,7 @@ export async function grepHandler(args, ctx) {
135
140
  // per-file denylist check below sees real paths too.
136
141
  let realRoot;
137
142
  try {
138
- realRoot = await assertRealPathContained(searchRoot, ctx.cwd);
143
+ realRoot = await assertRealPathContained(searchRoot, root);
139
144
  }
140
145
  catch (err) {
141
146
  if (err instanceof PathOutsideRootError) {
@@ -144,7 +149,7 @@ export async function grepHandler(args, ctx) {
144
149
  return { content: `error: ${err instanceof Error ? err.message : String(err)}`, is_error: true };
145
150
  }
146
151
  for (const cand of [searchRoot, realRoot]) {
147
- const verdict = isDenylistedPath(cand, ctx.cwd);
152
+ const verdict = isDenylistedPath(cand, root);
148
153
  if (verdict.blocked) {
149
154
  return {
150
155
  content: `error: refusing to search protected path "${verdict.relFromRoot}" (denylist: ${DENYLIST_DESCRIPTION})`,
@@ -8,6 +8,7 @@ import { promises as fs } from 'node:fs';
8
8
  import * as path from 'node:path';
9
9
  import { registerTool } from '../index.js';
10
10
  import { resolveSafePath, assertRealPathContained, PathOutsideRootError, DENYLIST_DESCRIPTION, isDenylistedPath, } from '../_filesystem-shared.js';
11
+ import { teamWorkTarget } from '../_team-work-path.js';
11
12
  import { extractDocument, DocumentTooLargeError } from './extract-document.js';
12
13
  const MAX_LINES = 2000;
13
14
  const MAX_BYTES = 50 * 1024 * 1024; // 50 MB
@@ -20,9 +21,11 @@ export async function readDocumentHandler(args, ctx) {
20
21
  if (args.limit !== undefined && args.limit < 1) {
21
22
  return { content: `error: limit must be a positive integer (got ${args.limit})`, is_error: true };
22
23
  }
24
+ // Task 15 — in a team folder a skill working file is read from the seat.
25
+ const t = teamWorkTarget(ctx.cwd, args.path);
23
26
  let abs;
24
27
  try {
25
- abs = resolveSafePath(ctx.cwd, args.path);
28
+ abs = resolveSafePath(t.root, t.path);
26
29
  }
27
30
  catch (err) {
28
31
  if (err instanceof PathOutsideRootError)
@@ -41,7 +44,7 @@ export async function readDocumentHandler(args, ctx) {
41
44
  }
42
45
  let realAbs;
43
46
  try {
44
- realAbs = await assertRealPathContained(abs, ctx.cwd);
47
+ realAbs = await assertRealPathContained(abs, t.root);
45
48
  }
46
49
  catch (err) {
47
50
  if (err instanceof PathOutsideRootError)
@@ -50,7 +53,7 @@ export async function readDocumentHandler(args, ctx) {
50
53
  return { content: 'error: file not found', is_error: true };
51
54
  return { content: `error: ${err instanceof Error ? err.message : String(err)}`, is_error: true };
52
55
  }
53
- const denylist = isDenylistedPath(realAbs, ctx.cwd);
56
+ const denylist = isDenylistedPath(realAbs, t.root);
54
57
  if (denylist.blocked) {
55
58
  return { content: `error: refusing to read sensitive path "${denylist.relFromRoot}" (denylist: ${DENYLIST_DESCRIPTION})`, is_error: true };
56
59
  }
@@ -30,6 +30,7 @@ import { registerTool } from '../index.js';
30
30
  import { mergeCcaAssessments, renderCcaMergeManifest, } from '../../projects/merge-cca.js';
31
31
  import { loadEnvelope, MANIFEST_PASTE_INSTRUCTIONS } from './_merge-shared.js';
32
32
  import { assertWritable } from '../_filesystem-shared.js';
33
+ import { pointsIntoSharedCspeach, SHARED_CSPEACH_REFUSAL, teamWorkTarget } from '../_team-work-path.js';
33
34
  export async function ccaMergeHandler(args, ctx) {
34
35
  if (!Array.isArray(args.inputs) || args.inputs.some((i) => typeof i !== 'string')) {
35
36
  return { content: 'error: inputs must be an array of envelope paths / @-tokens.', is_error: true };
@@ -98,11 +99,16 @@ export async function ccaMergeHandler(args, ctx) {
98
99
  // mergedFrom: the input envelopes [{ file, label }]
99
100
  // }
100
101
  let detailWritten = true;
101
- const detailAbs = isAbsolute(merged.detailPath) ? merged.detailPath : join(ctx.cwd, merged.detailPath);
102
+ // Task 15 — in a team folder the detail file is a working file: the seat's.
103
+ const t = teamWorkTarget(ctx.cwd, merged.detailPath);
104
+ const detailAbs = isAbsolute(t.path) ? t.path : join(t.root, t.path);
102
105
  try {
103
106
  // SEC (review round 2): the real landing path must stay in the project and
104
107
  // not be protected (a junctioned .cspeach/<domain> -> .git is refused).
105
- await assertWritable(detailAbs, ctx.cwd);
108
+ const realDetail = await assertWritable(detailAbs, t.root);
109
+ // Final fix wave, item 3 — never into the shared .cspeach of a team folder.
110
+ if (pointsIntoSharedCspeach(ctx.cwd, realDetail))
111
+ throw new Error(SHARED_CSPEACH_REFUSAL);
106
112
  mkdirSync(dirname(detailAbs), { recursive: true });
107
113
  writeFileSync(detailAbs, JSON.stringify({
108
114
  generatedAt: new Date().toISOString(),
@@ -0,0 +1,91 @@
1
+ /**
2
+ * team_estate — read-only (spec 2026-10-06 §10).
3
+ *
4
+ * The estate inventory the lead already pulled with `cspeach team discover`:
5
+ * packages, totals, the usage sentence and its sources, unknowns, modifications
6
+ * of SAP objects and how far the cross-reference index was read — and, on
7
+ * request, the objects of named packages with their evidence. What
8
+ * /abap-cca judges from. No SAP call, no SQL, no write.
9
+ *
10
+ * Read from the newest discovery in the team project this laptop joined, or
11
+ * from the working folder's `discovery/` when no team project is joined.
12
+ */
13
+ import { estateSummaryOf } from '@cspeach/register-core';
14
+ import { registerTool } from '../index.js';
15
+ import { shownUpTo } from '../../register/findings.js';
16
+ import { activeProject, readEstate } from '../../register/store.js';
17
+ const CAP = 200;
18
+ const MODIFICATIONS = 50;
19
+ const TEXT_CAP = 300;
20
+ const NO_ESTATE = 'No estate inventory yet. The lead runs: cspeach team discover --out <folder>';
21
+ const match = (pattern, value) => new RegExp(`^${pattern.trim().replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`, 'i').test(value);
22
+ /**
23
+ * Every string cleaned the way a finding title is, before it reaches the model:
24
+ * the discovery files were written on the lead's laptop out of SAP text.
25
+ */
26
+ function clean(v, depth = 0) {
27
+ if (typeof v === 'string')
28
+ return shownUpTo(v, TEXT_CAP);
29
+ if (depth > 4 || v === null || typeof v !== 'object')
30
+ return v;
31
+ if (Array.isArray(v))
32
+ return v.map((x) => clean(x, depth + 1));
33
+ return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, clean(x, depth + 1)]));
34
+ }
35
+ export async function teamEstateHandler(args, ctx) {
36
+ try {
37
+ const dir = activeProject()?.projectDir ?? ctx.cwd;
38
+ const found = readEstate(dir);
39
+ const summary = found ? estateSummaryOf(found.estate) : null;
40
+ if (!found || !summary)
41
+ return { content: NO_ESTATE, is_error: true };
42
+ const body = {
43
+ takenAt: summary.takenAt,
44
+ packages: summary.packages,
45
+ totals: summary.totals,
46
+ usageLine: summary.usageLine,
47
+ usageSources: summary.usageSources,
48
+ unknowns: summary.unknowns,
49
+ modifications: summary.modifications.slice(0, MODIFICATIONS),
50
+ ...(summary.modifications.length > MODIFICATIONS ? { modificationsMore: summary.modifications.length - MODIFICATIONS } : {}),
51
+ xref: summary.xref,
52
+ };
53
+ const patterns = (args.packages ?? '').split(',').map((s) => s.trim()).filter(Boolean);
54
+ if (patterns.length) {
55
+ const all = found.objects?.kind === 'estate-objects'
56
+ && Array.isArray(found.objects.objects)
57
+ ? found.objects.objects : [];
58
+ const picked = all.filter((o) => !!o && typeof o === 'object'
59
+ && typeof o.name === 'string' && typeof o.type === 'string' && typeof o.package === 'string'
60
+ && patterns.some((p) => match(p, o.package)));
61
+ body.objects = picked.slice(0, CAP).map((o) => ({
62
+ type: o.type, name: o.name, package: o.package,
63
+ changed: o.changed ?? null, callers: o.callers ?? null, batch: o.batch ?? null, usage: o.usage ?? { state: 'unknown' },
64
+ }));
65
+ if (picked.length > CAP)
66
+ body.more = picked.length - CAP;
67
+ if (!found.objects)
68
+ body.objectsNote = 'The object list of this inventory could not be read.';
69
+ }
70
+ return { content: JSON.stringify(clean(body)) };
71
+ }
72
+ catch (e) {
73
+ return { content: `The estate inventory could not be read: ${e instanceof Error ? e.message : String(e)}`, is_error: true };
74
+ }
75
+ }
76
+ registerTool({
77
+ name: 'team_estate',
78
+ description: 'Read-only. The newest estate inventory the lead pulled with cspeach team discover: packages, totals, usage sources and '
79
+ + 'the usage sentence, unknowns, modifications of SAP objects, cross-reference coverage. With packages, also the objects of those '
80
+ + 'packages with callers, batch runs, usage and last change (at most 200). Use it for /abap-cca. No SAP call.',
81
+ isMutating: false,
82
+ category: 'project',
83
+ requiresSap: false,
84
+ input_schema: {
85
+ type: 'object',
86
+ properties: {
87
+ packages: { type: 'string', description: 'Package patterns, comma-separated, e.g. "ZFI_*,ZOLD_TOOLS". Adds their objects.' },
88
+ },
89
+ },
90
+ handler: (args, ctx) => teamEstateHandler(args, ctx),
91
+ });
@@ -0,0 +1,145 @@
1
+ /**
2
+ * team_findings — read-only (spec 2026-10-06 §4.2, triage).
3
+ *
4
+ * What a /abap-clean-core triage proposes from: the objects of a lane (or of
5
+ * package patterns) with the findings measurement zero recorded for them, read
6
+ * from the team project this laptop joined. No SAP call, no second ATC run, no
7
+ * write: every decision is still recorded by program code, one Yes per row.
8
+ *
9
+ * Pages (guided journey, Task 4): `limit` (default 40, at most 200) and
10
+ * `after` (the key "TYPE|NAME" of the last object seen) give one page, with
11
+ * `more` and `next`. Without either, the first 200 and `more`, as before.
12
+ * While a journey triage is queued in the session, only the page the journey
13
+ * handed out (journey/flow.ts, at most 40 keys) is shown: the skill needs no
14
+ * change to work in pages.
15
+ */
16
+ import { dispositionOf, DISPOSITION_WORD, inLane } from '@cspeach/register-core';
17
+ import { registerTool } from '../index.js';
18
+ import { findingsByObject, shownUpTo } from '../../register/findings.js';
19
+ import { registerFor } from '../../register/picture.js';
20
+ import { activeProject, readProjectFile } from '../../register/store.js';
21
+ import { shown } from '../../register/atc-file.js';
22
+ import { journeyPage } from '../../journey/flow.js';
23
+ const CAP = 200;
24
+ /** One page when `after` or `limit` is given. */
25
+ const PAGE = 40;
26
+ /** A decision's reason was written on another laptop and goes to the model: cleaned like a finding title, kept whole up to 300. */
27
+ const REASON_CAP = 300;
28
+ /**
29
+ * Guided journey (Task 7): the triage skill tells the person to record a row
30
+ * "your way with `cspeach team decide`". In the journey the person types no
31
+ * command, and the skill is not reseeded in this slice, so the page the
32
+ * journey hands out carries this note for the model.
33
+ */
34
+ export const JOURNEY_NOTE = "This triage is one step of CSPeach's guided clean-core run. The person types no commands: "
35
+ + 'never suggest a cspeach command (not cspeach team decide, not any other). Say instead that CSPeach asks about each row, '
36
+ + 'and a row skipped is asked again later. Use plain words: "your objects", "your project" — never team, lane, outbox or writer.';
37
+ const match = (pattern, value) => new RegExp(`^${pattern.trim().replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`, 'i').test(value);
38
+ function usageOf(u) {
39
+ return {
40
+ state: u.state,
41
+ ...(u.source ? { source: shownUpTo(u.source, 60) } : {}),
42
+ ...(u.period ? { period: { from: shownUpTo(u.period.from, 30), to: shownUpTo(u.period.to, 30) } } : {}),
43
+ ...(u.lastUsed ? { lastUsed: shownUpTo(u.lastUsed, 30) } : {}),
44
+ ...(u.why ? { why: shownUpTo(u.why, REASON_CAP) } : {}),
45
+ };
46
+ }
47
+ /** A number only when the index was read; otherwise why not, in words. */
48
+ function callersOf(c) {
49
+ if (!c)
50
+ return 'not read: no estate inventory for this object';
51
+ return c.read ? c.objects : `not read: ${shownUpTo(c.why, REASON_CAP)}`;
52
+ }
53
+ export async function teamFindingsHandler(args, ctx) {
54
+ const joined = activeProject();
55
+ if (!joined)
56
+ return { content: 'This laptop has not joined a team project. The developer runs: cspeach team join <folder>', is_error: true };
57
+ try {
58
+ const project = readProjectFile(joined.projectDir);
59
+ const { register } = registerFor(joined.projectDir, joined.outboxDir);
60
+ const laneId = args.lane?.trim();
61
+ const patterns = (args.packages ?? '').split(',').map((s) => s.trim()).filter(Boolean);
62
+ let objects = register.objects;
63
+ let scope = 'all objects';
64
+ if (laneId) {
65
+ const lane = register.lanes.find((l) => l.id.toLowerCase() === laneId.toLowerCase());
66
+ if (!lane)
67
+ return { content: `There is no lane "${laneId}". Lanes: ${register.lanes.map((l) => l.id).join(', ') || 'none yet'}.`, is_error: true };
68
+ objects = objects.filter((o) => inLane(o, lane.id));
69
+ scope = `lane ${lane.id}`;
70
+ }
71
+ else if (patterns.length) {
72
+ objects = objects.filter((o) => patterns.some((p) => match(p, o.package)));
73
+ scope = `packages ${patterns.join(', ')}`;
74
+ }
75
+ // A journey triage sees only the page the journey handed out.
76
+ const page = journeyPage(ctx?.session);
77
+ if (page) {
78
+ const keys = new Set(page.map((k) => k.toUpperCase()));
79
+ objects = objects.filter((o) => keys.has(o.key.toUpperCase()));
80
+ }
81
+ // An empty after is no after.
82
+ const paged = (typeof args.after === 'string' && args.after.trim() !== '') || args.limit !== undefined;
83
+ let start = 0;
84
+ if (typeof args.after === 'string' && args.after.trim()) {
85
+ const after = args.after.trim().toUpperCase();
86
+ const at = objects.findIndex((o) => o.key.toUpperCase() === after);
87
+ if (at === -1)
88
+ return { content: `There is no object ${shown(args.after)} in this scope. Leave out after to start at the first.`, is_error: true };
89
+ start = at + 1;
90
+ }
91
+ const limit = !paged ? CAP
92
+ : Number.isFinite(args.limit) ? Math.min(CAP, Math.max(1, Math.floor(args.limit))) : PAGE;
93
+ const slice = objects.slice(start, start + limit);
94
+ const more = objects.length - start - slice.length;
95
+ const findings = findingsByObject(joined.projectDir, joined.outboxDir);
96
+ const list = slice.map((o) => {
97
+ const f = findings.get(o.key);
98
+ const d = dispositionOf(o.decision);
99
+ return {
100
+ type: o.type, name: o.name, package: o.package,
101
+ owner: register.lanes.find((l) => l.id === o.lane)?.owner ?? null,
102
+ decided: d ? { disposition: DISPOSITION_WORD[d], level: o.decision.level ?? null, targetLevel: o.decision.targetLevel ?? null, reason: typeof o.decision.reason === 'string' ? shownUpTo(o.decision.reason, REASON_CAP) : null } : null,
103
+ baselineAt: f?.baselineAt ?? null, baseline: f?.baseline ?? [], openNow: o.openFindings,
104
+ // Evidence (spec 2026-10-06 §10): usage only with its source, callers only when the index was read,
105
+ // and when the code last CHANGED — which is never use.
106
+ usage: usageOf(o.evidence.usage),
107
+ callers: callersOf(o.evidence.callers),
108
+ lastChanged: o.evidence.changed?.at ? shownUpTo(o.evidence.changed.at, 30) : null,
109
+ retireCandidate: o.retireCandidate,
110
+ priority: { band: o.priority.effective.band, source: o.priority.effective.source },
111
+ };
112
+ });
113
+ return {
114
+ content: JSON.stringify({
115
+ teamProject: project.name, scope, objects: list,
116
+ ...(page ? { journey: JOURNEY_NOTE } : {}),
117
+ ...(more > 0 ? { more } : {}),
118
+ ...(paged && more > 0 && slice.length ? { next: slice[slice.length - 1].key } : {}),
119
+ }),
120
+ };
121
+ }
122
+ catch (e) {
123
+ return { content: `The team project could not be read: ${e instanceof Error ? e.message : String(e)}`, is_error: true };
124
+ }
125
+ }
126
+ registerTool({
127
+ name: 'team_findings',
128
+ description: 'Read-only. The objects of a lane (or package patterns) in the team project this laptop joined, with the findings '
129
+ + 'measurement zero recorded for each, any decision already recorded, and per object: usage (state and source), callers from the '
130
+ + 'index (or why they were not read), last change (a change, not use), whether it is a retire candidate, and its priority. '
131
+ + 'Use it for a /abap-clean-core triage. No SAP call.',
132
+ isMutating: false,
133
+ category: 'project',
134
+ requiresSap: false,
135
+ input_schema: {
136
+ type: 'object',
137
+ properties: {
138
+ lane: { type: 'string', description: 'Lane id, e.g. "fin".' },
139
+ packages: { type: 'string', description: 'Package patterns, comma-separated, e.g. "ZCC_DEMO_*". Ignored when lane is given.' },
140
+ after: { type: 'string', description: 'Page on: the key "TYPE|NAME" of the last object seen (the `next` of the previous page).' },
141
+ limit: { type: 'number', description: 'Objects per page, 1 to 200 (default 40 when paging). Leave out both after and limit for the first 200.' },
142
+ },
143
+ },
144
+ handler: (args, ctx) => teamFindingsHandler(args, ctx),
145
+ });
@@ -32,6 +32,7 @@ import { registerTool } from '../index.js';
32
32
  import { mergeUpgradeProgress, renderUpgradeMergeManifest, } from '../../projects/merge-upgrade.js';
33
33
  import { loadEnvelope, MANIFEST_PASTE_INSTRUCTIONS } from './_merge-shared.js';
34
34
  import { assertWritable } from '../_filesystem-shared.js';
35
+ import { pointsIntoSharedCspeach, SHARED_CSPEACH_REFUSAL, teamWorkTarget } from '../_team-work-path.js';
35
36
  import { resolveDetailFile } from '../../projects/detail-path.js';
36
37
  function sha256OfFile(path) {
37
38
  return createHash('sha256').update(readFileSync(path)).digest('hex');
@@ -149,11 +150,16 @@ export async function upgradeMergeProgressHandler(args, ctx) {
149
150
  // mergedFrom: the input envelopes [{ file, label }]
150
151
  // }
151
152
  let detailWritten = true;
152
- const mergedDetailAbs = isAbsolute(merged.detailPath) ? merged.detailPath : join(ctx.cwd, merged.detailPath);
153
+ // Task 15 — in a team folder the detail file is a working file: the seat's.
154
+ const t = teamWorkTarget(ctx.cwd, merged.detailPath);
155
+ const mergedDetailAbs = isAbsolute(t.path) ? t.path : join(t.root, t.path);
153
156
  try {
154
157
  // SEC (review round 2): the real landing path must stay in the project and
155
158
  // not be protected (a junctioned .cspeach/<domain> -> .git is refused).
156
- await assertWritable(mergedDetailAbs, ctx.cwd);
159
+ const realDetail = await assertWritable(mergedDetailAbs, t.root);
160
+ // Final fix wave, item 3 — never into the shared .cspeach of a team folder.
161
+ if (pointsIntoSharedCspeach(ctx.cwd, realDetail))
162
+ throw new Error(SHARED_CSPEACH_REFUSAL);
157
163
  mkdirSync(dirname(mergedDetailAbs), { recursive: true });
158
164
  writeFileSync(mergedDetailAbs, JSON.stringify({
159
165
  generatedAt: new Date().toISOString(),
@@ -400,9 +400,12 @@ registerTool({
400
400
  // -----------------------------------------------------------------------
401
401
  // 8. sap_usage_references
402
402
  // -----------------------------------------------------------------------
403
+ /** T001 alone has ~20,000 callers on an S/4 box; list the first 200, count the rest. */
404
+ const USAGE_REFERENCES_MAX_LINES = 200;
403
405
  registerTool({
404
406
  name: 'sap_usage_references',
405
- description: 'Find all places where a given ABAP object is referenced (where-used list).',
407
+ description: 'Find all places where a given ABAP object is referenced (where-used list). '
408
+ + 'Widely used SAP objects (T001, BUKRS …) can take minutes; prefer custom objects.',
406
409
  isMutating: false,
407
410
  requiresSap: true,
408
411
  input_schema: {
@@ -414,9 +417,23 @@ registerTool({
414
417
  required: ['name', 'type'],
415
418
  },
416
419
  handler: async (args, ctx) => {
417
- // usageReferences(objectType, objectName) — type first
418
- const refs = await ctx.adt.usageReferences(args.type, args.name);
419
- return { content: JSON.stringify(refs, null, 2) };
420
+ // usageReferences(objectType, objectName) — type first. `unreadable` is
421
+ // never "no callers" (spec 2026-10-06 §5.3) — and never a bare `[]`.
422
+ const result = await ctx.adt.usageReferences(args.type, args.name);
423
+ if (result.status !== 'ok') {
424
+ return { content: `Where-used could not be read: ${result.note || 'no reason given'}` };
425
+ }
426
+ if (result.refs.length === 0)
427
+ return { content: 'No callers found (where-used read OK)' };
428
+ const shown = result.refs.slice(0, USAGE_REFERENCES_MAX_LINES).map((r) => `${r.type} ${r.name}`);
429
+ const hidden = result.refs.length - shown.length;
430
+ return {
431
+ content: [
432
+ `Callers found: ${result.refs.length} (where-used read OK)`,
433
+ ...shown,
434
+ ...(hidden > 0 ? [`… and ${hidden} more not shown`] : []),
435
+ ].join('\n'),
436
+ };
420
437
  },
421
438
  });
422
439
  // -----------------------------------------------------------------------