@brainervirus/workit-core 0.4.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.
- package/LICENSE +21 -0
- package/README.md +180 -0
- package/commands/wk-changelog.md +2 -0
- package/commands/wk-commit.md +2 -0
- package/commands/wk-docs-refresh.md +2 -0
- package/commands/wk-handoff.md +2 -0
- package/commands/wk-implement.md +2 -0
- package/commands/wk-init.md +2 -0
- package/commands/wk-issue-update.md +2 -0
- package/commands/wk-meetings.md +2 -0
- package/commands/wk-pr.md +2 -0
- package/commands/wk-release-notes.md +2 -0
- package/commands/wk-status.md +2 -0
- package/commands/wk-verify.md +2 -0
- package/package.json +43 -0
- package/scripts/_shared/common.sh +158 -0
- package/scripts/changelog-context.sh +42 -0
- package/scripts/docs-refresh-context.sh +40 -0
- package/scripts/fixtures/sample-plan.md +15 -0
- package/scripts/fixtures/sample-sdd/progress.md +2 -0
- package/scripts/init/apply.sh +5 -0
- package/scripts/init/status.sh +5 -0
- package/scripts/init/toolkit-status.sh +5 -0
- package/scripts/install-cursor-plugin.sh +83 -0
- package/scripts/install-opencode-plugin.sh +76 -0
- package/scripts/pr-create.sh +5 -0
- package/scripts/pr-ready-context.sh +88 -0
- package/scripts/present/ascii-wireframe.sh +5 -0
- package/scripts/present/flow-diagram.sh +5 -0
- package/scripts/release-notes-context.sh +40 -0
- package/scripts/rewrite-workspace-deps.ts +17 -0
- package/scripts/run-cursor-mcp.sh +10 -0
- package/scripts/sync-runtime.sh +96 -0
- package/scripts/update-superpowers.sh +56 -0
- package/scripts/vcs/config.sh +5 -0
- package/scripts/vcs/merged-style.sh +5 -0
- package/scripts/vcs/token-create-urls.sh +5 -0
- package/scripts/vcs/verify-token.sh +5 -0
- package/scripts/verify-project.sh +140 -0
- package/scripts/youtrack/api.sh +5 -0
- package/scripts/youtrack/config.sh +5 -0
- package/scripts/youtrack/greeting.sh +5 -0
- package/scripts/youtrack/parse-duration.sh +5 -0
- package/scripts/youtrack/token-create-url.sh +5 -0
- package/scripts/youtrack/verify-token.sh +5 -0
- package/scripts/youtrack/work-date-ms.sh +5 -0
- package/skills/wk-changelog/SKILL.md +15 -0
- package/skills/wk-commit/SKILL.md +16 -0
- package/skills/wk-docs-refresh/SKILL.md +15 -0
- package/skills/wk-handoff/SKILL.md +17 -0
- package/skills/wk-implement/SKILL.md +41 -0
- package/skills/wk-init/SKILL.md +31 -0
- package/skills/wk-issue-update/SKILL.md +27 -0
- package/skills/wk-issue-update/references/youtrack-update-style.md +81 -0
- package/skills/wk-meetings/SKILL.md +17 -0
- package/skills/wk-pr/SKILL.md +27 -0
- package/skills/wk-release-notes/SKILL.md +15 -0
- package/skills/wk-status/SKILL.md +16 -0
- package/skills/wk-verify/SKILL.md +16 -0
- package/src/core/branch.ts +246 -0
- package/src/core/changelog.ts +312 -0
- package/src/core/config-guard.ts +26 -0
- package/src/core/config.ts +73 -0
- package/src/core/detector.ts +207 -0
- package/src/core/doc-render.ts +14 -0
- package/src/core/docs-repo.ts +196 -0
- package/src/core/docs-validate.ts +255 -0
- package/src/core/flow-state.ts +225 -0
- package/src/core/git.ts +56 -0
- package/src/core/gitignore.ts +43 -0
- package/src/core/handoff-context.ts +115 -0
- package/src/core/hygiene.ts +77 -0
- package/src/core/init.ts +443 -0
- package/src/core/parse-sections.ts +24 -0
- package/src/core/plan-tasks.ts +33 -0
- package/src/core/ports/init-apply.ts +15 -0
- package/src/core/ports/init-status.ts +4 -0
- package/src/core/ports/init-toolkit-status.ts +4 -0
- package/src/core/ports/pr-create.ts +22 -0
- package/src/core/ports/present-ascii.ts +10 -0
- package/src/core/ports/present-flow.ts +10 -0
- package/src/core/ports/vcs-config.ts +14 -0
- package/src/core/ports/vcs-merged-style.ts +5 -0
- package/src/core/ports/vcs-token-create-urls.ts +4 -0
- package/src/core/ports/vcs-verify-token.ts +4 -0
- package/src/core/ports/youtrack-api.ts +17 -0
- package/src/core/ports/youtrack-config.ts +23 -0
- package/src/core/ports/youtrack-greeting.ts +10 -0
- package/src/core/ports/youtrack-parse-duration.ts +14 -0
- package/src/core/ports/youtrack-token-create-url.ts +4 -0
- package/src/core/ports/youtrack-verify-token.ts +12 -0
- package/src/core/ports/youtrack-work-date-ms.ts +10 -0
- package/src/core/pr-create.ts +212 -0
- package/src/core/present.ts +100 -0
- package/src/core/reminder.ts +82 -0
- package/src/core/repo-tool.ts +9 -0
- package/src/core/rules.ts +122 -0
- package/src/core/scripts.ts +42 -0
- package/src/core/sdd.ts +188 -0
- package/src/core/templates.ts +37 -0
- package/src/core/vcs-config.ts +252 -0
- package/src/core/verify-parse.ts +27 -0
- package/src/core/workspaces.ts +81 -0
- package/src/core/youtrack.ts +488 -0
- package/src/core.ts +62 -0
- package/src/state.ts +22 -0
- package/src/tools/docs-repo.ts +42 -0
- package/src/tools/flow.ts +88 -0
- package/src/tools/handoff.ts +160 -0
- package/src/tools/index.ts +22 -0
- package/src/tools/present.ts +45 -0
- package/src/tools/repo.ts +357 -0
- package/src/tools/rules.ts +30 -0
- package/src/tools/sdd.ts +189 -0
- package/src/tools/templates.ts +27 -0
- package/src/tools/youtrack.ts +356 -0
- package/templates/execution-contract.md +56 -0
- package/templates/greeting.md +1 -0
- package/templates/headers.md +3 -0
- package/templates/hygiene/.editorconfig +8 -0
- package/templates/hygiene/.gitattributes +3 -0
- package/templates/hygiene/CHANGELOG.md +14 -0
- package/templates/hygiene/CONTRIBUTING.md +3 -0
- package/templates/hygiene/LICENSE +21 -0
- package/templates/hygiene/README.md +3 -0
- package/templates/issue-update.md +6 -0
- package/templates/plan-template.md +25 -0
- package/templates/spec-template.md +51 -0
- package/templates/superpowers-doc-contract.md +69 -0
- package/vendor/superpowers/skills/brainstorming/SKILL.md +159 -0
- package/vendor/superpowers/skills/brainstorming/scripts/frame-template.html +213 -0
- package/vendor/superpowers/skills/brainstorming/scripts/helper.js +167 -0
- package/vendor/superpowers/skills/brainstorming/scripts/server.cjs +723 -0
- package/vendor/superpowers/skills/brainstorming/scripts/start-server.sh +209 -0
- package/vendor/superpowers/skills/brainstorming/scripts/stop-server.sh +120 -0
- package/vendor/superpowers/skills/brainstorming/spec-document-reviewer-prompt.md +49 -0
- package/vendor/superpowers/skills/brainstorming/visual-companion.md +291 -0
- package/vendor/superpowers/skills/dispatching-parallel-agents/SKILL.md +185 -0
- package/vendor/superpowers/skills/executing-plans/SKILL.md +70 -0
- package/vendor/superpowers/skills/finishing-a-development-branch/SKILL.md +241 -0
- package/vendor/superpowers/skills/receiving-code-review/SKILL.md +213 -0
- package/vendor/superpowers/skills/requesting-code-review/SKILL.md +103 -0
- package/vendor/superpowers/skills/requesting-code-review/code-reviewer.md +172 -0
- package/vendor/superpowers/skills/subagent-driven-development/SKILL.md +418 -0
- package/vendor/superpowers/skills/subagent-driven-development/implementer-prompt.md +139 -0
- package/vendor/superpowers/skills/subagent-driven-development/scripts/review-package +44 -0
- package/vendor/superpowers/skills/subagent-driven-development/scripts/sdd-workspace +22 -0
- package/vendor/superpowers/skills/subagent-driven-development/scripts/task-brief +40 -0
- package/vendor/superpowers/skills/subagent-driven-development/task-reviewer-prompt.md +188 -0
- package/vendor/superpowers/skills/systematic-debugging/CREATION-LOG.md +119 -0
- package/vendor/superpowers/skills/systematic-debugging/SKILL.md +296 -0
- package/vendor/superpowers/skills/systematic-debugging/condition-based-waiting-example.ts +158 -0
- package/vendor/superpowers/skills/systematic-debugging/condition-based-waiting.md +115 -0
- package/vendor/superpowers/skills/systematic-debugging/defense-in-depth.md +122 -0
- package/vendor/superpowers/skills/systematic-debugging/find-polluter.sh +63 -0
- package/vendor/superpowers/skills/systematic-debugging/root-cause-tracing.md +169 -0
- package/vendor/superpowers/skills/systematic-debugging/test-academic.md +14 -0
- package/vendor/superpowers/skills/systematic-debugging/test-pressure-1.md +58 -0
- package/vendor/superpowers/skills/systematic-debugging/test-pressure-2.md +68 -0
- package/vendor/superpowers/skills/systematic-debugging/test-pressure-3.md +69 -0
- package/vendor/superpowers/skills/test-driven-development/SKILL.md +371 -0
- package/vendor/superpowers/skills/test-driven-development/testing-anti-patterns.md +299 -0
- package/vendor/superpowers/skills/using-git-worktrees/SKILL.md +202 -0
- package/vendor/superpowers/skills/using-superpowers/SKILL.md +62 -0
- package/vendor/superpowers/skills/using-superpowers/references/antigravity-tools.md +23 -0
- package/vendor/superpowers/skills/using-superpowers/references/codex-tools.md +39 -0
- package/vendor/superpowers/skills/using-superpowers/references/pi-tools.md +16 -0
- package/vendor/superpowers/skills/verification-before-completion/SKILL.md +139 -0
- package/vendor/superpowers/skills/writing-plans/SKILL.md +174 -0
- package/vendor/superpowers/skills/writing-plans/plan-document-reviewer-prompt.md +49 -0
- package/vendor/superpowers/skills/writing-skills/SKILL.md +689 -0
- package/vendor/superpowers/skills/writing-skills/anthropic-best-practices.md +1150 -0
- package/vendor/superpowers/skills/writing-skills/examples/CLAUDE_MD_TESTING.md +189 -0
- package/vendor/superpowers/skills/writing-skills/graphviz-conventions.dot +172 -0
- package/vendor/superpowers/skills/writing-skills/persuasion-principles.md +187 -0
- package/vendor/superpowers/skills/writing-skills/render-graphs.js +168 -0
- package/vendor/superpowers/skills/writing-skills/testing-skills-with-subagents.md +384 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
const configPath = () =>
|
|
7
|
+
process.env.WORKFLOW_DOCS_REPO_CONFIG
|
|
8
|
+
?? path.join(os.homedir(), ".config", "workflow-toolkit", "docs-repo.json");
|
|
9
|
+
|
|
10
|
+
export const readDocsRepoConfig = (): { path: string } | null => {
|
|
11
|
+
try {
|
|
12
|
+
const parsed = JSON.parse(readFileSync(configPath(), "utf8")) as { path?: string };
|
|
13
|
+
return parsed.path ? { path: parsed.path } : null;
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const writeDocsRepoConfig = (docsPath: string): void => {
|
|
20
|
+
const file = configPath();
|
|
21
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
22
|
+
writeFileSync(file, JSON.stringify({ path: docsPath }, null, 2) + "\n", "utf8");
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const docsRepoPath = (): string | null => readDocsRepoConfig()?.path ?? null;
|
|
26
|
+
|
|
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}` };
|
|
29
|
+
try {
|
|
30
|
+
execFileSync("git", ["-C", docsPath, "rev-parse", "--is-inside-work-tree"], { stdio: "pipe" });
|
|
31
|
+
} catch {
|
|
32
|
+
return { ok: false, error: `docs repo is not a git repository: ${docsPath}` };
|
|
33
|
+
}
|
|
34
|
+
const featuresDir = path.join(docsPath, "features");
|
|
35
|
+
if (!existsSync(featuresDir)) mkdirSync(featuresDir, { recursive: true });
|
|
36
|
+
return { ok: true };
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const linkDocsRepo = (
|
|
40
|
+
docsPath: string,
|
|
41
|
+
confirmed: boolean,
|
|
42
|
+
): { ok: true; path: string } | { ok: false; error: string } => {
|
|
43
|
+
if (!confirmed) return { ok: false, error: "confirmed: true required" };
|
|
44
|
+
const valid = validateDocsRepo(docsPath);
|
|
45
|
+
if (!valid.ok) return valid;
|
|
46
|
+
writeDocsRepoConfig(docsPath);
|
|
47
|
+
return { ok: true, path: docsPath };
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const listSpecs = (
|
|
51
|
+
workspaceRoot: string,
|
|
52
|
+
): { docs_repo: string | null; specs: { slug: string; spec: string; promoted: boolean; target: string | null }[] } => {
|
|
53
|
+
const repoPath = docsRepoPath();
|
|
54
|
+
const specs: { slug: string; spec: string; promoted: boolean; target: string | null }[] = [];
|
|
55
|
+
const docsDir = path.join(workspaceRoot, "docs");
|
|
56
|
+
if (existsSync(docsDir)) {
|
|
57
|
+
for (const slug of readdirSync(docsDir)) {
|
|
58
|
+
if (slug.startsWith(".")) continue;
|
|
59
|
+
const spec = path.posix.join("docs", slug, "spec.md");
|
|
60
|
+
if (!existsSync(path.join(workspaceRoot, spec))) continue;
|
|
61
|
+
let promoted = false;
|
|
62
|
+
let target: string | null = null;
|
|
63
|
+
if (repoPath) {
|
|
64
|
+
const featuresDir = path.join(repoPath, "features");
|
|
65
|
+
if (existsSync(featuresDir)) {
|
|
66
|
+
const match = readdirSync(featuresDir).find((d) => new RegExp(`^20\\d{2}-\\d{2}-${slug}$`).test(d));
|
|
67
|
+
if (match) {
|
|
68
|
+
promoted = true;
|
|
69
|
+
target = path.join(repoPath, "features", match);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
specs.push({ slug, spec, promoted, target });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { docs_repo: repoPath, specs };
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
import { docsValidate, qualitySpec } from "./docs-validate";
|
|
80
|
+
|
|
81
|
+
const monthPrefix = () => {
|
|
82
|
+
const now = new Date();
|
|
83
|
+
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const readSafe = (p: string): string | null => {
|
|
87
|
+
try { return readFileSync(p, "utf8"); } catch { return null; }
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const specSummary = (specText: string): string => {
|
|
91
|
+
const contextMatch = specText.match(/## Context\n\n([\s\S]*?)(?=\n## |\Z)/);
|
|
92
|
+
if (!contextMatch) return "";
|
|
93
|
+
const first = contextMatch[1].trim().split("\n").find((l) => l.trim() && !l.startsWith("<!--"));
|
|
94
|
+
return (first ?? "").trim();
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const specRepos = (specText: string): string => {
|
|
98
|
+
const match = specText.match(/^\*\*Repos:\*\*\s*(.+)$/m);
|
|
99
|
+
return match?.[1]?.trim() ?? "—";
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const SLUG_RE = /^[a-z0-9][a-z0-9._-]*$/i;
|
|
103
|
+
|
|
104
|
+
export const promoteSpec = (
|
|
105
|
+
workspaceRoot: string,
|
|
106
|
+
slug: string,
|
|
107
|
+
opts: { confirmed: boolean; force?: boolean },
|
|
108
|
+
): { ok: true; target_dir: string; files: string[]; index_updated: boolean }
|
|
109
|
+
| { ok: false; error: string; findings?: unknown[] } => {
|
|
110
|
+
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)}` };
|
|
112
|
+
const repoPath = docsRepoPath();
|
|
113
|
+
if (!repoPath) return { ok: false, error: "docs repo not linked — run workflow_docs_repo_link" };
|
|
114
|
+
const repoValid = validateDocsRepo(repoPath);
|
|
115
|
+
if (!repoValid.ok) return { ok: false, error: repoValid.error };
|
|
116
|
+
|
|
117
|
+
const specRel = path.posix.join("docs", slug, "spec.md");
|
|
118
|
+
const planRel = path.posix.join("docs", slug, "plan.md");
|
|
119
|
+
const specText = readSafe(path.join(workspaceRoot, specRel));
|
|
120
|
+
if (specText === null) return { ok: false, error: `docs/${slug}/spec.md not found` };
|
|
121
|
+
|
|
122
|
+
const planText = readSafe(path.join(workspaceRoot, planRel));
|
|
123
|
+
if (planText !== null) {
|
|
124
|
+
const validated = docsValidate({ spec_path: specRel, plan_path: planRel, workspace_root: workspaceRoot });
|
|
125
|
+
if (validated.ok === false) return { ok: false, error: validated.error };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const findings = qualitySpec(specText);
|
|
129
|
+
const hardFindings = findings.filter((f) => f.severity === "hard");
|
|
130
|
+
if (hardFindings.length > 0 && !opts.force) {
|
|
131
|
+
return { ok: false, error: "spec has hard quality findings; pass force: true to override", findings };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// SDD working state must be gitignored before promotion
|
|
135
|
+
if (!opts.force) {
|
|
136
|
+
const sddDir = path.join(workspaceRoot, "docs", slug, "sdd");
|
|
137
|
+
if (existsSync(sddDir)) {
|
|
138
|
+
try {
|
|
139
|
+
execFileSync("git", ["-C", workspaceRoot, "check-ignore", path.posix.join("docs", slug, "sdd", "progress.md")], { stdio: "pipe" });
|
|
140
|
+
} catch {
|
|
141
|
+
return { ok: false, error: `docs/${slug}/sdd/ is not gitignored — add 'docs/*/sdd/' to .gitignore or pass force: true` };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const prefix = monthPrefix();
|
|
147
|
+
const featuresDir = path.join(repoPath, "features");
|
|
148
|
+
const existing = existsSync(featuresDir)
|
|
149
|
+
? readdirSync(featuresDir).find((d) => new RegExp(`^20\\d{2}-\\d{2}-${slug}$`).test(d))
|
|
150
|
+
: undefined;
|
|
151
|
+
const targetDir = path.join(featuresDir, existing ?? `${prefix}-${slug}`);
|
|
152
|
+
mkdirSync(targetDir, { recursive: true });
|
|
153
|
+
|
|
154
|
+
const files: string[] = ["spec.md"];
|
|
155
|
+
writeFileSync(path.join(targetDir, "spec.md"), specText, "utf8");
|
|
156
|
+
if (planText !== null) {
|
|
157
|
+
writeFileSync(path.join(targetDir, "plan.md"), planText, "utf8");
|
|
158
|
+
files.push("plan.md");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const title = specText.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? slug;
|
|
162
|
+
const readme = `# Feature: ${title}
|
|
163
|
+
|
|
164
|
+
**Fecha:** ${prefix}
|
|
165
|
+
**Estado:** Spec en revisión
|
|
166
|
+
**Repos afectados:** ${specRepos(specText)}
|
|
167
|
+
|
|
168
|
+
## Resumen
|
|
169
|
+
|
|
170
|
+
${specSummary(specText)}
|
|
171
|
+
|
|
172
|
+
## Documentación
|
|
173
|
+
|
|
174
|
+
| Documento | Contenido |
|
|
175
|
+
| --- | --- |
|
|
176
|
+
| [spec.md](./spec.md) | Especificación completa |
|
|
177
|
+
${planText !== null ? "| [plan.md](./plan.md) | Plan de implementación |\n" : ""}`;
|
|
178
|
+
writeFileSync(path.join(targetDir, "README.md"), readme, "utf8");
|
|
179
|
+
files.push("README.md");
|
|
180
|
+
|
|
181
|
+
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`;
|
|
183
|
+
const row = `| [${slug}](./${prefix}-${slug}/) | ${specRepos(specText)} | Spec en revisión |`;
|
|
184
|
+
const rowRe = new RegExp(`^\\| \\[${slug}\\]\\([^)]*\\) \\|.*$`, "m");
|
|
185
|
+
let newIndex: string;
|
|
186
|
+
if (rowRe.test(indexText)) {
|
|
187
|
+
newIndex = indexText.replace(rowRe, row);
|
|
188
|
+
} else if (/^(\| --- \| --- \| --- \|\n)/m.test(indexText)) {
|
|
189
|
+
newIndex = indexText.replace(/^(\| --- \| --- \| --- \|\n)/m, `$1${row}\n`);
|
|
190
|
+
} else {
|
|
191
|
+
newIndex = indexText + `\n${row}\n`;
|
|
192
|
+
}
|
|
193
|
+
writeFileSync(indexPath, newIndex, "utf8");
|
|
194
|
+
|
|
195
|
+
return { ok: true, target_dir: targetDir, files, index_updated: true };
|
|
196
|
+
};
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { hygieneFiles } from "./hygiene";
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
export type DocError = { code: string; message: string; path?: string };
|
|
7
|
+
|
|
8
|
+
const BRANCH_RE = /^\s*\*+Branch:\*+\s*`?([^`\s|]+)`?\s*$/im;
|
|
9
|
+
const SPEC_LINK_RE = /^\s*\*+Spec:\*+\s*(?:`([^`]+)`|(\S+))\s*$/im;
|
|
10
|
+
const TASK_RE = /^###\s+Task\s+(\d+):\s*(.*)$/i;
|
|
11
|
+
|
|
12
|
+
const err = (code: string, message: string, path?: string): DocError => {
|
|
13
|
+
const item: DocError = { code, message };
|
|
14
|
+
if (path) item.path = path;
|
|
15
|
+
return item;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const failValidate = (
|
|
19
|
+
errors: DocError[],
|
|
20
|
+
): { ok: false; errors: DocError[]; error: string } => ({
|
|
21
|
+
ok: false,
|
|
22
|
+
errors,
|
|
23
|
+
error: errors.map((e) => e.message).filter(Boolean).join("; ") || "docs validation failed",
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const readBranch = (text: string, label: string): [string | null, DocError | null] => {
|
|
27
|
+
const match = text.match(BRANCH_RE);
|
|
28
|
+
if (!match) return [null, err("missing_branch", `**Branch:** feature/* or bugfix/* required in ${label}`)];
|
|
29
|
+
return [match[1].trim().replace(/`/g, ""), null];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const scanTaskHeadings = (planText: string): [number[], string[], DocError | null] => {
|
|
33
|
+
const ids: number[] = [];
|
|
34
|
+
const titles: string[] = [];
|
|
35
|
+
let inFence = false;
|
|
36
|
+
for (const line of planText.split("\n")) {
|
|
37
|
+
if (line.startsWith("```")) { inFence = !inFence; continue; }
|
|
38
|
+
if (inFence) continue;
|
|
39
|
+
const match = line.match(TASK_RE);
|
|
40
|
+
if (match) { ids.push(Number(match[1])); titles.push(match[2].trim()); }
|
|
41
|
+
}
|
|
42
|
+
if (ids.length === 0) return [ids, titles, err("task_order", "no ### Task N sections found outside fences")];
|
|
43
|
+
const expected = ids.map((_, i) => i + 1);
|
|
44
|
+
const sorted = [...ids].sort((a, b) => a - b);
|
|
45
|
+
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}`)];
|
|
47
|
+
}
|
|
48
|
+
return [ids, titles, null];
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// Port of scripts/lib/parse-plan-tasks.sh (JSON mode)
|
|
52
|
+
export const parseTasksFromPlan = (planText: string): { id: number; title: string; section_text: string }[] => {
|
|
53
|
+
const tasks: { id: number; title: string; section_text: string }[] = [];
|
|
54
|
+
let current: { id: number; title: string; body: string[] } | null = null;
|
|
55
|
+
let inFence = false;
|
|
56
|
+
for (const line of planText.split("\n")) {
|
|
57
|
+
if (line.startsWith("```")) { inFence = !inFence; continue; }
|
|
58
|
+
if (inFence) continue;
|
|
59
|
+
const match = line.match(TASK_RE);
|
|
60
|
+
if (match) {
|
|
61
|
+
if (current) tasks.push({ id: current.id, title: current.title, section_text: current.body.join("\n") });
|
|
62
|
+
current = { id: Number(match[1]), title: match[2].trim(), body: [] };
|
|
63
|
+
} else if (current) {
|
|
64
|
+
current.body.push(line);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (current) tasks.push({ id: current.id, title: current.title, section_text: current.body.join("\n") });
|
|
68
|
+
return tasks;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const docsValidate = ({
|
|
72
|
+
spec_path,
|
|
73
|
+
plan_path,
|
|
74
|
+
workspace_root,
|
|
75
|
+
}: {
|
|
76
|
+
spec_path: string;
|
|
77
|
+
plan_path: string;
|
|
78
|
+
workspace_root: string;
|
|
79
|
+
}):
|
|
80
|
+
| { ok: true; spec: string; plan: string; branch: string; task_count: number; quality: QualityFinding[] }
|
|
81
|
+
| { 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);
|
|
85
|
+
const errors: DocError[] = [];
|
|
86
|
+
|
|
87
|
+
const read = (p: string): string | null => {
|
|
88
|
+
try { return readFileSync(p, "utf8"); } catch { return null; }
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const specText = read(specAbs);
|
|
92
|
+
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));
|
|
95
|
+
if (errors.length) return failValidate(errors);
|
|
96
|
+
|
|
97
|
+
const [specBranch, specErr] = readBranch(specText!, "spec");
|
|
98
|
+
if (specErr) errors.push(specErr);
|
|
99
|
+
const [planBranch, planErr] = readBranch(planText!, "plan");
|
|
100
|
+
if (planErr) errors.push(planErr);
|
|
101
|
+
|
|
102
|
+
const linkMatch = planText!.match(SPEC_LINK_RE);
|
|
103
|
+
if (!linkMatch) {
|
|
104
|
+
errors.push(err("missing_spec_link", "**Spec:** link required in plan", plan_path));
|
|
105
|
+
} else {
|
|
106
|
+
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));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (specBranch && planBranch && specBranch !== planBranch) {
|
|
114
|
+
errors.push(err("branch_mismatch", `spec branch ${JSON.stringify(specBranch)} != plan branch ${JSON.stringify(planBranch)}`, plan_path));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const [, , taskErr] = scanTaskHeadings(planText!);
|
|
118
|
+
if (taskErr) errors.push(taskErr);
|
|
119
|
+
|
|
120
|
+
if (errors.length) return failValidate(errors);
|
|
121
|
+
|
|
122
|
+
const tasks = parseTasksFromPlan(planText!);
|
|
123
|
+
const [headingIds, headingTitles, headingErr] = scanTaskHeadings(planText!);
|
|
124
|
+
if (headingErr) return failValidate([headingErr]);
|
|
125
|
+
if (tasks.length !== headingIds.length) {
|
|
126
|
+
return failValidate([err("task_order", `parse count ${tasks.length} != heading count ${headingIds.length}`, plan_path)]);
|
|
127
|
+
}
|
|
128
|
+
for (let i = 0; i < tasks.length; i++) {
|
|
129
|
+
if (String(tasks[i].id) !== String(headingIds[i]) || tasks[i].title.trim() !== headingTitles[i]) {
|
|
130
|
+
return failValidate([err("task_order", `task mismatch at position ${i + 1}`, plan_path)]);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const relSpec = path.isAbsolute(spec_path) ? path.relative(cwd, specAbs) : spec_path;
|
|
135
|
+
const relPlan = path.isAbsolute(plan_path) ? path.relative(cwd, planAbs) : plan_path;
|
|
136
|
+
const quality = qualitySpec(specText!);
|
|
137
|
+
const slug = path.basename(path.dirname(spec_path));
|
|
138
|
+
if (!sddIgnored(cwd, slug)) {
|
|
139
|
+
quality.push({
|
|
140
|
+
code: "sdd_not_ignored",
|
|
141
|
+
message: `docs/${slug}/sdd/ exists but is not gitignored — add 'docs/*/sdd/' to .gitignore (or run wk-init)`,
|
|
142
|
+
severity: "hard",
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
const hygiene = hygieneFiles(cwd);
|
|
146
|
+
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" });
|
|
154
|
+
return {
|
|
155
|
+
ok: true,
|
|
156
|
+
spec: relSpec,
|
|
157
|
+
plan: relPlan,
|
|
158
|
+
branch: specBranch!,
|
|
159
|
+
task_count: tasks.length,
|
|
160
|
+
quality,
|
|
161
|
+
};
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
export type QualityFinding = {
|
|
165
|
+
code: string;
|
|
166
|
+
message: string;
|
|
167
|
+
severity: "warning" | "hard";
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const REQUIRED_SECTIONS = [
|
|
171
|
+
"## Context",
|
|
172
|
+
"## Goals",
|
|
173
|
+
"## Non-goals",
|
|
174
|
+
"## Architecture",
|
|
175
|
+
"## Acceptance criteria",
|
|
176
|
+
];
|
|
177
|
+
|
|
178
|
+
const UI_KEYWORDS = [/\bui\b/, /\binterface\b/, /\bscreen\b/, /\bmodal\b/, /\bform\b/, /\bcomponent\b/];
|
|
179
|
+
const FLOW_KEYWORDS = [/\bflow\b/, /\bpipeline\b/, /\bsequence\b/, /\bdiagram\b/];
|
|
180
|
+
const GLOSSARY_KEYWORDS = [/\bglossary\b/, /\bcontracts?\b/, /\bscope\b/];
|
|
181
|
+
|
|
182
|
+
const finding = (code: string, message: string, severity: "warning" | "hard"): QualityFinding =>
|
|
183
|
+
({ code, message, severity });
|
|
184
|
+
|
|
185
|
+
// Replace fenced code blocks with a single marker line so their content cannot
|
|
186
|
+
// satisfy the checks, but the fence itself (and its language) stays detectable.
|
|
187
|
+
export const stripFences = (text: string): string => {
|
|
188
|
+
const lines = text.split("\n");
|
|
189
|
+
const out: string[] = [];
|
|
190
|
+
let inFence = false;
|
|
191
|
+
for (const line of lines) {
|
|
192
|
+
if (line.startsWith("```")) {
|
|
193
|
+
if (!inFence) out.push(line); // opening fence: keep the ```lang marker
|
|
194
|
+
inFence = !inFence;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (!inFence) out.push(line);
|
|
198
|
+
}
|
|
199
|
+
return out.join("\n");
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
export const qualitySpec = (text: string): QualityFinding[] => {
|
|
203
|
+
const findings: QualityFinding[] = [];
|
|
204
|
+
const body = stripFences(text);
|
|
205
|
+
const lower = body.toLowerCase();
|
|
206
|
+
|
|
207
|
+
for (const section of REQUIRED_SECTIONS) {
|
|
208
|
+
if (!body.includes(section)) {
|
|
209
|
+
findings.push(finding("missing_section", `required section ${section} missing`, "hard"));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const hasCa = /^\s*(?:- CA-\d+|CA-\d+[.:])/m.test(body); // M1: ^ with /m covers line starts
|
|
214
|
+
if (!hasCa) {
|
|
215
|
+
findings.push(finding("missing_acceptance_criteria", "no enumerable CA-XX acceptance criteria found", "hard"));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const hasAsciiFence = /```(?:text|ascii)/.test(body);
|
|
219
|
+
const mentionsUi = UI_KEYWORDS.some((k) => k.test(lower));
|
|
220
|
+
if (mentionsUi && !hasAsciiFence) {
|
|
221
|
+
findings.push(finding("missing_ascii_for_ui", "spec mentions UI but has no ASCII wireframe fence", "warning"));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const hasMermaid = /```mermaid/.test(body);
|
|
225
|
+
const explicitlyNoFlow = /\bno (?:flow|pipeline|sequence|diagram)\b/.test(lower);
|
|
226
|
+
const mentionsFlow = FLOW_KEYWORDS.some((k) => k.test(lower));
|
|
227
|
+
if (mentionsFlow && !hasMermaid && !explicitlyNoFlow) {
|
|
228
|
+
findings.push(finding("missing_mermaid_for_flow", "spec describes a flow/pipeline/sequence but has no mermaid fence", "warning"));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const hasTable = /^\s*\|.+\|.+\|/m.test(body);
|
|
232
|
+
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);
|
|
234
|
+
if (mentionsGlossary && !hasTable && !onlyOutOfScope) {
|
|
235
|
+
findings.push(finding("missing_table", "spec has glossary/contract/scope content but no markdown table", "warning"));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return findings;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const sddIgnored = (cwd: string, slug: string): boolean => {
|
|
242
|
+
const sddDir = path.join(cwd, "docs", slug, "sdd");
|
|
243
|
+
if (!existsSync(sddDir)) return true;
|
|
244
|
+
try {
|
|
245
|
+
execFileSync("git", ["-C", cwd, "rev-parse", "--is-inside-work-tree"], { stdio: "pipe" });
|
|
246
|
+
} catch {
|
|
247
|
+
return true; // not a git repo — nothing to be unignored in; validation presupposes a repo
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
execFileSync("git", ["-C", cwd, "check-ignore", path.posix.join("docs", slug, "sdd", "progress.md")], { stdio: "pipe" });
|
|
251
|
+
return true;
|
|
252
|
+
} catch {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
};
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync, existsSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parseTasksFromPlan, qualitySpec, stripFences } from "./docs-validate";
|
|
4
|
+
|
|
5
|
+
export type FlowStatus = "draft" | "self_reviewed" | "approved";
|
|
6
|
+
export type FlowDocState = { path: string; status: FlowStatus };
|
|
7
|
+
export type FlowState = {
|
|
8
|
+
slug: string;
|
|
9
|
+
spec: FlowDocState;
|
|
10
|
+
plan: FlowDocState;
|
|
11
|
+
menu: { presented: boolean; chosen: string };
|
|
12
|
+
updated_at: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type Result = { ok: true } | { ok: false; error: string };
|
|
16
|
+
|
|
17
|
+
const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
18
|
+
|
|
19
|
+
const flowPath = (root: string, slug: string) => {
|
|
20
|
+
if (!SLUG_RE.test(slug)) throw new Error(`invalid slug: ${JSON.stringify(slug)}`);
|
|
21
|
+
return path.join(root, "docs", slug, "sdd", "flow.json");
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const readFlowState = (root: string, slug: string): FlowState => {
|
|
25
|
+
const file = flowPath(root, slug);
|
|
26
|
+
if (!existsSync(file)) {
|
|
27
|
+
return {
|
|
28
|
+
slug,
|
|
29
|
+
spec: { path: "", status: "draft" },
|
|
30
|
+
plan: { path: "", status: "draft" },
|
|
31
|
+
menu: { presented: false, chosen: "" },
|
|
32
|
+
updated_at: Date.now(),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const parsed = JSON.parse(readFileSync(file, "utf8")) as Partial<FlowState>;
|
|
37
|
+
return {
|
|
38
|
+
slug: parsed.slug ?? slug,
|
|
39
|
+
spec: { path: parsed.spec?.path ?? "", status: parsed.spec?.status ?? "draft" },
|
|
40
|
+
plan: { path: parsed.plan?.path ?? "", status: parsed.plan?.status ?? "draft" },
|
|
41
|
+
menu: { presented: Boolean(parsed.menu?.presented), chosen: parsed.menu?.chosen ?? "" },
|
|
42
|
+
updated_at: parsed.updated_at ?? Date.now(),
|
|
43
|
+
};
|
|
44
|
+
} catch {
|
|
45
|
+
return {
|
|
46
|
+
slug,
|
|
47
|
+
spec: { path: "", status: "draft" },
|
|
48
|
+
plan: { path: "", status: "draft" },
|
|
49
|
+
menu: { presented: false, chosen: "" },
|
|
50
|
+
updated_at: Date.now(),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const writeFlowState = (root: string, state: FlowState) => {
|
|
56
|
+
const file = flowPath(root, state.slug);
|
|
57
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
58
|
+
const tmp = `${file}.tmp`;
|
|
59
|
+
writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n", "utf8");
|
|
60
|
+
renameSync(tmp, file);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const nextStatus = (
|
|
64
|
+
current: FlowStatus,
|
|
65
|
+
confirmed: boolean,
|
|
66
|
+
): { ok: false; error: string } | { ok: true; next: FlowStatus } => {
|
|
67
|
+
if (!confirmed) return { ok: false, error: "confirmed: true required" };
|
|
68
|
+
if (current === "draft") return { ok: true, next: "self_reviewed" };
|
|
69
|
+
if (current === "self_reviewed") return { ok: true, next: "approved" };
|
|
70
|
+
return { ok: false, error: "already approved; no further transitions" };
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const transitionSpec = (
|
|
74
|
+
root: string,
|
|
75
|
+
slug: string,
|
|
76
|
+
specPath: string,
|
|
77
|
+
confirmed: boolean,
|
|
78
|
+
): Result => {
|
|
79
|
+
let state: FlowState;
|
|
80
|
+
try {
|
|
81
|
+
state = readFlowState(root, slug);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
return { ok: false, error: error instanceof Error ? error.message : "invalid flow state" };
|
|
84
|
+
}
|
|
85
|
+
if (!existsSync(path.isAbsolute(specPath) ? specPath : path.join(root, specPath))) {
|
|
86
|
+
return { ok: false, error: `spec not found: ${specPath}` };
|
|
87
|
+
}
|
|
88
|
+
if (state.spec.status === "draft" && confirmed) {
|
|
89
|
+
const specFile = path.isAbsolute(specPath) ? specPath : path.join(root, specPath);
|
|
90
|
+
let text: string;
|
|
91
|
+
try {
|
|
92
|
+
text = readFileSync(specFile, "utf8");
|
|
93
|
+
} catch (error) {
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
error: `spec self-review failed: unreadable spec: ${error instanceof Error ? error.message : String(error)}`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
const hard = qualitySpec(text).filter((f) => f.severity === "hard");
|
|
100
|
+
const missing: string[] = [];
|
|
101
|
+
if (!/^\s*\*+Branch:\*+/im.test(stripFences(text))) missing.push("**Branch:** header missing");
|
|
102
|
+
if (hard.length > 0 || missing.length > 0) {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
error:
|
|
106
|
+
"spec self-review failed: " +
|
|
107
|
+
hard.map((f) => `${f.code} — ${f.message}`).concat(missing).join("; ") +
|
|
108
|
+
" — see templates/spec-template.md for the required structure",
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const step = nextStatus(state.spec.status, confirmed);
|
|
113
|
+
if (!step.ok) return { ok: false, error: step.error };
|
|
114
|
+
writeFlowState(root, {
|
|
115
|
+
...state,
|
|
116
|
+
spec: { path: specPath, status: step.next },
|
|
117
|
+
updated_at: Date.now(),
|
|
118
|
+
});
|
|
119
|
+
return { ok: true };
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export const transitionPlan = (
|
|
123
|
+
root: string,
|
|
124
|
+
slug: string,
|
|
125
|
+
planPath: string,
|
|
126
|
+
confirmed: boolean,
|
|
127
|
+
): Result => {
|
|
128
|
+
let state: FlowState;
|
|
129
|
+
try {
|
|
130
|
+
state = readFlowState(root, slug);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
return { ok: false, error: error instanceof Error ? error.message : "invalid flow state" };
|
|
133
|
+
}
|
|
134
|
+
if (!existsSync(path.isAbsolute(planPath) ? planPath : path.join(root, planPath))) {
|
|
135
|
+
return { ok: false, error: `plan not found: ${planPath}` };
|
|
136
|
+
}
|
|
137
|
+
if (state.spec.status !== "approved") {
|
|
138
|
+
return { ok: false, error: "spec must be approved before the plan can be approved" };
|
|
139
|
+
}
|
|
140
|
+
if (state.plan.status === "draft" && confirmed) {
|
|
141
|
+
const planFile = path.isAbsolute(planPath) ? planPath : path.join(root, planPath);
|
|
142
|
+
let text: string;
|
|
143
|
+
try {
|
|
144
|
+
text = readFileSync(planFile, "utf8");
|
|
145
|
+
} catch (error) {
|
|
146
|
+
return {
|
|
147
|
+
ok: false,
|
|
148
|
+
error: `plan self-review failed: unreadable plan: ${error instanceof Error ? error.message : String(error)}`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
const missing: string[] = [];
|
|
152
|
+
const stripped = stripFences(text);
|
|
153
|
+
if (parseTasksFromPlan(text).length === 0) missing.push("no ### Task N: sections outside fences");
|
|
154
|
+
if (!/^\s*\*+Spec:\*+/im.test(stripped)) missing.push("**Spec:** header missing");
|
|
155
|
+
if (!/^\s*\*+Branch:\*+/im.test(stripped)) missing.push("**Branch:** header missing");
|
|
156
|
+
if (missing.length > 0) {
|
|
157
|
+
return { ok: false, error: "plan self-review failed: " + missing.join("; ") };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const step = nextStatus(state.plan.status, confirmed);
|
|
161
|
+
if (!step.ok) return { ok: false, error: step.error };
|
|
162
|
+
writeFlowState(root, {
|
|
163
|
+
...state,
|
|
164
|
+
plan: { path: planPath, status: step.next },
|
|
165
|
+
updated_at: Date.now(),
|
|
166
|
+
});
|
|
167
|
+
return { ok: true };
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
export const recordMenuChoice = (
|
|
171
|
+
root: string,
|
|
172
|
+
slug: string,
|
|
173
|
+
planPath: string,
|
|
174
|
+
choice: string,
|
|
175
|
+
confirmed: boolean,
|
|
176
|
+
): Result => {
|
|
177
|
+
if (!confirmed) return { ok: false, error: "confirmed: true required" };
|
|
178
|
+
let state: FlowState;
|
|
179
|
+
try {
|
|
180
|
+
state = readFlowState(root, slug);
|
|
181
|
+
} catch (error) {
|
|
182
|
+
return { ok: false, error: error instanceof Error ? error.message : "invalid flow state" };
|
|
183
|
+
}
|
|
184
|
+
writeFlowState(root, {
|
|
185
|
+
...state,
|
|
186
|
+
plan: state.plan.path ? state.plan : { path: planPath, status: state.plan.status },
|
|
187
|
+
menu: { presented: true, chosen: choice },
|
|
188
|
+
updated_at: Date.now(),
|
|
189
|
+
});
|
|
190
|
+
return { ok: true };
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
export const slugFromPath = (p: string) => {
|
|
194
|
+
const dirName = path.basename(path.dirname(p));
|
|
195
|
+
return dirName === "." || dirName === "/" || dirName === "" ? "" : dirName;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
export const assertFlowGates = (
|
|
199
|
+
root: string,
|
|
200
|
+
planPath: string,
|
|
201
|
+
opts: { requireMenu?: boolean } = {},
|
|
202
|
+
): Result => {
|
|
203
|
+
const slug = slugFromPath(planPath);
|
|
204
|
+
const state = readFlowState(root, slug);
|
|
205
|
+
if (state.spec.status !== "approved") {
|
|
206
|
+
return {
|
|
207
|
+
ok: false,
|
|
208
|
+
error: `spec not approved (status: ${state.spec.status}). Run workflow_spec_approve after the user's approval.`,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
if (state.plan.status !== "approved") {
|
|
212
|
+
return {
|
|
213
|
+
ok: false,
|
|
214
|
+
error: `plan not approved (status: ${state.plan.status}). Run workflow_plan_approve after the user's approval.`,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
if (opts.requireMenu && !state.menu.presented) {
|
|
218
|
+
return {
|
|
219
|
+
ok: false,
|
|
220
|
+
error:
|
|
221
|
+
"post-plan menu not presented. Ask the native question menu (Subagent-driven/Inline/Handoff/Review spec/Review plan) and record the answer with workflow_plan_menu.",
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
return { ok: true };
|
|
225
|
+
};
|