@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,206 @@
1
+ import { createServer } from "node:http";
2
+ import { STAGES, PHASE_NAMES, STAGE_INDEX } from "../workflow/schema.js";
3
+ import { listFeatures } from "../workflow/features.js";
4
+ import { computeStatus, renderStatusText, approvalState } from "../workflow/status.js";
5
+ import { readProjectConfig } from "../project/config.js";
6
+ import { declaredDefaultContext } from "../project/contexts.js";
7
+ import { findWorksRoot } from "../workflow/features.js";
8
+ import { renderDashboardHtml } from "./dashboard-view.js";
9
+ export { renderDashboardHtml };
10
+ export const DEFAULT_PORT = 8787;
11
+ function percentage(done, total) {
12
+ return total === 0 ? null : Math.round(100 * done / total);
13
+ }
14
+ export function dashboardData(root, filters = {}) {
15
+ const config = readProjectConfig(root);
16
+ const allFeatures = listFeatures(root);
17
+ const features = allFeatures.filter((feature) => (filters.context === undefined || feature.context === filters.context)
18
+ && (filters.kind === undefined || (feature.meta?.kind ?? "feature") === filters.kind));
19
+ const snapshot = {
20
+ root,
21
+ context: declaredDefaultContext(config),
22
+ updatedAt: new Date().toISOString(),
23
+ stages: STAGES.map((stage) => {
24
+ const stageFeatures = features
25
+ .filter((f) => f.stage === stage)
26
+ .map((f) => {
27
+ const st = computeStatus(f, config.harness);
28
+ return {
29
+ name: st.feature.name,
30
+ kind: st.feature.meta?.kind ?? "feature",
31
+ context: st.feature.context,
32
+ folder: st.feature.folder,
33
+ stage,
34
+ stageIndex: STAGE_INDEX[stage],
35
+ artifacts: st.artifacts.map((a) => ({
36
+ id: a.id,
37
+ file: a.file,
38
+ status: a.status,
39
+ due: a.due,
40
+ note: a.note,
41
+ shortFile: a.file.replace(/^phase-\d-/, ""),
42
+ })),
43
+ doneCount: st.doneCount,
44
+ dueCount: st.dueCount,
45
+ totalCount: st.totalCount,
46
+ next: st.next,
47
+ taskProgress: st.taskProgress,
48
+ approval: approvalState(f),
49
+ bypasses: f.meta?.bypasses?.length ?? 0,
50
+ runs: f.meta?.runs ?? [],
51
+ text: renderStatusText(st),
52
+ };
53
+ });
54
+ return {
55
+ id: stage,
56
+ name: PHASE_NAMES[stage],
57
+ features: stageFeatures,
58
+ };
59
+ }),
60
+ };
61
+ const items = snapshot.stages.flatMap((stage) => stage.features);
62
+ const executing = items.filter((item) => ["implementation", "testing", "review"].includes(item.stage));
63
+ const completed = items.filter((item) => item.stage === "dones").length;
64
+ const cancelled = items.filter((item) => item.stage === "cancelled").length;
65
+ const approvals = items.filter((item) => item.stage !== "brainstorm" && item.stage !== "dones");
66
+ const taskDone = executing.reduce((sum, item) => sum + item.taskProgress.done, 0);
67
+ const taskTotal = executing.reduce((sum, item) => sum + item.taskProgress.total, 0);
68
+ const itemsTracked = executing.filter((item) => item.taskProgress.total > 0).length;
69
+ const contexts = [...new Set(allFeatures.map((feature) => feature.context))]
70
+ .sort((a, b) => (a ?? "").localeCompare(b ?? ""));
71
+ return {
72
+ ...snapshot,
73
+ filters: { context: filters.context, kind: filters.kind ?? null },
74
+ availableContexts: contexts.map((context) => ({ id: context, label: context ?? "Unassigned" })),
75
+ metrics: {
76
+ total: items.length,
77
+ features: items.filter((item) => item.kind === "feature").length,
78
+ bugs: items.filter((item) => item.kind === "bug").length,
79
+ executing: executing.length,
80
+ backlog: items.filter((item) => item.stage === "backlog").length,
81
+ completed,
82
+ cancelled,
83
+ // Dropped work must not drag the rate down, or nobody will admit to dropping anything.
84
+ completionRate: percentage(completed, items.length - cancelled),
85
+ bypassed: items.filter((item) => item.bypasses > 0).length,
86
+ runs: runMetrics(items.flatMap((item) => item.runs)),
87
+ tasks: {
88
+ done: taskDone,
89
+ total: taskTotal,
90
+ completionRate: percentage(taskDone, taskTotal),
91
+ itemsTracked,
92
+ itemsUntracked: executing.length - itemsTracked,
93
+ },
94
+ },
95
+ charts: {
96
+ byStage: snapshot.stages.map((stage) => ({
97
+ id: stage.id, label: stage.id, count: stage.features.length,
98
+ features: stage.features.filter((item) => item.kind === "feature").length,
99
+ bugs: stage.features.filter((item) => item.kind === "bug").length,
100
+ })),
101
+ byKind: ["feature", "bug"].map((kind) => ({
102
+ id: kind, label: kind === "feature" ? "Feature" : "Bug",
103
+ count: items.filter((item) => item.kind === kind).length,
104
+ })),
105
+ byContext: contexts.map((context) => {
106
+ const contextItems = items.filter((item) => item.context === context);
107
+ return {
108
+ id: context, label: context ?? "Unassigned", count: contextItems.length,
109
+ features: contextItems.filter((item) => item.kind === "feature").length,
110
+ bugs: contextItems.filter((item) => item.kind === "bug").length,
111
+ };
112
+ }).filter((context) => context.count > 0).sort((a, b) => b.count - a.count || a.label.localeCompare(b.label)),
113
+ approvals: ["pending", "approved", "changed"].map((approval) => ({
114
+ id: approval,
115
+ label: { pending: "Awaiting approval", approved: "Approved", changed: "Contract changed" }[approval],
116
+ count: approvals.filter((item) => item.approval === approval).length,
117
+ })),
118
+ },
119
+ };
120
+ }
121
+ function runMetrics(runs) {
122
+ const byRole = {};
123
+ const usage = {};
124
+ for (const r of runs) {
125
+ const a = (byRole[r.role] ??= { runs: 0, done: 0, failed: 0 });
126
+ a.runs += 1;
127
+ if (r.status === "done")
128
+ a.done += 1;
129
+ if (r.status === "failed" || r.status === "timeout" || r.status === "reset")
130
+ a.failed += 1;
131
+ if (r.usage) {
132
+ const u = (usage[r.role] ??= { input: 0, output: 0, costUsd: 0 });
133
+ u.input += r.usage.input;
134
+ u.output += r.usage.output;
135
+ u.costUsd += r.usage.costUsd ?? 0;
136
+ }
137
+ }
138
+ return { byRole, usage };
139
+ }
140
+ function json(res, code, body) {
141
+ res.writeHead(code, { "content-type": "application/json; charset=utf-8" });
142
+ res.end(JSON.stringify(body, null, 2));
143
+ }
144
+ /**
145
+ * Start a local dashboard server. The listening server keeps the event loop
146
+ * alive, so the CLI prints the URL then stays running until Ctrl+C.
147
+ */
148
+ export async function cmdDashboard(port = DEFAULT_PORT) {
149
+ const root = findWorksRoot(process.cwd());
150
+ if (!root) {
151
+ return { code: 1, stdout: "No .works found. Run: kf init", stderr: "no works" };
152
+ }
153
+ const server = createServer((req, res) => {
154
+ try {
155
+ const url = new URL(req.url ?? "/", "http://localhost");
156
+ if (url.pathname === "/api/data") {
157
+ const kind = url.searchParams.get("kind");
158
+ if (kind !== null && kind !== "feature" && kind !== "bug") {
159
+ json(res, 400, { error: "kind must be feature or bug" });
160
+ return;
161
+ }
162
+ const context = url.searchParams.get("context");
163
+ const filters = {
164
+ kind: kind ?? undefined,
165
+ context: context === "__none__" ? null : context ?? undefined,
166
+ };
167
+ try {
168
+ json(res, 200, dashboardData(root, filters));
169
+ }
170
+ catch (err) {
171
+ process.stderr.write(`Dashboard data failed: ${err instanceof Error ? err.stack : String(err)}\n`);
172
+ json(res, 500, { error: "Unable to read dashboard metrics. Check the server output." });
173
+ }
174
+ return;
175
+ }
176
+ if (url.pathname === "/" || url.pathname === "/favicon.ico") {
177
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
178
+ res.end(renderDashboardHtml());
179
+ return;
180
+ }
181
+ res.writeHead(404, { "content-type": "text/plain" });
182
+ res.end("not found");
183
+ }
184
+ catch (err) {
185
+ process.stderr.write(`Dashboard request failed: ${err instanceof Error ? err.stack : String(err)}\n`);
186
+ if (!res.headersSent)
187
+ res.writeHead(500, { "content-type": "text/plain" });
188
+ res.end("internal error");
189
+ }
190
+ });
191
+ const url = `http://localhost:${port}`;
192
+ await new Promise((resolve, reject) => {
193
+ server.once("error", reject);
194
+ server.listen(port, "127.0.0.1", () => resolve());
195
+ });
196
+ const shutdown = () => {
197
+ server.close(() => process.exit(0));
198
+ server.closeAllConnections();
199
+ };
200
+ process.once("SIGINT", shutdown);
201
+ process.once("SIGTERM", shutdown);
202
+ return {
203
+ code: 0,
204
+ stdout: `kanban-flow dashboard running at ${url}\n\nOpen ${url} in your browser. Press Ctrl+C to stop.`,
205
+ };
206
+ }
@@ -0,0 +1,41 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { findFeature } from "../workflow/features.js";
3
+ import { createRunPlan, executeRun, writeRunPlan } from "./run.js";
4
+ function loadFeature(root, name) {
5
+ const feature = findFeature(root, name);
6
+ if (!feature)
7
+ throw new Error(`Work item '${name}' not found.`);
8
+ return feature;
9
+ }
10
+ /** Plan for one role, with the id fixed when a supervisor already announced it. */
11
+ export function planFor(root, feature, assignment, opts, previous, position, id) {
12
+ const plan = createRunPlan(root, feature, assignment, { fresh: opts.fresh, timeoutMs: opts.timeoutMs, previous });
13
+ return { ...plan, ...(id ? { id } : {}), ...(position ? { chain: position } : {}) };
14
+ }
15
+ /**
16
+ * Run a stage's roles in order. Each role is its own worker and its own run
17
+ * record; the next role is told where the previous one wrote. A step that does
18
+ * not finish DONE stops the chain, so no worker builds on half-finished work
19
+ * and no tokens are spent on steps that cannot succeed.
20
+ */
21
+ export async function executeChain(root, featureName, chain, opts) {
22
+ const outcomes = [];
23
+ let previous = null;
24
+ const chainId = opts.firstRunId ?? newChainId();
25
+ for (const [index, assignment] of chain.entries()) {
26
+ const feature = loadFeature(root, featureName);
27
+ const position = { id: chainId, index: index + 1, total: chain.length };
28
+ const plan = planFor(root, feature, assignment, opts, previous, position, index === 0 ? opts.firstRunId : undefined);
29
+ writeRunPlan(feature, plan);
30
+ const outcome = await executeRun(plan, opts.env ?? process.env);
31
+ outcomes.push(outcome);
32
+ if (!outcome.ok) {
33
+ return { outcomes, ok: false, stoppedAt: assignment.role, skipped: chain.slice(index + 1).map((a) => a.role) };
34
+ }
35
+ previous = { role: assignment.role, output: assignment.output, log: outcome.record.log };
36
+ }
37
+ return { outcomes, ok: true, skipped: [] };
38
+ }
39
+ export function newChainId() {
40
+ return randomUUID().slice(0, 8);
41
+ }
@@ -0,0 +1,168 @@
1
+ import { STAGES } from "../workflow/schema.js";
2
+ export const HARNESS_STAGES = STAGES.filter((s) => s !== "backlog");
3
+ const isStringArray = (v) => Array.isArray(v) && v.every((x) => typeof x === "string");
4
+ function validateRunner(name, value, fail) {
5
+ const at = `harness.runners.${name}`;
6
+ if (!value || typeof value !== "object" || Array.isArray(value))
7
+ fail(at, "must be an object");
8
+ const r = value;
9
+ if (!isStringArray(r.start) || r.start.length === 0)
10
+ fail(`${at}.start`, "must be a non-empty array of strings");
11
+ if (!r.start.some((a) => a.includes("{prompt}")))
12
+ fail(`${at}.start`, "must contain {prompt}");
13
+ if (r.resume !== undefined) {
14
+ if (!isStringArray(r.resume) || r.resume.length === 0)
15
+ fail(`${at}.resume`, "must be a non-empty array of strings");
16
+ if (!r.resume.some((a) => a.includes("{session}")))
17
+ fail(`${at}.resume`, "must contain {session}");
18
+ }
19
+ const session = r.session;
20
+ if (session !== undefined && session !== "provided") {
21
+ if (!session || typeof session !== "object")
22
+ fail(`${at}.session`, 'must be "provided", { stdout } or { command, idField, matchField }');
23
+ const s = session;
24
+ if ("stdout" in s) {
25
+ if (typeof s.stdout !== "string")
26
+ fail(`${at}.session.stdout`, "must be a regex string");
27
+ }
28
+ else if (!isStringArray(s.command) || typeof s.idField !== "string" || typeof s.matchField !== "string") {
29
+ fail(`${at}.session`, "command capture needs command[], idField, matchField");
30
+ }
31
+ }
32
+ if (r.start.some((a) => a.includes("{session}")) && session !== "provided") {
33
+ fail(`${at}.start`, '{session} is only allowed when session is "provided"');
34
+ }
35
+ if (r.usage !== undefined && r.usage !== "json")
36
+ fail(`${at}.usage`, 'must be "json"');
37
+ if (r.skillsDir !== undefined && typeof r.skillsDir !== "string")
38
+ fail(`${at}.skillsDir`, "must be a string");
39
+ if (r.resumeFailure !== undefined && typeof r.resumeFailure !== "string")
40
+ fail(`${at}.resumeFailure`, "must be a regex string");
41
+ return r;
42
+ }
43
+ function validateRole(name, value, runners, fail) {
44
+ const at = `harness.roles.${name}`;
45
+ const role = typeof value === "string" ? { runner: value } : { ...value };
46
+ if (!value || (typeof value !== "string" && (typeof value !== "object" || Array.isArray(value)))) {
47
+ fail(at, "must be a runner name or an object { runner, brief?, output? }");
48
+ }
49
+ if (typeof role.runner !== "string" || !role.runner)
50
+ fail(`${at}.runner`, "must be a runner name");
51
+ if (!(role.runner in runners))
52
+ fail(`${at}.runner`, `"${role.runner}" is not declared in harness.runners`);
53
+ if (role.brief !== undefined && typeof role.brief !== "string")
54
+ fail(`${at}.brief`, "must be a string");
55
+ if (role.output !== undefined) {
56
+ if (typeof role.output !== "string" || !role.output)
57
+ fail(`${at}.output`, "must be a path relative to the work item folder");
58
+ const output = role.output;
59
+ if (output.startsWith("/") || output.split(/[/\\]/).includes(".."))
60
+ fail(`${at}.output`, "must stay inside the work item folder");
61
+ }
62
+ return role;
63
+ }
64
+ function validateChain(stage, value, roles, runners, fail) {
65
+ const at = `harness.stages.${stage}`;
66
+ const chain = typeof value === "string" ? [value] : value;
67
+ if (!isStringArray(chain))
68
+ fail(at, "must be a role name or an array of role names");
69
+ if (chain.length === 0)
70
+ fail(at, "must list at least one role");
71
+ const seen = new Set();
72
+ for (const role of chain) {
73
+ if (!(role in roles)) {
74
+ // The previous release let stages point straight at a runner; say so instead of "unknown role".
75
+ if (role in runners)
76
+ fail(at, `"${role}" is a runner, not a role; declare a role in harness.roles that points at it`);
77
+ fail(at, `"${role}" is not a role. Known roles: ${Object.keys(roles).join(", ")}`);
78
+ }
79
+ if (seen.has(role))
80
+ fail(at, `lists role "${role}" twice; each role runs once per stage`);
81
+ seen.add(role);
82
+ }
83
+ return chain;
84
+ }
85
+ /** Validate the `harness` block of .kf/config.json; throws with the offending field. */
86
+ export function validateHarness(value, file) {
87
+ const fail = (field, why) => {
88
+ throw new Error(`Invalid project config: ${file} — ${field} ${why}`);
89
+ };
90
+ if (!value || typeof value !== "object" || Array.isArray(value))
91
+ fail("harness", "must be an object");
92
+ const h = value;
93
+ if (!h.runners || typeof h.runners !== "object" || Array.isArray(h.runners))
94
+ fail("harness.runners", "must be an object");
95
+ const runners = {};
96
+ for (const [name, runner] of Object.entries(h.runners))
97
+ runners[name] = validateRunner(name, runner, fail);
98
+ if (!h.roles || typeof h.roles !== "object" || Array.isArray(h.roles))
99
+ fail("harness.roles", "must be an object mapping role names to runners");
100
+ const roles = {};
101
+ for (const [name, role] of Object.entries(h.roles))
102
+ roles[name] = validateRole(name, role, runners, fail);
103
+ const main = typeof h.main === "string" ? h.main : "";
104
+ if (!main)
105
+ fail("harness.main", "must be a role name");
106
+ if (!(main in roles)) {
107
+ if (main in runners)
108
+ fail("harness.main", `"${main}" is a runner, not a role; declare a role in harness.roles that points at it`);
109
+ fail("harness.main", `"${main}" is not a role. Known roles: ${Object.keys(roles).join(", ")}`);
110
+ }
111
+ const stagesRaw = h.stages ?? {};
112
+ if (!stagesRaw || typeof stagesRaw !== "object" || Array.isArray(stagesRaw))
113
+ fail("harness.stages", "must be an object");
114
+ const stages = {};
115
+ for (const [stage, chain] of Object.entries(stagesRaw)) {
116
+ if (!HARNESS_STAGES.includes(stage))
117
+ fail(`harness.stages.${stage}`, `is not an assignable stage (${HARNESS_STAGES.join(", ")})`);
118
+ stages[stage] = validateChain(stage, chain, roles, runners, fail);
119
+ }
120
+ return { main, roles, stages, runners };
121
+ }
122
+ /**
123
+ * Runner presets verified against each CLI's --help and a one-prompt smoke run
124
+ * on 2026-09-19. gemini and opencode could not be verified for resume, so they
125
+ * start fresh every time until the user adds a `resume` template.
126
+ */
127
+ export function harnessPresets() {
128
+ return {
129
+ claude: {
130
+ start: ["claude", "-p", "{prompt}", "--session-id", "{session}", "--permission-mode", "acceptEdits", "--output-format", "json"],
131
+ resume: ["claude", "-p", "{prompt}", "-r", "{session}", "--permission-mode", "acceptEdits", "--output-format", "json"],
132
+ session: "provided",
133
+ usage: "json",
134
+ },
135
+ codex: {
136
+ start: ["codex", "exec", "--json", "{prompt}"],
137
+ resume: ["codex", "exec", "resume", "{session}", "{prompt}"],
138
+ session: { stdout: "\"thread_id\":\"([^\"]+)\"" },
139
+ usage: "json",
140
+ },
141
+ devin: {
142
+ start: ["devin", "-p", "{prompt}", "--permission-mode", "accept-edits"],
143
+ resume: ["devin", "-p", "{prompt}", "-r", "{session}", "--permission-mode", "accept-edits"],
144
+ session: { command: ["devin", "list", "--format", "json"], idField: "id", matchField: "title" },
145
+ },
146
+ gemini: {
147
+ start: ["gemini", "-p", "{prompt}", "--approval-mode", "auto_edit"],
148
+ },
149
+ opencode: {
150
+ start: ["opencode", "run", "{prompt}"],
151
+ },
152
+ };
153
+ }
154
+ /** Default roles: the jobs a kanban pipeline actually has, all pointing at one runner until the user splits them. */
155
+ export const ROLE_BRIEFS = {
156
+ architect: "Orchestrates the pipeline and keeps the human gates. Decides transitions; does not do a stage's work when that stage has its own role.",
157
+ researcher: "Explores breadth: prior art, libraries, existing code paths, comparable features. Reports findings and trade-offs; does not design or write the spec.",
158
+ writer: "Turns agreed decisions into precise prose that follows the template exactly. Adds no scope of its own.",
159
+ coder: "Implements the approved plan and keeps the build green. Changes nothing outside the approved contract.",
160
+ tester: "Runs the real test suite against the approved test cases and records evidence with exact commands and exit codes. Never claims a result it did not observe.",
161
+ reviewer: "Audits the diff against the contract and the review rules, hunting for failures the author would not see. Reproduces plausible failures instead of guessing.",
162
+ };
163
+ export function seedHarness(agents) {
164
+ const runners = harnessPresets();
165
+ const runner = agents.find((a) => a in runners) ?? "claude";
166
+ const roles = Object.fromEntries(Object.entries(ROLE_BRIEFS).map(([name, brief]) => [name, { runner, brief }]));
167
+ return { main: "architect", roles, stages: {}, runners };
168
+ }
@@ -0,0 +1,105 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+ import { ARTIFACTS } from "../workflow/schema.js";
4
+ import { skillsDirFor } from "../integrations/agents.js";
5
+ /** Skill a worker loads for each stage; backlog has no work to hand off. */
6
+ export const STAGE_SKILL = {
7
+ brainstorm: "kanban-brainstorm",
8
+ planning: "kanban-plan",
9
+ backlog: null,
10
+ implementation: "kanban-implement",
11
+ testing: "kanban-test",
12
+ review: "kanban-review",
13
+ dones: "kanban-archive",
14
+ cancelled: null,
15
+ };
16
+ function assign(role, config, harness, stage, skillName, root) {
17
+ const runner = harness.runners[config.runner];
18
+ if (!runner)
19
+ return `Role "${role}" points at runner "${config.runner}", which is not declared.`;
20
+ const skillsDir = runner.skillsDir ? join(root, runner.skillsDir) : skillsDirFor(config.runner, root);
21
+ const skillPath = join(skillsDir, skillName, "SKILL.md");
22
+ if (!existsSync(skillPath)) {
23
+ return `Skill ${relative(root, skillPath)} is missing for role "${role}" (runner ${config.runner}) — run: kf install --agent ${config.runner} (or set harness.runners.${config.runner}.skillsDir).`;
24
+ }
25
+ return { role, runnerName: config.runner, runner, stage, skill: skillName, skillPath, brief: config.brief, output: config.output };
26
+ }
27
+ /**
28
+ * Resolve the chain of roles that runs a stage. A stage can name several roles
29
+ * (research then write, say); they run in order and each is a separate worker.
30
+ */
31
+ export function resolveChain(harness, feature, root, overrides = {}) {
32
+ if (!harness)
33
+ return { ok: false, reason: "No harness configured in .kf/config.json — run: kf init (seeds roles and runner presets)." };
34
+ const stage = overrides.stage ?? feature.stage;
35
+ if (feature.stage === "dones" && !overrides.stage)
36
+ return { ok: false, reason: `'${feature.name}' is archived (dones); nothing to run.` };
37
+ const skillName = feature.meta?.kind === "bug" && stage === "brainstorm" ? "kanban-bug" : STAGE_SKILL[stage];
38
+ if (!skillName)
39
+ return { ok: false, reason: `Stage "${stage}" has no work to hand off.` };
40
+ const stageChain = harness.stages[stage] ?? [];
41
+ let roles = stageChain;
42
+ if (overrides.role) {
43
+ if (!(overrides.role in harness.roles)) {
44
+ return { ok: false, reason: `Unknown role "${overrides.role}". Known roles: ${Object.keys(harness.roles).join(", ")}.` };
45
+ }
46
+ if (stageChain.length > 0 && !stageChain.includes(overrides.role)) {
47
+ return { ok: false, reason: `Role "${overrides.role}" is not in the chain for stage "${stage}" (${stageChain.join(" → ")}). Pass --stage to run it elsewhere.` };
48
+ }
49
+ roles = [overrides.role];
50
+ }
51
+ if (roles.length === 0) {
52
+ return { ok: false, reason: `Stage "${stage}" has no role assigned in harness.stages — the main role (${harness.main}) does it. Use --role <name> to hand it off anyway.` };
53
+ }
54
+ const chain = [];
55
+ for (const role of roles) {
56
+ const config = harness.roles[role];
57
+ if (!config)
58
+ return { ok: false, reason: `Unknown role "${role}". Known roles: ${Object.keys(harness.roles).join(", ")}.` };
59
+ const result = assign(role, config, harness, stage, skillName, root);
60
+ if (typeof result === "string")
61
+ return { ok: false, reason: result };
62
+ chain.push(result);
63
+ }
64
+ return { ok: true, chain };
65
+ }
66
+ /** Marker placed first in every worker prompt so sessions can be traced back to a run. */
67
+ export function runMarker(runId) {
68
+ return `kf-run:${runId}`;
69
+ }
70
+ /** Find the current testing/review report when it is a loop-back result. */
71
+ export function currentFailReport(feature) {
72
+ for (const id of ["review-report", "testing-result"]) {
73
+ const p = join(feature.dir, ARTIFACTS[id].file);
74
+ if (existsSync(p))
75
+ return p;
76
+ }
77
+ return null;
78
+ }
79
+ export function buildWorkerPrompt(input) {
80
+ const { feature, root, assignment, runId } = input;
81
+ const kind = feature.meta?.kind === "bug" ? "bug" : "feature";
82
+ const dir = relative(root, feature.dir);
83
+ const lines = [
84
+ runMarker(runId),
85
+ `You are the "${assignment.role}" worker for the kanban-flow ${kind} "${feature.name}" (context ${feature.context ?? "n/a"}) at stage "${assignment.stage}".`,
86
+ ];
87
+ if (assignment.brief)
88
+ lines.push(`Your role: ${assignment.brief}`);
89
+ lines.push(`Project root: ${root}`, `Work item folder: ${dir}`, `Load and follow the skill: ${relative(root, assignment.skillPath)} (${assignment.skill}). Skip its "move to stage" step — the stage is already set.`, "Read the artifacts in the work item folder before acting; run `kf status --change " + feature.name + "` for the checklist and `kf instruct <artifact> --change " + feature.name + "` for the exact template and output path of anything you must write.");
90
+ if (input.previous) {
91
+ const parts = [`The "${input.previous.role}" role ran before you in this stage.`];
92
+ if (input.previous.output)
93
+ parts.push(`Read its output first: ${join(dir, input.previous.output)}.`);
94
+ parts.push(`Its full log is at ${join(dir, input.previous.log)} if you need the details.`);
95
+ lines.push(`Previous step: ${parts.join(" ")}`);
96
+ }
97
+ if (assignment.output) {
98
+ lines.push(`Write your findings to ${join(dir, assignment.output)} so the next role can read them; this is in addition to the stage's own artifacts.`);
99
+ }
100
+ if (input.failReport) {
101
+ lines.push(`This is a repair loop: read ${relative(root, input.failReport)} first and fix exactly what it reports.`);
102
+ }
103
+ lines.push("Contract:", "- Do only this stage's work and write its artifacts into the work item folder.", "- Never run `kf stage`, `kf approve`, `kf archive`, or `kf run`; the main agent decides transitions.", "- Never edit approved contract artifacts (requirement, plan, use cases, test cases); track progress in tasks.md.", "- Do not commit or push.", "- Finish with two final lines exactly in this form:", "STATUS: DONE | DONE_WITH_CONCERNS | BLOCKED | NEEDS_CONTEXT", "Summary: <one or two sentences>");
104
+ return lines.join("\n");
105
+ }