@phuthuycoding/kanban-flow 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +173 -0
  3. package/dist/cli/args.js +219 -0
  4. package/dist/cli/commands/approve.js +44 -0
  5. package/dist/cli/commands/archive.js +245 -0
  6. package/dist/cli/commands/artifacts.js +100 -0
  7. package/dist/cli/commands/autoconfig.js +180 -0
  8. package/dist/cli/commands/cancel.js +129 -0
  9. package/dist/cli/commands/contexts.js +101 -0
  10. package/dist/cli/commands/doctor.js +35 -0
  11. package/dist/cli/commands/harness.js +60 -0
  12. package/dist/cli/commands/helpers.js +22 -0
  13. package/dist/cli/commands/init.js +119 -0
  14. package/dist/cli/commands/inspect.js +141 -0
  15. package/dist/cli/commands/new.js +80 -0
  16. package/dist/cli/commands/rules.js +69 -0
  17. package/dist/cli/commands/run.js +156 -0
  18. package/dist/cli/commands/stage.js +186 -0
  19. package/dist/cli/result.js +1 -0
  20. package/dist/dashboard/dashboard-view.js +238 -0
  21. package/dist/dashboard/dashboard.js +206 -0
  22. package/dist/harness/chain.js +41 -0
  23. package/dist/harness/config.js +168 -0
  24. package/dist/harness/prompt.js +105 -0
  25. package/dist/harness/run.js +245 -0
  26. package/dist/harness/session.js +78 -0
  27. package/dist/harness/supervise.js +65 -0
  28. package/dist/index.js +123 -0
  29. package/dist/integrations/agents.js +67 -0
  30. package/dist/integrations/hooks.js +59 -0
  31. package/dist/integrations/install.js +193 -0
  32. package/dist/project/bootstrap.js +358 -0
  33. package/dist/project/config.js +111 -0
  34. package/dist/project/contexts.js +98 -0
  35. package/dist/project/doctor.js +163 -0
  36. package/dist/shared/frontmatter.js +54 -0
  37. package/dist/shared/paths.js +78 -0
  38. package/dist/shared/time.js +5 -0
  39. package/dist/workflow/direction.js +56 -0
  40. package/dist/workflow/features.js +198 -0
  41. package/dist/workflow/findings.js +3 -0
  42. package/dist/workflow/schema.js +148 -0
  43. package/dist/workflow/secrets.js +52 -0
  44. package/dist/workflow/status.js +188 -0
  45. package/dist/workflow/validate-approval.js +25 -0
  46. package/dist/workflow/validate-artifacts.js +89 -0
  47. package/dist/workflow/validate-cancel.js +14 -0
  48. package/dist/workflow/validate-reports.js +121 -0
  49. package/dist/workflow/validate-traceability.js +91 -0
  50. package/dist/workflow/validate.js +73 -0
  51. package/docs/workflow/README.md +67 -0
  52. package/docs/workflow/artifacts.md +60 -0
  53. package/docs/workflow/cli-reference.md +78 -0
  54. package/docs/workflow/dashboard.md +35 -0
  55. package/docs/workflow/gates.md +103 -0
  56. package/docs/workflow/harness.md +144 -0
  57. package/docs/workflow/lifecycle.md +107 -0
  58. package/docs/workflow/skills.md +52 -0
  59. package/docs/workflow/source-layout.md +47 -0
  60. package/docs/workflow/state-machine.md +83 -0
  61. package/kanban-flow/review/rules/README.md +30 -0
  62. package/kanban-flow/review/rules/general.md +41 -0
  63. package/kanban-flow/review/rules/performance.md +29 -0
  64. package/kanban-flow/review/rules/security.md +32 -0
  65. package/kanban-flow/review/stacks/go.md +33 -0
  66. package/kanban-flow/review/stacks/java.md +38 -0
  67. package/kanban-flow/review/stacks/node.md +28 -0
  68. package/kanban-flow/review/stacks/php.md +30 -0
  69. package/kanban-flow/review/stacks/python.md +34 -0
  70. package/kanban-flow/review/stacks/ruby.md +32 -0
  71. package/kanban-flow/review/stacks/rust.md +33 -0
  72. package/kanban-flow/templates/phase-1-bug-report.md +76 -0
  73. package/kanban-flow/templates/phase-1-spec-requirement.md +67 -0
  74. package/kanban-flow/templates/phase-2-implementation-plan.md +85 -0
  75. package/kanban-flow/templates/phase-2-test-case.md +68 -0
  76. package/kanban-flow/templates/phase-2-use-case-diagram.md +18 -0
  77. package/kanban-flow/templates/phase-2-use-case-specification.md +33 -0
  78. package/kanban-flow/templates/phase-2-use-case.md +60 -0
  79. package/kanban-flow/templates/phase-4-testing-result.md +63 -0
  80. package/kanban-flow/templates/phase-5-review-report.md +68 -0
  81. package/kanban-flow/templates/phase-6-feature-report.md +78 -0
  82. package/package.json +63 -0
  83. package/skills/kanban-archive/SKILL.md +78 -0
  84. package/skills/kanban-brainstorm/SKILL.md +310 -0
  85. package/skills/kanban-bug/SKILL.md +55 -0
  86. package/skills/kanban-flow/SKILL.md +136 -0
  87. package/skills/kanban-implement/SKILL.md +72 -0
  88. package/skills/kanban-plan/SKILL.md +102 -0
  89. package/skills/kanban-review/SKILL.md +90 -0
  90. package/skills/kanban-test/SKILL.md +76 -0
@@ -0,0 +1,98 @@
1
+ import { listFeatures } from "../workflow/features.js";
2
+ /** Fallback when neither `contexts` nor `defaultContext` is set. Matches the historical default. */
3
+ export const FALLBACK_CONTEXT = "app";
4
+ /**
5
+ * Contexts are compared case-insensitively when suggesting a correction: `assertPathName` accepts
6
+ * `Auth`, but on a case-sensitive filesystem that would be a second docs tree next to `auth`.
7
+ */
8
+ export function normalizeContext(context) {
9
+ return context.trim().toLowerCase();
10
+ }
11
+ function levenshtein(a, b) {
12
+ // Single-row dynamic programming; the strings here are short context names.
13
+ let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
14
+ for (let i = 1; i <= a.length; i += 1) {
15
+ const current = [i];
16
+ for (let j = 1; j <= b.length; j += 1) {
17
+ const substitution = previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1);
18
+ current[j] = Math.min(current[j - 1] + 1, previous[j] + 1, substitution);
19
+ }
20
+ previous = current;
21
+ }
22
+ return previous[b.length];
23
+ }
24
+ /**
25
+ * Nearest declared context, or null when nothing is close enough. A suggestion is always a verbatim
26
+ * element of `declared`, so following one is always accepted by `checkContext`.
27
+ */
28
+ export function suggestContext(input, declared) {
29
+ const target = normalizeContext(input);
30
+ let best = null;
31
+ for (const name of declared) {
32
+ const candidate = normalizeContext(name);
33
+ // Budget from the longer of the two, so dropping characters does not shrink the allowance:
34
+ // keyed off the typo alone, "au" would be further from "auth" than "biling" is from "billing".
35
+ const span = Math.max(target.length, candidate.length);
36
+ const budget = Math.min(3, Math.max(1, Math.ceil(span / 3)));
37
+ const distance = levenshtein(target, candidate);
38
+ // The two must still share something: "b" and "a" are one edit apart and wholly unrelated.
39
+ if (distance <= budget && distance < span && (best === null || distance < best.distance)) {
40
+ best = { name, distance };
41
+ }
42
+ }
43
+ return best?.name ?? null;
44
+ }
45
+ /**
46
+ * The default context the config actually states, or null when it states none. `contexts` declares
47
+ * it by position, so there is no second field to drift out of sync and no config shape whose
48
+ * default its own guard rejects.
49
+ */
50
+ export function declaredDefaultContext(cfg) {
51
+ if (cfg.contexts && cfg.contexts.length > 0)
52
+ return cfg.contexts[0];
53
+ return cfg.defaultContext ?? null;
54
+ }
55
+ /** The default context to act on, falling back to the historical value. */
56
+ export function effectiveDefaultContext(cfg) {
57
+ return declaredDefaultContext(cfg) ?? FALLBACK_CONTEXT;
58
+ }
59
+ /** Check a context against the declared list. An undeclared project is unrestricted, as before. */
60
+ export function checkContext(context, cfg) {
61
+ const declared = cfg.contexts;
62
+ if (!declared || declared.length === 0)
63
+ return { ok: true, suggestion: null, declared: [] };
64
+ if (declared.includes(context))
65
+ return { ok: true, suggestion: null, declared };
66
+ // A case-only difference is refused rather than folded: accepting `Auth` would create a second
67
+ // directory beside `auth` on a case-sensitive filesystem, which is the whole point of the list.
68
+ // No special case is needed for it: after normalising, the same name sits at distance 0, which
69
+ // no other candidate can beat, so suggestContext already returns exactly it.
70
+ return { ok: false, suggestion: suggestContext(context, declared), declared };
71
+ }
72
+ /** Human-readable refusal naming the input, the nearest declared name, and the whole list. */
73
+ export function contextRefusal(context, check) {
74
+ const lines = [`"${context}" is not a declared context.`];
75
+ if (check.suggestion)
76
+ lines.push(` Did you mean "${check.suggestion}"?`);
77
+ lines.push(` Declared: ${check.declared.join(", ")}`);
78
+ lines.push(" Add one by editing contexts in .kf/config.json");
79
+ return lines.join("\n");
80
+ }
81
+ /**
82
+ * Contexts that work items actually use on disk, with a count each, busiest first.
83
+ *
84
+ * Grouped by the exact spelling, not a folded one. `Legacy` and `legacy` are two directories and
85
+ * therefore two rows: collapsing them would hide the case drift this feature exists to surface,
86
+ * and would make the surviving spelling depend on folder sort order. Callers compare exactly.
87
+ */
88
+ export function contextsInUse(root) {
89
+ const counts = new Map();
90
+ for (const f of listFeatures(root)) {
91
+ if (!f.context)
92
+ continue;
93
+ counts.set(f.context, (counts.get(f.context) ?? 0) + 1);
94
+ }
95
+ return [...counts.entries()]
96
+ .map(([context, count]) => ({ context, count }))
97
+ .sort((a, b) => b.count - a.count || a.context.localeCompare(b.context));
98
+ }
@@ -0,0 +1,163 @@
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { STAGES } from "../workflow/schema.js";
4
+ import { listFeatures, readFeatureMeta } from "../workflow/features.js";
5
+ import { validateFeature } from "../workflow/validate.js";
6
+ import { readProjectConfig, configPath } from "./config.js";
7
+ import { AGENTS, DEFAULT_AGENT, projectSkillsDir, parseAgentIds } from "../integrations/agents.js";
8
+ import { MANAGED_SKILLS } from "../integrations/install.js";
9
+ /** Every stage needs its directory; a file sitting where one belongs counts as missing. */
10
+ function checkStageDirs(root) {
11
+ const missing = STAGES.filter((stage) => {
12
+ const dir = join(root, ".works", stage);
13
+ if (!existsSync(dir))
14
+ return true;
15
+ try {
16
+ return !statSync(dir).isDirectory();
17
+ }
18
+ catch {
19
+ // Unreadable is as unusable as absent, and saying so beats crashing.
20
+ return true;
21
+ }
22
+ });
23
+ if (missing.length === 0)
24
+ return [];
25
+ return [{
26
+ level: "ERROR",
27
+ area: ".works/",
28
+ message: `Missing stage ${missing.length === 1 ? "directory" : "directories"}: ${missing.join(", ")}`,
29
+ action: "kf init --minimal (recreates the stage directories; existing work items are left alone)",
30
+ }];
31
+ }
32
+ /**
33
+ * The config check has to survive a config it cannot read — that is the case it exists for.
34
+ * readProjectConfig throws on bad JSON and on a bad shape, so the throw is caught here and turned
35
+ * into the diagnosis. This is not swallowing: the error becomes the answer the command returns.
36
+ */
37
+ function checkConfig(root) {
38
+ const path = configPath(root);
39
+ if (!existsSync(path)) {
40
+ return {
41
+ findings: [{ level: "WARNING", area: ".kf/config.json", message: "No project config; defaults are in use.", action: "kf init --defaults" }],
42
+ cfg: null,
43
+ };
44
+ }
45
+ try {
46
+ return { findings: [], cfg: readProjectConfig(root) };
47
+ }
48
+ catch (err) {
49
+ return {
50
+ findings: [{
51
+ level: "ERROR",
52
+ area: ".kf/config.json",
53
+ message: `Cannot read the project config: ${err instanceof Error ? err.message : String(err)}`,
54
+ action: "fix the JSON by hand, or delete .kf/config.json and re-run kf init",
55
+ }],
56
+ cfg: null,
57
+ };
58
+ }
59
+ }
60
+ /** A work item whose metadata cannot be read is invisible to every other command. */
61
+ function checkItemMetadata(root) {
62
+ const findings = [];
63
+ for (const f of listFeatures(root)) {
64
+ try {
65
+ readFeatureMeta(f.dir);
66
+ }
67
+ catch (err) {
68
+ findings.push({
69
+ level: "ERROR",
70
+ area: `${f.stage}/${f.folder}`,
71
+ message: `Unreadable metadata: ${err instanceof Error ? err.message : String(err)}`,
72
+ action: `fix ${join(f.dir, ".kfw.json")} by hand`,
73
+ });
74
+ continue;
75
+ }
76
+ if (!f.meta) {
77
+ findings.push({
78
+ level: "ERROR",
79
+ area: `${f.stage}/${f.folder}`,
80
+ message: "No .kfw.json — the item is invisible to approval and execution tracking.",
81
+ action: `write ${join(f.dir, ".kfw.json")}, or remove the folder if it is not a work item`,
82
+ });
83
+ }
84
+ }
85
+ return findings;
86
+ }
87
+ /** Skills can be installed and then deleted; nothing notices until a worker has no instructions. */
88
+ function checkSkills(root, agents) {
89
+ const findings = [];
90
+ for (const id of agents) {
91
+ const adapter = AGENTS.find((a) => a.id === id);
92
+ if (!adapter)
93
+ continue;
94
+ const dir = projectSkillsDir(adapter, root);
95
+ const missing = MANAGED_SKILLS.filter((s) => !existsSync(join(dir, s, "SKILL.md")));
96
+ if (missing.length === 0)
97
+ continue;
98
+ findings.push({
99
+ level: "ERROR",
100
+ area: dir,
101
+ message: `${missing.length} of ${MANAGED_SKILLS.length} skills missing: ${missing.join(", ")}`,
102
+ action: `kf install --agent ${id}`,
103
+ });
104
+ }
105
+ return findings;
106
+ }
107
+ /** Config fields that still parse but no longer mean what someone reading them would assume. */
108
+ function checkLegacyFields(root, cfg) {
109
+ if (!cfg)
110
+ return [];
111
+ const findings = [];
112
+ const raw = readRawConfig(root);
113
+ if (cfg.contexts && cfg.contexts.length > 0 && raw?.defaultContext !== undefined) {
114
+ findings.push({
115
+ level: "WARNING",
116
+ area: ".kf/config.json",
117
+ message: `defaultContext "${String(raw.defaultContext)}" is ignored because contexts is declared; the default is contexts[0] = "${cfg.contexts[0]}".`,
118
+ action: "remove defaultContext, or reorder contexts so the intended default is first",
119
+ });
120
+ }
121
+ if (raw?.stack !== undefined && raw?.stacks !== undefined) {
122
+ findings.push({
123
+ level: "WARNING",
124
+ area: ".kf/config.json",
125
+ message: "Both stack (legacy, singular) and stacks are set; only stacks is read.",
126
+ action: "remove the legacy stack field",
127
+ });
128
+ }
129
+ return findings;
130
+ }
131
+ /** The file as written, not as normalised by readProjectConfig, which fills stacks in from stack. */
132
+ function readRawConfig(root) {
133
+ try {
134
+ const parsed = JSON.parse(readFileSync(configPath(root), "utf8"));
135
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
136
+ }
137
+ catch {
138
+ // checkConfig already reported an unreadable config; a second finding would be noise.
139
+ return null;
140
+ }
141
+ }
142
+ /** Diagnose a project. Read-only: nothing here writes, moves or deletes. */
143
+ export function runDoctor(root) {
144
+ const findings = [];
145
+ findings.push(...checkStageDirs(root));
146
+ const config = checkConfig(root);
147
+ findings.push(...config.findings);
148
+ findings.push(...checkLegacyFields(root, config.cfg));
149
+ const agents = config.cfg?.agents ? parseAgentIds(config.cfg.agents) : [DEFAULT_AGENT];
150
+ findings.push(...checkSkills(root, agents.length > 0 ? agents : [DEFAULT_AGENT]));
151
+ findings.push(...checkItemMetadata(root));
152
+ // Invalid work items are the normal state of a running pipeline, so they are counted and
153
+ // reported but deliberately kept out of `findings` — they must not decide the verdict.
154
+ const items = listFeatures(root);
155
+ const invalidItems = items.filter((f) => !validateFeature(f).valid).length;
156
+ return {
157
+ root,
158
+ findings,
159
+ invalidItems,
160
+ totalItems: items.length,
161
+ ok: findings.every((f) => f.level !== "ERROR"),
162
+ };
163
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Minimal YAML frontmatter support (key: value lines). No external deps.
3
+ * Used only for the standard `---` blocks in our templates.
4
+ */
5
+ /** Extract the frontmatter block and body from a markdown file. */
6
+ export function splitFrontmatter(content) {
7
+ if (!content.startsWith("---")) {
8
+ return { fm: {}, body: content };
9
+ }
10
+ const end = content.indexOf("\n---", 3);
11
+ if (end === -1)
12
+ return { fm: {}, body: content };
13
+ const block = content.slice(3, end).trim();
14
+ const body = content.slice(end + 4);
15
+ const fm = {};
16
+ for (const line of block.split("\n")) {
17
+ const idx = line.indexOf(":");
18
+ if (idx === -1)
19
+ continue;
20
+ const key = line.slice(0, idx).trim();
21
+ const value = line.slice(idx + 1).trim();
22
+ if (key && !/^\s/.test(line))
23
+ fm[key] = value.replace(/^["']|["']$/g, "");
24
+ }
25
+ return { fm, body };
26
+ }
27
+ /** Serialize frontmatter back into a `---\n...\n---\n` document. */
28
+ export function applyFrontmatter(fm, body) {
29
+ const keys = Object.keys(fm);
30
+ if (keys.length === 0)
31
+ return body;
32
+ const block = keys.map((k) => `${k}: ${fm[k]}`).join("\n");
33
+ const trimmed = body.replace(/^\n+/, "");
34
+ return `---\n${block}\n---\n${trimmed}`;
35
+ }
36
+ /** Get one frontmatter key value (string) if present. */
37
+ export function getFmValue(content, key) {
38
+ const { fm } = splitFrontmatter(content);
39
+ return fm[key] ?? null;
40
+ }
41
+ /** Returns true if the content still contains unresolved template placeholder tokens like `{feature_name}`. Tokens inside code blocks and inline code are literal content, not placeholders. */
42
+ export function hasUnresolvedPlaceholders(content) {
43
+ const stripped = content
44
+ .replace(/```[\s\S]*?(```|$)/g, "")
45
+ .replace(/`[^`\n]*`/g, "");
46
+ return /\{[a-z0-9_-]+\}/i.test(stripped);
47
+ }
48
+ /** True when an artifact file is considered "filled" — exists, non-empty, no template tokens left. */
49
+ export function isFilledFile(raw, body) {
50
+ const text = body.trim();
51
+ if (text.length === 0)
52
+ return false;
53
+ return !hasUnresolvedPlaceholders(raw);
54
+ }
@@ -0,0 +1,78 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { mkdir, writeFile, rename, rm } from "node:fs/promises";
3
+ import { randomUUID } from "node:crypto";
4
+ import { homedir } from "node:os";
5
+ import { dirname, join, resolve } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { findWorksRoot } from "../workflow/features.js";
8
+ export const USER_KABAN_DIR = resolve(homedir(), ".kf");
9
+ function findPackageRoot() {
10
+ let dir = dirname(fileURLToPath(import.meta.url));
11
+ for (let i = 0; i < 8; i += 1) {
12
+ if (existsSync(join(dir, "package.json")))
13
+ return dir;
14
+ const parent = dirname(dir);
15
+ if (parent === dir)
16
+ break;
17
+ dir = parent;
18
+ }
19
+ return dir;
20
+ }
21
+ export const PKG_ROOT = findPackageRoot();
22
+ export const PKG_TEMPLATES_DIR = join(PKG_ROOT, "kanban-flow", "templates");
23
+ export const PKG_RULES_DIR = join(PKG_ROOT, "kanban-flow", "review", "rules");
24
+ export const PKG_STACK_RULES_DIR = join(PKG_ROOT, "kanban-flow", "review", "stacks");
25
+ /**
26
+ * Find a kanban-flow config dir relative to project root (project overrides).
27
+ * Project layout: {project}/.kf/{templates|hooks|review/rules}
28
+ */
29
+ function projectKabanDir(cwd) {
30
+ return join(findWorksRoot(cwd) ?? cwd, ".kf");
31
+ }
32
+ /** Resolve one template file. Precedence: project → user → package. */
33
+ export function resolveTemplate(cwd, template) {
34
+ const candidates = [
35
+ ["project", join(projectKabanDir(cwd), "templates", template)],
36
+ ["user", join(USER_KABAN_DIR, "templates", template)],
37
+ ["package", join(PKG_TEMPLATES_DIR, template)],
38
+ ];
39
+ for (const [source, path] of candidates) {
40
+ if (existsSync(path))
41
+ return { name: template, path, source };
42
+ }
43
+ return null;
44
+ }
45
+ /** Resolve a review rule file. Precedence: project → user → package. */
46
+ export function resolveRule(cwd, rule) {
47
+ const candidates = [
48
+ ["project", join(projectKabanDir(cwd), "review", "rules", rule)],
49
+ ["user", join(USER_KABAN_DIR, "review", "rules", rule)],
50
+ ["package", join(PKG_RULES_DIR, rule)],
51
+ ];
52
+ for (const [source, path] of candidates) {
53
+ if (existsSync(path))
54
+ return { name: rule, path, source };
55
+ }
56
+ return null;
57
+ }
58
+ /** Read a template's raw text (for `kf instruct`). */
59
+ export async function readTemplate(cwd, template) {
60
+ const resolved = resolveTemplate(cwd, template);
61
+ if (!resolved)
62
+ return null;
63
+ return (await import("node:fs/promises")).readFile(resolved.path, "utf8");
64
+ }
65
+ export async function ensureDir(dir) {
66
+ await mkdir(dir, { recursive: true });
67
+ }
68
+ export async function writeFileAtomic(file, content) {
69
+ await ensureDir(dirname(file));
70
+ const temp = `${file}.${randomUUID()}.tmp`;
71
+ try {
72
+ await writeFile(temp, content, { encoding: "utf8", flag: "wx", mode: existsSync(file) ? statSync(file).mode & 0o777 : 0o600 });
73
+ await rename(temp, file);
74
+ }
75
+ finally {
76
+ await rm(temp, { force: true });
77
+ }
78
+ }
@@ -0,0 +1,5 @@
1
+ export function nowTimestamp() {
2
+ const d = new Date();
3
+ const pad = (n) => String(n).padStart(2, "0");
4
+ return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}`;
5
+ }
@@ -0,0 +1,56 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { ARTIFACTS } from "./schema.js";
4
+ import { splitFrontmatter } from "../shared/frontmatter.js";
5
+ import { countTasks } from "./status.js";
6
+ import { finding } from "./findings.js";
7
+ /**
8
+ * Checks whether the CURRENT stage allows leaving toward `to`.
9
+ * This is the semantic half of the gate: it reads the report STATUS, not just
10
+ * artifact existence. The artifact gate (validateFeature) and the transition
11
+ * graph (TRANSITIONS) both still apply on top.
12
+ * Returns a list of blocking findings (empty = allowed).
13
+ */
14
+ export function checkDirectionGate(feature, to) {
15
+ const issues = [];
16
+ const read = (file) => {
17
+ const p = join(feature.dir, file);
18
+ if (!existsSync(p))
19
+ return null;
20
+ const { fm } = splitFrontmatter(readFileSync(p, "utf8"));
21
+ return fm.status ?? null;
22
+ };
23
+ // implementation → testing requires all tracked tasks done (DoD).
24
+ if (feature.stage === "implementation" && to === "testing") {
25
+ const tasksPath = join(feature.dir, "tasks.md");
26
+ if (existsSync(tasksPath)) {
27
+ const { done, total } = countTasks(readFileSync(tasksPath, "utf8"));
28
+ if (done < total) {
29
+ issues.push(finding(feature, "ERROR", "tasks.md", "tasks_incomplete", `Feature has unfinished tasks (${done}/${total} done) — complete them before testing`));
30
+ }
31
+ }
32
+ }
33
+ // testing → review requires PASS; testing → implementation required on FAIL.
34
+ if (feature.stage === "testing") {
35
+ const file = ARTIFACTS["testing-result"].file;
36
+ const status = read(file);
37
+ if (to === "review" && status !== "PASS") {
38
+ issues.push(finding(feature, "ERROR", file, "testing_not_pass", `testing-result status is ${status ?? "missing"}; only PASS may enter review (FAIL/REJECT must loop back to implementation)`));
39
+ }
40
+ if (to === "implementation" && status === "PASS") {
41
+ issues.push(finding(feature, "ERROR", file, "testing_already_pass", "testing-result is PASS — looping back to implementation hides passing tests; re-run kf stage <feature> review instead"));
42
+ }
43
+ }
44
+ // review → dones requires PASS; REQUIREMENT_BUG stops the feature entirely.
45
+ if (feature.stage === "review") {
46
+ const file = ARTIFACTS["review-report"].file;
47
+ const status = read(file);
48
+ if (to === "dones" && status !== "PASS") {
49
+ issues.push(finding(feature, "ERROR", file, "review_not_pass", `review-report status is ${status ?? "missing"}; only PASS may enter dones (FAIL/REJECT loop to implementation)`));
50
+ }
51
+ if (status === "REQUIREMENT_BUG") {
52
+ issues.push(finding(feature, "ERROR", file, "requirement_bug_stop", "REQUIREMENT_BUG: STOP FEATURE. Do not silently rewrite the requirement — report back to the human."));
53
+ }
54
+ }
55
+ return issues;
56
+ }
@@ -0,0 +1,198 @@
1
+ import { readdirSync, existsSync, statSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { join, resolve, dirname } from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { STAGES, ARTIFACTS, STAGE_GATES, METADATA_FILE } from "./schema.js";
5
+ import { writeFileAtomic } from "../shared/paths.js";
6
+ /** Walk up from cwd to find the directory containing `.works/`. */
7
+ export function findWorksRoot(start) {
8
+ let dir = resolve(start);
9
+ for (;;) {
10
+ if (existsSync(join(dir, ".works")))
11
+ return dir;
12
+ const parent = dirname(dir);
13
+ if (parent === dir)
14
+ return null;
15
+ dir = parent;
16
+ }
17
+ }
18
+ /** A feature folder is `{feature}_{YYYYMMDD_HHmm}` (timestamp trailing part). */
19
+ export function parseFolderName(folder) {
20
+ const m = /^(.+?)_(\d{8}_\d{4})$/.exec(folder);
21
+ if (m)
22
+ return { name: m[1], ts: m[2] };
23
+ return { name: folder, ts: null };
24
+ }
25
+ export function readFeatureMeta(dir) {
26
+ const f = join(dir, METADATA_FILE);
27
+ if (!existsSync(f))
28
+ return null;
29
+ let meta;
30
+ try {
31
+ meta = JSON.parse(readFileSync(f, "utf8"));
32
+ }
33
+ catch (err) {
34
+ if (err instanceof SyntaxError)
35
+ throw new Error(`Invalid JSON in feature metadata: ${f}`, { cause: err });
36
+ throw err;
37
+ }
38
+ if (!meta || meta.schema !== "kanban-flow" || typeof meta.feature !== "string"
39
+ || typeof meta.context !== "string" || typeof meta.created !== "string"
40
+ || (meta.kind !== undefined && !["feature", "bug"].includes(meta.kind))
41
+ || (meta.executionId !== undefined && typeof meta.executionId !== "string")
42
+ || (meta.approval !== undefined && (!meta.approval
43
+ || !["pending", "approved"].includes(meta.approval.status)
44
+ || (meta.approval.contractHash !== undefined && typeof meta.approval.contractHash !== "string")))
45
+ || (meta.bypasses !== undefined && (!Array.isArray(meta.bypasses) || !meta.bypasses.every(isBypass)))
46
+ || (meta.sessions !== undefined && (!meta.sessions || typeof meta.sessions !== "object" || Array.isArray(meta.sessions)
47
+ || !Object.values(meta.sessions).every((s) => typeof s === "string")))
48
+ || (meta.runs !== undefined && (!Array.isArray(meta.runs) || !meta.runs.every(isRunRecord)))
49
+ || (meta.status !== undefined && meta.status !== "archived" && meta.status !== "cancelled")
50
+ || (meta.cancellation !== undefined && !isCancellation(meta.cancellation))) {
51
+ throw new Error(`Invalid feature metadata: ${f}`);
52
+ }
53
+ assertPathName(meta.feature, "feature");
54
+ assertPathName(meta.context, "context");
55
+ return meta;
56
+ }
57
+ function isBypass(value) {
58
+ if (!value || typeof value !== "object")
59
+ return false;
60
+ const b = value;
61
+ return typeof b.at === "string" && (b.from === null || (typeof b.from === "string" && STAGES.includes(b.from)))
62
+ && typeof b.to === "string" && STAGES.includes(b.to)
63
+ && (b.flag === "force" || b.flag === "skip-hooks")
64
+ && Array.isArray(b.codes) && b.codes.every((c) => typeof c === "string");
65
+ }
66
+ function isCancellation(value) {
67
+ if (!value || typeof value !== "object")
68
+ return false;
69
+ const c = value;
70
+ return typeof c.at === "string" && typeof c.by === "string" && typeof c.reason === "string"
71
+ && typeof c.fromStage === "string" && STAGES.includes(c.fromStage);
72
+ }
73
+ const RUN_STATUSES = ["running", "done", "failed", "timeout", "reset"];
74
+ function isRunRecord(value) {
75
+ if (!value || typeof value !== "object")
76
+ return false;
77
+ const r = value;
78
+ if (r.chain !== undefined && (typeof r.chain !== "object" || r.chain === null
79
+ || typeof r.chain.id !== "string" || typeof r.chain.index !== "number" || typeof r.chain.total !== "number"))
80
+ return false;
81
+ return typeof r.id === "string" && typeof r.role === "string" && typeof r.runner === "string"
82
+ && typeof r.stage === "string" && STAGES.includes(r.stage)
83
+ && (r.mode === "start" || r.mode === "resume") && typeof r.at === "string" && typeof r.log === "string"
84
+ && typeof r.status === "string" && RUN_STATUSES.includes(r.status);
85
+ }
86
+ export async function writeFeatureMeta(dir, meta) {
87
+ await writeFileAtomic(join(dir, METADATA_FILE), `${JSON.stringify(meta, null, 2)}\n`);
88
+ }
89
+ export function assertPathName(value, label) {
90
+ if (!/^[a-z0-9][a-z0-9_-]*$/i.test(value)) {
91
+ throw new Error(`Invalid ${label} '${value}': use letters, digits, hyphens or underscores, starting with a letter or digit.`);
92
+ }
93
+ }
94
+ export function executionContractHash(dir, kind = "feature") {
95
+ const hash = createHash("sha256");
96
+ const contractArtifacts = kind === "bug"
97
+ ? ["spec-requirement"]
98
+ : ["spec-requirement", ...STAGE_GATES.planning];
99
+ for (const id of contractArtifacts) {
100
+ const file = ARTIFACTS[id].file;
101
+ const path = join(dir, file);
102
+ if (!existsSync(path))
103
+ return null;
104
+ hash.update(JSON.stringify([file, readFileSync(path, "utf8")]));
105
+ }
106
+ if (kind === "feature") {
107
+ const useCaseDir = join(dir, "use-cases");
108
+ if (existsSync(useCaseDir)) {
109
+ for (const file of readdirSync(useCaseDir).filter((entry) => /^UC-\d+\.md$/i.test(entry)).sort()) {
110
+ hash.update(JSON.stringify([`use-cases/${file}`, readFileSync(join(useCaseDir, file), "utf8")]));
111
+ }
112
+ }
113
+ }
114
+ return hash.digest("hex");
115
+ }
116
+ /** True only for a real, readable directory. Anything else is not a stage we can walk. */
117
+ function isDirectory(path) {
118
+ const stats = statSync(path, { throwIfNoEntry: false });
119
+ return stats !== undefined && stats.isDirectory();
120
+ }
121
+ /** List all features found under a `.works` root, in stage order (brainstorm → dones). */
122
+ export function listFeatures(root) {
123
+ const out = [];
124
+ // Within a stage, sort by folder name desc — the trailing timestamp approximates newest first.
125
+ for (const stage of STAGES) {
126
+ const stageDir = join(root, ".works", stage);
127
+ // A stage path that is not a directory — a stray file, a dangling link — used to reach
128
+ // readdirSync and throw ENOTDIR out of every command that lists work items. A malformed
129
+ // tree is exactly when `kf list` and `kf doctor` have to keep working; `kf doctor` reports
130
+ // the missing stage separately, so skipping here loses nothing.
131
+ if (!isDirectory(stageDir))
132
+ continue;
133
+ const folders = readdirSync(stageDir)
134
+ .map((folder) => {
135
+ const dir = join(stageDir, folder);
136
+ // `throwIfNoEntry: false` covers exactly one case: a symlink whose target is gone. That
137
+ // used to throw a raw ENOENT out of every command that lists features, so one dangling
138
+ // link made `kf list` — the command you run to find out what is wrong — unusable. Any
139
+ // other stat failure (EACCES, ELOOP) still propagates; this is not a blanket guard.
140
+ //
141
+ // A missing target falls through rather than returning null: if the name looks like a
142
+ // work item it is reported with no metadata, so `kf validate` calls it invalid instead
143
+ // of the item silently disappearing.
144
+ const stats = statSync(dir, { throwIfNoEntry: false });
145
+ if (stats && !stats.isDirectory())
146
+ return null;
147
+ const parsed = parseFolderName(folder);
148
+ let meta = null;
149
+ let metaError;
150
+ try {
151
+ meta = readFeatureMeta(dir);
152
+ }
153
+ catch (err) {
154
+ metaError = err instanceof Error ? err.message : String(err);
155
+ }
156
+ if (!meta && !metaError && !parsed.ts)
157
+ return null;
158
+ return { folder, dir, name: (meta && meta.feature) || parsed.name, meta, metaError };
159
+ })
160
+ .filter((x) => x !== null)
161
+ .sort((a, b) => (b.folder.localeCompare(a.folder)));
162
+ for (const f of folders) {
163
+ out.push({
164
+ name: f.name,
165
+ context: f.meta ? f.meta.context : null,
166
+ stage,
167
+ dir: f.dir,
168
+ folder: f.folder,
169
+ meta: f.meta,
170
+ metaError: f.metaError,
171
+ });
172
+ }
173
+ }
174
+ return out;
175
+ }
176
+ /** Find a single feature by name across all stages. */
177
+ export function findFeature(root, name) {
178
+ const matches = listFeatures(root).filter((f) => f.name === name || f.folder === name);
179
+ if (matches.length > 1) {
180
+ throw new Error(`Ambiguous feature '${name}': select its full folder name (${matches.map((f) => f.folder).join(", ")}).`);
181
+ }
182
+ return matches[0] ?? null;
183
+ }
184
+ /** Ensure the workflow folders and canonical documentation roots exist under root. */
185
+ export function ensureWorksStructure(root) {
186
+ for (const stage of STAGES) {
187
+ mkdirSync(join(root, ".works", stage), { recursive: true });
188
+ }
189
+ for (const docsDir of ["requirement", "use-cases", "testplan"]) {
190
+ mkdirSync(join(root, "docs", docsDir), { recursive: true });
191
+ }
192
+ }
193
+ export function stageDir(root, stage) {
194
+ return join(root, ".works", stage);
195
+ }
196
+ export function featureFolderName(feature, ts) {
197
+ return `${feature}_${ts}`;
198
+ }
@@ -0,0 +1,3 @@
1
+ export function finding(feature, severity, file, code, message) {
2
+ return { severity, feature: feature.name, stage: feature.stage, file, code, message };
3
+ }