@brainervirus/workit-core 0.6.1 → 0.7.1
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.
- package/package.json +3 -7
- package/scripts/doctor-check.ts +20 -0
- package/scripts/install-cursor-plugin.sh +51 -28
- package/scripts/install-opencode-plugin.sh +19 -21
- package/scripts/rewrite-workspace-deps.ts +15 -9
- package/scripts/sync-runtime.sh +71 -19
- package/scripts/vendor-assets.ts +37 -0
- package/skills/wk-implement/SKILL.md +2 -2
- package/skills/wk-pr/SKILL.md +1 -1
- package/src/core/boundary.ts +27 -0
- package/src/core/branch-policy.ts +63 -0
- package/src/core/branch.ts +30 -16
- package/src/core/config.ts +193 -31
- package/src/core/docs-layout.ts +251 -0
- package/src/core/docs-migration.ts +639 -0
- package/src/core/docs-repo.ts +11 -9
- package/src/core/docs-validate.ts +18 -6
- package/src/core/doctor.ts +801 -0
- package/src/core/flow-state.ts +1579 -141
- package/src/core/git.ts +22 -5
- package/src/{tools/handoff.ts → core/handoff-tools.ts} +5 -57
- package/src/core/hygiene.ts +26 -12
- package/src/core/init.ts +43 -11
- package/src/core/logger.ts +321 -0
- package/src/core/package-root.ts +28 -0
- package/src/core/ports/init-toolkit-status.ts +1 -1
- package/src/core/ports/vcs-verify-token.ts +1 -1
- package/src/core/ports/youtrack-api.ts +1 -1
- package/src/core/ports/youtrack-verify-token.ts +1 -1
- package/src/core/pr-create.ts +116 -21
- package/src/core/registration.ts +215 -0
- package/src/core/repo-context.ts +447 -0
- package/src/core/repo-tools.ts +23 -0
- package/src/core/safe-write.ts +22 -0
- package/src/core/scripts.ts +3 -44
- package/src/core/sdd.ts +45 -28
- package/src/core/setup-state.ts +54 -0
- package/src/core/setup.ts +1216 -0
- package/src/core/skill-manifests.ts +95 -0
- package/src/core/support-matrix.ts +12 -0
- package/src/core/sync-runtime.ts +348 -0
- package/src/core/templates.ts +2 -2
- package/src/core/vcs-config.ts +107 -37
- package/src/core/verify-project.ts +181 -0
- package/src/core/workspaces.ts +136 -17
- package/src/core/youtrack-tools.ts +228 -0
- package/src/core/youtrack.ts +125 -67
- package/templates/execution-contract.md +9 -7
- package/templates/superpowers-doc-contract.md +4 -3
- package/scripts/_shared/common.sh +0 -173
- package/scripts/changelog-context.sh +0 -42
- package/scripts/docs-refresh-context.sh +0 -40
- package/scripts/init/apply.sh +0 -5
- package/scripts/init/status.sh +0 -5
- package/scripts/init/toolkit-status.sh +0 -5
- package/scripts/pr-create.sh +0 -5
- package/scripts/pr-ready-context.sh +0 -88
- package/scripts/present/ascii-wireframe.sh +0 -5
- package/scripts/present/flow-diagram.sh +0 -5
- package/scripts/release-notes-context.sh +0 -40
- package/scripts/vcs/config.sh +0 -5
- package/scripts/vcs/merged-style.sh +0 -5
- package/scripts/vcs/token-create-urls.sh +0 -5
- package/scripts/vcs/verify-token.sh +0 -5
- package/scripts/verify-project.sh +0 -140
- package/scripts/youtrack/api.sh +0 -5
- package/scripts/youtrack/config.sh +0 -5
- package/scripts/youtrack/greeting.sh +0 -5
- package/scripts/youtrack/parse-duration.sh +0 -5
- package/scripts/youtrack/token-create-url.sh +0 -5
- package/scripts/youtrack/verify-token.sh +0 -5
- package/scripts/youtrack/work-date-ms.sh +0 -5
- package/src/tools/docs-repo.ts +0 -51
- package/src/tools/flow.ts +0 -99
- package/src/tools/index.ts +0 -22
- package/src/tools/present.ts +0 -49
- package/src/tools/repo.ts +0 -490
- package/src/tools/rules.ts +0 -30
- package/src/tools/sdd.ts +0 -216
- package/src/tools/templates.ts +0 -27
- 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
|
+
};
|
package/src/core/docs-repo.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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(
|
|
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:
|
|
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(
|
|
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
|
-
|
|
166
|
+
workspaceRootCanonical,
|
|
165
167
|
"check-ignore",
|
|
166
168
|
path.posix.join("docs", slug, "sdd", "progress.md"),
|
|
167
169
|
],
|