@cspeach/cli 1.1.47 → 1.1.49
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/dist/agent/level.js +1 -1
- package/dist/agent/loop.js +7 -6
- package/dist/agent/sap-connection-adapter.js +2 -0
- package/dist/agent/system-prompt.js +2 -2
- package/dist/agent/tool-dispatch.js +13 -1
- package/dist/approvals/op-labels.js +49 -10
- package/dist/approvals/render.js +14 -23
- package/dist/approvals/risk-floor.js +196 -19
- package/dist/approvals/visible-changes.js +35 -0
- package/dist/commands/config-set.js +5 -5
- package/dist/commands/cost.js +2 -1
- package/dist/commands/help.js +1 -1
- package/dist/commands/project.js +301 -0
- package/dist/commands/team-plan.js +16 -6
- package/dist/commands/team.js +295 -68
- package/dist/config/loader.js +13 -0
- package/dist/cost/cost-log.js +7 -4
- package/dist/cost/credits-wire.js +11 -6
- package/dist/cost/credits.js +15 -0
- package/dist/cost/display-mode.js +16 -4
- package/dist/doctor/checks/zcspeach.js +96 -0
- package/dist/journey/chain.js +489 -61
- package/dist/journey/clean-core.js +61 -36
- package/dist/journey/flow.js +75 -2
- package/dist/journey/people.js +37 -0
- package/dist/journey/rebuild.js +7 -6
- package/dist/journey/run.js +129 -42
- package/dist/journey/trigger.js +16 -10
- package/dist/one-shot.js +1 -1
- package/dist/project-context/render.js +7 -0
- package/dist/projects/clean-core-confirm.js +166 -44
- package/dist/projects/email-template.js +56 -0
- package/dist/projects/extract-cca.js +8 -7
- package/dist/projects/index.js +0 -1
- package/dist/projects/merge-cca.js +0 -19
- package/dist/projects/merge-upgrade.js +2 -2
- package/dist/projects/project-folder.js +2 -1
- package/dist/projects/save-command.js +51 -14
- package/dist/register/decision-rules.js +2 -1
- package/dist/register/export-files.js +18 -0
- package/dist/register/picture.js +3 -3
- package/dist/register/project-transport.js +125 -0
- package/dist/register/records.js +25 -13
- package/dist/register/rules-edit.js +5 -0
- package/dist/register/status-file/progress-chart.js +17 -6
- package/dist/register/status-file/register-map.js +8 -5
- package/dist/register/status-file/register-model.js +55 -36
- package/dist/register/status-file/register-parts.js +97 -82
- package/dist/register/status-file/register-status-file.js +16 -7
- package/dist/register/status-file/report-model.js +293 -205
- package/dist/register/status-file/team-css.js +1 -1
- package/dist/register/status-html.js +11 -4
- package/dist/register/store.js +2 -2
- package/dist/register/workbooks.js +24 -13
- package/dist/renderer/footer-line.js +10 -2
- package/dist/renderer/status-footer.js +2 -1
- package/dist/renderer/tool-labels.js +2 -2
- package/dist/repl/builtin-commands.js +1 -1
- package/dist/repl/continue-skill.js +5 -0
- package/dist/repl/level-command.js +6 -15
- package/dist/repl.js +21 -8
- package/dist/router/router-core.js +5 -3
- package/dist/session/audit-export.js +4 -1
- package/dist/skill-catalog.js +4 -7
- package/dist/tools/_team-work-path.js +53 -0
- package/dist/tools/approval.js +97 -19
- package/dist/tools/filesystem/file-edit.js +8 -1
- package/dist/tools/filesystem/file-write.js +8 -1
- package/dist/tools/local-files.js +31 -0
- package/dist/tools/project/_merge-shared.js +2 -2
- package/dist/tools/project/_team-tool-shared.js +5 -0
- package/dist/tools/project/cca_merge.js +1 -11
- package/dist/tools/project/rules_propose.js +2 -1
- package/dist/tools/project/team_findings.js +81 -28
- package/dist/tools/project/team_lane_move.js +127 -0
- package/dist/tools/project/upgrade_merge_progress.js +8 -4
- package/dist/tools/subagent/schedule_create.js +131 -0
- package/dist/tools/text-elements/tools.js +7 -1
- package/dist/tools/tool-loading.js +1 -1
- package/dist/tools/update-method-intercept.js +18 -1
- package/dist/tools/write-pipeline.js +19 -5
- package/dist/ui/approval-modal.js +6 -3
- package/dist/ui/error-emitter.js +21 -0
- package/dist/ui/footer-line.js +19 -1
- package/dist/ui/header.js +41 -0
- package/dist/ui/ink-classifier-route.js +19 -0
- package/dist/ui/rich-error-box.js +9 -0
- package/dist/ui/sidebar.js +12 -0
- package/dist/ui/status-emitter.js +19 -0
- package/dist/ui/status-line.js +62 -0
- package/dist/ui/status-row.js +38 -0
- package/dist/ui/todo-panel.js +85 -0
- package/dist/ui/widgets/ask-form.js +250 -0
- package/dist/ui/widgets/ask-question-modal.js +178 -0
- package/fiori/zlas-inspection-lr/package.json +26 -0
- package/fiori/zlas-inspection-lr/ui5-local.yaml +45 -0
- package/fiori/zlas-inspection-lr/ui5.yaml +41 -0
- package/fiori/zlas-inspection-lr/webapp/Component.js +12 -0
- package/fiori/zlas-inspection-lr/webapp/i18n/i18n.properties +9 -0
- package/fiori/zlas-inspection-lr/webapp/index.html +35 -0
- package/fiori/zlas-inspection-lr/webapp/manifest.json +145 -0
- package/live-check-2026-10-17-1343.txt +41 -0
- package/package.json +121 -121
- package/test-harness/fe-draft/app.ts +0 -188
- package/test-harness/fe-draft/fe-draft.e2e.test.ts +0 -461
- package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +0 -51
- package/test-harness/fe-draft/routes.ts +0 -224
- package/test-harness/fe-draft/routes.unit.test.ts +0 -45
- package/test-harness/fe-draft/server.ts +0 -225
- package/test-harness/fe-draft/variants.ts +0 -196
|
@@ -131,3 +131,56 @@ export function pointsIntoSharedCspeach(cwd, realTarget) {
|
|
|
131
131
|
}
|
|
132
132
|
return false;
|
|
133
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* Task 4 (B3, 2026-10-20) — the files a team project is made of. CSPeach
|
|
136
|
+
* writes them (team commands, cards, team_lane_move); a model's file write
|
|
137
|
+
* never does. Root-relative path, either separator; segments compared the way
|
|
138
|
+
* Windows opens them, so `LANES.JSON`, `plan\x.json` or `lanes.json.` count.
|
|
139
|
+
*/
|
|
140
|
+
const TEAM_OWNED_FILES = ['cspeach-team.json', 'lanes.json'];
|
|
141
|
+
const TEAM_OWNED_DIRS = ['writers', 'rulings', 'plan', 'baseline'];
|
|
142
|
+
export function isTeamOwnedRel(rel) {
|
|
143
|
+
const segs = rel.split(/[\\/]+/).filter((s) => s !== '' && s !== '.');
|
|
144
|
+
if (!segs.length)
|
|
145
|
+
return false;
|
|
146
|
+
const first = nameKey(segs[0]);
|
|
147
|
+
if (TEAM_OWNED_DIRS.includes(first))
|
|
148
|
+
return true;
|
|
149
|
+
return segs.length === 1 && TEAM_OWNED_FILES.includes(first);
|
|
150
|
+
}
|
|
151
|
+
/** The refusal for a model write to one of the team project's own files. */
|
|
152
|
+
export const TEAM_FILE_REFUSAL = 'This file belongs to the project and CSPeach writes it. Say what you want changed instead.';
|
|
153
|
+
/**
|
|
154
|
+
* True when `cwd` is a team folder and the REAL landing path of a write lies
|
|
155
|
+
* on one of its own files (isTeamOwnedRel), whatever spelling, link or short
|
|
156
|
+
* name got it there. Only refuses; never maps anything. A folder without a
|
|
157
|
+
* team project: always false.
|
|
158
|
+
*/
|
|
159
|
+
export function pointsAtTeamFile(cwd, realTarget) {
|
|
160
|
+
if (teamWorkRoot(cwd) === null)
|
|
161
|
+
return false;
|
|
162
|
+
const absRoot = path.resolve(cwd);
|
|
163
|
+
const realOf = (p) => { try {
|
|
164
|
+
return realpathSync.native(p);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return null;
|
|
168
|
+
} };
|
|
169
|
+
for (const base of new Set([absRoot, realOf(absRoot) ?? absRoot])) {
|
|
170
|
+
const rel = path.relative(base, realTarget);
|
|
171
|
+
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel))
|
|
172
|
+
continue;
|
|
173
|
+
if (isTeamOwnedRel(rel))
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
// A link into one of the project folders: the real folder, not its name.
|
|
177
|
+
for (const dir of TEAM_OWNED_DIRS) {
|
|
178
|
+
const real = realOf(path.join(absRoot, dir));
|
|
179
|
+
if (real === null)
|
|
180
|
+
continue;
|
|
181
|
+
const rel = path.relative(real, realTarget);
|
|
182
|
+
if (rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)))
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
return false;
|
|
186
|
+
}
|
package/dist/tools/approval.js
CHANGED
|
@@ -3,7 +3,7 @@ import { notModifiableText } from './release-texts.js';
|
|
|
3
3
|
import { isModifiable, knownObjectCount } from '@cspeach/sap-client';
|
|
4
4
|
import { registerTool } from './index.js';
|
|
5
5
|
import { isHeadless, shouldUseInk } from '../renderer/tty.js';
|
|
6
|
-
import { effectiveRisk,
|
|
6
|
+
import { effectiveRisk, riskResultFields, typeCode } from '../approvals/risk-floor.js';
|
|
7
7
|
import { objectsWithSyntaxErrors } from './syntax-state.js';
|
|
8
8
|
import { noteWriteTransport } from '../repl/current-transport.js';
|
|
9
9
|
/**
|
|
@@ -19,6 +19,7 @@ function noteApprovedTransport(t) {
|
|
|
19
19
|
import { mintChangeApproval } from '../approvals/jwt.js';
|
|
20
20
|
import { canonicalApprovalObject } from '../approvals/canonical.js';
|
|
21
21
|
import { renderPlanGate, renderPerChangeApprovalV3 } from '../approvals/render.js';
|
|
22
|
+
import { collapseImplicitActivates } from '../approvals/visible-changes.js';
|
|
22
23
|
import { loadConfig, effectiveApprovals } from '../config/loader.js';
|
|
23
24
|
import { getPlanGrant, grantCovers, noteGrantUse, noteApprovalsSetting, grantClaimsFor } from '../approvals/plan-grant.js';
|
|
24
25
|
import { normalizeObjectType } from '../projects/plan-objects.js';
|
|
@@ -29,6 +30,8 @@ import { markPlanGateApproved } from '../repl/rule8-detector.js';
|
|
|
29
30
|
import { getEffectiveWriteMode } from '../repl/mode-cycle.js';
|
|
30
31
|
import { getCurrentTransport } from '../repl/current-transport.js';
|
|
31
32
|
import { displayTransport } from '../approvals/op-labels.js';
|
|
33
|
+
import { canonicalApprovalObject as bareObjectName } from '../approvals/canonical.js';
|
|
34
|
+
import { projectTransport, SEVERAL_REQUESTS_LABEL, transportForWrite, TRANSPORT_REQUEST } from '../register/project-transport.js';
|
|
32
35
|
import { NOT_ON_THIS_RELEASE, approvalRefusedByProfile } from '../sap/profile-gate.js';
|
|
33
36
|
import { isGuardedRunActive, getCurrentPhaseWrites, notePlanDeviation, PLAN_DEVIATION_DETAIL, } from '../commands/plan-gate.js';
|
|
34
37
|
/**
|
|
@@ -171,6 +174,39 @@ async function attachReleaseFacts(changes, ctx) {
|
|
|
171
174
|
}
|
|
172
175
|
return null;
|
|
173
176
|
}
|
|
177
|
+
/** What the owning-request lookup accepts as a name (sap-client's identifier guard): nothing else is looked up. */
|
|
178
|
+
const LOOKUP_NAME = /^[A-Z0-9_/]+$/;
|
|
179
|
+
/**
|
|
180
|
+
* Task 8 (B11) — before any card: for every modify/delete, the open request
|
|
181
|
+
* that already holds the object, looked up the way the write tools resolve it
|
|
182
|
+
* (resolveWriteTransport: one owning request forces the write into it). The
|
|
183
|
+
* model's value is dropped first, on every change. One owning request →
|
|
184
|
+
* `c.owningTransport`; several candidates, a capped or failed lookup, or a
|
|
185
|
+
* free object → nothing, so the card is today's. Standalone's `ctx.adt`
|
|
186
|
+
* throws on every call, which is the failed-lookup path. Never refuses.
|
|
187
|
+
*/
|
|
188
|
+
async function attachOwningTransport(changes, ctx) {
|
|
189
|
+
for (const c of changes) {
|
|
190
|
+
if (!c)
|
|
191
|
+
continue;
|
|
192
|
+
// Never the model's — on any change.
|
|
193
|
+
delete c.owningTransport;
|
|
194
|
+
if (c.op !== 'modify' && c.op !== 'delete')
|
|
195
|
+
continue;
|
|
196
|
+
const name = bareObjectName(String(c.object ?? ''));
|
|
197
|
+
if (!LOOKUP_NAME.test(name))
|
|
198
|
+
continue;
|
|
199
|
+
try {
|
|
200
|
+
const r = await ctx.adt.transportForObject(typeCode(c.type), name);
|
|
201
|
+
const t = typeof r?.transport === 'string' ? r.transport.trim().toUpperCase() : '';
|
|
202
|
+
if (!r?.capped && TRANSPORT_REQUEST.test(t))
|
|
203
|
+
c.owningTransport = t;
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
// Best-effort, as at write time: the card stays as it is today.
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
174
210
|
registerTool({
|
|
175
211
|
name: 'request_approval',
|
|
176
212
|
description: 'Request user approval for one or more SAP mutations. Returns approval_ids (one per change) to be passed as approval_id on matching mutating tool calls.',
|
|
@@ -192,6 +228,7 @@ registerTool({
|
|
|
192
228
|
diff: { type: 'string', description: 'For a CREATE op this MUST be the exact, complete source that will be written — verbatim, character-for-character, NOT a description, summary, or paraphrase. It is shown to the developer in the approval box as the exact thing they are approving, so a one-line summary here means they approve a write they cannot inspect. For a MODIFY op, the real unified diff of the change (again verbatim, not a description).' },
|
|
193
229
|
package: { type: 'string', description: 'Target package (development class) for the object, if known — shown on the approval box' },
|
|
194
230
|
affects: { type: 'string', description: 'The standard SAP process this change runs inside, in plain words — REQUIRED for type ENHO (a BAdI implementation): name the BAdI, its spot and what calls it, e.g. "purchase order check before save (BAdI ME_PROCESS_PO_CUST, spot ES_MM_PO)". Shown on the approval card.' },
|
|
231
|
+
behaviour: { type: 'string', description: "What changes for callers or users, in plain words, before the write: a changed type, a dropped step, a check that returns fewer rows. One or two short sentences, no greeting, no joke. 'none' when nothing changes." },
|
|
195
232
|
},
|
|
196
233
|
required: ['op', 'object', 'type'],
|
|
197
234
|
},
|
|
@@ -274,6 +311,8 @@ registerTool({
|
|
|
274
311
|
const releaseRefusal = await attachReleaseFacts(args.changes ?? [], ctx);
|
|
275
312
|
if (releaseRefusal)
|
|
276
313
|
return { content: JSON.stringify(releaseRefusal), is_error: true };
|
|
314
|
+
// Task 8 (B11) — the request that already holds each modified object, before any card.
|
|
315
|
+
await attachOwningTransport(args.changes ?? [], ctx);
|
|
277
316
|
let sapCfg = cfg.sap[ctx.sapAlias];
|
|
278
317
|
const riskCtx = {
|
|
279
318
|
sapAlias: ctx.sapAlias,
|
|
@@ -320,8 +359,7 @@ registerTool({
|
|
|
320
359
|
approved: true,
|
|
321
360
|
approved_by: 'the plan object list',
|
|
322
361
|
approval_ids: ids,
|
|
323
|
-
|
|
324
|
-
raised_by: eff.raisedBy.map(riskReasonText),
|
|
362
|
+
...riskResultFields(eff),
|
|
325
363
|
}),
|
|
326
364
|
};
|
|
327
365
|
}
|
|
@@ -334,9 +372,31 @@ registerTool({
|
|
|
334
372
|
// displayTransport in approvals/op-labels.ts. The plan gate is a batch
|
|
335
373
|
// summary: session fallback applies only when the batch contains a
|
|
336
374
|
// create (the only op it is true for).
|
|
375
|
+
//
|
|
376
|
+
// Task 8 (B11): a modify/delete already held by a request is forced into
|
|
377
|
+
// it (owningTransport, looked up above); with none named, the project
|
|
378
|
+
// request of this folder is used (null outside a joined project, so the
|
|
379
|
+
// card is today's). One order for card and write: owning lock
|
|
380
|
+
// (modify/delete) ?? explicit ?? project ?? session. The plan gate names
|
|
381
|
+
// the request the writes will use (owner decision, final review): each
|
|
382
|
+
// write's own transportForWrite choice, as on its per-change card. When
|
|
383
|
+
// they all land in one request, the card names it; when every write has a
|
|
384
|
+
// request and they differ, it says "several requests" (never one request
|
|
385
|
+
// for every object). A write with no request at all leaves today's "no
|
|
386
|
+
// transport". A plan with no write at all (activate only) keeps today's fact.
|
|
337
387
|
const sessionTransport = getCurrentTransport();
|
|
338
|
-
const
|
|
339
|
-
|
|
388
|
+
const project = projectTransport(ctx.cwd);
|
|
389
|
+
const session = changes.some((c) => c.op === 'create') ? sessionTransport : undefined;
|
|
390
|
+
const writes = changes.filter((c) => c.op === 'create' || c.op === 'modify' || c.op === 'delete');
|
|
391
|
+
const choices = writes.map((c) => transportForWrite({
|
|
392
|
+
op: c.op, explicit: args.transport, owning: c.owningTransport, project, session,
|
|
393
|
+
}));
|
|
394
|
+
const requests = new Set(choices.map((c) => c.request));
|
|
395
|
+
const planGateTransport = writes.length === 0
|
|
396
|
+
? args.transport ?? transportForWrite({ op: 'create', project, session }).label
|
|
397
|
+
: requests.size === 1
|
|
398
|
+
? (choices.every((c) => c.from === choices[0].from) ? choices[0].label : choices[0].request)
|
|
399
|
+
: requests.has(undefined) ? undefined : SEVERAL_REQUESTS_LABEL;
|
|
340
400
|
// Task 6 (agentic-flow, 2026-07-03) — guarded plan chains disable
|
|
341
401
|
// auto-approve entirely: the user's auto_approve consent was given for
|
|
342
402
|
// hand-driven turns, not for phases the harness auto-dispatched. When the
|
|
@@ -386,8 +446,7 @@ registerTool({
|
|
|
386
446
|
approved: true,
|
|
387
447
|
auto_approved: true,
|
|
388
448
|
approval_ids: ids,
|
|
389
|
-
|
|
390
|
-
raised_by: eff.raisedBy.map(riskReasonText), // round 8 item 4: plain words, never codes
|
|
449
|
+
...riskResultFields(eff),
|
|
391
450
|
}),
|
|
392
451
|
};
|
|
393
452
|
}
|
|
@@ -408,8 +467,7 @@ registerTool({
|
|
|
408
467
|
message: 'Headless run: writes cannot be approved without a user at the terminal, and headless runs never auto-approve. ' +
|
|
409
468
|
'No approval IDs were minted — do not attempt any write operations. ' +
|
|
410
469
|
'Summarise the proposed changes so the user can re-run interactively or switch write_mode to "advisory-only".',
|
|
411
|
-
|
|
412
|
-
raised_by: eff.raisedBy.map(riskReasonText), // round 8 item 4: plain words, never codes
|
|
470
|
+
...riskResultFields(eff),
|
|
413
471
|
}),
|
|
414
472
|
is_error: true,
|
|
415
473
|
};
|
|
@@ -419,16 +477,22 @@ registerTool({
|
|
|
419
477
|
// it skips the redundant per-change prompts that produced the "3 prompts
|
|
420
478
|
// for one task" complaint. Review-each falls back to the per-change
|
|
421
479
|
// gate. Cancel rejects the whole plan.
|
|
480
|
+
//
|
|
481
|
+
// Task 9 (C8): only for more than one VISIBLE change — the same count the
|
|
482
|
+
// plan gate shows (an activate whose write is in the list is not a change
|
|
483
|
+
// a person sees). A modify + its activate on one class gets the
|
|
484
|
+
// per-change card only; the activate follows its approved write below.
|
|
422
485
|
let skipPerChangeGates = false;
|
|
423
|
-
|
|
486
|
+
// Raw keys on purpose: the same collapse (and count) the plan gate displays.
|
|
487
|
+
const visibleCount = collapseImplicitActivates(changes).length;
|
|
488
|
+
if (visibleCount > 1) {
|
|
424
489
|
const mode = await renderPlanGate(args.summary, changes, planGateTransport, eff, ctx.sapAlias);
|
|
425
490
|
if (mode === 'cancel') {
|
|
426
491
|
return {
|
|
427
492
|
content: JSON.stringify({
|
|
428
493
|
approved: false,
|
|
429
494
|
reason: 'plan_rejected',
|
|
430
|
-
|
|
431
|
-
raised_by: eff.raisedBy.map(riskReasonText), // round 8 item 4: plain words, never codes
|
|
495
|
+
...riskResultFields(eff),
|
|
432
496
|
}),
|
|
433
497
|
};
|
|
434
498
|
}
|
|
@@ -461,7 +525,18 @@ registerTool({
|
|
|
461
525
|
if (c.op === 'modify' || c.op === 'create')
|
|
462
526
|
writeKeys.add(`${c.type}:${canonicalApprovalObject(c.object)}`);
|
|
463
527
|
}
|
|
464
|
-
|
|
528
|
+
// Task 9 (C8): with no plan gate, an activate listed before its own write
|
|
529
|
+
// waits for that write's card, so one change is one card. With the plan
|
|
530
|
+
// gate (more than one visible change) the order stays as today.
|
|
531
|
+
const followsItsWrite = (c) => c.op === 'activate' && writeKeys.has(`${c.type}:${canonicalApprovalObject(c.object)}`);
|
|
532
|
+
const cardOrder = visibleCount > 1 ? changes : [
|
|
533
|
+
...changes.filter((c) => !followsItsWrite(c)),
|
|
534
|
+
...changes.filter(followsItsWrite),
|
|
535
|
+
];
|
|
536
|
+
// The cards may run in another order; the ids go back in the declared
|
|
537
|
+
// changes[] order (final review), so ids[i] still belongs to changes[i].
|
|
538
|
+
const idFor = new Map();
|
|
539
|
+
for (const c of cardOrder) {
|
|
465
540
|
const objKey = `${c.type}:${canonicalApprovalObject(c.object)}`;
|
|
466
541
|
let outcome;
|
|
467
542
|
if (skipPerChangeGates) {
|
|
@@ -474,14 +549,14 @@ registerTool({
|
|
|
474
549
|
outcome = { approved: false, reason: 'sibling_write_skipped' };
|
|
475
550
|
}
|
|
476
551
|
else {
|
|
477
|
-
outcome = await renderPerChangeApprovalV3(c, eff, displayTransport(c.op, args.transport, sessionTransport));
|
|
552
|
+
outcome = await renderPerChangeApprovalV3(c, eff, displayTransport(c.op, args.transport, sessionTransport, c.owningTransport, project));
|
|
478
553
|
}
|
|
479
554
|
if (outcome.approved) {
|
|
480
555
|
if (c.op === 'modify' || c.op === 'create')
|
|
481
556
|
autoApproveActivateFor.add(objKey);
|
|
482
557
|
// Task A3: mint via the canonical path (same code path the write
|
|
483
558
|
// tools' validators compare against).
|
|
484
|
-
|
|
559
|
+
idFor.set(c, await mintChangeApproval(c, ctx.session.id));
|
|
485
560
|
}
|
|
486
561
|
else {
|
|
487
562
|
if (outcome.reason === '__cancel_turn__') {
|
|
@@ -489,8 +564,7 @@ registerTool({
|
|
|
489
564
|
content: JSON.stringify({
|
|
490
565
|
approved: false,
|
|
491
566
|
reason: 'user_cancelled_turn',
|
|
492
|
-
|
|
493
|
-
raised_by: eff.raisedBy.map(riskReasonText), // round 8 item 4: plain words, never codes
|
|
567
|
+
...riskResultFields(eff),
|
|
494
568
|
}),
|
|
495
569
|
};
|
|
496
570
|
}
|
|
@@ -504,6 +578,11 @@ registerTool({
|
|
|
504
578
|
rejections.push({ object: c.object, op: c.op, reason: outcome.reason ?? 'skipped' });
|
|
505
579
|
}
|
|
506
580
|
}
|
|
581
|
+
for (const c of changes) {
|
|
582
|
+
const id = idFor.get(c);
|
|
583
|
+
if (id)
|
|
584
|
+
approvalIds.push(id);
|
|
585
|
+
}
|
|
507
586
|
// Tell Rule 8 the user has explicitly approved (some/all of) this
|
|
508
587
|
// batch via the plan-gate / per-change flow. Suppresses the redundant
|
|
509
588
|
// Rule 8 prompt at every subsequent dispatch this turn.
|
|
@@ -516,8 +595,7 @@ registerTool({
|
|
|
516
595
|
approved: approvalIds.length > 0,
|
|
517
596
|
approval_ids: approvalIds,
|
|
518
597
|
skipped: rejections,
|
|
519
|
-
|
|
520
|
-
raised_by: eff.raisedBy.map(riskReasonText), // round 8 item 4: plain words, never codes
|
|
598
|
+
...riskResultFields(eff),
|
|
521
599
|
}),
|
|
522
600
|
};
|
|
523
601
|
},
|
|
@@ -15,7 +15,7 @@ import { promises as fs } from 'node:fs';
|
|
|
15
15
|
import * as path from 'node:path';
|
|
16
16
|
import { registerTool } from '../index.js';
|
|
17
17
|
import { resolveSafePath, assertRealPathContained, PathOutsideRootError, DENYLIST_DESCRIPTION, isDenylistedPath, blockedExecutableExtension, } from '../_filesystem-shared.js';
|
|
18
|
-
import { pointsIntoSharedCspeach, SHARED_CSPEACH_REFUSAL, teamWorkTarget } from '../_team-work-path.js';
|
|
18
|
+
import { pointsAtTeamFile, pointsIntoSharedCspeach, SHARED_CSPEACH_REFUSAL, TEAM_FILE_REFUSAL, teamWorkTarget } from '../_team-work-path.js';
|
|
19
19
|
export async function fileEditHandler(args, ctx) {
|
|
20
20
|
// 1. Validate args before any IO
|
|
21
21
|
if (args.old_string === args.new_string) {
|
|
@@ -74,6 +74,13 @@ export async function fileEditHandler(args, ctx) {
|
|
|
74
74
|
if (pointsIntoSharedCspeach(ctx.cwd, realAbs)) {
|
|
75
75
|
return { content: `error: ${SHARED_CSPEACH_REFUSAL}`, is_error: true };
|
|
76
76
|
}
|
|
77
|
+
// 4d. Team folder (Task 4, B3): the project's own files — cspeach-team.json,
|
|
78
|
+
// lanes.json, writers/, rulings/, plan/, baseline/ — are CSPeach's to
|
|
79
|
+
// write. Judged on the real landing path. A folder without a team
|
|
80
|
+
// project never gets here.
|
|
81
|
+
if (pointsAtTeamFile(ctx.cwd, realAbs)) {
|
|
82
|
+
return { content: `error: ${TEAM_FILE_REFUSAL}`, is_error: true };
|
|
83
|
+
}
|
|
77
84
|
// 5. Read the file
|
|
78
85
|
let content;
|
|
79
86
|
try {
|
|
@@ -19,7 +19,7 @@ import { promises as fs, readFileSync } from 'node:fs';
|
|
|
19
19
|
import * as path from 'node:path';
|
|
20
20
|
import { registerTool } from '../index.js';
|
|
21
21
|
import { resolveSafePath, realPathForWrite, PathOutsideRootError, DENYLIST_DESCRIPTION, isDenylistedPath, blockedExecutableExtension, } from '../_filesystem-shared.js';
|
|
22
|
-
import { pointsIntoSharedCspeach, SHARED_CSPEACH_REFUSAL, teamWorkTarget } from '../_team-work-path.js';
|
|
22
|
+
import { pointsAtTeamFile, pointsIntoSharedCspeach, SHARED_CSPEACH_REFUSAL, TEAM_FILE_REFUSAL, teamWorkTarget } from '../_team-work-path.js';
|
|
23
23
|
/**
|
|
24
24
|
* 2026-09-23 (Glenn's first run) — a standalone run writes GUIDE.md and said
|
|
25
25
|
* nothing about it. He opened it in a text editor, then tried to drop it into
|
|
@@ -107,6 +107,13 @@ export async function fileWriteHandler(args, ctx) {
|
|
|
107
107
|
if (pointsIntoSharedCspeach(ctx.cwd, target)) {
|
|
108
108
|
return { content: `error: ${SHARED_CSPEACH_REFUSAL}`, is_error: true };
|
|
109
109
|
}
|
|
110
|
+
// 3d. Team folder (Task 4, B3): the project's own files — cspeach-team.json,
|
|
111
|
+
// lanes.json, writers/, rulings/, plan/, baseline/ — are CSPeach's to
|
|
112
|
+
// write. Judged on the real landing path. A folder without a team
|
|
113
|
+
// project never gets here.
|
|
114
|
+
if (pointsAtTeamFile(ctx.cwd, target)) {
|
|
115
|
+
return { content: `error: ${TEAM_FILE_REFUSAL}`, is_error: true };
|
|
116
|
+
}
|
|
110
117
|
// 4. Ensure parent directory exists (no-op if it already does)
|
|
111
118
|
try {
|
|
112
119
|
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// cspeach-cli/src/tools/local-files.ts
|
|
2
|
+
/**
|
|
3
|
+
* Startup wiring for the persisted `local_files` config toggle.
|
|
4
|
+
*
|
|
5
|
+
* When `cfg.local_files === true`, the otherwise flag-gated filesystem tools
|
|
6
|
+
* (file_read/file_write/file_edit/glob/grep) are force-enabled via the
|
|
7
|
+
* in-memory override in `_flag.ts`, BEFORE the first listTools()/turn. This is
|
|
8
|
+
* what makes /abap-fiori-build runnable — it writes local app files.
|
|
9
|
+
*
|
|
10
|
+
* The toggle is PERSISTED (config.toml), not re-consented each session, so
|
|
11
|
+
* `applyLocalFilesConfig` returns an announce line the entry point prints
|
|
12
|
+
* whenever the tools are live — the user must always see that local file
|
|
13
|
+
* access is on this session (trust-visibility requirement). The FS tools'
|
|
14
|
+
* own cwd-sandbox (writes confined to the project root; .cspeach/.env/.git
|
|
15
|
+
* denylisted) still applies regardless of this toggle.
|
|
16
|
+
*/
|
|
17
|
+
import { enableToolFlags } from './_flag.js';
|
|
18
|
+
/** The exact tool NAMEs the flag uses (file_read → CSPEACH_TOOL_FILE_READ). */
|
|
19
|
+
export const LOCAL_FILE_TOOLS = ['file_read', 'file_write', 'file_edit', 'glob', 'grep'];
|
|
20
|
+
/**
|
|
21
|
+
* Apply the persisted `local_files` toggle. When on, force-enables the FS
|
|
22
|
+
* tools and returns a one-line announce string (undefined when off, so the
|
|
23
|
+
* caller prints nothing). `cwd` is surfaced so the user sees exactly which
|
|
24
|
+
* directory tree read/write/edit operate under.
|
|
25
|
+
*/
|
|
26
|
+
export function applyLocalFilesConfig(localFiles, cwd) {
|
|
27
|
+
if (localFiles !== true)
|
|
28
|
+
return undefined;
|
|
29
|
+
enableToolFlags([...LOCAL_FILE_TOOLS]);
|
|
30
|
+
return `local file tools enabled (read/write/edit under ${cwd}) — cspeach config set local_files off to disable`;
|
|
31
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared input-resolution for the
|
|
3
|
-
*
|
|
2
|
+
* Shared input-resolution for the merge tool (upgrade_merge_progress;
|
|
3
|
+
* its cca_merge twin was cut 2026-10-20, drift spec §3.6).
|
|
4
4
|
*
|
|
5
5
|
* Inputs arrive as the same `@<token>` forms the rest of the CLI uses
|
|
6
6
|
* (workspace.ts:resolveAtToken): absolute path, relative path, or a bare
|
|
@@ -29,8 +29,6 @@ import { dirname, isAbsolute, join } from 'node:path';
|
|
|
29
29
|
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
|
-
import { assertWritable } from '../_filesystem-shared.js';
|
|
33
|
-
import { pointsIntoSharedCspeach, SHARED_CSPEACH_REFUSAL, teamWorkTarget } from '../_team-work-path.js';
|
|
34
32
|
export async function ccaMergeHandler(args, ctx) {
|
|
35
33
|
if (!Array.isArray(args.inputs) || args.inputs.some((i) => typeof i !== 'string')) {
|
|
36
34
|
return { content: 'error: inputs must be an array of envelope paths / @-tokens.', is_error: true };
|
|
@@ -99,16 +97,8 @@ export async function ccaMergeHandler(args, ctx) {
|
|
|
99
97
|
// mergedFrom: the input envelopes [{ file, label }]
|
|
100
98
|
// }
|
|
101
99
|
let detailWritten = true;
|
|
102
|
-
|
|
103
|
-
const t = teamWorkTarget(ctx.cwd, merged.detailPath);
|
|
104
|
-
const detailAbs = isAbsolute(t.path) ? t.path : join(t.root, t.path);
|
|
100
|
+
const detailAbs = isAbsolute(merged.detailPath) ? merged.detailPath : join(ctx.cwd, merged.detailPath);
|
|
105
101
|
try {
|
|
106
|
-
// SEC (review round 2): the real landing path must stay in the project and
|
|
107
|
-
// not be protected (a junctioned .cspeach/<domain> -> .git is refused).
|
|
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);
|
|
112
102
|
mkdirSync(dirname(detailAbs), { recursive: true });
|
|
113
103
|
writeFileSync(detailAbs, JSON.stringify({
|
|
114
104
|
generatedAt: new Date().toISOString(),
|
|
@@ -13,10 +13,11 @@ import { registerTool } from '../index.js';
|
|
|
13
13
|
import { activeProject, readProjectFile, RegisterError } from '../../register/store.js';
|
|
14
14
|
import { addProposed, ruleWords } from '../../register/rules-edit.js';
|
|
15
15
|
import { setRuleProposal } from '../../register/rule-proposal.js';
|
|
16
|
+
import { NOT_JOINED_LINE } from './_team-tool-shared.js';
|
|
16
17
|
export async function rulesProposeHandler(args, _ctx) {
|
|
17
18
|
const joined = activeProject();
|
|
18
19
|
if (!joined)
|
|
19
|
-
return { content:
|
|
20
|
+
return { content: NOT_JOINED_LINE, is_error: true };
|
|
20
21
|
try {
|
|
21
22
|
const project = readProjectFile(joined.projectDir);
|
|
22
23
|
const current = readDecisionRules(project);
|
|
@@ -13,12 +13,19 @@
|
|
|
13
13
|
* handed out (journey/flow.ts, at most 40 keys) is shown: the skill needs no
|
|
14
14
|
* change to work in pages.
|
|
15
15
|
*
|
|
16
|
-
* Decision rules (step 2, D6): every row carries `
|
|
16
|
+
* Decision rules (step 2, D6): every row carries `proposal` — the first
|
|
17
17
|
* rule of the team project (or the defaults) that decides it, or null when no
|
|
18
18
|
* rule does and the row is CSPeach's judgement. Rules the file names but this
|
|
19
19
|
* build cannot read give `rulesWarning` (one line) and the defaults.
|
|
20
|
+
*
|
|
21
|
+
* Task 6 (project view, R5/R6): every fact comes from the project view —
|
|
22
|
+
* `scope: { kind, name, total, parts }` says the whole scope (so a decided row
|
|
23
|
+
* is never read as "none triaged"), each row says its part, its decision
|
|
24
|
+
* (`{ what, by, reason, reason_from }`), its level and who decided it, and its
|
|
25
|
+
* target with where it came from (never a bare letter). The usage sentence the
|
|
26
|
+
* rows share is said once, in `usage_note`; a row with usage of its own keeps it.
|
|
20
27
|
*/
|
|
21
|
-
import {
|
|
28
|
+
import { DECISION_WORDS, partCounts, projectView, readDecisionRules, } from '@cspeach/register-core';
|
|
22
29
|
import { registerTool } from '../index.js';
|
|
23
30
|
import { findingsByObject, shownUpTo } from '../../register/findings.js';
|
|
24
31
|
import { registerFor } from '../../register/picture.js';
|
|
@@ -26,6 +33,7 @@ import { ruleProposalOf } from '../../register/decision-rules.js';
|
|
|
26
33
|
import { activeProject, readProjectFile } from '../../register/store.js';
|
|
27
34
|
import { shown } from '../../register/atc-file.js';
|
|
28
35
|
import { journeyPage } from '../../journey/flow.js';
|
|
36
|
+
import { NOT_JOINED_LINE } from './_team-tool-shared.js';
|
|
29
37
|
const CAP = 200;
|
|
30
38
|
/** One page when `after` or `limit` is given. */
|
|
31
39
|
const PAGE = 40;
|
|
@@ -39,7 +47,7 @@ const REASON_CAP = 300;
|
|
|
39
47
|
*/
|
|
40
48
|
export const JOURNEY_NOTE = "This triage is one step of CSPeach's guided clean-core run. The person types no commands: "
|
|
41
49
|
+ '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
|
|
50
|
+
+ 'and a row skipped is asked about again at the end of this run, or when the person says carry on. Use plain words: "your objects", "your project" — never team, lane, outbox or writer.';
|
|
43
51
|
const match = (pattern, value) => new RegExp(`^${pattern.trim().replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`, 'i').test(value);
|
|
44
52
|
function usageOf(u) {
|
|
45
53
|
return {
|
|
@@ -50,6 +58,30 @@ function usageOf(u) {
|
|
|
50
58
|
...(u.why ? { why: shownUpTo(u.why, REASON_CAP) } : {}),
|
|
51
59
|
};
|
|
52
60
|
}
|
|
61
|
+
/** The usage every row shares when none of them was read: unknown, with the one sentence that says why. */
|
|
62
|
+
const sharedUsage = (u) => u.state === 'unknown' && Object.keys(u).every((k) => k === 'state' || k === 'why');
|
|
63
|
+
export const usageNoteOf = (why) => `Usage, for every row without a usage field: unknown.${why ? ` ${why}` : ''}`;
|
|
64
|
+
export const OWN_USAGE_NOTE = 'Every row gives its own usage.';
|
|
65
|
+
/**
|
|
66
|
+
* Task 6 (R6): the usage sentence most rows of the page share goes to one note;
|
|
67
|
+
* those rows drop their usage. Any other row keeps its own.
|
|
68
|
+
*/
|
|
69
|
+
function usageNote(usages) {
|
|
70
|
+
const counts = new Map();
|
|
71
|
+
for (const u of usages)
|
|
72
|
+
if (sharedUsage(u))
|
|
73
|
+
counts.set(String(u.why ?? ''), (counts.get(String(u.why ?? '')) ?? 0) + 1);
|
|
74
|
+
if (!counts.size)
|
|
75
|
+
return { note: OWN_USAGE_NOTE, shared: () => false };
|
|
76
|
+
let why = '';
|
|
77
|
+
let most = 0;
|
|
78
|
+
for (const [w, n] of counts)
|
|
79
|
+
if (n > most) {
|
|
80
|
+
why = w;
|
|
81
|
+
most = n;
|
|
82
|
+
}
|
|
83
|
+
return { note: usageNoteOf(why || undefined), shared: (u) => sharedUsage(u) && String(u.why ?? '') === why };
|
|
84
|
+
}
|
|
53
85
|
/** A number only when the index was read; otherwise why not, in words. */
|
|
54
86
|
function callersOf(c) {
|
|
55
87
|
if (!c)
|
|
@@ -59,25 +91,30 @@ function callersOf(c) {
|
|
|
59
91
|
export async function teamFindingsHandler(args, ctx) {
|
|
60
92
|
const joined = activeProject();
|
|
61
93
|
if (!joined)
|
|
62
|
-
return { content:
|
|
94
|
+
return { content: NOT_JOINED_LINE, is_error: true };
|
|
63
95
|
try {
|
|
64
96
|
const project = readProjectFile(joined.projectDir);
|
|
65
97
|
const { register } = registerFor(joined.projectDir, joined.outboxDir);
|
|
66
98
|
const laneId = args.lane?.trim();
|
|
67
99
|
const patterns = (args.packages ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
68
|
-
|
|
69
|
-
|
|
100
|
+
// The project view, built once: the scope, its parts and every row read it.
|
|
101
|
+
const view = projectView(register, project);
|
|
102
|
+
let objects = view.all.objects;
|
|
103
|
+
let scope = { kind: 'project', name: project.name };
|
|
70
104
|
if (laneId) {
|
|
71
105
|
const lane = register.lanes.find((l) => l.id.toLowerCase() === laneId.toLowerCase());
|
|
72
106
|
if (!lane)
|
|
73
107
|
return { content: `There is no lane "${laneId}". Lanes: ${register.lanes.map((l) => l.id).join(', ') || 'none yet'}.`, is_error: true };
|
|
74
|
-
|
|
75
|
-
|
|
108
|
+
// The lane's own objects (design §2): an object another lane's pattern also matches is its owner's.
|
|
109
|
+
objects = view.lanes.find((s) => s.scope.kind === 'lane' && s.scope.id === lane.id)?.objects ?? [];
|
|
110
|
+
scope = { kind: 'lane', name: lane.id };
|
|
76
111
|
}
|
|
77
112
|
else if (patterns.length) {
|
|
78
113
|
objects = objects.filter((o) => patterns.some((p) => match(p, o.package)));
|
|
79
|
-
scope =
|
|
114
|
+
scope = { kind: 'packages', name: shown(patterns.join(', ')) };
|
|
80
115
|
}
|
|
116
|
+
// The header is the whole scope's, not the page's.
|
|
117
|
+
const header = { ...scope, total: objects.length, parts: partCounts(objects) };
|
|
81
118
|
// A journey triage sees only the page the journey handed out.
|
|
82
119
|
const page = journeyPage(ctx?.session);
|
|
83
120
|
if (page) {
|
|
@@ -100,28 +137,42 @@ export async function teamFindingsHandler(args, ctx) {
|
|
|
100
137
|
const more = objects.length - start - slice.length;
|
|
101
138
|
const findings = findingsByObject(joined.projectDir, joined.outboxDir);
|
|
102
139
|
const rules = readDecisionRules(project);
|
|
103
|
-
const
|
|
140
|
+
const registerObject = new Map(register.objects.map((o) => [o.key, o]));
|
|
141
|
+
const usages = slice.map((v) => usageOf(registerObject.get(v.key).evidence.usage));
|
|
142
|
+
const usage = usageNote(usages);
|
|
143
|
+
const list = slice.map((v, i) => {
|
|
144
|
+
const o = registerObject.get(v.key);
|
|
104
145
|
const f = findings.get(o.key);
|
|
105
|
-
const d =
|
|
146
|
+
const d = v.decision;
|
|
106
147
|
return {
|
|
107
|
-
type:
|
|
108
|
-
owner:
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
148
|
+
type: v.type, name: v.name, package: v.package,
|
|
149
|
+
owner: v.owner?.name ?? null,
|
|
150
|
+
part: v.part,
|
|
151
|
+
// The view's decision, in words: who decided it and whose words the reason is. Never a bare target letter.
|
|
152
|
+
decided: d.what === 'none' ? null : {
|
|
153
|
+
what: DECISION_WORDS[d.what],
|
|
154
|
+
by: d.by ? shown(d.by.name) : null,
|
|
155
|
+
reason: d.reason?.text ? shownUpTo(d.reason.text, REASON_CAP) : null,
|
|
156
|
+
reason_from: d.reason?.from ?? 'none',
|
|
157
|
+
},
|
|
158
|
+
level: { decided: v.level.decided, by: v.level.by ? shown(v.level.by.name) : null },
|
|
159
|
+
target: { level: v.target.level, from: v.target.from },
|
|
160
|
+
// The rule that decides this row, from the latest counted run's findings; null = no rule decides.
|
|
161
|
+
proposal: ruleProposalOf(o, f, rules.rules),
|
|
162
|
+
baselineAt: f?.baselineAt ?? null, baseline: f?.baseline ?? [], openNow: v.proof.open,
|
|
163
|
+
// Evidence (spec 2026-10-06 §10): usage only with its source (the shared sentence is usage_note),
|
|
164
|
+
// callers only when the index was read, and when the code last CHANGED — which is never use.
|
|
165
|
+
...(usage.shared(usages[i]) ? {} : { usage: usages[i] }),
|
|
114
166
|
callers: callersOf(o.evidence.callers),
|
|
115
167
|
lastChanged: o.evidence.changed?.at ? shownUpTo(o.evidence.changed.at, 30) : null,
|
|
116
|
-
|
|
117
|
-
|
|
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),
|
|
168
|
+
priority: { band: v.priority.band, source: v.priority.source },
|
|
169
|
+
retireCandidate: d.what === 'retire-candidate',
|
|
120
170
|
};
|
|
121
171
|
});
|
|
122
172
|
return {
|
|
123
173
|
content: JSON.stringify({
|
|
124
|
-
|
|
174
|
+
// An empty page has no rows to share a usage: no note.
|
|
175
|
+
teamProject: project.name, scope: header, ...(list.length ? { usage_note: usage.note } : {}), objects: list,
|
|
125
176
|
...(page ? { journey: JOURNEY_NOTE } : {}),
|
|
126
177
|
...(rules.warning ? { rulesWarning: rules.warning } : {}),
|
|
127
178
|
...(more > 0 ? { more } : {}),
|
|
@@ -135,11 +186,13 @@ export async function teamFindingsHandler(args, ctx) {
|
|
|
135
186
|
}
|
|
136
187
|
registerTool({
|
|
137
188
|
name: 'team_findings',
|
|
138
|
-
description: 'Read-only. The objects of a lane (or package patterns) in the team project this laptop joined
|
|
139
|
-
+ '
|
|
140
|
-
+ '
|
|
141
|
-
+ '
|
|
142
|
-
+ '
|
|
189
|
+
description: 'Read-only. The objects of a lane (or package patterns) in the team project this laptop joined. '
|
|
190
|
+
+ 'scope gives the whole scope: total and parts (how many are decided, fixed, not decided yet). Per object: its part, '
|
|
191
|
+
+ 'the findings measurement zero recorded, any decision already recorded (decided: what, by whom, the reason and whose '
|
|
192
|
+
+ 'words it is), the level and who decided it, the target and where it came from, usage when it has its own (otherwise '
|
|
193
|
+
+ 'usage_note says it once), callers from the index (or why they were not read), last change (a change, not use), whether '
|
|
194
|
+
+ 'it is a retire candidate, its priority, and proposal: the decision rule that decides the row (null = no rule decides; '
|
|
195
|
+
+ 'your judgement). Use it for a /abap-clean-core triage. No SAP call.',
|
|
143
196
|
isMutating: false,
|
|
144
197
|
category: 'project',
|
|
145
198
|
requiresSap: false,
|