@cspeach/cli 1.1.30 → 1.1.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/.cspeach/project.json +4 -0
  2. package/README.md +11 -1
  3. package/dist/agent/loop.js +121 -8
  4. package/dist/cli.js +36 -14
  5. package/dist/commands/config-set.js +1 -1
  6. package/dist/commands/plan-continue.js +1 -1
  7. package/dist/commands/plan-gate.js +8 -1
  8. package/dist/commands/plan-resume.js +24 -18
  9. package/dist/commands/plan-startup.js +49 -6
  10. package/dist/commands/team-discover.js +195 -0
  11. package/dist/commands/team-plan.js +585 -0
  12. package/dist/commands/team.js +375 -41
  13. package/dist/journey/chain.js +728 -0
  14. package/dist/journey/clean-core.js +213 -0
  15. package/dist/journey/flow.js +166 -0
  16. package/dist/journey/run.js +435 -0
  17. package/dist/journey/session-deps.js +77 -0
  18. package/dist/journey/trigger.js +153 -0
  19. package/dist/lane/app-chain.js +9 -7
  20. package/dist/lane/chain-token.js +9 -5
  21. package/dist/lane/roots.js +97 -41
  22. package/dist/one-shot.js +20 -1
  23. package/dist/projects/clean-core-confirm.js +156 -0
  24. package/dist/projects/detail-path.js +9 -4
  25. package/dist/projects/extract-clean-core.js +3 -0
  26. package/dist/projects/migration.js +4 -105
  27. package/dist/projects/plan-lane.js +67 -17
  28. package/dist/projects/plan-sources.js +13 -7
  29. package/dist/projects/project-folder.js +131 -0
  30. package/dist/projects/promote-command.js +39 -0
  31. package/dist/projects/save-command.js +83 -12
  32. package/dist/projects/saved-lines.js +17 -3
  33. package/dist/projects/sort-command.js +499 -0
  34. package/dist/projects/sort-core.js +248 -0
  35. package/dist/projects/workspace-retire.js +48 -0
  36. package/dist/projects/workspace.js +182 -121
  37. package/dist/register/discover.js +901 -0
  38. package/dist/register/findings.js +5 -2
  39. package/dist/register/offer.js +100 -8
  40. package/dist/register/picture.js +6 -2
  41. package/dist/register/plan-import.js +325 -0
  42. package/dist/register/records.js +25 -0
  43. package/dist/register/status-file/estate-parts.js +168 -0
  44. package/dist/register/status-file/plan-parts.js +68 -0
  45. package/dist/register/status-file/progress-chart.js +110 -0
  46. package/dist/register/status-file/register-map.js +11 -3
  47. package/dist/register/status-file/register-model.js +40 -6
  48. package/dist/register/status-file/register-parts.js +57 -11
  49. package/dist/register/status-file/register-status-file.js +24 -6
  50. package/dist/register/status-file/team-css.js +1 -1
  51. package/dist/register/status-file-sync.js +3 -0
  52. package/dist/register/status-html.js +3 -1
  53. package/dist/register/store.js +40 -2
  54. package/dist/register/workbooks.js +27 -7
  55. package/dist/register/xlsx.js +13 -3
  56. package/dist/register/xref.js +117 -0
  57. package/dist/renderer/startup-lines.js +11 -0
  58. package/dist/renderer/tool-labels.js +2 -1
  59. package/dist/renderer/tool-widget.js +15 -1
  60. package/dist/repl/at-completer.js +6 -2
  61. package/dist/repl/at-picker.js +1 -1
  62. package/dist/repl/journey-entry.js +25 -0
  63. package/dist/repl.js +157 -39
  64. package/dist/session/backfill.js +342 -0
  65. package/dist/session/project-match.js +63 -0
  66. package/dist/session/resume.js +72 -24
  67. package/dist/session/schema.js +3 -1
  68. package/dist/session/store.js +96 -51
  69. package/dist/skill-catalog.js +2 -2
  70. package/dist/skills/bundled-skills.js +1 -1
  71. package/dist/tools/_filesystem-shared.js +174 -12
  72. package/dist/tools/_team-work-path.js +133 -0
  73. package/dist/tools/approval.js +7 -1
  74. package/dist/tools/filesystem/extract-xlsx.js +27 -3
  75. package/dist/tools/filesystem/file-edit.js +12 -4
  76. package/dist/tools/filesystem/file-read.js +8 -4
  77. package/dist/tools/filesystem/file-write.js +12 -4
  78. package/dist/tools/filesystem/glob.js +33 -8
  79. package/dist/tools/filesystem/grep.js +9 -4
  80. package/dist/tools/filesystem/read-document.js +6 -3
  81. package/dist/tools/project/cca_merge.js +8 -2
  82. package/dist/tools/project/team_estate.js +91 -0
  83. package/dist/tools/project/team_findings.js +77 -4
  84. package/dist/tools/project/upgrade_merge_progress.js +8 -2
  85. package/dist/tools/sap-read.js +21 -4
  86. package/dist/tools/sap-write.js +200 -10
  87. package/dist/tools/subagent/read_agent.js +3 -0
  88. package/dist/tools/tool-loading.js +1 -1
  89. package/dist/tools/transport-fit-check.js +117 -0
  90. package/dist/tools/transport-fit.js +15 -7
  91. package/dist/tools/transport.js +57 -2
  92. package/dist/ui/app.js +2 -2
  93. package/dist/ui/approval-modal.js +74 -6
  94. package/dist/ui/widgets/confirm-card.js +68 -5
  95. package/dist/util/open-in-browser.js +143 -0
  96. package/package.json +120 -120
@@ -7,6 +7,7 @@ import { sessionsDir, pinnedSessionsDir } from '../config/paths.js';
7
7
  import { SCHEMA_VERSION } from './schema.js';
8
8
  import { userPromptText, isUserPrompt, promptPreviewText } from './user-prompt.js';
9
9
  import { stripUnsignedThinking, dropTrailingThinkingOnly } from '../agent/repair-partial.js';
10
+ import { normDir, sessionMatchesFolder } from './project-match.js';
10
11
  export function makeSessionId() {
11
12
  return crypto.randomBytes(4).toString('hex');
12
13
  }
@@ -71,6 +72,9 @@ function validateSchema(parsed) {
71
72
  // Phase 4 chunk 4A: agentMode defaults to 'skill' for sessions written
72
73
  // before this chunk landed. Mirrors the lastUserPrompt back-compat above.
73
74
  parsed.agentMode ??= 'skill';
75
+ // 2026-10-08: sessions are listed per folder. Older files have no folder.
76
+ parsed.project_dir ??= null;
77
+ parsed.project_id ??= null;
74
78
  // Phase 1 cost tracking (2026-05-16): cache_creation_input_tokens added
75
79
  // to usage. Pre-2026-05-16 sessions don't have it; default to 0 so the
76
80
  // cost calculator can safely treat usage as a TokenCounts shape.
@@ -130,11 +134,17 @@ export const RESUME_PICKER_LIMIT = 20;
130
134
  async function recentSessionFiles(dir, limit) {
131
135
  // Merge note — Task 18 reader-* child sessions are skipped by NAME here, so a
132
136
  // burst of reader files can never crowd real sessions out of the window.
133
- const files = (await fs.readdir(dir).catch(() => []))
134
- .filter((f) => f.endsWith('.json') && !f.includes('-reader-'));
137
+ const files = await sessionFileNames(dir);
135
138
  const cap = Math.max(limit * 3, 50);
136
139
  if (files.length <= cap)
137
140
  return files;
141
+ return (await newestFirst(dir, files)).slice(0, cap);
142
+ }
143
+ async function sessionFileNames(dir) {
144
+ return (await fs.readdir(dir).catch(() => []))
145
+ .filter((f) => f.endsWith('.json') && !f.includes('-reader-'));
146
+ }
147
+ async function newestFirst(dir, files) {
138
148
  const stamped = await Promise.all(files.map(async (f) => {
139
149
  try {
140
150
  return { f, t: (await fs.stat(path.join(dir, f))).mtimeMs };
@@ -144,78 +154,113 @@ async function recentSessionFiles(dir, limit) {
144
154
  }
145
155
  }));
146
156
  stamped.sort((a, b) => b.t - a.t);
147
- return stamped.slice(0, cap).map((x) => x.f);
157
+ return stamped.map((x) => x.f);
158
+ }
159
+ /** 2026-10-08 — every session file, newest first by mtime (no cap): `--continue` / `--resume` scans. */
160
+ async function allSessionFilesNewestFirst(dir) {
161
+ return newestFirst(dir, await sessionFileNames(dir));
162
+ }
163
+ /** One picker row from a session file; null for a reader child or a corrupt file. */
164
+ async function summaryOf(file, filename) {
165
+ try {
166
+ const raw = await fs.readFile(file, 'utf-8');
167
+ const parsed = JSON.parse(raw);
168
+ if (isReaderSessionId(parsed.id))
169
+ return null; // Task 18 — internal child, never resumed
170
+ const messages = parsed.messages ?? [];
171
+ // First user message that's a plain string (not a tool_result array) — the
172
+ // most recent prompt text that initiated a turn. We pick the LAST such
173
+ // message because the picker wants the freshest turn's prompt, not the
174
+ // first one of the session.
175
+ let promptSnippet = null;
176
+ for (let i = messages.length - 1; i >= 0; i--) {
177
+ const m = messages[i];
178
+ const text = m ? userPromptText(m) : null;
179
+ if (text !== null) {
180
+ // Strip <session_context>...</session_context> wrapper that the
181
+ // first turn injects, leaving only the user-typed prompt visible
182
+ // in the picker.
183
+ // (fix round 3: every injected block, e.g. <project_context>, and
184
+ // newlines — they broke the picker's table row.)
185
+ const stripped = promptPreviewText(text);
186
+ promptSnippet = (stripped || '<empty>').slice(0, 80);
187
+ break;
188
+ }
189
+ }
190
+ const userTurnCount = messages.filter((m) => m && isUserPrompt(m)).length;
191
+ return {
192
+ id: parsed.id,
193
+ startedAt: parsed.started_at,
194
+ lastTurnAt: parsed.last_turn_at,
195
+ skill: parsed.skill,
196
+ sapSystem: parsed.sap_system,
197
+ filename,
198
+ interrupted: parsed.turnInterrupted === true,
199
+ interruptedReason: parsed.turnInterruptedReason ?? null,
200
+ promptSnippet,
201
+ userTurnCount,
202
+ projectDir: parsed.project_dir ?? null,
203
+ projectId: parsed.project_id ?? null,
204
+ };
205
+ }
206
+ catch {
207
+ return null; // skip corrupt
208
+ }
148
209
  }
210
+ /**
211
+ * The most recent sessions, newest first by last_turn_at.
212
+ * - `here`: only the sessions of that folder (sessionMatchesFolder).
213
+ * - `scanAll` (with `here`): walk every file newest-first instead of the
214
+ * startup window, stopping once `limit` matches are found
215
+ * (`--continue`, `--resume`). Without `here` the window is always used.
216
+ */
149
217
  export async function listSessions(limit = 10, opts = {}) {
150
218
  // NOTE: bypasses validateSchema() for performance — reads JSON directly via
151
219
  // JSON.parse + cast. Any new SessionState field accessed here must be
152
220
  // defaulted at the read site (e.g. `?? 'skill'`) because pre-chunk-4A
153
221
  // serialised sessions on disk lack the field.
154
222
  const dir = sessionsDir();
155
- const files = await recentSessionFiles(dir, limit);
223
+ const scanAll = !!opts.here && opts.scanAll === true;
224
+ const files = scanAll ? await allSessionFilesNewestFirst(dir) : await recentSessionFiles(dir, limit);
225
+ const hereDirNorm = opts.here ? normDir(opts.here.dir) : undefined; // once per call, not per file
156
226
  const results = [];
157
227
  for (const f of files) {
158
- try {
159
- const raw = await fs.readFile(path.join(dir, f), 'utf-8');
160
- const parsed = JSON.parse(raw);
161
- if (isReaderSessionId(parsed.id))
162
- continue; // Task 18 — internal child, never resumed
163
- const messages = parsed.messages ?? [];
164
- // First user message that's a plain string (not a tool_result array) — the
165
- // most recent prompt text that initiated a turn. We pick the LAST such
166
- // message because the picker wants the freshest turn's prompt, not the
167
- // first one of the session.
168
- let promptSnippet = null;
169
- for (let i = messages.length - 1; i >= 0; i--) {
170
- const m = messages[i];
171
- const text = m ? userPromptText(m) : null;
172
- if (text !== null) {
173
- // Strip <session_context>...</session_context> wrapper that the
174
- // first turn injects, leaving only the user-typed prompt visible
175
- // in the picker.
176
- // (fix round 3: every injected block, e.g. <project_context>, and
177
- // newlines — they broke the picker's table row.)
178
- const stripped = promptPreviewText(text);
179
- promptSnippet = (stripped || '<empty>').slice(0, 80);
180
- break;
181
- }
182
- }
183
- const userTurnCount = messages.filter((m) => m && isUserPrompt(m)).length;
184
- results.push({
185
- id: parsed.id,
186
- startedAt: parsed.started_at,
187
- lastTurnAt: parsed.last_turn_at,
188
- skill: parsed.skill,
189
- sapSystem: parsed.sap_system,
190
- filename: f,
191
- interrupted: parsed.turnInterrupted === true,
192
- interruptedReason: parsed.turnInterruptedReason ?? null,
193
- promptSnippet,
194
- userTurnCount,
195
- });
196
- }
197
- catch { /* skip corrupt */ }
228
+ const s = await summaryOf(path.join(dir, f), f);
229
+ if (!s)
230
+ continue;
231
+ if (opts.here && !sessionMatchesFolder(s, opts.here, hereDirNorm))
232
+ continue;
233
+ // Round 6 review M1 — the picker drops 0-turn sessions (unless interrupted)
234
+ // BEFORE the limit, so a pile of start-then-quit files cannot crowd out
235
+ // the sessions worth resuming.
236
+ if (opts.skipEmpty && !(s.userTurnCount > 0 || s.interrupted))
237
+ continue;
238
+ results.push(s);
239
+ if (scanAll && results.length >= limit)
240
+ break;
198
241
  }
199
242
  results.sort((a, b) => b.lastTurnAt.localeCompare(a.lastTurnAt));
200
- // Round 6 review M1 — the picker drops 0-turn sessions (unless interrupted)
201
- // BEFORE the limit, so a pile of start-then-quit files cannot crowd out
202
- // the sessions worth resuming.
203
- const kept = opts.skipEmpty ? results.filter((r) => r.userTurnCount > 0 || r.interrupted) : results;
204
- return kept.slice(0, limit);
243
+ return results.slice(0, limit);
244
+ }
245
+ /** 2026-10-08 — the latest non-empty session of this folder (`--continue`), or null. */
246
+ export async function latestSessionHere(here, opts = {}) {
247
+ const [s] = await listSessions(1, { here, skipEmpty: true, scanAll: opts.scanAll ?? false });
248
+ return s ?? null;
205
249
  }
206
250
  /**
207
251
  * Count interrupted sessions for the startup banner — among the sessions the
208
252
  * `--resume` picker can show (RESUME_PICKER_LIMIT most recent), so the
209
253
  * "! N unfinished sessions" notice never points at sessions the picker does
210
254
  * not list (fix round 3, item 11d). Returns 0 on any IO failure.
255
+ * 2026-10-08 — with `here`, only that folder's sessions count.
211
256
  */
212
- export async function countInterruptedSessions() {
257
+ export async function countInterruptedSessions(here) {
213
258
  try {
214
259
  // Round 8 item 7 — a turn the USER stopped (Ctrl+C, /exit mid-turn) is
215
260
  // not unfinished work: counting it kept "! 1 unfinished session" on every
216
261
  // start until that session happened to finish a turn. The picker still
217
262
  // lists it as "Stopped by you".
218
- return (await listSessions(RESUME_PICKER_LIMIT))
263
+ return (await listSessions(RESUME_PICKER_LIMIT, here ? { here } : {}))
219
264
  .filter((s) => s.interrupted && !isUserStopReason(s.interruptedReason)).length;
220
265
  }
221
266
  catch {
@@ -168,8 +168,8 @@ export const SKILL_CATALOG = [
168
168
  {
169
169
  name: 'abap-cca',
170
170
  category: 'Upgrade / Migrate',
171
- description: 'Custom code analysis pipeline for S/4 upgrade — whole-estate inventory + tier classification + ATC findings + report. Multi-session project with state file.',
172
- whenToUse: `pick this to discover + classify the whole custom estate (not /abap-migrate — that's per object)`,
171
+ description: 'Reads the estate inventory the lead pulled; proposes scope, areas, retire candidates and next commands; read-only.',
172
+ whenToUse: `pick this after cspeach team discover, to choose packages, lanes and a first focus (not /abap-migrate — that's per object)`,
173
173
  asks: 'which package or namespace? e.g. "Z*" or "package ZTEST_LAS"',
174
174
  },
175
175
  {
@@ -1,6 +1,6 @@
1
1
  // GENERATED FILE — bundled skills for local mode.
2
2
  // Source: https://manifest.cspeach.dev/v1.json
3
- // Generated: 2026-10-06T20:59:01.468Z
3
+ // Generated: 2026-10-09T00:51:14.599Z
4
4
  // Manifest signature verified at build time.
5
5
  export const BUNDLED_SKILLS = {
6
6
  "abap-app": {
@@ -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 = [];