@cspeach/cli 1.1.29 → 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 (105) hide show
  1. package/.cspeach/project.json +4 -0
  2. package/README.md +11 -1
  3. package/dist/agent/loop.js +127 -11
  4. package/dist/auth/auth-file.js +2 -2
  5. package/dist/cli.js +39 -13
  6. package/dist/commands/config-set.js +1 -1
  7. package/dist/commands/login.js +1 -1
  8. package/dist/commands/logout.js +1 -1
  9. package/dist/commands/plan-continue.js +1 -1
  10. package/dist/commands/plan-gate.js +8 -1
  11. package/dist/commands/plan-resume.js +24 -18
  12. package/dist/commands/plan-startup.js +49 -6
  13. package/dist/commands/team-discover.js +195 -0
  14. package/dist/commands/team-plan.js +585 -0
  15. package/dist/commands/team.js +652 -46
  16. package/dist/commands/whoami.js +1 -1
  17. package/dist/config/paths.js +15 -1
  18. package/dist/journey/chain.js +728 -0
  19. package/dist/journey/clean-core.js +213 -0
  20. package/dist/journey/flow.js +166 -0
  21. package/dist/journey/run.js +435 -0
  22. package/dist/journey/session-deps.js +77 -0
  23. package/dist/journey/trigger.js +153 -0
  24. package/dist/lane/app-chain.js +9 -7
  25. package/dist/lane/chain-token.js +9 -5
  26. package/dist/lane/roots.js +97 -41
  27. package/dist/one-shot.js +21 -1
  28. package/dist/projects/clean-core-confirm.js +192 -0
  29. package/dist/projects/detail-path.js +9 -4
  30. package/dist/projects/extract-clean-core.js +82 -0
  31. package/dist/projects/migration.js +4 -105
  32. package/dist/projects/plan-lane.js +67 -17
  33. package/dist/projects/plan-sources.js +13 -7
  34. package/dist/projects/project-folder.js +131 -0
  35. package/dist/projects/promote-command.js +39 -0
  36. package/dist/projects/save-command.js +115 -15
  37. package/dist/projects/saved-lines.js +17 -3
  38. package/dist/projects/sort-command.js +499 -0
  39. package/dist/projects/sort-core.js +248 -0
  40. package/dist/projects/validate.js +2 -1
  41. package/dist/projects/workspace-retire.js +48 -0
  42. package/dist/projects/workspace.js +182 -120
  43. package/dist/register/atc-file.js +9 -1
  44. package/dist/register/discover.js +901 -0
  45. package/dist/register/findings.js +82 -0
  46. package/dist/register/measure.js +60 -1
  47. package/dist/register/offer.js +125 -0
  48. package/dist/register/picture.js +25 -0
  49. package/dist/register/plan-import.js +325 -0
  50. package/dist/register/records.js +57 -0
  51. package/dist/register/status-file/estate-parts.js +168 -0
  52. package/dist/register/status-file/plan-parts.js +68 -0
  53. package/dist/register/status-file/progress-chart.js +110 -0
  54. package/dist/register/status-file/register-map.js +221 -0
  55. package/dist/register/status-file/register-model.js +341 -0
  56. package/dist/register/status-file/register-parts.js +555 -0
  57. package/dist/register/status-file/register-status-file.js +189 -0
  58. package/dist/register/status-file/team-css.js +5 -0
  59. package/dist/register/status-file-sync.js +22 -0
  60. package/dist/register/status-html.js +19 -0
  61. package/dist/register/store.js +40 -2
  62. package/dist/register/workbooks.js +124 -0
  63. package/dist/register/write-whole.js +22 -0
  64. package/dist/register/xlsx.js +127 -0
  65. package/dist/register/xref.js +117 -0
  66. package/dist/renderer/startup-lines.js +11 -0
  67. package/dist/renderer/tool-labels.js +3 -1
  68. package/dist/renderer/tool-widget.js +15 -1
  69. package/dist/repl/at-completer.js +6 -2
  70. package/dist/repl/at-picker.js +1 -1
  71. package/dist/repl/journey-entry.js +25 -0
  72. package/dist/repl.js +158 -39
  73. package/dist/session/backfill.js +342 -0
  74. package/dist/session/project-match.js +63 -0
  75. package/dist/session/resume.js +72 -24
  76. package/dist/session/schema.js +3 -1
  77. package/dist/session/store.js +96 -51
  78. package/dist/skill-catalog.js +2 -2
  79. package/dist/skills/bundled-skills.js +45 -45
  80. package/dist/tools/_filesystem-shared.js +190 -21
  81. package/dist/tools/_team-work-path.js +133 -0
  82. package/dist/tools/approval.js +7 -1
  83. package/dist/tools/filesystem/extract-xlsx.js +27 -3
  84. package/dist/tools/filesystem/file-edit.js +12 -4
  85. package/dist/tools/filesystem/file-read.js +8 -4
  86. package/dist/tools/filesystem/file-write.js +12 -4
  87. package/dist/tools/filesystem/glob.js +33 -8
  88. package/dist/tools/filesystem/grep.js +9 -4
  89. package/dist/tools/filesystem/read-document.js +6 -3
  90. package/dist/tools/project/cca_merge.js +8 -2
  91. package/dist/tools/project/team_estate.js +91 -0
  92. package/dist/tools/project/team_findings.js +145 -0
  93. package/dist/tools/project/upgrade_merge_progress.js +8 -2
  94. package/dist/tools/sap-read.js +21 -4
  95. package/dist/tools/sap-write.js +203 -5
  96. package/dist/tools/subagent/read_agent.js +3 -0
  97. package/dist/tools/tool-loading.js +2 -2
  98. package/dist/tools/transport-fit-check.js +117 -0
  99. package/dist/tools/transport-fit.js +15 -7
  100. package/dist/tools/transport.js +57 -2
  101. package/dist/ui/app.js +2 -2
  102. package/dist/ui/approval-modal.js +74 -6
  103. package/dist/ui/widgets/confirm-card.js +68 -5
  104. package/dist/util/open-in-browser.js +143 -0
  105. package/package.json +120 -120
@@ -1,115 +1,14 @@
1
1
  /**
2
- * One-time workspace-default migration prompt.
2
+ * One-time `.abapforge/` → `.cspeach/` output-namespace migration offer.
3
3
  *
4
- * Why this exists: until 2026-05-07 the default workspace was `~/CSPeach/`
5
- * — a single global folder per user. As of 2026-05-08 the default is
6
- * project-rooted (`<git-root>/.cspeach/`), so a consultant working on
7
- * customer A and customer B no longer has both customers' artefacts
8
- * piling into one folder.
9
- *
10
- * For users with files already in `~/CSPeach/`, the switch needs to ask
11
- * once: keep the old global, move into the new project, or leave both
12
- * alone. The answer is recorded in `config.migration_prompted`; the
13
- * prompt fires exactly once.
14
- *
15
- * Three branches:
16
- * - "move": renameSync every artefact from ~/CSPeach/ into the
17
- * resolved project workspace. Old folder cleaned.
18
- * - "keep_global": set workspace_folder = '~/CSPeach', preserving the
19
- * pre-2026-05-08 behaviour for users who liked it.
20
- * - "leave": do nothing — old folder stays as-is, new artefacts
21
- * go to the project workspace. User can manually
22
- * copy whatever they want later.
23
- *
24
- * The prompt is interactive (via the supplied async prompt callback);
25
- * one-shot mode skips it entirely (caller passes a no-op).
4
+ * The old first-run workspace prompt (2026-05-08, which could pin
5
+ * `workspace_folder`) was removed 2026-10-08: files now save in the folder
6
+ * you start in. See workspace-retire.ts for the offer that retires the key.
26
7
  */
27
8
  import { existsSync, mkdirSync, readdirSync, renameSync, rmdirSync, statSync } from 'node:fs';
28
9
  import { join } from 'node:path';
29
- import os from 'node:os';
30
10
  import { loadConfig, saveConfig } from '../config/loader.js';
31
- import { defaultWorkspaceFor } from './workspace.js';
32
11
  import { OUTPUT_ROOT, LEGACY_ROOT } from './output-paths.js';
33
- /**
34
- * Run the migration prompt if and only if it hasn't run before AND
35
- * there's actually something to migrate. No-op otherwise. Always sets
36
- * `migration_prompted: true` after running so the next invocation
37
- * skips this whole module.
38
- */
39
- export async function maybePromptWorkspaceMigration(deps) {
40
- const cfg = await loadConfig();
41
- if (cfg.migration_prompted === true)
42
- return;
43
- // If user already pinned workspace_folder, they've already chosen —
44
- // mark prompted, skip the dialog. The pin survives.
45
- if (cfg.workspace_folder !== undefined && cfg.workspace_folder.trim().length > 0) {
46
- await saveConfig({ ...cfg, migration_prompted: true });
47
- return;
48
- }
49
- const oldWorkspace = join(os.homedir(), 'CSPeach');
50
- if (!existsSync(oldWorkspace)) {
51
- await saveConfig({ ...cfg, migration_prompted: true });
52
- return;
53
- }
54
- let oldFiles;
55
- try {
56
- oldFiles = readdirSync(oldWorkspace).filter((f) => f.endsWith('.cspeach.json') || f.endsWith('.txt') || f.endsWith('.md'));
57
- }
58
- catch {
59
- await saveConfig({ ...cfg, migration_prompted: true });
60
- return;
61
- }
62
- if (oldFiles.length === 0) {
63
- await saveConfig({ ...cfg, migration_prompted: true });
64
- return;
65
- }
66
- const newWorkspace = defaultWorkspaceFor(process.cwd());
67
- let choice;
68
- try {
69
- choice = await deps.prompt({
70
- fileCount: oldFiles.length,
71
- oldWorkspace,
72
- newWorkspace,
73
- });
74
- }
75
- catch {
76
- // User Ctrl+C'd the prompt — leave migration_prompted unset so the
77
- // dialog re-fires next session. Better than silently committing to
78
- // a default they never saw.
79
- return;
80
- }
81
- if (choice === 'move') {
82
- try {
83
- mkdirSync(newWorkspace, { recursive: true });
84
- for (const f of oldFiles) {
85
- const src = join(oldWorkspace, f);
86
- const dst = join(newWorkspace, f);
87
- if (existsSync(dst)) {
88
- deps.log(`(skipping ${f} — already exists in new workspace)`);
89
- continue;
90
- }
91
- renameSync(src, dst);
92
- }
93
- deps.log(`Moved ${oldFiles.length} file(s) → ${newWorkspace}`);
94
- }
95
- catch (err) {
96
- deps.log(`Migration failed: ${err.message}. Old folder left untouched.`);
97
- }
98
- }
99
- else if (choice === 'keep_global') {
100
- await saveConfig({
101
- ...cfg,
102
- workspace_folder: oldWorkspace,
103
- migration_prompted: true,
104
- });
105
- deps.log(`Workspace pinned to ${oldWorkspace}.`);
106
- return; // saveConfig already done
107
- }
108
- else {
109
- deps.log(`Leaving ${oldWorkspace} alone. New artefacts go to ${newWorkspace}.`);
110
- }
111
- await saveConfig({ ...cfg, migration_prompted: true });
112
- }
113
12
  /**
114
13
  * Recursively move every entry under `srcDir` into `dstDir`, MERGING into
115
14
  * existing directories rather than clobbering them. Files that already exist
@@ -10,10 +10,12 @@
10
10
  // (Review Focus 4).
11
11
  // - renderLaneBlock is the <lane_decision> context block for the plan-creation
12
12
  // turn and every resumed phase. It never carries the file path.
13
+ import { promises as fs } from 'node:fs';
14
+ import path from 'node:path';
13
15
  import { cardLine } from '../lane/card.js';
14
16
  import { canonicalLanePath } from '../lane/paths.js';
15
17
  import { getAppFlow } from '../lane/app-flow.js';
16
- import { laneRootFor } from '../lane/roots.js';
18
+ import { cliHomeRefusal, laneRootFor } from '../lane/roots.js';
17
19
  import { LaneShaMismatchError, readPinnedLaneDecision } from '../lane/store.js';
18
20
  /** Shown on the plan-creation turn (and at save) when the file no longer matches the card. */
19
21
  export const LANE_CHANGED_AT_CREATE = 'The lane decision file changed after you confirmed it. Run /abap-app again.';
@@ -37,24 +39,72 @@ export function validateLaneRecord(rec) {
37
39
  return null;
38
40
  }
39
41
  /**
40
- * Pinned read + re-validation. Never throws. `cwd` is the caller's folder;
41
- * the file is read relative to its lane root (I3: the folder that holds the
42
- * workspace `.cspeach`), the same root the lane card wrote it under.
42
+ * 2026-10-08 — the folder that holds the plan file's `.cspeach`: the root its
43
+ * relative pins (`from.lane.path`, `from.specGap.path`) were written against.
44
+ * Undefined when there is no plan path, or the plan does not sit directly in
45
+ * a `.cspeach` folder (then it adds no root). Task 14 — also undefined when
46
+ * that folder is the home folder, holds a CLI home as its `.cspeach`, or sits
47
+ * in a CLI home (`cspeachRoot()` or `~/.cspeach`), real paths compared —
48
+ * except exactly the seat working root `<cspeachRoot()>/work/<id>`
49
+ * (cliHomeRefusal, the lane root rule).
43
50
  */
44
- export async function readPlanLane(cwd, ref) {
45
- let rec;
51
+ export function planRootOf(planPath) {
52
+ if (!planPath)
53
+ return undefined;
54
+ const dir = path.dirname(path.resolve(planPath));
55
+ if (path.basename(dir).toLowerCase() !== '.cspeach')
56
+ return undefined;
57
+ const root = path.dirname(dir);
58
+ return cliHomeRefusal(root) === null ? root : undefined;
59
+ }
60
+ /** Candidate roots for a plan's pins: the plan's own root first, then the current rule. */
61
+ export async function planPinRoots(cwd, planPath) {
62
+ const roots = [planRootOf(planPath), await laneRootFor(cwd)];
63
+ return roots.filter((r, i, a) => !!r && a.indexOf(r) === i);
64
+ }
65
+ /**
66
+ * Pinned read + re-validation. Never throws. `cwd` is the caller's folder.
67
+ * The file is read relative to the plan file's own root (`planPath`, the
68
+ * folder holding its `.cspeach`) first, then the lane root for cwd (the
69
+ * folder that holds the working `.cspeach`). The first root that HAS the file
70
+ * decides: a changed (sha mismatch) or refused file there is final — never
71
+ * fall through to another root past it.
72
+ */
73
+ export async function readPlanLane(cwd, ref, planPath) {
74
+ const rel = canonicalLanePath(ref.path);
75
+ const roots = await planPinRoots(cwd, planPath);
76
+ let lastErr;
77
+ for (let i = 0; i < roots.length; i++) {
78
+ const root = roots[i];
79
+ const isLast = i === roots.length - 1;
80
+ // Only a root without the file is skipped; any other outcome there is final.
81
+ if (!isLast && !(await entryExists(path.resolve(root, rel))))
82
+ continue;
83
+ let rec;
84
+ try {
85
+ rec = await readPinnedLaneDecision(root, rel, ref.sha256);
86
+ }
87
+ catch (e) {
88
+ if (e instanceof LaneShaMismatchError)
89
+ return { ok: false, mismatch: true, message: e.message };
90
+ lastErr = e;
91
+ break;
92
+ }
93
+ const bad = validateLaneRecord(rec);
94
+ if (bad)
95
+ return { ok: false, mismatch: false, message: `The lane decision file ${cardLine(rel)} is not valid: ${bad}` };
96
+ return { ok: true, rec };
97
+ }
98
+ return { ok: false, mismatch: false, message: cardLine(lastErr instanceof Error ? lastErr.message : String(lastErr)) };
99
+ }
100
+ async function entryExists(abs) {
46
101
  try {
47
- rec = await readPinnedLaneDecision(await laneRootFor(cwd), canonicalLanePath(ref.path), ref.sha256);
102
+ await fs.lstat(abs);
103
+ return true;
48
104
  }
49
- catch (e) {
50
- if (e instanceof LaneShaMismatchError)
51
- return { ok: false, mismatch: true, message: e.message };
52
- return { ok: false, mismatch: false, message: cardLine(e instanceof Error ? e.message : String(e)) };
105
+ catch {
106
+ return false;
53
107
  }
54
- const bad = validateLaneRecord(rec);
55
- if (bad)
56
- return { ok: false, mismatch: false, message: `The lane decision file ${cardLine(canonicalLanePath(ref.path))} is not valid: ${bad}` };
57
- return { ok: true, rec };
58
108
  }
59
109
  /**
60
110
  * Controller ruling R5 — per-lane whitelist of delegate x layer. A phase whose
@@ -160,11 +210,11 @@ export function checkRequiredDeploy(content, rec) {
160
210
  * re-validate it and check the stored phases against it. Lane-less plans pass
161
211
  * with no record. On failure, `lines` is what to print; the plan is paused.
162
212
  */
163
- export async function loadPlanLaneForRun(cwd, content) {
213
+ export async function loadPlanLaneForRun(cwd, content, planPath) {
164
214
  const ref = content.from.lane;
165
215
  if (!ref)
166
216
  return { ok: true };
167
- const lr = await readPlanLane(cwd, ref);
217
+ const lr = await readPlanLane(cwd, ref, planPath);
168
218
  if (!lr.ok) {
169
219
  return { ok: false, lines: [lr.mismatch ? laneChangedOnResume(ref.path) : `${lr.message} The plan is paused.`] };
170
220
  }
@@ -26,6 +26,7 @@ import { readProjectFile } from './status.js';
26
26
  import { buildPromotedFromSnapshot } from './promote.js';
27
27
  import { assertRealPathContained } from '../tools/_filesystem-shared.js';
28
28
  import { laneRootFor, workspaceFolderFor } from '../lane/roots.js';
29
+ import { planRootOf } from './plan-lane.js';
29
30
  /** A spec-gap envelope file name (`...spec-gap-<4hex>-v<N>[-n].cspeach.json`). */
30
31
  const SPEC_GAP_FILE_RE = /-spec-gap-[0-9a-f]{4}-v\d+(?:-\d+)?\.cspeach\.json$/i;
31
32
  /** Path relative to `root` ('/'-separated) when inside it, else absolute. */
@@ -64,25 +65,30 @@ async function containedIn(abs, root) {
64
65
  }
65
66
  /**
66
67
  * Find and read the plan's spec-gap file. Candidates: `from.specGap.path`,
67
- * then the lane decision's `specGap.path`; each resolved against the project
68
+ * then the lane decision's `specGap.path`; each resolved against the plan
69
+ * file's own root (the folder holding its `.cspeach`, 2026-10-08), the project
68
70
  * folder and cwd. A candidate is used only when it is a spec-gap file name,
69
- * sits (real path) inside cwd or the workspace folder, parses as a spec-gap
71
+ * sits (real path) inside cwd, the workspace folder, the project folder or
72
+ * the plan's root, parses as a spec-gap
70
73
  * envelope, and — when the plan holds a promotedFrom snapshot — is the SAME
71
74
  * envelope (id match). Returns undefined when none qualifies.
72
75
  */
73
- async function readLiveSpecGap(cwd, content, promotedFrom, laneRecord) {
76
+ async function readLiveSpecGap(cwd, content, promotedFrom, laneRecord, planPath) {
74
77
  const recorded = [content.from.specGap?.path, laneRecord?.specGap?.path]
75
78
  .filter((p) => typeof p === 'string' && p.trim() !== '');
76
79
  if (recorded.length === 0)
77
80
  return undefined;
78
81
  const root = await laneRootFor(cwd).catch(() => path.resolve(cwd));
79
82
  const ws = await workspaceFolderFor(cwd).catch(() => undefined);
83
+ const planRoot = planRootOf(planPath);
84
+ const bases = [planRoot, root, path.resolve(cwd)].filter((b, i, a) => !!b && a.indexOf(b) === i);
80
85
  for (const rec of recorded) {
81
86
  if (!SPEC_GAP_FILE_RE.test(path.basename(rec)))
82
87
  continue;
83
- for (const base of [root, path.resolve(cwd)]) {
88
+ for (const base of bases) {
84
89
  const abs = path.resolve(base, rec);
85
- if (!(await containedIn(abs, cwd)) && !(await containedIn(abs, ws)) && !(await containedIn(abs, root)))
90
+ if (!(await containedIn(abs, cwd)) && !(await containedIn(abs, ws)) && !(await containedIn(abs, root)) &&
91
+ !(await containedIn(abs, planRoot)))
86
92
  continue;
87
93
  let env;
88
94
  try {
@@ -95,7 +101,7 @@ async function readLiveSpecGap(cwd, content, promotedFrom, laneRecord) {
95
101
  continue;
96
102
  if (promotedFrom && promotedFrom.id !== env.id)
97
103
  continue;
98
- return { env, shownPath: relOrAbs(root, abs) };
104
+ return { env, shownPath: relOrAbs(base === planRoot ? planRoot : root, abs) };
99
105
  }
100
106
  }
101
107
  return undefined;
@@ -109,7 +115,7 @@ export async function resolvePlanSources(args) {
109
115
  const pf = args.envelope.promotedFrom && args.envelope.promotedFrom.artefactType === 'spec-gap'
110
116
  ? args.envelope.promotedFrom : null;
111
117
  try {
112
- const live = await readLiveSpecGap(args.cwd, args.content, pf, args.laneRecord);
118
+ const live = await readLiveSpecGap(args.cwd, args.content, pf, args.laneRecord, args.planPath);
113
119
  if (live) {
114
120
  const snap = await buildPromotedFromSnapshot(live.env);
115
121
  const createdSha = args.content.from.specGap?.sha256 ?? pf?.sha256;
@@ -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);