@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,56 +7,23 @@
7
7
  * the user — the path-copy circus. The workspace folder is one configurable
8
8
  * directory that the CLI saves into and resolves bare @<name> tokens against.
9
9
  *
10
- * Default resolution (2026-05-08, "project-rooted workspace"):
11
- * 1. `cfg.workspace_folder` in `~/.cspeach/config.toml` — explicit override,
12
- * highest priority.
13
- * 2. `<git-root>/.cspeach/` — when the cwd is inside a git repository.
14
- * Each customer / project gets its own workspace; multi-project
15
- * consultants don't have artefacts piling up in one folder.
16
- * 3. `<cwd>/.cspeach/` — fallback when cwd isn't inside a git repo.
17
- *
18
- * Earlier (pre-2026-05-08) default was `~/CSPeach/`. A first-run migration
19
- * prompt (see src/projects/migration.ts) offers to move existing artefacts
20
- * into the new project-rooted workspace, or pin workspace_folder back to
21
- * `~/CSPeach/` for users who prefer the old behaviour.
10
+ * Resolution (2026-10-08): see projects/project-folder.ts — the folder you
11
+ * start in; team folders save to the seat. No git walk; workspace_folder is
12
+ * ignored.
22
13
  *
23
14
  * The folder is created on first use — no `cspeach init` ceremony.
24
15
  */
25
16
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
26
17
  import { dirname, join, isAbsolute } from 'node:path';
27
- import os from 'node:os';
28
- import toml from '@iarna/toml';
29
- import { loadConfig } from '../config/loader.js';
30
- import { configFile } from '../config/paths.js';
18
+ import { legacyDirsFor, resolveProjectFolderSync } from './project-folder.js';
19
+ import { cspeachRoot } from '../config/paths.js';
20
+ import { normDir } from '../session/project-match.js';
31
21
  /**
32
- * Walk up the directory tree from `startDir` looking for a `.git` entry
33
- * (file or directory — submodules use a file). Returns the directory
34
- * containing `.git`, or null when the search hits the filesystem root
35
- * without finding one.
36
- *
37
- * Bounded by parent === dir, which terminates at "/" on POSIX and at
38
- * each drive root on Windows.
39
- */
40
- export function findProjectRoot(startDir) {
41
- let dir = startDir;
42
- for (let i = 0; i < 200; i++) {
43
- if (existsSync(join(dir, '.git')))
44
- return dir;
45
- const parent = dirname(dir);
46
- if (parent === dir)
47
- return null;
48
- dir = parent;
49
- }
50
- return null;
51
- }
52
- /**
53
- * Resolve the project-rooted default workspace path for a given cwd.
54
- * Pure: does NOT touch the filesystem (other than the .git existence
55
- * check via findProjectRoot). Caller decides whether to mkdir.
22
+ * The workspace path for a given cwd (see projects/project-folder.ts).
23
+ * Pure: does NOT create anything. Caller decides whether to mkdir.
56
24
  */
57
25
  export function defaultWorkspaceFor(cwd) {
58
- const root = findProjectRoot(cwd);
59
- return join(root ?? cwd, '.cspeach');
26
+ return resolveProjectFolderSync(cwd).workDir;
60
27
  }
61
28
  /**
62
29
  * If the workspace lives directly under a project root that has a
@@ -92,17 +59,13 @@ function maybeAddToGitignore(workspaceDir) {
92
59
  }
93
60
  }
94
61
  /**
95
- * Resolve the workspace folder PATH without creating it. Same resolution
96
- * order as ensureWorkspace (config override > project-rooted default) but
97
- * strictly read-only: no mkdir, no .gitignore append. For callers that only
98
- * need to LOOK at the workspace — e.g. the pre-confirm prior-plan scan in
99
- * save-command.ts, which must not leave filesystem side effects before the
100
- * user says yes to the save.
62
+ * Resolve the workspace folder PATH without creating it. Strictly read-only:
63
+ * no mkdir, no .gitignore append. For callers that only need to LOOK at the
64
+ * workspace — e.g. the pre-confirm prior-plan scan in save-command.ts, which
65
+ * must not leave filesystem side effects before the user says yes to the save.
101
66
  */
102
67
  export async function resolveWorkspacePath() {
103
- const cfg = await loadConfig();
104
- const configured = cfg.workspace_folder?.trim();
105
- return resolveTarget(configured);
68
+ return defaultWorkspaceFor(process.cwd());
106
69
  }
107
70
  /**
108
71
  * App lane routing I3 — the same read-only resolution as resolveWorkspacePath,
@@ -110,64 +73,35 @@ export async function resolveWorkspacePath() {
110
73
  * real session the two are the same folder; tests pass a temp cwd.
111
74
  */
112
75
  export async function resolveWorkspacePathFor(cwd) {
113
- const cfg = await loadConfig();
114
- const configured = cfg.workspace_folder?.trim();
115
- return configured && configured.length > 0 ? expandHome(configured) : defaultWorkspaceFor(cwd);
116
- }
117
- function resolveTarget(configured) {
118
- return configured && configured.length > 0
119
- ? expandHome(configured)
120
- : defaultWorkspaceFor(process.cwd());
76
+ return defaultWorkspaceFor(cwd);
121
77
  }
122
78
  /**
123
- * Resolve the workspace folder path, creating it if missing.
124
- * Returns the absolute path. Idempotent.
79
+ * Resolve the workspace folder path for `cwd` (default: process.cwd()),
80
+ * creating it if missing. Returns the absolute path. Idempotent.
125
81
  */
126
- export async function ensureWorkspace() {
127
- const cfg = await loadConfig();
128
- const configured = cfg.workspace_folder?.trim();
129
- const target = resolveTarget(configured);
130
- const justCreated = !existsSync(target);
131
- if (justCreated) {
132
- mkdirSync(target, { recursive: true });
133
- // Only add to .gitignore for default project-rooted workspaces.
134
- // Explicit overrides (e.g. ~/CSPeach/, /shared/team/cspeach/) are
135
- // outside any project tree by design — adding to a parent .gitignore
136
- // would be overreach.
137
- if (!configured)
138
- maybeAddToGitignore(target);
139
- }
140
- return target;
82
+ export async function ensureWorkspace(cwd = process.cwd()) {
83
+ const pf = resolveProjectFolderSync(cwd);
84
+ if (!existsSync(pf.workDir)) {
85
+ mkdirSync(pf.workDir, { recursive: true });
86
+ // Only for the folder you start in. The .gitignore beside a team folder
87
+ // or ~/CSPeach is not ours to edit.
88
+ if (pf.kind === 'solo')
89
+ maybeAddToGitignore(pf.workDir);
90
+ }
91
+ return pf.workDir;
141
92
  }
142
93
  /**
143
94
  * Synchronous variant for callers that cannot await — most importantly
144
- * Node's readline `completer` interface, which is sync. Reads the TOML
145
- * config directly via fs.readFileSync. Falls back to project-rooted
146
- * default on any error (missing config, parse failure, etc.) — the
147
- * completer should never throw.
148
- *
149
- * Earlier revision used `require('@iarna/toml')` here, which threw in
150
- * ESM context (cspeach-cli has `"type": "module"`) and silently broke
151
- * Tab completion. Top-level import is correct for ESM modules.
95
+ * Node's readline `completer` interface, which is sync. Never throws.
152
96
  */
153
- export function ensureWorkspaceSync() {
154
- let target = defaultWorkspaceFor(process.cwd());
97
+ export function ensureWorkspaceSync(cwd = process.cwd()) {
98
+ const target = defaultWorkspaceFor(cwd);
155
99
  try {
156
- const cfgPath = configFile();
157
- if (existsSync(cfgPath)) {
158
- const raw = readFileSync(cfgPath, 'utf-8');
159
- const parsed = toml.parse(raw);
160
- const configured = parsed.workspace_folder?.trim();
161
- if (configured && configured.length > 0) {
162
- target = expandHome(configured);
163
- }
164
- }
100
+ if (!existsSync(target))
101
+ mkdirSync(target, { recursive: true });
165
102
  }
166
103
  catch {
167
- // Fall through to default — completer must never throw on a borked config.
168
- }
169
- if (!existsSync(target)) {
170
- mkdirSync(target, { recursive: true });
104
+ // The completer must never throw.
171
105
  }
172
106
  return target;
173
107
  }
@@ -206,16 +140,6 @@ export function listProjectFilesSync(workspace) {
206
140
  return [];
207
141
  }
208
142
  }
209
- /**
210
- * Expand a leading `~/` to the user's home directory. Other paths are
211
- * returned unchanged.
212
- */
213
- function expandHome(p) {
214
- if (p.startsWith('~/') || p === '~') {
215
- return join(os.homedir(), p.slice(p === '~' ? 1 : 2));
216
- }
217
- return p;
218
- }
219
143
  /** File suffixes recognised as text-input candidates for skills. */
220
144
  const TEXT_SUFFIXES = ['.txt', '.md'];
221
145
  /**
@@ -252,18 +176,63 @@ export function isAttachableFilename(name) {
252
176
  * yield an `unknown` summary rather than throwing — callers see the
253
177
  * file name with no metadata so the user can investigate.
254
178
  */
255
- export async function listProjectFiles() {
256
- const ws = await ensureWorkspace();
257
- const entries = readdirSync(ws);
179
+ export async function listProjectFiles(cwd = process.cwd()) {
180
+ const ws = await ensureWorkspace(cwd);
181
+ const out = summarizeDir(readdirSync(ws), ws, false);
182
+ out.sort((a, b) => b.mtimeMs - a.mtimeMs);
183
+ // 2026-10-08 (sessions/folders, Task 11) — for one release the old save
184
+ // locations stay readable: their files follow the new folder's, marked
185
+ // `earlier`. Read-only: nothing is created or written there. A file with
186
+ // the same name in the new folder wins.
187
+ const seen = new Set(out.map((f) => f.filename.toLowerCase()));
188
+ let legacy;
189
+ try {
190
+ legacy = legacyDirsFor(cwd);
191
+ }
192
+ catch {
193
+ legacy = [];
194
+ }
195
+ for (const dir of legacy) {
196
+ let names;
197
+ try {
198
+ names = readdirSync(dir);
199
+ }
200
+ catch {
201
+ continue;
202
+ }
203
+ const rows = summarizeDir(names, dir, isCliHome(dir)).filter((f) => !seen.has(f.filename.toLowerCase()));
204
+ rows.sort((a, b) => b.mtimeMs - a.mtimeMs);
205
+ for (const f of rows) {
206
+ seen.add(f.filename.toLowerCase());
207
+ out.push({ ...f, earlier: true });
208
+ }
209
+ }
210
+ return out;
211
+ }
212
+ /**
213
+ * Whether `dir` is the CLI home. In the CLI home only `*.cspeach.json` files
214
+ * directly in it are ever listed — never config.toml, auth or sessions.
215
+ */
216
+ function isCliHome(dir) {
217
+ return normDir(dir) === normDir(cspeachRoot());
218
+ }
219
+ /** The supported files among `names` in `dir`, parsed for the summary. */
220
+ function summarizeDir(names, dir, envelopesOnly) {
258
221
  const out = [];
259
- for (const name of entries) {
222
+ for (const name of names) {
260
223
  const isEnvelope = name.endsWith('.cspeach.json');
261
- const isText = isTextFilename(name);
262
- const isDoc = isDocFilename(name);
224
+ const isText = !envelopesOnly && isTextFilename(name);
225
+ const isDoc = !envelopesOnly && isDocFilename(name);
263
226
  if (!isEnvelope && !isText && !isDoc)
264
227
  continue;
265
- const path = join(ws, name);
266
- const stat = statSync(path);
228
+ const path = join(dir, name);
229
+ let stat;
230
+ try {
231
+ stat = statSync(path);
232
+ }
233
+ catch {
234
+ continue;
235
+ }
267
236
  if (!stat.isFile())
268
237
  continue;
269
238
  if (isEnvelope) {
@@ -307,9 +276,95 @@ export async function listProjectFiles() {
307
276
  });
308
277
  }
309
278
  }
310
- out.sort((a, b) => b.mtimeMs - a.mtimeMs);
311
279
  return out;
312
280
  }
281
+ /**
282
+ * The @ completer's view of the old save locations (sync, names only): the
283
+ * same files listProjectFiles marks `earlier`; names in `skip` (the new
284
+ * folder's) are left out. Never throws.
285
+ */
286
+ export function listLegacyFilesSync(cwd, skip) {
287
+ const out = [];
288
+ const seen = new Set([...skip].map((n) => n.toLowerCase()));
289
+ let dirs;
290
+ try {
291
+ dirs = legacyDirsFor(cwd);
292
+ }
293
+ catch {
294
+ return out;
295
+ }
296
+ for (const dir of dirs) {
297
+ const home = isCliHome(dir);
298
+ for (const f of listProjectFilesSync(dir)) {
299
+ if (home && f.kind !== 'envelope')
300
+ continue;
301
+ if (seen.has(f.filename.toLowerCase()))
302
+ continue;
303
+ seen.add(f.filename.toLowerCase());
304
+ out.push(f);
305
+ }
306
+ }
307
+ return out;
308
+ }
309
+ /** Number of `*.cspeach.json` files directly in `dir` (0 when unreadable). */
310
+ function countEnvelopes(dir) {
311
+ try {
312
+ return readdirSync(dir).filter((n) => n.endsWith('.cspeach.json')).length;
313
+ }
314
+ catch {
315
+ return 0;
316
+ }
317
+ }
318
+ /**
319
+ * The start-up hint for files left in old save locations, or null when
320
+ * there are none. Names the first old folder; counts envelopes in all.
321
+ */
322
+ export function legacyHintLine(cwd) {
323
+ let dirs;
324
+ try {
325
+ dirs = legacyDirsFor(cwd);
326
+ }
327
+ catch {
328
+ return null;
329
+ }
330
+ const n = dirs.reduce((sum, d) => sum + countEnvelopes(d), 0);
331
+ if (n === 0)
332
+ return null;
333
+ const lead = n === 1
334
+ ? `1 project file from earlier builds is in ${dirs[0]}.`
335
+ : `${n} project files from earlier builds are in ${dirs[0]}.`;
336
+ return `${lead} Run: cspeach files sort`;
337
+ }
338
+ function localDay(d) {
339
+ const pad = (x) => String(x).padStart(2, '0');
340
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
341
+ }
342
+ /**
343
+ * At most once per folder per day (spec §3.6): true when the hint for `cwd`
344
+ * was not shown today, and records today in <CSPEACH_HOME>/hints.json.
345
+ * Best-effort: a missing or broken file counts as "not shown"; never throws.
346
+ */
347
+ export function claimLegacyHintToday(cwd, now = new Date()) {
348
+ const file = join(cspeachRoot(), 'hints.json');
349
+ const today = localDay(now);
350
+ let map = {};
351
+ try {
352
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
353
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
354
+ map = parsed;
355
+ }
356
+ catch { /* missing or broken — start fresh */ }
357
+ const k = normDir(cwd);
358
+ if (map[k] === today)
359
+ return false;
360
+ map[k] = today;
361
+ try {
362
+ mkdirSync(dirname(file), { recursive: true });
363
+ writeFileSync(file, `${JSON.stringify(map, null, 2)}\n`, 'utf8');
364
+ }
365
+ catch { /* best-effort */ }
366
+ return true;
367
+ }
313
368
  /**
314
369
  * Inspect a `design` envelope and decide whether the design describes a
315
370
  * RAP stack (Behavior Definition / Behavior Pool / Service Binding) or a
@@ -390,6 +445,12 @@ export function resolveAtToken(args) {
390
445
  if (exact.length === 1) {
391
446
  return { kind: 'path', path: exact[0].path };
392
447
  }
448
+ // Final fix wave, item 6 — one match in this folder beats matches that are
449
+ // only in an earlier location.
450
+ const here = matches.filter((f) => !f.earlier);
451
+ if (here.length === 1) {
452
+ return { kind: 'path', path: here[0].path };
453
+ }
393
454
  return { kind: 'ambiguous', matches };
394
455
  }
395
456
  /**
@@ -478,8 +539,8 @@ export function resolveBareTokenInCwd(token, cwd) {
478
539
  * a plain `@spec.txt` instead of an absolute path.
479
540
  */
480
541
  export async function resolveAtTokenAsync(token, cwd) {
481
- const workspace = await ensureWorkspace();
482
- const files = await listProjectFiles();
542
+ const workspace = await ensureWorkspace(cwd);
543
+ const files = await listProjectFiles(cwd);
483
544
  const result = resolveAtToken({ token, cwd, workspace, files });
484
545
  if (result.kind === 'notFound' && isBareToken(token)) {
485
546
  const cwdResult = resolveBareTokenInCwd(token, cwd);
@@ -506,7 +567,7 @@ export function formatProjectFileList(files, workspace) {
506
567
  const meta = f.kind === 'envelope'
507
568
  ? `${f.artefactType.padEnd(9)} v${f.version ?? '?'}`
508
569
  : `${f.kind.padEnd(9)} ${formatBytes(f.sizeBytes).padStart(7)}`;
509
- lines.push(` ${f.filename} — ${meta} ${ageStr}`);
570
+ lines.push(` ${f.filename} — ${meta} ${ageStr}${f.earlier ? ' (earlier location)' : ''}`);
510
571
  }
511
572
  lines.push('');
512
573
  lines.push(`${files.length} file(s). Use @<partial-name> to load — e.g. @${shortHint(files[0].filename)}`);