@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
@@ -0,0 +1,131 @@
1
+ // src/projects/project-folder.ts
2
+ /**
3
+ * The ONE rule for where a person's project files go (spec 2026-10-08 §3.1).
4
+ *
5
+ * solo → <cwd>/.cspeach (the folder you start in)
6
+ * team → <CSPEACH_HOME>/work/<projectId>/.cspeach (private to this seat; the
7
+ * shared team files stay in the team folder, untouched)
8
+ * home → ~/CSPeach/.cspeach (never the CLI home itself)
9
+ *
10
+ * No git walk. `workspace_folder` is ignored. Markers describe the folder;
11
+ * they never move the save location to another folder the person can see.
12
+ */
13
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, renameSync, realpathSync } from 'node:fs';
14
+ import { randomBytes } from 'node:crypto';
15
+ import os from 'node:os';
16
+ import path from 'node:path';
17
+ import toml from '@iarna/toml';
18
+ import { cspeachRoot, configFile } from '../config/paths.js';
19
+ export const SAFE_PROJECT_ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
20
+ const TEAM_FILE = 'cspeach-team.json';
21
+ const ID_FILE = 'project.json';
22
+ const real = (p) => { try {
23
+ return realpathSync.native(p);
24
+ }
25
+ catch {
26
+ return path.resolve(p);
27
+ } };
28
+ const key = (p) => { const r = real(p).replace(/[\\/]+$/, ''); return process.platform === 'win32' ? r.toLowerCase() : r; };
29
+ /** The team project id when `cwd` holds a valid cspeach-team.json, else null. */
30
+ export function teamProjectId(cwd) {
31
+ try {
32
+ const raw = readFileSync(path.join(cwd, TEAM_FILE), 'utf8');
33
+ const p = JSON.parse(raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw);
34
+ if (p?.kind !== 'team-project' || typeof p.projectId !== 'string')
35
+ return null;
36
+ return SAFE_PROJECT_ID.test(p.projectId) ? p.projectId : null;
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
42
+ export function readSoloProjectId(cwd) {
43
+ try {
44
+ const p = JSON.parse(readFileSync(path.join(cwd, '.cspeach', ID_FILE), 'utf8'));
45
+ return typeof p?.id === 'string' && /^[0-9a-f]{8}$/.test(p.id) ? p.id : null;
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ export function resolveProjectFolderSync(cwd) {
52
+ const projectDir = path.resolve(cwd);
53
+ const teamId = existsSync(path.join(projectDir, TEAM_FILE)) ? teamProjectId(projectDir) : null;
54
+ if (teamId) {
55
+ const workRoot = path.join(cspeachRoot(), 'work', teamId);
56
+ return { kind: 'team', projectDir, workRoot, workDir: path.join(workRoot, '.cspeach'), projectId: teamId };
57
+ }
58
+ const isHome = key(projectDir) === key(os.homedir()) || key(path.join(projectDir, '.cspeach')) === key(cspeachRoot());
59
+ if (isHome) {
60
+ const workRoot = path.join(os.homedir(), 'CSPeach');
61
+ return { kind: 'home', projectDir, workRoot, workDir: path.join(workRoot, '.cspeach'), projectId: readSoloProjectId(workRoot) };
62
+ }
63
+ return { kind: 'solo', projectDir, workRoot: projectDir, workDir: path.join(projectDir, '.cspeach'), projectId: readSoloProjectId(projectDir) };
64
+ }
65
+ export async function resolveProjectFolder(cwd) {
66
+ return resolveProjectFolderSync(cwd);
67
+ }
68
+ /** Create `<workDir>/project.json` on first use (tmp + rename); return the id. */
69
+ export function ensureProjectId(workDir, now = () => new Date(), rand = (n) => randomBytes(n).toString('hex')) {
70
+ const file = path.join(workDir, ID_FILE);
71
+ const existing = readSoloProjectId(path.dirname(workDir));
72
+ if (existing)
73
+ return existing;
74
+ mkdirSync(workDir, { recursive: true });
75
+ const id = rand(4);
76
+ writeFileSync(`${file}.tmp`, `${JSON.stringify({ id, createdAt: now().toISOString() }, null, 2)}\n`);
77
+ renameSync(`${file}.tmp`, file);
78
+ return id;
79
+ }
80
+ /** The pre-1.1.31 git-root walk — kept ONLY to find old files (read-only, this release). */
81
+ function oldGitRoot(startDir) {
82
+ let dir = startDir;
83
+ for (let i = 0; i < 200; i++) {
84
+ if (existsSync(path.join(dir, '.git')))
85
+ return dir;
86
+ const parent = path.dirname(dir);
87
+ if (parent === dir)
88
+ return null;
89
+ dir = parent;
90
+ }
91
+ return null;
92
+ }
93
+ const holdsEnvelopes = (dir) => {
94
+ try {
95
+ return readdirSync(dir).some((n) => n.endsWith('.cspeach.json'));
96
+ }
97
+ catch {
98
+ return false;
99
+ }
100
+ };
101
+ function oldWorkspaceFolder() {
102
+ try {
103
+ const parsed = toml.parse(readFileSync(configFile(), 'utf8'));
104
+ const v = parsed.workspace_folder?.trim();
105
+ if (!v)
106
+ return null;
107
+ return v === '~' || v.startsWith('~/') ? path.join(os.homedir(), v.slice(v === '~' ? 1 : 2)) : path.resolve(v);
108
+ }
109
+ catch {
110
+ return null;
111
+ }
112
+ }
113
+ /** Old save locations that still hold project files. Read-only use only. */
114
+ export function legacyDirsFor(cwd) {
115
+ const pf = resolveProjectFolderSync(cwd);
116
+ const mine = key(pf.workDir);
117
+ const out = [];
118
+ const add = (d) => {
119
+ if (d && key(d) !== mine && holdsEnvelopes(d) && !out.some((o) => key(o) === key(d)))
120
+ out.push(d);
121
+ };
122
+ // A team folder's saves before this release went to <team>/.cspeach.
123
+ if (pf.kind === 'team')
124
+ add(path.join(pf.projectDir, '.cspeach'));
125
+ const g = oldGitRoot(pf.projectDir);
126
+ if (g && key(g) !== key(pf.projectDir))
127
+ add(path.join(g, '.cspeach'));
128
+ add(oldWorkspaceFolder());
129
+ add(cspeachRoot());
130
+ return out;
131
+ }
@@ -1,7 +1,42 @@
1
+ import { realpathSync } from 'node:fs';
1
2
  import { readProjectFile } from './status.js';
2
3
  import { buildPromotedFromSnapshot, buildReviewDecisionsBlock, buildDetailPathHint, validatePromotionSource } from './promote.js';
3
4
  import { resolveAtTokenAsync, formatProjectFileList } from './workspace.js';
4
5
  import { askYesNo } from './yes-no.js';
6
+ import path from 'node:path';
7
+ import { cspeachRoot } from '../config/paths.js';
8
+ import { isCliHomePath, isSeatReadOnlyFile } from '../tools/_filesystem-shared.js';
9
+ /**
10
+ * Task 14 — a `--from` source inside the CLI home is read only in an explicit
11
+ * shape: the read-only seat shape (`<seat>/work/<id>/.cspeach/<name>.cspeach.json`)
12
+ * or an envelope directly in the CLI home (the earlier save location this
13
+ * release still lists). Config, keys, sessions, snapshots and every other
14
+ * file there stay out of reach. Spelled and real paths are both judged.
15
+ */
16
+ function cliHomeSourceAllowed(abs) {
17
+ if (!isCliHomePath(abs))
18
+ return true;
19
+ if (isSeatReadOnlyFile(abs))
20
+ return true;
21
+ return directHomeEnvelope(abs) && directHomeEnvelope(realOr(abs));
22
+ }
23
+ function directHomeEnvelope(p) {
24
+ if (p === null)
25
+ return false;
26
+ const base = path.basename(p);
27
+ if (!/^[^:~]+\.cspeach\.json$/i.test(base) || /[. ]$/.test(base))
28
+ return false;
29
+ const k = (x) => (process.platform === 'win32' ? x.toLowerCase() : x);
30
+ return [cspeachRoot(), realOr(cspeachRoot())].some((h) => h !== null && k(path.dirname(p)) === k(h));
31
+ }
32
+ function realOr(p) {
33
+ try {
34
+ return realpathSync.native(p);
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ }
5
40
  /**
6
41
  * Per-skill predecessor map. v0.7 admits multi-source via array values —
7
42
  * e.g. /abap-upgrade-fix accepts BOTH upgrade-scan (the existing chain
@@ -66,6 +101,10 @@ export async function runPromoteCommand(args) {
66
101
  args.log(`Use a more specific @<fragment> to disambiguate.`);
67
102
  return null;
68
103
  }
104
+ if (!cliHomeSourceAllowed(path.resolve(args.cwd ?? process.cwd(), resolvedPath))) {
105
+ args.log(`--from: ${resolvedPath} is inside CSPeach's own folder and cannot be read.`);
106
+ return null;
107
+ }
69
108
  let env;
70
109
  try {
71
110
  env = readProjectFile(resolvedPath);
@@ -18,6 +18,7 @@ import { saveProject } from './save.js';
18
18
  import { formatSavedLines } from './saved-lines.js';
19
19
  import { writePlanHandover } from './handover-md.js';
20
20
  import { ensureWorkspace, resolveWorkspacePath } from './workspace.js';
21
+ import { resolveProjectFolderSync, ensureProjectId } from './project-folder.js';
21
22
  import { askYesNo, isYes } from './yes-no.js';
22
23
  import { checkLaneConsistency, checkRequiredDeploy, LANE_CHANGED_AT_CREATE, readPlanLane } from './plan-lane.js';
23
24
  import { specGapRefForSave } from './plan-sources.js';
@@ -384,19 +385,57 @@ export async function runSaveCommand(args) {
384
385
  // run confirms no row. Any further question routed through `prompt` would
385
386
  // silently receive 'y' too: give maybeOfferSave's headless prompt a
386
387
  // question-aware answer first.
387
- const answer = await askYesNo({ question: 'Save as project file?', confirm: args.confirm, prompt: args.prompt });
388
+ // Inside a journey a triage is saved without the question: the journey needs the rows.
389
+ const journey = spec.artefactType === 'clean-core-decision' ? args.journey : undefined;
390
+ const answer = journey ? 'y' : await askYesNo({ question: 'Save as project file?', confirm: args.confirm, prompt: args.prompt });
388
391
  if (!isYes(answer)) {
389
392
  args.onNotSaved?.('declined');
390
393
  return null;
391
394
  }
392
395
  // 2026-10-06 — a clean-core triage becomes team project decisions only row by
393
396
  // row, each on its own Yes. Without a joined team project nothing is asked.
397
+ let tally;
394
398
  if (spec.artefactType === 'clean-core-decision') {
395
- const { confirmCleanCoreRows } = await import('./clean-core-confirm.js');
399
+ const { confirmCleanCoreRows, decideCleanCoreRows } = await import('./clean-core-confirm.js');
396
400
  const { activeProject } = await import('../register/store.js');
397
- extract = await confirmCleanCoreRows(extract, {
398
- ...(args.confirm ? { confirm: args.confirm } : {}), log: args.log, joined: activeProject() !== null,
399
- });
401
+ const project = activeProject();
402
+ const joined = project !== null;
403
+ // Step 2 (D6): the rule that decides each row, said on its card. Never blocks a save: unreadable → no rule line.
404
+ let rules;
405
+ if (project) {
406
+ try {
407
+ const { ruleProposalsFor } = await import('../register/decision-rules.js');
408
+ const r = ruleProposalsFor(project.projectDir, project.outboxDir);
409
+ rules = r.lookup;
410
+ // Inside a journey no file name is said (Task 7); the triage tool carries the warning to the model.
411
+ if (r.warning && !journey)
412
+ args.log(r.warning);
413
+ }
414
+ catch {
415
+ // No rule can be read: every row is CSPeach's judgement, so a "Rule <id>:" the model wrote is taken off (review 9 #2).
416
+ rules = () => null;
417
+ }
418
+ }
419
+ if (journey && joined) {
420
+ // Guided journey: [Yes] [Keep as is] [Skip] per row.
421
+ const r = await decideCleanCoreRows(extract, {
422
+ card: journey.card, ask: journey.ask, skip: journey.skip, log: args.log, ...(rules ? { rules } : {}),
423
+ });
424
+ extract = r.extract;
425
+ tally = r.tally;
426
+ }
427
+ else {
428
+ if (journey) {
429
+ // Inside a journey: no team words, no command to type (Task 7).
430
+ const { JOURNEY_NOT_IN_PROJECT_LINE } = await import('./clean-core-confirm.js');
431
+ args.log(JOURNEY_NOT_IN_PROJECT_LINE);
432
+ }
433
+ else {
434
+ extract = await confirmCleanCoreRows(extract, {
435
+ ...(args.confirm ? { confirm: args.confirm } : {}), log: args.log, joined, ...(rules ? { rules } : {}),
436
+ });
437
+ }
438
+ }
400
439
  }
401
440
  let env;
402
441
  if (planExtract && prior) {
@@ -436,6 +475,17 @@ export async function runSaveCommand(args) {
436
475
  let outDir;
437
476
  try {
438
477
  outDir = await ensureWorkspace();
478
+ // 2026-10-08 — the first save writes .cspeach/project.json (solo/home);
479
+ // a team folder uses its team id. Never fails the save.
480
+ try {
481
+ const pf = resolveProjectFolderSync(process.cwd());
482
+ const projectId = pf.kind === 'team' ? pf.projectId : ensureProjectId(pf.workDir);
483
+ try {
484
+ args.onProjectId?.(projectId);
485
+ }
486
+ catch { /* tagging is best-effort */ }
487
+ }
488
+ catch { /* id file is best-effort */ }
439
489
  }
440
490
  catch {
441
491
  outDir = args.cwd;
@@ -450,7 +500,9 @@ export async function runSaveCommand(args) {
450
500
  writePlanHandover(path, args.log);
451
501
  // 1.1.28 — two plain lines; the old suggested-email block is gone (saved-lines.ts).
452
502
  const [savedLine, reviewLine] = formatSavedLines(path, args.cwd);
453
- args.log(savedLine);
503
+ // Inside a journey the file is plumbing: the closing lines say what was recorded.
504
+ if (!journey)
505
+ args.log(savedLine);
454
506
  // Project Register (2026-09-18): when this laptop has joined a project, a
455
507
  // decision / fix record is written to the LOCAL outbox as a by-product of the
456
508
  // save. No project joined → `activeProject()` is one missing-file read and
@@ -460,27 +512,63 @@ export async function runSaveCommand(args) {
460
512
  const joined = activeProject();
461
513
  if (joined) {
462
514
  const { recordFromEnvelope } = await import('../register/records.js');
463
- const res = recordFromEnvelope(env, path, joined, { name: joined.name ?? args.author.name }, args.cwd);
464
- if (res?.file)
515
+ // Task 15 — detail files are read from where the skill wrote them: the
516
+ // seat work root in a team folder, the start folder otherwise. The
517
+ // record itself still goes to the shared outbox.
518
+ const res = recordFromEnvelope(env, path, joined, { name: joined.name ?? args.author.name }, resolveProjectFolderSync(args.cwd).workRoot);
519
+ if (tally) {
520
+ const { journeyRowLines } = await import('./clean-core-confirm.js');
521
+ for (const line of journeyRowLines(tally, res?.refused ?? []))
522
+ args.log(line);
523
+ }
524
+ else if (res?.file)
465
525
  args.log(`Recorded for team project ${joined.projectId}: ${res.written} objects → ${basename(res.file)}`);
466
526
  if (res?.file) {
467
527
  const { regenerateWorkingList } = await import('../register/workbooks.js');
468
528
  // The name the team project knows this laptop by (init --lead), as the team verbs use.
469
529
  const sheet = regenerateWorkingList(joined, joined.name ?? args.author.name);
470
- if (sheet.line)
530
+ if (journey) {
531
+ const { journeyListLine } = await import('./clean-core-confirm.js');
532
+ const line = journeyListLine(sheet.kind);
533
+ if (line)
534
+ args.log(line);
535
+ }
536
+ else if (sheet.line)
471
537
  args.log(sheet.line.trimStart());
472
538
  }
473
539
  if (res?.refused.length) {
474
- args.log(`Not recorded (${res.refused.length} not in the team project's object list): ${res.refused.slice(0, 5).join(', ')}${res.refused.length > 5 ? ', …' : ''}`);
540
+ args.log(`Not recorded (${res.refused.length} not in ${tally ? 'your project' : 'the team project'}'s object list): ${res.refused.slice(0, 5).join(', ')}${res.refused.length > 5 ? ', …' : ''}`);
475
541
  }
476
542
  }
477
543
  }
478
544
  catch (e) {
479
- args.log(`(team project record not written: ${e instanceof Error ? e.message : String(e)})`);
545
+ if (journey) {
546
+ const { notRecordedLine } = await import('../journey/run.js');
547
+ args.log(notRecordedLine(e));
548
+ }
549
+ else
550
+ args.log(`(team project record not written: ${e instanceof Error ? e.message : String(e)})`);
480
551
  }
481
552
  if (planExtract && prior) {
482
553
  args.log(`(revision v${env.version} of the existing plan — version chain preserved, no new id)`);
483
554
  }
484
- args.log(reviewLine);
555
+ if (!journey)
556
+ args.log(reviewLine);
557
+ if (journey && tally) {
558
+ if (tally.cancelled) {
559
+ journey.pause();
560
+ }
561
+ else if (journey.page?.length) {
562
+ // One triage per page: a row of the page the triage left out is put off
563
+ // until "carry on", so the journey moves on instead of asking it again — and says so.
564
+ const answered = new Set([...tally.yes, ...tally.kept.map((k) => k.key), ...tally.skipped]);
565
+ const left = journey.page.filter((k) => !answered.has(k.toUpperCase()));
566
+ if (left.length) {
567
+ journey.leaveForLater(left);
568
+ const { leftOutLine } = await import('./clean-core-confirm.js');
569
+ args.log(leftOutLine(left));
570
+ }
571
+ }
572
+ }
485
573
  return path;
486
574
  }
@@ -10,7 +10,8 @@
10
10
  *
11
11
  * Never prints a user name.
12
12
  */
13
- import { basename, dirname, join, resolve } from 'node:path';
13
+ import { basename, dirname, resolve } from 'node:path';
14
+ import { resolveProjectFolderSync } from './project-folder.js';
14
15
  function editorLabel() {
15
16
  const url = process.env.CSPEACH_EDITOR_URL?.trim();
16
17
  return url ? url : 'cspeach.dev/edit';
@@ -22,10 +23,23 @@ function sameDir(a, b) {
22
23
  };
23
24
  return norm(a) === norm(b);
24
25
  }
25
- /** `<file name> (in this project's .cspeach folder)` — or the folder, when elsewhere. */
26
+ /**
27
+ * `<file name> (in <where>)` — names the folder kind (2026-10-08): this
28
+ * project's .cspeach folder (solo), your own working folder (team), or the
29
+ * home fallback folder with a hint. Anywhere else: the folder itself.
30
+ */
26
31
  export function savedLocation(savedPath, cwd) {
27
32
  const dir = dirname(savedPath);
28
- const where = sameDir(dir, join(cwd, '.cspeach')) ? "this project's .cspeach folder" : dir;
33
+ const pf = resolveProjectFolderSync(cwd);
34
+ let where = dir;
35
+ if (sameDir(dir, pf.workDir)) {
36
+ if (pf.kind === 'solo')
37
+ where = "this project's .cspeach folder";
38
+ else if (pf.kind === 'team')
39
+ where = 'your own working folder for this team project, not shared';
40
+ else
41
+ where = `${dir} — start CSPeach in a project folder to keep projects apart`;
42
+ }
29
43
  return `${basename(savedPath)} (in ${where})`;
30
44
  }
31
45
  export function formatSavedLines(savedPath, cwd) {