@brainervirus/workit-core 0.6.0 → 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 (95) hide show
  1. package/package.json +10 -14
  2. package/scripts/doctor-check.ts +20 -0
  3. package/scripts/install-cursor-plugin.sh +51 -28
  4. package/scripts/install-opencode-plugin.sh +25 -22
  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 +166 -57
  13. package/src/core/changelog.ts +17 -14
  14. package/src/core/config-guard.ts +9 -2
  15. package/src/core/config.ts +227 -32
  16. package/src/core/detector.ts +22 -11
  17. package/src/core/docs-layout.ts +251 -0
  18. package/src/core/docs-migration.ts +639 -0
  19. package/src/core/docs-repo.ts +58 -21
  20. package/src/core/docs-validate.ts +181 -43
  21. package/src/core/doctor.ts +801 -0
  22. package/src/core/flow-state.ts +1579 -137
  23. package/src/core/git.ts +22 -5
  24. package/src/core/gitignore.ts +11 -2
  25. package/src/core/handoff-context.ts +18 -5
  26. package/src/{tools/handoff.ts → core/handoff-tools.ts} +16 -63
  27. package/src/core/hygiene.ts +53 -17
  28. package/src/core/init.ts +128 -31
  29. package/src/core/logger.ts +321 -0
  30. package/src/core/package-root.ts +28 -0
  31. package/src/core/parse-sections.ts +2 -2
  32. package/src/core/plan-tasks.ts +13 -3
  33. package/src/core/ports/init-toolkit-status.ts +1 -1
  34. package/src/core/ports/vcs-verify-token.ts +1 -1
  35. package/src/core/ports/youtrack-api.ts +4 -2
  36. package/src/core/ports/youtrack-config.ts +1 -3
  37. package/src/core/ports/youtrack-verify-token.ts +1 -1
  38. package/src/core/pr-create.ts +156 -29
  39. package/src/core/present.ts +11 -2
  40. package/src/core/registration.ts +215 -0
  41. package/src/core/reminder.ts +1 -2
  42. package/src/core/repo-context.ts +447 -0
  43. package/src/core/repo-tool.ts +4 -1
  44. package/src/core/repo-tools.ts +23 -0
  45. package/src/core/rules.ts +10 -7
  46. package/src/core/safe-write.ts +22 -0
  47. package/src/core/scripts.ts +3 -39
  48. package/src/core/sdd.ts +56 -31
  49. package/src/core/setup-state.ts +54 -0
  50. package/src/core/setup.ts +1216 -0
  51. package/src/core/skill-manifests.ts +95 -0
  52. package/src/core/support-matrix.ts +12 -0
  53. package/src/core/sync-runtime.ts +348 -0
  54. package/src/core/templates.ts +16 -6
  55. package/src/core/vcs-config.ts +191 -62
  56. package/src/core/verify-parse.ts +4 -2
  57. package/src/core/verify-project.ts +181 -0
  58. package/src/core/workspaces.ts +136 -17
  59. package/src/core/youtrack-tools.ts +228 -0
  60. package/src/core/youtrack.ts +320 -87
  61. package/src/core.ts +18 -3
  62. package/templates/execution-contract.md +9 -7
  63. package/templates/superpowers-doc-contract.md +5 -4
  64. package/scripts/_shared/common.sh +0 -158
  65. package/scripts/changelog-context.sh +0 -42
  66. package/scripts/docs-refresh-context.sh +0 -40
  67. package/scripts/init/apply.sh +0 -5
  68. package/scripts/init/status.sh +0 -5
  69. package/scripts/init/toolkit-status.sh +0 -5
  70. package/scripts/pr-create.sh +0 -5
  71. package/scripts/pr-ready-context.sh +0 -88
  72. package/scripts/present/ascii-wireframe.sh +0 -5
  73. package/scripts/present/flow-diagram.sh +0 -5
  74. package/scripts/release-notes-context.sh +0 -40
  75. package/scripts/vcs/config.sh +0 -5
  76. package/scripts/vcs/merged-style.sh +0 -5
  77. package/scripts/vcs/token-create-urls.sh +0 -5
  78. package/scripts/vcs/verify-token.sh +0 -5
  79. package/scripts/verify-project.sh +0 -140
  80. package/scripts/youtrack/api.sh +0 -5
  81. package/scripts/youtrack/config.sh +0 -5
  82. package/scripts/youtrack/greeting.sh +0 -5
  83. package/scripts/youtrack/parse-duration.sh +0 -5
  84. package/scripts/youtrack/token-create-url.sh +0 -5
  85. package/scripts/youtrack/verify-token.sh +0 -5
  86. package/scripts/youtrack/work-date-ms.sh +0 -5
  87. package/src/tools/docs-repo.ts +0 -42
  88. package/src/tools/flow.ts +0 -88
  89. package/src/tools/index.ts +0 -22
  90. package/src/tools/present.ts +0 -45
  91. package/src/tools/repo.ts +0 -357
  92. package/src/tools/rules.ts +0 -30
  93. package/src/tools/sdd.ts +0 -189
  94. package/src/tools/templates.ts +0 -27
  95. package/src/tools/youtrack.ts +0 -360
@@ -2,10 +2,10 @@ 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
- process.env.WORKFLOW_DOCS_REPO_CONFIG
8
- ?? path.join(configDir(), "docs-repo.json");
8
+ process.env.WORKFLOW_DOCS_REPO_CONFIG ?? path.join(configDir(), "docs-repo.json");
9
9
 
10
10
  export const readDocsRepoConfig = (): { path: string } | null => {
11
11
  try {
@@ -25,7 +25,8 @@ export const writeDocsRepoConfig = (docsPath: string): void => {
25
25
  export const docsRepoPath = (): string | null => readDocsRepoConfig()?.path ?? null;
26
26
 
27
27
  export const validateDocsRepo = (docsPath: string): { ok: true } | { ok: false; error: string } => {
28
- if (!existsSync(docsPath)) return { ok: false, error: `docs repo path does not exist: ${docsPath}` };
28
+ if (!existsSync(docsPath))
29
+ return { ok: false, error: `docs repo path does not exist: ${docsPath}` };
29
30
  try {
30
31
  execFileSync("git", ["-C", docsPath, "rev-parse", "--is-inside-work-tree"], { stdio: "pipe" });
31
32
  } catch {
@@ -49,7 +50,10 @@ export const linkDocsRepo = (
49
50
 
50
51
  export const listSpecs = (
51
52
  workspaceRoot: string,
52
- ): { docs_repo: string | null; specs: { slug: string; spec: string; promoted: boolean; target: string | null }[] } => {
53
+ ): {
54
+ docs_repo: string | null;
55
+ specs: { slug: string; spec: string; promoted: boolean; target: string | null }[];
56
+ } => {
53
57
  const repoPath = docsRepoPath();
54
58
  const specs: { slug: string; spec: string; promoted: boolean; target: string | null }[] = [];
55
59
  const docsDir = path.join(workspaceRoot, "docs");
@@ -63,7 +67,9 @@ export const listSpecs = (
63
67
  if (repoPath) {
64
68
  const featuresDir = path.join(repoPath, "features");
65
69
  if (existsSync(featuresDir)) {
66
- const match = readdirSync(featuresDir).find((d) => new RegExp(`^20\\d{2}-\\d{2}-${slug}$`).test(d));
70
+ const match = readdirSync(featuresDir).find((d) =>
71
+ new RegExp(`^20\\d{2}-\\d{2}-${slug}$`).test(d),
72
+ );
67
73
  if (match) {
68
74
  promoted = true;
69
75
  target = path.join(repoPath, "features", match);
@@ -84,13 +90,20 @@ const monthPrefix = () => {
84
90
  };
85
91
 
86
92
  const readSafe = (p: string): string | null => {
87
- try { return readFileSync(p, "utf8"); } catch { return null; }
93
+ try {
94
+ return readFileSync(p, "utf8");
95
+ } catch {
96
+ return null;
97
+ }
88
98
  };
89
99
 
90
100
  const specSummary = (specText: string): string => {
91
- const contextMatch = specText.match(/## Context\n\n([\s\S]*?)(?=\n## |\Z)/);
101
+ const contextMatch = specText.match(/## Context\n\n([\s\S]*?)(?=\n## |Z)/);
92
102
  if (!contextMatch) return "";
93
- const first = contextMatch[1].trim().split("\n").find((l) => l.trim() && !l.startsWith("<!--"));
103
+ const first = contextMatch[1]
104
+ .trim()
105
+ .split("\n")
106
+ .find((l) => l.trim() && !l.startsWith("<!--"));
94
107
  return (first ?? "").trim();
95
108
  };
96
109
 
@@ -99,46 +112,68 @@ const specRepos = (specText: string): string => {
99
112
  return match?.[1]?.trim() ?? "—";
100
113
  };
101
114
 
102
- const SLUG_RE = /^[a-z0-9][a-z0-9._-]*$/i;
103
-
104
115
  export const promoteSpec = (
105
116
  workspaceRoot: string,
106
117
  slug: string,
107
118
  opts: { confirmed: boolean; force?: boolean },
108
- ): { ok: true; target_dir: string; files: string[]; index_updated: boolean }
119
+ ):
120
+ | { ok: true; target_dir: string; files: string[]; index_updated: boolean }
109
121
  | { ok: false; error: string; findings?: unknown[] } => {
110
122
  if (!opts.confirmed) return { ok: false, error: "confirmed: true required" };
111
- 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;
112
128
  const repoPath = docsRepoPath();
113
129
  if (!repoPath) return { ok: false, error: "docs repo not linked — run workflow_docs_repo_link" };
114
130
  const repoValid = validateDocsRepo(repoPath);
115
131
  if (!repoValid.ok) return { ok: false, error: repoValid.error };
116
-
117
132
  const specRel = path.posix.join("docs", slug, "spec.md");
118
133
  const planRel = path.posix.join("docs", slug, "plan.md");
119
- const specText = readSafe(path.join(workspaceRoot, specRel));
134
+ const specText = readSafe(resolved.layout.spec);
120
135
  if (specText === null) return { ok: false, error: `docs/${slug}/spec.md not found` };
121
136
 
122
- const planText = readSafe(path.join(workspaceRoot, planRel));
137
+ const planText = readSafe(resolved.layout.plan);
123
138
  if (planText !== null) {
124
- const validated = docsValidate({ spec_path: specRel, plan_path: planRel, workspace_root: workspaceRoot });
139
+ const validated = docsValidate({
140
+ spec_path: specRel,
141
+ plan_path: planRel,
142
+ workspace_root: workspaceRootCanonical,
143
+ });
125
144
  if (validated.ok === false) return { ok: false, error: validated.error };
126
145
  }
127
146
 
128
147
  const findings = qualitySpec(specText);
129
148
  const hardFindings = findings.filter((f) => f.severity === "hard");
130
149
  if (hardFindings.length > 0 && !opts.force) {
131
- return { ok: false, error: "spec has hard quality findings; pass force: true to override", findings };
150
+ return {
151
+ ok: false,
152
+ error: "spec has hard quality findings; pass force: true to override",
153
+ findings,
154
+ };
132
155
  }
133
156
 
134
157
  // SDD working state must be gitignored before promotion
135
158
  if (!opts.force) {
136
- const sddDir = path.join(workspaceRoot, "docs", slug, "sdd");
159
+ const sddDir = path.join(workspaceRootCanonical, "docs", slug, "sdd");
137
160
  if (existsSync(sddDir)) {
138
161
  try {
139
- execFileSync("git", ["-C", workspaceRoot, "check-ignore", path.posix.join("docs", slug, "sdd", "progress.md")], { stdio: "pipe" });
162
+ execFileSync(
163
+ "git",
164
+ [
165
+ "-C",
166
+ workspaceRootCanonical,
167
+ "check-ignore",
168
+ path.posix.join("docs", slug, "sdd", "progress.md"),
169
+ ],
170
+ { stdio: "pipe" },
171
+ );
140
172
  } catch {
141
- return { ok: false, error: `docs/${slug}/sdd/ is not gitignored — add 'docs/*/sdd/' to .gitignore or pass force: true` };
173
+ return {
174
+ ok: false,
175
+ error: `docs/${slug}/sdd/ is not gitignored — add 'docs/*/sdd/' to .gitignore or pass force: true`,
176
+ };
142
177
  }
143
178
  }
144
179
  }
@@ -179,7 +214,9 @@ ${planText !== null ? "| [plan.md](./plan.md) | Plan de implementación |\n" : "
179
214
  files.push("README.md");
180
215
 
181
216
  const indexPath = path.join(repoPath, "features", "README.md");
182
- const indexText = readSafe(indexPath) ?? `# Features\n\nEspecificaciones y planes por feature.\n\n## Features documentadas\n\n| Feature | Repos afectados | Estado |\n| --- | --- | --- |\n`;
217
+ const indexText =
218
+ readSafe(indexPath) ??
219
+ `# Features\n\nEspecificaciones y planes por feature.\n\n## Features documentadas\n\n| Feature | Repos afectados | Estado |\n| --- | --- | --- |\n`;
183
220
  const row = `| [${slug}](./${prefix}-${slug}/) | ${specRepos(specText)} | Spec en revisión |`;
184
221
  const rowRe = new RegExp(`^\\| \\[${slug}\\]\\([^)]*\\) \\|.*$`, "m");
185
222
  let newIndex: string;
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { hygieneFiles } from "./hygiene";
3
3
  import { execFileSync } from "node:child_process";
4
4
  import path from "node:path";
5
+ import { resolveCanonicalLayout, resolveDocsPath } from "./docs-layout";
5
6
 
6
7
  export type DocError = { code: string; message: string; path?: string };
7
8
 
@@ -15,17 +16,20 @@ const err = (code: string, message: string, path?: string): DocError => {
15
16
  return item;
16
17
  };
17
18
 
18
- const failValidate = (
19
- errors: DocError[],
20
- ): { ok: false; errors: DocError[]; error: string } => ({
19
+ const failValidate = (errors: DocError[]): { ok: false; errors: DocError[]; error: string } => ({
21
20
  ok: false,
22
21
  errors,
23
- error: errors.map((e) => e.message).filter(Boolean).join("; ") || "docs validation failed",
22
+ error:
23
+ errors
24
+ .map((e) => e.message)
25
+ .filter(Boolean)
26
+ .join("; ") || "docs validation failed",
24
27
  });
25
28
 
26
29
  const readBranch = (text: string, label: string): [string | null, DocError | null] => {
27
30
  const match = text.match(BRANCH_RE);
28
- if (!match) return [null, err("missing_branch", `**Branch:** feature/* or bugfix/* required in ${label}`)];
31
+ if (!match)
32
+ return [null, err("missing_branch", `**Branch:** feature/* or bugfix/* required in ${label}`)];
29
33
  return [match[1].trim().replace(/`/g, ""), null];
30
34
  };
31
35
 
@@ -34,37 +38,55 @@ const scanTaskHeadings = (planText: string): [number[], string[], DocError | nul
34
38
  const titles: string[] = [];
35
39
  let inFence = false;
36
40
  for (const line of planText.split("\n")) {
37
- if (line.startsWith("```")) { inFence = !inFence; continue; }
41
+ if (line.startsWith("```")) {
42
+ inFence = !inFence;
43
+ continue;
44
+ }
38
45
  if (inFence) continue;
39
46
  const match = line.match(TASK_RE);
40
- if (match) { ids.push(Number(match[1])); titles.push(match[2].trim()); }
47
+ if (match) {
48
+ ids.push(Number(match[1]));
49
+ titles.push(match[2].trim());
50
+ }
41
51
  }
42
- if (ids.length === 0) return [ids, titles, err("task_order", "no ### Task N sections found outside fences")];
52
+ if (ids.length === 0)
53
+ return [ids, titles, err("task_order", "no ### Task N sections found outside fences")];
43
54
  const expected = ids.map((_, i) => i + 1);
44
55
  const sorted = [...ids].sort((a, b) => a - b);
45
56
  if (JSON.stringify(sorted) !== JSON.stringify(expected) || new Set(ids).size !== ids.length) {
46
- return [ids, titles, err("task_order", `task headings must be contiguous from 1..${ids.length}; found ${ids}`)];
57
+ return [
58
+ ids,
59
+ titles,
60
+ err("task_order", `task headings must be contiguous from 1..${ids.length}; found ${ids}`),
61
+ ];
47
62
  }
48
63
  return [ids, titles, null];
49
64
  };
50
65
 
51
66
  // Port of scripts/lib/parse-plan-tasks.sh (JSON mode)
52
- export const parseTasksFromPlan = (planText: string): { id: number; title: string; section_text: string }[] => {
67
+ export const parseTasksFromPlan = (
68
+ planText: string,
69
+ ): { id: number; title: string; section_text: string }[] => {
53
70
  const tasks: { id: number; title: string; section_text: string }[] = [];
54
71
  let current: { id: number; title: string; body: string[] } | null = null;
55
72
  let inFence = false;
56
73
  for (const line of planText.split("\n")) {
57
- if (line.startsWith("```")) { inFence = !inFence; continue; }
74
+ if (line.startsWith("```")) {
75
+ inFence = !inFence;
76
+ continue;
77
+ }
58
78
  if (inFence) continue;
59
79
  const match = line.match(TASK_RE);
60
80
  if (match) {
61
- if (current) tasks.push({ id: current.id, title: current.title, section_text: current.body.join("\n") });
81
+ if (current)
82
+ tasks.push({ id: current.id, title: current.title, section_text: current.body.join("\n") });
62
83
  current = { id: Number(match[1]), title: match[2].trim(), body: [] };
63
84
  } else if (current) {
64
85
  current.body.push(line);
65
86
  }
66
87
  }
67
- if (current) tasks.push({ id: current.id, title: current.title, section_text: current.body.join("\n") });
88
+ if (current)
89
+ tasks.push({ id: current.id, title: current.title, section_text: current.body.join("\n") });
68
90
  return tasks;
69
91
  };
70
92
 
@@ -77,21 +99,40 @@ export const docsValidate = ({
77
99
  plan_path: string;
78
100
  workspace_root: string;
79
101
  }):
80
- | { ok: true; spec: string; plan: string; branch: string; task_count: number; quality: QualityFinding[] }
102
+ | {
103
+ ok: true;
104
+ spec: string;
105
+ plan: string;
106
+ branch: string;
107
+ task_count: number;
108
+ quality: QualityFinding[];
109
+ }
81
110
  | { ok: false; errors: DocError[]; error: string } => {
82
- const cwd = path.resolve(workspace_root);
83
- const specAbs = path.isAbsolute(spec_path) ? spec_path : path.join(cwd, spec_path);
84
- const planAbs = path.isAbsolute(plan_path) ? plan_path : path.join(cwd, plan_path);
111
+ // One shared contained path contract (DC-01, DC-02): absolute/traversal/
112
+ // symlink-escape/cross-slug/wrong-basename/legacy paths all fail here before
113
+ // any document bytes are read.
114
+ const resolved = resolveCanonicalLayout({ workspace_root, spec_path, plan_path });
115
+ if (!resolved.ok) return failValidate([err("path_contract", resolved.error)]);
116
+ const cwd = resolved.layout.workspace;
117
+ const specAbs = resolved.layout.spec;
118
+ const planAbs = resolved.layout.plan;
119
+ const slug = resolved.layout.slug;
85
120
  const errors: DocError[] = [];
86
121
 
87
122
  const read = (p: string): string | null => {
88
- try { return readFileSync(p, "utf8"); } catch { return null; }
123
+ try {
124
+ return readFileSync(p, "utf8");
125
+ } catch {
126
+ return null;
127
+ }
89
128
  };
90
129
 
91
130
  const specText = read(specAbs);
92
131
  const planText = read(planAbs);
93
- if (specText === null) errors.push(err("missing_file", `spec not found: ${spec_path}`, spec_path));
94
- if (planText === null) errors.push(err("missing_file", `plan not found: ${plan_path}`, plan_path));
132
+ if (specText === null)
133
+ errors.push(err("missing_file", `spec not found: ${spec_path}`, spec_path));
134
+ if (planText === null)
135
+ errors.push(err("missing_file", `plan not found: ${plan_path}`, plan_path));
95
136
  if (errors.length) return failValidate(errors);
96
137
 
97
138
  const [specBranch, specErr] = readBranch(specText!, "spec");
@@ -104,14 +145,32 @@ export const docsValidate = ({
104
145
  errors.push(err("missing_spec_link", "**Spec:** link required in plan", plan_path));
105
146
  } else {
106
147
  const linked = (linkMatch[1] ?? linkMatch[2] ?? "").trim();
107
- const linkedAbs = path.isAbsolute(linked) ? linked : path.join(cwd, linked);
108
- if (path.resolve(linkedAbs) !== path.resolve(specAbs)) {
109
- errors.push(err("spec_mismatch", `plan **Spec:** ${linked} does not match spec_path ${spec_path}`, plan_path));
148
+ // The plan link is a path inside the workspace docs tree: canonicalize it
149
+ // under the same contained contract before comparing to the canonical spec.
150
+ const linkedResolved = resolveDocsPath({ workspace_root: cwd, path: linked });
151
+ if (!linkedResolved.ok) {
152
+ errors.push(
153
+ err("spec_link_escape", `plan **Spec:** ${linked} is not a contained docs path`, plan_path),
154
+ );
155
+ } else if (linkedResolved.path !== specAbs) {
156
+ errors.push(
157
+ err(
158
+ "spec_mismatch",
159
+ `plan **Spec:** ${linked} does not match spec_path ${spec_path}`,
160
+ plan_path,
161
+ ),
162
+ );
110
163
  }
111
164
  }
112
165
 
113
166
  if (specBranch && planBranch && specBranch !== planBranch) {
114
- errors.push(err("branch_mismatch", `spec branch ${JSON.stringify(specBranch)} != plan branch ${JSON.stringify(planBranch)}`, plan_path));
167
+ errors.push(
168
+ err(
169
+ "branch_mismatch",
170
+ `spec branch ${JSON.stringify(specBranch)} != plan branch ${JSON.stringify(planBranch)}`,
171
+ plan_path,
172
+ ),
173
+ );
115
174
  }
116
175
 
117
176
  const [, , taskErr] = scanTaskHeadings(planText!);
@@ -123,10 +182,19 @@ export const docsValidate = ({
123
182
  const [headingIds, headingTitles, headingErr] = scanTaskHeadings(planText!);
124
183
  if (headingErr) return failValidate([headingErr]);
125
184
  if (tasks.length !== headingIds.length) {
126
- return failValidate([err("task_order", `parse count ${tasks.length} != heading count ${headingIds.length}`, plan_path)]);
185
+ return failValidate([
186
+ err(
187
+ "task_order",
188
+ `parse count ${tasks.length} != heading count ${headingIds.length}`,
189
+ plan_path,
190
+ ),
191
+ ]);
127
192
  }
128
193
  for (let i = 0; i < tasks.length; i++) {
129
- if (String(tasks[i].id) !== String(headingIds[i]) || tasks[i].title.trim() !== headingTitles[i]) {
194
+ if (
195
+ String(tasks[i].id) !== String(headingIds[i]) ||
196
+ tasks[i].title.trim() !== headingTitles[i]
197
+ ) {
130
198
  return failValidate([err("task_order", `task mismatch at position ${i + 1}`, plan_path)]);
131
199
  }
132
200
  }
@@ -134,7 +202,6 @@ export const docsValidate = ({
134
202
  const relSpec = path.isAbsolute(spec_path) ? path.relative(cwd, specAbs) : spec_path;
135
203
  const relPlan = path.isAbsolute(plan_path) ? path.relative(cwd, planAbs) : plan_path;
136
204
  const quality = qualitySpec(specText!);
137
- const slug = path.basename(path.dirname(spec_path));
138
205
  if (!sddIgnored(cwd, slug)) {
139
206
  quality.push({
140
207
  code: "sdd_not_ignored",
@@ -144,13 +211,45 @@ export const docsValidate = ({
144
211
  }
145
212
  const hygiene = hygieneFiles(cwd);
146
213
  const hyState = hygiene.state;
147
- if (hyState["CHANGELOG.md"] === "missing") quality.push({ code: "changelog_missing", message: "CHANGELOG.md missing — create it with Keep a Changelog format (run wk-init hygiene)", severity: "warning" });
148
- if (hyState["CHANGELOG.md"] === "invalid") quality.push({ code: "changelog_invalid_format", message: "CHANGELOG.md lacks ## [Unreleased] — Keep a Changelog format required", severity: "warning" });
149
- if (hyState["README.md"] === "missing") quality.push({ code: "readme_missing", message: "README.md missing", severity: "warning" });
150
- if (hyState[".editorconfig"] === "missing") quality.push({ code: "editorconfig_missing", message: ".editorconfig missing", severity: "warning" });
151
- if (hyState[".gitattributes"] === "missing") quality.push({ code: "gitattributes_missing", message: ".gitattributes missing", severity: "warning" });
152
- if (hygiene.openSource && hyState.LICENSE === "missing") quality.push({ code: "license_missing", message: "LICENSE missing (open-source repo)", severity: "warning" });
153
- if (hygiene.openSource && hyState["CONTRIBUTING.md"] === "missing") quality.push({ code: "contributing_missing", message: "CONTRIBUTING.md missing (open-source repo)", severity: "warning" });
214
+ if (hyState["CHANGELOG.md"] === "missing")
215
+ quality.push({
216
+ code: "changelog_missing",
217
+ message:
218
+ "CHANGELOG.md missing create it with Keep a Changelog format (run wk-init hygiene)",
219
+ severity: "warning",
220
+ });
221
+ if (hyState["CHANGELOG.md"] === "invalid")
222
+ quality.push({
223
+ code: "changelog_invalid_format",
224
+ message: "CHANGELOG.md lacks ## [Unreleased] — Keep a Changelog format required",
225
+ severity: "warning",
226
+ });
227
+ if (hyState["README.md"] === "missing")
228
+ quality.push({ code: "readme_missing", message: "README.md missing", severity: "warning" });
229
+ if (hyState[".editorconfig"] === "missing")
230
+ quality.push({
231
+ code: "editorconfig_missing",
232
+ message: ".editorconfig missing",
233
+ severity: "warning",
234
+ });
235
+ if (hyState[".gitattributes"] === "missing")
236
+ quality.push({
237
+ code: "gitattributes_missing",
238
+ message: ".gitattributes missing",
239
+ severity: "warning",
240
+ });
241
+ if (hygiene.openSource && hyState.LICENSE === "missing")
242
+ quality.push({
243
+ code: "license_missing",
244
+ message: "LICENSE missing (open-source repo)",
245
+ severity: "warning",
246
+ });
247
+ if (hygiene.openSource && hyState["CONTRIBUTING.md"] === "missing")
248
+ quality.push({
249
+ code: "contributing_missing",
250
+ message: "CONTRIBUTING.md missing (open-source repo)",
251
+ severity: "warning",
252
+ });
154
253
  return {
155
254
  ok: true,
156
255
  spec: relSpec,
@@ -175,12 +274,22 @@ const REQUIRED_SECTIONS = [
175
274
  "## Acceptance criteria",
176
275
  ];
177
276
 
178
- const UI_KEYWORDS = [/\bui\b/, /\binterface\b/, /\bscreen\b/, /\bmodal\b/, /\bform\b/, /\bcomponent\b/];
277
+ const UI_KEYWORDS = [
278
+ /\bui\b/,
279
+ /\binterface\b/,
280
+ /\bscreen\b/,
281
+ /\bmodal\b/,
282
+ /\bform\b/,
283
+ /\bcomponent\b/,
284
+ ];
179
285
  const FLOW_KEYWORDS = [/\bflow\b/, /\bpipeline\b/, /\bsequence\b/, /\bdiagram\b/];
180
286
  const GLOSSARY_KEYWORDS = [/\bglossary\b/, /\bcontracts?\b/, /\bscope\b/];
181
287
 
182
- const finding = (code: string, message: string, severity: "warning" | "hard"): QualityFinding =>
183
- ({ code, message, severity });
288
+ const finding = (code: string, message: string, severity: "warning" | "hard"): QualityFinding => ({
289
+ code,
290
+ message,
291
+ severity,
292
+ });
184
293
 
185
294
  // Replace fenced code blocks with a single marker line so their content cannot
186
295
  // satisfy the checks, but the fence itself (and its language) stays detectable.
@@ -212,27 +321,52 @@ export const qualitySpec = (text: string): QualityFinding[] => {
212
321
 
213
322
  const hasCa = /^\s*(?:- CA-\d+|CA-\d+[.:])/m.test(body); // M1: ^ with /m covers line starts
214
323
  if (!hasCa) {
215
- findings.push(finding("missing_acceptance_criteria", "no enumerable CA-XX acceptance criteria found", "hard"));
324
+ findings.push(
325
+ finding(
326
+ "missing_acceptance_criteria",
327
+ "no enumerable CA-XX acceptance criteria found",
328
+ "hard",
329
+ ),
330
+ );
216
331
  }
217
332
 
218
333
  const hasAsciiFence = /```(?:text|ascii)/.test(body);
219
334
  const mentionsUi = UI_KEYWORDS.some((k) => k.test(lower));
220
335
  if (mentionsUi && !hasAsciiFence) {
221
- findings.push(finding("missing_ascii_for_ui", "spec mentions UI but has no ASCII wireframe fence", "warning"));
336
+ findings.push(
337
+ finding(
338
+ "missing_ascii_for_ui",
339
+ "spec mentions UI but has no ASCII wireframe fence",
340
+ "warning",
341
+ ),
342
+ );
222
343
  }
223
344
 
224
345
  const hasMermaid = /```mermaid/.test(body);
225
346
  const explicitlyNoFlow = /\bno (?:flow|pipeline|sequence|diagram)\b/.test(lower);
226
347
  const mentionsFlow = FLOW_KEYWORDS.some((k) => k.test(lower));
227
348
  if (mentionsFlow && !hasMermaid && !explicitlyNoFlow) {
228
- findings.push(finding("missing_mermaid_for_flow", "spec describes a flow/pipeline/sequence but has no mermaid fence", "warning"));
349
+ findings.push(
350
+ finding(
351
+ "missing_mermaid_for_flow",
352
+ "spec describes a flow/pipeline/sequence but has no mermaid fence",
353
+ "warning",
354
+ ),
355
+ );
229
356
  }
230
357
 
231
358
  const hasTable = /^\s*\|.+\|.+\|/m.test(body);
232
359
  const mentionsGlossary = GLOSSARY_KEYWORDS.some((k) => k.test(lower));
233
- const onlyOutOfScope = /\bout of scope\b/.test(lower) && !/\bglossary\b/.test(lower) && !/\bcontracts?\b/.test(lower);
360
+ const onlyOutOfScope =
361
+ /\bout of scope\b/.test(lower) && !/\bglossary\b/.test(lower) && !/\bcontracts?\b/.test(lower);
234
362
  if (mentionsGlossary && !hasTable && !onlyOutOfScope) {
235
- findings.push(finding("missing_table", "spec has glossary/contract/scope content but no markdown table", "warning"));
363
+ findings.push(
364
+ finding(
365
+ "missing_table",
366
+ "spec has glossary/contract/scope content but no markdown table",
367
+ "warning",
368
+ ),
369
+ );
236
370
  }
237
371
 
238
372
  return findings;
@@ -247,7 +381,11 @@ const sddIgnored = (cwd: string, slug: string): boolean => {
247
381
  return true; // not a git repo — nothing to be unignored in; validation presupposes a repo
248
382
  }
249
383
  try {
250
- execFileSync("git", ["-C", cwd, "check-ignore", path.posix.join("docs", slug, "sdd", "progress.md")], { stdio: "pipe" });
384
+ execFileSync(
385
+ "git",
386
+ ["-C", cwd, "check-ignore", path.posix.join("docs", slug, "sdd", "progress.md")],
387
+ { stdio: "pipe" },
388
+ );
251
389
  return true;
252
390
  } catch {
253
391
  return false;