@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.
- package/package.json +9 -9
- package/scripts/_shared/common.sh +18 -3
- package/scripts/install-opencode-plugin.sh +14 -9
- package/src/core/branch.ts +143 -48
- package/src/core/changelog.ts +17 -14
- package/src/core/config-guard.ts +9 -2
- package/src/core/config.ts +48 -15
- package/src/core/detector.ts +22 -11
- package/src/core/docs-repo.ts +49 -14
- package/src/core/docs-validate.ts +163 -37
- package/src/core/flow-state.ts +6 -2
- package/src/core/gitignore.ts +11 -2
- package/src/core/handoff-context.ts +18 -5
- package/src/core/hygiene.ts +27 -5
- package/src/core/init.ts +86 -21
- package/src/core/parse-sections.ts +2 -2
- package/src/core/plan-tasks.ts +13 -3
- package/src/core/ports/youtrack-api.ts +3 -1
- package/src/core/ports/youtrack-config.ts +1 -3
- package/src/core/pr-create.ts +47 -15
- package/src/core/present.ts +11 -2
- package/src/core/reminder.ts +1 -2
- package/src/core/repo-tool.ts +4 -1
- package/src/core/rules.ts +10 -7
- package/src/core/scripts.ts +7 -2
- package/src/core/sdd.ts +11 -3
- package/src/core/templates.ts +14 -4
- package/src/core/vcs-config.ts +93 -34
- package/src/core/verify-parse.ts +4 -2
- package/src/core/workspaces.ts +2 -2
- package/src/core/youtrack.ts +231 -56
- package/src/core.ts +18 -3
- package/src/tools/docs-repo.ts +12 -3
- package/src/tools/flow.ts +24 -13
- package/src/tools/handoff.ts +28 -23
- package/src/tools/present.ts +14 -10
- package/src/tools/repo.ts +220 -87
- package/src/tools/sdd.ts +93 -66
- package/src/tools/youtrack.ts +115 -52
- package/templates/superpowers-doc-contract.md +1 -1
package/src/core/detector.ts
CHANGED
|
@@ -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") ||
|
|
90
|
-
text.includes("
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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("```")) {
|
|
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");
|
package/src/core/docs-repo.ts
CHANGED
|
@@ -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))
|
|
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
|
-
): {
|
|
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) =>
|
|
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 {
|
|
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##
|
|
100
|
+
const contextMatch = specText.match(/## Context\n\n([\s\S]*?)(?=\n## |Z)/);
|
|
92
101
|
if (!contextMatch) return "";
|
|
93
|
-
const first = contextMatch[1]
|
|
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
|
-
):
|
|
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({
|
|
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 {
|
|
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(
|
|
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 {
|
|
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 =
|
|
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:
|
|
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)
|
|
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("```")) {
|
|
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) {
|
|
46
|
+
if (match) {
|
|
47
|
+
ids.push(Number(match[1]));
|
|
48
|
+
titles.push(match[2].trim());
|
|
49
|
+
}
|
|
41
50
|
}
|
|
42
|
-
if (ids.length === 0)
|
|
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 [
|
|
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 = (
|
|
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("```")) {
|
|
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)
|
|
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)
|
|
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
|
-
| {
|
|
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 {
|
|
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)
|
|
94
|
-
|
|
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(
|
|
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(
|
|
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([
|
|
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 (
|
|
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")
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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 = [
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
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(
|
|
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(
|
|
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;
|
package/src/core/flow-state.ts
CHANGED
|
@@ -104,7 +104,10 @@ export const transitionSpec = (
|
|
|
104
104
|
ok: false,
|
|
105
105
|
error:
|
|
106
106
|
"spec self-review failed: " +
|
|
107
|
-
hard
|
|
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)
|
|
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) {
|
package/src/core/gitignore.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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)
|
|
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(
|
|
53
|
-
|
|
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 {
|
|
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 {
|
|
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 };
|