@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.
Files changed (110) hide show
  1. package/dist/agent/level.js +1 -1
  2. package/dist/agent/loop.js +7 -6
  3. package/dist/agent/sap-connection-adapter.js +2 -0
  4. package/dist/agent/system-prompt.js +2 -2
  5. package/dist/agent/tool-dispatch.js +13 -1
  6. package/dist/approvals/op-labels.js +49 -10
  7. package/dist/approvals/render.js +14 -23
  8. package/dist/approvals/risk-floor.js +196 -19
  9. package/dist/approvals/visible-changes.js +35 -0
  10. package/dist/commands/config-set.js +5 -5
  11. package/dist/commands/cost.js +2 -1
  12. package/dist/commands/help.js +1 -1
  13. package/dist/commands/project.js +301 -0
  14. package/dist/commands/team-plan.js +16 -6
  15. package/dist/commands/team.js +295 -68
  16. package/dist/config/loader.js +13 -0
  17. package/dist/cost/cost-log.js +7 -4
  18. package/dist/cost/credits-wire.js +11 -6
  19. package/dist/cost/credits.js +15 -0
  20. package/dist/cost/display-mode.js +16 -4
  21. package/dist/doctor/checks/zcspeach.js +96 -0
  22. package/dist/journey/chain.js +489 -61
  23. package/dist/journey/clean-core.js +61 -36
  24. package/dist/journey/flow.js +75 -2
  25. package/dist/journey/people.js +37 -0
  26. package/dist/journey/rebuild.js +7 -6
  27. package/dist/journey/run.js +129 -42
  28. package/dist/journey/trigger.js +16 -10
  29. package/dist/one-shot.js +1 -1
  30. package/dist/project-context/render.js +7 -0
  31. package/dist/projects/clean-core-confirm.js +166 -44
  32. package/dist/projects/email-template.js +56 -0
  33. package/dist/projects/extract-cca.js +8 -7
  34. package/dist/projects/index.js +0 -1
  35. package/dist/projects/merge-cca.js +0 -19
  36. package/dist/projects/merge-upgrade.js +2 -2
  37. package/dist/projects/project-folder.js +2 -1
  38. package/dist/projects/save-command.js +51 -14
  39. package/dist/register/decision-rules.js +2 -1
  40. package/dist/register/export-files.js +18 -0
  41. package/dist/register/picture.js +3 -3
  42. package/dist/register/project-transport.js +125 -0
  43. package/dist/register/records.js +25 -13
  44. package/dist/register/rules-edit.js +5 -0
  45. package/dist/register/status-file/progress-chart.js +17 -6
  46. package/dist/register/status-file/register-map.js +8 -5
  47. package/dist/register/status-file/register-model.js +55 -36
  48. package/dist/register/status-file/register-parts.js +97 -82
  49. package/dist/register/status-file/register-status-file.js +16 -7
  50. package/dist/register/status-file/report-model.js +293 -205
  51. package/dist/register/status-file/team-css.js +1 -1
  52. package/dist/register/status-html.js +11 -4
  53. package/dist/register/store.js +2 -2
  54. package/dist/register/workbooks.js +24 -13
  55. package/dist/renderer/footer-line.js +10 -2
  56. package/dist/renderer/status-footer.js +2 -1
  57. package/dist/renderer/tool-labels.js +2 -2
  58. package/dist/repl/builtin-commands.js +1 -1
  59. package/dist/repl/continue-skill.js +5 -0
  60. package/dist/repl/level-command.js +6 -15
  61. package/dist/repl.js +21 -8
  62. package/dist/router/router-core.js +5 -3
  63. package/dist/session/audit-export.js +4 -1
  64. package/dist/skill-catalog.js +4 -7
  65. package/dist/tools/_team-work-path.js +53 -0
  66. package/dist/tools/approval.js +97 -19
  67. package/dist/tools/filesystem/file-edit.js +8 -1
  68. package/dist/tools/filesystem/file-write.js +8 -1
  69. package/dist/tools/local-files.js +31 -0
  70. package/dist/tools/project/_merge-shared.js +2 -2
  71. package/dist/tools/project/_team-tool-shared.js +5 -0
  72. package/dist/tools/project/cca_merge.js +1 -11
  73. package/dist/tools/project/rules_propose.js +2 -1
  74. package/dist/tools/project/team_findings.js +81 -28
  75. package/dist/tools/project/team_lane_move.js +127 -0
  76. package/dist/tools/project/upgrade_merge_progress.js +8 -4
  77. package/dist/tools/subagent/schedule_create.js +131 -0
  78. package/dist/tools/text-elements/tools.js +7 -1
  79. package/dist/tools/tool-loading.js +1 -1
  80. package/dist/tools/update-method-intercept.js +18 -1
  81. package/dist/tools/write-pipeline.js +19 -5
  82. package/dist/ui/approval-modal.js +6 -3
  83. package/dist/ui/error-emitter.js +21 -0
  84. package/dist/ui/footer-line.js +19 -1
  85. package/dist/ui/header.js +41 -0
  86. package/dist/ui/ink-classifier-route.js +19 -0
  87. package/dist/ui/rich-error-box.js +9 -0
  88. package/dist/ui/sidebar.js +12 -0
  89. package/dist/ui/status-emitter.js +19 -0
  90. package/dist/ui/status-line.js +62 -0
  91. package/dist/ui/status-row.js +38 -0
  92. package/dist/ui/todo-panel.js +85 -0
  93. package/dist/ui/widgets/ask-form.js +250 -0
  94. package/dist/ui/widgets/ask-question-modal.js +178 -0
  95. package/fiori/zlas-inspection-lr/package.json +26 -0
  96. package/fiori/zlas-inspection-lr/ui5-local.yaml +45 -0
  97. package/fiori/zlas-inspection-lr/ui5.yaml +41 -0
  98. package/fiori/zlas-inspection-lr/webapp/Component.js +12 -0
  99. package/fiori/zlas-inspection-lr/webapp/i18n/i18n.properties +9 -0
  100. package/fiori/zlas-inspection-lr/webapp/index.html +35 -0
  101. package/fiori/zlas-inspection-lr/webapp/manifest.json +145 -0
  102. package/live-check-2026-10-17-1343.txt +41 -0
  103. package/package.json +121 -121
  104. package/test-harness/fe-draft/app.ts +0 -188
  105. package/test-harness/fe-draft/fe-draft.e2e.test.ts +0 -461
  106. package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +0 -51
  107. package/test-harness/fe-draft/routes.ts +0 -224
  108. package/test-harness/fe-draft/routes.unit.test.ts +0 -45
  109. package/test-harness/fe-draft/server.ts +0 -225
  110. 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
+ }
@@ -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, riskReasonText, typeCode } from '../approvals/risk-floor.js';
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
- effective_risk: eff.level,
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 planGateTransport = args.transport
339
- ?? (changes.some((c) => c.op === 'create') ? sessionTransport ?? undefined : undefined);
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
- effective_risk: eff.level,
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
- effective_risk: eff.level,
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
- if (changes.length > 1) {
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
- effective_risk: eff.level,
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
- for (const c of changes) {
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
- approvalIds.push(await mintChangeApproval(c, ctx.session.id));
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
- effective_risk: eff.level,
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
- effective_risk: eff.level,
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 two merge tools (cca_merge,
3
- * upgrade_merge_progress).
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
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Lines the team project tools share, so they never drift apart.
3
+ * Model-facing: the developer runs the command, the model does not.
4
+ */
5
+ export const NOT_JOINED_LINE = 'This laptop has not joined a team project. The developer runs: cspeach team join <folder>';
@@ -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
- // 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);
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: 'This laptop has not joined a team project. The developer runs: cspeach team join <folder>', is_error: true };
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 `ruleProposal` — the first
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 { dispositionOf, DISPOSITION_WORD, inLane, readDecisionRules, } from '@cspeach/register-core';
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 later. Use plain words: "your objects", "your project" — never team, lane, outbox or writer.';
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: 'This laptop has not joined a team project. The developer runs: cspeach team join <folder>', is_error: true };
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
- let objects = register.objects;
69
- let scope = 'all objects';
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
- objects = objects.filter((o) => inLane(o, lane.id));
75
- scope = `lane ${lane.id}`;
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 = `packages ${patterns.join(', ')}`;
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 list = slice.map((o) => {
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 = dispositionOf(o.decision);
146
+ const d = v.decision;
106
147
  return {
107
- type: o.type, name: o.name, package: o.package,
108
- owner: register.lanes.find((l) => l.id === o.lane)?.owner ?? null,
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,
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),
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
- 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),
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
- teamProject: project.name, scope, objects: list,
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, with the findings '
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.',
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,