@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,499 @@
1
+ /**
2
+ * 2026-10-08 (sessions/folders, Task 13) — `cspeach files sort`.
3
+ *
4
+ * cspeach files sort list old project files with a likely
5
+ * folder; write <cwd>/sort-plan.json
6
+ * cspeach files sort --apply <plan> COPY each placed file (and a plan's
7
+ * lane file + spec-gap) into
8
+ * <folder>/.cspeach/, check every copy
9
+ * by sha256; never delete
10
+ * cspeach files sort --clean <plan> after one confirm, delete only the
11
+ * originals whose copy checks
12
+ *
13
+ * Data safety: copy only; never overwrite a different file (report, skip);
14
+ * a chain of linked files is copied whole or not at all; only top-level
15
+ * *.cspeach.json files in the old save locations and a plan's own linked
16
+ * files are read or copied; session files are never touched (the session
17
+ * backfill runs first, as at start-up). The plan file is untrusted on
18
+ * --apply / --clean and is checked again.
19
+ */
20
+ import { createHash } from 'node:crypto';
21
+ import { constants as fsc, promises as fs, realpathSync } from 'node:fs';
22
+ import path from 'node:path';
23
+ import { cspeachRoot, sessionsDir } from '../config/paths.js';
24
+ import { backfillSessionFolders } from '../session/backfill.js';
25
+ import { normDir } from '../session/project-match.js';
26
+ import { canonicalLanePath, isLanePath } from '../lane/paths.js';
27
+ import { ensureProjectId, legacyDirsFor } from './project-folder.js';
28
+ import { buildSortPlan, insideCliHome, linkChains, linkedFileCandidates } from './sort-core.js';
29
+ /**
30
+ * Task 14 — the folder a copy lands in, judged by its REAL path (nearest
31
+ * existing ancestor realpath'd, links followed; never a cached lookup, the
32
+ * folder may have changed since): a link deeper in the target
33
+ * (`<target>/.cspeach/lane` → the CLI home) is caught. Unknown → refused.
34
+ */
35
+ function destInCliHome(dest) {
36
+ let cur = path.dirname(path.resolve(dest));
37
+ const tail = [];
38
+ for (;;) {
39
+ try {
40
+ const real = realpathSync.native(cur);
41
+ return insideCliHome(tail.length ? path.join(real, ...tail) : real);
42
+ }
43
+ catch (e) {
44
+ if (e?.code !== 'ENOENT')
45
+ return true;
46
+ const parent = path.dirname(cur);
47
+ if (parent === cur)
48
+ return true;
49
+ tail.unshift(path.basename(cur));
50
+ cur = parent;
51
+ }
52
+ }
53
+ }
54
+ const ENVELOPE = /\.cspeach\.json$/i;
55
+ const PLAN_NAME = 'sort-plan.json';
56
+ const USAGE = 'Usage: cspeach files sort [--apply <plan>] [--clean <plan>]';
57
+ export async function runFilesSort(args, io) {
58
+ if (args.length === 0)
59
+ return suggest(io);
60
+ if (args.length === 2 && args[0] === '--apply')
61
+ return apply(path.resolve(io.cwd, args[1]), io);
62
+ if (args.length === 2 && args[0] === '--clean')
63
+ return clean(path.resolve(io.cwd, args[1]), io);
64
+ io.log(USAGE);
65
+ return 1;
66
+ }
67
+ /* ── reading ────────────────────────────────────────────────────────────── */
68
+ async function sha256(file) {
69
+ return createHash('sha256').update(await fs.readFile(file)).digest('hex');
70
+ }
71
+ /** A regular file (not a link, not a folder). */
72
+ async function isPlainFile(p) {
73
+ try {
74
+ return (await fs.lstat(p)).isFile();
75
+ }
76
+ catch {
77
+ return false;
78
+ }
79
+ }
80
+ async function isFolder(p) {
81
+ try {
82
+ return (await fs.stat(p)).isDirectory();
83
+ }
84
+ catch {
85
+ return false;
86
+ }
87
+ }
88
+ /** Top-level *.cspeach.json files in the old save locations. */
89
+ async function scanSources(dirs) {
90
+ const out = [];
91
+ for (const dir of dirs) {
92
+ let names;
93
+ try {
94
+ names = await fs.readdir(dir);
95
+ }
96
+ catch {
97
+ continue;
98
+ }
99
+ for (const name of names.filter((n) => ENVELOPE.test(n)).sort()) {
100
+ const p = path.join(dir, name);
101
+ let st;
102
+ try {
103
+ st = await fs.lstat(p);
104
+ }
105
+ catch {
106
+ continue;
107
+ }
108
+ if (!st.isFile())
109
+ continue;
110
+ let env = null;
111
+ try {
112
+ env = JSON.parse(stripBom(await fs.readFile(p, 'utf8')));
113
+ }
114
+ catch { /* unreadable: listed low */ }
115
+ out.push({ path: p, env: env && typeof env === 'object' && !Array.isArray(env) ? env : null, mtimeMs: st.mtimeMs });
116
+ }
117
+ }
118
+ return out;
119
+ }
120
+ const stripBom = (s) => (s.charCodeAt(0) === 0xfeff ? s.slice(1) : s);
121
+ /** Every session file, parsed; corrupt and reader files skipped. Read only. */
122
+ async function loadSessions() {
123
+ const dir = sessionsDir();
124
+ let names;
125
+ try {
126
+ names = await fs.readdir(dir);
127
+ }
128
+ catch {
129
+ return [];
130
+ }
131
+ const out = [];
132
+ for (const n of names) {
133
+ if (!n.endsWith('.json') || n.startsWith('.') || n.includes('-reader-'))
134
+ continue;
135
+ try {
136
+ const s = JSON.parse(await fs.readFile(path.join(dir, n), 'utf8'));
137
+ if (s && typeof s === 'object' && !Array.isArray(s))
138
+ out.push(s);
139
+ }
140
+ catch { /* corrupt: skip */ }
141
+ }
142
+ return out;
143
+ }
144
+ /**
145
+ * Write the plan without touching an earlier one the person may have edited:
146
+ * sort-plan.json, else sort-plan-2.json, sort-plan-3.json, ...
147
+ */
148
+ async function writeNewPlanFile(dir, text) {
149
+ for (let n = 1;; n++) {
150
+ const out = path.join(dir, n === 1 ? PLAN_NAME : `sort-plan-${n}.json`);
151
+ try {
152
+ await fs.writeFile(out, text, { encoding: 'utf8', flag: 'wx' });
153
+ return out;
154
+ }
155
+ catch (e) {
156
+ if (e?.code !== 'EEXIST' || n >= 999)
157
+ throw e;
158
+ }
159
+ }
160
+ }
161
+ /* ── suggest ────────────────────────────────────────────────────────────── */
162
+ async function suggest(io) {
163
+ await backfillSessionFolders().catch(() => { });
164
+ const sources = legacyDirsFor(io.cwd);
165
+ const files = await scanSources(sources);
166
+ if (files.length === 0) {
167
+ io.log('No project files from earlier builds were found.');
168
+ return 0;
169
+ }
170
+ const sessions = await loadSessions();
171
+ const projectDirs = sessions
172
+ .map((s) => s.project_dir)
173
+ .filter((d) => typeof d === 'string' && d !== '');
174
+ const rows = buildSortPlan({ files, sessions, projectDirs });
175
+ const created = new Map(files.map((f) => [f.path, String(f.env?.createdAt ?? '').slice(0, 10) || '?']));
176
+ io.log(`${files.length} project file${files.length === 1 ? '' : 's'} from earlier builds, in: ${sources.join(', ')}`);
177
+ io.log('');
178
+ const table = [
179
+ ['file', 'created', 'chain', 'likely folder', 'confidence (why)'],
180
+ ...rows.map((r) => [path.basename(r.file), created.get(r.file) ?? '?', r.chain, r.target ?? '?', `${r.confidence} (${r.why})`]),
181
+ ];
182
+ const widths = table[0].map((_, c) => Math.max(...table.map((row) => row[c].length)));
183
+ for (const row of table)
184
+ io.log(row.map((cell, c) => (c === row.length - 1 ? cell : cell.padEnd(widths[c]))).join(' '));
185
+ io.log('');
186
+ const out = await writeNewPlanFile(io.cwd, `${JSON.stringify(rows, null, 2)}\n`);
187
+ io.log(`Saved the plan to ${out}.`);
188
+ io.log('Check each "target". Change it to another full folder path, or set it to null to leave a file where it is.');
189
+ io.log(`Then run: cspeach files sort --apply ${out}`);
190
+ io.log('Files are copied. Nothing is moved or deleted.');
191
+ return 0;
192
+ }
193
+ /** A full folder path: on Windows it must start with a drive (`C:\`) or a share (`\\server`). */
194
+ function isFullPath(p) {
195
+ if (!path.isAbsolute(p))
196
+ return false;
197
+ return process.platform !== 'win32' || /^[A-Za-z]:[\\/]/.test(p) || /^[\\/]{2}[^\\/]/.test(p);
198
+ }
199
+ /**
200
+ * Read the plan and check every row: `file` must be a *.cspeach.json file
201
+ * directly in one of the old save locations; `target` must be null or an
202
+ * existing folder given as a full path, never in or under CSPeach's own
203
+ * folder. Bad rows are reported and dropped (and counted).
204
+ */
205
+ async function readCheckedPlan(planPath, sources, io) {
206
+ let raw;
207
+ try {
208
+ raw = JSON.parse(stripBom(await fs.readFile(planPath, 'utf8')));
209
+ }
210
+ catch {
211
+ io.log(`The plan file could not be read: ${planPath}`);
212
+ return null;
213
+ }
214
+ if (!Array.isArray(raw)) {
215
+ io.log(`The plan file is not a list of rows: ${planPath}`);
216
+ return null;
217
+ }
218
+ const srcKeys = new Set(sources.map(normDir));
219
+ const rows = [];
220
+ let refused = 0;
221
+ for (const r of raw) {
222
+ const file = r?.file;
223
+ if (typeof file !== 'string' || !isFullPath(file) || !ENVELOPE.test(file)
224
+ || !srcKeys.has(normDir(path.dirname(file))) || !(await isPlainFile(file))) {
225
+ io.log(`Skipped ${String(file)}: not in an earlier save location.`);
226
+ refused++;
227
+ continue;
228
+ }
229
+ const target = r?.target ?? null;
230
+ if (target !== null && (typeof target !== 'string' || !isFullPath(target) || !(await isFolder(target)))) {
231
+ io.log(`Skipped ${path.basename(file)}: ${String(target)} is not an existing full folder path.`);
232
+ refused++;
233
+ continue;
234
+ }
235
+ if (target !== null && insideCliHome(target)) {
236
+ io.log(`Skipped ${path.basename(file)}: ${target} would be inside CSPeach's own folder.`);
237
+ refused++;
238
+ continue;
239
+ }
240
+ rows.push({ file, target });
241
+ }
242
+ return { rows, refused };
243
+ }
244
+ /** Inside the CLI home only *.cspeach.json files directly in it may be read. */
245
+ function allowedFromCliHome(p) {
246
+ const home = normDir(cspeachRoot());
247
+ const k = normDir(p);
248
+ if (k !== home && !k.startsWith(home + path.sep))
249
+ return true;
250
+ return normDir(path.dirname(p)) === home && ENVELOPE.test(p);
251
+ }
252
+ /** The lane file and spec-gap a plan links to, as copy jobs into `target`. */
253
+ async function linkedJobs(planFile, env, target, io) {
254
+ const jobs = [];
255
+ const from = env?.artefactType === 'plan' ? env?.content?.from : undefined;
256
+ const find = async (rel) => {
257
+ for (const c of linkedFileCandidates(planFile, rel))
258
+ if (allowedFromCliHome(c) && (await isPlainFile(c)))
259
+ return c;
260
+ return null;
261
+ };
262
+ const lane = from?.lane?.path;
263
+ if (typeof lane === 'string') {
264
+ const rel = canonicalLanePath(lane);
265
+ const src = isLanePath(rel) ? await find(rel) : null;
266
+ if (src)
267
+ jobs.push({ src, dest: path.join(target, ...rel.split('/')) });
268
+ else
269
+ io.log(`${path.basename(planFile)}: its lane file was not found; the plan is copied without it.`);
270
+ }
271
+ const gap = from?.specGap?.path;
272
+ if (typeof gap === 'string') {
273
+ const src = ENVELOPE.test(gap) ? await find(gap) : null;
274
+ if (src)
275
+ jobs.push({ src, dest: path.join(target, '.cspeach', path.basename(src)) });
276
+ else
277
+ io.log(`${path.basename(planFile)}: its spec-gap file was not found; the plan is copied without it.`);
278
+ }
279
+ return jobs;
280
+ }
281
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
282
+ /**
283
+ * Copy one chain's jobs. Checks first (never write over a different file),
284
+ * then copies with sha256 checks. On any failure, this run's new copies of
285
+ * the chain are removed again, so a chain is never left half copied.
286
+ */
287
+ async function copyChain(jobs, names, io) {
288
+ const todo = [];
289
+ let same = 0;
290
+ try {
291
+ for (const j of jobs) {
292
+ if (normDir(j.src) === normDir(j.dest)) {
293
+ same++;
294
+ continue;
295
+ } // this file is already in the target
296
+ if (await isPlainFile(j.dest)) {
297
+ if ((await sha256(j.dest)) === (await sha256(j.src))) {
298
+ same++;
299
+ continue;
300
+ }
301
+ io.log(`Not copied: ${names}. ${j.dest} already exists with different content.`);
302
+ return { ok: false, copied: 0, same: 0 };
303
+ }
304
+ let taken = true;
305
+ try {
306
+ await fs.lstat(j.dest);
307
+ }
308
+ catch {
309
+ taken = false;
310
+ }
311
+ if (taken) {
312
+ io.log(`Not copied: ${names}. ${j.dest} already exists.`);
313
+ return { ok: false, copied: 0, same: 0 };
314
+ }
315
+ if (destInCliHome(j.dest)) {
316
+ io.log(`Not copied: ${names}. ${j.dest} would be inside CSPeach's own folder.`);
317
+ return { ok: false, copied: 0, same: 0 };
318
+ }
319
+ todo.push(j);
320
+ }
321
+ }
322
+ catch {
323
+ io.log(`Not copied: ${names}. A file could not be read.`);
324
+ return { ok: false, copied: 0, same: 0 };
325
+ }
326
+ const made = [];
327
+ for (const j of todo) {
328
+ try {
329
+ await fs.mkdir(path.dirname(j.dest), { recursive: true });
330
+ // Checked again now the folder exists (it may have changed since the check above).
331
+ if (destInCliHome(j.dest))
332
+ throw new Error('inside the CLI home');
333
+ await fs.copyFile(j.src, j.dest, fsc.COPYFILE_EXCL);
334
+ made.push(j.dest);
335
+ if ((await sha256(j.dest)) !== (await sha256(j.src)))
336
+ throw new Error('copy differs');
337
+ }
338
+ catch {
339
+ for (const d of made)
340
+ await fs.rm(d, { force: true }).catch(() => { });
341
+ io.log(`Not copied: ${names}. Copying ${path.basename(j.src)} failed, so this run's copies of these files were removed. The originals are untouched.`);
342
+ return { ok: false, copied: 0, same: 0 };
343
+ }
344
+ }
345
+ return { ok: true, copied: made.length, same };
346
+ }
347
+ async function apply(planPath, io) {
348
+ const sources = legacyDirsFor(io.cwd);
349
+ const plan = await readCheckedPlan(planPath, sources, io);
350
+ if (!plan)
351
+ return 1;
352
+ const byFile = new Map(plan.rows.map((r) => [normDir(r.file), r]));
353
+ // Chains are worked out again from the files themselves, not from the plan.
354
+ const files = await scanSources(sources);
355
+ let copied = 0;
356
+ let same = 0;
357
+ let notCopied = 0;
358
+ const touched = new Set();
359
+ for (const group of linkChains(files)) {
360
+ const members = group.map((i) => files[i]);
361
+ const planned = members.map((m) => byFile.get(normDir(m.path)));
362
+ const targets = planned.map((r) => r?.target ?? null);
363
+ if (targets.every((t) => t === null))
364
+ continue;
365
+ const names = members.map((m) => path.basename(m.path)).join(', ');
366
+ const target = targets.find((t) => t !== null);
367
+ if (planned.some((r) => !r) || targets.some((t) => t === null || normDir(t) !== normDir(target))) {
368
+ io.log(`Not copied: ${names}. They are linked and must go to one folder together.`);
369
+ notCopied += members.length;
370
+ continue;
371
+ }
372
+ // Every file of the chain, plus each plan's lane file and spec-gap.
373
+ const jobs = members.map((m) => ({ src: m.path, dest: path.join(target, '.cspeach', path.basename(m.path)) }));
374
+ for (const m of members)
375
+ jobs.push(...(await linkedJobs(m.path, m.env, target, io)));
376
+ const byDest = new Map();
377
+ let clash = false;
378
+ for (const j of jobs) {
379
+ const seen = byDest.get(normDir(j.dest));
380
+ if (!seen)
381
+ byDest.set(normDir(j.dest), j);
382
+ else if (normDir(seen.src) !== normDir(j.src))
383
+ clash = true;
384
+ }
385
+ if (clash) {
386
+ io.log(`Not copied: ${names}. Two files would get the same name in ${target}.`);
387
+ notCopied += members.length;
388
+ continue;
389
+ }
390
+ const r = await copyChain([...byDest.values()], names, io);
391
+ if (!r.ok) {
392
+ notCopied += members.length;
393
+ continue;
394
+ }
395
+ copied += r.copied;
396
+ same += r.same;
397
+ if (r.copied > 0)
398
+ touched.add(target);
399
+ }
400
+ for (const t of touched) {
401
+ try {
402
+ ensureProjectId(path.join(t, '.cspeach'));
403
+ }
404
+ catch {
405
+ io.log(`Could not write the project id in ${t}.`);
406
+ }
407
+ }
408
+ io.log(`Copied ${plural(copied, 'file')}${same ? `, ${same} already there` : ''}${notCopied ? `, ${notCopied} not copied` : ''}. Every copy was checked.`);
409
+ io.log('The originals are still in place.');
410
+ if (copied + same > 0)
411
+ io.log(`To delete the originals whose copies check out: cspeach files sort --clean ${planPath}`);
412
+ return plan.refused > 0 || notCopied > 0 ? 1 : 0;
413
+ }
414
+ /* ── clean ──────────────────────────────────────────────────────────────── */
415
+ async function defaultConfirm(message) {
416
+ const { confirm } = await import('../repl/themed-prompts.js');
417
+ return confirm({ message, default: false });
418
+ }
419
+ /** The copy of `file` in `<target>/.cspeach`, when it matches by sha256. */
420
+ async function checkedCopy(file, target) {
421
+ const copy = path.join(target, '.cspeach', path.basename(file));
422
+ if (normDir(copy) === normDir(file))
423
+ return null;
424
+ try {
425
+ if (!(await isPlainFile(copy)) || !(await isPlainFile(file)))
426
+ return null;
427
+ return (await sha256(copy)) === (await sha256(file)) ? copy : null;
428
+ }
429
+ catch {
430
+ return null;
431
+ }
432
+ }
433
+ async function clean(planPath, io) {
434
+ const sources = legacyDirsFor(io.cwd);
435
+ const plan = await readCheckedPlan(planPath, sources, io);
436
+ if (!plan)
437
+ return 1;
438
+ const byFile = new Map(plan.rows.map((r) => [normDir(r.file), r]));
439
+ const files = await scanSources(sources);
440
+ // A chain's originals go only when EVERY member's copy checks out.
441
+ let kept = 0;
442
+ const ready = [];
443
+ for (const group of linkChains(files)) {
444
+ const members = group.map((i) => files[i]);
445
+ if (members.every((m) => !byFile.get(normDir(m.path))?.target))
446
+ continue;
447
+ const pairs = [];
448
+ for (const m of members) {
449
+ const t = byFile.get(normDir(m.path))?.target;
450
+ const copy = t ? await checkedCopy(m.path, t) : null;
451
+ if (!t || !copy)
452
+ break;
453
+ pairs.push({ file: m.path, target: t, copy });
454
+ }
455
+ if (pairs.length === members.length)
456
+ ready.push(...pairs);
457
+ else {
458
+ io.log(`Kept: ${members.map((m) => path.basename(m.path)).join(', ')}. Not every linked file has a checked copy.`);
459
+ kept += members.length;
460
+ }
461
+ }
462
+ const status = () => (plan.refused > 0 || kept > 0 ? 1 : 0);
463
+ if (ready.length === 0) {
464
+ io.log('No originals with a checked copy. Run --apply first.');
465
+ return status();
466
+ }
467
+ io.log('These originals have a checked copy:');
468
+ for (const r of ready)
469
+ io.log(` ${r.file} -> ${r.copy}`);
470
+ let yes = false;
471
+ try {
472
+ yes = await (io.confirm ?? defaultConfirm)(`Delete these ${ready.length} originals? The copies stay.`);
473
+ }
474
+ catch {
475
+ yes = false;
476
+ }
477
+ if (!yes) {
478
+ io.log('Nothing deleted.');
479
+ return status();
480
+ }
481
+ let deleted = 0;
482
+ for (const r of ready) {
483
+ // Check again right before deleting.
484
+ try {
485
+ if ((await checkedCopy(r.file, r.target)) !== null) {
486
+ await fs.unlink(r.file);
487
+ deleted++;
488
+ continue;
489
+ }
490
+ io.log(`Kept ${r.file}: its copy no longer matches.`);
491
+ }
492
+ catch {
493
+ io.log(`Kept ${r.file}: it could not be deleted.`);
494
+ }
495
+ kept++;
496
+ }
497
+ io.log(`Deleted ${plural(deleted, 'original')}.`);
498
+ return status();
499
+ }