@cspeach/cli 1.1.30 → 1.1.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/README.md +11 -1
  2. package/dist/agent/loop.js +132 -9
  3. package/dist/cli.js +49 -18
  4. package/dist/commands/config-set.js +1 -1
  5. package/dist/commands/help.js +16 -1
  6. package/dist/commands/plan-chain.js +3 -0
  7. package/dist/commands/plan-continue.js +1 -1
  8. package/dist/commands/plan-gate.js +17 -1
  9. package/dist/commands/plan-resume.js +28 -18
  10. package/dist/commands/plan-startup.js +49 -6
  11. package/dist/commands/team-discover.js +195 -0
  12. package/dist/commands/team-plan.js +585 -0
  13. package/dist/commands/team-rules.js +187 -0
  14. package/dist/commands/team.js +971 -95
  15. package/dist/journey/chain.js +978 -0
  16. package/dist/journey/clean-core.js +235 -0
  17. package/dist/journey/flow.js +216 -0
  18. package/dist/journey/rebuild.js +464 -0
  19. package/dist/journey/run.js +628 -0
  20. package/dist/journey/session-deps.js +81 -0
  21. package/dist/journey/trigger.js +153 -0
  22. package/dist/lane/app-chain.js +21 -9
  23. package/dist/lane/chain-token.js +9 -5
  24. package/dist/lane/roots.js +97 -41
  25. package/dist/one-shot.js +28 -2
  26. package/dist/projects/clean-core-confirm.js +192 -3
  27. package/dist/projects/detail-path.js +9 -4
  28. package/dist/projects/extract-clean-core.js +3 -0
  29. package/dist/projects/migration.js +4 -105
  30. package/dist/projects/plan-lane.js +67 -17
  31. package/dist/projects/plan-sources.js +13 -7
  32. package/dist/projects/project-folder.js +131 -0
  33. package/dist/projects/promote-command.js +39 -0
  34. package/dist/projects/save-command.js +100 -12
  35. package/dist/projects/saved-lines.js +17 -3
  36. package/dist/projects/sort-command.js +499 -0
  37. package/dist/projects/sort-core.js +248 -0
  38. package/dist/projects/workspace-retire.js +48 -0
  39. package/dist/projects/workspace.js +182 -121
  40. package/dist/register/baseline.js +39 -2
  41. package/dist/register/decision-rules.js +35 -0
  42. package/dist/register/discover.js +931 -0
  43. package/dist/register/export-files.js +58 -0
  44. package/dist/register/findings.js +5 -2
  45. package/dist/register/measure.js +191 -9
  46. package/dist/register/offer.js +100 -8
  47. package/dist/register/picture.js +6 -2
  48. package/dist/register/plan-import.js +328 -0
  49. package/dist/register/records.js +40 -3
  50. package/dist/register/rule-proposal.js +4 -0
  51. package/dist/register/rules-edit.js +257 -0
  52. package/dist/register/status-file/estate-parts.js +168 -0
  53. package/dist/register/status-file/plan-parts.js +68 -0
  54. package/dist/register/status-file/progress-chart.js +110 -0
  55. package/dist/register/status-file/register-map.js +11 -3
  56. package/dist/register/status-file/register-model.js +40 -6
  57. package/dist/register/status-file/register-parts.js +57 -11
  58. package/dist/register/status-file/register-status-file.js +106 -90
  59. package/dist/register/status-file/report-model.js +569 -0
  60. package/dist/register/status-file/team-css.js +1 -1
  61. package/dist/register/status-file-sync.js +4 -0
  62. package/dist/register/status-html.js +27 -6
  63. package/dist/register/store.js +44 -5
  64. package/dist/register/variant.js +65 -0
  65. package/dist/register/workbooks.js +28 -42
  66. package/dist/register/write-whole.js +30 -2
  67. package/dist/register/xlsx.js +13 -3
  68. package/dist/register/xref.js +117 -0
  69. package/dist/renderer/startup-lines.js +11 -0
  70. package/dist/renderer/tool-labels.js +3 -1
  71. package/dist/renderer/tool-widget.js +15 -1
  72. package/dist/repl/at-completer.js +6 -2
  73. package/dist/repl/at-picker.js +1 -1
  74. package/dist/repl/builtin-commands.js +4 -0
  75. package/dist/repl/journey-entry.js +27 -0
  76. package/dist/repl/plan-hop.js +2 -9
  77. package/dist/repl/rules-menu.js +177 -0
  78. package/dist/repl/slash-completer.js +1 -0
  79. package/dist/repl/slash-picker.js +5 -4
  80. package/dist/repl.js +236 -59
  81. package/dist/router/classifier.js +8 -4
  82. package/dist/router/router-core.js +43 -7
  83. package/dist/router/routing-decision.js +1 -1
  84. package/dist/runs/bundled-runs.js +100 -0
  85. package/dist/runs/front-door.js +19 -0
  86. package/dist/runs/manifest-runs.js +50 -0
  87. package/dist/runs/run-command.js +24 -0
  88. package/dist/runs/runs.js +27 -0
  89. package/dist/runs/shadow.js +100 -0
  90. package/dist/runs/types.js +1 -0
  91. package/dist/session/backfill.js +342 -0
  92. package/dist/session/project-match.js +63 -0
  93. package/dist/session/queued-step.js +45 -0
  94. package/dist/session/resume.js +72 -24
  95. package/dist/session/schema.js +3 -1
  96. package/dist/session/store.js +96 -51
  97. package/dist/skill-catalog.js +2 -2
  98. package/dist/skills/bundled-skills.js +53 -53
  99. package/dist/tools/_filesystem-shared.js +174 -12
  100. package/dist/tools/_team-work-path.js +133 -0
  101. package/dist/tools/approval.js +7 -1
  102. package/dist/tools/dispatch-skill.js +8 -4
  103. package/dist/tools/filesystem/extract-xlsx.js +27 -3
  104. package/dist/tools/filesystem/file-edit.js +12 -4
  105. package/dist/tools/filesystem/file-read.js +8 -4
  106. package/dist/tools/filesystem/file-write.js +12 -4
  107. package/dist/tools/filesystem/glob.js +33 -8
  108. package/dist/tools/filesystem/grep.js +9 -4
  109. package/dist/tools/filesystem/read-document.js +6 -3
  110. package/dist/tools/lane/app-lane-resolve.js +1 -1
  111. package/dist/tools/project/cca_merge.js +8 -2
  112. package/dist/tools/project/rules_propose.js +59 -0
  113. package/dist/tools/project/team_estate.js +91 -0
  114. package/dist/tools/project/team_findings.js +89 -5
  115. package/dist/tools/project/upgrade_merge_progress.js +8 -2
  116. package/dist/tools/sap-read.js +21 -4
  117. package/dist/tools/sap-write.js +200 -10
  118. package/dist/tools/subagent/read_agent.js +3 -0
  119. package/dist/tools/tool-loading.js +1 -1
  120. package/dist/tools/transport-fit-check.js +117 -0
  121. package/dist/tools/transport-fit.js +15 -7
  122. package/dist/tools/transport.js +57 -2
  123. package/dist/ui/app.js +2 -2
  124. package/dist/ui/approval-modal.js +74 -6
  125. package/dist/ui/widgets/confirm-card.js +68 -5
  126. package/dist/upgrade-check.js +49 -4
  127. package/dist/util/open-in-browser.js +143 -0
  128. package/package.json +3 -3
@@ -10,6 +10,7 @@
10
10
  import * as path from 'node:path';
11
11
  import { promises as fsPromises, realpathSync, existsSync } from 'node:fs';
12
12
  import { cspeachRoot, defaultCspeachRoot } from '../config/paths.js';
13
+ import { SAFE_PROJECT_ID } from '../projects/project-folder.js';
13
14
  /**
14
15
  * Phase 3 sensitive in-root directory prefixes — refused by all filesystem
15
16
  * tools. Matched as `rel === prefix` or `rel` starts with `prefix + '/'`.
@@ -190,21 +191,21 @@ function relIsDenylisted(rel) {
190
191
  * so the caller must fail closed — `{ failClosed: true }`. Returns null only
191
192
  * when nothing up to the drive root exists.
192
193
  */
193
- function longNameAbs(absPath) {
194
- let cur = path.win32.resolve(absPath);
194
+ function longNameAbs(absPath, p = path.win32) {
195
+ let cur = p.resolve(absPath);
195
196
  const tail = [];
196
197
  for (;;) {
197
198
  try {
198
199
  const real = realpathSync.native(cur);
199
- return { abs: tail.length ? path.win32.join(real, ...tail) : real };
200
+ return { abs: tail.length ? p.join(real, ...tail) : real };
200
201
  }
201
202
  catch (err) {
202
203
  if (err?.code !== 'ENOENT')
203
204
  return { failClosed: true };
204
- const parent = path.win32.dirname(cur);
205
+ const parent = p.dirname(cur);
205
206
  if (parent === cur)
206
207
  return null;
207
- tail.unshift(path.win32.basename(cur));
208
+ tail.unshift(p.basename(cur));
208
209
  cur = parent;
209
210
  }
210
211
  }
@@ -259,6 +260,140 @@ function isUnderCliHome(absPath, p, native) {
259
260
  }
260
261
  return false;
261
262
  }
263
+ /**
264
+ * The segments of `abs` below `<cspeachRoot()>/work/<SAFE id>/.cspeach/`
265
+ * (lowercased on win32), or null when `abs` is not there under the spelled or
266
+ * the real seat. Null also for ANY segment below the seat that is empty, `.`,
267
+ * `..`, carries ':' (stream), '~' (possible 8.3 name), a trailing dot/space,
268
+ * is `.git`, or is a Windows device name — fail closed, never decoded.
269
+ * Pure string logic on the seat's two spellings; callers pass both the spelled
270
+ * and the real form of the path.
271
+ */
272
+ function seatTail(abs, p, isWin) {
273
+ const cmp = seatSegs(abs, p, isWin);
274
+ if (!cmp || cmp.length < 4 || cmp[0] !== 'work' || !SAFE_PROJECT_ID.test(cmp[1]) || cmp[2] !== '.cspeach')
275
+ return null;
276
+ return cmp.slice(3);
277
+ }
278
+ /** The checked segments of `abs` below the seat (see seatTail), or null. */
279
+ function seatSegs(abs, p, isWin) {
280
+ const seat = cspeachRoot();
281
+ const seats = [p.resolve(seat)];
282
+ const realSeat = realDirOrNull(seat);
283
+ if (realSeat && !seats.includes(realSeat))
284
+ seats.push(realSeat);
285
+ for (const s of seats) {
286
+ const rel = p.relative(s, abs).replace(/\\/g, '/');
287
+ if (rel === '' || !relIsInside(p, rel))
288
+ continue;
289
+ const segs = rel.split('/');
290
+ const bad = (seg) => seg === '' || seg === '.' || seg === '..' || seg.includes(':') || seg.includes('~')
291
+ || ntfsCanonicalSegment(seg) !== seg || seg.toLowerCase() === '.git' || (isWin && isWindowsReservedName(seg));
292
+ if (segs.some(bad))
293
+ return null;
294
+ return isWin ? segs.map((x) => x.toLowerCase()) : segs;
295
+ }
296
+ return null;
297
+ }
298
+ /**
299
+ * Fix round 1 (security review) — the anchor. The REAL path of `root`, and
300
+ * the real `absPath` relative to it ('/'-separated), when the real root is
301
+ * exactly a seat work root `<cspeachRoot()>/work/<SAFE id>` AND the real
302
+ * form of `absPath` sits inside it; else null. The seat opening applies only
303
+ * then: a session started in the home folder, its parent, a team folder or
304
+ * the seat itself never reaches a seat file through the file tools.
305
+ */
306
+ function workRootFor(root, absPath, platform) {
307
+ const r = spelledAndReal(root, platform);
308
+ const a = spelledAndReal(absPath, platform);
309
+ if (!r || !a)
310
+ return null;
311
+ const realRoot = r.forms[1];
312
+ const segs = seatSegs(realRoot, r.p, r.isWin);
313
+ if (!segs || segs.length !== 2 || segs[0] !== 'work' || !SAFE_PROJECT_ID.test(segs[1]))
314
+ return null;
315
+ const rel = r.p.relative(realRoot, a.forms[1]).replace(/\\/g, '/');
316
+ return rel !== '' && relIsInside(r.p, rel) ? { root: realRoot, rel } : null;
317
+ }
318
+ /**
319
+ * Both forms of `absPath` — as spelled, and real (nearest existing ancestor
320
+ * realpath'd, which also expands 8.3 names; missing tail re-attached) — or
321
+ * null when the real form cannot be known (any lookup error but "does not
322
+ * exist", or a platform other than the one we run on): fail closed.
323
+ */
324
+ function spelledAndReal(absPath, platform) {
325
+ if (platform !== process.platform)
326
+ return null;
327
+ const isWin = platform === 'win32';
328
+ const p = isWin ? path.win32 : path.posix;
329
+ const spelled = p.resolve(absPath);
330
+ const real = longNameAbs(spelled, p);
331
+ if (!real || 'failClosed' in real)
332
+ return null;
333
+ return { p, isWin, forms: [spelled, real.abs] };
334
+ }
335
+ /**
336
+ * Task 14 (2026-10-08) — the ONE opening in the CLI home for the file tools:
337
+ * true only when `absPath`, spelled AND real, is strictly inside
338
+ * `<cspeachRoot()>/work/<id>/.cspeach/<sub>/` with `<id>` matching
339
+ * SAFE_PROJECT_ID and `<sub>` in CSPEACH_WORK_SUBDIRS; no segment below the
340
+ * seat carries ':' / '~' / a trailing dot or space / `.git` / a device name;
341
+ * the basename is not a `*.cspeach.json` envelope and not `.env*`. A link
342
+ * anywhere on the way (a junction to the seat, to sessions, out of the seat)
343
+ * fails it, because the real form must match too. Only the seat CSPEACH_HOME
344
+ * chose opens — never `~/.cspeach` when CSPEACH_HOME names another seat.
345
+ */
346
+ export function isSeatWorkPath(absPath, platform = process.platform) {
347
+ const both = spelledAndReal(absPath, platform);
348
+ if (!both)
349
+ return false;
350
+ return both.forms.every((form) => {
351
+ const tail = seatTail(form, both.p, both.isWin);
352
+ if (!tail || tail.length < 2 || !CSPEACH_WORK_SUBDIRS.includes(tail[0]))
353
+ return false;
354
+ const base = tail[tail.length - 1];
355
+ return !base.toLowerCase().endsWith('.cspeach.json') && !BLOCKED_BASENAME_RE.test(base);
356
+ });
357
+ }
358
+ /**
359
+ * Task 14 — the READ-ONLY seat shapes: a saved result directly in the seat
360
+ * working folder, `<cspeachRoot()>/work/<id>/.cspeach/<name>.cspeach.json`,
361
+ * or a lane decision, `<cspeachRoot()>/work/<id>/.cspeach/lane/<name>.json`.
362
+ * Same rules as isSeatWorkPath (spelled AND real, fail closed). The write
363
+ * tools never consult this.
364
+ */
365
+ export function isSeatReadOnlyFile(absPath, platform = process.platform) {
366
+ const both = spelledAndReal(absPath, platform);
367
+ if (!both)
368
+ return false;
369
+ return both.forms.every((form) => {
370
+ const tail = seatTail(form, both.p, both.isWin);
371
+ if (!tail)
372
+ return false;
373
+ const base = tail[tail.length - 1];
374
+ if (BLOCKED_BASENAME_RE.test(base))
375
+ return false;
376
+ if (tail.length === 1)
377
+ return /\.cspeach\.json$/i.test(base);
378
+ return tail.length === 2 && tail[0] === 'lane' && /\.json$/i.test(base);
379
+ });
380
+ }
381
+ /**
382
+ * True when `absPath` — spelled or real (nearest existing ancestor) — is the
383
+ * CLI home (`cspeachRoot()` or `~/.cspeach`) or below it. Fails closed: a path
384
+ * whose real form cannot be known counts as inside.
385
+ */
386
+ export function isCliHomePath(absPath, platform = process.platform) {
387
+ const isWin = platform === 'win32';
388
+ const p = isWin ? path.win32 : path.posix;
389
+ const native = platform === process.platform;
390
+ if (isUnderCliHome(p.resolve(absPath), p, native))
391
+ return true;
392
+ const both = spelledAndReal(absPath, platform);
393
+ if (!both)
394
+ return true;
395
+ return isUnderCliHome(both.forms[1], p, native);
396
+ }
262
397
  /**
263
398
  * True if `realAbsPath` is denylisted relative to root — either it falls under
264
399
  * one of BLOCKED_PREFIXES (directory prefixes) or its basename is in the
@@ -286,6 +421,17 @@ function isUnderCliHome(absPath, p, native) {
286
421
  * also when the CLI runs from `C:\Users`, `C:\` or `/home`, where it is not a
287
422
  * root-level `.cspeach`. The carve-out does not apply there.
288
423
  *
424
+ * ONE ANCHORED EXCEPTION (Task 14, 2026-10-08): a team seat's working files,
425
+ * `<cspeachRoot()>/work/<SAFE id>/.cspeach/<cca|upgrades|modernize|tests>/…`
426
+ * (isSeatWorkPath: spelled AND real path, no stream / '~' / trailing dot or
427
+ * space / .git / device-name segment, no *.cspeach.json, no .env*), are
428
+ * readable and writable ONLY when the REAL root is exactly that path's work
429
+ * root `<cspeachRoot()>/work/<id>` (workRootFor). The path is then judged
430
+ * relative to the real work root alone, with every rule below still applied.
431
+ * Any other root — the home folder, its parent, a team folder, the seat, a
432
+ * sibling work root — keeps the whole CLI home blocked, so a project run from
433
+ * the home folder gets nothing.
434
+ *
289
435
  * WINDOWS NAME ALIASES (SEC fix, `.git::$INDEX_ALLOCATION`). NTFS opens the
290
436
  * same object under spellings that do not byte-match the denylist:
291
437
  * - stream suffixes: `.git::$INDEX_ALLOCATION`, `.git:$I30:$INDEX_ALLOCATION`,
@@ -338,10 +484,19 @@ export function isDenylistedPath(realAbsPath, root, platform = process.platform)
338
484
  relFromRoot = realRel;
339
485
  }
340
486
  }
487
+ // The CLI home is protected whatever the root — except exactly the seat
488
+ // working subfolders (isSeatWorkPath), and only when the real root IS that
489
+ // path's work root (workRootFor). Then the path is judged relative to the
490
+ // real work root alone (a root spelled through a link, on another drive or
491
+ // a share, cannot change the verdict).
492
+ if (isUnderCliHome(realAbsPath, p, native)) {
493
+ const work = isSeatWorkPath(realAbsPath, platform) ? workRootFor(root, realAbsPath, platform) : null;
494
+ if (work === null)
495
+ return { blocked: true, relFromRoot };
496
+ relFromRoot = work.rel;
497
+ rels.splice(0, rels.length, work.rel);
498
+ }
341
499
  const blocked = { blocked: true, relFromRoot };
342
- // The CLI home is protected whatever the root.
343
- if (isUnderCliHome(realAbsPath, p, native))
344
- return blocked;
345
500
  for (const rel of rels) {
346
501
  const segments = rel.split('/');
347
502
  // (1) win32: an NTFS stream / stream-type suffix anywhere → refuse.
@@ -389,7 +544,11 @@ export function isDenylistedPath(realAbsPath, root, platform = process.platform)
389
544
  *
390
545
  * Narrow by construction — true only when ALL hold:
391
546
  * - the path is NOT under the CLI home (`~/.cspeach`: auth, config, sessions),
392
- * whatever the root (a project run from the home folder gets nothing);
547
+ * whatever the root (a project run from the home folder gets nothing) —
548
+ * with ONE anchored exception (Task 14): the seat shapes
549
+ * `<cspeachRoot()>/work/<id>/.cspeach/<name>.cspeach.json` and
550
+ * `.cspeach/lane/<name>.json` (isSeatReadOnlyFile), only when the REAL
551
+ * root is exactly that file's work root `<cspeachRoot()>/work/<id>`;
393
552
  * - it sits inside the spelled root, at exactly one of the two shapes above
394
553
  * (no deeper nesting, no other `.cspeach` file such as conventions.toml);
395
554
  * - no NTFS alias spelling: no ':' segment, no trailing dot/space segment,
@@ -401,8 +560,11 @@ export function isReadOnlyProjectFile(realAbsPath, root, platform = process.plat
401
560
  const isWin = platform === 'win32';
402
561
  const native = platform === process.platform;
403
562
  const p = isWin ? path.win32 : path.posix;
404
- if (isUnderCliHome(realAbsPath, p, native))
405
- return false;
563
+ // Under the CLI home only the two read-only seat shapes (Task 14), and only
564
+ // when the real root IS that file's work root (fix round 1).
565
+ if (isUnderCliHome(realAbsPath, p, native)) {
566
+ return isSeatReadOnlyFile(realAbsPath, platform) && workRootFor(root, realAbsPath, platform) !== null;
567
+ }
406
568
  const rel = p.relative(p.resolve(root), realAbsPath).replace(/\\/g, '/');
407
569
  if (!relIsInside(p, rel) || rel === '')
408
570
  return false;
@@ -483,7 +645,7 @@ export function isInsideRoot(target, root) {
483
645
  * Real form of a folder that may not exist yet: nearest existing ancestor
484
646
  * realpath'd, missing tail re-attached. null on any non-ENOENT error.
485
647
  */
486
- function realOfNearestOrNull(abs) {
648
+ export function realOfNearestOrNull(abs) {
487
649
  let cur = abs;
488
650
  const tail = [];
489
651
  for (;;) {
@@ -0,0 +1,133 @@
1
+ // cspeach-cli/src/tools/_team-work-path.ts
2
+ /**
3
+ * Task 15 (2026-10-08 sessions/folders) — in a TEAM folder (a shared folder
4
+ * holding cspeach-team.json) the skills' personal working files,
5
+ * `.cspeach/<cca|upgrades|modernize|tests>/…`, belong to the person's seat
6
+ * work root `<CSPEACH_HOME>/work/<projectId>`, not to the shared folder.
7
+ *
8
+ * teamWorkTarget is a PURE path mapper: it picks the root a file tool should
9
+ * work against and hands back the same relative path. It opens nothing on its
10
+ * own — every tool still runs its full pipeline (resolveSafePath containment,
11
+ * real-path checks, denylist, write checks) against the root it returns, and
12
+ * the seat opening in the denylist applies only when that root is exactly the
13
+ * work root.
14
+ *
15
+ * solo / home folder → { root: cwd, path: userPath } (unchanged)
16
+ * team folder, `.cspeach/<sub>` → { root: workRoot, path: <same relative path> }
17
+ * team folder, anything else → { root: cwd, path: userPath } (unchanged)
18
+ *
19
+ * The `.cspeach/<sub>` test compares each segment the way Windows opens it
20
+ * (case-insensitive, `:stream` and trailing dots/spaces dropped), so an alias
21
+ * spelling never slips a working file into the shared folder; in the seat the
22
+ * denylist then refuses the alias outright.
23
+ */
24
+ import { existsSync, realpathSync } from 'node:fs';
25
+ import * as path from 'node:path';
26
+ import { CSPEACH_WORK_SUBDIRS } from './_filesystem-shared.js';
27
+ import { resolveProjectFolderSync } from '../projects/project-folder.js';
28
+ /** A segment as NTFS resolves it for a name compare, lowercased. */
29
+ function nameKey(seg) {
30
+ const colon = seg.indexOf(':');
31
+ return (colon === -1 ? seg : seg.slice(0, colon)).replace(/[. ]+$/, '').toLowerCase();
32
+ }
33
+ /** The seat work root when `cwd` is a team folder, else null. */
34
+ export function teamWorkRoot(cwd) {
35
+ try {
36
+ const pf = resolveProjectFolderSync(cwd);
37
+ return pf.kind === 'team' ? pf.workRoot : null;
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ export function teamWorkTarget(cwd, userPath) {
44
+ const same = { root: cwd, path: userPath };
45
+ if (typeof userPath !== 'string' || userPath.includes('\0'))
46
+ return same;
47
+ const workRoot = teamWorkRoot(cwd);
48
+ if (workRoot === null)
49
+ return same;
50
+ const absRoot = path.resolve(cwd);
51
+ const abs = path.isAbsolute(userPath) ? path.resolve(userPath) : path.resolve(absRoot, userPath);
52
+ const rel = path.relative(absRoot, abs);
53
+ if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel))
54
+ return same;
55
+ return isWorkRel(rel) ? { root: workRoot, path: rel } : same;
56
+ }
57
+ /**
58
+ * Ruling 27 — the READ twin of teamWorkTarget, for file_read and glob only.
59
+ * Besides the working files it maps the two read-only shapes a save leaves in
60
+ * the seat — `.cspeach/<name>.cspeach.json` and `.cspeach/lane/<name>.json` —
61
+ * so reading your own saved results looks where they were saved, as in a solo
62
+ * folder. Writes never use this: those shapes stay CLI-owned.
63
+ */
64
+ export function teamReadTarget(cwd, userPath) {
65
+ const same = { root: cwd, path: userPath };
66
+ const work = teamWorkTarget(cwd, userPath);
67
+ if (work.root !== cwd || typeof userPath !== 'string' || userPath.includes('\0'))
68
+ return work;
69
+ const workRoot = teamWorkRoot(cwd);
70
+ if (workRoot === null)
71
+ return same;
72
+ const absRoot = path.resolve(cwd);
73
+ const abs = path.isAbsolute(userPath) ? path.resolve(userPath) : path.resolve(absRoot, userPath);
74
+ const rel = path.relative(absRoot, abs);
75
+ if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel))
76
+ return same;
77
+ if (!isReadOnlyRel(rel))
78
+ return same;
79
+ // Final fix wave, item 1 — a save made before this release sits in the
80
+ // shared <team>/.cspeach. The seat copy comes first; only when the seat has
81
+ // none and the shared one exists is the shared one read (read-only: the
82
+ // write tools never use this function).
83
+ if (!existsSync(path.join(workRoot, rel)) && existsSync(path.join(absRoot, rel)))
84
+ return same;
85
+ return { root: workRoot, path: rel };
86
+ }
87
+ /** True for `.cspeach/<name>.cspeach.json` or `.cspeach/lane/<name>.json` (either separator). */
88
+ export function isReadOnlyRel(rel) {
89
+ const segs = rel.split(/[\\/]+/);
90
+ if (nameKey(segs[0] ?? '') !== '.cspeach')
91
+ return false;
92
+ if (segs.length === 2)
93
+ return /\.cspeach\.json$/i.test(nameKey(segs[1]));
94
+ return segs.length === 3 && nameKey(segs[1]) === 'lane' && /\.json$/i.test(nameKey(segs[2]));
95
+ }
96
+ /** True when a root-relative path (either separator) is `.cspeach/<work-subdir>` or below. */
97
+ export function isWorkRel(rel) {
98
+ const segs = rel.split(/[\\/]+/);
99
+ return segs.length >= 2 && nameKey(segs[0]) === '.cspeach'
100
+ && CSPEACH_WORK_SUBDIRS.includes(nameKey(segs[1]));
101
+ }
102
+ /** Final fix wave, item 3 — the refusal for a write into the shared `.cspeach`. */
103
+ export const SHARED_CSPEACH_REFUSAL = 'In a team folder, working files go to your own folder; this path points into the shared .cspeach.';
104
+ /**
105
+ * True when `cwd` is a team folder and the REAL landing path of a write
106
+ * (links followed, 8.3 names expanded) is inside the shared `<team>/.cspeach`
107
+ * — whatever spelling, junction or short name got it there. Only refuses;
108
+ * never maps anything.
109
+ */
110
+ export function pointsIntoSharedCspeach(cwd, realTarget) {
111
+ if (teamWorkRoot(cwd) === null)
112
+ return false;
113
+ const absRoot = path.resolve(cwd);
114
+ const realOf = (p) => { try {
115
+ return realpathSync.native(p);
116
+ }
117
+ catch {
118
+ return null;
119
+ } };
120
+ const relUnder = (base) => {
121
+ const rel = path.relative(base, realTarget);
122
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)) ? rel : null;
123
+ };
124
+ const realShared = realOf(path.join(absRoot, '.cspeach'));
125
+ if (realShared !== null && relUnder(realShared) !== null)
126
+ return true;
127
+ for (const base of new Set([absRoot, realOf(absRoot) ?? absRoot])) {
128
+ const rel = relUnder(base);
129
+ if (rel !== null && rel !== '' && nameKey(rel.split(/[\\/]+/)[0]) === '.cspeach')
130
+ return true;
131
+ }
132
+ return false;
133
+ }
@@ -284,8 +284,14 @@ registerTool({
284
284
  sapCfg = cfg.sap[ctx.sapAlias];
285
285
  riskCtx.autoApprovePolicy = sapCfg?.auto_approve ?? 'never';
286
286
  }
287
+ // 1.1.31 (owner, 2026-10-07): a package or transport always gets a person's
288
+ // yes. Live, auto_approve="low" let the model's own "low" create package
289
+ // ZCC_FIX_PROOF and request S4HK903757 with no card. The plan-grant path
290
+ // above is separate and unchanged (a plan the person approved may carry
291
+ // its one new transport).
292
+ const touchesInfra = changes.some((c) => INFRA_BASE_OBJECT_TYPES.has((c.type ?? '').trim().toUpperCase()));
287
293
  // Auto-approve caps — never auto-approve at high, cap change count per risk level.
288
- const autoApproveAllowed = !guardedRun && !qasNoAutoApprove && ((sapCfg?.auto_approve === 'low' && eff.level === 'low' && changes.length <= 5) ||
294
+ const autoApproveAllowed = !guardedRun && !qasNoAutoApprove && !touchesInfra && ((sapCfg?.auto_approve === 'low' && eff.level === 'low' && changes.length <= 5) ||
289
295
  (sapCfg?.auto_approve === 'medium' && (eff.level === 'low' || eff.level === 'medium') && changes.length <= 2));
290
296
  if (autoApproveAllowed) {
291
297
  const ids = [];
@@ -15,8 +15,9 @@
15
15
  * user's keypress to make.
16
16
  * - The command argument MUST start with a known slash route. Free
17
17
  * text without a leading `/` is rejected.
18
- * - Set the slot via ctx.pendingDispatch.set(command). repl.tsx reads
19
- * it after runTurn and routes as the next submission.
18
+ * - Set the slot via ctx.pendingDispatch.set(command, { by: 'switch', … }).
19
+ * repl.tsx reads it after runTurn and routes it as the next submission,
20
+ * shown as "Auto-routed: <command>" — never as the person's own line.
20
21
  * - Skill ends the turn naturally after this tool call. Do not also
21
22
  * emit a "Type X at the next prompt" hint — the dispatch supersedes it.
22
23
  *
@@ -32,7 +33,7 @@
32
33
  * for every other dispatch (e.g. /abap-generate → /abap-rap switch).
33
34
  */
34
35
  import { registerTool } from './index.js';
35
- import { isPlanResumeCommand } from '../commands/plan-resume.js';
36
+ import { autoRoutedNotice, isPlanResumeCommand } from '../commands/plan-resume.js';
36
37
  export const PLAN_RESUME_DISPATCH_REASON = 'Do not dispatch /abap-plan --resume. When your plan manifest is saved, CSPeach runs the next step itself with the exact saved file. Simply end the turn now.';
37
38
  registerTool({
38
39
  name: 'dispatch_skill',
@@ -91,7 +92,10 @@ registerTool({
91
92
  }),
92
93
  };
93
94
  }
94
- ctx.pendingDispatch.set(command);
95
+ // Must-fix follow-up (b): the person agreed to the switch, but the model
96
+ // wrote the command, so it is never shown as theirs ("You › …"). Tagged
97
+ // `switch`, both REPLs show its one "Auto-routed:" line (queuedStepView).
98
+ ctx.pendingDispatch.set(command, { by: 'switch', line: autoRoutedNotice(command) ?? `Auto-routed: ${command}` });
95
99
  return {
96
100
  content: JSON.stringify({
97
101
  ok: true,
@@ -43,10 +43,11 @@ export function colIndex(ref) {
43
43
  return n - 1;
44
44
  }
45
45
  /** One worksheet's cells as a rectangular grid of strings. */
46
- export function sheetRows(xml, strings, budget) {
46
+ export function sheetRows(xml, strings, budget, opts = {}) {
47
+ const maxRows = opts.maxRows ?? MAX_SHEET_ROWS;
47
48
  const rows = [];
48
49
  for (const rowMatch of xml.matchAll(/<row[^>]*>([\s\S]*?)<\/row>/g)) {
49
- if (rows.length >= MAX_SHEET_ROWS || budget.cells <= 0)
50
+ if (rows.length >= maxRows || budget.cells <= 0)
50
51
  break;
51
52
  const cells = [];
52
53
  for (const cell of rowMatch[1].matchAll(/<c\b([^>]*)>([\s\S]*?)<\/c>|<c\b([^>]*)\/>/g)) {
@@ -68,7 +69,7 @@ export function sheetRows(xml, strings, budget) {
68
69
  }
69
70
  while (cells.length < at)
70
71
  cells.push('');
71
- cells[at] = value.replace(/[\t\r\n]+/g, ' ').trim();
72
+ cells[at] = (opts.keepLines ? value.replace(/\r\n?/g, '\n') : value.replace(/[\t\r\n]+/g, ' ')).trim();
72
73
  budget.cells -= 1;
73
74
  if (budget.cells <= 0)
74
75
  break;
@@ -137,3 +138,26 @@ export function extractXlsx(buffer) {
137
138
  warnings.push('no cell text found — the workbook may be empty or hold only charts/images');
138
139
  return { text, sheets: sheets.length, warnings };
139
140
  }
141
+ /**
142
+ * Every sheet as rows of cell text, for a caller that reads a workbook back as
143
+ * data (the Plan sheet, spec 2026-10-06 §8). Numbers come back as their text
144
+ * (`2`, `46000`), shared and inline strings as written, line breaks inside a
145
+ * cell kept. No row cap: a Plan sheet holds one row per object. Throws on a
146
+ * file that is not a readable .xlsx.
147
+ */
148
+ export function readSheets(buffer) {
149
+ const zip = new AdmZip(buffer);
150
+ const read = (p) => {
151
+ const entry = zip.getEntry(p);
152
+ return entry ? entry.getData().toString('utf-8') : null;
153
+ };
154
+ const strings = sharedStrings(read('xl/sharedStrings.xml'));
155
+ const budget = { cells: Number.POSITIVE_INFINITY };
156
+ const out = [];
157
+ for (const s of sheetOrder(read('xl/workbook.xml'), read('xl/_rels/workbook.xml.rels'))) {
158
+ const xml = read(s.path);
159
+ if (xml !== null)
160
+ out.push({ name: s.name, rows: sheetRows(xml, strings, budget, { maxRows: Number.POSITIVE_INFINITY, keepLines: true }) });
161
+ }
162
+ return out;
163
+ }
@@ -15,6 +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
19
  export async function fileEditHandler(args, ctx) {
19
20
  // 1. Validate args before any IO
20
21
  if (args.old_string === args.new_string) {
@@ -24,9 +25,11 @@ export async function fileEditHandler(args, ctx) {
24
25
  };
25
26
  }
26
27
  // 2. Path containment — sync phase (pure string math)
28
+ // Task 15 — in a team folder a skill working file is edited in the seat.
29
+ const t = teamWorkTarget(ctx.cwd, args.path);
27
30
  let abs;
28
31
  try {
29
- abs = resolveSafePath(ctx.cwd, args.path);
32
+ abs = resolveSafePath(t.root, t.path);
30
33
  }
31
34
  catch (err) {
32
35
  if (err instanceof PathOutsideRootError) {
@@ -37,7 +40,7 @@ export async function fileEditHandler(args, ctx) {
37
40
  // 3. Path containment — async phase (follows symlinks)
38
41
  let realAbs;
39
42
  try {
40
- realAbs = await assertRealPathContained(abs, ctx.cwd);
43
+ realAbs = await assertRealPathContained(abs, t.root);
41
44
  }
42
45
  catch (err) {
43
46
  if (err instanceof PathOutsideRootError) {
@@ -49,7 +52,7 @@ export async function fileEditHandler(args, ctx) {
49
52
  return { content: `error: ${err instanceof Error ? err.message : String(err)}`, is_error: true };
50
53
  }
51
54
  // 4. Denylist check: refuse sensitive in-root paths
52
- const denylist = isDenylistedPath(realAbs, ctx.cwd);
55
+ const denylist = isDenylistedPath(realAbs, t.root);
53
56
  if (denylist.blocked) {
54
57
  return {
55
58
  content: `error: refusing to edit sensitive path "${denylist.relFromRoot}" (denylist: ${DENYLIST_DESCRIPTION})`,
@@ -66,6 +69,11 @@ export async function fileEditHandler(args, ctx) {
66
69
  is_error: true,
67
70
  };
68
71
  }
72
+ // 4c. Team folder: the real path never lies in the shared .cspeach
73
+ // (an 8.3 name, a junction or an odd spelling would get it there).
74
+ if (pointsIntoSharedCspeach(ctx.cwd, realAbs)) {
75
+ return { content: `error: ${SHARED_CSPEACH_REFUSAL}`, is_error: true };
76
+ }
69
77
  // 5. Read the file
70
78
  let content;
71
79
  try {
@@ -114,7 +122,7 @@ export async function fileEditHandler(args, ctx) {
114
122
  return { content: `error: write failed — ${err.message}`, is_error: true };
115
123
  }
116
124
  const noun = replacementCount === 1 ? 'replacement' : 'replacements';
117
- const relPath = path.relative(path.resolve(ctx.cwd), realAbs).replace(/\\/g, '/');
125
+ const relPath = path.relative(path.resolve(t.root), realAbs).replace(/\\/g, '/');
118
126
  return { content: `edited ${relPath} (${replacementCount} ${noun})` };
119
127
  }
120
128
  registerTool({
@@ -11,6 +11,7 @@
11
11
  import { promises as fs } from 'node:fs';
12
12
  import { registerTool } from '../index.js';
13
13
  import { resolveSafePath, assertRealPathContained, PathOutsideRootError, DENYLIST_DESCRIPTION, isDenylistedPath, isReadOnlyProjectFile } from '../_filesystem-shared.js';
14
+ import { teamReadTarget } from '../_team-work-path.js';
14
15
  import { spillToolResult, isUnderSpillRoot } from '../result-spill.js';
15
16
  import { basename } from 'node:path';
16
17
  const MAX_LINES = 2000;
@@ -22,9 +23,12 @@ export async function fileReadHandler(args, ctx) {
22
23
  if (args.limit !== undefined && args.limit < 1) {
23
24
  return { content: `error: limit must be a positive integer (got ${args.limit})`, is_error: true };
24
25
  }
26
+ // Task 15 — in a team folder a skill working file, a saved result
27
+ // (.cspeach/*.cspeach.json) or a lane decision is read from the seat.
28
+ const t = teamReadTarget(ctx.cwd, args.path);
25
29
  let abs;
26
30
  try {
27
- abs = resolveSafePath(ctx.cwd, args.path);
31
+ abs = resolveSafePath(t.root, t.path);
28
32
  }
29
33
  catch (err) {
30
34
  if (err instanceof PathOutsideRootError) {
@@ -34,7 +38,7 @@ export async function fileReadHandler(args, ctx) {
34
38
  }
35
39
  let realAbs;
36
40
  try {
37
- realAbs = await assertRealPathContained(abs, ctx.cwd);
41
+ realAbs = await assertRealPathContained(abs, t.root);
38
42
  }
39
43
  catch (err) {
40
44
  if (err instanceof PathOutsideRootError) {
@@ -48,8 +52,8 @@ export async function fileReadHandler(args, ctx) {
48
52
  // Denylist check: refuse sensitive in-root paths. The project's own saved
49
53
  // results (.cspeach/*.cspeach.json, .cspeach/lane/*.json) are readable —
50
54
  // isReadOnlyProjectFile; the write tools never get this carve-out.
51
- const denylist = isDenylistedPath(realAbs, ctx.cwd);
52
- if (denylist.blocked && !isReadOnlyProjectFile(realAbs, ctx.cwd)) {
55
+ const denylist = isDenylistedPath(realAbs, t.root);
56
+ if (denylist.blocked && !isReadOnlyProjectFile(realAbs, t.root)) {
53
57
  return {
54
58
  content: `error: refusing to read sensitive path "${denylist.relFromRoot}" (denylist: ${DENYLIST_DESCRIPTION})`,
55
59
  is_error: true,
@@ -19,6 +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
23
  /**
23
24
  * 2026-09-23 (Glenn's first run) — a standalone run writes GUIDE.md and said
24
25
  * nothing about it. He opened it in a text editor, then tried to drop it into
@@ -57,9 +58,11 @@ function noteGuideWritten(relPath, ctx) {
57
58
  }
58
59
  export async function fileWriteHandler(args, ctx) {
59
60
  // 1. Path containment — sync phase (pure string math, catches .. traversals)
61
+ // Task 15 — in a team folder a skill working file goes to the seat.
62
+ const t = teamWorkTarget(ctx.cwd, args.path);
60
63
  let abs;
61
64
  try {
62
- abs = resolveSafePath(ctx.cwd, args.path);
65
+ abs = resolveSafePath(t.root, t.path);
63
66
  }
64
67
  catch (err) {
65
68
  if (err instanceof PathOutsideRootError) {
@@ -74,7 +77,7 @@ export async function fileWriteHandler(args, ctx) {
74
77
  // `.git` behind a link (`docs -> .git`) or a short name (`GIT~1`).
75
78
  let target;
76
79
  try {
77
- target = await realPathForWrite(abs, ctx.cwd);
80
+ target = await realPathForWrite(abs, t.root);
78
81
  }
79
82
  catch (err) {
80
83
  if (err instanceof PathOutsideRootError) {
@@ -83,7 +86,7 @@ export async function fileWriteHandler(args, ctx) {
83
86
  return { content: `error: ${err instanceof Error ? err.message : String(err)}`, is_error: true };
84
87
  }
85
88
  // 3. Denylist check: refuse sensitive in-root paths
86
- const denylist = isDenylistedPath(target, ctx.cwd);
89
+ const denylist = isDenylistedPath(target, t.root);
87
90
  if (denylist.blocked) {
88
91
  return {
89
92
  content: `error: refusing to write sensitive path "${denylist.relFromRoot}" (denylist: ${DENYLIST_DESCRIPTION})`,
@@ -99,6 +102,11 @@ export async function fileWriteHandler(args, ctx) {
99
102
  is_error: true,
100
103
  };
101
104
  }
105
+ // 3c. Team folder: the real landing path never lies in the shared .cspeach
106
+ // (an 8.3 name, a junction or an odd spelling would get it there).
107
+ if (pointsIntoSharedCspeach(ctx.cwd, target)) {
108
+ return { content: `error: ${SHARED_CSPEACH_REFUSAL}`, is_error: true };
109
+ }
102
110
  // 4. Ensure parent directory exists (no-op if it already does)
103
111
  try {
104
112
  await fs.mkdir(path.dirname(target), { recursive: true });
@@ -117,7 +125,7 @@ export async function fileWriteHandler(args, ctx) {
117
125
  await fs.unlink(tmpPath).catch(() => undefined);
118
126
  return { content: `error: write failed — ${err.message}`, is_error: true };
119
127
  }
120
- const relPath = path.relative(path.resolve(ctx.cwd), target).replace(/\\/g, '/');
128
+ const relPath = path.relative(path.resolve(t.root), target).replace(/\\/g, '/');
121
129
  const bytes = Buffer.byteLength(args.content, 'utf-8');
122
130
  noteGuideWritten(relPath, ctx);
123
131
  return { content: `wrote ${relPath} (${bytes} bytes)` };