@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,101 @@
|
|
|
1
|
+
import { readProjectConfig } from "../../project/config.js";
|
|
2
|
+
import { contextsInUse, normalizeContext } from "../../project/contexts.js";
|
|
3
|
+
import { findRoot } from "./helpers.js";
|
|
4
|
+
/**
|
|
5
|
+
* The brief a worker reads when no list exists yet. It states the grouping rule, the size, and that
|
|
6
|
+
* the human decides — the last one is a promise in prose, not something the CLI can enforce.
|
|
7
|
+
*/
|
|
8
|
+
function surveyBrief(inUse) {
|
|
9
|
+
const lines = [
|
|
10
|
+
"No contexts declared in .kf/config.json.",
|
|
11
|
+
"",
|
|
12
|
+
"Survey this repo and propose a context list:",
|
|
13
|
+
"- read the top-level source directories and the package manifests",
|
|
14
|
+
"- group by business domain, not by technical layer: billing and catalog,",
|
|
15
|
+
" never frontend and backend",
|
|
16
|
+
"- propose 3 to 7 names, one word each; where a context is already in use,",
|
|
17
|
+
" match the spelling the work items use rather than re-casing it",
|
|
18
|
+
"- show them to the human and write only the list they confirm to",
|
|
19
|
+
" .kf/config.json as \"contexts\"; the first entry becomes the default",
|
|
20
|
+
"",
|
|
21
|
+
"Do not write the list on your own. Naming a business domain is the",
|
|
22
|
+
"product owner's call, not something to infer from a directory tree.",
|
|
23
|
+
"",
|
|
24
|
+
];
|
|
25
|
+
lines.push(inUse.length === 0
|
|
26
|
+
? "No work item uses a context yet."
|
|
27
|
+
: `Contexts currently in use on disk: ${inUse.map((c) => `${c.context} (${c.count})`).join(", ")}`);
|
|
28
|
+
return lines.join("\n");
|
|
29
|
+
}
|
|
30
|
+
const plural = (n) => `${n} work item${n === 1 ? "" : "s"}`;
|
|
31
|
+
export async function cmdContexts(args, cwd) {
|
|
32
|
+
const root = await findRoot(cwd);
|
|
33
|
+
if (!root.ok)
|
|
34
|
+
return { code: 1, stdout: root.err, stderr: "no works" };
|
|
35
|
+
const cfg = readProjectConfig(root.root);
|
|
36
|
+
const inUse = contextsInUse(root.root);
|
|
37
|
+
const declared = cfg.contexts ?? [];
|
|
38
|
+
// Exact comparison on both sides. `kf new` accepts a context only on an exact match, so
|
|
39
|
+
// "declared" here must mean the same thing, or the advice below becomes a lie.
|
|
40
|
+
const countOf = (name) => inUse.find((u) => u.context === name)?.count ?? 0;
|
|
41
|
+
const missing = inUse.filter((u) => !declared.includes(u.context));
|
|
42
|
+
// "Can this name be added?" depends on every other name that would sit beside it, not just on
|
|
43
|
+
// what is already declared: two undeclared spellings of one name collide with each other too,
|
|
44
|
+
// and readProjectConfig refuses the repeat either way.
|
|
45
|
+
const undeclared = missing.map((u) => {
|
|
46
|
+
const key = normalizeContext(u.context);
|
|
47
|
+
// Every partner, not the first one found: with a declared clash AND another in-use spelling,
|
|
48
|
+
// naming only one produces two instructions that cannot both be obeyed, and the reader
|
|
49
|
+
// bounces between them forever. Only one name in the whole group can ever be declared.
|
|
50
|
+
const declaredClash = declared.find((d) => normalizeContext(d) === key) ?? null;
|
|
51
|
+
const inUseClashes = missing.filter((o) => o.context !== u.context && normalizeContext(o.context) === key).map((o) => o.context);
|
|
52
|
+
return { ...u, collidesWith: declaredClash ?? inUseClashes[0] ?? null, collidesWithDeclared: declaredClash !== null, declaredClash, inUseClashes };
|
|
53
|
+
});
|
|
54
|
+
const collisionAdvice = (u) => {
|
|
55
|
+
const others = [...(u.declaredClash ? [`declared "${u.declaredClash}"`] : []), ...u.inUseClashes.map((c) => `"${c}" (also in use)`)];
|
|
56
|
+
const list = others.length > 1 ? `${others.slice(0, -1).join(", ")} and ${others[others.length - 1]}` : others[0];
|
|
57
|
+
return [
|
|
58
|
+
` ⚠ differs only by case from ${list}.`,
|
|
59
|
+
" The config refuses two names that differ only by case, so only one of",
|
|
60
|
+
` these ${others.length + 1} names can be declared. Rename the other work items first.`,
|
|
61
|
+
];
|
|
62
|
+
};
|
|
63
|
+
const clashing = undeclared.filter((u) => u.inUseClashes.length > 0);
|
|
64
|
+
// One brief, built once. Splitting it produced a text warning and a JSON brief without one,
|
|
65
|
+
// and --json is the mode an agent reads, so the warning went missing exactly where it counts.
|
|
66
|
+
const briefText = clashing.length === 0
|
|
67
|
+
? surveyBrief(inUse)
|
|
68
|
+
: [surveyBrief(inUse), "", "Before you write the list, note:",
|
|
69
|
+
...clashing.flatMap((u) => [` ${u.context}`, ...collisionAdvice(u)])].join("\n");
|
|
70
|
+
if (args.options.json) {
|
|
71
|
+
return {
|
|
72
|
+
code: 0,
|
|
73
|
+
stdout: JSON.stringify({
|
|
74
|
+
declared: declared.map((name, index) => ({ name, count: countOf(name), default: index === 0 })),
|
|
75
|
+
undeclared,
|
|
76
|
+
restricted: declared.length > 0,
|
|
77
|
+
// UC-003's actor is an agent, and --json is the mode an agent reads: it needs the brief most.
|
|
78
|
+
...(declared.length === 0 ? { brief: briefText } : {}),
|
|
79
|
+
}, null, 2),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
if (declared.length === 0)
|
|
83
|
+
return { code: 0, stdout: briefText };
|
|
84
|
+
const lines = ["Declared contexts:"];
|
|
85
|
+
for (const [index, name] of declared.entries()) {
|
|
86
|
+
const marker = index === 0 ? " (default)" : "";
|
|
87
|
+
lines.push(` ${name.padEnd(16)} ${plural(countOf(name))}${marker}`);
|
|
88
|
+
}
|
|
89
|
+
if (undeclared.length > 0) {
|
|
90
|
+
lines.push("", "In use but not declared:");
|
|
91
|
+
for (const u of undeclared) {
|
|
92
|
+
lines.push(` ${u.context.padEnd(16)} ${plural(u.count)}`);
|
|
93
|
+
if (u.collidesWith)
|
|
94
|
+
lines.push(...collisionAdvice(u));
|
|
95
|
+
}
|
|
96
|
+
if (undeclared.some((u) => !u.collidesWith)) {
|
|
97
|
+
lines.push("", "kf new refuses these. Add the ones you want to keep to contexts in .kf/config.json.");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return { code: 0, stdout: lines.join("\n") };
|
|
101
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { runDoctor } from "../../project/doctor.js";
|
|
2
|
+
import { findRoot } from "./helpers.js";
|
|
3
|
+
function renderFinding(f) {
|
|
4
|
+
const lines = [` [${f.level}] ${f.area}: ${f.message}`];
|
|
5
|
+
if (f.action)
|
|
6
|
+
lines.push(` → ${f.action}`);
|
|
7
|
+
return lines;
|
|
8
|
+
}
|
|
9
|
+
export async function cmdDoctor(args, cwd) {
|
|
10
|
+
const root = await findRoot(cwd);
|
|
11
|
+
if (!root.ok)
|
|
12
|
+
return { code: 1, stdout: root.err, stderr: "no works" };
|
|
13
|
+
const report = runDoctor(root.root);
|
|
14
|
+
if (args.options.json) {
|
|
15
|
+
return { code: report.ok ? 0 : 1, stdout: JSON.stringify(report, null, 2) };
|
|
16
|
+
}
|
|
17
|
+
const lines = [`Project: ${report.root}`, ""];
|
|
18
|
+
if (report.findings.length === 0) {
|
|
19
|
+
lines.push("No problems found.");
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
for (const f of report.findings)
|
|
23
|
+
lines.push(...renderFinding(f));
|
|
24
|
+
}
|
|
25
|
+
lines.push("");
|
|
26
|
+
// Deliberately below the findings and outside them: an invalid work item is an ordinary state
|
|
27
|
+
// for a pipeline in motion, not a broken project, so it never changes the verdict.
|
|
28
|
+
lines.push(report.totalItems === 0
|
|
29
|
+
? "Work items: none yet."
|
|
30
|
+
: `Work items: ${report.totalItems}, ${report.invalidItems} not currently valid` +
|
|
31
|
+
(report.invalidItems > 0 ? " — run: kf validate --all" : ""));
|
|
32
|
+
lines.push("");
|
|
33
|
+
lines.push(report.ok ? "✓ Healthy." : "✗ Problems found — see the ERROR lines above.");
|
|
34
|
+
return { code: report.ok ? 0 : 1, stdout: lines.join("\n"), stderr: report.ok ? undefined : "doctor found problems" };
|
|
35
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { delimiter, join } from "node:path";
|
|
3
|
+
import { readProjectConfig } from "../../project/config.js";
|
|
4
|
+
import { HARNESS_STAGES } from "../../harness/config.js";
|
|
5
|
+
import { findWorksRoot } from "../../workflow/features.js";
|
|
6
|
+
/** True when the runner's executable resolves on PATH (Windows also checks .cmd/.exe). */
|
|
7
|
+
export function cliAvailable(cli, env = process.env) {
|
|
8
|
+
if (cli.includes("/") || cli.includes("\\"))
|
|
9
|
+
return existsSync(cli);
|
|
10
|
+
const dirs = (env.PATH ?? "").split(delimiter).filter(Boolean);
|
|
11
|
+
const names = process.platform === "win32" ? [cli, `${cli}.cmd`, `${cli}.exe`] : [cli];
|
|
12
|
+
return dirs.some((dir) => names.some((n) => existsSync(join(dir, n))));
|
|
13
|
+
}
|
|
14
|
+
function sessionKind(runner) {
|
|
15
|
+
if (runner.session === undefined)
|
|
16
|
+
return "none";
|
|
17
|
+
if (runner.session === "provided")
|
|
18
|
+
return "provided";
|
|
19
|
+
return "stdout" in runner.session ? "stdout" : "command";
|
|
20
|
+
}
|
|
21
|
+
function shorten(text, max = 60) {
|
|
22
|
+
if (!text)
|
|
23
|
+
return "";
|
|
24
|
+
return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
|
|
25
|
+
}
|
|
26
|
+
export async function cmdHarness(args, cwd) {
|
|
27
|
+
const root = findWorksRoot(cwd) ?? cwd;
|
|
28
|
+
const harness = readProjectConfig(root).harness;
|
|
29
|
+
if (!harness) {
|
|
30
|
+
return { code: 1, stdout: "No harness configured in .kf/config.json — run: kf init (seeds roles and runner presets).", stderr: "no harness" };
|
|
31
|
+
}
|
|
32
|
+
const stages = HARNESS_STAGES.map((stage) => ({ stage, roles: harness.stages[stage] ?? [] }));
|
|
33
|
+
const roles = Object.entries(harness.roles).map(([name, role]) => ({
|
|
34
|
+
name, runner: role.runner, brief: role.brief ?? null, output: role.output ?? null,
|
|
35
|
+
available: cliAvailable(harness.runners[role.runner].start[0]),
|
|
36
|
+
}));
|
|
37
|
+
const runners = Object.entries(harness.runners).map(([name, runner]) => ({
|
|
38
|
+
name,
|
|
39
|
+
cli: runner.start[0],
|
|
40
|
+
available: cliAvailable(runner.start[0]),
|
|
41
|
+
resume: runner.resume !== undefined,
|
|
42
|
+
session: sessionKind(runner),
|
|
43
|
+
usage: runner.usage ?? null,
|
|
44
|
+
}));
|
|
45
|
+
if (args.options.json)
|
|
46
|
+
return { code: 0, stdout: JSON.stringify({ main: harness.main, stages, roles, runners }, null, 2) };
|
|
47
|
+
const lines = [
|
|
48
|
+
`main role: ${harness.main} (${harness.roles[harness.main].runner})`,
|
|
49
|
+
"",
|
|
50
|
+
"stages → roles:",
|
|
51
|
+
...stages.map((s) => ` ${s.stage.padEnd(14)} ${s.roles.length > 0 ? s.roles.join(" → ") : `${harness.main} (main, not handed off)`}`),
|
|
52
|
+
"",
|
|
53
|
+
"roles → runners:",
|
|
54
|
+
...roles.map((r) => ` ${r.name.padEnd(12)} ${r.runner.padEnd(10)} ${r.available ? "on PATH" : "missing"}${r.output ? ` output: ${r.output}` : ""}${r.brief ? `\n ${shorten(r.brief, 96)}` : ""}`),
|
|
55
|
+
"",
|
|
56
|
+
"runners:",
|
|
57
|
+
...runners.map((r) => ` ${r.name.padEnd(12)} ${r.cli.padEnd(10)} ${r.available ? "on PATH" : "missing"} resume: ${r.resume ? "yes" : "no"} session: ${r.session}${r.usage ? ` usage: ${r.usage}` : ""}`),
|
|
58
|
+
];
|
|
59
|
+
return { code: 0, stdout: lines.join("\n") };
|
|
60
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { findWorksRoot } from "../../workflow/features.js";
|
|
2
|
+
import { nowTimestamp } from "../../shared/time.js";
|
|
3
|
+
export async function findRoot(cwd) {
|
|
4
|
+
const root = findWorksRoot(cwd);
|
|
5
|
+
if (!root) {
|
|
6
|
+
return { root: "", ok: false, err: "No .works found. Run: kf init" };
|
|
7
|
+
}
|
|
8
|
+
return { root, ok: true };
|
|
9
|
+
}
|
|
10
|
+
/** Build the audit records for this transition: only bypasses that actually skipped something are recorded. */
|
|
11
|
+
export function recordBypasses(from, to, forcedCodes, skippedHook) {
|
|
12
|
+
const at = nowTimestamp();
|
|
13
|
+
const records = [];
|
|
14
|
+
if (forcedCodes.length > 0)
|
|
15
|
+
records.push({ at, from, to, flag: "force", codes: [...new Set(forcedCodes)] });
|
|
16
|
+
if (skippedHook)
|
|
17
|
+
records.push({ at, from, to, flag: "skip-hooks", codes: [`hook:${skippedHook.path}`] });
|
|
18
|
+
return records;
|
|
19
|
+
}
|
|
20
|
+
export function bypassNote(recorded) {
|
|
21
|
+
return recorded.map((b) => `\n ⚠ Bypass recorded: --${b.flag} (${b.codes.join(", ")})`).join("");
|
|
22
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { STAGES } from "../../workflow/schema.js";
|
|
5
|
+
import { assertPathName, ensureWorksStructure } from "../../workflow/features.js";
|
|
6
|
+
import { checkContext, contextRefusal, effectiveDefaultContext } from "../../project/contexts.js";
|
|
7
|
+
import { readProjectConfig, detectStacks, writeProjectConfig, configPath } from "../../project/config.js";
|
|
8
|
+
import { seedHarness } from "../../harness/config.js";
|
|
9
|
+
import { nowTimestamp } from "../../shared/time.js";
|
|
10
|
+
import { parseAgentIds } from "../../integrations/agents.js";
|
|
11
|
+
import { installProjectSkills } from "../../integrations/install.js";
|
|
12
|
+
import { hasWorkItems, bootstrapDefaults, onboardAnswers, seedOverrides, saveConfig, appendIgnoreWorks, seedAgentsFile } from "../../project/bootstrap.js";
|
|
13
|
+
import { cmdNew } from "./new.js";
|
|
14
|
+
export async function cmdInit(args, cwd) {
|
|
15
|
+
const target = resolve(cwd, args.positionals[0] ?? ".");
|
|
16
|
+
const agents = parseAgentIds(args.options.agent);
|
|
17
|
+
if (typeof args.options.context === "string") {
|
|
18
|
+
assertPathName(args.options.context, "context");
|
|
19
|
+
// Seeding docs/{ctx}/ for a context kf new would refuse leaves a tree nothing can ever fill.
|
|
20
|
+
// This runs before either branch, so --minimal and the bootstrap path are both covered.
|
|
21
|
+
const explicit = checkContext(args.options.context, readProjectConfig(target));
|
|
22
|
+
if (!explicit.ok)
|
|
23
|
+
return { code: 1, stdout: contextRefusal(args.options.context, explicit), stderr: "unknown context" };
|
|
24
|
+
}
|
|
25
|
+
const minimal = Boolean(args.options.minimal);
|
|
26
|
+
const useDefaults = Boolean(args.options.defaults);
|
|
27
|
+
if (!minimal) {
|
|
28
|
+
return cmdBootstrap(args, target, !useDefaults);
|
|
29
|
+
}
|
|
30
|
+
const cfg = readProjectConfig(target);
|
|
31
|
+
const ctx = typeof args.options.context === "string" ? args.options.context : effectiveDefaultContext(cfg);
|
|
32
|
+
// Same rule as the bootstrap path: an explicit --context on a genuinely new project is a
|
|
33
|
+
// decision and gets declared. --minimal writes a config anyway, so leaving it out here would
|
|
34
|
+
// mean the same command declares or not depending on a flag that says nothing about contexts.
|
|
35
|
+
const declareHere = typeof args.options.context === "string"
|
|
36
|
+
&& cfg.contexts === undefined && !existsSync(configPath(target)) && !hasWorkItems(target);
|
|
37
|
+
ensureWorksStructure(target);
|
|
38
|
+
// A named context must be recorded even when FR-007 forbids declaring a list, or --minimal
|
|
39
|
+
// seeds docs/{ctx}/ and then sends every new item somewhere else — but only where nothing else
|
|
40
|
+
// already decides the default. With `contexts` present, `contexts[0]` is the default and
|
|
41
|
+
// `defaultContext` is ignored, so writing it would leave a config contradicting itself: the
|
|
42
|
+
// file naming one default while `kf new` and `kf contexts` use another.
|
|
43
|
+
const recordContext = typeof args.options.context === "string" && cfg.contexts === undefined;
|
|
44
|
+
// The write sits OUTSIDE the harness check on purpose. Every project kf init has already
|
|
45
|
+
// touched carries a harness, so guarding it on `!cfg.harness` meant the record never happened
|
|
46
|
+
// on any real project — only on the two config shapes a real init never produces.
|
|
47
|
+
if (!cfg.harness || recordContext) {
|
|
48
|
+
await mkdir(join(target, ".kf"), { recursive: true });
|
|
49
|
+
writeProjectConfig(target, {
|
|
50
|
+
...cfg, schema: "kanban-flow", created: cfg.created ?? nowTimestamp(),
|
|
51
|
+
...(declareHere ? { contexts: [ctx] } : {}),
|
|
52
|
+
...(!declareHere && recordContext ? { defaultContext: ctx } : {}),
|
|
53
|
+
harness: cfg.harness ?? seedHarness(agents.length ? agents : parseAgentIds(cfg.agents)),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
await mkdir(join(target, ".kf", "templates"), { recursive: true });
|
|
57
|
+
await mkdir(join(target, ".kf", "hooks"), { recursive: true });
|
|
58
|
+
await mkdir(join(target, ".kf", "review", "rules"), { recursive: true });
|
|
59
|
+
for (const docsDir of ["requirement", "use-cases", "testplan"]) {
|
|
60
|
+
await mkdir(join(target, "docs", docsDir, ctx), { recursive: true });
|
|
61
|
+
}
|
|
62
|
+
const skills = await installProjectSkills(target, agents.length ? agents : parseAgentIds(cfg.agents));
|
|
63
|
+
if (skills.code !== 0)
|
|
64
|
+
return skills;
|
|
65
|
+
const agentsFile = seedAgentsFile(target, cfg.stacks?.length ? cfg.stacks : detectStacks(target));
|
|
66
|
+
return {
|
|
67
|
+
code: 0,
|
|
68
|
+
stdout: `✓ Initialized kanban-flow in ${target}\n .works/{${STAGES.join(",")}}\n docs/{requirement,use-cases,testplan}/${ctx}\n .kf/templates (project overrides)\n .kf/hooks (phase hooks, e.g. hooks/planning.sh)\n .kf/review/rules (project review rules)\n AGENTS.md: ${agentsFile === "created" ? "created" : "kept existing AGENTS.md/CLAUDE.md"}\n\n${skills.stdout}`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
async function cmdBootstrap(args, target, interactive) {
|
|
72
|
+
const tty = process.stdin.isTTY && process.stdout.isTTY;
|
|
73
|
+
const explicitContext = typeof args.options.context === "string" ? args.options.context : undefined;
|
|
74
|
+
let answers;
|
|
75
|
+
if (interactive && !tty) {
|
|
76
|
+
answers = bootstrapDefaults(target, explicitContext);
|
|
77
|
+
}
|
|
78
|
+
else if (interactive) {
|
|
79
|
+
answers = await onboardAnswers(target, explicitContext);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
answers = bootstrapDefaults(target, explicitContext);
|
|
83
|
+
}
|
|
84
|
+
const flagged = parseAgentIds(args.options.agent);
|
|
85
|
+
if (flagged.length > 0)
|
|
86
|
+
answers.agents = flagged;
|
|
87
|
+
// Name validity lives in saveConfig, the one point every set of answers passes through;
|
|
88
|
+
// repeating it here would be a guard that can never fire.
|
|
89
|
+
ensureWorksStructure(target);
|
|
90
|
+
await seedOverrides(target);
|
|
91
|
+
saveConfig(target, answers);
|
|
92
|
+
const skills = await installProjectSkills(target, answers.agents);
|
|
93
|
+
if (skills.code !== 0)
|
|
94
|
+
return skills;
|
|
95
|
+
for (const docsDir of ["requirement", "use-cases", "testplan"]) {
|
|
96
|
+
await mkdir(join(target, "docs", docsDir, answers.defaultContext), { recursive: true });
|
|
97
|
+
}
|
|
98
|
+
const out = [];
|
|
99
|
+
out.push(`✓ Bootstrapped kanban-flow in ${target}${interactive && !tty ? " (non-interactive: using defaults)" : ""}`);
|
|
100
|
+
out.push(` .works/{${STAGES.join(",")}}`);
|
|
101
|
+
const ctxLine = answers.contexts.length > 0 ? `contexts: ${answers.contexts.join(", ")}` : `context: ${answers.defaultContext} (not restricted)`;
|
|
102
|
+
out.push(` ${ctxLine} stacks: ${answers.stacks.join(", ") || "unset"} reviewer: ${answers.reviewer}`);
|
|
103
|
+
out.push(` agents: ${answers.agents.join(", ")}`);
|
|
104
|
+
out.push(` .kf/config.json (defaults for new features)`);
|
|
105
|
+
out.push(` .kf/{templates,hooks,review/rules} seeded from package`);
|
|
106
|
+
out.push(skills.stdout);
|
|
107
|
+
out.push(` AGENTS.md: ${seedAgentsFile(target, answers.stacks) === "created" ? "created" : "kept existing AGENTS.md/CLAUDE.md"}`);
|
|
108
|
+
if (answers.ignoreWorks) {
|
|
109
|
+
appendIgnoreWorks(target);
|
|
110
|
+
out.push(` .gitignore: added .works/`);
|
|
111
|
+
}
|
|
112
|
+
if (answers.seedFeature) {
|
|
113
|
+
const seed = await cmdNew({ command: "new", positionals: ["demo"], options: { context: answers.defaultContext } }, target);
|
|
114
|
+
if (seed.code !== 0)
|
|
115
|
+
return seed;
|
|
116
|
+
out.push(` seeded demo feature → brainstorm`);
|
|
117
|
+
}
|
|
118
|
+
return { code: 0, stdout: out.join("\n") };
|
|
119
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { ARTIFACTS } from "../../workflow/schema.js";
|
|
4
|
+
import { findFeature, listFeatures } from "../../workflow/features.js";
|
|
5
|
+
import { computeStatus, renderStatusText, statusToJson } from "../../workflow/status.js";
|
|
6
|
+
import { validateFeature, renderValidateText, validateToJson } from "../../workflow/validate.js";
|
|
7
|
+
import { dashboardData } from "../../dashboard/dashboard.js";
|
|
8
|
+
import { readProjectConfig } from "../../project/config.js";
|
|
9
|
+
import { findRoot } from "./helpers.js";
|
|
10
|
+
export async function cmdList(args, cwd) {
|
|
11
|
+
const root = await findRoot(cwd);
|
|
12
|
+
if (!root.ok)
|
|
13
|
+
return { code: 1, stdout: root.err, stderr: "no works" };
|
|
14
|
+
const features = listFeatures(root.root);
|
|
15
|
+
if (args.options.json) {
|
|
16
|
+
return {
|
|
17
|
+
code: 0,
|
|
18
|
+
stdout: JSON.stringify(features.map((f) => ({
|
|
19
|
+
name: f.name,
|
|
20
|
+
kind: f.meta?.kind ?? "feature",
|
|
21
|
+
context: f.context,
|
|
22
|
+
stage: f.stage,
|
|
23
|
+
folder: f.folder,
|
|
24
|
+
})), null, 2),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
if (features.length === 0) {
|
|
28
|
+
return { code: 0, stdout: "No features found." };
|
|
29
|
+
}
|
|
30
|
+
const lines = features.map((f) => `${f.stage.padEnd(14)} ${(f.meta?.kind === "bug" ? "[bug] " : "") + f.name}${f.context ? ` (${f.context})` : ""}`);
|
|
31
|
+
return { code: 0, stdout: lines.join("\n") };
|
|
32
|
+
}
|
|
33
|
+
export async function cmdShow(args, cwd) {
|
|
34
|
+
const name = args.positionals[0];
|
|
35
|
+
if (!name)
|
|
36
|
+
return { code: 1, stdout: "Missing feature name.", stderr: "missing feature" };
|
|
37
|
+
const root = await findRoot(cwd);
|
|
38
|
+
if (!root.ok)
|
|
39
|
+
return { code: 1, stdout: root.err, stderr: "no works" };
|
|
40
|
+
const f = findFeature(root.root, name);
|
|
41
|
+
if (!f)
|
|
42
|
+
return { code: 1, stdout: `Unknown feature '${name}'. Run: kf list`, stderr: "unknown feature" };
|
|
43
|
+
const specPath = join(f.dir, ARTIFACTS["spec-requirement"].file);
|
|
44
|
+
let content;
|
|
45
|
+
try {
|
|
46
|
+
content = await readFile(specPath, "utf8");
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
if (!(err instanceof Error && "code" in err && err.code === "ENOENT"))
|
|
50
|
+
throw err;
|
|
51
|
+
content = `(no ${ARTIFACTS["spec-requirement"].file} in ${f.dir})`;
|
|
52
|
+
}
|
|
53
|
+
if (args.options.json) {
|
|
54
|
+
return { code: 0, stdout: JSON.stringify({ feature: f.name, kind: f.meta?.kind ?? "feature", context: f.context, stage: f.stage, spec: content }) };
|
|
55
|
+
}
|
|
56
|
+
return { code: 0, stdout: content };
|
|
57
|
+
}
|
|
58
|
+
export async function cmdView(args, cwd) {
|
|
59
|
+
const root = await findRoot(cwd);
|
|
60
|
+
if (!root.ok)
|
|
61
|
+
return { code: 1, stdout: root.err, stderr: "no works" };
|
|
62
|
+
const data = dashboardData(root.root);
|
|
63
|
+
if (args.options.json)
|
|
64
|
+
return { code: 0, stdout: JSON.stringify(data, null, 2) };
|
|
65
|
+
const m = data.metrics;
|
|
66
|
+
const lines = [
|
|
67
|
+
"kanban-flow analytics", "",
|
|
68
|
+
`Total: ${m.total} (${m.features} features, ${m.bugs} bugs)`,
|
|
69
|
+
`Executing: ${m.executing} Backlog: ${m.backlog} Completed: ${m.completed} Cancelled: ${m.cancelled} Bypassed: ${m.bypassed}`,
|
|
70
|
+
`Completion rate: ${m.completionRate === null ? "N/A" : m.completionRate + "%"} (cancelled work excluded)`,
|
|
71
|
+
`Executing tasks: ${m.tasks.done}/${m.tasks.total} (${m.tasks.completionRate === null ? "N/A" : m.tasks.completionRate + "%"})`,
|
|
72
|
+
...(Object.keys(m.runs.byRole).length > 0
|
|
73
|
+
? ["", "Worker runs by role:", ...Object.entries(m.runs.byRole).map(([role, r]) => ` ${role.padEnd(12)} ${r.runs} runs (${r.done} done, ${r.failed} failed)${m.runs.usage[role] ? ` ${m.runs.usage[role].input} in / ${m.runs.usage[role].output} out` : ""}`)]
|
|
74
|
+
: []),
|
|
75
|
+
"", "By stage:",
|
|
76
|
+
...data.charts.byStage.map((row) => ` ${row.id.padEnd(14)} ${row.count} (${row.features} features, ${row.bugs} bugs)`),
|
|
77
|
+
"", "By context:",
|
|
78
|
+
...data.charts.byContext.map((row) => ` ${row.label}: ${row.count} (${row.features} features, ${row.bugs} bugs)`),
|
|
79
|
+
"", "Approval (planning through review):",
|
|
80
|
+
...data.charts.approvals.map((row) => ` ${row.id}: ${row.count}`),
|
|
81
|
+
];
|
|
82
|
+
return { code: 0, stdout: lines.join("\n") };
|
|
83
|
+
}
|
|
84
|
+
export async function cmdStatus(args, cwd) {
|
|
85
|
+
const root = await findRoot(cwd);
|
|
86
|
+
if (!root.ok)
|
|
87
|
+
return { code: 1, stdout: root.err, stderr: "no works" };
|
|
88
|
+
const json = Boolean(args.options.json);
|
|
89
|
+
const all = Boolean(args.options.all);
|
|
90
|
+
const change = typeof args.options.change === "string" ? args.options.change : null;
|
|
91
|
+
if (!change && !all) {
|
|
92
|
+
return { code: 1, stdout: 'Missing required option --change <feature> (or --all for every feature).\nRun: kf list', stderr: "missing --change" };
|
|
93
|
+
}
|
|
94
|
+
const features = change
|
|
95
|
+
? (() => { const f = findFeature(root.root, change); return f ? [f] : []; })()
|
|
96
|
+
: listFeatures(root.root);
|
|
97
|
+
if (change && features.length === 0) {
|
|
98
|
+
return { code: 1, stdout: `Unknown feature '${change}'. Run: kf list`, stderr: "unknown feature" };
|
|
99
|
+
}
|
|
100
|
+
const harness = readProjectConfig(root.root).harness;
|
|
101
|
+
const statuses = features.map((f) => computeStatus(f, harness));
|
|
102
|
+
if (json) {
|
|
103
|
+
return {
|
|
104
|
+
code: 0,
|
|
105
|
+
stdout: JSON.stringify(change
|
|
106
|
+
? statusToJson(statuses[0])
|
|
107
|
+
: { features: statuses.map(statusToJson) }, null, 2),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
if (statuses.length === 0) {
|
|
111
|
+
return { code: 0, stdout: "No active features. Create one with: kf new <feature> --context <ctx>" };
|
|
112
|
+
}
|
|
113
|
+
const blocks = statuses.map(renderStatusText);
|
|
114
|
+
return { code: 0, stdout: blocks.join("\n\n") };
|
|
115
|
+
}
|
|
116
|
+
export async function cmdValidate(args, cwd) {
|
|
117
|
+
const root = await findRoot(cwd);
|
|
118
|
+
if (!root.ok)
|
|
119
|
+
return { code: 1, stdout: root.err, stderr: "no works" };
|
|
120
|
+
const json = Boolean(args.options.json);
|
|
121
|
+
const strict = Boolean(args.options.strict);
|
|
122
|
+
const all = Boolean(args.options.all);
|
|
123
|
+
const change = typeof args.options.change === "string" ? args.options.change : null;
|
|
124
|
+
if (!change && !all) {
|
|
125
|
+
return { code: 1, stdout: 'Missing required option --change <feature> (or --all).', stderr: "missing --change" };
|
|
126
|
+
}
|
|
127
|
+
const features = change
|
|
128
|
+
? (() => { const f = findFeature(root.root, change); return f ? [f] : []; })()
|
|
129
|
+
: listFeatures(root.root);
|
|
130
|
+
if (change && features.length === 0) {
|
|
131
|
+
return { code: 1, stdout: `Unknown feature '${change}'.`, stderr: "unknown feature" };
|
|
132
|
+
}
|
|
133
|
+
const results = features.map((f) => validateFeature(f, strict));
|
|
134
|
+
const allValid = results.every((r) => r.valid);
|
|
135
|
+
if (json) {
|
|
136
|
+
return { code: allValid ? 0 : 1, stdout: JSON.stringify(results.map(validateToJson), null, 2), stderr: allValid ? undefined : "validation failed" };
|
|
137
|
+
}
|
|
138
|
+
const blocks = results.map(renderValidateText);
|
|
139
|
+
const codes = blocks.join("\n") + `\nTotals: ${results.filter((r) => r.valid).length} passed, ${results.filter((r) => !r.valid).length} failed (${results.length} items)`;
|
|
140
|
+
return { code: allValid ? 0 : 1, stdout: codes, stderr: allValid ? undefined : "validation failed" };
|
|
141
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { mkdir, writeFile, rm } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { ARTIFACTS } from "../../workflow/schema.js";
|
|
4
|
+
import { assertPathName, listFeatures, featureFolderName, writeFeatureMeta, stageDir } from "../../workflow/features.js";
|
|
5
|
+
import { readProjectConfig } from "../../project/config.js";
|
|
6
|
+
import { checkContext, contextRefusal, declaredDefaultContext, effectiveDefaultContext } from "../../project/contexts.js";
|
|
7
|
+
import { readTemplate } from "../../shared/paths.js";
|
|
8
|
+
import { nowTimestamp } from "../../shared/time.js";
|
|
9
|
+
import { runHook } from "../../integrations/hooks.js";
|
|
10
|
+
import { findRoot } from "./helpers.js";
|
|
11
|
+
export async function cmdNew(args, cwd) {
|
|
12
|
+
const feature = args.positionals[0];
|
|
13
|
+
if (!feature) {
|
|
14
|
+
return { code: 1, stdout: "Missing feature name. Usage: kf new <feature> --context <ctx>", stderr: "missing feature" };
|
|
15
|
+
}
|
|
16
|
+
assertPathName(feature, "feature");
|
|
17
|
+
const kindRaw = args.options.type;
|
|
18
|
+
const kindValue = kindRaw === undefined ? "feature" : String(kindRaw);
|
|
19
|
+
if (kindValue !== "feature" && kindValue !== "bug") {
|
|
20
|
+
return { code: 1, stdout: `Unknown work item type '${kindValue}'. Expected feature or bug.`, stderr: "invalid type" };
|
|
21
|
+
}
|
|
22
|
+
const kind = kindValue;
|
|
23
|
+
const root = await findRoot(cwd);
|
|
24
|
+
if (!root.ok)
|
|
25
|
+
return { code: 1, stdout: root.err, stderr: "no works" };
|
|
26
|
+
if (listFeatures(root.root).some((f) => f.name === feature)) {
|
|
27
|
+
return { code: 1, stdout: `Feature '${feature}' already exists. Select it with kf status --change ${feature}.`, stderr: "feature exists" };
|
|
28
|
+
}
|
|
29
|
+
const cfg = readProjectConfig(root.root);
|
|
30
|
+
// The config's own answer wins; only when it states none do we guess from existing work items.
|
|
31
|
+
const ctx = typeof args.options.context === "string"
|
|
32
|
+
? args.options.context
|
|
33
|
+
: declaredDefaultContext(cfg) ?? listFeatures(root.root).find((f) => f.context)?.context ?? effectiveDefaultContext(cfg);
|
|
34
|
+
assertPathName(ctx, "context");
|
|
35
|
+
// Only bites once the project has declared a list; an undeclared project behaves exactly as before.
|
|
36
|
+
const check = checkContext(ctx, cfg);
|
|
37
|
+
if (!check.ok)
|
|
38
|
+
return { code: 1, stdout: contextRefusal(ctx, check), stderr: "unknown context" };
|
|
39
|
+
const requirementTemplate = kind === "bug" ? "phase-1-bug-report.md" : ARTIFACTS["spec-requirement"].template;
|
|
40
|
+
const tpl = await readTemplate(root.root, requirementTemplate);
|
|
41
|
+
if (!tpl)
|
|
42
|
+
return { code: 1, stdout: "Requirement template not found.", stderr: "no template" };
|
|
43
|
+
const ts = nowTimestamp();
|
|
44
|
+
const dir = join(stageDir(root.root, "brainstorm"), featureFolderName(feature, ts));
|
|
45
|
+
await mkdir(dirname(dir), { recursive: true });
|
|
46
|
+
try {
|
|
47
|
+
await mkdir(dir);
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
if (err instanceof Error && "code" in err && err.code === "EEXIST") {
|
|
51
|
+
return { code: 1, stdout: `Feature folder already exists: ${dir}`, stderr: "feature exists" };
|
|
52
|
+
}
|
|
53
|
+
throw err;
|
|
54
|
+
}
|
|
55
|
+
await writeFeatureMeta(dir, {
|
|
56
|
+
schema: "kanban-flow",
|
|
57
|
+
feature,
|
|
58
|
+
context: ctx,
|
|
59
|
+
created: ts,
|
|
60
|
+
kind,
|
|
61
|
+
goal: typeof args.options.goal === "string" ? args.options.goal : undefined,
|
|
62
|
+
});
|
|
63
|
+
const rendered = tpl
|
|
64
|
+
.replaceAll("{feature_name}", feature)
|
|
65
|
+
.replaceAll("{Feature Name}", feature.split("-").map((w) => w[0]?.toUpperCase() + w.slice(1)).join(" "))
|
|
66
|
+
.replaceAll("{context}", ctx)
|
|
67
|
+
.replaceAll("{timestamp}", ts);
|
|
68
|
+
await writeFile(join(dir, ARTIFACTS["spec-requirement"].file), rendered, "utf8");
|
|
69
|
+
const hook = runHook(root.root, {
|
|
70
|
+
feature, context: ctx, dir, root: root.root, from: null, to: "brainstorm", approval: "pending",
|
|
71
|
+
});
|
|
72
|
+
if (hook.ran && !hook.ok) {
|
|
73
|
+
await rm(dir, { recursive: true });
|
|
74
|
+
return { code: 1, stdout: `Brainstorm hook failed (exit ${hook.code}). Feature creation rolled back.\n${hook.output}`, stderr: "hook failed" };
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
code: 0,
|
|
78
|
+
stdout: `Created ${kind} '${feature}' at ${dir}/\nContext: ${ctx}\nNext: kf instruct spec-requirement --change "${feature}"`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { copyFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { findWorksRoot } from "../../workflow/features.js";
|
|
5
|
+
import { detectStacks, STACK_IDS } from "../../project/config.js";
|
|
6
|
+
import { PKG_STACK_RULES_DIR } from "../../shared/paths.js";
|
|
7
|
+
/** Copy stack-specific review rules from the package into .kf/review/rules/. */
|
|
8
|
+
export async function cmdRules(args, cwd) {
|
|
9
|
+
const root = findWorksRoot(cwd) ?? cwd;
|
|
10
|
+
const detected = detectStacks(root);
|
|
11
|
+
const available = STACK_IDS.filter((id) => existsSync(join(PKG_STACK_RULES_DIR, `${id}.md`)));
|
|
12
|
+
if (args.options.list) {
|
|
13
|
+
return {
|
|
14
|
+
code: 0,
|
|
15
|
+
stdout: [
|
|
16
|
+
`Detected stacks: ${detected.join(", ") || "none"}`,
|
|
17
|
+
`Available rule packs: ${available.join(", ") || "none"}`,
|
|
18
|
+
].join("\n"),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
const requested = args.options.stack;
|
|
22
|
+
const stacks = Array.isArray(requested)
|
|
23
|
+
? requested.map(String)
|
|
24
|
+
: typeof requested === "string"
|
|
25
|
+
? [requested]
|
|
26
|
+
: detected;
|
|
27
|
+
if (stacks.length === 0) {
|
|
28
|
+
return {
|
|
29
|
+
code: 1,
|
|
30
|
+
stdout: `Cannot detect project stack. Pass --stack <id>. Available: ${available.join(", ")}`,
|
|
31
|
+
stderr: "no stack detected",
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
const invalid = stacks.filter((s) => !available.includes(s));
|
|
35
|
+
if (invalid.length > 0) {
|
|
36
|
+
return {
|
|
37
|
+
code: 1,
|
|
38
|
+
stdout: `Unknown stack '${invalid.join(", ")}'. Available: ${available.join(", ")}`,
|
|
39
|
+
stderr: "unknown stack",
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const force = Boolean(args.options.force);
|
|
43
|
+
const rulesDir = join(root, ".kf", "review", "rules");
|
|
44
|
+
const lines = [];
|
|
45
|
+
for (const stack of stacks) {
|
|
46
|
+
const src = join(PKG_STACK_RULES_DIR, `${stack}.md`);
|
|
47
|
+
const dest = join(rulesDir, `${stack}.md`);
|
|
48
|
+
if (existsSync(dest)) {
|
|
49
|
+
if (readFileSync(dest, "utf8") === readFileSync(src, "utf8")) {
|
|
50
|
+
lines.push(`· ${stack}: already installed`);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (!force) {
|
|
54
|
+
lines.push(`⚠ ${stack}: exists with local changes — skipped (use --force to overwrite)`);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
mkdirSync(rulesDir, { recursive: true });
|
|
58
|
+
await copyFile(src, dest);
|
|
59
|
+
lines.push(`✓ ${stack}: overwritten → ${dest}`);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
mkdirSync(rulesDir, { recursive: true });
|
|
63
|
+
await copyFile(src, dest);
|
|
64
|
+
lines.push(`✓ ${stack}: installed → ${dest}`);
|
|
65
|
+
}
|
|
66
|
+
lines.push("");
|
|
67
|
+
lines.push("kanban-review loads these rules automatically (project → user → package).");
|
|
68
|
+
return { code: 0, stdout: lines.join("\n") };
|
|
69
|
+
}
|