@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.
- package/README.md +11 -1
- package/dist/agent/loop.js +132 -9
- package/dist/cli.js +49 -18
- package/dist/commands/config-set.js +1 -1
- package/dist/commands/help.js +16 -1
- package/dist/commands/plan-chain.js +3 -0
- package/dist/commands/plan-continue.js +1 -1
- package/dist/commands/plan-gate.js +17 -1
- package/dist/commands/plan-resume.js +28 -18
- package/dist/commands/plan-startup.js +49 -6
- package/dist/commands/team-discover.js +195 -0
- package/dist/commands/team-plan.js +585 -0
- package/dist/commands/team-rules.js +187 -0
- package/dist/commands/team.js +971 -95
- package/dist/journey/chain.js +978 -0
- package/dist/journey/clean-core.js +235 -0
- package/dist/journey/flow.js +216 -0
- package/dist/journey/rebuild.js +464 -0
- package/dist/journey/run.js +628 -0
- package/dist/journey/session-deps.js +81 -0
- package/dist/journey/trigger.js +153 -0
- package/dist/lane/app-chain.js +21 -9
- package/dist/lane/chain-token.js +9 -5
- package/dist/lane/roots.js +97 -41
- package/dist/one-shot.js +28 -2
- package/dist/projects/clean-core-confirm.js +192 -3
- package/dist/projects/detail-path.js +9 -4
- package/dist/projects/extract-clean-core.js +3 -0
- package/dist/projects/migration.js +4 -105
- package/dist/projects/plan-lane.js +67 -17
- package/dist/projects/plan-sources.js +13 -7
- package/dist/projects/project-folder.js +131 -0
- package/dist/projects/promote-command.js +39 -0
- package/dist/projects/save-command.js +100 -12
- package/dist/projects/saved-lines.js +17 -3
- package/dist/projects/sort-command.js +499 -0
- package/dist/projects/sort-core.js +248 -0
- package/dist/projects/workspace-retire.js +48 -0
- package/dist/projects/workspace.js +182 -121
- package/dist/register/baseline.js +39 -2
- package/dist/register/decision-rules.js +35 -0
- package/dist/register/discover.js +931 -0
- package/dist/register/export-files.js +58 -0
- package/dist/register/findings.js +5 -2
- package/dist/register/measure.js +191 -9
- package/dist/register/offer.js +100 -8
- package/dist/register/picture.js +6 -2
- package/dist/register/plan-import.js +328 -0
- package/dist/register/records.js +40 -3
- package/dist/register/rule-proposal.js +4 -0
- package/dist/register/rules-edit.js +257 -0
- package/dist/register/status-file/estate-parts.js +168 -0
- package/dist/register/status-file/plan-parts.js +68 -0
- package/dist/register/status-file/progress-chart.js +110 -0
- package/dist/register/status-file/register-map.js +11 -3
- package/dist/register/status-file/register-model.js +40 -6
- package/dist/register/status-file/register-parts.js +57 -11
- package/dist/register/status-file/register-status-file.js +106 -90
- package/dist/register/status-file/report-model.js +569 -0
- package/dist/register/status-file/team-css.js +1 -1
- package/dist/register/status-file-sync.js +4 -0
- package/dist/register/status-html.js +27 -6
- package/dist/register/store.js +44 -5
- package/dist/register/variant.js +65 -0
- package/dist/register/workbooks.js +28 -42
- package/dist/register/write-whole.js +30 -2
- package/dist/register/xlsx.js +13 -3
- package/dist/register/xref.js +117 -0
- package/dist/renderer/startup-lines.js +11 -0
- package/dist/renderer/tool-labels.js +3 -1
- package/dist/renderer/tool-widget.js +15 -1
- package/dist/repl/at-completer.js +6 -2
- package/dist/repl/at-picker.js +1 -1
- package/dist/repl/builtin-commands.js +4 -0
- package/dist/repl/journey-entry.js +27 -0
- package/dist/repl/plan-hop.js +2 -9
- package/dist/repl/rules-menu.js +177 -0
- package/dist/repl/slash-completer.js +1 -0
- package/dist/repl/slash-picker.js +5 -4
- package/dist/repl.js +236 -59
- package/dist/router/classifier.js +8 -4
- package/dist/router/router-core.js +43 -7
- package/dist/router/routing-decision.js +1 -1
- package/dist/runs/bundled-runs.js +100 -0
- package/dist/runs/front-door.js +19 -0
- package/dist/runs/manifest-runs.js +50 -0
- package/dist/runs/run-command.js +24 -0
- package/dist/runs/runs.js +27 -0
- package/dist/runs/shadow.js +100 -0
- package/dist/runs/types.js +1 -0
- package/dist/session/backfill.js +342 -0
- package/dist/session/project-match.js +63 -0
- package/dist/session/queued-step.js +45 -0
- package/dist/session/resume.js +72 -24
- package/dist/session/schema.js +3 -1
- package/dist/session/store.js +96 -51
- package/dist/skill-catalog.js +2 -2
- package/dist/skills/bundled-skills.js +53 -53
- package/dist/tools/_filesystem-shared.js +174 -12
- package/dist/tools/_team-work-path.js +133 -0
- package/dist/tools/approval.js +7 -1
- package/dist/tools/dispatch-skill.js +8 -4
- package/dist/tools/filesystem/extract-xlsx.js +27 -3
- package/dist/tools/filesystem/file-edit.js +12 -4
- package/dist/tools/filesystem/file-read.js +8 -4
- package/dist/tools/filesystem/file-write.js +12 -4
- package/dist/tools/filesystem/glob.js +33 -8
- package/dist/tools/filesystem/grep.js +9 -4
- package/dist/tools/filesystem/read-document.js +6 -3
- package/dist/tools/lane/app-lane-resolve.js +1 -1
- package/dist/tools/project/cca_merge.js +8 -2
- package/dist/tools/project/rules_propose.js +59 -0
- package/dist/tools/project/team_estate.js +91 -0
- package/dist/tools/project/team_findings.js +89 -5
- package/dist/tools/project/upgrade_merge_progress.js +8 -2
- package/dist/tools/sap-read.js +21 -4
- package/dist/tools/sap-write.js +200 -10
- package/dist/tools/subagent/read_agent.js +3 -0
- package/dist/tools/tool-loading.js +1 -1
- package/dist/tools/transport-fit-check.js +117 -0
- package/dist/tools/transport-fit.js +15 -7
- package/dist/tools/transport.js +57 -2
- package/dist/ui/app.js +2 -2
- package/dist/ui/approval-modal.js +74 -6
- package/dist/ui/widgets/confirm-card.js +68 -5
- package/dist/upgrade-check.js +49 -4
- package/dist/util/open-in-browser.js +143 -0
- package/package.json +3 -3
|
@@ -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(
|
|
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,
|
|
158
|
-
if (denylist.blocked && !isReadOnlyProjectFile(abs,
|
|
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,
|
|
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,
|
|
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(
|
|
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,
|
|
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,
|
|
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,
|
|
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(
|
|
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,
|
|
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,
|
|
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
|
}
|
|
@@ -284,7 +284,7 @@ export async function appLaneResolveHandler(args, ctx, deps = {}) {
|
|
|
284
284
|
ctx.chunkEmitter?.emit('info', `Lane chosen by the rules (no confirm card in this terminal): ${shown}.`);
|
|
285
285
|
}
|
|
286
286
|
if (flow?.specGapPath && planCmd) {
|
|
287
|
-
ctx.pendingDispatch?.set(planCmd);
|
|
287
|
+
ctx.pendingDispatch?.set(planCmd, { by: 'app' }); // audit M-2: CSPeach's step, never shown as typed
|
|
288
288
|
lines.push('Next: the plan is being created.');
|
|
289
289
|
}
|
|
290
290
|
else if (flow?.specGapPath && headless) {
|
|
@@ -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
|
-
|
|
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,
|
|
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,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rules_propose — read-only (step 2, Task 10, optional step; spec 2026-10-08 §7 D7).
|
|
3
|
+
*
|
|
4
|
+
* "Rule from words": the person says a rule in words, the model turns it into
|
|
5
|
+
* the decision-rule shape and calls this. Code checks it with register-core's
|
|
6
|
+
* strict reader against the team project's rules and says it back in plain
|
|
7
|
+
* words. Nothing is written: the proposal is kept in this process, and the
|
|
8
|
+
* person saves it from `/rules` (Add the proposed rule → card → Save), lead
|
|
9
|
+
* only. The model never writes the rules.
|
|
10
|
+
*/
|
|
11
|
+
import { readDecisionRules } from '@cspeach/register-core';
|
|
12
|
+
import { registerTool } from '../index.js';
|
|
13
|
+
import { activeProject, readProjectFile, RegisterError } from '../../register/store.js';
|
|
14
|
+
import { addProposed, ruleWords } from '../../register/rules-edit.js';
|
|
15
|
+
import { setRuleProposal } from '../../register/rule-proposal.js';
|
|
16
|
+
export async function rulesProposeHandler(args, _ctx) {
|
|
17
|
+
const joined = activeProject();
|
|
18
|
+
if (!joined)
|
|
19
|
+
return { content: 'This laptop has not joined a team project. The developer runs: cspeach team join <folder>', is_error: true };
|
|
20
|
+
try {
|
|
21
|
+
const project = readProjectFile(joined.projectDir);
|
|
22
|
+
const current = readDecisionRules(project);
|
|
23
|
+
if (current.warning)
|
|
24
|
+
return { content: `${current.warning} Nothing can be added until the file is fixed or the rules are restored (/rules → Restore defaults).`, is_error: true };
|
|
25
|
+
const edit = addProposed(current.rules, args.rule, { by: 'proposed', at: new Date(0).toISOString() });
|
|
26
|
+
const rule = edit.after;
|
|
27
|
+
const { id: _id, on: _on, changedBy: _by, changedAt: _at, ...proposal } = rule;
|
|
28
|
+
setRuleProposal({ projectId: joined.projectId, proposal, words: ruleWords(rule) });
|
|
29
|
+
return {
|
|
30
|
+
content: JSON.stringify({
|
|
31
|
+
ok: true, id: rule.id, words: ruleWords(rule), note: rule.note, saved: false,
|
|
32
|
+
next: 'Nothing is saved. Tell the person: open /rules and pick "Add the proposed rule"; it is saved only when they press Save (the lead only). New rules go last: the first rule that is on and holds decides.',
|
|
33
|
+
}),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
if (e instanceof RegisterError)
|
|
38
|
+
return { content: e.message, is_error: true };
|
|
39
|
+
throw e;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
registerTool({
|
|
43
|
+
name: 'rules_propose',
|
|
44
|
+
description: 'Read-only. Check a decision rule the person described in words, for the team project this laptop joined. '
|
|
45
|
+
+ 'Give it as { when: { types?, levelNow?, targetAtMost?, minFindings?, maxFindings?, findingClasses?, onlyClasses?, findingRules? (rule-id prefixes, e.g. SEARCH_PROBLEMATIC_STATEMENTS_FOR_ALV_CALLS), callers?, noOpenForTarget? }, '
|
|
46
|
+
+ 'then: { disposition: keep|fix|rebuild|retire-candidate, target? }, note }. Returns the rule in plain words or why it cannot be read. '
|
|
47
|
+
+ 'Never saves: the person saves it from /rules. No SAP call.',
|
|
48
|
+
isMutating: false,
|
|
49
|
+
category: 'project',
|
|
50
|
+
requiresSap: false,
|
|
51
|
+
input_schema: {
|
|
52
|
+
type: 'object',
|
|
53
|
+
properties: {
|
|
54
|
+
rule: { type: 'object', description: 'The proposed rule: { when, then, note }. Any id, on or changedBy given is ignored.' },
|
|
55
|
+
},
|
|
56
|
+
required: ['rule'],
|
|
57
|
+
},
|
|
58
|
+
handler: (args, ctx) => rulesProposeHandler(args, ctx),
|
|
59
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -5,17 +5,58 @@
|
|
|
5
5
|
* package patterns) with the findings measurement zero recorded for them, read
|
|
6
6
|
* from the team project this laptop joined. No SAP call, no second ATC run, no
|
|
7
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
|
+
* Decision rules (step 2, D6): every row carries `ruleProposal` — the first
|
|
17
|
+
* rule of the team project (or the defaults) that decides it, or null when no
|
|
18
|
+
* rule does and the row is CSPeach's judgement. Rules the file names but this
|
|
19
|
+
* build cannot read give `rulesWarning` (one line) and the defaults.
|
|
8
20
|
*/
|
|
9
|
-
import { dispositionOf, DISPOSITION_WORD, inLane } from '@cspeach/register-core';
|
|
21
|
+
import { dispositionOf, DISPOSITION_WORD, inLane, readDecisionRules, } from '@cspeach/register-core';
|
|
10
22
|
import { registerTool } from '../index.js';
|
|
11
23
|
import { findingsByObject, shownUpTo } from '../../register/findings.js';
|
|
12
24
|
import { registerFor } from '../../register/picture.js';
|
|
25
|
+
import { ruleProposalOf } from '../../register/decision-rules.js';
|
|
13
26
|
import { activeProject, readProjectFile } from '../../register/store.js';
|
|
27
|
+
import { shown } from '../../register/atc-file.js';
|
|
28
|
+
import { journeyPage } from '../../journey/flow.js';
|
|
14
29
|
const CAP = 200;
|
|
30
|
+
/** One page when `after` or `limit` is given. */
|
|
31
|
+
const PAGE = 40;
|
|
15
32
|
/** A decision's reason was written on another laptop and goes to the model: cleaned like a finding title, kept whole up to 300. */
|
|
16
33
|
const REASON_CAP = 300;
|
|
34
|
+
/**
|
|
35
|
+
* Guided journey (Task 7): the triage skill tells the person to record a row
|
|
36
|
+
* "your way with `cspeach team decide`". In the journey the person types no
|
|
37
|
+
* command, and the skill is not reseeded in this slice, so the page the
|
|
38
|
+
* journey hands out carries this note for the model.
|
|
39
|
+
*/
|
|
40
|
+
export const JOURNEY_NOTE = "This triage is one step of CSPeach's guided clean-core run. The person types no commands: "
|
|
41
|
+
+ 'never suggest a cspeach command (not cspeach team decide, not any other). Say instead that CSPeach asks about each row, '
|
|
42
|
+
+ 'and a row skipped is asked again later. Use plain words: "your objects", "your project" — never team, lane, outbox or writer.';
|
|
17
43
|
const match = (pattern, value) => new RegExp(`^${pattern.trim().replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`, 'i').test(value);
|
|
18
|
-
|
|
44
|
+
function usageOf(u) {
|
|
45
|
+
return {
|
|
46
|
+
state: u.state,
|
|
47
|
+
...(u.source ? { source: shownUpTo(u.source, 60) } : {}),
|
|
48
|
+
...(u.period ? { period: { from: shownUpTo(u.period.from, 30), to: shownUpTo(u.period.to, 30) } } : {}),
|
|
49
|
+
...(u.lastUsed ? { lastUsed: shownUpTo(u.lastUsed, 30) } : {}),
|
|
50
|
+
...(u.why ? { why: shownUpTo(u.why, REASON_CAP) } : {}),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** A number only when the index was read; otherwise why not, in words. */
|
|
54
|
+
function callersOf(c) {
|
|
55
|
+
if (!c)
|
|
56
|
+
return 'not read: no estate inventory for this object';
|
|
57
|
+
return c.read ? c.objects : `not read: ${shownUpTo(c.why, REASON_CAP)}`;
|
|
58
|
+
}
|
|
59
|
+
export async function teamFindingsHandler(args, ctx) {
|
|
19
60
|
const joined = activeProject();
|
|
20
61
|
if (!joined)
|
|
21
62
|
return { content: 'This laptop has not joined a team project. The developer runs: cspeach team join <folder>', is_error: true };
|
|
@@ -37,8 +78,29 @@ export async function teamFindingsHandler(args, _ctx) {
|
|
|
37
78
|
objects = objects.filter((o) => patterns.some((p) => match(p, o.package)));
|
|
38
79
|
scope = `packages ${patterns.join(', ')}`;
|
|
39
80
|
}
|
|
81
|
+
// A journey triage sees only the page the journey handed out.
|
|
82
|
+
const page = journeyPage(ctx?.session);
|
|
83
|
+
if (page) {
|
|
84
|
+
const keys = new Set(page.map((k) => k.toUpperCase()));
|
|
85
|
+
objects = objects.filter((o) => keys.has(o.key.toUpperCase()));
|
|
86
|
+
}
|
|
87
|
+
// An empty after is no after.
|
|
88
|
+
const paged = (typeof args.after === 'string' && args.after.trim() !== '') || args.limit !== undefined;
|
|
89
|
+
let start = 0;
|
|
90
|
+
if (typeof args.after === 'string' && args.after.trim()) {
|
|
91
|
+
const after = args.after.trim().toUpperCase();
|
|
92
|
+
const at = objects.findIndex((o) => o.key.toUpperCase() === after);
|
|
93
|
+
if (at === -1)
|
|
94
|
+
return { content: `There is no object ${shown(args.after)} in this scope. Leave out after to start at the first.`, is_error: true };
|
|
95
|
+
start = at + 1;
|
|
96
|
+
}
|
|
97
|
+
const limit = !paged ? CAP
|
|
98
|
+
: Number.isFinite(args.limit) ? Math.min(CAP, Math.max(1, Math.floor(args.limit))) : PAGE;
|
|
99
|
+
const slice = objects.slice(start, start + limit);
|
|
100
|
+
const more = objects.length - start - slice.length;
|
|
40
101
|
const findings = findingsByObject(joined.projectDir, joined.outboxDir);
|
|
41
|
-
const
|
|
102
|
+
const rules = readDecisionRules(project);
|
|
103
|
+
const list = slice.map((o) => {
|
|
42
104
|
const f = findings.get(o.key);
|
|
43
105
|
const d = dispositionOf(o.decision);
|
|
44
106
|
return {
|
|
@@ -46,9 +108,26 @@ export async function teamFindingsHandler(args, _ctx) {
|
|
|
46
108
|
owner: register.lanes.find((l) => l.id === o.lane)?.owner ?? null,
|
|
47
109
|
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,
|
|
48
110
|
baselineAt: f?.baselineAt ?? null, baseline: f?.baseline ?? [], openNow: o.openFindings,
|
|
111
|
+
// Evidence (spec 2026-10-06 §10): usage only with its source, callers only when the index was read,
|
|
112
|
+
// and when the code last CHANGED — which is never use.
|
|
113
|
+
usage: usageOf(o.evidence.usage),
|
|
114
|
+
callers: callersOf(o.evidence.callers),
|
|
115
|
+
lastChanged: o.evidence.changed?.at ? shownUpTo(o.evidence.changed.at, 30) : null,
|
|
116
|
+
retireCandidate: o.retireCandidate,
|
|
117
|
+
priority: { band: o.priority.effective.band, source: o.priority.effective.source },
|
|
118
|
+
// The rule that decides this row, from the latest counted run's findings; null = no rule decides.
|
|
119
|
+
ruleProposal: ruleProposalOf(o, f, rules.rules),
|
|
49
120
|
};
|
|
50
121
|
});
|
|
51
|
-
return {
|
|
122
|
+
return {
|
|
123
|
+
content: JSON.stringify({
|
|
124
|
+
teamProject: project.name, scope, objects: list,
|
|
125
|
+
...(page ? { journey: JOURNEY_NOTE } : {}),
|
|
126
|
+
...(rules.warning ? { rulesWarning: rules.warning } : {}),
|
|
127
|
+
...(more > 0 ? { more } : {}),
|
|
128
|
+
...(paged && more > 0 && slice.length ? { next: slice[slice.length - 1].key } : {}),
|
|
129
|
+
}),
|
|
130
|
+
};
|
|
52
131
|
}
|
|
53
132
|
catch (e) {
|
|
54
133
|
return { content: `The team project could not be read: ${e instanceof Error ? e.message : String(e)}`, is_error: true };
|
|
@@ -57,7 +136,10 @@ export async function teamFindingsHandler(args, _ctx) {
|
|
|
57
136
|
registerTool({
|
|
58
137
|
name: 'team_findings',
|
|
59
138
|
description: 'Read-only. The objects of a lane (or package patterns) in the team project this laptop joined, with the findings '
|
|
60
|
-
+ 'measurement zero recorded for each
|
|
139
|
+
+ 'measurement zero recorded for each, any decision already recorded, and per object: usage (state and source), callers from the '
|
|
140
|
+
+ 'index (or why they were not read), last change (a change, not use), whether it is a retire candidate, its priority, and '
|
|
141
|
+
+ 'ruleProposal: the decision rule that decides the row (null = no rule decides; your judgement). '
|
|
142
|
+
+ 'Use it for a /abap-clean-core triage. No SAP call.',
|
|
61
143
|
isMutating: false,
|
|
62
144
|
category: 'project',
|
|
63
145
|
requiresSap: false,
|
|
@@ -66,6 +148,8 @@ registerTool({
|
|
|
66
148
|
properties: {
|
|
67
149
|
lane: { type: 'string', description: 'Lane id, e.g. "fin".' },
|
|
68
150
|
packages: { type: 'string', description: 'Package patterns, comma-separated, e.g. "ZCC_DEMO_*". Ignored when lane is given.' },
|
|
151
|
+
after: { type: 'string', description: 'Page on: the key "TYPE|NAME" of the last object seen (the `next` of the previous page).' },
|
|
152
|
+
limit: { type: 'number', description: 'Objects per page, 1 to 200 (default 40 when paging). Leave out both after and limit for the first 200.' },
|
|
69
153
|
},
|
|
70
154
|
},
|
|
71
155
|
handler: (args, ctx) => teamFindingsHandler(args, ctx),
|
|
@@ -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
|
-
|
|
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,
|
|
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(),
|
package/dist/tools/sap-read.js
CHANGED
|
@@ -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
|
-
|
|
419
|
-
|
|
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
|
// -----------------------------------------------------------------------
|