@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,193 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { cp, mkdir, rm } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { findWorksRoot } from "../workflow/features.js";
5
+ import { selectOption } from "../project/bootstrap.js";
6
+ import { PKG_ROOT } from "../shared/paths.js";
7
+ import { AGENTS, DEFAULT_AGENT, projectSkillsDir, } from "./agents.js";
8
+ export const MANAGED_SKILLS = [
9
+ "kanban-flow",
10
+ "kanban-bug",
11
+ "kanban-brainstorm",
12
+ "kanban-plan",
13
+ "kanban-implement",
14
+ "kanban-test",
15
+ "kanban-review",
16
+ "kanban-archive",
17
+ ];
18
+ const PKG_SKILLS_DIR = join(PKG_ROOT, "skills");
19
+ /** Copy the managed skills from the package into the given skill dir. */
20
+ export async function copySkillsTo(skillsDir) {
21
+ if (!existsSync(PKG_SKILLS_DIR)) {
22
+ return {
23
+ code: 1,
24
+ stdout: `Package skills dir not found: ${PKG_SKILLS_DIR}`,
25
+ stderr: "skills dir missing",
26
+ };
27
+ }
28
+ for (const name of MANAGED_SKILLS) {
29
+ const from = join(PKG_SKILLS_DIR, name);
30
+ if (!existsSync(join(from, "SKILL.md"))) {
31
+ return {
32
+ code: 1,
33
+ stdout: `Package skill missing: ${from}`,
34
+ stderr: "skill missing",
35
+ };
36
+ }
37
+ }
38
+ await mkdir(skillsDir, { recursive: true });
39
+ const copied = [];
40
+ for (const name of MANAGED_SKILLS) {
41
+ const from = join(PKG_SKILLS_DIR, name);
42
+ await cp(from, join(skillsDir, name), { recursive: true, force: true });
43
+ copied.push(name);
44
+ }
45
+ return { code: 0, stdout: `${copied.join(", ")} → ${skillsDir}/` };
46
+ }
47
+ /** Remove the managed skills from the given skill dir (leaves others intact). */
48
+ export async function removeSkillsFrom(skillsDir) {
49
+ const removed = [];
50
+ for (const name of MANAGED_SKILLS) {
51
+ const target = join(skillsDir, name);
52
+ if (existsSync(target)) {
53
+ await rm(target, { recursive: true, force: true });
54
+ removed.push(name);
55
+ }
56
+ }
57
+ return { removed };
58
+ }
59
+ const PURGE_DIRS = [".works", ".kf", join("docs", "requirement"), join("docs", "use-cases"), join("docs", "testplan")];
60
+ /**
61
+ * Install skills into each agent's project-level dir ({root}/.claude/skills, ...).
62
+ * Requires a kanban project (.works/) — run `kf init` first.
63
+ * Empty/missing agents fall back to the default agent (claude).
64
+ */
65
+ export async function cmdInstall(agents = [DEFAULT_AGENT], opts = {}) {
66
+ const ids = agents.length > 0 ? agents : [DEFAULT_AGENT];
67
+ const base = opts.cwd ?? process.cwd();
68
+ const root = findWorksRoot(base);
69
+ if (!root) {
70
+ return {
71
+ code: 1,
72
+ stdout: `No kanban project found at ${base} (missing .works/) — run: kf init`,
73
+ stderr: "not a kanban project",
74
+ };
75
+ }
76
+ const lines = [];
77
+ let failed = false;
78
+ for (const id of ids) {
79
+ const a = AGENTS.find((x) => x.id === id);
80
+ if (!a)
81
+ continue;
82
+ const dir = projectSkillsDir(a, root);
83
+ const res = await copySkillsTo(dir);
84
+ failed ||= res.code !== 0;
85
+ lines.push(res.code === 0 ? `✓ ${a.label}: ${res.stdout}` : `⚠ ${a.label}: ${res.stdout}`);
86
+ }
87
+ if (lines.length === 0) {
88
+ return { code: 1, stdout: "No valid agents given for install.", stderr: "no agent" };
89
+ }
90
+ const alsoReads = ids.length === 1 ? AGENTS.find((x) => x.id === ids[0])?.alsoReads.join(", ") ?? "" : "";
91
+ const note = alsoReads ? `\nAlso picked up by: ${alsoReads}` : "";
92
+ return {
93
+ code: failed ? 1 : 0,
94
+ stdout: `Installed kanban skills:\n${lines.join("\n")}${note}\n\nRestart your AI assistant to detect them.`,
95
+ stderr: failed ? "skill installation failed" : undefined,
96
+ };
97
+ }
98
+ /**
99
+ * Remove skills from each agent's project-level skill dir.
100
+ * Resolves the .works root; falls back to cwd so cleanup still works when
101
+ * .works/ was deleted manually. Empty/missing agents fall back to the
102
+ * default agent (claude).
103
+ */
104
+ export async function cmdUninstall(agents = [DEFAULT_AGENT], opts = {}) {
105
+ const ids = agents.length > 0 ? agents : [DEFAULT_AGENT];
106
+ const base = opts.cwd ?? process.cwd();
107
+ const root = findWorksRoot(base) ?? base;
108
+ const purgeTargets = opts.purge ? PURGE_DIRS.filter((d) => existsSync(join(root, d))) : [];
109
+ if (opts.purge && purgeTargets.length > 0 && !opts.force) {
110
+ if (!process.stdin.isTTY) {
111
+ return {
112
+ code: 1,
113
+ stdout: `Refusing to purge project data (${purgeTargets.join(", ")}) in ${root} without confirmation — re-run with --force.`,
114
+ stderr: "purge requires interactive confirmation or --force",
115
+ };
116
+ }
117
+ const pick = await selectOption(`Purge permanently deletes in ${root}:\n${purgeTargets.map((d) => ` ${d}/`).join("\n")}\nProceed? (↑/↓ + Enter)`, ["Cancel — keep everything", `Purge — delete ${purgeTargets.map((d) => `${d}/`).join(" ")} and project skills`]);
118
+ if (pick !== 1) {
119
+ return { code: 0, stdout: "Purge cancelled — nothing removed.\nRun without --purge to remove only skills." };
120
+ }
121
+ }
122
+ const lines = [];
123
+ for (const id of ids) {
124
+ const a = AGENTS.find((x) => x.id === id);
125
+ if (!a)
126
+ continue;
127
+ const dir = projectSkillsDir(a, root);
128
+ const { removed } = await removeSkillsFrom(dir);
129
+ lines.push(removed.length > 0 ? `✗ ${a.label}: removed ${removed.length} skills from ${dir}/` : `· ${a.label}: already clean in ${dir}/`);
130
+ }
131
+ if (lines.length === 0) {
132
+ return { code: 1, stdout: "No valid agents given for uninstall.", stderr: "no agent" };
133
+ }
134
+ let purged = "";
135
+ if (opts.purge) {
136
+ if (purgeTargets.length > 0) {
137
+ for (const d of purgeTargets) {
138
+ await rm(join(root, d), { recursive: true, force: true });
139
+ }
140
+ purged = `\nPurged project data: ${purgeTargets.map((d) => `${d}/`).join(" ")}`;
141
+ }
142
+ else {
143
+ purged = "\nNo project data (.works/, .kf/) found to purge.";
144
+ }
145
+ purged += leftoverIgnoreNote(root);
146
+ }
147
+ return {
148
+ code: 0,
149
+ stdout: `${lines.join("\n")}${purged}\n\nCLI still on PATH — unlink with: npm rm -g @phuthuycoding/kanban-flow`,
150
+ };
151
+ }
152
+ /**
153
+ * `kf init` can add `.works/` to .gitignore. Purge deliberately leaves it: .gitignore belongs to
154
+ * the user and may have been edited by hand. But a command that says it purged the project data
155
+ * has to say what it left behind, or the line only surfaces later, when some unrelated `.works/`
156
+ * is quietly ignored and nobody remembers why.
157
+ */
158
+ function leftoverIgnoreNote(root) {
159
+ const gitignore = join(root, ".gitignore");
160
+ if (!existsSync(gitignore))
161
+ return "";
162
+ let body;
163
+ try {
164
+ body = readFileSync(gitignore, "utf8");
165
+ }
166
+ catch {
167
+ // An unreadable .gitignore is not a reason to fail an uninstall that already succeeded.
168
+ return "";
169
+ }
170
+ if (!body.split("\n").some((line) => line.trim() === ".works/"))
171
+ return "";
172
+ return `\nLeft alone: .gitignore still ignores .works/ — it is your file, so remove that line yourself if you want it gone.`;
173
+ }
174
+ /** Install the managed kanban skills into each agent's project-level dir (e.g. ./.claude/skills). */
175
+ export async function installProjectSkills(root, agents = [DEFAULT_AGENT]) {
176
+ const ids = agents.length > 0 ? agents : [DEFAULT_AGENT];
177
+ const lines = [];
178
+ let failed = false;
179
+ for (const id of ids) {
180
+ const a = AGENTS.find((x) => x.id === id);
181
+ if (!a)
182
+ continue;
183
+ const dir = projectSkillsDir(a, root);
184
+ const res = await copySkillsTo(dir);
185
+ failed ||= res.code !== 0;
186
+ lines.push(res.code === 0 ? `✓ ${a.label}: ${res.stdout}` : `⚠ ${a.label}: ${res.stdout}`);
187
+ }
188
+ return {
189
+ code: failed ? 1 : 0,
190
+ stderr: failed ? "skill installation failed" : undefined,
191
+ stdout: `Installed kanban skills:\n${lines.join("\n")}\n\nRestart your AI assistant to detect them.`,
192
+ };
193
+ }
@@ -0,0 +1,358 @@
1
+ import { existsSync, readFileSync, writeFileSync, readdirSync } from "node:fs";
2
+ import { mkdir, copyFile, readdir } from "node:fs/promises";
3
+ import { createInterface } from "node:readline/promises";
4
+ import { emitKeypressEvents } from "node:readline";
5
+ import { stdin as input, stdout as output } from "node:process";
6
+ import { execFileSync } from "node:child_process";
7
+ import { join, resolve } from "node:path";
8
+ import { nowTimestamp } from "../shared/time.js";
9
+ import { detectStacks, writeProjectConfig, configPath } from "./config.js";
10
+ import { effectiveDefaultContext, normalizeContext } from "./contexts.js";
11
+ import { STAGES } from "../workflow/schema.js";
12
+ import { assertPathName } from "../workflow/features.js";
13
+ import { seedHarness } from "../harness/config.js";
14
+ import { AGENTS, DEFAULT_AGENT, parseAgentIds } from "../integrations/agents.js";
15
+ import { readProjectConfig } from "./config.js";
16
+ /** git user.name, else $USER, else "human". */
17
+ export function detectReviewer(root = process.cwd()) {
18
+ try {
19
+ const name = execFileSync("git", ["-C", root, "config", "user.name"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
20
+ if (name)
21
+ return name;
22
+ }
23
+ catch (err) {
24
+ if (!(err instanceof Error && (("status" in err && err.status === 1) || ("code" in err && err.code === "ENOENT"))))
25
+ throw err;
26
+ }
27
+ return process.env.USER || process.env.USERNAME || "human";
28
+ }
29
+ /** True when .git exists and .works/ is not already ignored. */
30
+ export function shouldSuggestIgnoreWorks(root) {
31
+ if (!existsSync(join(root, ".git")))
32
+ return false;
33
+ const gi = join(root, ".gitignore");
34
+ if (!existsSync(gi))
35
+ return true;
36
+ const content = readFileSync(gi, "utf8");
37
+ return !/^\s*\.works\/?\s*$/m.test(content) && !content.split("\n").some((l) => l.includes(".works"));
38
+ }
39
+ export function appendIgnoreWorks(root) {
40
+ const gi = join(root, ".gitignore");
41
+ const add = "\n# kanban-flow\n.works/\n";
42
+ if (!existsSync(gi))
43
+ writeFileSync(gi, add, "utf8");
44
+ else
45
+ writeFileSync(gi, readFileSync(gi, "utf8") + add, "utf8");
46
+ }
47
+ async function confirm(rl, q, def) {
48
+ const hint = def ? "Y/n" : "y/N";
49
+ const ans = (await rl.question(`${q} (${hint}) `)).trim().toLowerCase();
50
+ if (ans === "")
51
+ return def;
52
+ return ans === "y" || ans === "yes";
53
+ }
54
+ /** Drop case-insensitive repeats, keeping the first spelling: the config reader refuses them. */
55
+ function dedupeContexts(names) {
56
+ const seen = new Set();
57
+ const out = [];
58
+ for (const name of names) {
59
+ const key = normalizeContext(name);
60
+ if (seen.has(key))
61
+ continue;
62
+ seen.add(key);
63
+ out.push(name);
64
+ }
65
+ return out;
66
+ }
67
+ /**
68
+ * Does `.works/` hold any work item folder? Deliberately only reads directory entries: reading
69
+ * work item metadata would make `kf init` die on a half-broken project, which is the one moment
70
+ * someone reaches for it. An empty `.works/` is not an existing project.
71
+ */
72
+ export function hasWorkItems(root) {
73
+ for (const stage of STAGES) {
74
+ let entries;
75
+ try {
76
+ entries = readdirSync(join(root, ".works", stage));
77
+ }
78
+ catch (err) {
79
+ // Missing means nothing to count. Anything else — unreadable, not a directory — means we
80
+ // cannot tell, and the safe answer is "existing": declaring a list would be the change
81
+ // that locks someone out, while declining to declare one changes nothing.
82
+ if (err.code === "ENOENT")
83
+ continue;
84
+ return true;
85
+ }
86
+ // A work item is a folder named `<feature>_<timestamp>`. A .gitkeep or a .DS_Store is not one,
87
+ // and counting it would silently suppress the list — the very failure this check exists for.
88
+ if (entries.some((name) => /_\d{8}_\d{4}$/.test(name)))
89
+ return true;
90
+ }
91
+ return false;
92
+ }
93
+ export function bootstrapDefaults(root, explicitContext) {
94
+ const cfg = readProjectConfig(root);
95
+ const defaultContext = explicitContext || effectiveDefaultContext(cfg);
96
+ // Declaring a list is a decision, and `--defaults` means nobody made one, so only an explicit
97
+ // --context declares. Even then, not on a project that already exists: declaring one name there
98
+ // would lock out every context its work items already use, which is what FR-007 forbids. An
99
+ // existing project declares by answering on a TTY, or by hand.
100
+ const isNewProject = !existsSync(configPath(root)) && !hasWorkItems(root);
101
+ const declared = cfg.contexts?.length ? cfg.contexts
102
+ : explicitContext && isNewProject ? [explicitContext]
103
+ : [];
104
+ return {
105
+ contexts: declared,
106
+ defaultContext,
107
+ defaultContextStated: Boolean(explicitContext) || cfg.contexts !== undefined || cfg.defaultContext !== undefined,
108
+ stacks: cfg.stacks?.length ? cfg.stacks : detectStacks(root),
109
+ reviewer: cfg.reviewer ?? detectReviewer(root),
110
+ ignoreWorks: shouldSuggestIgnoreWorks(root),
111
+ seedFeature: false,
112
+ agents: cfg.agents?.length ? parseAgentIds(cfg.agents) : [DEFAULT_AGENT],
113
+ };
114
+ }
115
+ /** Arrow-key radio select on a TTY; digit keys also work. Returns the chosen index. */
116
+ export async function selectOption(question, options) {
117
+ return new Promise((resolveP, reject) => {
118
+ let idx = 0;
119
+ let drawn = 0;
120
+ const draw = () => {
121
+ if (drawn > 0)
122
+ output.write(`\x1b[${drawn}A\x1b[J`);
123
+ const text = `${question}\n${options.map((o, i) => ` ${i === idx ? "❯" : " "} ${o}`).join("\n")}`;
124
+ output.write(`${text}\n`);
125
+ drawn = text.split("\n").length;
126
+ };
127
+ const cleanup = () => {
128
+ input.setRawMode(false);
129
+ input.off("keypress", onKey);
130
+ input.pause();
131
+ };
132
+ const onKey = (_s, key) => {
133
+ if (key.name === "up")
134
+ idx = (idx - 1 + options.length) % options.length;
135
+ else if (key.name === "down")
136
+ idx = (idx + 1) % options.length;
137
+ else if (/^[1-9]$/.test(key.name) && Number(key.name) <= options.length)
138
+ idx = Number(key.name) - 1;
139
+ else if (key.name === "return") {
140
+ cleanup();
141
+ resolveP(idx);
142
+ return;
143
+ }
144
+ else if (key.name === "c" && key.ctrl) {
145
+ cleanup();
146
+ reject(new Error("Setup aborted (Ctrl+C). Re-run with --defaults to skip prompts."));
147
+ return;
148
+ }
149
+ else
150
+ return;
151
+ draw();
152
+ };
153
+ emitKeypressEvents(input);
154
+ input.setRawMode(true);
155
+ input.resume();
156
+ input.on("keypress", onKey);
157
+ draw();
158
+ });
159
+ }
160
+ /**
161
+ * Onboarding entry: let the user pick quick setup (defaults) or customize,
162
+ * then collect answers accordingly.
163
+ */
164
+ export async function onboardAnswers(root, explicitContext) {
165
+ const d = bootstrapDefaults(root, explicitContext);
166
+ const mode = await selectOption("Setup mode (↑/↓ + Enter):", [
167
+ `Quick setup — defaults (context: ${d.defaultContext}, stacks: ${d.stacks.join(", ") || "unset"}, reviewer: ${d.reviewer}, agents: ${d.agents.join(", ")})`,
168
+ "Customize — answer each question",
169
+ ]);
170
+ if (mode === 0)
171
+ return d;
172
+ return promptAnswers(root, explicitContext);
173
+ }
174
+ /**
175
+ * Ask the bootstrap questions interactively. Every question has a sensible
176
+ * default — Enter accepts it. Returns the collected answers.
177
+ */
178
+ export async function promptAnswers(root, explicitContext) {
179
+ const rl = createInterface({ input, output });
180
+ try {
181
+ return await askAll(rl, root, explicitContext);
182
+ }
183
+ catch (err) {
184
+ if (err instanceof Error && (err.name === "AbortError" || err.name === "ERR_ABORT")) {
185
+ throw new Error("Setup aborted (EOF/Ctrl+C). Re-run with --defaults to skip prompts.");
186
+ }
187
+ throw err;
188
+ }
189
+ finally {
190
+ rl.close();
191
+ }
192
+ }
193
+ /**
194
+ * Exported for tests: the interactive branch is otherwise unreachable without a TTY, and it owns
195
+ * the context-list dedupe and the empty-answer fallback, both of which must not regress silently.
196
+ */
197
+ export async function askAll(rl, root, explicitContext) {
198
+ const d = bootstrapDefaults(root, explicitContext);
199
+ // One question, not two: the list declares the default by position, so there is no second
200
+ // field to keep in step with it.
201
+ //
202
+ // On a project that is already unrestricted, `d.contexts` is empty and the prompt says so:
203
+ // pressing Enter there must leave it unrestricted. Enter is not a decision, and declaring a
204
+ // list on someone's behalf would lock out every context their work items already use.
205
+ const hint = d.contexts.length > 0 ? d.contexts.join(",") : "leave empty to keep this project unrestricted";
206
+ const ctxRaw = (await rl.question(`Contexts, comma separated, first is the default [${hint}]: `)).trim();
207
+ const typed = ctxRaw.split(/[\s,]+/).map((c) => c.trim()).filter(Boolean);
208
+ const contexts = typed.length > 0 ? dedupeContexts(typed) : d.contexts;
209
+ const defaultContext = contexts[0] ?? d.defaultContext;
210
+ let stacks = d.stacks;
211
+ const parseStacks = (raw) => raw.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean);
212
+ if (stacks.length > 0) {
213
+ const ok = await confirm(rl, `Detected stacks: ${stacks.join(", ")}. Use them?`, true);
214
+ if (!ok) {
215
+ const s = (await rl.question("Stacks (comma-separated, e.g. node,go — or skip): ")).trim();
216
+ stacks = parseStacks(s);
217
+ }
218
+ }
219
+ else {
220
+ const s = (await rl.question("Stacks (comma-separated, e.g. node,go — or skip): ")).trim();
221
+ stacks = parseStacks(s);
222
+ }
223
+ const rev = (await rl.question(`Default reviewer for kf approve [${d.reviewer}]: `)).trim();
224
+ const reviewer = rev || d.reviewer;
225
+ const agents = (await promptAgents(rl, d.agents)).agents;
226
+ let ignoreWorks = d.ignoreWorks;
227
+ if (d.ignoreWorks) {
228
+ ignoreWorks = await confirm(rl, "Add .works/ to .gitignore?", true);
229
+ }
230
+ const seedFeature = await confirm(rl, "Seed a demo feature to show the structure?", false);
231
+ // Typing a list is a decision; pressing Enter is not. Recording Enter as one would repoint
232
+ // every future context-less `kf new` at the invented fallback, on the one path where the
233
+ // prompt has just promised "leave empty to keep this project unrestricted".
234
+ return { contexts, defaultContext, defaultContextStated: typed.length > 0 || d.defaultContextStated, stacks, reviewer, ignoreWorks, seedFeature, agents };
235
+ }
236
+ /** Multi-select agent prompt (comma-separated ids; Enter = default agent). */
237
+ async function promptAgents(rl, defaults) {
238
+ const list = AGENTS.map((a) => a.id).join(", ");
239
+ const ans = (await rl.question(`Install skills for which agents? (comma-separated, default: ${defaults.join(", ")}) [${list}]: `)).trim();
240
+ if (ans === "")
241
+ return { agents: defaults };
242
+ return { agents: parseAgentIds(ans.toLowerCase().split(/[\s,]+/)) };
243
+ }
244
+ /** Copy package templates/rules (+ empty hooks dir) into the new project override dirs. */
245
+ export async function seedOverrides(root) {
246
+ const dirs = [
247
+ join(root, ".kf", "templates"),
248
+ join(root, ".kf", "hooks"),
249
+ join(root, ".kf", "review", "rules"),
250
+ ];
251
+ await Promise.all(dirs.map((d) => mkdir(d, { recursive: true })));
252
+ const { PKG_TEMPLATES_DIR, PKG_RULES_DIR } = await import("../shared/paths.js");
253
+ await copyDirInto(PKG_TEMPLATES_DIR, join(root, ".kf", "templates"));
254
+ await copyDirInto(PKG_RULES_DIR, join(root, ".kf", "review", "rules"));
255
+ }
256
+ async function copyDirInto(src, dest) {
257
+ if (!existsSync(src))
258
+ return;
259
+ const files = await readdir(src);
260
+ for (const f of files) {
261
+ const from = resolve(src, f);
262
+ const to = resolve(dest, f);
263
+ if (!existsSync(from) || existsSync(to))
264
+ continue;
265
+ await mkdir(dest, { recursive: true });
266
+ await copyFile(from, to);
267
+ }
268
+ }
269
+ /** Write .kf/config.json for this project. */
270
+ export function saveConfig(root, a) {
271
+ const existing = readProjectConfig(root);
272
+ // Both halves of the rule live here, not at a caller: this is the one point every set of
273
+ // answers passes through, so no producer — present or future — can write a contexts list that
274
+ // readProjectConfig then refuses. Dedupe covers the repeat rule; assertPathName covers names.
275
+ const contexts = dedupeContexts(a.contexts);
276
+ for (const c of contexts)
277
+ assertPathName(c, "context");
278
+ // `contexts[0]` is the default once a list exists, so a second field would only drift. With no
279
+ // list, keep whatever the project already stated and invent nothing: a default the tool made up
280
+ // would make `kf new`'s guess-from-work-items arm unreachable.
281
+ // Four cases, and each one matters:
282
+ // named by a person → write it, or `--context X` would seed docs/X and then send every
283
+ // new item somewhere else
284
+ // already in the config → counts as named: `defaultContextStated` is true whenever the
285
+ // config already carried context information, so this is the same
286
+ // branch. A separate `existing.defaultContext ??` fallback here
287
+ // would be unreachable, since both read the same file.
288
+ // nothing, no work items → write the fallback; on a fresh project there is nothing to
289
+ // override, and the field is how someone discovers it exists
290
+ // nothing, but work items exist → write nothing, or the invented default would make
291
+ // `kf new`'s guess-from-work-items arm unreachable
292
+ const keepDefault = a.defaultContextStated ? a.defaultContext : hasWorkItems(root) ? undefined : a.defaultContext;
293
+ const legacyDefault = contexts.length === 0 && keepDefault ? { defaultContext: keepDefault } : {};
294
+ const cfg = {
295
+ schema: "kanban-flow",
296
+ ...(contexts.length > 0 ? { contexts } : {}),
297
+ ...legacyDefault,
298
+ stacks: a.stacks,
299
+ reviewer: a.reviewer,
300
+ agents: a.agents,
301
+ harness: existing.harness ?? seedHarness(a.agents),
302
+ created: existing.created ?? nowTimestamp(),
303
+ };
304
+ writeProjectConfig(root, cfg);
305
+ }
306
+ const AGENT_SCRIPTS = [["Install", "ci"], ["Build", "build"], ["Typecheck", "typecheck"], ["Lint", "lint"], ["Test", "test"]];
307
+ function nodeCommandRows(root) {
308
+ const manifest = join(root, "package.json");
309
+ if (!existsSync(manifest))
310
+ return [];
311
+ let pkg;
312
+ try {
313
+ pkg = JSON.parse(readFileSync(manifest, "utf8"));
314
+ }
315
+ catch (err) {
316
+ if (err instanceof SyntaxError)
317
+ throw new Error(`Invalid JSON in ${manifest}`, { cause: err });
318
+ throw err;
319
+ }
320
+ const scripts = pkg.scripts ?? {};
321
+ return AGENT_SCRIPTS
322
+ .filter(([, script]) => script === "ci" || script in scripts)
323
+ .map(([label, script]) => `| ${label} | \`${script === "ci" ? (existsSync(join(root, "package-lock.json")) ? "npm ci" : "npm install") : script === "test" ? "npm test" : `npm run ${script}`}\` |`);
324
+ }
325
+ /**
326
+ * Seed AGENTS.md at the project root so agents know the commands and the
327
+ * kanban-flow conventions. Never overwrites an existing AGENTS.md or CLAUDE.md.
328
+ */
329
+ export function seedAgentsFile(root, stacks) {
330
+ if (existsSync(join(root, "AGENTS.md")) || existsSync(join(root, "CLAUDE.md")))
331
+ return "kept";
332
+ const rows = stacks.includes("node") ? nodeCommandRows(root) : [];
333
+ const commands = rows.length > 0
334
+ ? rows.join("\n")
335
+ : "| Build | `TODO: fill in` |\n| Test | `TODO: fill in` |\n| Lint | `TODO: fill in` |";
336
+ const content = `# AGENTS.md
337
+
338
+ Conventions for AI coding agents working in this repository. Seeded by \`kf init\`; edit freely.
339
+
340
+ ## Commands
341
+
342
+ | Task | Command |
343
+ |---|---|
344
+ ${commands}
345
+
346
+ Detect anything else by reading the repository, never by guessing.
347
+
348
+ ## Workflow (kanban-flow)
349
+
350
+ - Start a feature with one command: \`kanban <context> <feature>\` (bug: \`kanban <context> <name> --type bug\`).
351
+ - Only two human gates: confirm the requirement (Phase 1) and approve the plan + choose start-now vs backlog (Phase 2). Do not ask "continue?" between other phases.
352
+ - Work item state lives in \`.works/\`; move items only with \`kf stage\` / \`kf archive\`, never by moving folders.
353
+ - Never use \`--force\` or \`--skip-hooks\` without explicit user approval; every bypass is recorded in the work item's metadata.
354
+ - Run \`kf autoconfig\` for the setup checklist and the review rules to apply as coding conventions.
355
+ `;
356
+ writeFileSync(join(root, "AGENTS.md"), content, "utf8");
357
+ return "created";
358
+ }
@@ -0,0 +1,111 @@
1
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { assertPathName } from "../workflow/features.js";
4
+ import { parseAgentIds } from "../integrations/agents.js";
5
+ import { validateHarness } from "../harness/config.js";
6
+ import { normalizeContext } from "./contexts.js";
7
+ export const CONFIG_FILE = "config.json";
8
+ /** Stack ids that have a shipped review rule pack (kanban-flow/review/stacks/{id}.md). */
9
+ export const STACK_IDS = ["node", "go", "rust", "python", "php", "ruby", "java"];
10
+ export function projectKabanDir(root) {
11
+ return join(root, ".kf");
12
+ }
13
+ export function configPath(root) {
14
+ return join(projectKabanDir(root), CONFIG_FILE);
15
+ }
16
+ /** Read the project config; missing config uses defaults, invalid config is an error. */
17
+ export function readProjectConfig(root) {
18
+ const f = configPath(root);
19
+ if (!existsSync(f))
20
+ return {};
21
+ let cfg;
22
+ try {
23
+ cfg = JSON.parse(readFileSync(f, "utf8"));
24
+ }
25
+ catch (err) {
26
+ if (err instanceof SyntaxError)
27
+ throw new Error(`Invalid JSON in project config: ${f}`, { cause: err });
28
+ throw err;
29
+ }
30
+ if (!cfg || typeof cfg !== "object" || Array.isArray(cfg)
31
+ || (cfg.defaultContext !== undefined && typeof cfg.defaultContext !== "string")
32
+ || (cfg.reviewer !== undefined && typeof cfg.reviewer !== "string")
33
+ || (cfg.created !== undefined && typeof cfg.created !== "string")
34
+ || (cfg.schema !== undefined && cfg.schema !== "kanban-flow")
35
+ || (cfg.stack !== undefined && cfg.stack !== null && typeof cfg.stack !== "string")
36
+ || (cfg.stacks !== undefined && (!Array.isArray(cfg.stacks) || !cfg.stacks.every((s) => typeof s === "string")))
37
+ || (cfg.agents !== undefined && (!Array.isArray(cfg.agents) || !cfg.agents.every((a) => typeof a === "string")))) {
38
+ throw new Error(`Invalid project config: ${f}`);
39
+ }
40
+ if (cfg.contexts !== undefined) {
41
+ if (!Array.isArray(cfg.contexts) || cfg.contexts.length === 0 || !cfg.contexts.every((c) => typeof c === "string")) {
42
+ throw new Error(`Invalid project config: ${f} — contexts must be a non-empty array of strings`);
43
+ }
44
+ for (const c of cfg.contexts)
45
+ assertPathName(c, "context");
46
+ const seen = new Set();
47
+ for (const c of cfg.contexts) {
48
+ const key = normalizeContext(c);
49
+ if (seen.has(key))
50
+ throw new Error(`Invalid project config: ${f} — contexts lists "${c}" twice (names are compared case-insensitively)`);
51
+ seen.add(key);
52
+ }
53
+ }
54
+ if (cfg.stacks === undefined && typeof cfg.stack === "string")
55
+ cfg.stacks = [cfg.stack];
56
+ if (cfg.defaultContext !== undefined)
57
+ assertPathName(cfg.defaultContext, "context");
58
+ if (cfg.agents)
59
+ parseAgentIds(cfg.agents);
60
+ if (cfg.harness !== undefined)
61
+ cfg.harness = validateHarness(cfg.harness, f);
62
+ return cfg;
63
+ }
64
+ export function writeProjectConfig(root, cfg) {
65
+ writeFileSync(configPath(root), `${JSON.stringify(cfg, null, 2)}\n`, "utf8");
66
+ }
67
+ const STACK_PROBES = [
68
+ ["package.json", "node"],
69
+ ["go.mod", "go"],
70
+ ["Cargo.toml", "rust"],
71
+ ["pyproject.toml", "python"],
72
+ ["requirements.txt", "python"],
73
+ ["composer.json", "php"],
74
+ ["Gemfile", "ruby"],
75
+ ["pom.xml", "java"],
76
+ ];
77
+ /** Dirs never worth scanning for manifests (deps, VCS, build output). */
78
+ const SKIP_DIRS = new Set([
79
+ "node_modules", ".git", "dist", "build", "out", "target", "vendor",
80
+ "coverage", ".works", ".kf", "bin", "pkg", "__pycache__",
81
+ ]);
82
+ /** Detect all tech stacks from manifest files at the root and subdirectories (monorepo-aware). */
83
+ export function detectStacks(root, maxDepth = 2) {
84
+ const found = [];
85
+ const visit = (dir, depth) => {
86
+ for (const [file, stack] of STACK_PROBES) {
87
+ if (existsSync(join(dir, file)) && !found.includes(stack))
88
+ found.push(stack);
89
+ }
90
+ if (depth >= maxDepth)
91
+ return;
92
+ let entries;
93
+ try {
94
+ entries = readdirSync(dir, { withFileTypes: true });
95
+ }
96
+ catch {
97
+ return;
98
+ }
99
+ for (const e of entries) {
100
+ if (e.isDirectory() && !e.name.startsWith(".") && !SKIP_DIRS.has(e.name)) {
101
+ visit(join(dir, e.name), depth + 1);
102
+ }
103
+ }
104
+ };
105
+ visit(root, 0);
106
+ return found;
107
+ }
108
+ /** Detect the primary tech stack — first probe match at the project root only. */
109
+ export function detectStack(cwd) {
110
+ return detectStacks(cwd, 0)[0] ?? null;
111
+ }