@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.
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
@@ -3,13 +3,23 @@ import { execFileSync } from "node:child_process";
3
3
  import path from "node:path";
4
4
  import { gitContext } from "./git";
5
5
  import { readConfig, resolveBranchPolicy } from "./config";
6
+ import { resolveWorkspace } from "./workspaces";
6
7
  import { vcsConfig } from "./vcs-config";
7
8
 
8
- const policy = () => resolveBranchPolicy(readConfig());
9
- const allowedBranch = (name: string) => policy().allowed.some((r) => r.test(name));
10
- const isProtected = (name: string) => policy().protected.has(name.toLowerCase());
11
- const baseBranch = (cwd: string) =>
12
- String(vcsConfig("resolve", cwd).defaultTargetBranch ?? "develop");
9
+ /** CA-09: the one policy resolver every consumer calls. */
10
+ export const resolveBranchPolicyFor = (workspaceRoot: string) =>
11
+ resolveBranchPolicy(readConfig(), resolveWorkspace(workspaceRoot));
12
+
13
+ const policy = (root: string) => resolveBranchPolicyFor(root);
14
+ const allowedBranch = (root: string, name: string) =>
15
+ policy(root).allowed.some((r) => r.test(name));
16
+ const isProtected = (root: string, name: string) => policy(root).protected.has(name.toLowerCase());
17
+ // RL-01: malformed vcs.json blocks branch resolution with an exact-path error.
18
+ const baseBranch = (cwd: string): { base: string } | { error: string } => {
19
+ const resolved = vcsConfig("resolve", cwd);
20
+ if (resolved.ok === false) return { error: String(resolved.error) };
21
+ return { base: String(resolved.defaultTargetBranch ?? "develop") };
22
+ };
13
23
  const DECLARE_RE = /^\s*\*+Branch:\*+\s*`?([^`\s|]+)`?\s*$/gim;
14
24
  const USE_CURRENT_RE = /^\s*\*+Branch:\*+\s*use-current\s*$/im;
15
25
  const readSafe = (p: string): string | null => {
@@ -20,10 +30,10 @@ const readSafe = (p: string): string | null => {
20
30
  }
21
31
  };
22
32
 
23
- const normalizeBranch = (name: string): string | null => {
33
+ const normalizeBranch = (root: string, name: string): string | null => {
24
34
  const n = name.trim().replace(/`/g, "").replace(/\.+$/, "");
25
- if (isProtected(n)) return null;
26
- if (!allowedBranch(n)) return null;
35
+ if (isProtected(root, n)) return null;
36
+ if (!allowedBranch(root, n)) return null;
27
37
  const parts = n
28
38
  .toLowerCase()
29
39
  .split("/")
@@ -94,14 +104,14 @@ export const resolveBranch = ({
94
104
  const text = readSafe(file);
95
105
  if (!text) continue;
96
106
  if (USE_CURRENT_RE.test(text)) {
97
- if (!current || !allowedBranch(current) || isProtected(current)) {
107
+ if (!current || !allowedBranch(cwd, current) || isProtected(cwd, current)) {
98
108
  return { error: `use-current but HEAD ${current} is not an allowed branch` };
99
109
  }
100
110
  return finish(current, "use-current");
101
111
  }
102
112
  }
103
113
 
104
- if (current && allowedBranch(current) && !isProtected(current))
114
+ if (current && allowedBranch(cwd, current) && !isProtected(cwd, current))
105
115
  return finish(current, "keep-current");
106
116
 
107
117
  let declaredButInvalid: string | null = null;
@@ -109,7 +119,7 @@ export const resolveBranch = ({
109
119
  const text = readSafe(file);
110
120
  if (!text) continue;
111
121
  for (const match of text.matchAll(DECLARE_RE)) {
112
- const normalized = normalizeBranch(match[1]);
122
+ const normalized = normalizeBranch(cwd, match[1]);
113
123
  if (normalized) return finish(normalized, file === spec ? "spec" : "plan");
114
124
  declaredButInvalid ??= match[1];
115
125
  }
@@ -140,7 +150,9 @@ export const docsBranch = ({
140
150
  const git = gitContext(cwd);
141
151
  const current = git.branch;
142
152
  const kindArg = (kind ?? "feature").toLowerCase();
143
- const base = baseBranch(cwd);
153
+ const baseResolved = baseBranch(cwd);
154
+ if ("error" in baseResolved) return { error: baseResolved.error };
155
+ const base = baseResolved.base;
144
156
 
145
157
  if (current === base || current === "main" || current === "master" || current === "develop") {
146
158
  let slug = "";
@@ -162,7 +174,7 @@ export const docsBranch = ({
162
174
  dirty: Boolean(git.status_short.trim()),
163
175
  };
164
176
  }
165
- if (current && allowedBranch(current) && !isProtected(current)) {
177
+ if (current && allowedBranch(cwd, current) && !isProtected(cwd, current)) {
166
178
  return {
167
179
  branch: current,
168
180
  action: "keep",
@@ -280,8 +292,8 @@ export const branchSetup = ({
280
292
 
281
293
  const target = target_branch ?? "";
282
294
  if (!target) return { error: "target branch required" };
283
- if (isProtected(target)) return { error: `protected branch ${target}` };
284
- if (!allowedBranch(target))
295
+ if (isProtected(cwd, target)) return { error: `protected branch ${target}` };
296
+ if (!allowedBranch(cwd, target))
285
297
  return { error: `target branch ${target} is not allowed by the branch policy` };
286
298
 
287
299
  let stash_ref: string | undefined;
@@ -311,7 +323,9 @@ export const branchSetup = ({
311
323
  };
312
324
  }
313
325
  try {
314
- const baseResult = ensureBaseBranch(cwd, baseBranch(cwd));
326
+ const baseResolved = baseBranch(cwd);
327
+ if ("error" in baseResolved) return { error: baseResolved.error };
328
+ const baseResult = ensureBaseBranch(cwd, baseResolved.base);
315
329
  if (!baseResult.ok) return { error: baseResult.error };
316
330
  exec(["checkout", "-b", target]);
317
331
  } catch (createError) {
@@ -9,6 +9,16 @@ import {
9
9
  } from "node:fs";
10
10
  import os from "node:os";
11
11
  import path from "node:path";
12
+ import type { Logger } from "./logger";
13
+ import { EVENT, errorDetail } from "./boundary";
14
+
15
+ // Optional diagnostic seam: adapters install their host logger so config
16
+ // migration and provenance events land in the same sanitized log stream.
17
+ let diagnosticLogger: Logger | undefined;
18
+ export const setDiagnosticLogger = (logger: Logger | undefined): void => {
19
+ diagnosticLogger = logger;
20
+ };
21
+ export const getDiagnosticLogger = (): Logger | undefined => diagnosticLogger;
12
22
 
13
23
  export type BranchPreset = "gitflow" | "github-flow" | "trunk-based" | "custom";
14
24
 
@@ -29,6 +39,28 @@ export const PRESETS: Record<BranchPreset, { allowed: string[]; protected: strin
29
39
  custom: { allowed: [], protected: [] },
30
40
  };
31
41
 
42
+ // RL-02: one shared preset merge helper. Changing the preset resets every
43
+ // derived policy field from PRESETS; only `custom` carries explicit values
44
+ // (falling back to the current policy when none are given).
45
+ export const mergePreset = (
46
+ preset: BranchPreset,
47
+ input: { allowed?: string[]; protectedNames?: string[] } = {},
48
+ current: { branchPolicy: ToolkitConfig["branchPolicy"] } = {
49
+ branchPolicy: { preset, allowed: [], protected: [] },
50
+ },
51
+ ): ToolkitConfig["branchPolicy"] => {
52
+ const defs = PRESETS[preset];
53
+ return {
54
+ preset,
55
+ allowed:
56
+ preset === "custom" ? (input.allowed ?? current.branchPolicy.allowed) : [...defs.allowed],
57
+ protected:
58
+ preset === "custom"
59
+ ? (input.protectedNames ?? current.branchPolicy.protected)
60
+ : [...defs.protected],
61
+ };
62
+ };
63
+
32
64
  export const resolveConfigDir = (): string =>
33
65
  process.env.WORKFLOW_TOOLKIT_CONFIG ??
34
66
  process.env.WORKFLOW_TOOLKIT_CONFIG_DIR ??
@@ -66,6 +98,7 @@ export const ensureConfigDir = (dir: string = resolveConfigDir()): string => {
66
98
  }
67
99
  migrationFailed = false;
68
100
  mkdirSync(dir, { recursive: true });
101
+ diagnosticLogger?.info(EVENT.migration, { from: legacy, to: dir });
69
102
  for (const entry of readdirSync(legacy, { withFileTypes: true })) {
70
103
  const src = path.join(legacy, entry.name);
71
104
  const dest = path.join(dir, entry.name);
@@ -75,7 +108,12 @@ export const ensureConfigDir = (dir: string = resolveConfigDir()): string => {
75
108
  else if (entry.isFile()) copyFileSync(src, dest);
76
109
  } catch (err) {
77
110
  migrationFailed = true;
78
- console.warn(`[workit] config migration: failed to copy ${src}: ${(err as Error).message}`);
111
+ diagnosticLogger?.warn(EVENT.migration, { from: src, ok: false, ...errorDetail(err) });
112
+ // Task 10 advisory: never leak the raw legacy source path to the terminal —
113
+ // the structured warn above carries it through the sanitized logger.
114
+ console.warn(
115
+ `[workit] config migration: a file could not be copied to ${dir}; it will be retried on the next run`,
116
+ );
79
117
  }
80
118
  }
81
119
  if (!migrationFailed) migratedDir = dir;
@@ -105,49 +143,173 @@ const readSafe = (p: string): string | null => {
105
143
  }
106
144
  };
107
145
 
108
- export const readConfig = (): ToolkitConfig => {
109
- const raw = readSafe(path.join(configDir(), "config.json"));
110
- if (!raw) return DEFAULTS;
146
+ // RL-01: typed reader contract. Every config reader distinguishes missing from
147
+ // valid from malformed and reports the exact file path; risky consumers stop on
148
+ // malformed instead of silently falling back to defaults.
149
+ export type ReaderStatus = "missing" | "valid" | "malformed";
150
+
151
+ export type ReaderResult<T> = {
152
+ status: ReaderStatus;
153
+ path: string;
154
+ config?: T;
155
+ error?: string;
156
+ };
157
+
158
+ // AR-07/CA-37: the one shared fail-closed shape rule for every object-config
159
+ // reader (config, setup-state, workspaces, doctor). A parseable non-object
160
+ // (null, scalar, array) is malformed, never defaults.
161
+ export const isConfigObject = (value: unknown): boolean =>
162
+ value !== null && typeof value === "object" && !Array.isArray(value);
163
+
164
+ const parseConfigResult = (raw: string | null, file: string): ReaderResult<ToolkitConfig> => {
165
+ if (raw === null) return { status: "missing", path: file };
166
+ let parsed: unknown;
111
167
  try {
112
- const parsed = JSON.parse(raw) as Partial<ToolkitConfig>;
113
- const locale = LOCALE_RE.test(String(parsed.locale ?? ""))
114
- ? (parsed.locale as string)
115
- : DEFAULTS.locale;
116
- const preset = (parsed.branchPolicy?.preset ?? "gitflow") as BranchPreset;
117
- const presetOk = Object.hasOwn(PRESETS, preset) ? preset : "gitflow";
118
- const presetDefs = PRESETS[presetOk];
119
- return {
120
- locale,
121
- localeOptions: Array.isArray(parsed.localeOptions)
122
- ? parsed.localeOptions
123
- : DEFAULTS.localeOptions,
124
- timezone: parsed.timezone ?? DEFAULTS.timezone,
125
- branchPolicy: {
126
- preset: presetOk,
127
- allowed: Array.isArray(parsed.branchPolicy?.allowed)
128
- ? parsed.branchPolicy.allowed
129
- : presetDefs.allowed,
130
- protected: Array.isArray(parsed.branchPolicy?.protected)
131
- ? parsed.branchPolicy.protected
132
- : presetDefs.protected,
133
- },
134
- };
168
+ parsed = JSON.parse(raw);
135
169
  } catch {
136
- return DEFAULTS;
170
+ return { status: "malformed", path: file, error: `${file} is not valid JSON` };
137
171
  }
172
+ if (!isConfigObject(parsed)) {
173
+ return { status: "malformed", path: file, error: `${file} is not a JSON object` };
174
+ }
175
+ const input = parsed as Partial<ToolkitConfig>;
176
+ const locale = LOCALE_RE.test(String(input.locale ?? ""))
177
+ ? (input.locale as string)
178
+ : DEFAULTS.locale;
179
+ const preset = (
180
+ Object.hasOwn(PRESETS, input.branchPolicy?.preset as string)
181
+ ? input.branchPolicy?.preset
182
+ : "gitflow"
183
+ ) as BranchPreset;
184
+ return {
185
+ status: "valid",
186
+ path: file,
187
+ config: {
188
+ locale,
189
+ localeOptions: Array.isArray(input.localeOptions)
190
+ ? input.localeOptions
191
+ : DEFAULTS.localeOptions,
192
+ timezone: input.timezone ?? DEFAULTS.timezone,
193
+ // RL-02/CA-23: the persisted preset is authoritative; derived allowed /
194
+ // protected fields always reset from PRESETS via the one shared merge.
195
+ branchPolicy: mergePreset(
196
+ preset,
197
+ {
198
+ allowed: Array.isArray(input.branchPolicy?.allowed)
199
+ ? input.branchPolicy.allowed
200
+ : undefined,
201
+ protectedNames: Array.isArray(input.branchPolicy?.protected)
202
+ ? input.branchPolicy.protected
203
+ : undefined,
204
+ },
205
+ DEFAULTS,
206
+ ),
207
+ },
208
+ };
209
+ };
210
+
211
+ export const readConfigTyped = (dir?: string): ReaderResult<ToolkitConfig> => {
212
+ const file = path.join(dir ?? configDir(), "config.json");
213
+ return parseConfigResult(readSafe(file), file);
214
+ };
215
+
216
+ // RL-01: no silent fallback on malformed config — every consumer gets an
217
+ // exact-path diagnostic instead of defaults. Missing config still defaults.
218
+ export const readConfig = (): ToolkitConfig => {
219
+ const result = readConfigTyped();
220
+ if (result.status === "malformed") throw new Error(result.error);
221
+ return result.config ?? DEFAULTS;
222
+ };
223
+
224
+ export const readConfigFromDir = (dir: string): ToolkitConfig => {
225
+ const result = readConfigTyped(dir);
226
+ if (result.status === "malformed") throw new Error(result.error);
227
+ return result.config ?? DEFAULTS;
228
+ };
229
+
230
+ export type ConfigInput = {
231
+ locale?: string;
232
+ localeOptions?: string[];
233
+ timezone?: string;
234
+ preset?: BranchPreset;
235
+ allowed?: string[];
236
+ protectedNames?: string[];
138
237
  };
139
238
 
239
+ // RL-02: the single authoritative ToolkitConfig merge. CLI, OpenCode, and Cursor
240
+ // adapters all route their `config` writes through this so a preset switch
241
+ // resets every derived policy field identically everywhere.
242
+ export const mergeConfigValues = (input: ConfigInput, current: ToolkitConfig): ToolkitConfig => ({
243
+ locale: input.locale ?? current.locale,
244
+ localeOptions: input.localeOptions ?? current.localeOptions,
245
+ timezone: input.timezone ?? current.timezone,
246
+ branchPolicy: mergePreset(input.preset ?? current.branchPolicy.preset, input, current),
247
+ });
248
+
140
249
  export const writeConfig = (config: ToolkitConfig): void => {
141
250
  const dir = configDir();
142
251
  mkdirSync(dir, { recursive: true });
143
252
  writeFileSync(path.join(dir, "config.json"), JSON.stringify(config, null, 2) + "\n", "utf8");
144
253
  };
145
254
 
255
+ // Configuration provenance for startup diagnostics: where config.json came from
256
+ // and whether it parsed. Only paths + a malformed flag — never the file body.
257
+ export const describeConfigSource = (
258
+ dir: string = resolveConfigDir(),
259
+ ): { source: string; config_dir: string; malformed: boolean } => {
260
+ const file = path.join(dir, "config.json");
261
+ if (!existsSync(file)) return { source: "defaults", config_dir: dir, malformed: false };
262
+ const raw = readSafe(file);
263
+ if (raw === null) return { source: "unreadable", config_dir: dir, malformed: true };
264
+ try {
265
+ const parsed = JSON.parse(raw);
266
+ if (!isConfigObject(parsed)) {
267
+ return { source: "defaults", config_dir: dir, malformed: true };
268
+ }
269
+ return { source: "file", config_dir: dir, malformed: false };
270
+ } catch {
271
+ return { source: "defaults", config_dir: dir, malformed: true };
272
+ }
273
+ };
274
+
146
275
  export const resolveBranchPolicy = (
147
276
  config: ToolkitConfig,
148
- ): { allowed: RegExp[]; protected: Set<string> } => {
149
- const allowed = config.branchPolicy.allowed.map(
277
+ workspace?: { branchPolicy?: Record<string, any> } | null,
278
+ ): {
279
+ preset: BranchPreset;
280
+ allowed: RegExp[];
281
+ protected: Set<string>;
282
+ integration: "pr" | "merge";
283
+ defaultTargetBranch: string;
284
+ } => {
285
+ const wp = (workspace?.branchPolicy ?? {}) as Record<string, any>;
286
+ // An invalid workspace preset (e.g. a typo) falls back to the global preset,
287
+ // preserving resolution order workspace > global > preset, instead of
288
+ // crashing on PRESETS[preset] (mirrors parseConfigResult's Object.hasOwn).
289
+ const preset = (
290
+ Object.hasOwn(PRESETS, wp.preset) ? wp.preset : (config.branchPolicy?.preset ?? "gitflow")
291
+ ) as BranchPreset;
292
+ // RL-02: the preset is authoritative — allowed/protected re-derive from the
293
+ // workspace's own values or the preset table, never the global config's, and
294
+ // the current config remains the `custom` fallback when no values are given.
295
+ const merged = mergePreset(
296
+ preset,
297
+ {
298
+ allowed: wp.allowed,
299
+ protectedNames: wp.protected,
300
+ },
301
+ config,
302
+ );
303
+ const allowed = merged.allowed.map(
150
304
  (p) => new RegExp(`^${p.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*")}$`, "i"),
151
305
  );
152
- return { allowed, protected: new Set(config.branchPolicy.protected.map((p) => p.toLowerCase())) };
306
+ return {
307
+ preset,
308
+ allowed,
309
+ protected: new Set(merged.protected.map((p) => p.toLowerCase())),
310
+ integration: wp.integration === "merge" ? "merge" : "pr",
311
+ // CA-05: preset-aware default target when vcs.defaultTargetBranch is unset.
312
+ defaultTargetBranch:
313
+ preset === "github-flow" ? "main" : preset === "trunk-based" ? "master" : "develop",
314
+ };
153
315
  };
@@ -0,0 +1,251 @@
1
+ import { existsSync, 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
+ const real = realpathSync(ancestor);
53
+ if (real !== base && !real.startsWith(base + path.sep)) {
54
+ throw new Error(`path must stay inside repository root: ${candidate}`);
55
+ }
56
+ return path.join(real, path.relative(ancestor, abs));
57
+ };
58
+
59
+ const buildLayout = (workspace: string, slug: string): CanonicalLayout => {
60
+ const docs = canonicalize(workspace, "docs");
61
+ const dir = canonicalize(workspace, path.join(docs, slug));
62
+ return {
63
+ workspace,
64
+ slug,
65
+ docs,
66
+ dir,
67
+ spec: path.join(dir, "spec.md"),
68
+ plan: path.join(dir, "plan.md"),
69
+ sdd: path.join(dir, "sdd"),
70
+ };
71
+ };
72
+
73
+ /** Read-only legacy detection (DC-04): never mutates legacy state. */
74
+ export const probeLegacyDocs = (workspace: string): LegacyProbe => ({
75
+ legacy_sdd: existsSync(path.join(workspace, ".superpowers", "sdd")),
76
+ superpowers_dir: existsSync(path.join(workspace, "docs", LEGACY_SLUG)),
77
+ });
78
+
79
+ /**
80
+ * The one canonical workspace/slug/pair resolver. Given a slug or any spec/plan
81
+ * pair path, returns canonical paths for the pair. Rejects absolute paths,
82
+ * traversal, symlink escapes, cross-slug pairs, wrong basenames, and arbitrary
83
+ * or legacy locations (DC-01, DC-02). Creates nothing.
84
+ */
85
+ export const resolveCanonicalLayout = (input: {
86
+ workspace_root: string;
87
+ slug?: string;
88
+ spec_path?: string;
89
+ plan_path?: string;
90
+ }): LayoutResult => {
91
+ const { workspace_root, slug, spec_path, plan_path } = input;
92
+ if (!workspace_root) return { ok: false, error: "workspace_root required" };
93
+ let workspace: string;
94
+ try {
95
+ workspace = realpathSync(path.resolve(workspace_root));
96
+ } catch {
97
+ return { ok: false, error: `workspace root not found: ${workspace_root}` };
98
+ }
99
+
100
+ let derived: string | null = null;
101
+ for (const [candidate, kind] of [
102
+ [spec_path, "spec"],
103
+ [plan_path, "plan"],
104
+ ] as const) {
105
+ if (!candidate) continue;
106
+ if (path.isAbsolute(candidate)) {
107
+ return { ok: false, error: `absolute path not allowed: ${candidate}` };
108
+ }
109
+ let abs: string;
110
+ try {
111
+ abs = canonicalize(workspace, candidate);
112
+ } catch (error) {
113
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
114
+ }
115
+ const rel = posix(path.relative(workspace, abs));
116
+ const match = rel.match(/^docs\/([^/]+)\/(spec|plan)\.md$/);
117
+ if (!match) {
118
+ return {
119
+ ok: false,
120
+ error: `path must be docs/<slug>/(spec|plan).md inside workspace_root: ${candidate}`,
121
+ };
122
+ }
123
+ const pathSlug = match[1];
124
+ if (pathSlug === LEGACY_SLUG) {
125
+ return { ok: false, error: `legacy path not allowed: ${candidate}` };
126
+ }
127
+ if (!SLUG_RE.test(pathSlug)) {
128
+ return { ok: false, error: `invalid slug derived from path: ${JSON.stringify(pathSlug)}` };
129
+ }
130
+ if (match[2] !== kind) {
131
+ return {
132
+ ok: false,
133
+ error: `wrong basename for ${kind}: expected ${kind}.md, got ${path.basename(candidate)}`,
134
+ };
135
+ }
136
+ if (derived && derived !== pathSlug) {
137
+ return {
138
+ ok: false,
139
+ error: "cross-slug pair: spec_path and plan_path must share the same docs/<slug>/",
140
+ };
141
+ }
142
+ derived = pathSlug;
143
+ }
144
+
145
+ let resolvedSlug = slug;
146
+ if (resolvedSlug !== undefined) {
147
+ if (!SLUG_RE.test(resolvedSlug)) {
148
+ return { ok: false, error: `invalid slug: ${JSON.stringify(resolvedSlug)}` };
149
+ }
150
+ if (resolvedSlug === LEGACY_SLUG) {
151
+ return { ok: false, error: `reserved slug: ${LEGACY_SLUG}` };
152
+ }
153
+ if (derived && derived !== resolvedSlug) {
154
+ return {
155
+ ok: false,
156
+ error: `slug ${JSON.stringify(resolvedSlug)} does not match docs path ${JSON.stringify(derived)}`,
157
+ };
158
+ }
159
+ } else if (derived) {
160
+ resolvedSlug = derived;
161
+ } else {
162
+ return { ok: false, error: "slug or spec_path/plan_path required" };
163
+ }
164
+
165
+ try {
166
+ return { ok: true, layout: buildLayout(workspace, resolvedSlug) };
167
+ } catch (error) {
168
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
169
+ }
170
+ };
171
+
172
+ /**
173
+ * Prepare the canonical layout (DC-04): create only missing `docs/` and
174
+ * `docs/<slug>/`, return canonical (realpath) paths, and probe legacy state
175
+ * read-only. Never creates sdd/, spec.md, or plan.md.
176
+ */
177
+ export const prepareDocsLayout = (input: {
178
+ workspace_root: string;
179
+ slug?: string;
180
+ spec_path?: string;
181
+ plan_path?: string;
182
+ }): PrepareResult => {
183
+ const resolved = resolveCanonicalLayout(input);
184
+ if (!resolved.ok) return { ok: false, error: resolved.error };
185
+ const { layout } = resolved;
186
+ const created: string[] = [];
187
+ const ensure = (dir: string) => {
188
+ if (!existsSync(dir)) {
189
+ mkdirSync(dir, { recursive: true });
190
+ const rel = posix(path.relative(layout.workspace, dir));
191
+ created.push(rel || ".");
192
+ } else if (!statSync(dir).isDirectory()) {
193
+ throw new Error(
194
+ `path exists but is not a directory: ${posix(path.relative(layout.workspace, dir))}`,
195
+ );
196
+ }
197
+ };
198
+ try {
199
+ ensure(layout.docs);
200
+ ensure(layout.dir);
201
+ // Re-canonicalize after creation so a symlinked docs/<slug> that escapes
202
+ // the workspace is rejected, not silently accepted (DC-02).
203
+ const docs = canonicalize(layout.workspace, "docs");
204
+ const dir = canonicalize(layout.workspace, path.join(docs, layout.slug));
205
+ return {
206
+ ok: true,
207
+ layout: { ...layout, docs, dir },
208
+ created,
209
+ legacy: probeLegacyDocs(layout.workspace),
210
+ };
211
+ } catch (error) {
212
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
213
+ }
214
+ };
215
+
216
+ /**
217
+ * Containment check for paths that must live inside the workspace docs tree
218
+ * (sdd dirs, progress files, plan links). Rejects absolute paths, escapes, and
219
+ * the reserved legacy root `docs/superpowers/`.
220
+ */
221
+ export const resolveDocsPath = (input: {
222
+ workspace_root: string;
223
+ path: string;
224
+ }): { ok: true; path: string; relative: string; base: string } | { ok: false; error: string } => {
225
+ if (path.isAbsolute(input.path)) {
226
+ return { ok: false, error: `absolute path not allowed: ${input.path}` };
227
+ }
228
+ let workspace: string;
229
+ try {
230
+ workspace = realpathSync(path.resolve(input.workspace_root));
231
+ } catch {
232
+ return { ok: false, error: `workspace root not found: ${input.workspace_root}` };
233
+ }
234
+ try {
235
+ const abs = canonicalize(workspace, input.path);
236
+ const relative = posix(path.relative(workspace, abs));
237
+ if (
238
+ !relative.startsWith("docs/") ||
239
+ relative === `docs/${LEGACY_SLUG}` ||
240
+ relative.startsWith(`docs/${LEGACY_SLUG}/`)
241
+ ) {
242
+ return {
243
+ ok: false,
244
+ error: `path must live under docs/ and not under docs/${LEGACY_SLUG}/: ${input.path}`,
245
+ };
246
+ }
247
+ return { ok: true, path: abs, relative, base: workspace };
248
+ } catch (error) {
249
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
250
+ }
251
+ };