@brainervirus/workit-core 2.1.0 → 2.1.2

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 (43) hide show
  1. package/README.md +2 -2
  2. package/package.json +2 -3
  3. package/scripts/sync-runtime.sh +9 -5
  4. package/src/core/init.ts +1 -256
  5. package/src/core/policy-resolver.ts +0 -45
  6. package/src/core/repo-context.ts +1 -1
  7. package/src/core/rules.ts +1 -61
  8. package/src/core/session-context.ts +111 -0
  9. package/src/core/task-engine.ts +24 -2
  10. package/src/core/task-store.ts +185 -0
  11. package/src/core/templates.ts +1 -26
  12. package/src/core/vcs-config.ts +0 -4
  13. package/src/core/workspaces.ts +1 -2
  14. package/src/core.ts +7 -1
  15. package/src/core/config-guard.ts +0 -27
  16. package/src/core/doc-render.ts +0 -14
  17. package/src/core/docs-layout.ts +0 -279
  18. package/src/core/docs-migration.ts +0 -639
  19. package/src/core/docs-repo.ts +0 -233
  20. package/src/core/docs-validate.ts +0 -393
  21. package/src/core/parse-sections.ts +0 -24
  22. package/src/core/ports/init-apply.ts +0 -15
  23. package/src/core/ports/init-status.ts +0 -4
  24. package/src/core/ports/init-toolkit-status.ts +0 -4
  25. package/src/core/ports/pr-create.ts +0 -30
  26. package/src/core/ports/present-ascii.ts +0 -10
  27. package/src/core/ports/present-flow.ts +0 -10
  28. package/src/core/ports/vcs-config.ts +0 -14
  29. package/src/core/ports/vcs-merged-style.ts +0 -5
  30. package/src/core/ports/vcs-verify-token.ts +0 -4
  31. package/src/core/ports/youtrack-api.ts +0 -19
  32. package/src/core/ports/youtrack-config.ts +0 -21
  33. package/src/core/ports/youtrack-greeting.ts +0 -10
  34. package/src/core/ports/youtrack-parse-duration.ts +0 -14
  35. package/src/core/ports/youtrack-token-create-url.ts +0 -4
  36. package/src/core/ports/youtrack-verify-token.ts +0 -12
  37. package/src/core/ports/youtrack-work-date-ms.ts +0 -10
  38. package/src/core/present.ts +0 -109
  39. package/src/core/repo-tool.ts +0 -12
  40. package/src/core/repo-tools.ts +0 -22
  41. package/src/core/sync-runtime.ts +0 -375
  42. package/src/core/verify-parse.ts +0 -29
  43. package/src/core/verify-project.ts +0 -181
@@ -105,6 +105,73 @@ const metadataLockSchema = z
105
105
  externalAction: z.literal(true).optional(),
106
106
  })
107
107
  .strict();
108
+ /** One task's listing facts, kept in `.workit/index.json` so per-turn host
109
+ * hooks can find a session's task without parsing every full record. */
110
+ export type TaskIndexEntry = {
111
+ id: Id;
112
+ revision: Revision;
113
+ status: TaskRecord["status"];
114
+ createdAt: Utc;
115
+ updatedAt: Utc;
116
+ objective: string;
117
+ source: { host: string; kind: Provenance["kind"] };
118
+ progress: { summary: string; nextAction: string | null };
119
+ /** Host sessions bound to the task: the intent session (workerId null) and worker sessions. */
120
+ sessions: { host: string; handle: string; workerId: Id | null }[];
121
+ /** Stat signature of the task file the entry was derived from. */
122
+ file: string;
123
+ };
124
+ const INDEX_VERSION = 1;
125
+ const hostSession = (value: unknown): { host: string; handle: string } | null =>
126
+ isObject(value) &&
127
+ value.kind === "host" &&
128
+ typeof value.host === "string" &&
129
+ typeof value.handle === "string"
130
+ ? { host: value.host, handle: value.handle }
131
+ : null;
132
+ const indexEntry = (task: TaskRecord, file: string): TaskIndexEntry => {
133
+ const sessions: TaskIndexEntry["sessions"] = [];
134
+ const intent = hostSession(task.intent.provenance.session);
135
+ if (intent) sessions.push({ ...intent, workerId: null });
136
+ for (const worker of task.workers) {
137
+ const session = hostSession(worker.data.session);
138
+ if (session) sessions.push({ ...session, workerId: worker.id });
139
+ }
140
+ return {
141
+ id: task.id,
142
+ revision: task.revision,
143
+ status: task.status,
144
+ createdAt: task.createdAt,
145
+ updatedAt: task.updatedAt,
146
+ objective: task.intent.data.objective,
147
+ source: { host: task.intent.provenance.host, kind: task.intent.provenance.kind },
148
+ progress: { summary: task.progress.summary, nextAction: task.progress.nextAction },
149
+ sessions,
150
+ file,
151
+ };
152
+ };
153
+ /** Cheap change detector. Atomic replacement can reuse inodes and file
154
+ * timestamps are often only jiffy-granular, so a signature alone may repeat
155
+ * across rapid rewrites; see `racySignature`. */
156
+ export const fileSignature = (file: string): string | null => {
157
+ try {
158
+ const stat = fs.statSync(file, { bigint: true });
159
+ return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
160
+ } catch {
161
+ return null;
162
+ }
163
+ };
164
+ const RACY_WINDOW_NS = 2_000_000_000n;
165
+ /** Like Git's racy-index rule: a file changed within the timestamp-granularity
166
+ * window may be rewritten again without changing its signature, so callers
167
+ * must re-read it instead of trusting a cached signature. */
168
+ export const racySignature = (signature: string): boolean => {
169
+ // ctime catches content written with a back-dated mtime (cp -p, touch -d).
170
+ const [mtime = 0n, ctime = 0n] = signature.split(":").slice(3, 5).map(BigInt);
171
+ const changed = mtime > ctime ? mtime : ctime;
172
+ return BigInt(Date.now()) * 1_000_000n - changed < RACY_WINDOW_NS;
173
+ };
174
+
108
175
  export type RecoveryCandidate = {
109
176
  target: "task" | "workspace";
110
177
  path: string;
@@ -258,6 +325,58 @@ export class TaskStore {
258
325
  return success(null, null, tasks);
259
326
  }
260
327
 
328
+ /**
329
+ * List tasks from `.workit/index.json` without parsing full records. Each
330
+ * entry is checked against its task file's stat signature; missing, stale,
331
+ * or corrupt entries are rebuilt from the full (validated) record and the
332
+ * index is rewritten best-effort. Errors match `listTasks()`.
333
+ */
334
+ listTaskIndex(): Result<TaskIndexEntry[]> {
335
+ if (!fs.existsSync(this.tasksDir)) return success(null, null, []);
336
+ let names: string[];
337
+ try {
338
+ names = fs.readdirSync(this.tasksDir).filter((name) => name.endsWith(".json"));
339
+ } catch (error) {
340
+ return failure("storage_error", `unable to list tasks: ${String(error)}`, {
341
+ path: this.tasksDir,
342
+ });
343
+ }
344
+ const workspace = this.readWorkspace();
345
+ if (!workspace.ok) return workspace as Result<never>;
346
+ if (!workspace.data)
347
+ return failure("recovery_required", "task workspace binding is invalid", {
348
+ path: this.workspacePath,
349
+ });
350
+ const stored = this.readIndex(workspace.data.id);
351
+ const entries: TaskIndexEntry[] = [];
352
+ let changed = Object.keys(stored).length !== names.length;
353
+ for (const name of names.sort()) {
354
+ const file = path.join(this.tasksDir, name);
355
+ const signature = fileSignature(file);
356
+ if (signature === null) {
357
+ changed = true;
358
+ continue;
359
+ }
360
+ const cached = stored[name.slice(0, -5)];
361
+ if (cached && cached.file === signature && !racySignature(signature)) {
362
+ entries.push(cached);
363
+ continue;
364
+ }
365
+ const item = this.readRecord<TaskRecord>(file, taskRecordSchema);
366
+ if (!item.exists) continue;
367
+ if (!item.result.ok) return item.result as Result<never>;
368
+ if (!validId(name.slice(0, -5)) || item.result.data.id !== name.slice(0, -5))
369
+ return failure("recovery_required", "task filename and record ID differ", { path: name });
370
+ if (item.result.data.workspaceId !== workspace.data.id)
371
+ return failure("recovery_required", "task workspace binding is invalid", { path: name });
372
+ const entry = indexEntry(item.result.data, signature);
373
+ if (!cached || canonicalJson(cached) !== canonicalJson(entry)) changed = true;
374
+ entries.push(entry);
375
+ }
376
+ if (changed) this.writeIndex(workspace.data.id, entries);
377
+ return success(null, null, entries);
378
+ }
379
+
261
380
  readWorkspace(): Result<WorkspaceRecord | null> {
262
381
  const item = this.readRecord<WorkspaceRecord>(this.workspacePath, workspaceRecordSchema);
263
382
  if (!item.exists) return success(null, null, null);
@@ -1108,6 +1227,7 @@ export class TaskStore {
1108
1227
  fs.renameSync(temporary, file);
1109
1228
  temporary = undefined;
1110
1229
  this.fsyncDirectory(path.dirname(file));
1230
+ if (path.dirname(file) === this.tasksDir) this.indexTaskWrite(file, value as TaskRecord);
1111
1231
  return success(null, null, value);
1112
1232
  } catch (error) {
1113
1233
  return failure("storage_error", `snapshot replacement failed: ${String(error)}`, {
@@ -1121,6 +1241,68 @@ export class TaskStore {
1121
1241
  }
1122
1242
  }
1123
1243
 
1244
+ private readIndex(workspaceId: Id): Record<string, TaskIndexEntry> {
1245
+ try {
1246
+ const value = JSON.parse(fs.readFileSync(this.indexPath, "utf8")) as unknown;
1247
+ if (
1248
+ !isObject(value) ||
1249
+ value.version !== INDEX_VERSION ||
1250
+ value.workspaceId !== workspaceId ||
1251
+ !isObject(value.tasks)
1252
+ )
1253
+ return {};
1254
+ const tasks: Record<string, TaskIndexEntry> = {};
1255
+ for (const [id, entry] of Object.entries(value.tasks))
1256
+ if (
1257
+ isObject(entry) &&
1258
+ entry.id === id &&
1259
+ typeof entry.file === "string" &&
1260
+ typeof entry.status === "string" &&
1261
+ typeof entry.updatedAt === "string" &&
1262
+ Array.isArray(entry.sessions) &&
1263
+ isObject(entry.progress) &&
1264
+ isObject(entry.source)
1265
+ )
1266
+ tasks[id] = entry as TaskIndexEntry;
1267
+ return tasks;
1268
+ } catch {
1269
+ return {};
1270
+ }
1271
+ }
1272
+
1273
+ /** The index is a disposable cache: write it atomically, never fail the caller. */
1274
+ private writeIndex(workspaceId: Id, entries: TaskIndexEntry[]) {
1275
+ const temporary = `${this.indexPath}.${process.pid}.${randomUUID()}.tmp`;
1276
+ try {
1277
+ fs.writeFileSync(
1278
+ temporary,
1279
+ JSON.stringify({
1280
+ version: INDEX_VERSION,
1281
+ workspaceId,
1282
+ tasks: Object.fromEntries(entries.map((entry) => [entry.id, entry])),
1283
+ }),
1284
+ { mode: 0o600, flag: "wx" },
1285
+ );
1286
+ fs.renameSync(temporary, this.indexPath);
1287
+ } catch {
1288
+ try {
1289
+ fs.unlinkSync(temporary);
1290
+ } catch {}
1291
+ }
1292
+ }
1293
+
1294
+ private indexTaskWrite(file: string, task: TaskRecord) {
1295
+ try {
1296
+ const signature = fileSignature(file);
1297
+ if (signature === null) return;
1298
+ const tasks = this.readIndex(task.workspaceId);
1299
+ tasks[task.id] = indexEntry(task, signature);
1300
+ this.writeIndex(task.workspaceId, Object.values(tasks));
1301
+ } catch {
1302
+ // A stale index entry is repaired by the next listTaskIndex().
1303
+ }
1304
+ }
1305
+
1124
1306
  private saveRecovery(file: string, bytes: string | Buffer) {
1125
1307
  const target = path.basename(file) === "workspace.json" ? "workspace" : "task";
1126
1308
  const id = target === "task" ? path.basename(file, ".json") : "workspace";
@@ -1298,6 +1480,9 @@ export class TaskStore {
1298
1480
  private get workspacePath() {
1299
1481
  return path.join(this.workitDir, "workspace.json");
1300
1482
  }
1483
+ private get indexPath() {
1484
+ return path.join(this.workitDir, "index.json");
1485
+ }
1301
1486
  private get lockPath() {
1302
1487
  return path.join(this.workitDir, "metadata.lock");
1303
1488
  }
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { existsSync, readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { configDir } from "./config";
4
4
  import { assetRoot } from "./package-root";
@@ -20,28 +20,3 @@ export const readTemplate = (
20
20
  content: readFileSync(path.join(repoRoot, "templates", `${name}.md`), "utf8"),
21
21
  };
22
22
  };
23
-
24
- export const writeTemplate = (
25
- name: TemplateName,
26
- content: string,
27
- confirmed: boolean,
28
- ): { ok: true; path: string } | { ok: false; error: string } => {
29
- if (!confirmed) return { ok: false, error: "confirmed: true required" };
30
- const file = templatePath(name);
31
- mkdirSync(path.dirname(file), { recursive: true });
32
- writeFileSync(file, content, "utf8");
33
- return { ok: true, path: file };
34
- };
35
-
36
- export const listTemplates = (): {
37
- name: TemplateName;
38
- source: "config" | "repo" | "missing";
39
- path: string;
40
- }[] =>
41
- (["issue-update", "greeting", "headers"] as TemplateName[]).map((name) => {
42
- const cfg = templatePath(name);
43
- const repoFile = path.join(repoRoot, "templates", `${name}.md`);
44
- if (existsSync(cfg)) return { name, source: "config", path: cfg };
45
- if (existsSync(repoFile)) return { name, source: "repo", path: repoFile };
46
- return { name, source: "missing", path: cfg };
47
- });
@@ -303,10 +303,6 @@ export function vcsCliIdentity(cwd?: string): Record<string, any> {
303
303
  }
304
304
 
305
305
  /** Legacy command name, now verifies native CLI auth rather than a separate token. */
306
- export async function vcsVerifyToken(): Promise<Record<string, any>> {
307
- return vcsCliIdentity();
308
- }
309
-
310
306
  /** Port of scripts/vcs/merged-style.sh — recent merged MR/PR bodies for style reference. */
311
307
  export function mergedPrStyle(limit = 6, cwd?: string): Record<string, any> {
312
308
  const cfg = vcsConfig("load", cwd);
@@ -448,8 +448,7 @@ export const resolveWorkspaceFromEntries = <T extends { name: string; glob: stri
448
448
  // macOS/Windows tmpdir symlinks (/var -> /private/var): git's
449
449
  // --show-toplevel returns the realpath while config globs are usually
450
450
  // written with the logical path, so a workspace would silently stop
451
- // matching on macOS. Match both forms on each side — same class as the
452
- // docs-migration escape-guard realpath comparison; on Linux both forms
451
+ // matching on macOS. Match both forms on each side; on Linux both forms
453
452
  // are identical so behavior is unchanged.
454
453
  const targets = [cwd, realpathOf(cwd)].map((p) => p.replaceAll("\\", "/"));
455
454
  const matches: T[] = [];
package/src/core.ts CHANGED
@@ -60,7 +60,13 @@ export type {
60
60
  Result as ContractResult,
61
61
  } from "./core/task-contract";
62
62
  export { TaskStore, runtimeVersion } from "./core/task-store";
63
- export type { MetadataLock, ProcessEvidence, RecoveryInput } from "./core/task-store";
63
+ export type {
64
+ MetadataLock,
65
+ ProcessEvidence,
66
+ RecoveryInput,
67
+ TaskIndexEntry,
68
+ } from "./core/task-store";
69
+ export { sessionCompactContext, unfinishedTaskOffer } from "./core/session-context";
64
70
  export { compactTaskContext, reconcileResume } from "./core/task-context";
65
71
  export type {
66
72
  CompactTaskContext,
@@ -1,27 +0,0 @@
1
- import { initStatus } from "./init";
2
-
3
- export const ALL_ITEM_IDS = ["youtrack_json", "youtrack_token", "vcs_json"];
4
- export const CONFIG_GAP_MARKER = "workflow config missing";
5
-
6
- export function describeConfigGaps(scope?: string[]): { missing: string[]; ok: boolean } {
7
- const all = scope ?? ALL_ITEM_IDS;
8
- try {
9
- const status: unknown = initStatus();
10
- if (!status || typeof status !== "object" || (status as Record<string, unknown>).error)
11
- return { missing: all, ok: false };
12
- const items = (status as Record<string, unknown>).items;
13
- if (!Array.isArray(items) || items.length === 0) return { missing: all, ok: false };
14
- const known = all;
15
- const missing = items
16
- .filter((item) => item && (item as Record<string, unknown>).ok === false)
17
- .map((item) => String((item as Record<string, unknown>).id))
18
- .filter((id) => known.includes(id));
19
- return { missing, ok: missing.length === 0 };
20
- } catch {
21
- return { missing: all, ok: false };
22
- }
23
- }
24
-
25
- export function configGuardError(missing: string[]): string {
26
- return `${CONFIG_GAP_MARKER}: ${missing.join(", ")}. Run \`npx workit init\` or \`/wk-init\` to configure.`;
27
- }
@@ -1,14 +0,0 @@
1
- export const MAX_LINES = 150;
2
- export const MAX_BYTES = 8192;
3
- export const MAX_MERMAID = 3;
4
-
5
- const MERMAID_FENCE = /^```mermaid\s*$/gm;
6
-
7
- export const shouldRenderDoc = (text: string): boolean => {
8
- const lines = text.split(/\r?\n/);
9
- if (lines[lines.length - 1] === "") lines.pop();
10
- if (lines.length > MAX_LINES) return false;
11
- if (Buffer.byteLength(text, "utf8") > MAX_BYTES) return false;
12
- const mermaidCount = text.match(MERMAID_FENCE)?.length ?? 0;
13
- return mermaidCount <= MAX_MERMAID;
14
- };
@@ -1,279 +0,0 @@
1
- import { existsSync, lstatSync, mkdirSync, realpathSync, statSync } from "node:fs";
2
- import path from "node:path";
3
-
4
- // One canonical document path contract (DC-01, DC-02, DC-04, DC-14): workspace
5
- // root, slug, and spec/plan pair resolution all funnel through here so every
6
- // document/flow/SDD consumer on both hosts enforces the same containment rules.
7
-
8
- export type CanonicalLayout = {
9
- /** Canonical (realpath) workspace root. */
10
- workspace: string;
11
- /** Validated slug. */
12
- slug: string;
13
- /** Canonical path of docs/. */
14
- docs: string;
15
- /** Canonical path of docs/<slug>/. */
16
- dir: string;
17
- /** Canonical path of docs/<slug>/spec.md. */
18
- spec: string;
19
- /** Canonical path of docs/<slug>/plan.md. */
20
- plan: string;
21
- /** Canonical path of docs/<slug>/sdd/. */
22
- sdd: string;
23
- };
24
-
25
- export type LayoutResult = { ok: true; layout: CanonicalLayout } | { ok: false; error: string };
26
-
27
- export type LegacyProbe = {
28
- /** `.superpowers/sdd` exists. */
29
- legacy_sdd: boolean;
30
- /** `docs/superpowers/` exists. */
31
- superpowers_dir: boolean;
32
- };
33
-
34
- export type PrepareResult =
35
- | { ok: true; layout: CanonicalLayout; created: string[]; legacy: LegacyProbe }
36
- | { ok: false; error: string };
37
-
38
- const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
39
-
40
- /** Reserved legacy root (DC-05): never resolve or prepare a slug under it. */
41
- const LEGACY_SLUG = "superpowers";
42
-
43
- const posix = (p: string) => p.split(path.sep).join("/");
44
-
45
- // Realpath the nearest existing ancestor of `candidate` and reject the result
46
- // when it escapes `base` (base must already be canonical). The returned path is
47
- // canonical where it exists and joined for the non-existent tail.
48
- const canonicalize = (base: string, candidate: string): string => {
49
- const abs = path.resolve(base, candidate);
50
- let ancestor = abs;
51
- while (!existsSync(ancestor)) ancestor = path.dirname(ancestor);
52
- let real: string;
53
- try {
54
- real = realpathSync(ancestor);
55
- } catch (error) {
56
- // macOS realpath fails with EACCES on a mode-000 file while Linux succeeds;
57
- // an existing non-symlink file cannot escape the workspace, so resolve the
58
- // nearest existing directory instead (symlinks keep the strict path).
59
- if (
60
- (error as NodeJS.ErrnoException).code === "EACCES" &&
61
- !lstatSync(ancestor).isSymbolicLink()
62
- ) {
63
- real = path.join(realpathSync(path.dirname(ancestor)), path.basename(ancestor));
64
- } else {
65
- throw error;
66
- }
67
- }
68
- if (real !== base && !real.startsWith(base + path.sep)) {
69
- throw new Error(`path must stay inside repository root: ${candidate}`);
70
- }
71
- return path.join(real, path.relative(ancestor, abs));
72
- };
73
-
74
- const buildLayout = (workspace: string, slug: string): CanonicalLayout => {
75
- const docs = canonicalize(workspace, "docs");
76
- const dir = canonicalize(workspace, path.join(docs, slug));
77
- return {
78
- workspace,
79
- slug,
80
- docs,
81
- dir,
82
- spec: path.join(dir, "spec.md"),
83
- plan: path.join(dir, "plan.md"),
84
- sdd: path.join(dir, "sdd"),
85
- };
86
- };
87
-
88
- /** Read-only legacy detection (DC-04): never mutates legacy state. */
89
- export const probeLegacyDocs = (workspace: string): LegacyProbe => ({
90
- legacy_sdd: existsSync(path.join(workspace, ".superpowers", "sdd")),
91
- superpowers_dir: existsSync(path.join(workspace, "docs", LEGACY_SLUG)),
92
- });
93
-
94
- /**
95
- * The one canonical workspace/slug/pair resolver. Given a slug or any spec/plan
96
- * pair path, returns canonical paths for the pair. Rejects absolute paths,
97
- * traversal, symlink escapes, cross-slug pairs, wrong basenames, and arbitrary
98
- * or legacy locations (DC-01, DC-02). Creates nothing.
99
- */
100
- export const resolveCanonicalLayout = (input: {
101
- workspace_root: string;
102
- slug?: string;
103
- spec_path?: string;
104
- plan_path?: string;
105
- }): LayoutResult => {
106
- const { workspace_root, slug, spec_path, plan_path } = input;
107
- if (!workspace_root) return { ok: false, error: "workspace_root required" };
108
- let workspace: string;
109
- try {
110
- workspace = realpathSync(path.resolve(workspace_root));
111
- } catch {
112
- return { ok: false, error: `workspace root not found: ${workspace_root}` };
113
- }
114
-
115
- let derived: string | null = null;
116
- for (const [candidate, kind] of [
117
- [spec_path, "spec"],
118
- [plan_path, "plan"],
119
- ] as const) {
120
- if (!candidate) continue;
121
- if (path.isAbsolute(candidate)) {
122
- return { ok: false, error: `absolute path not allowed: ${candidate}` };
123
- }
124
- // Exact-spelling contract (DC-01): the caller path must be written as the
125
- // canonical `docs/<slug>/spec.md` / `docs/<slug>/plan.md` — no `./`,
126
- // no `..` segments, no repeated or trailing separators. The strict regex
127
- // below rejects those spellings before any bytes are read or resolved.
128
- const spelling = posix(candidate);
129
- const match = spelling.match(/^docs\/([^/]+)\/(spec|plan)\.md$/);
130
- if (!match) {
131
- return {
132
- ok: false,
133
- error: `path must be docs/<slug>/(spec|plan).md inside workspace_root: ${candidate}`,
134
- };
135
- }
136
- const pathSlug = match[1];
137
- if (pathSlug === LEGACY_SLUG) {
138
- return { ok: false, error: `legacy path not allowed: ${candidate}` };
139
- }
140
- if (!SLUG_RE.test(pathSlug)) {
141
- return { ok: false, error: `invalid slug derived from path: ${JSON.stringify(pathSlug)}` };
142
- }
143
- if (match[2] !== kind) {
144
- return {
145
- ok: false,
146
- error: `wrong basename for ${kind}: expected ${kind}.md, got ${path.basename(candidate)}`,
147
- };
148
- }
149
- if (derived && derived !== pathSlug) {
150
- return {
151
- ok: false,
152
- error: "cross-slug pair: spec_path and plan_path must share the same docs/<slug>/",
153
- };
154
- }
155
- derived = pathSlug;
156
- // Symlink/canonical containment (DC-02): after the exact-spelling match,
157
- // resolve the canonical path so a symlinked docs/<slug> or doc file that
158
- // escapes the workspace or resolves to a different slug is still rejected.
159
- let abs: string;
160
- try {
161
- abs = canonicalize(workspace, candidate);
162
- } catch (error) {
163
- return { ok: false, error: error instanceof Error ? error.message : String(error) };
164
- }
165
- if (posix(path.relative(workspace, abs)) !== spelling) {
166
- return {
167
- ok: false,
168
- error: `path must resolve to ${JSON.stringify(spelling)}: ${candidate}`,
169
- };
170
- }
171
- }
172
-
173
- let resolvedSlug = slug;
174
- if (resolvedSlug !== undefined) {
175
- if (!SLUG_RE.test(resolvedSlug)) {
176
- return { ok: false, error: `invalid slug: ${JSON.stringify(resolvedSlug)}` };
177
- }
178
- if (resolvedSlug === LEGACY_SLUG) {
179
- return { ok: false, error: `reserved slug: ${LEGACY_SLUG}` };
180
- }
181
- if (derived && derived !== resolvedSlug) {
182
- return {
183
- ok: false,
184
- error: `slug ${JSON.stringify(resolvedSlug)} does not match docs path ${JSON.stringify(derived)}`,
185
- };
186
- }
187
- } else if (derived) {
188
- resolvedSlug = derived;
189
- } else {
190
- return { ok: false, error: "slug or spec_path/plan_path required" };
191
- }
192
-
193
- try {
194
- return { ok: true, layout: buildLayout(workspace, resolvedSlug) };
195
- } catch (error) {
196
- return { ok: false, error: error instanceof Error ? error.message : String(error) };
197
- }
198
- };
199
-
200
- /**
201
- * Prepare the canonical layout (DC-04): create only missing `docs/` and
202
- * `docs/<slug>/`, return canonical (realpath) paths, and probe legacy state
203
- * read-only. Never creates sdd/, spec.md, or plan.md.
204
- */
205
- export const prepareDocsLayout = (input: {
206
- workspace_root: string;
207
- slug?: string;
208
- spec_path?: string;
209
- plan_path?: string;
210
- }): PrepareResult => {
211
- const resolved = resolveCanonicalLayout(input);
212
- if (!resolved.ok) return { ok: false, error: resolved.error };
213
- const { layout } = resolved;
214
- const created: string[] = [];
215
- const ensure = (dir: string) => {
216
- if (!existsSync(dir)) {
217
- mkdirSync(dir, { recursive: true });
218
- const rel = posix(path.relative(layout.workspace, dir));
219
- created.push(rel || ".");
220
- } else if (!statSync(dir).isDirectory()) {
221
- throw new Error(
222
- `path exists but is not a directory: ${posix(path.relative(layout.workspace, dir))}`,
223
- );
224
- }
225
- };
226
- try {
227
- ensure(layout.docs);
228
- ensure(layout.dir);
229
- // Re-canonicalize after creation so a symlinked docs/<slug> that escapes
230
- // the workspace is rejected, not silently accepted (DC-02).
231
- const docs = canonicalize(layout.workspace, "docs");
232
- const dir = canonicalize(layout.workspace, path.join(docs, layout.slug));
233
- return {
234
- ok: true,
235
- layout: { ...layout, docs, dir },
236
- created,
237
- legacy: probeLegacyDocs(layout.workspace),
238
- };
239
- } catch (error) {
240
- return { ok: false, error: error instanceof Error ? error.message : String(error) };
241
- }
242
- };
243
-
244
- /**
245
- * Containment check for paths that must live inside the workspace docs tree
246
- * (sdd dirs, progress files, plan links). Rejects absolute paths, escapes, and
247
- * the reserved legacy root `docs/superpowers/`.
248
- */
249
- export const resolveDocsPath = (input: {
250
- workspace_root: string;
251
- path: string;
252
- }): { ok: true; path: string; relative: string; base: string } | { ok: false; error: string } => {
253
- if (path.isAbsolute(input.path)) {
254
- return { ok: false, error: `absolute path not allowed: ${input.path}` };
255
- }
256
- let workspace: string;
257
- try {
258
- workspace = realpathSync(path.resolve(input.workspace_root));
259
- } catch {
260
- return { ok: false, error: `workspace root not found: ${input.workspace_root}` };
261
- }
262
- try {
263
- const abs = canonicalize(workspace, input.path);
264
- const relative = posix(path.relative(workspace, abs));
265
- if (
266
- !relative.startsWith("docs/") ||
267
- relative === `docs/${LEGACY_SLUG}` ||
268
- relative.startsWith(`docs/${LEGACY_SLUG}/`)
269
- ) {
270
- return {
271
- ok: false,
272
- error: `path must live under docs/ and not under docs/${LEGACY_SLUG}/: ${input.path}`,
273
- };
274
- }
275
- return { ok: true, path: abs, relative, base: workspace };
276
- } catch (error) {
277
- return { ok: false, error: error instanceof Error ? error.message : String(error) };
278
- }
279
- };