@brainervirus/workit-core 0.6.0 → 0.6.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 (40) hide show
  1. package/package.json +9 -9
  2. package/scripts/_shared/common.sh +18 -3
  3. package/scripts/install-opencode-plugin.sh +14 -9
  4. package/src/core/branch.ts +143 -48
  5. package/src/core/changelog.ts +17 -14
  6. package/src/core/config-guard.ts +9 -2
  7. package/src/core/config.ts +48 -15
  8. package/src/core/detector.ts +22 -11
  9. package/src/core/docs-repo.ts +49 -14
  10. package/src/core/docs-validate.ts +163 -37
  11. package/src/core/flow-state.ts +6 -2
  12. package/src/core/gitignore.ts +11 -2
  13. package/src/core/handoff-context.ts +18 -5
  14. package/src/core/hygiene.ts +27 -5
  15. package/src/core/init.ts +86 -21
  16. package/src/core/parse-sections.ts +2 -2
  17. package/src/core/plan-tasks.ts +13 -3
  18. package/src/core/ports/youtrack-api.ts +3 -1
  19. package/src/core/ports/youtrack-config.ts +1 -3
  20. package/src/core/pr-create.ts +47 -15
  21. package/src/core/present.ts +11 -2
  22. package/src/core/reminder.ts +1 -2
  23. package/src/core/repo-tool.ts +4 -1
  24. package/src/core/rules.ts +10 -7
  25. package/src/core/scripts.ts +7 -2
  26. package/src/core/sdd.ts +11 -3
  27. package/src/core/templates.ts +14 -4
  28. package/src/core/vcs-config.ts +93 -34
  29. package/src/core/verify-parse.ts +4 -2
  30. package/src/core/workspaces.ts +2 -2
  31. package/src/core/youtrack.ts +231 -56
  32. package/src/core.ts +18 -3
  33. package/src/tools/docs-repo.ts +12 -3
  34. package/src/tools/flow.ts +24 -13
  35. package/src/tools/handoff.ts +28 -23
  36. package/src/tools/present.ts +14 -10
  37. package/src/tools/repo.ts +220 -87
  38. package/src/tools/sdd.ts +93 -66
  39. package/src/tools/youtrack.ts +115 -52
  40. package/templates/superpowers-doc-contract.md +1 -1
@@ -5,8 +5,7 @@ import { parseTasksFromPlan } from "./docs-validate";
5
5
 
6
6
  export type Detection = { choices: string[]; pattern: "alpha" | "numeric" } | null;
7
7
 
8
- export const detectConfigGapError = (text: string): boolean =>
9
- text.includes(CONFIG_GAP_MARKER);
8
+ export const detectConfigGapError = (text: string): boolean => text.includes(CONFIG_GAP_MARKER);
10
9
 
11
10
  // Enforcement-rail detectors: case-insensitive word-boundary heuristics.
12
11
  // Conservative bias (D-03): require 1+ signal word AND 0 evidence words;
@@ -86,20 +85,27 @@ export const detectInstructionOption = (questions: unknown): boolean => {
86
85
  // Labeled blocks are stripped first so their plain closing fence (```) can't match.
87
86
  export const detectRawDocDelivery = (text: string): boolean =>
88
87
  /^```\s*$/m.test(text.replace(/```\S[^\n]*\r?\n[\s\S]*?```/g, "")) &&
89
- (text.includes("# Spec") || text.includes("# Plan") ||
90
- text.includes("**Spec:**") || text.includes("**Branch:**"));
88
+ (text.includes("# Spec") ||
89
+ text.includes("# Plan") ||
90
+ text.includes("**Spec:**") ||
91
+ text.includes("**Branch:**"));
91
92
 
92
93
  // Interrogative gate: a literal question mark OR explicit interrogative phrases.
93
94
  // Plain "I want to confirm..." or "the script which runs" must NOT match.
94
- const INTERROGATIVE = /[?¿]|which\s+one|choose\s+(?:one|between|among)|do\s+you\s+(?:want|prefer)|want\s+me\s+to/i;
95
+ const INTERROGATIVE =
96
+ /[?¿]|which\s+one|choose\s+(?:one|between|among)|do\s+you\s+(?:want|prefer)|want\s+me\s+to/i;
95
97
 
96
98
  export const detectProseChoices = (text: string): Detection => {
97
99
  if (!INTERROGATIVE.test(text)) return null;
98
100
 
99
- const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
101
+ const lines = text
102
+ .split("\n")
103
+ .map((l) => l.trim())
104
+ .filter(Boolean);
100
105
 
101
- const alphaAll = [...text.matchAll(/([a-dA-D])[.)]\s+([^\n]*?)(?=\s+[a-dA-D][.)]\s|$)/g)]
102
- .map((m) => ({ letter: m[1].toLowerCase(), choice: m[2].trim() }));
106
+ const alphaAll = [...text.matchAll(/([a-dA-D])[.)]\s+([^\n]*?)(?=\s+[a-dA-D][.)]\s|$)/g)].map(
107
+ (m) => ({ letter: m[1].toLowerCase(), choice: m[2].trim() }),
108
+ );
103
109
  const alphaLines = lines
104
110
  .map((l) => /^([a-dA-D])[.)]\s+(.+)$/.exec(l))
105
111
  .filter((m): m is RegExpExecArray => Boolean(m))
@@ -114,8 +120,10 @@ export const detectProseChoices = (text: string): Detection => {
114
120
  }
115
121
  }
116
122
 
117
- const numericAll = [...text.matchAll(/(\d+)[.)]\s+([^\n]*?)(?=\s+\d+[.)]\s|$)/g)]
118
- .map((m) => ({ num: Number(m[1]), choice: m[2].trim() }));
123
+ const numericAll = [...text.matchAll(/(\d+)[.)]\s+([^\n]*?)(?=\s+\d+[.)]\s|$)/g)].map((m) => ({
124
+ num: Number(m[1]),
125
+ choice: m[2].trim(),
126
+ }));
119
127
  const numericLines = lines
120
128
  .map((l) => /^(\d+)[.)]\s+(.+)$/.exec(l))
121
129
  .filter((m): m is RegExpExecArray => Boolean(m))
@@ -137,7 +145,10 @@ const stripFences = (text: string): string => {
137
145
  const out: string[] = [];
138
146
  let inFence = false;
139
147
  for (const line of lines) {
140
- if (line.startsWith("```")) { inFence = !inFence; continue; }
148
+ if (line.startsWith("```")) {
149
+ inFence = !inFence;
150
+ continue;
151
+ }
141
152
  if (!inFence) out.push(line);
142
153
  }
143
154
  return out.join("\n");
@@ -4,8 +4,7 @@ import path from "node:path";
4
4
  import { configDir } from "./config";
5
5
 
6
6
  const configPath = () =>
7
- process.env.WORKFLOW_DOCS_REPO_CONFIG
8
- ?? path.join(configDir(), "docs-repo.json");
7
+ process.env.WORKFLOW_DOCS_REPO_CONFIG ?? path.join(configDir(), "docs-repo.json");
9
8
 
10
9
  export const readDocsRepoConfig = (): { path: string } | null => {
11
10
  try {
@@ -25,7 +24,8 @@ export const writeDocsRepoConfig = (docsPath: string): void => {
25
24
  export const docsRepoPath = (): string | null => readDocsRepoConfig()?.path ?? null;
26
25
 
27
26
  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}` };
27
+ if (!existsSync(docsPath))
28
+ return { ok: false, error: `docs repo path does not exist: ${docsPath}` };
29
29
  try {
30
30
  execFileSync("git", ["-C", docsPath, "rev-parse", "--is-inside-work-tree"], { stdio: "pipe" });
31
31
  } catch {
@@ -49,7 +49,10 @@ export const linkDocsRepo = (
49
49
 
50
50
  export const listSpecs = (
51
51
  workspaceRoot: string,
52
- ): { docs_repo: string | null; specs: { slug: string; spec: string; promoted: boolean; target: string | null }[] } => {
52
+ ): {
53
+ docs_repo: string | null;
54
+ specs: { slug: string; spec: string; promoted: boolean; target: string | null }[];
55
+ } => {
53
56
  const repoPath = docsRepoPath();
54
57
  const specs: { slug: string; spec: string; promoted: boolean; target: string | null }[] = [];
55
58
  const docsDir = path.join(workspaceRoot, "docs");
@@ -63,7 +66,9 @@ export const listSpecs = (
63
66
  if (repoPath) {
64
67
  const featuresDir = path.join(repoPath, "features");
65
68
  if (existsSync(featuresDir)) {
66
- const match = readdirSync(featuresDir).find((d) => new RegExp(`^20\\d{2}-\\d{2}-${slug}$`).test(d));
69
+ const match = readdirSync(featuresDir).find((d) =>
70
+ new RegExp(`^20\\d{2}-\\d{2}-${slug}$`).test(d),
71
+ );
67
72
  if (match) {
68
73
  promoted = true;
69
74
  target = path.join(repoPath, "features", match);
@@ -84,13 +89,20 @@ const monthPrefix = () => {
84
89
  };
85
90
 
86
91
  const readSafe = (p: string): string | null => {
87
- try { return readFileSync(p, "utf8"); } catch { return null; }
92
+ try {
93
+ return readFileSync(p, "utf8");
94
+ } catch {
95
+ return null;
96
+ }
88
97
  };
89
98
 
90
99
  const specSummary = (specText: string): string => {
91
- const contextMatch = specText.match(/## Context\n\n([\s\S]*?)(?=\n## |\Z)/);
100
+ const contextMatch = specText.match(/## Context\n\n([\s\S]*?)(?=\n## |Z)/);
92
101
  if (!contextMatch) return "";
93
- const first = contextMatch[1].trim().split("\n").find((l) => l.trim() && !l.startsWith("<!--"));
102
+ const first = contextMatch[1]
103
+ .trim()
104
+ .split("\n")
105
+ .find((l) => l.trim() && !l.startsWith("<!--"));
94
106
  return (first ?? "").trim();
95
107
  };
96
108
 
@@ -105,7 +117,8 @@ export const promoteSpec = (
105
117
  workspaceRoot: string,
106
118
  slug: string,
107
119
  opts: { confirmed: boolean; force?: boolean },
108
- ): { ok: true; target_dir: string; files: string[]; index_updated: boolean }
120
+ ):
121
+ | { ok: true; target_dir: string; files: string[]; index_updated: boolean }
109
122
  | { ok: false; error: string; findings?: unknown[] } => {
110
123
  if (!opts.confirmed) return { ok: false, error: "confirmed: true required" };
111
124
  if (!SLUG_RE.test(slug)) return { ok: false, error: `invalid slug: ${JSON.stringify(slug)}` };
@@ -121,14 +134,22 @@ export const promoteSpec = (
121
134
 
122
135
  const planText = readSafe(path.join(workspaceRoot, planRel));
123
136
  if (planText !== null) {
124
- const validated = docsValidate({ spec_path: specRel, plan_path: planRel, workspace_root: workspaceRoot });
137
+ const validated = docsValidate({
138
+ spec_path: specRel,
139
+ plan_path: planRel,
140
+ workspace_root: workspaceRoot,
141
+ });
125
142
  if (validated.ok === false) return { ok: false, error: validated.error };
126
143
  }
127
144
 
128
145
  const findings = qualitySpec(specText);
129
146
  const hardFindings = findings.filter((f) => f.severity === "hard");
130
147
  if (hardFindings.length > 0 && !opts.force) {
131
- return { ok: false, error: "spec has hard quality findings; pass force: true to override", findings };
148
+ return {
149
+ ok: false,
150
+ error: "spec has hard quality findings; pass force: true to override",
151
+ findings,
152
+ };
132
153
  }
133
154
 
134
155
  // SDD working state must be gitignored before promotion
@@ -136,9 +157,21 @@ export const promoteSpec = (
136
157
  const sddDir = path.join(workspaceRoot, "docs", slug, "sdd");
137
158
  if (existsSync(sddDir)) {
138
159
  try {
139
- execFileSync("git", ["-C", workspaceRoot, "check-ignore", path.posix.join("docs", slug, "sdd", "progress.md")], { stdio: "pipe" });
160
+ execFileSync(
161
+ "git",
162
+ [
163
+ "-C",
164
+ workspaceRoot,
165
+ "check-ignore",
166
+ path.posix.join("docs", slug, "sdd", "progress.md"),
167
+ ],
168
+ { stdio: "pipe" },
169
+ );
140
170
  } catch {
141
- return { ok: false, error: `docs/${slug}/sdd/ is not gitignored — add 'docs/*/sdd/' to .gitignore or pass force: true` };
171
+ return {
172
+ ok: false,
173
+ error: `docs/${slug}/sdd/ is not gitignored — add 'docs/*/sdd/' to .gitignore or pass force: true`,
174
+ };
142
175
  }
143
176
  }
144
177
  }
@@ -179,7 +212,9 @@ ${planText !== null ? "| [plan.md](./plan.md) | Plan de implementación |\n" : "
179
212
  files.push("README.md");
180
213
 
181
214
  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`;
215
+ const indexText =
216
+ readSafe(indexPath) ??
217
+ `# Features\n\nEspecificaciones y planes por feature.\n\n## Features documentadas\n\n| Feature | Repos afectados | Estado |\n| --- | --- | --- |\n`;
183
218
  const row = `| [${slug}](./${prefix}-${slug}/) | ${specRepos(specText)} | Spec en revisión |`;
184
219
  const rowRe = new RegExp(`^\\| \\[${slug}\\]\\([^)]*\\) \\|.*$`, "m");
185
220
  let newIndex: string;
@@ -15,17 +15,20 @@ const err = (code: string, message: string, path?: string): DocError => {
15
15
  return item;
16
16
  };
17
17
 
18
- const failValidate = (
19
- errors: DocError[],
20
- ): { ok: false; errors: DocError[]; error: string } => ({
18
+ const failValidate = (errors: DocError[]): { ok: false; errors: DocError[]; error: string } => ({
21
19
  ok: false,
22
20
  errors,
23
- error: errors.map((e) => e.message).filter(Boolean).join("; ") || "docs validation failed",
21
+ error:
22
+ errors
23
+ .map((e) => e.message)
24
+ .filter(Boolean)
25
+ .join("; ") || "docs validation failed",
24
26
  });
25
27
 
26
28
  const readBranch = (text: string, label: string): [string | null, DocError | null] => {
27
29
  const match = text.match(BRANCH_RE);
28
- if (!match) return [null, err("missing_branch", `**Branch:** feature/* or bugfix/* required in ${label}`)];
30
+ if (!match)
31
+ return [null, err("missing_branch", `**Branch:** feature/* or bugfix/* required in ${label}`)];
29
32
  return [match[1].trim().replace(/`/g, ""), null];
30
33
  };
31
34
 
@@ -34,37 +37,55 @@ const scanTaskHeadings = (planText: string): [number[], string[], DocError | nul
34
37
  const titles: string[] = [];
35
38
  let inFence = false;
36
39
  for (const line of planText.split("\n")) {
37
- if (line.startsWith("```")) { inFence = !inFence; continue; }
40
+ if (line.startsWith("```")) {
41
+ inFence = !inFence;
42
+ continue;
43
+ }
38
44
  if (inFence) continue;
39
45
  const match = line.match(TASK_RE);
40
- if (match) { ids.push(Number(match[1])); titles.push(match[2].trim()); }
46
+ if (match) {
47
+ ids.push(Number(match[1]));
48
+ titles.push(match[2].trim());
49
+ }
41
50
  }
42
- if (ids.length === 0) return [ids, titles, err("task_order", "no ### Task N sections found outside fences")];
51
+ if (ids.length === 0)
52
+ return [ids, titles, err("task_order", "no ### Task N sections found outside fences")];
43
53
  const expected = ids.map((_, i) => i + 1);
44
54
  const sorted = [...ids].sort((a, b) => a - b);
45
55
  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}`)];
56
+ return [
57
+ ids,
58
+ titles,
59
+ err("task_order", `task headings must be contiguous from 1..${ids.length}; found ${ids}`),
60
+ ];
47
61
  }
48
62
  return [ids, titles, null];
49
63
  };
50
64
 
51
65
  // Port of scripts/lib/parse-plan-tasks.sh (JSON mode)
52
- export const parseTasksFromPlan = (planText: string): { id: number; title: string; section_text: string }[] => {
66
+ export const parseTasksFromPlan = (
67
+ planText: string,
68
+ ): { id: number; title: string; section_text: string }[] => {
53
69
  const tasks: { id: number; title: string; section_text: string }[] = [];
54
70
  let current: { id: number; title: string; body: string[] } | null = null;
55
71
  let inFence = false;
56
72
  for (const line of planText.split("\n")) {
57
- if (line.startsWith("```")) { inFence = !inFence; continue; }
73
+ if (line.startsWith("```")) {
74
+ inFence = !inFence;
75
+ continue;
76
+ }
58
77
  if (inFence) continue;
59
78
  const match = line.match(TASK_RE);
60
79
  if (match) {
61
- if (current) tasks.push({ id: current.id, title: current.title, section_text: current.body.join("\n") });
80
+ if (current)
81
+ tasks.push({ id: current.id, title: current.title, section_text: current.body.join("\n") });
62
82
  current = { id: Number(match[1]), title: match[2].trim(), body: [] };
63
83
  } else if (current) {
64
84
  current.body.push(line);
65
85
  }
66
86
  }
67
- if (current) tasks.push({ id: current.id, title: current.title, section_text: current.body.join("\n") });
87
+ if (current)
88
+ tasks.push({ id: current.id, title: current.title, section_text: current.body.join("\n") });
68
89
  return tasks;
69
90
  };
70
91
 
@@ -77,7 +98,14 @@ export const docsValidate = ({
77
98
  plan_path: string;
78
99
  workspace_root: string;
79
100
  }):
80
- | { ok: true; spec: string; plan: string; branch: string; task_count: number; quality: QualityFinding[] }
101
+ | {
102
+ ok: true;
103
+ spec: string;
104
+ plan: string;
105
+ branch: string;
106
+ task_count: number;
107
+ quality: QualityFinding[];
108
+ }
81
109
  | { ok: false; errors: DocError[]; error: string } => {
82
110
  const cwd = path.resolve(workspace_root);
83
111
  const specAbs = path.isAbsolute(spec_path) ? spec_path : path.join(cwd, spec_path);
@@ -85,13 +113,19 @@ export const docsValidate = ({
85
113
  const errors: DocError[] = [];
86
114
 
87
115
  const read = (p: string): string | null => {
88
- try { return readFileSync(p, "utf8"); } catch { return null; }
116
+ try {
117
+ return readFileSync(p, "utf8");
118
+ } catch {
119
+ return null;
120
+ }
89
121
  };
90
122
 
91
123
  const specText = read(specAbs);
92
124
  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));
125
+ if (specText === null)
126
+ errors.push(err("missing_file", `spec not found: ${spec_path}`, spec_path));
127
+ if (planText === null)
128
+ errors.push(err("missing_file", `plan not found: ${plan_path}`, plan_path));
95
129
  if (errors.length) return failValidate(errors);
96
130
 
97
131
  const [specBranch, specErr] = readBranch(specText!, "spec");
@@ -106,12 +140,24 @@ export const docsValidate = ({
106
140
  const linked = (linkMatch[1] ?? linkMatch[2] ?? "").trim();
107
141
  const linkedAbs = path.isAbsolute(linked) ? linked : path.join(cwd, linked);
108
142
  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));
143
+ errors.push(
144
+ err(
145
+ "spec_mismatch",
146
+ `plan **Spec:** ${linked} does not match spec_path ${spec_path}`,
147
+ plan_path,
148
+ ),
149
+ );
110
150
  }
111
151
  }
112
152
 
113
153
  if (specBranch && planBranch && specBranch !== planBranch) {
114
- errors.push(err("branch_mismatch", `spec branch ${JSON.stringify(specBranch)} != plan branch ${JSON.stringify(planBranch)}`, plan_path));
154
+ errors.push(
155
+ err(
156
+ "branch_mismatch",
157
+ `spec branch ${JSON.stringify(specBranch)} != plan branch ${JSON.stringify(planBranch)}`,
158
+ plan_path,
159
+ ),
160
+ );
115
161
  }
116
162
 
117
163
  const [, , taskErr] = scanTaskHeadings(planText!);
@@ -123,10 +169,19 @@ export const docsValidate = ({
123
169
  const [headingIds, headingTitles, headingErr] = scanTaskHeadings(planText!);
124
170
  if (headingErr) return failValidate([headingErr]);
125
171
  if (tasks.length !== headingIds.length) {
126
- return failValidate([err("task_order", `parse count ${tasks.length} != heading count ${headingIds.length}`, plan_path)]);
172
+ return failValidate([
173
+ err(
174
+ "task_order",
175
+ `parse count ${tasks.length} != heading count ${headingIds.length}`,
176
+ plan_path,
177
+ ),
178
+ ]);
127
179
  }
128
180
  for (let i = 0; i < tasks.length; i++) {
129
- if (String(tasks[i].id) !== String(headingIds[i]) || tasks[i].title.trim() !== headingTitles[i]) {
181
+ if (
182
+ String(tasks[i].id) !== String(headingIds[i]) ||
183
+ tasks[i].title.trim() !== headingTitles[i]
184
+ ) {
130
185
  return failValidate([err("task_order", `task mismatch at position ${i + 1}`, plan_path)]);
131
186
  }
132
187
  }
@@ -144,13 +199,45 @@ export const docsValidate = ({
144
199
  }
145
200
  const hygiene = hygieneFiles(cwd);
146
201
  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" });
202
+ if (hyState["CHANGELOG.md"] === "missing")
203
+ quality.push({
204
+ code: "changelog_missing",
205
+ message:
206
+ "CHANGELOG.md missing create it with Keep a Changelog format (run wk-init hygiene)",
207
+ severity: "warning",
208
+ });
209
+ if (hyState["CHANGELOG.md"] === "invalid")
210
+ quality.push({
211
+ code: "changelog_invalid_format",
212
+ message: "CHANGELOG.md lacks ## [Unreleased] — Keep a Changelog format required",
213
+ severity: "warning",
214
+ });
215
+ if (hyState["README.md"] === "missing")
216
+ quality.push({ code: "readme_missing", message: "README.md missing", severity: "warning" });
217
+ if (hyState[".editorconfig"] === "missing")
218
+ quality.push({
219
+ code: "editorconfig_missing",
220
+ message: ".editorconfig missing",
221
+ severity: "warning",
222
+ });
223
+ if (hyState[".gitattributes"] === "missing")
224
+ quality.push({
225
+ code: "gitattributes_missing",
226
+ message: ".gitattributes missing",
227
+ severity: "warning",
228
+ });
229
+ if (hygiene.openSource && hyState.LICENSE === "missing")
230
+ quality.push({
231
+ code: "license_missing",
232
+ message: "LICENSE missing (open-source repo)",
233
+ severity: "warning",
234
+ });
235
+ if (hygiene.openSource && hyState["CONTRIBUTING.md"] === "missing")
236
+ quality.push({
237
+ code: "contributing_missing",
238
+ message: "CONTRIBUTING.md missing (open-source repo)",
239
+ severity: "warning",
240
+ });
154
241
  return {
155
242
  ok: true,
156
243
  spec: relSpec,
@@ -175,12 +262,22 @@ const REQUIRED_SECTIONS = [
175
262
  "## Acceptance criteria",
176
263
  ];
177
264
 
178
- const UI_KEYWORDS = [/\bui\b/, /\binterface\b/, /\bscreen\b/, /\bmodal\b/, /\bform\b/, /\bcomponent\b/];
265
+ const UI_KEYWORDS = [
266
+ /\bui\b/,
267
+ /\binterface\b/,
268
+ /\bscreen\b/,
269
+ /\bmodal\b/,
270
+ /\bform\b/,
271
+ /\bcomponent\b/,
272
+ ];
179
273
  const FLOW_KEYWORDS = [/\bflow\b/, /\bpipeline\b/, /\bsequence\b/, /\bdiagram\b/];
180
274
  const GLOSSARY_KEYWORDS = [/\bglossary\b/, /\bcontracts?\b/, /\bscope\b/];
181
275
 
182
- const finding = (code: string, message: string, severity: "warning" | "hard"): QualityFinding =>
183
- ({ code, message, severity });
276
+ const finding = (code: string, message: string, severity: "warning" | "hard"): QualityFinding => ({
277
+ code,
278
+ message,
279
+ severity,
280
+ });
184
281
 
185
282
  // Replace fenced code blocks with a single marker line so their content cannot
186
283
  // satisfy the checks, but the fence itself (and its language) stays detectable.
@@ -212,27 +309,52 @@ export const qualitySpec = (text: string): QualityFinding[] => {
212
309
 
213
310
  const hasCa = /^\s*(?:- CA-\d+|CA-\d+[.:])/m.test(body); // M1: ^ with /m covers line starts
214
311
  if (!hasCa) {
215
- findings.push(finding("missing_acceptance_criteria", "no enumerable CA-XX acceptance criteria found", "hard"));
312
+ findings.push(
313
+ finding(
314
+ "missing_acceptance_criteria",
315
+ "no enumerable CA-XX acceptance criteria found",
316
+ "hard",
317
+ ),
318
+ );
216
319
  }
217
320
 
218
321
  const hasAsciiFence = /```(?:text|ascii)/.test(body);
219
322
  const mentionsUi = UI_KEYWORDS.some((k) => k.test(lower));
220
323
  if (mentionsUi && !hasAsciiFence) {
221
- findings.push(finding("missing_ascii_for_ui", "spec mentions UI but has no ASCII wireframe fence", "warning"));
324
+ findings.push(
325
+ finding(
326
+ "missing_ascii_for_ui",
327
+ "spec mentions UI but has no ASCII wireframe fence",
328
+ "warning",
329
+ ),
330
+ );
222
331
  }
223
332
 
224
333
  const hasMermaid = /```mermaid/.test(body);
225
334
  const explicitlyNoFlow = /\bno (?:flow|pipeline|sequence|diagram)\b/.test(lower);
226
335
  const mentionsFlow = FLOW_KEYWORDS.some((k) => k.test(lower));
227
336
  if (mentionsFlow && !hasMermaid && !explicitlyNoFlow) {
228
- findings.push(finding("missing_mermaid_for_flow", "spec describes a flow/pipeline/sequence but has no mermaid fence", "warning"));
337
+ findings.push(
338
+ finding(
339
+ "missing_mermaid_for_flow",
340
+ "spec describes a flow/pipeline/sequence but has no mermaid fence",
341
+ "warning",
342
+ ),
343
+ );
229
344
  }
230
345
 
231
346
  const hasTable = /^\s*\|.+\|.+\|/m.test(body);
232
347
  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);
348
+ const onlyOutOfScope =
349
+ /\bout of scope\b/.test(lower) && !/\bglossary\b/.test(lower) && !/\bcontracts?\b/.test(lower);
234
350
  if (mentionsGlossary && !hasTable && !onlyOutOfScope) {
235
- findings.push(finding("missing_table", "spec has glossary/contract/scope content but no markdown table", "warning"));
351
+ findings.push(
352
+ finding(
353
+ "missing_table",
354
+ "spec has glossary/contract/scope content but no markdown table",
355
+ "warning",
356
+ ),
357
+ );
236
358
  }
237
359
 
238
360
  return findings;
@@ -247,7 +369,11 @@ const sddIgnored = (cwd: string, slug: string): boolean => {
247
369
  return true; // not a git repo — nothing to be unignored in; validation presupposes a repo
248
370
  }
249
371
  try {
250
- execFileSync("git", ["-C", cwd, "check-ignore", path.posix.join("docs", slug, "sdd", "progress.md")], { stdio: "pipe" });
372
+ execFileSync(
373
+ "git",
374
+ ["-C", cwd, "check-ignore", path.posix.join("docs", slug, "sdd", "progress.md")],
375
+ { stdio: "pipe" },
376
+ );
251
377
  return true;
252
378
  } catch {
253
379
  return false;
@@ -104,7 +104,10 @@ export const transitionSpec = (
104
104
  ok: false,
105
105
  error:
106
106
  "spec self-review failed: " +
107
- hard.map((f) => `${f.code} — ${f.message}`).concat(missing).join("; ") +
107
+ hard
108
+ .map((f) => `${f.code} — ${f.message}`)
109
+ .concat(missing)
110
+ .join("; ") +
108
111
  " — see templates/spec-template.md for the required structure",
109
112
  };
110
113
  }
@@ -150,7 +153,8 @@ export const transitionPlan = (
150
153
  }
151
154
  const missing: string[] = [];
152
155
  const stripped = stripFences(text);
153
- if (parseTasksFromPlan(text).length === 0) missing.push("no ### Task N: sections outside fences");
156
+ if (parseTasksFromPlan(text).length === 0)
157
+ missing.push("no ### Task N: sections outside fences");
154
158
  if (!/^\s*\*+Spec:\*+/im.test(stripped)) missing.push("**Spec:** header missing");
155
159
  if (!/^\s*\*+Branch:\*+/im.test(stripped)) missing.push("**Branch:** header missing");
156
160
  if (missing.length > 0) {
@@ -25,7 +25,12 @@ export const ensureProjectGitignore = (
25
25
  if (!confirmed) return { ok: false, error: "confirmed: true required" };
26
26
  const file = path.join(workspaceRoot, ".gitignore");
27
27
  const existing = existsSync(file) ? readFileSync(file, "utf8") : "";
28
- const existingLines = new Set(existing.split("\n").map((l) => l.trim()).filter(Boolean));
28
+ const existingLines = new Set(
29
+ existing
30
+ .split("\n")
31
+ .map((l) => l.trim())
32
+ .filter(Boolean),
33
+ );
29
34
  const added: string[] = [];
30
35
  const append: string[] = [];
31
36
  for (const entry of GITIGNORE_ENTRIES) {
@@ -35,7 +40,11 @@ export const ensureProjectGitignore = (
35
40
  }
36
41
  if (append.length) {
37
42
  const separator = existing && !existing.endsWith("\n") ? "\n" : "";
38
- writeFileSync(file, existing + separator + (existing ? "\n" : "") + append.join("\n") + "\n", "utf8");
43
+ writeFileSync(
44
+ file,
45
+ existing + separator + (existing ? "\n" : "") + append.join("\n") + "\n",
46
+ "utf8",
47
+ );
39
48
  } else if (!existsSync(file)) {
40
49
  writeFileSync(file, "", "utf8");
41
50
  }
@@ -25,7 +25,8 @@ const resolveFromMessagePaths = (root: string, message: string): Resolved => {
25
25
  const paths = extractMessagePaths(message);
26
26
  if (paths.length === 0) return { error: "no paths" };
27
27
  const slugs = [...new Set(paths.map((p) => p.split("/")[1]))];
28
- if (slugs.length !== 1) return { error: "multiple features in message — use exactly one docs/<slug>/ pair" };
28
+ if (slugs.length !== 1)
29
+ return { error: "multiple features in message — use exactly one docs/<slug>/ pair" };
29
30
  const slug = slugs[0];
30
31
  const plan = `docs/${slug}/plan.md`;
31
32
  const spec = `docs/${slug}/spec.md`;
@@ -49,8 +50,15 @@ const resolveActivePair = (root: string): Resolved => {
49
50
  const plan = path.join("docs", slug, "plan.md");
50
51
  const spec = path.join("docs", slug, "spec.md");
51
52
  if (!existsSync(path.join(root, plan)) || !existsSync(path.join(root, spec))) continue;
52
- const score = Math.max(statSync(path.join(root, spec)).mtimeMs, statSync(path.join(root, plan)).mtimeMs);
53
- if (best === null || score > best.score || (score === best.score && slug < best.spec.split("/")[1])) {
53
+ const score = Math.max(
54
+ statSync(path.join(root, spec)).mtimeMs,
55
+ statSync(path.join(root, plan)).mtimeMs,
56
+ );
57
+ if (
58
+ best === null ||
59
+ score > best.score ||
60
+ (score === best.score && slug < best.spec.split("/")[1])
61
+ ) {
54
62
  best = { score, spec, plan, source: "active_pair" };
55
63
  }
56
64
  }
@@ -69,7 +77,10 @@ export const resolveWorkflowPaths = (root: string, message: string): Resolved =>
69
77
  if (!existsSync(docsDir) || listMd(docsDir).length === 0) {
70
78
  return { error: "no docs/<slug>/ features found under docs/" };
71
79
  }
72
- return { error: "could not resolve spec and plan — mention docs/<slug>/plan.md or create docs/<slug>/{spec.md,plan.md}" };
80
+ return {
81
+ error:
82
+ "could not resolve spec and plan — mention docs/<slug>/plan.md or create docs/<slug>/{spec.md,plan.md}",
83
+ };
73
84
  };
74
85
 
75
86
  export const buildHandoffContract = ({
@@ -85,7 +96,9 @@ export const buildHandoffContract = ({
85
96
  }): { prompt: string } | { error: string } => {
86
97
  const validated = docsValidate({ spec_path: spec, plan_path: plan, workspace_root: root });
87
98
  if (validated.ok === false) {
88
- return { error: `docs validation failed\n${JSON.stringify({ ok: false, errors: validated.errors })}` };
99
+ return {
100
+ error: `docs validation failed\n${JSON.stringify({ ok: false, errors: validated.errors })}`,
101
+ };
89
102
  }
90
103
  const branchResolved = resolveBranch({ spec_path: spec, plan_path: plan, workspace_root: root });
91
104
  if ("error" in branchResolved) return { error: branchResolved.error as string };