@brainervirus/workit-core 0.6.1 → 0.7.0

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 (81) hide show
  1. package/package.json +3 -7
  2. package/scripts/doctor-check.ts +20 -0
  3. package/scripts/install-cursor-plugin.sh +51 -28
  4. package/scripts/install-opencode-plugin.sh +19 -21
  5. package/scripts/rewrite-workspace-deps.ts +15 -9
  6. package/scripts/sync-runtime.sh +71 -19
  7. package/scripts/vendor-assets.ts +37 -0
  8. package/skills/wk-implement/SKILL.md +2 -2
  9. package/skills/wk-pr/SKILL.md +1 -1
  10. package/src/core/boundary.ts +27 -0
  11. package/src/core/branch-policy.ts +63 -0
  12. package/src/core/branch.ts +30 -16
  13. package/src/core/config.ts +193 -31
  14. package/src/core/docs-layout.ts +251 -0
  15. package/src/core/docs-migration.ts +639 -0
  16. package/src/core/docs-repo.ts +11 -9
  17. package/src/core/docs-validate.ts +18 -6
  18. package/src/core/doctor.ts +801 -0
  19. package/src/core/flow-state.ts +1579 -141
  20. package/src/core/git.ts +22 -5
  21. package/src/{tools/handoff.ts → core/handoff-tools.ts} +5 -57
  22. package/src/core/hygiene.ts +26 -12
  23. package/src/core/init.ts +43 -11
  24. package/src/core/logger.ts +321 -0
  25. package/src/core/package-root.ts +28 -0
  26. package/src/core/ports/init-toolkit-status.ts +1 -1
  27. package/src/core/ports/vcs-verify-token.ts +1 -1
  28. package/src/core/ports/youtrack-api.ts +1 -1
  29. package/src/core/ports/youtrack-verify-token.ts +1 -1
  30. package/src/core/pr-create.ts +116 -21
  31. package/src/core/registration.ts +215 -0
  32. package/src/core/repo-context.ts +447 -0
  33. package/src/core/repo-tools.ts +23 -0
  34. package/src/core/safe-write.ts +22 -0
  35. package/src/core/scripts.ts +3 -44
  36. package/src/core/sdd.ts +45 -28
  37. package/src/core/setup-state.ts +54 -0
  38. package/src/core/setup.ts +1216 -0
  39. package/src/core/skill-manifests.ts +95 -0
  40. package/src/core/support-matrix.ts +12 -0
  41. package/src/core/sync-runtime.ts +348 -0
  42. package/src/core/templates.ts +2 -2
  43. package/src/core/vcs-config.ts +107 -37
  44. package/src/core/verify-project.ts +181 -0
  45. package/src/core/workspaces.ts +136 -17
  46. package/src/core/youtrack-tools.ts +228 -0
  47. package/src/core/youtrack.ts +125 -67
  48. package/templates/execution-contract.md +9 -7
  49. package/templates/superpowers-doc-contract.md +4 -3
  50. package/scripts/_shared/common.sh +0 -173
  51. package/scripts/changelog-context.sh +0 -42
  52. package/scripts/docs-refresh-context.sh +0 -40
  53. package/scripts/init/apply.sh +0 -5
  54. package/scripts/init/status.sh +0 -5
  55. package/scripts/init/toolkit-status.sh +0 -5
  56. package/scripts/pr-create.sh +0 -5
  57. package/scripts/pr-ready-context.sh +0 -88
  58. package/scripts/present/ascii-wireframe.sh +0 -5
  59. package/scripts/present/flow-diagram.sh +0 -5
  60. package/scripts/release-notes-context.sh +0 -40
  61. package/scripts/vcs/config.sh +0 -5
  62. package/scripts/vcs/merged-style.sh +0 -5
  63. package/scripts/vcs/token-create-urls.sh +0 -5
  64. package/scripts/vcs/verify-token.sh +0 -5
  65. package/scripts/verify-project.sh +0 -140
  66. package/scripts/youtrack/api.sh +0 -5
  67. package/scripts/youtrack/config.sh +0 -5
  68. package/scripts/youtrack/greeting.sh +0 -5
  69. package/scripts/youtrack/parse-duration.sh +0 -5
  70. package/scripts/youtrack/token-create-url.sh +0 -5
  71. package/scripts/youtrack/verify-token.sh +0 -5
  72. package/scripts/youtrack/work-date-ms.sh +0 -5
  73. package/src/tools/docs-repo.ts +0 -51
  74. package/src/tools/flow.ts +0 -99
  75. package/src/tools/index.ts +0 -22
  76. package/src/tools/present.ts +0 -49
  77. package/src/tools/repo.ts +0 -490
  78. package/src/tools/rules.ts +0 -30
  79. package/src/tools/sdd.ts +0 -216
  80. package/src/tools/templates.ts +0 -27
  81. package/src/tools/youtrack.ts +0 -423
@@ -0,0 +1,639 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import {
3
+ existsSync,
4
+ lstatSync,
5
+ mkdirSync,
6
+ readdirSync,
7
+ readFileSync,
8
+ realpathSync,
9
+ renameSync,
10
+ rmSync,
11
+ statSync,
12
+ writeFileSync,
13
+ } from "node:fs";
14
+ import path from "node:path";
15
+ import { resolveCanonicalLayout } from "./docs-layout";
16
+
17
+ // Bounded legacy detection and atomic copy-only migration (DC-05-DC-11). The
18
+ // legacy root `docs/superpowers/` is reserved (Task 16): it is never written
19
+ // through the canonical layout, and migration is the only bounded path that
20
+ // reads it. Migration copies byte-identical sources, never deletes or edits
21
+ // them, never overwrites a differing destination, and rewrites plan links /
22
+ // valid flow paths only inside the files it copies.
23
+
24
+ export const LEGACY_DIR = "docs/superpowers";
25
+
26
+ // Exact native question choices (DC-07): both host adapters surface these
27
+ // verbatim through their native question / AskQuestion flow.
28
+ export const MIGRATION_CHOICES = ["Migrate safely", "Not now"] as const;
29
+
30
+ export type LegacyEntry = {
31
+ /** Proposed canonical slug; "" when none can be derived. */
32
+ slug: string;
33
+ /** Relative legacy dir, e.g. docs/superpowers/foo. */
34
+ legacy_dir: string;
35
+ /** Relative legacy spec.md path when present. */
36
+ spec: string | null;
37
+ /** Relative legacy plan.md path when present. */
38
+ plan: string | null;
39
+ /** Legacy docs/superpowers/<name>/sdd exists. */
40
+ sdd: boolean;
41
+ /** spec + plan both present. */
42
+ paired: boolean;
43
+ /** Paired through an explicit plan **Spec:** link. */
44
+ explicit: boolean;
45
+ status: "paired" | "orphan" | "ambiguous";
46
+ };
47
+
48
+ export type DetectResult = {
49
+ legacy_dir: string;
50
+ entries: LegacyEntry[];
51
+ paired: LegacyEntry[];
52
+ orphaned: LegacyEntry[];
53
+ ambiguous: LegacyEntry[];
54
+ /** No ambiguity or collision — preflight is safe to migrate. */
55
+ safe: boolean;
56
+ };
57
+
58
+ export type MigrationQuestion = {
59
+ prompt: string;
60
+ options: readonly ["Migrate safely", "Not now"];
61
+ };
62
+
63
+ export type MigrateItem = {
64
+ kind: "spec" | "plan" | "sdd";
65
+ from: string;
66
+ to: string;
67
+ status: "copied" | "rewritten" | "already_migrated" | "malformed";
68
+ };
69
+
70
+ export type MigrateReport = {
71
+ legacy_dir: string;
72
+ copied: string[];
73
+ rewritten: string[];
74
+ already_migrated: string[];
75
+ malformed: string[];
76
+ collisions: string[];
77
+ items: MigrateItem[];
78
+ };
79
+
80
+ export type MigrateResult =
81
+ | { ok: true; declined: false; data: MigrateReport }
82
+ | {
83
+ ok: false;
84
+ declined: boolean;
85
+ active_workflow: boolean;
86
+ error: string;
87
+ collisions?: string[];
88
+ };
89
+
90
+ const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
91
+ const RESERVED_SLUG = "superpowers";
92
+ const SPEC_LINK_RE = /^\s*\*+Spec:\*+\s*(?:`([^`]+)`|(\S+))\s*$/im;
93
+
94
+ const posix = (p: string) => p.split(path.sep).join("/");
95
+
96
+ const legacyRoot = (workspace: string) => path.join(workspace, "docs", "superpowers");
97
+
98
+ const readFileSafe = (p: string): string | null => {
99
+ try {
100
+ return readFileSync(p, "utf8");
101
+ } catch {
102
+ return null;
103
+ }
104
+ };
105
+
106
+ // The canonical `docs/<slug>/sdd/` ignore contract (DC-10): SDD state is only
107
+ // copied once the working-state dir is gitignored. Not a git repo means the
108
+ // contract is not active — refuse rather than guess.
109
+ const sddIgnoreActive = (cwd: string, slug: string): boolean => {
110
+ try {
111
+ execFileSync("git", ["-C", cwd, "rev-parse", "--is-inside-work-tree"], { stdio: "pipe" });
112
+ } catch {
113
+ return false;
114
+ }
115
+ try {
116
+ execFileSync(
117
+ "git",
118
+ ["-C", cwd, "check-ignore", path.posix.join("docs", slug, "sdd", "progress.md")],
119
+ { stdio: "pipe" },
120
+ );
121
+ return true;
122
+ } catch {
123
+ return false;
124
+ }
125
+ };
126
+
127
+ const explicitTargetSlug = (planText: string | null): string | null => {
128
+ if (planText === null) return null;
129
+ const match = planText.match(SPEC_LINK_RE);
130
+ if (!match) return null;
131
+ const link = (match[1] ?? match[2] ?? "").trim();
132
+ const m = link.match(/^docs\/([^/]+)\/spec\.md$/);
133
+ if (!m) return null;
134
+ const candidate = m[1];
135
+ if (candidate === RESERVED_SLUG || !SLUG_RE.test(candidate)) return null;
136
+ return candidate;
137
+ };
138
+
139
+ /**
140
+ * Read-only bounded preflight (DC-05, DC-06): scans only `docs/superpowers/`
141
+ * one level deep, pairs via explicit plan links first then filename fallback,
142
+ * and reports orphaned and ambiguous items. Never mutates anything.
143
+ */
144
+ export const detectLegacyDocs = (workspace_root: string): DetectResult => {
145
+ const root = legacyRoot(workspace_root);
146
+ const raw: Array<{
147
+ slug: string;
148
+ legacy_dir: string;
149
+ spec: string | null;
150
+ plan: string | null;
151
+ sdd: boolean;
152
+ explicit: boolean;
153
+ }> = [];
154
+
155
+ if (existsSync(root)) {
156
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
157
+ const abs = path.join(root, entry.name);
158
+ const rel = posix(path.relative(workspace_root, abs));
159
+ if (entry.isDirectory()) {
160
+ const hasSpec = existsSync(path.join(abs, "spec.md"));
161
+ const hasPlan = existsSync(path.join(abs, "plan.md"));
162
+ if (!hasSpec && !hasPlan && !existsSync(path.join(abs, "sdd"))) continue;
163
+ const explicit = explicitTargetSlug(
164
+ hasPlan ? readFileSafe(path.join(abs, "plan.md")) : null,
165
+ );
166
+ raw.push({
167
+ slug: explicit ?? entry.name,
168
+ legacy_dir: rel,
169
+ spec: hasSpec ? posix(path.join(rel, "spec.md")) : null,
170
+ plan: hasPlan ? posix(path.join(rel, "plan.md")) : null,
171
+ sdd: existsSync(path.join(abs, "sdd")),
172
+ explicit: explicit !== null,
173
+ });
174
+ } else if (entry.name === "spec.md" || entry.name === "plan.md") {
175
+ // Top-level docs/superpowers/(spec|plan).md has no derivable slug.
176
+ raw.push({
177
+ slug: "",
178
+ legacy_dir: LEGACY_DIR,
179
+ spec: entry.name === "spec.md" ? rel : null,
180
+ plan: entry.name === "plan.md" ? rel : null,
181
+ sdd: false,
182
+ explicit: false,
183
+ });
184
+ }
185
+ }
186
+ }
187
+
188
+ // Colliding target slugs are ambiguous: two legacy dirs mapping to the same
189
+ // canonical slug cannot be merged safely.
190
+ const groupKey = (slug: string) => (slug === "" ? "\u0000" : slug);
191
+ const bySlug = new Map<string, number>();
192
+ for (const r of raw) bySlug.set(groupKey(r.slug), (bySlug.get(groupKey(r.slug)) ?? 0) + 1);
193
+
194
+ const entries: LegacyEntry[] = [];
195
+ for (const r of raw) {
196
+ const collided =
197
+ (bySlug.get(groupKey(r.slug)) ?? 0) > 1 ||
198
+ r.slug === RESERVED_SLUG ||
199
+ (r.slug !== "" && !SLUG_RE.test(r.slug));
200
+ const hasSpec = r.spec !== null;
201
+ const hasPlan = r.plan !== null;
202
+ let status: LegacyEntry["status"];
203
+ if (hasSpec && hasPlan) {
204
+ status = collided || r.slug === "" ? "ambiguous" : "paired";
205
+ } else if (hasSpec || hasPlan || r.sdd) {
206
+ status = collided || r.slug === "" ? "ambiguous" : "orphan";
207
+ } else {
208
+ continue;
209
+ }
210
+ entries.push({
211
+ ...r,
212
+ paired: hasSpec && hasPlan,
213
+ status,
214
+ });
215
+ }
216
+
217
+ const paired = entries.filter((e) => e.status === "paired");
218
+ const orphaned = entries.filter((e) => e.status === "orphan");
219
+ const ambiguous = entries.filter((e) => e.status === "ambiguous");
220
+ return {
221
+ legacy_dir: LEGACY_DIR,
222
+ entries,
223
+ paired,
224
+ orphaned,
225
+ ambiguous,
226
+ safe: ambiguous.length === 0,
227
+ };
228
+ };
229
+
230
+ export const migrationQuestion = (detect: DetectResult): MigrationQuestion => {
231
+ const count = detect.paired.length;
232
+ return {
233
+ prompt:
234
+ count === 0
235
+ ? "No legacy workflows found under docs/superpowers/."
236
+ : `Legacy workflows found under docs/superpowers/ (${count} safe to migrate). Copy them to docs/<slug>/ without touching the originals?`,
237
+ options: MIGRATION_CHOICES,
238
+ };
239
+ };
240
+
241
+ type PlannedCopy = {
242
+ kind: "spec" | "plan" | "sdd";
243
+ legacyName: string;
244
+ slug: string;
245
+ fromAbs: string;
246
+ fromRel: string;
247
+ toRel: string;
248
+ destAbs: string;
249
+ /** Exact bytes that will be written (already rewritten when applicable). */
250
+ bytes: Buffer;
251
+ status: MigrateItem["status"];
252
+ };
253
+
254
+ // Rewrite references from this entry's legacy paths to the canonical slug. The
255
+ // scope is bounded to the copied file's own workflow name (DC-09): other
256
+ // workflows' legacy paths are left untouched. A `/` or end-of-string boundary
257
+ // after the name prevents rewriting sibling-name-prefix paths such as
258
+ // docs/superpowers/flowchart/... when the workflow name is flow.
259
+ const rewriteLegacyReferences = (text: string, legacyName: string, slug: string): string => {
260
+ const escaped = legacyName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
261
+ return text.split(new RegExp(`docs/superpowers/${escaped}(?=/|$)`, "g")).join(`docs/${slug}`);
262
+ };
263
+
264
+ // Valid JSON flow paths may be rewritten; invalid flow state is preserved
265
+ // byte-for-byte and reported as malformed (DC-09).
266
+ const flowRewrite = (
267
+ text: string,
268
+ legacyName: string,
269
+ slug: string,
270
+ ): { ok: boolean; out: string; changed: boolean } => {
271
+ try {
272
+ JSON.parse(text);
273
+ } catch {
274
+ return { ok: false, out: text, changed: false };
275
+ }
276
+ const out = rewriteLegacyReferences(text, legacyName, slug);
277
+ return { ok: true, out, changed: out !== text };
278
+ };
279
+
280
+ const isPlanMalformed = (text: string): boolean => !/^\s*\*+Spec:\*+/im.test(text);
281
+
282
+ const readBufferSafe = (p: string): Buffer | null => {
283
+ try {
284
+ return readFileSync(p);
285
+ } catch {
286
+ return null;
287
+ }
288
+ };
289
+
290
+ // Compute the exact destination bytes and item status for a spec/plan copy.
291
+ const classifyDoc = (
292
+ kind: "spec" | "plan",
293
+ legacyName: string,
294
+ slug: string,
295
+ fromAbs: string,
296
+ ): { ok: true; bytes: Buffer; status: MigrateItem["status"] } | { ok: false; error: string } => {
297
+ const raw = readBufferSafe(fromAbs);
298
+ if (raw === null) return { ok: false, error: `unreadable source ${fromAbs}` };
299
+ if (kind === "spec") return { ok: true, bytes: raw, status: "copied" };
300
+ const text = raw.toString("utf8");
301
+ if (isPlanMalformed(text)) return { ok: true, bytes: raw, status: "malformed" };
302
+ const next = rewriteLegacyReferences(text, legacyName, slug);
303
+ return {
304
+ ok: true,
305
+ bytes: Buffer.from(next, "utf8"),
306
+ status: next === text ? "copied" : "rewritten",
307
+ };
308
+ };
309
+
310
+ const classifySdd = (
311
+ legacyName: string,
312
+ slug: string,
313
+ fromAbs: string,
314
+ toRel: string,
315
+ ): { ok: true; bytes: Buffer; status: MigrateItem["status"] } | { ok: false; error: string } => {
316
+ const raw = readBufferSafe(fromAbs);
317
+ if (raw === null) return { ok: false, error: `unreadable source ${fromAbs}` };
318
+ if (path.basename(toRel) !== "flow.json") return { ok: true, bytes: raw, status: "copied" };
319
+ const flow = flowRewrite(raw.toString("utf8"), legacyName, slug);
320
+ if (!flow.ok) return { ok: true, bytes: raw, status: "malformed" };
321
+ return {
322
+ ok: true,
323
+ bytes: Buffer.from(flow.out, "utf8"),
324
+ status: flow.changed ? "rewritten" : "copied",
325
+ };
326
+ };
327
+
328
+ // Walk a source tree (files and symlinks) producing planned file copies.
329
+ // Symlinks are resolved: escaping the workspace aborts the whole migration
330
+ // atomically; in-workspace targets are copied as regular file content. The
331
+ // destination root is the canonical (realpath) docs/<slug>/ dir resolved by the
332
+ // layout contract, so writes can never land outside the workspace.
333
+ const planTree = (
334
+ workspace: string,
335
+ legacyAbs: string,
336
+ legacyRel: string,
337
+ destRelRoot: string,
338
+ destAbsRoot: string,
339
+ legacyName: string,
340
+ slug: string,
341
+ plan: PlannedCopy[],
342
+ visits: Set<string>,
343
+ ): { ok: true } | { ok: false; error: string } => {
344
+ // Canonicalize the workspace once: a raw path containing a symlink segment
345
+ // (e.g. macOS TMPDIR=/tmp -> /private/tmp) would otherwise make every real
346
+ // source path "escape" the un-resolved workspace string. Real-to-real
347
+ // comparison keeps true escapes refused.
348
+ let workspaceReal: string;
349
+ try {
350
+ workspaceReal = realpathSync(workspace);
351
+ } catch {
352
+ workspaceReal = workspace;
353
+ }
354
+ let real: string;
355
+ try {
356
+ real = realpathSync(legacyAbs);
357
+ } catch {
358
+ return { ok: false, error: `dangling symlink refused: ${legacyRel}` };
359
+ }
360
+ if (real !== workspaceReal && !real.startsWith(workspaceReal + path.sep)) {
361
+ return { ok: false, error: `symlink escape refused: ${legacyRel}` };
362
+ }
363
+ if (visits.has(real)) return { ok: true };
364
+ visits.add(real);
365
+ for (const entry of readdirSync(real, { withFileTypes: true })) {
366
+ const fromAbs = path.join(real, entry.name);
367
+ const fromRel = posix(path.join(legacyRel, entry.name));
368
+ const toRel = posix(path.join(destRelRoot, entry.name));
369
+ if (entry.isDirectory()) {
370
+ const sub = planTree(
371
+ workspace,
372
+ fromAbs,
373
+ fromRel,
374
+ toRel,
375
+ path.join(destAbsRoot, entry.name),
376
+ legacyName,
377
+ slug,
378
+ plan,
379
+ visits,
380
+ );
381
+ if (!sub.ok) return sub;
382
+ } else if (entry.isFile() || entry.isSymbolicLink()) {
383
+ let targetAbs = fromAbs;
384
+ if (entry.isSymbolicLink()) {
385
+ let target: string;
386
+ try {
387
+ target = realpathSync(fromAbs);
388
+ } catch {
389
+ return { ok: false, error: `dangling symlink refused: ${fromRel}` };
390
+ }
391
+ if (target !== workspaceReal && !target.startsWith(workspaceReal + path.sep)) {
392
+ return { ok: false, error: `symlink escape refused: ${fromRel}` };
393
+ }
394
+ if (statSync(target).isDirectory()) {
395
+ const sub = planTree(
396
+ workspace,
397
+ fromAbs,
398
+ fromRel,
399
+ toRel,
400
+ path.join(destAbsRoot, entry.name),
401
+ legacyName,
402
+ slug,
403
+ plan,
404
+ visits,
405
+ );
406
+ if (!sub.ok) return sub;
407
+ continue;
408
+ }
409
+ targetAbs = target;
410
+ }
411
+ const classified = classifySdd(legacyName, slug, targetAbs, toRel);
412
+ if (!classified.ok) return classified;
413
+ plan.push({
414
+ kind: "sdd",
415
+ legacyName,
416
+ slug,
417
+ fromAbs,
418
+ fromRel,
419
+ toRel,
420
+ destAbs: path.join(destAbsRoot, entry.name),
421
+ bytes: classified.bytes,
422
+ status: classified.status,
423
+ });
424
+ }
425
+ }
426
+ return { ok: true };
427
+ };
428
+
429
+ const planEntry = (
430
+ workspace: string,
431
+ entry: LegacyEntry,
432
+ destDirAbs: string,
433
+ plan: PlannedCopy[],
434
+ visits: Set<string>,
435
+ ): { ok: true } | { ok: false; error: string } => {
436
+ const legacyName = entry.legacy_dir.slice(LEGACY_DIR.length + 1);
437
+ const base = (kind: "spec" | "plan") => ({
438
+ kind,
439
+ legacyName,
440
+ slug: entry.slug,
441
+ toRel: posix(path.join("docs", entry.slug, `${kind}.md`)),
442
+ destAbs: path.join(destDirAbs, `${kind}.md`),
443
+ });
444
+ if (entry.spec) {
445
+ const classified = classifyDoc(
446
+ "spec",
447
+ legacyName,
448
+ entry.slug,
449
+ path.join(workspace, entry.spec),
450
+ );
451
+ if (!classified.ok) return classified;
452
+ plan.push({
453
+ ...base("spec"),
454
+ fromAbs: path.join(workspace, entry.spec),
455
+ fromRel: entry.spec,
456
+ bytes: classified.bytes,
457
+ status: classified.status,
458
+ });
459
+ }
460
+ if (entry.plan) {
461
+ const classified = classifyDoc(
462
+ "plan",
463
+ legacyName,
464
+ entry.slug,
465
+ path.join(workspace, entry.plan),
466
+ );
467
+ if (!classified.ok) return classified;
468
+ plan.push({
469
+ ...base("plan"),
470
+ fromAbs: path.join(workspace, entry.plan),
471
+ fromRel: entry.plan,
472
+ bytes: classified.bytes,
473
+ status: classified.status,
474
+ });
475
+ }
476
+ if (entry.sdd) {
477
+ const legacySdd = posix(path.join(entry.legacy_dir, "sdd"));
478
+ return planTree(
479
+ workspace,
480
+ path.join(workspace, legacySdd),
481
+ legacySdd,
482
+ posix(path.join("docs", entry.slug, "sdd")),
483
+ path.join(destDirAbs, "sdd"),
484
+ legacyName,
485
+ entry.slug,
486
+ plan,
487
+ visits,
488
+ );
489
+ }
490
+ return { ok: true };
491
+ };
492
+
493
+ const abort = (error: string, collisions?: string[]): MigrateResult => ({
494
+ ok: false,
495
+ declined: false,
496
+ active_workflow: false,
497
+ error,
498
+ ...(collisions ? { collisions } : {}),
499
+ });
500
+
501
+ const declined = (active_workflow: boolean): MigrateResult => ({
502
+ ok: false,
503
+ declined: true,
504
+ active_workflow,
505
+ error: "legacy migration declined",
506
+ });
507
+
508
+ /**
509
+ * Copy-only migration (DC-08, DC-09, DC-10). Requires confirmed preflight
510
+ * identity: every run rescans legacy state, never writes without confirmation,
511
+ * aborts atomically on differing destinations, never overwrites identical or
512
+ * differing targets, and preserves sources byte-identical. When migration of
513
+ * the active workflow is declined, canonical authoring must stop (DC-11) — the
514
+ * caller reads `active_workflow` and halts.
515
+ */
516
+ export const migrateLegacyDocs = (input: {
517
+ workspace_root: string;
518
+ slug?: string;
519
+ confirmed: boolean;
520
+ }): MigrateResult => {
521
+ const { workspace_root, slug, confirmed } = input;
522
+ const detect = detectLegacyDocs(workspace_root);
523
+
524
+ if (confirmed !== true) {
525
+ const active = Boolean(slug && detect.paired.some((e) => e.slug === slug));
526
+ return declined(active);
527
+ }
528
+
529
+ if (!detect.safe) {
530
+ const names = detect.ambiguous.map((e) => e.legacy_dir).join(", ");
531
+ return abort(
532
+ `legacy migration aborted: ambiguous legacy entries (${names}); resolve before migrating`,
533
+ );
534
+ }
535
+
536
+ // SDD ignore gate (DC-10): refuse SDD copying until the canonical sdd dir is
537
+ // gitignored. This also guards against creating a docs-only divergent copy of
538
+ // an active legacy workflow (CA-17).
539
+ for (const entry of detect.paired) {
540
+ if (entry.sdd && !sddIgnoreActive(workspace_root, entry.slug)) {
541
+ return abort(
542
+ `legacy migration refused: docs/${entry.slug}/sdd/ is not gitignored (add 'docs/*/sdd/' to .gitignore) so active SDD state cannot be copied safely`,
543
+ );
544
+ }
545
+ }
546
+
547
+ const plan: PlannedCopy[] = [];
548
+ for (const entry of detect.paired) {
549
+ // Resolve the destination through the canonical layout contract (DC-01,
550
+ // DC-02): a docs/<slug> symlink escaping the workspace is refused here,
551
+ // before any write is planned, instead of silently writing outside.
552
+ const layout = resolveCanonicalLayout({ workspace_root, slug: entry.slug });
553
+ if (!layout.ok) {
554
+ return abort(
555
+ `legacy migration aborted: destination docs/${entry.slug} refused (${layout.error})`,
556
+ );
557
+ }
558
+ // Per-entry visit set: a symlinked subtree shared between workflows is
559
+ // copied for every workflow, not silently skipped after the first.
560
+ const visits = new Set<string>();
561
+ const planned = planEntry(workspace_root, entry, layout.layout.dir, plan, visits);
562
+ if (!planned.ok) return abort(planned.error);
563
+ }
564
+
565
+ // Preflight classification (no writes yet): differing destinations abort the
566
+ // whole migration atomically; destinations matching the exact bytes that
567
+ // would be written are already migrated (idempotent retries stay green).
568
+ // A destination that is a directory, or a symlink to one, is a collision —
569
+ // readFileSync would throw EISDIR uncaught, so any read failure is treated
570
+ // as a collision and aborts structurally (D4).
571
+ const collisions: string[] = [];
572
+ for (const item of plan) {
573
+ if (!existsSync(item.destAbs)) continue;
574
+ let identical = false;
575
+ try {
576
+ identical =
577
+ !lstatSync(item.destAbs).isDirectory() && readFileSync(item.destAbs).equals(item.bytes);
578
+ } catch {
579
+ identical = false;
580
+ }
581
+ if (identical) {
582
+ item.status = "already_migrated";
583
+ } else {
584
+ collisions.push(item.toRel);
585
+ }
586
+ }
587
+ if (collisions.length > 0) {
588
+ return abort(
589
+ `legacy migration aborted: collision on differing destinations (${collisions.join(", ")}); no files were written`,
590
+ collisions,
591
+ );
592
+ }
593
+
594
+ // Execute staged copies: write to a temp sibling then rename so a partial
595
+ // failure never leaves a torn destination, and a retry stays idempotent.
596
+ const report: MigrateReport = {
597
+ legacy_dir: LEGACY_DIR,
598
+ copied: [],
599
+ rewritten: [],
600
+ already_migrated: [],
601
+ malformed: [],
602
+ collisions: [],
603
+ items: [],
604
+ };
605
+
606
+ for (const item of plan) {
607
+ if (item.status === "already_migrated") {
608
+ report.already_migrated.push(item.toRel);
609
+ report.items.push({
610
+ kind: item.kind,
611
+ from: item.fromRel,
612
+ to: item.toRel,
613
+ status: "already_migrated",
614
+ });
615
+ continue;
616
+ }
617
+ const tmp = `${item.destAbs}.workit-tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
618
+ try {
619
+ mkdirSync(path.dirname(item.destAbs), { recursive: true });
620
+ writeFileSync(tmp, item.bytes);
621
+ renameSync(tmp, item.destAbs);
622
+ } catch (error) {
623
+ try {
624
+ rmSync(tmp, { force: true });
625
+ } catch {
626
+ // best-effort temp cleanup
627
+ }
628
+ return abort(
629
+ `legacy migration retry required: failed to copy ${item.fromRel}: ${error instanceof Error ? error.message : String(error)}`,
630
+ );
631
+ }
632
+ if (item.status === "malformed") report.malformed.push(item.toRel);
633
+ else if (item.status === "rewritten") report.rewritten.push(item.toRel);
634
+ else report.copied.push(item.toRel);
635
+ report.items.push({ kind: item.kind, from: item.fromRel, to: item.toRel, status: item.status });
636
+ }
637
+
638
+ return { ok: true, declined: false, data: report };
639
+ };
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from
2
2
  import { execFileSync } from "node:child_process";
3
3
  import path from "node:path";
4
4
  import { configDir } from "./config";
5
+ import { resolveCanonicalLayout } from "./docs-layout";
5
6
 
6
7
  const configPath = () =>
7
8
  process.env.WORKFLOW_DOCS_REPO_CONFIG ?? path.join(configDir(), "docs-repo.json");
@@ -111,8 +112,6 @@ const specRepos = (specText: string): string => {
111
112
  return match?.[1]?.trim() ?? "—";
112
113
  };
113
114
 
114
- const SLUG_RE = /^[a-z0-9][a-z0-9._-]*$/i;
115
-
116
115
  export const promoteSpec = (
117
116
  workspaceRoot: string,
118
117
  slug: string,
@@ -121,23 +120,26 @@ export const promoteSpec = (
121
120
  | { ok: true; target_dir: string; files: string[]; index_updated: boolean }
122
121
  | { ok: false; error: string; findings?: unknown[] } => {
123
122
  if (!opts.confirmed) return { ok: false, error: "confirmed: true required" };
124
- if (!SLUG_RE.test(slug)) return { ok: false, error: `invalid slug: ${JSON.stringify(slug)}` };
123
+ // One shared contained path contract (DC-01, DC-02): invalid slugs and
124
+ // non-canonical workspace roots fail here before any read or write.
125
+ const resolved = resolveCanonicalLayout({ workspace_root: workspaceRoot, slug });
126
+ if (!resolved.ok) return { ok: false, error: resolved.error };
127
+ const workspaceRootCanonical = resolved.layout.workspace;
125
128
  const repoPath = docsRepoPath();
126
129
  if (!repoPath) return { ok: false, error: "docs repo not linked — run workflow_docs_repo_link" };
127
130
  const repoValid = validateDocsRepo(repoPath);
128
131
  if (!repoValid.ok) return { ok: false, error: repoValid.error };
129
-
130
132
  const specRel = path.posix.join("docs", slug, "spec.md");
131
133
  const planRel = path.posix.join("docs", slug, "plan.md");
132
- const specText = readSafe(path.join(workspaceRoot, specRel));
134
+ const specText = readSafe(resolved.layout.spec);
133
135
  if (specText === null) return { ok: false, error: `docs/${slug}/spec.md not found` };
134
136
 
135
- const planText = readSafe(path.join(workspaceRoot, planRel));
137
+ const planText = readSafe(resolved.layout.plan);
136
138
  if (planText !== null) {
137
139
  const validated = docsValidate({
138
140
  spec_path: specRel,
139
141
  plan_path: planRel,
140
- workspace_root: workspaceRoot,
142
+ workspace_root: workspaceRootCanonical,
141
143
  });
142
144
  if (validated.ok === false) return { ok: false, error: validated.error };
143
145
  }
@@ -154,14 +156,14 @@ export const promoteSpec = (
154
156
 
155
157
  // SDD working state must be gitignored before promotion
156
158
  if (!opts.force) {
157
- const sddDir = path.join(workspaceRoot, "docs", slug, "sdd");
159
+ const sddDir = path.join(workspaceRootCanonical, "docs", slug, "sdd");
158
160
  if (existsSync(sddDir)) {
159
161
  try {
160
162
  execFileSync(
161
163
  "git",
162
164
  [
163
165
  "-C",
164
- workspaceRoot,
166
+ workspaceRootCanonical,
165
167
  "check-ignore",
166
168
  path.posix.join("docs", slug, "sdd", "progress.md"),
167
169
  ],