@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 { rename, rm } from "node:fs/promises";
|
|
2
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { ARTIFACTS, METADATA_FILE } from "../../workflow/schema.js";
|
|
5
|
+
import { findFeature, stageDir, assertPathName, writeFeatureMeta } from "../../workflow/features.js";
|
|
6
|
+
import { validateFeature, checkDirectionGate, renderValidateText } from "../../workflow/validate.js";
|
|
7
|
+
import { splitFrontmatter, applyFrontmatter } from "../../shared/frontmatter.js";
|
|
8
|
+
import { writeFileAtomic } from "../../shared/paths.js";
|
|
9
|
+
import { runHook, resolveHook } from "../../integrations/hooks.js";
|
|
10
|
+
import { findRoot, recordBypasses, bypassNote } from "./helpers.js";
|
|
11
|
+
function archivedDocument(raw) {
|
|
12
|
+
const { fm, body } = splitFrontmatter(raw);
|
|
13
|
+
if (Object.keys(fm).length === 0)
|
|
14
|
+
return raw;
|
|
15
|
+
fm.status = "archived";
|
|
16
|
+
return applyFrontmatter(fm, body);
|
|
17
|
+
}
|
|
18
|
+
/** Canonical doc paths a feature owns, whether or not they exist on disk. */
|
|
19
|
+
export function canonicalDocPaths(root, feature) {
|
|
20
|
+
if (!feature.context || feature.meta?.kind === "bug")
|
|
21
|
+
return [];
|
|
22
|
+
const base = join(root, "docs");
|
|
23
|
+
const { context, name } = { context: feature.context, name: feature.name };
|
|
24
|
+
const useCaseDir = join(base, "use-cases", context, name);
|
|
25
|
+
const files = [
|
|
26
|
+
join(base, "requirement", context, `${name}.md`),
|
|
27
|
+
join(base, "testplan", context, `${name}.md`),
|
|
28
|
+
join(base, "testplan", context, `${name}-result.md`),
|
|
29
|
+
];
|
|
30
|
+
if (existsSync(useCaseDir))
|
|
31
|
+
files.push(useCaseDir);
|
|
32
|
+
return files.filter((p) => existsSync(p));
|
|
33
|
+
}
|
|
34
|
+
function prepareCanonicalCopies(root, feature) {
|
|
35
|
+
if (!feature.context)
|
|
36
|
+
return [];
|
|
37
|
+
if (feature.meta?.kind === "bug")
|
|
38
|
+
return [];
|
|
39
|
+
assertPathName(feature.context, "context");
|
|
40
|
+
assertPathName(feature.name, "feature");
|
|
41
|
+
const context = feature.context;
|
|
42
|
+
const base = join(root, "docs");
|
|
43
|
+
const mappings = [
|
|
44
|
+
[ARTIFACTS["spec-requirement"].file, join(base, "requirement", context, `${feature.name}.md`), true],
|
|
45
|
+
[ARTIFACTS["use-case-specification"].file, join(base, "use-cases", context, feature.name, "README.md"), false],
|
|
46
|
+
[ARTIFACTS["use-case-diagram"].file, join(base, "use-cases", context, feature.name, "diagram.md"), false],
|
|
47
|
+
[ARTIFACTS["test-cases"].file, join(base, "testplan", context, `${feature.name}.md`), false],
|
|
48
|
+
[ARTIFACTS["testing-result"].file, join(base, "testplan", context, `${feature.name}-result.md`), false],
|
|
49
|
+
];
|
|
50
|
+
const copies = mappings.map(([sourceFile, destination, archiveStatus]) => {
|
|
51
|
+
const source = join(feature.dir, sourceFile);
|
|
52
|
+
if (!existsSync(source))
|
|
53
|
+
throw new Error(`Cannot sync canonical docs: missing source artifact ${source}`);
|
|
54
|
+
const raw = readFileSync(source, "utf8");
|
|
55
|
+
const content = sourceFile === ARTIFACTS["use-case-specification"].file
|
|
56
|
+
? raw.replace(/use-cases\/(UC-\d+\.md)/gi, "$1")
|
|
57
|
+
: archiveStatus ? archivedDocument(raw) : raw;
|
|
58
|
+
return {
|
|
59
|
+
destination,
|
|
60
|
+
content,
|
|
61
|
+
original: existsSync(destination) ? readFileSync(destination, "utf8") : null,
|
|
62
|
+
};
|
|
63
|
+
});
|
|
64
|
+
const useCaseSourceDir = join(feature.dir, "use-cases");
|
|
65
|
+
if (existsSync(useCaseSourceDir)) {
|
|
66
|
+
for (const file of readdirSync(useCaseSourceDir).filter((entry) => /^UC-\d+\.md$/i.test(entry))) {
|
|
67
|
+
const source = join(useCaseSourceDir, file);
|
|
68
|
+
const destination = join(base, "use-cases", context, feature.name, file);
|
|
69
|
+
copies.push({
|
|
70
|
+
destination,
|
|
71
|
+
content: readFileSync(source, "utf8"),
|
|
72
|
+
original: existsSync(destination) ? readFileSync(destination, "utf8") : null,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const legacy = join(base, "use-cases", context, `${feature.name}.md`);
|
|
77
|
+
// Older versions stored requirement mirrors under docs/use-cases.
|
|
78
|
+
if (existsSync(legacy) && !copies.some((copy) => copy.destination === legacy)) {
|
|
79
|
+
const raw = readFileSync(legacy, "utf8");
|
|
80
|
+
copies.push({ destination: legacy, content: archivedDocument(raw), original: raw });
|
|
81
|
+
}
|
|
82
|
+
return copies;
|
|
83
|
+
}
|
|
84
|
+
async function writeCanonicalCopies(copies) {
|
|
85
|
+
for (const copy of copies)
|
|
86
|
+
await writeFileAtomic(copy.destination, copy.content);
|
|
87
|
+
}
|
|
88
|
+
async function rollbackCanonicalCopies(copies) {
|
|
89
|
+
for (const copy of copies) {
|
|
90
|
+
if (copy.original === null)
|
|
91
|
+
await rm(copy.destination, { force: true });
|
|
92
|
+
else
|
|
93
|
+
await writeFileAtomic(copy.destination, copy.original);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export async function cmdArchive(args, cwd) {
|
|
97
|
+
const name = args.positionals[0];
|
|
98
|
+
if (!name)
|
|
99
|
+
return { code: 1, stdout: "Usage: kf archive <feature>", stderr: "missing feature" };
|
|
100
|
+
const root = await findRoot(cwd);
|
|
101
|
+
if (!root.ok)
|
|
102
|
+
return { code: 1, stdout: root.err, stderr: "no works" };
|
|
103
|
+
const f = findFeature(root.root, name);
|
|
104
|
+
if (!f)
|
|
105
|
+
return { code: 1, stdout: `Unknown feature '${name}'. Run: kf list`, stderr: "unknown feature" };
|
|
106
|
+
if (f.stage === "cancelled") {
|
|
107
|
+
return { code: 1, stdout: `'${name}' was cancelled (${f.meta?.cancellation?.reason ?? "no reason recorded"}). Move it back to ${f.meta?.cancellation?.fromStage ?? "its stage"} first if you want to finish it.`, stderr: "cancelled" };
|
|
108
|
+
}
|
|
109
|
+
// Only review → dones transition is archive-able. If already dones, refresh canonical docs idempotently.
|
|
110
|
+
if (f.stage === "dones") {
|
|
111
|
+
const check = validateFeature(f);
|
|
112
|
+
if (!check.valid && !args.options.force)
|
|
113
|
+
return { code: 1, stdout: renderValidateText(check), stderr: "gate failed" };
|
|
114
|
+
const copies = args.options["skip-specs"] ? [] : prepareCanonicalCopies(root.root, f);
|
|
115
|
+
const changedDocs = copies.filter((copy) => copy.original !== null && copy.original !== copy.content);
|
|
116
|
+
if (changedDocs.length > 0 && !args.options.force) {
|
|
117
|
+
return {
|
|
118
|
+
code: 1,
|
|
119
|
+
stdout: `Canonical docs have changed since archive:\n${changedDocs.map((copy) => ` ${copy.destination}`).join("\n")}\nUse --skip-specs to preserve them, or --force only to intentionally restore the archived snapshot.`,
|
|
120
|
+
stderr: "canonical docs changed",
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const metaPath = join(f.dir, METADATA_FILE);
|
|
124
|
+
const originalMeta = f.meta && existsSync(metaPath) ? readFileSync(metaPath, "utf8") : null;
|
|
125
|
+
try {
|
|
126
|
+
await writeCanonicalCopies(copies);
|
|
127
|
+
if (f.meta)
|
|
128
|
+
await writeFeatureMeta(f.dir, { ...f.meta, status: "archived" });
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
const rollback = await Promise.allSettled([
|
|
132
|
+
rollbackCanonicalCopies(copies),
|
|
133
|
+
...(originalMeta === null ? [] : [writeFileAtomic(metaPath, originalMeta)]),
|
|
134
|
+
]);
|
|
135
|
+
const failures = rollback.filter((r) => r.status === "rejected").map((r) => r.reason);
|
|
136
|
+
if (failures.length > 0)
|
|
137
|
+
throw new AggregateError([err, ...failures], `Canonical docs sync failed and rollback was incomplete for '${name}'.`);
|
|
138
|
+
throw new Error(`Canonical docs sync failed for '${name}'; feature remains in dones.`, { cause: err });
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
code: 0,
|
|
142
|
+
stdout: `Feature '${name}' is already in dones.\n${copies.length > 0 ? `Canonical docs synced:\n${copies.map((copy) => ` ${copy.destination}`).join("\n")}` : args.options["skip-specs"] ? "(--skip-specs: canonical docs not touched)" : "(no canonical docs synced)"}`,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
if (f.stage !== "review") {
|
|
146
|
+
return {
|
|
147
|
+
code: 1,
|
|
148
|
+
stdout: `Cannot archive '${name}': feature is in stage "${f.stage}". Archive only from review (run: kf stage ${name} review first).`,
|
|
149
|
+
stderr: "wrong stage",
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
const force = Boolean(args.options.force);
|
|
153
|
+
const forcedCodes = [];
|
|
154
|
+
const check = validateFeature({ ...f, stage: "dones" });
|
|
155
|
+
if (!check.valid) {
|
|
156
|
+
if (!force) {
|
|
157
|
+
return {
|
|
158
|
+
code: 1,
|
|
159
|
+
stdout: `Gate failed for '${name}'. Fix validation before archiving:\n\n${renderValidateText(check)}\n\nOr re-run with --force.`,
|
|
160
|
+
stderr: "gate failed",
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
forcedCodes.push(...check.issues.filter((i) => i.severity === "ERROR").map((i) => i.code));
|
|
164
|
+
}
|
|
165
|
+
// Directional gate: only a PASS review-report may enter dones.
|
|
166
|
+
const direction = checkDirectionGate(f, "dones");
|
|
167
|
+
if (direction.length > 0) {
|
|
168
|
+
if (!force) {
|
|
169
|
+
const lines = direction.map((i) => ` [${i.severity}] ${i.file}: ${i.message} (${i.code})`).join("\n");
|
|
170
|
+
return {
|
|
171
|
+
code: 1,
|
|
172
|
+
stdout: `Cannot archive '${name}' — review result does not allow dones:\n\n${lines}\n\nOr re-run with --force.`,
|
|
173
|
+
stderr: "direction gate failed",
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
forcedCodes.push(...direction.map((i) => i.code));
|
|
177
|
+
}
|
|
178
|
+
const dest = stageDir(root.root, "dones");
|
|
179
|
+
const target = join(dest, f.folder);
|
|
180
|
+
if (existsSync(target)) {
|
|
181
|
+
return { code: 1, stdout: `Target already exists: ${target}`, stderr: "target exists" };
|
|
182
|
+
}
|
|
183
|
+
// Phase hook: run the 'dones' hook before archiving (unless --skip-hooks).
|
|
184
|
+
const skipHooks = Boolean(args.options["skip-hooks"]);
|
|
185
|
+
const skippedHook = skipHooks ? resolveHook(root.root, "dones") : null;
|
|
186
|
+
if (!skipHooks) {
|
|
187
|
+
const hook = runHook(root.root, {
|
|
188
|
+
feature: f.name,
|
|
189
|
+
context: f.context,
|
|
190
|
+
dir: f.dir,
|
|
191
|
+
root: root.root,
|
|
192
|
+
from: f.stage,
|
|
193
|
+
to: "dones",
|
|
194
|
+
approval: f.meta?.approval?.status ?? "pending",
|
|
195
|
+
});
|
|
196
|
+
if (hook.ran && !hook.ok) {
|
|
197
|
+
return {
|
|
198
|
+
code: 1,
|
|
199
|
+
stdout: `Hook 'dones' failed (exit ${hook.code})${hook.hook ? ` [${hook.hook.path}]` : ""}.\nArchive refused.\n\n${hook.output}\n\nRe-run with --skip-hooks to bypass.`,
|
|
200
|
+
stderr: "hook failed",
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const afterHook = validateFeature({ ...f, stage: "dones" });
|
|
205
|
+
if (!afterHook.valid && !force)
|
|
206
|
+
return { code: 1, stdout: renderValidateText(afterHook), stderr: "gate failed" };
|
|
207
|
+
if (!f.meta)
|
|
208
|
+
return { code: 1, stdout: "Feature metadata is missing.", stderr: "metadata missing" };
|
|
209
|
+
const recorded = recordBypasses(f.stage, "dones", forcedCodes, skippedHook);
|
|
210
|
+
const bypasses = recorded.length > 0 ? [...(f.meta.bypasses ?? []), ...recorded] : f.meta.bypasses;
|
|
211
|
+
if (f.context)
|
|
212
|
+
assertPathName(f.context, "context");
|
|
213
|
+
assertPathName(f.name, "feature");
|
|
214
|
+
const copies = args.options["skip-specs"] ? [] : prepareCanonicalCopies(root.root, f);
|
|
215
|
+
const originalMeta = readFileSync(join(f.dir, METADATA_FILE), "utf8");
|
|
216
|
+
let moved = false;
|
|
217
|
+
try {
|
|
218
|
+
await rename(f.dir, target);
|
|
219
|
+
moved = true;
|
|
220
|
+
await writeCanonicalCopies(copies);
|
|
221
|
+
await writeFeatureMeta(target, { ...f.meta, bypasses, status: "archived" });
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
if (!moved)
|
|
225
|
+
throw err;
|
|
226
|
+
const rollback = await Promise.allSettled([
|
|
227
|
+
writeFileAtomic(join(target, METADATA_FILE), originalMeta),
|
|
228
|
+
rollbackCanonicalCopies(copies),
|
|
229
|
+
]);
|
|
230
|
+
const failures = rollback.filter((r) => r.status === "rejected").map((r) => r.reason);
|
|
231
|
+
try {
|
|
232
|
+
await rename(target, f.dir);
|
|
233
|
+
}
|
|
234
|
+
catch (rollbackError) {
|
|
235
|
+
failures.push(rollbackError);
|
|
236
|
+
}
|
|
237
|
+
if (failures.length > 0)
|
|
238
|
+
throw new AggregateError([err, ...failures], `Archive failed and rollback was incomplete for '${name}'.`);
|
|
239
|
+
throw new Error(`Archive failed for '${name}'; feature restored to review.`, { cause: err });
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
code: 0,
|
|
243
|
+
stdout: `✓ Archived '${name}' review → dones\n ${target}\n${copies.length > 0 ? ` Canonical docs synced:\n${copies.map((copy) => ` ${copy.destination}`).join("\n")}` : args.options["skip-specs"] ? " (--skip-specs: canonical docs not touched)" : " (no canonical docs synced)"}${bypassNote(recorded)}`,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { ARTIFACTS } from "../../workflow/schema.js";
|
|
3
|
+
import { findWorksRoot, findFeature } from "../../workflow/features.js";
|
|
4
|
+
import { resolveTemplate, readTemplate } from "../../shared/paths.js";
|
|
5
|
+
import { nowTimestamp } from "../../shared/time.js";
|
|
6
|
+
export async function cmdInstruct(args, cwd) {
|
|
7
|
+
const artifact = args.positionals[0];
|
|
8
|
+
const change = typeof args.options.change === "string" ? args.options.change : null;
|
|
9
|
+
const useCase = artifact === "use-case";
|
|
10
|
+
const useCaseId = typeof args.options.id === "string" ? args.options.id.toUpperCase() : null;
|
|
11
|
+
if (!artifact) {
|
|
12
|
+
const known = Object.values(ARTIFACTS).map((a) => a.id).join(", ");
|
|
13
|
+
return { code: 1, stdout: `Missing artifact. Usage: kf instruct <artifact|use-case> --change <feature>\nArtifacts: ${known}, use-case`, stderr: "missing artifact" };
|
|
14
|
+
}
|
|
15
|
+
const def = Object.values(ARTIFACTS).find((a) => a.id === artifact);
|
|
16
|
+
if (!def && !useCase) {
|
|
17
|
+
const known = Object.values(ARTIFACTS).map((a) => a.id).join(", ");
|
|
18
|
+
return { code: 1, stdout: `Unknown artifact '${artifact}'. Artifacts: ${known}, use-case`, stderr: "unknown artifact" };
|
|
19
|
+
}
|
|
20
|
+
if (useCase && !useCaseId) {
|
|
21
|
+
return { code: 1, stdout: "Use case ID is required. Usage: kf instruct use-case --id UC-### [--change <feature>]", stderr: "missing use-case id" };
|
|
22
|
+
}
|
|
23
|
+
if (useCase && useCaseId && !/^UC-\d+$/.test(useCaseId)) {
|
|
24
|
+
return { code: 1, stdout: `Invalid use-case id '${useCaseId}'. Expected UC-###.`, stderr: "invalid use-case id" };
|
|
25
|
+
}
|
|
26
|
+
const root = findWorksRoot(cwd) ?? cwd;
|
|
27
|
+
const feature = change ? findFeature(root, change) : null;
|
|
28
|
+
if (change && !feature)
|
|
29
|
+
return { code: 1, stdout: `Unknown feature '${change}'.`, stderr: "unknown feature" };
|
|
30
|
+
const templateName = useCase ? "phase-2-use-case.md" : def.id === "spec-requirement" && feature?.meta?.kind === "bug"
|
|
31
|
+
? "phase-1-bug-report.md"
|
|
32
|
+
: def.template;
|
|
33
|
+
const raw = await readTemplate(root, templateName);
|
|
34
|
+
const tpl = raw
|
|
35
|
+
?.replaceAll("{feature_name}", feature?.name ?? "{feature_name}")
|
|
36
|
+
.replaceAll("{context}", feature?.context ?? "{context}")
|
|
37
|
+
.replaceAll("{timestamp}", nowTimestamp())
|
|
38
|
+
.replaceAll("{use_case_id}", useCaseId ?? "{use_case_id}")
|
|
39
|
+
.replaceAll("{execution_id}", feature?.meta?.executionId ?? "{execution_id}");
|
|
40
|
+
if (!tpl) {
|
|
41
|
+
return { code: 1, stdout: `Template '${templateName}' not found anywhere (project → ~/.kf → package).`, stderr: "no template" };
|
|
42
|
+
}
|
|
43
|
+
if (args.options.json) {
|
|
44
|
+
const file = useCase ? `${useCaseId ?? "UC-###"}.md` : def.file;
|
|
45
|
+
const outputPath = feature ? join(feature.dir, useCase ? "use-cases" : "", file) : null;
|
|
46
|
+
return {
|
|
47
|
+
code: 0,
|
|
48
|
+
stdout: JSON.stringify({
|
|
49
|
+
artifact: useCase ? "use-case" : def.id,
|
|
50
|
+
file,
|
|
51
|
+
templatePath: resolveTemplate(cwd, templateName)?.path,
|
|
52
|
+
instruction: `Write ${outputPath ?? file}. Fill all {placeholders}.`,
|
|
53
|
+
outputPath,
|
|
54
|
+
executionId: feature?.meta?.executionId ?? null,
|
|
55
|
+
unlocks: useCase ? "Planning contract — one UC-### per file" : def.unlocks,
|
|
56
|
+
template: tpl,
|
|
57
|
+
}, null, 2),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const lines = [];
|
|
61
|
+
lines.push(`<artifact id="${useCase ? "use-case" : def.id}" stage-phase="${useCase ? "plan" : def.phase}">`);
|
|
62
|
+
lines.push("");
|
|
63
|
+
const outputPath = useCase ? `${useCaseId ?? "UC-###"}.md` : def.file;
|
|
64
|
+
lines.push(`Write to: ${feature ? join(feature.dir, useCase ? "use-cases" : "", outputPath) : `<feature-folder>/${useCase ? "use-cases/" : ""}${outputPath}`}`);
|
|
65
|
+
lines.push(`Template (${resolveTemplate(cwd, templateName)?.source ?? "?"}): ${resolveTemplate(cwd, templateName)?.path}`);
|
|
66
|
+
lines.push("");
|
|
67
|
+
lines.push(`Unlocks: ${useCase ? "Planning contract — one UC-### per file" : def.unlocks}`);
|
|
68
|
+
if (change)
|
|
69
|
+
lines.push(`Feature: ${change}`);
|
|
70
|
+
lines.push("");
|
|
71
|
+
lines.push("--- template ---");
|
|
72
|
+
lines.push(tpl);
|
|
73
|
+
return { code: 0, stdout: lines.join("\n") };
|
|
74
|
+
}
|
|
75
|
+
export async function cmdTemplates(args, cwd) {
|
|
76
|
+
const rows = [
|
|
77
|
+
...Object.values(ARTIFACTS).map((a) => ({
|
|
78
|
+
artifact: a.id,
|
|
79
|
+
template: a.template,
|
|
80
|
+
path: resolveTemplate(cwd, a.template)?.path ?? null,
|
|
81
|
+
source: resolveTemplate(cwd, a.template)?.source ?? "missing",
|
|
82
|
+
})),
|
|
83
|
+
{
|
|
84
|
+
artifact: "spec-requirement (bug)",
|
|
85
|
+
template: "phase-1-bug-report.md",
|
|
86
|
+
path: resolveTemplate(cwd, "phase-1-bug-report.md")?.path ?? null,
|
|
87
|
+
source: resolveTemplate(cwd, "phase-1-bug-report.md")?.source ?? "missing",
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
artifact: "use-case",
|
|
91
|
+
template: "phase-2-use-case.md",
|
|
92
|
+
path: resolveTemplate(cwd, "phase-2-use-case.md")?.path ?? null,
|
|
93
|
+
source: resolveTemplate(cwd, "phase-2-use-case.md")?.source ?? "missing",
|
|
94
|
+
},
|
|
95
|
+
];
|
|
96
|
+
if (args.options.json)
|
|
97
|
+
return { code: 0, stdout: JSON.stringify(rows, null, 2) };
|
|
98
|
+
const lines = rows.map((r) => `${r.artifact.padEnd(16)} ${r.source.padEnd(8)} ${r.path}`);
|
|
99
|
+
return { code: 0, stdout: lines.join("\n") };
|
|
100
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
import { findWorksRoot } from "../../workflow/features.js";
|
|
4
|
+
import { commandHelp } from "../args.js";
|
|
5
|
+
import { effectiveDefaultContext } from "../../project/contexts.js";
|
|
6
|
+
import { readProjectConfig, detectStacks, configPath, projectKabanDir } from "../../project/config.js";
|
|
7
|
+
import { AGENTS, DEFAULT_AGENT, projectSkillsDir } from "../../integrations/agents.js";
|
|
8
|
+
import { MANAGED_SKILLS } from "../../integrations/install.js";
|
|
9
|
+
import { PKG_RULES_DIR, USER_KABAN_DIR, resolveRule } from "../../shared/paths.js";
|
|
10
|
+
function installedAgents(root) {
|
|
11
|
+
const has = (dir) => MANAGED_SKILLS.every((s) => existsSync(join(dir, s, "SKILL.md")));
|
|
12
|
+
return AGENTS.filter((a) => has(projectSkillsDir(a, root))).map((a) => a.id);
|
|
13
|
+
}
|
|
14
|
+
function checklist(root, stacks, agents, cfg) {
|
|
15
|
+
const items = [];
|
|
16
|
+
const kf = projectKabanDir(root);
|
|
17
|
+
items.push({
|
|
18
|
+
done: existsSync(configPath(root)),
|
|
19
|
+
label: "Project config (.kf/config.json)",
|
|
20
|
+
action: "kf init --defaults",
|
|
21
|
+
});
|
|
22
|
+
for (const id of agents) {
|
|
23
|
+
const a = AGENTS.find((x) => x.id === id);
|
|
24
|
+
if (!a)
|
|
25
|
+
continue;
|
|
26
|
+
items.push({
|
|
27
|
+
done: MANAGED_SKILLS.every((s) => existsSync(join(projectSkillsDir(a, root), s, "SKILL.md"))),
|
|
28
|
+
label: `Project skills for ${a.label} (${projectSkillsDir(a, root)}/)`,
|
|
29
|
+
action: `kf install --agent ${id}`,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
const rulesDir = join(kf, "review", "rules");
|
|
33
|
+
const hasBase = ["general.md", "security.md", "performance.md"].every((f) => existsSync(join(rulesDir, f)));
|
|
34
|
+
items.push({
|
|
35
|
+
done: hasBase,
|
|
36
|
+
label: "Base review rules (.kf/review/rules/)",
|
|
37
|
+
action: "kf init --defaults (seeds templates + rules)",
|
|
38
|
+
});
|
|
39
|
+
const missingStacks = stacks.filter((s) => !existsSync(join(rulesDir, `${s}.md`)));
|
|
40
|
+
items.push({
|
|
41
|
+
done: stacks.length > 0 && missingStacks.length === 0,
|
|
42
|
+
label: stacks.length === 0 ? "Stack rule packs (no stack detected)" : `Stack rule packs: ${stacks.join(", ")}`,
|
|
43
|
+
action: stacks.length === 0 ? "kf rules --stack <id> (see: kf rules --list)" : `kf rules${missingStacks.map((s) => ` --stack ${s}`).join("")}`,
|
|
44
|
+
});
|
|
45
|
+
const hasGit = existsSync(join(root, ".git"));
|
|
46
|
+
if (hasGit) {
|
|
47
|
+
const gi = join(root, ".gitignore");
|
|
48
|
+
const ignored = existsSync(gi) && readFileSync(gi, "utf8").split("\n").some((l) => l.trim().replace(/\/$/, "") === ".works");
|
|
49
|
+
items.push({
|
|
50
|
+
done: ignored,
|
|
51
|
+
label: ".works/ ignored in .gitignore",
|
|
52
|
+
action: "echo '.works/' >> .gitignore",
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
items.push({
|
|
56
|
+
done: existsSync(join(root, "AGENTS.md")) || existsSync(join(root, "CLAUDE.md")),
|
|
57
|
+
label: "AGENTS.md (build/test/lint commands, conventions) at root",
|
|
58
|
+
action: "create AGENTS.md documenting the commands and conventions below",
|
|
59
|
+
});
|
|
60
|
+
const declared = cfg.contexts ?? [];
|
|
61
|
+
items.push({
|
|
62
|
+
done: declared.length > 0,
|
|
63
|
+
label: declared.length > 0
|
|
64
|
+
? `Declared contexts: ${declared.join(", ")} (first is the default)`
|
|
65
|
+
: "Declared contexts (contexts in .kf/config.json) — kf new accepts any context until one is declared",
|
|
66
|
+
action: declared.length > 0 ? undefined : "kf contexts (prints a survey brief; the human confirms the list)",
|
|
67
|
+
});
|
|
68
|
+
const assigned = Object.keys(cfg.harness?.stages ?? {}).length;
|
|
69
|
+
items.push({
|
|
70
|
+
done: cfg.harness !== undefined,
|
|
71
|
+
label: cfg.harness ? `Multi-agent harness: main ${cfg.harness.main}, ${assigned} stage${assigned === 1 ? "" : "s"} assigned (kf harness)` : "Multi-agent harness (harness block in .kf/config.json)",
|
|
72
|
+
action: cfg.harness ? undefined : "kf init --defaults (seeds runner presets)",
|
|
73
|
+
});
|
|
74
|
+
const hooksDir = join(kf, "hooks");
|
|
75
|
+
const hooks = existsSync(hooksDir) ? readdirSync(hooksDir).filter((f) => f !== ".gitkeep").length : 0;
|
|
76
|
+
items.push({
|
|
77
|
+
done: hooks > 0,
|
|
78
|
+
label: "Phase hooks (.kf/hooks/) — optional",
|
|
79
|
+
action: "add shell hooks named after stages (e.g. testing) to run on kf stage",
|
|
80
|
+
});
|
|
81
|
+
return items;
|
|
82
|
+
}
|
|
83
|
+
function effectiveRules(cwd) {
|
|
84
|
+
const names = new Set();
|
|
85
|
+
const dirs = [PKG_RULES_DIR, join(USER_KABAN_DIR, "review", "rules"), join(projectKabanDir(findWorksRoot(cwd) ?? cwd), "review", "rules")];
|
|
86
|
+
for (const d of dirs) {
|
|
87
|
+
if (!existsSync(d))
|
|
88
|
+
continue;
|
|
89
|
+
for (const f of readdirSync(d)) {
|
|
90
|
+
if (f.endsWith(".md") && f !== "README.md")
|
|
91
|
+
names.add(f);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const rules = [];
|
|
95
|
+
for (const name of [...names].sort()) {
|
|
96
|
+
const resolved = resolveRule(cwd, name);
|
|
97
|
+
if (resolved)
|
|
98
|
+
rules.push({ name, path: resolved.path, source: resolved.source });
|
|
99
|
+
}
|
|
100
|
+
return rules;
|
|
101
|
+
}
|
|
102
|
+
/** Commands an agent drives the pipeline with, in the order they are used. */
|
|
103
|
+
export const AGENT_COMMANDS = ["new", "status", "instruct", "approve", "validate", "stage", "run", "runs", "harness", "archive", "rules", "install"];
|
|
104
|
+
/** The guide is generated from the registered help strings so it can never drift from the parser. */
|
|
105
|
+
export function workflowGuide() {
|
|
106
|
+
const commands = AGENT_COMMANDS.map((cmd) => {
|
|
107
|
+
const [usage, description = ""] = commandHelp(cmd).replace(/^Usage:\s*/, "").split(/\s+—\s+/);
|
|
108
|
+
return ` ${usage}\n ${description}`;
|
|
109
|
+
}).join("\n");
|
|
110
|
+
return `## Workflow guide
|
|
111
|
+
|
|
112
|
+
Pipeline: brainstorm -> planning -> backlog -> implementation -> testing -> review -> dones
|
|
113
|
+
|
|
114
|
+
Human gates (only places the agent must stop for the user):
|
|
115
|
+
1. brainstorm: confirm the requirement before planning
|
|
116
|
+
2. planning: kf approve the contract + choose start-now vs backlog
|
|
117
|
+
|
|
118
|
+
Commands the agent will use (run \`kf help <command>\` for every option):
|
|
119
|
+
${commands}
|
|
120
|
+
|
|
121
|
+
Skills installed per agent: kanban-flow (orchestrator), kanban-brainstorm, kanban-bug,
|
|
122
|
+
kanban-plan, kanban-implement, kanban-test, kanban-review, kanban-archive.
|
|
123
|
+
Loop semantics: FAIL/REJECT in testing or review sends the item back to implementation
|
|
124
|
+
with a new execution id; BLOCKED stops; REQUIREMENT_BUG freezes all movement.
|
|
125
|
+
Never bypass a failed gate with --force or --skip-hooks unless the user explicitly approves;
|
|
126
|
+
every bypass is recorded in the work item's metadata and reported by kf status/validate.`;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Print a self-contained briefing an agent can consume to configure this
|
|
130
|
+
* project: current context, a setup checklist with missing actions, the
|
|
131
|
+
* effective review rules to enforce, and the kanban workflow guide.
|
|
132
|
+
*/
|
|
133
|
+
export async function cmdAutoconfig(_parsed, cwd) {
|
|
134
|
+
const root = findWorksRoot(cwd) ?? cwd;
|
|
135
|
+
const cfg = readProjectConfig(root);
|
|
136
|
+
const stacks = cfg.stacks?.length ? cfg.stacks : detectStacks(root);
|
|
137
|
+
const known = new Set(AGENTS.map((a) => a.id));
|
|
138
|
+
const configured = (cfg.agents ?? []).filter((a) => known.has(a));
|
|
139
|
+
const agents = configured.length > 0 ? configured : [DEFAULT_AGENT];
|
|
140
|
+
const skills = installedAgents(root);
|
|
141
|
+
const ctx = [
|
|
142
|
+
"## Project context",
|
|
143
|
+
"",
|
|
144
|
+
`- Root: ${root}`,
|
|
145
|
+
`- ${cfg.stacks?.length ? "Configured" : "Detected"} stacks: ${stacks.length ? stacks.join(", ") : "none"}`,
|
|
146
|
+
`- Config: ${existsSync(configPath(root)) ? `${configPath(root)} (context: ${effectiveDefaultContext(cfg)}, reviewer: ${cfg.reviewer ?? "unset"}, agents: ${agents.join(", ")})` : "missing — run kf init"}`,
|
|
147
|
+
`- Skills installed (project scope): [${skills.join(", ") || "none"}]`,
|
|
148
|
+
].join("\n");
|
|
149
|
+
const items = checklist(root, stacks, agents, cfg);
|
|
150
|
+
const check = [
|
|
151
|
+
"## Setup checklist",
|
|
152
|
+
"",
|
|
153
|
+
...items.map((i) => `- [${i.done ? "x" : " "}] ${i.label}${i.done || !i.action ? "" : ` — run: \`${i.action}\``}`),
|
|
154
|
+
].join("\n");
|
|
155
|
+
const rules = effectiveRules(cwd);
|
|
156
|
+
const rulesSection = [
|
|
157
|
+
"## Review rules to enforce",
|
|
158
|
+
"",
|
|
159
|
+
rules.length === 0
|
|
160
|
+
? "No review rules found — run `kf init --defaults` then `kf rules`."
|
|
161
|
+
: "These rules apply at review (precedence: project -> user -> package). Apply them as coding conventions now:",
|
|
162
|
+
...rules.flatMap((r) => ["", `### ${r.name} (${r.source}: ${r.path})`, "", readFileSync(r.path, "utf8").trim()]),
|
|
163
|
+
].join("\n");
|
|
164
|
+
const out = [
|
|
165
|
+
"# kanban-flow agent setup briefing",
|
|
166
|
+
"",
|
|
167
|
+
`> You are configuring the project at ${basename(root)} for the kanban-flow workflow.`,
|
|
168
|
+
"> Work through the checklist, adopt the rules below as conventions, and use the",
|
|
169
|
+
"> workflow guide as your reference for driving features through the pipeline.",
|
|
170
|
+
"",
|
|
171
|
+
ctx,
|
|
172
|
+
"",
|
|
173
|
+
check,
|
|
174
|
+
"",
|
|
175
|
+
rulesSection,
|
|
176
|
+
"",
|
|
177
|
+
workflowGuide(),
|
|
178
|
+
].join("\n");
|
|
179
|
+
return { code: 0, stdout: out };
|
|
180
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { rename, rm } from "node:fs/promises";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import { join, relative } from "node:path";
|
|
4
|
+
import { findFeature, stageDir, writeFeatureMeta } from "../../workflow/features.js";
|
|
5
|
+
import { findSecretLike } from "../../workflow/validate.js";
|
|
6
|
+
import { runningRun } from "../../harness/run.js";
|
|
7
|
+
import { runHook, resolveHook } from "../../integrations/hooks.js";
|
|
8
|
+
import { readProjectConfig } from "../../project/config.js";
|
|
9
|
+
import { selectOption } from "../../project/bootstrap.js";
|
|
10
|
+
import { nowTimestamp } from "../../shared/time.js";
|
|
11
|
+
import { canonicalDocPaths } from "./archive.js";
|
|
12
|
+
import { findRoot, recordBypasses, bypassNote } from "./helpers.js";
|
|
13
|
+
/**
|
|
14
|
+
* Stop a work item for good. The reason is mandatory: a cancelled item without
|
|
15
|
+
* one is indistinguishable from a folder someone forgot, which is the state
|
|
16
|
+
* this command exists to replace.
|
|
17
|
+
*/
|
|
18
|
+
export async function cmdCancel(args, cwd) {
|
|
19
|
+
const name = args.positionals[0];
|
|
20
|
+
if (!name)
|
|
21
|
+
return { code: 1, stdout: 'Usage: kf cancel <feature> --reason "<why>" [--by <name>] [--purge-docs] [--force] [--skip-hooks]', stderr: "missing feature" };
|
|
22
|
+
const reason = typeof args.options.reason === "string" ? args.options.reason.trim() : "";
|
|
23
|
+
if (!reason) {
|
|
24
|
+
return { code: 1, stdout: 'Cancelling needs a reason: kf cancel ' + name + ' --reason "<why it is being dropped>"', stderr: "missing reason" };
|
|
25
|
+
}
|
|
26
|
+
const secrets = findSecretLike(reason);
|
|
27
|
+
if (secrets.length > 0) {
|
|
28
|
+
return { code: 1, stdout: "The reason looks like it contains a credential; rewrite it without the secret.", stderr: "secret in reason" };
|
|
29
|
+
}
|
|
30
|
+
const root = await findRoot(cwd);
|
|
31
|
+
if (!root.ok)
|
|
32
|
+
return { code: 1, stdout: root.err, stderr: "no works" };
|
|
33
|
+
const f = findFeature(root.root, name);
|
|
34
|
+
if (!f)
|
|
35
|
+
return { code: 1, stdout: `Unknown feature '${name}'. Run: kf list`, stderr: "unknown feature" };
|
|
36
|
+
if (f.stage === "cancelled") {
|
|
37
|
+
return { code: 1, stdout: `'${name}' is already cancelled (${f.meta?.cancellation?.reason ?? "no reason recorded"}).`, stderr: "already cancelled" };
|
|
38
|
+
}
|
|
39
|
+
if (!f.meta)
|
|
40
|
+
return { code: 1, stdout: "Feature metadata is missing.", stderr: "metadata missing" };
|
|
41
|
+
const force = Boolean(args.options.force);
|
|
42
|
+
const forcedCodes = [];
|
|
43
|
+
const active = runningRun(f);
|
|
44
|
+
if (active) {
|
|
45
|
+
if (!force) {
|
|
46
|
+
return { code: 1, stdout: `Run ${active.id} (${active.role} @ ${active.stage}) is still running for '${name}'. Wait for it, or re-run with --force.`, stderr: "run in progress" };
|
|
47
|
+
}
|
|
48
|
+
forcedCodes.push("run_in_progress");
|
|
49
|
+
}
|
|
50
|
+
const skipHooks = Boolean(args.options["skip-hooks"]);
|
|
51
|
+
const skippedHook = skipHooks ? resolveHook(root.root, "cancelled") : null;
|
|
52
|
+
if (!skipHooks) {
|
|
53
|
+
const hook = runHook(root.root, {
|
|
54
|
+
feature: f.name, context: f.context, dir: f.dir, root: root.root,
|
|
55
|
+
from: f.stage, to: "cancelled", approval: f.meta.approval?.status ?? "pending",
|
|
56
|
+
});
|
|
57
|
+
if (hook.ran && !hook.ok) {
|
|
58
|
+
return {
|
|
59
|
+
code: 1,
|
|
60
|
+
stdout: `Hook 'cancelled' failed (exit ${hook.code})${hook.hook ? ` [${hook.hook.path}]` : ""}.\nCancel refused.\n\n${hook.output}\n\nRe-run with --skip-hooks to bypass.`,
|
|
61
|
+
stderr: "hook failed",
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const docs = f.stage === "dones" ? canonicalDocPaths(root.root, f) : [];
|
|
66
|
+
const purge = Boolean(args.options["purge-docs"]);
|
|
67
|
+
let purgeApproved = false;
|
|
68
|
+
if (purge && docs.length > 0) {
|
|
69
|
+
if (force) {
|
|
70
|
+
purgeApproved = true;
|
|
71
|
+
}
|
|
72
|
+
else if (!process.stdin.isTTY) {
|
|
73
|
+
return {
|
|
74
|
+
code: 1,
|
|
75
|
+
stdout: `Refusing to delete canonical docs without confirmation:\n${docs.map((d) => ` ${relative(root.root, d)}`).join("\n")}\nRe-run with --force.`,
|
|
76
|
+
stderr: "purge requires interactive confirmation or --force",
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
const pick = await selectOption(`Delete these canonical docs of '${name}'? (↑/↓ + Enter)\n${docs.map((d) => ` ${relative(root.root, d)}`).join("\n")}`, ["Keep them — only cancel the work item", "Delete them"]);
|
|
81
|
+
if (pick !== 1) {
|
|
82
|
+
return { code: 0, stdout: `Cancelled nothing. Re-run without --purge-docs to drop the work item and keep the docs.` };
|
|
83
|
+
}
|
|
84
|
+
purgeApproved = true;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const by = typeof args.options.by === "string" && args.options.by.trim()
|
|
88
|
+
? args.options.by.trim()
|
|
89
|
+
: readProjectConfig(root.root).reviewer ?? "human";
|
|
90
|
+
const recorded = recordBypasses(f.stage, "cancelled", forcedCodes, skippedHook);
|
|
91
|
+
const cancellation = { at: nowTimestamp(), by, reason, fromStage: f.stage };
|
|
92
|
+
const dest = stageDir(root.root, "cancelled");
|
|
93
|
+
mkdirSync(dest, { recursive: true });
|
|
94
|
+
const target = join(dest, f.folder);
|
|
95
|
+
await writeFeatureMeta(f.dir, {
|
|
96
|
+
...f.meta,
|
|
97
|
+
status: "cancelled",
|
|
98
|
+
cancellation,
|
|
99
|
+
...(recorded.length > 0 ? { bypasses: [...(f.meta.bypasses ?? []), ...recorded] } : {}),
|
|
100
|
+
});
|
|
101
|
+
try {
|
|
102
|
+
await rename(f.dir, target);
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
try {
|
|
106
|
+
await writeFeatureMeta(f.dir, f.meta);
|
|
107
|
+
}
|
|
108
|
+
catch (rollbackError) {
|
|
109
|
+
throw new AggregateError([err, rollbackError], `Cancel failed and metadata rollback was incomplete for '${name}'.`);
|
|
110
|
+
}
|
|
111
|
+
throw err;
|
|
112
|
+
}
|
|
113
|
+
// Deleting docs cannot be undone, so it happens only after the move succeeded.
|
|
114
|
+
let purged = false;
|
|
115
|
+
if (purgeApproved) {
|
|
116
|
+
for (const doc of docs)
|
|
117
|
+
await rm(doc, { recursive: true, force: true });
|
|
118
|
+
purged = true;
|
|
119
|
+
}
|
|
120
|
+
const docsNote = docs.length === 0
|
|
121
|
+
? ""
|
|
122
|
+
: purged
|
|
123
|
+
? `\n Deleted canonical docs:\n${docs.map((d) => ` ${relative(root.root, d)}`).join("\n")}`
|
|
124
|
+
: `\n Canonical docs left in place (use --purge-docs to delete them):\n${docs.map((d) => ` ${relative(root.root, d)}`).join("\n")}`;
|
|
125
|
+
return {
|
|
126
|
+
code: 0,
|
|
127
|
+
stdout: `✓ Cancelled '${name}' (was ${cancellation.fromStage}) by ${by}\n Reason: ${reason.split("\n")[0]}\n ${target}${docsNote}\n Reopen with: kf stage ${name} ${cancellation.fromStage}${bypassNote(recorded)}`,
|
|
128
|
+
};
|
|
129
|
+
}
|