@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.
- package/LICENSE +21 -0
- package/README.md +173 -0
- package/dist/cli/args.js +219 -0
- package/dist/cli/commands/approve.js +44 -0
- package/dist/cli/commands/archive.js +245 -0
- package/dist/cli/commands/artifacts.js +100 -0
- package/dist/cli/commands/autoconfig.js +180 -0
- package/dist/cli/commands/cancel.js +129 -0
- package/dist/cli/commands/contexts.js +101 -0
- package/dist/cli/commands/doctor.js +35 -0
- package/dist/cli/commands/harness.js +60 -0
- package/dist/cli/commands/helpers.js +22 -0
- package/dist/cli/commands/init.js +119 -0
- package/dist/cli/commands/inspect.js +141 -0
- package/dist/cli/commands/new.js +80 -0
- package/dist/cli/commands/rules.js +69 -0
- package/dist/cli/commands/run.js +156 -0
- package/dist/cli/commands/stage.js +186 -0
- package/dist/cli/result.js +1 -0
- package/dist/dashboard/dashboard-view.js +238 -0
- package/dist/dashboard/dashboard.js +206 -0
- package/dist/harness/chain.js +41 -0
- package/dist/harness/config.js +168 -0
- package/dist/harness/prompt.js +105 -0
- package/dist/harness/run.js +245 -0
- package/dist/harness/session.js +78 -0
- package/dist/harness/supervise.js +65 -0
- package/dist/index.js +123 -0
- package/dist/integrations/agents.js +67 -0
- package/dist/integrations/hooks.js +59 -0
- package/dist/integrations/install.js +193 -0
- package/dist/project/bootstrap.js +358 -0
- package/dist/project/config.js +111 -0
- package/dist/project/contexts.js +98 -0
- package/dist/project/doctor.js +163 -0
- package/dist/shared/frontmatter.js +54 -0
- package/dist/shared/paths.js +78 -0
- package/dist/shared/time.js +5 -0
- package/dist/workflow/direction.js +56 -0
- package/dist/workflow/features.js +198 -0
- package/dist/workflow/findings.js +3 -0
- package/dist/workflow/schema.js +148 -0
- package/dist/workflow/secrets.js +52 -0
- package/dist/workflow/status.js +188 -0
- package/dist/workflow/validate-approval.js +25 -0
- package/dist/workflow/validate-artifacts.js +89 -0
- package/dist/workflow/validate-cancel.js +14 -0
- package/dist/workflow/validate-reports.js +121 -0
- package/dist/workflow/validate-traceability.js +91 -0
- package/dist/workflow/validate.js +73 -0
- package/docs/workflow/README.md +67 -0
- package/docs/workflow/artifacts.md +60 -0
- package/docs/workflow/cli-reference.md +78 -0
- package/docs/workflow/dashboard.md +35 -0
- package/docs/workflow/gates.md +103 -0
- package/docs/workflow/harness.md +144 -0
- package/docs/workflow/lifecycle.md +107 -0
- package/docs/workflow/skills.md +52 -0
- package/docs/workflow/source-layout.md +47 -0
- package/docs/workflow/state-machine.md +83 -0
- package/kanban-flow/review/rules/README.md +30 -0
- package/kanban-flow/review/rules/general.md +41 -0
- package/kanban-flow/review/rules/performance.md +29 -0
- package/kanban-flow/review/rules/security.md +32 -0
- package/kanban-flow/review/stacks/go.md +33 -0
- package/kanban-flow/review/stacks/java.md +38 -0
- package/kanban-flow/review/stacks/node.md +28 -0
- package/kanban-flow/review/stacks/php.md +30 -0
- package/kanban-flow/review/stacks/python.md +34 -0
- package/kanban-flow/review/stacks/ruby.md +32 -0
- package/kanban-flow/review/stacks/rust.md +33 -0
- package/kanban-flow/templates/phase-1-bug-report.md +76 -0
- package/kanban-flow/templates/phase-1-spec-requirement.md +67 -0
- package/kanban-flow/templates/phase-2-implementation-plan.md +85 -0
- package/kanban-flow/templates/phase-2-test-case.md +68 -0
- package/kanban-flow/templates/phase-2-use-case-diagram.md +18 -0
- package/kanban-flow/templates/phase-2-use-case-specification.md +33 -0
- package/kanban-flow/templates/phase-2-use-case.md +60 -0
- package/kanban-flow/templates/phase-4-testing-result.md +63 -0
- package/kanban-flow/templates/phase-5-review-report.md +68 -0
- package/kanban-flow/templates/phase-6-feature-report.md +78 -0
- package/package.json +63 -0
- package/skills/kanban-archive/SKILL.md +78 -0
- package/skills/kanban-brainstorm/SKILL.md +310 -0
- package/skills/kanban-bug/SKILL.md +55 -0
- package/skills/kanban-flow/SKILL.md +136 -0
- package/skills/kanban-implement/SKILL.md +72 -0
- package/skills/kanban-plan/SKILL.md +102 -0
- package/skills/kanban-review/SKILL.md +90 -0
- package/skills/kanban-test/SKILL.md +76 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { findFeature, writeFeatureMeta } from "../workflow/features.js";
|
|
6
|
+
import { buildWorkerPrompt, currentFailReport, runMarker } from "./prompt.js";
|
|
7
|
+
import { captureSession, isResumeFailure, parseUsage, provisionSession } from "./session.js";
|
|
8
|
+
export const DEFAULT_TIMEOUT_MS = 30 * 60_000;
|
|
9
|
+
const TAIL_BYTES = 64 * 1024;
|
|
10
|
+
const KILL_GRACE_MS = 5_000;
|
|
11
|
+
export function isPidAlive(pid) {
|
|
12
|
+
if (!pid)
|
|
13
|
+
return false;
|
|
14
|
+
try {
|
|
15
|
+
process.kill(pid, 0);
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
if (err instanceof Error && "code" in err && err.code === "ESRCH")
|
|
20
|
+
return false;
|
|
21
|
+
if (err instanceof Error && "code" in err && err.code === "EPERM")
|
|
22
|
+
return true;
|
|
23
|
+
throw err;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function loadFeature(root, name) {
|
|
27
|
+
const feature = findFeature(root, name);
|
|
28
|
+
if (!feature || !feature.meta)
|
|
29
|
+
throw new Error(`Work item '${name}' disappeared or lost its metadata while a run was in progress.`);
|
|
30
|
+
return feature;
|
|
31
|
+
}
|
|
32
|
+
/** Re-read metadata right before writing so a concurrent `kf stage` or supervisor update is not clobbered. */
|
|
33
|
+
async function upsertRun(root, name, record) {
|
|
34
|
+
const feature = loadFeature(root, name);
|
|
35
|
+
const runs = [...(feature.meta.runs ?? [])];
|
|
36
|
+
const index = runs.findIndex((r) => r.id === record.id);
|
|
37
|
+
if (index === -1)
|
|
38
|
+
runs.push(record);
|
|
39
|
+
else
|
|
40
|
+
runs[index] = record;
|
|
41
|
+
await writeFeatureMeta(feature.dir, { ...feature.meta, runs });
|
|
42
|
+
return feature;
|
|
43
|
+
}
|
|
44
|
+
/** Sessions are keyed by role, not runner: two roles on the same CLI must not share context. */
|
|
45
|
+
async function saveSession(root, name, role, session) {
|
|
46
|
+
const feature = loadFeature(root, name);
|
|
47
|
+
const sessions = { ...feature.meta.sessions };
|
|
48
|
+
if (session === null)
|
|
49
|
+
delete sessions[role];
|
|
50
|
+
else
|
|
51
|
+
sessions[role] = session;
|
|
52
|
+
await writeFeatureMeta(feature.dir, { ...feature.meta, sessions: Object.keys(sessions).length > 0 ? sessions : undefined });
|
|
53
|
+
}
|
|
54
|
+
export function runningRun(feature) {
|
|
55
|
+
return (feature.meta?.runs ?? []).find((r) => r.status === "running" && (isPidAlive(r.pid) || isPidAlive(r.supervisorPid))) ?? null;
|
|
56
|
+
}
|
|
57
|
+
export function planPath(feature, id) {
|
|
58
|
+
return join(feature.dir, "runs", `${id}.json`);
|
|
59
|
+
}
|
|
60
|
+
export function createRunPlan(root, feature, assignment, opts) {
|
|
61
|
+
const id = randomUUID().slice(0, 8);
|
|
62
|
+
const failReport = assignment.stage === "implementation" ? currentFailReport(feature) : null;
|
|
63
|
+
const prompt = buildWorkerPrompt({ runId: id, feature, root, assignment, failReport, previous: opts.previous });
|
|
64
|
+
return {
|
|
65
|
+
id, root, feature: feature.name, role: assignment.role, runnerName: assignment.runnerName,
|
|
66
|
+
stage: assignment.stage, runner: assignment.runner, prompt, output: assignment.output,
|
|
67
|
+
fresh: opts.fresh, timeoutMs: opts.timeoutMs,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
export function writeRunPlan(feature, plan) {
|
|
71
|
+
mkdirSync(join(feature.dir, "runs"), { recursive: true });
|
|
72
|
+
const path = planPath(feature, plan.id);
|
|
73
|
+
writeFileSync(path, `${JSON.stringify(plan, null, 2)}\n`, "utf8");
|
|
74
|
+
return path;
|
|
75
|
+
}
|
|
76
|
+
export function readRunPlan(feature, id) {
|
|
77
|
+
const path = planPath(feature, id);
|
|
78
|
+
if (!existsSync(path))
|
|
79
|
+
throw new Error(`Run plan not found: ${path}`);
|
|
80
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
81
|
+
}
|
|
82
|
+
function readTail(path) {
|
|
83
|
+
if (!existsSync(path))
|
|
84
|
+
return "";
|
|
85
|
+
const size = statSync(path).size;
|
|
86
|
+
const length = Math.min(size, TAIL_BYTES);
|
|
87
|
+
const buffer = Buffer.alloc(length);
|
|
88
|
+
const fd = openSync(path, "r");
|
|
89
|
+
try {
|
|
90
|
+
readSync(fd, buffer, 0, length, size - length);
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
closeSync(fd);
|
|
94
|
+
}
|
|
95
|
+
return buffer.toString("utf8");
|
|
96
|
+
}
|
|
97
|
+
/** Every string value inside JSON found on the output (whole output or one JSONL line). */
|
|
98
|
+
function jsonStrings(output) {
|
|
99
|
+
const texts = [];
|
|
100
|
+
const visit = (value) => {
|
|
101
|
+
if (typeof value === "string")
|
|
102
|
+
texts.push(value);
|
|
103
|
+
else if (Array.isArray(value))
|
|
104
|
+
value.forEach(visit);
|
|
105
|
+
else if (value && typeof value === "object")
|
|
106
|
+
Object.values(value).forEach(visit);
|
|
107
|
+
};
|
|
108
|
+
for (const text of [output.trim(), ...output.split("\n").map((line) => line.trim())]) {
|
|
109
|
+
if (!text.startsWith("{"))
|
|
110
|
+
continue;
|
|
111
|
+
try {
|
|
112
|
+
visit(JSON.parse(text));
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
if (!(err instanceof SyntaxError))
|
|
116
|
+
throw err;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return texts;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Last STATUS/Summary lines the worker printed. CLIs in JSON mode (claude
|
|
123
|
+
* --output-format json, codex exec --json) wrap the reply in a JSON string, so
|
|
124
|
+
* the lines are also searched inside every string value of any JSON on stdout.
|
|
125
|
+
*/
|
|
126
|
+
export function extractStatus(output) {
|
|
127
|
+
let statusLine = null;
|
|
128
|
+
let summary;
|
|
129
|
+
for (const source of [output, ...jsonStrings(output)]) {
|
|
130
|
+
const statuses = [...source.matchAll(/^STATUS:\s*(.+?)\s*$/gm)];
|
|
131
|
+
const summaries = [...source.matchAll(/^Summary:\s*(.+?)\s*$/gm)];
|
|
132
|
+
if (statuses.length > 0)
|
|
133
|
+
statusLine = statuses[statuses.length - 1][1];
|
|
134
|
+
if (summaries.length > 0)
|
|
135
|
+
summary = summaries[summaries.length - 1][1];
|
|
136
|
+
}
|
|
137
|
+
return { statusLine, summary };
|
|
138
|
+
}
|
|
139
|
+
function killGroup(pid, signal) {
|
|
140
|
+
try {
|
|
141
|
+
process.kill(-pid, signal);
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
if (err instanceof Error && "code" in err && err.code === "ESRCH")
|
|
145
|
+
return;
|
|
146
|
+
throw err;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/** Spawn the worker in its own process group with stdout/stderr appended to the log; kill the group on timeout. */
|
|
150
|
+
function spawnWorker(argv, opts) {
|
|
151
|
+
return new Promise((resolve, reject) => {
|
|
152
|
+
const fd = openSync(opts.logPath, "a");
|
|
153
|
+
const child = spawn(argv[0], argv.slice(1), { cwd: opts.cwd, env: opts.env, detached: true, stdio: ["ignore", fd, fd] });
|
|
154
|
+
let timedOut = false;
|
|
155
|
+
let timer;
|
|
156
|
+
let killer;
|
|
157
|
+
const finish = (result) => {
|
|
158
|
+
if (timer)
|
|
159
|
+
clearTimeout(timer);
|
|
160
|
+
if (killer)
|
|
161
|
+
clearTimeout(killer);
|
|
162
|
+
closeSync(fd);
|
|
163
|
+
resolve(result);
|
|
164
|
+
};
|
|
165
|
+
child.once("error", (err) => finish({ pid: child.pid, exitCode: null, signal: null, timedOut, spawnError: err }));
|
|
166
|
+
child.once("spawn", () => {
|
|
167
|
+
// A worker nobody recorded cannot be tracked or killed later: stop it before surfacing the error.
|
|
168
|
+
opts.onSpawn(child.pid).catch((err) => {
|
|
169
|
+
killGroup(child.pid, "SIGKILL");
|
|
170
|
+
reject(err);
|
|
171
|
+
});
|
|
172
|
+
if (opts.timeoutMs > 0) {
|
|
173
|
+
timer = setTimeout(() => {
|
|
174
|
+
timedOut = true;
|
|
175
|
+
killGroup(child.pid, "SIGTERM");
|
|
176
|
+
killer = setTimeout(() => killGroup(child.pid, "SIGKILL"), KILL_GRACE_MS);
|
|
177
|
+
}, opts.timeoutMs);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
child.once("exit", (code, signal) => finish({ pid: child.pid, exitCode: code, signal, timedOut }));
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Execute one planned run to completion and record it. A resume that the runner
|
|
185
|
+
* reports as a dead session is reset exactly once: the stored session is dropped
|
|
186
|
+
* and a fresh start run follows under a new id.
|
|
187
|
+
*/
|
|
188
|
+
export async function executeRun(plan, env = process.env, allowReset = true) {
|
|
189
|
+
const feature = loadFeature(plan.root, plan.feature);
|
|
190
|
+
const stored = feature.meta.sessions?.[plan.role];
|
|
191
|
+
const provisioned = provisionSession(plan.runner, stored, plan.prompt, plan.fresh);
|
|
192
|
+
const logRel = join("runs", `${plan.id}.log`);
|
|
193
|
+
mkdirSync(join(feature.dir, "runs"), { recursive: true });
|
|
194
|
+
const supervisorPid = feature.meta.runs?.find((r) => r.id === plan.id)?.supervisorPid;
|
|
195
|
+
let record = {
|
|
196
|
+
id: plan.id, role: plan.role, runner: plan.runnerName, stage: plan.stage, mode: provisioned.mode, session: provisioned.session,
|
|
197
|
+
at: new Date().toISOString(), log: logRel, status: "running", supervisorPid, chain: plan.chain,
|
|
198
|
+
};
|
|
199
|
+
await upsertRun(plan.root, plan.feature, record);
|
|
200
|
+
const result = await spawnWorker(provisioned.argv, {
|
|
201
|
+
cwd: plan.root, env, logPath: join(feature.dir, logRel), timeoutMs: plan.timeoutMs,
|
|
202
|
+
onSpawn: async (pid) => {
|
|
203
|
+
record = { ...record, pid };
|
|
204
|
+
await upsertRun(plan.root, plan.feature, record);
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
const endedAt = new Date().toISOString();
|
|
208
|
+
if (result.spawnError) {
|
|
209
|
+
record = { ...record, status: "failed", endedAt, error: `${provisioned.argv[0]}: ${result.spawnError.message}` };
|
|
210
|
+
await upsertRun(plan.root, plan.feature, record);
|
|
211
|
+
return { record, ok: false };
|
|
212
|
+
}
|
|
213
|
+
const output = readTail(join(loadFeature(plan.root, plan.feature).dir, logRel));
|
|
214
|
+
const { statusLine, summary } = extractStatus(output);
|
|
215
|
+
const exitCode = result.exitCode ?? (result.signal ? 128 : 1);
|
|
216
|
+
const usage = plan.runner.usage === "json" ? parseUsage(output) : null;
|
|
217
|
+
record = { ...record, exitCode, endedAt, statusLine, summary, ...(usage ? { usage } : {}) };
|
|
218
|
+
if (result.timedOut) {
|
|
219
|
+
record = { ...record, status: "timeout", error: `Worker exceeded ${plan.timeoutMs / 60_000} min and was killed.` };
|
|
220
|
+
await upsertRun(plan.root, plan.feature, record);
|
|
221
|
+
return { record, ok: false };
|
|
222
|
+
}
|
|
223
|
+
if (provisioned.mode === "resume" && isResumeFailure(plan.runner, exitCode, output)) {
|
|
224
|
+
record = { ...record, status: "reset", error: "Resume failed: session no longer usable; stored session dropped." };
|
|
225
|
+
await upsertRun(plan.root, plan.feature, record);
|
|
226
|
+
await saveSession(plan.root, plan.feature, plan.role, null);
|
|
227
|
+
if (!allowReset)
|
|
228
|
+
return { record, ok: false };
|
|
229
|
+
const retry = await executeRun({ ...plan, id: randomUUID().slice(0, 8), fresh: true }, env, false);
|
|
230
|
+
return { ...retry, note: `Session for role ${plan.role} was reset (run ${plan.id}); re-ran as ${retry.record.id}.` };
|
|
231
|
+
}
|
|
232
|
+
if (provisioned.mode === "start") {
|
|
233
|
+
const captured = captureSession(plan.runner, provisioned.session, { stdout: output, marker: runMarker(plan.id), cwd: plan.root, env });
|
|
234
|
+
if (captured)
|
|
235
|
+
await saveSession(plan.root, plan.feature, plan.role, captured);
|
|
236
|
+
else if (plan.runner.session !== undefined)
|
|
237
|
+
record = { ...record, warning: "Session id not captured from worker output; the next run will start fresh." };
|
|
238
|
+
if (captured)
|
|
239
|
+
record = { ...record, session: captured };
|
|
240
|
+
}
|
|
241
|
+
const ok = exitCode === 0 && statusLine !== null && statusLine.startsWith("DONE");
|
|
242
|
+
record = { ...record, status: exitCode === 0 ? "done" : "failed" };
|
|
243
|
+
await upsertRun(plan.root, plan.feature, record);
|
|
244
|
+
return { record, ok };
|
|
245
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
const DEFAULT_RESUME_FAILURE = /session|not found|no such|unknown|does not exist/i;
|
|
4
|
+
function fill(template, prompt, session) {
|
|
5
|
+
return template.map((arg) => arg.replaceAll("{prompt}", prompt).replaceAll("{session}", session ?? ""));
|
|
6
|
+
}
|
|
7
|
+
/** Pick start vs resume and build argv. Sessions are never guessed: only a stored id is resumed. */
|
|
8
|
+
export function provisionSession(runner, stored, prompt, fresh) {
|
|
9
|
+
if (!fresh && stored && runner.resume) {
|
|
10
|
+
return { mode: "resume", session: stored, argv: fill(runner.resume, prompt, stored) };
|
|
11
|
+
}
|
|
12
|
+
const session = runner.session === "provided" ? randomUUID() : undefined;
|
|
13
|
+
return { mode: "start", session, argv: fill(runner.start, prompt, session) };
|
|
14
|
+
}
|
|
15
|
+
/** Learn the session id produced by a `start` run; null when the runner gives no way to know it. */
|
|
16
|
+
export function captureSession(runner, provided, input) {
|
|
17
|
+
const capture = runner.session;
|
|
18
|
+
if (capture === undefined)
|
|
19
|
+
return null;
|
|
20
|
+
if (capture === "provided")
|
|
21
|
+
return provided ?? null;
|
|
22
|
+
if ("stdout" in capture) {
|
|
23
|
+
const match = new RegExp(capture.stdout).exec(input.stdout);
|
|
24
|
+
return match?.[1] ?? null;
|
|
25
|
+
}
|
|
26
|
+
const [cmd, ...args] = capture.command;
|
|
27
|
+
const res = spawnSync(cmd, args, { cwd: input.cwd, env: input.env, encoding: "utf8", timeout: 30_000 });
|
|
28
|
+
if (res.error)
|
|
29
|
+
throw new Error(`Session lookup command failed (${capture.command.join(" ")}): ${res.error.message}`, { cause: res.error });
|
|
30
|
+
if (res.status !== 0)
|
|
31
|
+
throw new Error(`Session lookup command exited ${res.status} (${capture.command.join(" ")}): ${(res.stderr ?? "").trim()}`);
|
|
32
|
+
let list;
|
|
33
|
+
try {
|
|
34
|
+
list = JSON.parse(res.stdout);
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
if (err instanceof SyntaxError)
|
|
38
|
+
throw new Error(`Session lookup command did not return JSON (${capture.command.join(" ")})`, { cause: err });
|
|
39
|
+
throw err;
|
|
40
|
+
}
|
|
41
|
+
if (!Array.isArray(list))
|
|
42
|
+
return null;
|
|
43
|
+
const hit = list.find((item) => item && typeof item === "object"
|
|
44
|
+
&& String(item[capture.matchField] ?? "").includes(input.marker));
|
|
45
|
+
const id = hit ? hit[capture.idField] : undefined;
|
|
46
|
+
return typeof id === "string" ? id : null;
|
|
47
|
+
}
|
|
48
|
+
export function isResumeFailure(runner, exitCode, output) {
|
|
49
|
+
if (exitCode === 0)
|
|
50
|
+
return false;
|
|
51
|
+
const re = runner.resumeFailure ? new RegExp(runner.resumeFailure, "i") : DEFAULT_RESUME_FAILURE;
|
|
52
|
+
return re.test(output);
|
|
53
|
+
}
|
|
54
|
+
/** Usage from JSON on stdout: the last object (whole output or one JSONL line) carrying usage.input_tokens/output_tokens. */
|
|
55
|
+
export function parseUsage(stdout) {
|
|
56
|
+
const candidates = [stdout.trim(), ...stdout.split("\n").map((l) => l.trim()).reverse()];
|
|
57
|
+
for (const text of candidates) {
|
|
58
|
+
if (!text.startsWith("{"))
|
|
59
|
+
continue;
|
|
60
|
+
let obj;
|
|
61
|
+
try {
|
|
62
|
+
obj = JSON.parse(text);
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
if (err instanceof SyntaxError)
|
|
66
|
+
continue;
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
if (!obj || typeof obj !== "object")
|
|
70
|
+
continue;
|
|
71
|
+
const usage = obj.usage;
|
|
72
|
+
if (!usage || typeof usage.input_tokens !== "number" || typeof usage.output_tokens !== "number")
|
|
73
|
+
continue;
|
|
74
|
+
const cost = obj.total_cost_usd;
|
|
75
|
+
return { input: usage.input_tokens, output: usage.output_tokens, ...(typeof cost === "number" ? { costUsd: cost } : {}) };
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { findFeature, writeFeatureMeta } from "../workflow/features.js";
|
|
5
|
+
import { readProjectConfig } from "../project/config.js";
|
|
6
|
+
import { PKG_ROOT } from "../shared/paths.js";
|
|
7
|
+
import { executeChain, newChainId } from "./chain.js";
|
|
8
|
+
import { resolveChain } from "./prompt.js";
|
|
9
|
+
function chainPlanPath(dir, id) {
|
|
10
|
+
return join(dir, "runs", `chain-${id}.json`);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Detach a chain: the supervisor is another `kf` process, so it survives the
|
|
14
|
+
* caller's exit and can update metadata when the workers finish, on any OS.
|
|
15
|
+
*/
|
|
16
|
+
export async function startDetached(root, featureName, chain, opts) {
|
|
17
|
+
const feature = findFeature(root, featureName);
|
|
18
|
+
if (!feature?.meta)
|
|
19
|
+
throw new Error(`Work item '${featureName}' has no metadata.`);
|
|
20
|
+
const id = newChainId();
|
|
21
|
+
const plan = {
|
|
22
|
+
id, root, feature: featureName, stage: chain[0].stage,
|
|
23
|
+
roles: chain.map((a) => a.role), fresh: opts.fresh, timeoutMs: opts.timeoutMs,
|
|
24
|
+
};
|
|
25
|
+
mkdirSync(join(feature.dir, "runs"), { recursive: true });
|
|
26
|
+
writeFileSync(chainPlanPath(feature.dir, id), `${JSON.stringify(plan, null, 2)}\n`, "utf8");
|
|
27
|
+
const entry = join(PKG_ROOT, "dist", "index.js");
|
|
28
|
+
const child = spawn(process.execPath, [entry, "run", featureName, "--supervise", id], { cwd: root, detached: true, stdio: "ignore" });
|
|
29
|
+
const supervisorPid = await new Promise((resolve, reject) => {
|
|
30
|
+
child.once("error", reject);
|
|
31
|
+
child.once("spawn", () => resolve(child.pid));
|
|
32
|
+
});
|
|
33
|
+
child.unref();
|
|
34
|
+
const record = {
|
|
35
|
+
id, role: chain[0].role, runner: chain[0].runnerName, stage: chain[0].stage, mode: "start",
|
|
36
|
+
at: new Date().toISOString(), log: join("runs", `${id}.log`), status: "running", supervisorPid,
|
|
37
|
+
chain: { id, index: 1, total: chain.length },
|
|
38
|
+
};
|
|
39
|
+
await writeFeatureMeta(feature.dir, { ...feature.meta, runs: [...(feature.meta.runs ?? []), record] });
|
|
40
|
+
return record;
|
|
41
|
+
}
|
|
42
|
+
/** Entry point of the detached process: rebuild the chain from its plan and run it. */
|
|
43
|
+
export async function superviseRun(root, featureName, chainId) {
|
|
44
|
+
const feature = findFeature(root, featureName);
|
|
45
|
+
if (!feature) {
|
|
46
|
+
const orphanDir = join(root, ".works", "harness");
|
|
47
|
+
mkdirSync(orphanDir, { recursive: true });
|
|
48
|
+
const orphan = join(orphanDir, `orphan-${chainId}.json`);
|
|
49
|
+
writeFileSync(orphan, `${JSON.stringify({ chainId, feature: featureName, at: new Date().toISOString(), reason: "work item folder not found" }, null, 2)}\n`);
|
|
50
|
+
throw new Error(`Work item '${featureName}' not found; wrote ${orphan}`);
|
|
51
|
+
}
|
|
52
|
+
const path = chainPlanPath(feature.dir, chainId);
|
|
53
|
+
if (!existsSync(path))
|
|
54
|
+
throw new Error(`Chain plan not found: ${path}`);
|
|
55
|
+
const plan = JSON.parse(readFileSync(path, "utf8"));
|
|
56
|
+
const harness = readProjectConfig(root).harness;
|
|
57
|
+
const chain = [];
|
|
58
|
+
for (const role of plan.roles) {
|
|
59
|
+
const resolved = resolveChain(harness, feature, root, { stage: plan.stage, role });
|
|
60
|
+
if (!resolved.ok)
|
|
61
|
+
throw new Error(`Cannot resume chain ${chainId}: ${resolved.reason}`);
|
|
62
|
+
chain.push(...resolved.chain);
|
|
63
|
+
}
|
|
64
|
+
return executeChain(root, featureName, chain, { fresh: plan.fresh, timeoutMs: plan.timeoutMs, firstRunId: chainId });
|
|
65
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgsCli, allCommands, commandHelp } from "./cli/args.js";
|
|
3
|
+
import { cmdInit } from "./cli/commands/init.js";
|
|
4
|
+
import { cmdNew } from "./cli/commands/new.js";
|
|
5
|
+
import { cmdList, cmdShow, cmdView, cmdStatus, cmdValidate } from "./cli/commands/inspect.js";
|
|
6
|
+
import { cmdInstruct, cmdTemplates } from "./cli/commands/artifacts.js";
|
|
7
|
+
import { cmdRules } from "./cli/commands/rules.js";
|
|
8
|
+
import { cmdAutoconfig } from "./cli/commands/autoconfig.js";
|
|
9
|
+
import { cmdDoctor } from "./cli/commands/doctor.js";
|
|
10
|
+
import { cmdRun, cmdRuns } from "./cli/commands/run.js";
|
|
11
|
+
import { cmdHarness } from "./cli/commands/harness.js";
|
|
12
|
+
import { cmdContexts } from "./cli/commands/contexts.js";
|
|
13
|
+
import { cmdStage } from "./cli/commands/stage.js";
|
|
14
|
+
import { cmdArchive } from "./cli/commands/archive.js";
|
|
15
|
+
import { cmdCancel } from "./cli/commands/cancel.js";
|
|
16
|
+
import { cmdApprove } from "./cli/commands/approve.js";
|
|
17
|
+
import { cmdInstall, cmdUninstall } from "./integrations/install.js";
|
|
18
|
+
import { cmdDashboard } from "./dashboard/dashboard.js";
|
|
19
|
+
import { parseAgentIds } from "./integrations/agents.js";
|
|
20
|
+
import { readFileSync } from "node:fs";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { PKG_ROOT } from "./shared/paths.js";
|
|
23
|
+
const PKG_VERSION = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8")).version ?? "0.0.0";
|
|
24
|
+
function portFrom(parsed) {
|
|
25
|
+
if (parsed.options.port === undefined)
|
|
26
|
+
return 8787;
|
|
27
|
+
const raw = String(parsed.options.port);
|
|
28
|
+
const n = Number(raw);
|
|
29
|
+
if (!/^\d+$/.test(raw) || !Number.isInteger(n) || n < 1 || n > 65535) {
|
|
30
|
+
throw new Error(`Invalid port '${raw}': expected an integer from 1 to 65535.`);
|
|
31
|
+
}
|
|
32
|
+
return n;
|
|
33
|
+
}
|
|
34
|
+
async function help(parts) {
|
|
35
|
+
const topic = parts[0];
|
|
36
|
+
if (topic)
|
|
37
|
+
return { code: 0, stdout: commandHelp(topic) };
|
|
38
|
+
const cmds = allCommands().map((c) => ` ${c.padEnd(12)} ${commandHelp(c).split("\n")[0]}`).join("\n");
|
|
39
|
+
return {
|
|
40
|
+
code: 0,
|
|
41
|
+
stdout: `kanban-flow CLI v${PKG_VERSION}\n\nUsage: kf <command> [args]\n\nCommands:\n${cmds}\n\nRun "kf help <command>" for details.`,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
async function main(argv) {
|
|
45
|
+
const cwd = process.cwd();
|
|
46
|
+
if (argv[0] === "--version" || argv[0] === "-V") {
|
|
47
|
+
return { code: 0, stdout: PKG_VERSION };
|
|
48
|
+
}
|
|
49
|
+
if (argv[0] === "--help" || argv[0] === "-h") {
|
|
50
|
+
return help([]);
|
|
51
|
+
}
|
|
52
|
+
const parsed = parseArgsCli(argv);
|
|
53
|
+
if (parsed.options.help)
|
|
54
|
+
return help([parsed.command]);
|
|
55
|
+
switch (parsed.command) {
|
|
56
|
+
case "help":
|
|
57
|
+
return help(parsed.positionals);
|
|
58
|
+
case "version":
|
|
59
|
+
return { code: 0, stdout: PKG_VERSION };
|
|
60
|
+
case "init":
|
|
61
|
+
return cmdInit(parsed, cwd);
|
|
62
|
+
case "new":
|
|
63
|
+
return cmdNew(parsed, cwd);
|
|
64
|
+
case "list":
|
|
65
|
+
return cmdList(parsed, cwd);
|
|
66
|
+
case "show":
|
|
67
|
+
return cmdShow(parsed, cwd);
|
|
68
|
+
case "view":
|
|
69
|
+
return cmdView(parsed, cwd);
|
|
70
|
+
case "dashboard":
|
|
71
|
+
return cmdDashboard(portFrom(parsed));
|
|
72
|
+
case "status":
|
|
73
|
+
return cmdStatus(parsed, cwd);
|
|
74
|
+
case "instruct":
|
|
75
|
+
return cmdInstruct(parsed, cwd);
|
|
76
|
+
case "templates":
|
|
77
|
+
return cmdTemplates(parsed, cwd);
|
|
78
|
+
case "validate":
|
|
79
|
+
return cmdValidate(parsed, cwd);
|
|
80
|
+
case "stage":
|
|
81
|
+
return cmdStage(parsed, cwd);
|
|
82
|
+
case "archive":
|
|
83
|
+
return cmdArchive(parsed, cwd);
|
|
84
|
+
case "cancel":
|
|
85
|
+
return cmdCancel(parsed, cwd);
|
|
86
|
+
case "approve":
|
|
87
|
+
return cmdApprove(parsed, cwd);
|
|
88
|
+
case "rules":
|
|
89
|
+
return cmdRules(parsed, cwd);
|
|
90
|
+
case "autoconfig":
|
|
91
|
+
return cmdAutoconfig(parsed, cwd);
|
|
92
|
+
case "doctor":
|
|
93
|
+
return cmdDoctor(parsed, cwd);
|
|
94
|
+
case "run":
|
|
95
|
+
return cmdRun(parsed, cwd);
|
|
96
|
+
case "runs":
|
|
97
|
+
return cmdRuns(parsed, cwd);
|
|
98
|
+
case "harness":
|
|
99
|
+
return cmdHarness(parsed, cwd);
|
|
100
|
+
case "contexts":
|
|
101
|
+
return cmdContexts(parsed, cwd);
|
|
102
|
+
case "install":
|
|
103
|
+
return cmdInstall(parseAgentIds(parsed.options.agent), { cwd });
|
|
104
|
+
case "uninstall":
|
|
105
|
+
return cmdUninstall(parseAgentIds(parsed.options.agent), {
|
|
106
|
+
cwd,
|
|
107
|
+
purge: Boolean(parsed.options.purge),
|
|
108
|
+
force: Boolean(parsed.options.force),
|
|
109
|
+
});
|
|
110
|
+
default:
|
|
111
|
+
return { code: 1, stdout: commandHelp(parsed.command), stderr: `unknown command: ${parsed.command}` };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
main(process.argv.slice(2)).then((r) => {
|
|
115
|
+
if (r.stdout)
|
|
116
|
+
process.stdout.write(`${r.stdout}\n`);
|
|
117
|
+
if (r.stderr)
|
|
118
|
+
process.stderr.write(`${r.stderr}\n`);
|
|
119
|
+
process.exitCode = r.code;
|
|
120
|
+
}).catch((err) => {
|
|
121
|
+
process.stderr.write(`kf: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
122
|
+
process.exitCode = 1;
|
|
123
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
export const AGENTS = [
|
|
3
|
+
{
|
|
4
|
+
id: "claude",
|
|
5
|
+
label: "Claude Code",
|
|
6
|
+
projectRel: ".claude/skills",
|
|
7
|
+
alsoReads: ["cursor", "opencode"],
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
id: "codex",
|
|
11
|
+
label: "OpenAI Codex",
|
|
12
|
+
projectRel: ".agents/skills",
|
|
13
|
+
alsoReads: ["gemini", "cursor", "opencode"],
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
id: "gemini",
|
|
17
|
+
label: "Gemini CLI",
|
|
18
|
+
projectRel: ".gemini/skills",
|
|
19
|
+
alsoReads: ["codex", "cursor", "opencode"],
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
id: "kiro",
|
|
23
|
+
label: "Kiro",
|
|
24
|
+
projectRel: ".kiro/skills",
|
|
25
|
+
alsoReads: [],
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: "cursor",
|
|
29
|
+
label: "Cursor",
|
|
30
|
+
projectRel: ".cursor/skills",
|
|
31
|
+
alsoReads: ["codex", "gemini", "opencode"],
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: "opencode",
|
|
35
|
+
label: "OpenCode",
|
|
36
|
+
projectRel: ".opencode/skills",
|
|
37
|
+
alsoReads: ["codex", "gemini", "cursor"],
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
/** Default agent used when the user does not pick one (non-interactive). */
|
|
41
|
+
export const DEFAULT_AGENT = "claude";
|
|
42
|
+
export function agentById(id) {
|
|
43
|
+
return AGENTS.find((a) => a.id === id) ?? null;
|
|
44
|
+
}
|
|
45
|
+
export function projectSkillsDir(agent, root) {
|
|
46
|
+
return join(root, agent.projectRel);
|
|
47
|
+
}
|
|
48
|
+
/** Normalize raw CLI option values into a de-duped list of known agent ids. */
|
|
49
|
+
export function parseAgentIds(raw) {
|
|
50
|
+
const vals = Array.isArray(raw) ? raw.map(String) : raw != null ? [String(raw)] : [];
|
|
51
|
+
const seen = new Set();
|
|
52
|
+
for (const v of vals) {
|
|
53
|
+
const a = agentById(v);
|
|
54
|
+
if (!a)
|
|
55
|
+
throw new Error(`Unknown agent '${v}'. Supported agents: ${AGENTS.map((x) => x.id).join(", ")}.`);
|
|
56
|
+
seen.add(a.id);
|
|
57
|
+
}
|
|
58
|
+
return [...seen];
|
|
59
|
+
}
|
|
60
|
+
/** Skills dir for any agent name: the adapter's dir when known, else the open `.agents/skills` standard. */
|
|
61
|
+
export function skillsDirFor(name, root) {
|
|
62
|
+
const adapter = agentById(name);
|
|
63
|
+
return join(root, adapter ? adapter.projectRel : ".agents/skills");
|
|
64
|
+
}
|
|
65
|
+
export function agentLabel(id) {
|
|
66
|
+
return agentById(id)?.label ?? id;
|
|
67
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { findWorksRoot } from "../workflow/features.js";
|
|
5
|
+
import { PKG_ROOT, USER_KABAN_DIR } from "../shared/paths.js";
|
|
6
|
+
const PKG_HOOKS_DIR = join(PKG_ROOT, "kanban-flow", "hooks");
|
|
7
|
+
/**
|
|
8
|
+
* Resolve a per-phase hook script. Precedence: project → user → package.
|
|
9
|
+
* Hook file layout: {kanban-dir}/hooks/{phase}.sh
|
|
10
|
+
*/
|
|
11
|
+
export function resolveHook(cwd, phase) {
|
|
12
|
+
const candidates = [
|
|
13
|
+
["project", join(findWorksRoot(cwd) ?? cwd, ".kf", "hooks", `${phase}.sh`)],
|
|
14
|
+
["user", join(USER_KABAN_DIR, "hooks", `${phase}.sh`)],
|
|
15
|
+
["package", join(PKG_HOOKS_DIR, `${phase}.sh`)],
|
|
16
|
+
];
|
|
17
|
+
for (const [source, path] of candidates) {
|
|
18
|
+
if (existsSync(path))
|
|
19
|
+
return { name: phase, path, source };
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Run the hook for the phase the feature is about to ENTER.
|
|
25
|
+
* Returns { ok:false } when the hook script exits non-zero — the caller must
|
|
26
|
+
* then refuse the transition (or honor --skip-hooks).
|
|
27
|
+
*/
|
|
28
|
+
export function runHook(cwd, env) {
|
|
29
|
+
const hook = resolveHook(cwd, env.to);
|
|
30
|
+
if (!hook) {
|
|
31
|
+
return { hook: null, ran: false, ok: true, code: 0, output: "" };
|
|
32
|
+
}
|
|
33
|
+
const isSh = hook.path.endsWith(".sh");
|
|
34
|
+
const isJs = hook.path.endsWith(".js") || hook.path.endsWith(".mjs") || hook.path.endsWith(".cjs");
|
|
35
|
+
const cmd = isSh ? "bash" : isJs ? "node" : hook.path;
|
|
36
|
+
const args = isSh || isJs ? [hook.path] : [];
|
|
37
|
+
const res = spawnSync(cmd, args, {
|
|
38
|
+
encoding: "utf8",
|
|
39
|
+
cwd: env.dir,
|
|
40
|
+
env: {
|
|
41
|
+
...process.env,
|
|
42
|
+
KFW_FEATURE: env.feature,
|
|
43
|
+
KFW_CONTEXT: env.context ?? "",
|
|
44
|
+
KFW_FEATURE_DIR: env.dir,
|
|
45
|
+
KFW_WORK_ROOT: env.root,
|
|
46
|
+
KFW_FROM_STAGE: env.from ?? "",
|
|
47
|
+
KFW_TO_STAGE: env.to,
|
|
48
|
+
KFW_APPROVAL: env.approval,
|
|
49
|
+
},
|
|
50
|
+
timeout: 120_000,
|
|
51
|
+
});
|
|
52
|
+
return {
|
|
53
|
+
hook,
|
|
54
|
+
ran: true,
|
|
55
|
+
ok: res.status === 0,
|
|
56
|
+
code: res.status ?? 1,
|
|
57
|
+
output: `${res.stdout ?? ""}${res.stderr ?? ""}${res.error ? `\n${res.error.message}` : ""}${res.signal ? `\nTerminated by ${res.signal}` : ""}`.trim(),
|
|
58
|
+
};
|
|
59
|
+
}
|