@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,148 @@
|
|
|
1
|
+
export const STAGES = [
|
|
2
|
+
"brainstorm",
|
|
3
|
+
"planning",
|
|
4
|
+
"backlog",
|
|
5
|
+
"implementation",
|
|
6
|
+
"testing",
|
|
7
|
+
"review",
|
|
8
|
+
"dones",
|
|
9
|
+
"cancelled",
|
|
10
|
+
];
|
|
11
|
+
export const ARTIFACT_IDS = [
|
|
12
|
+
"spec-requirement",
|
|
13
|
+
"implementation-plan",
|
|
14
|
+
"use-case-specification",
|
|
15
|
+
"use-case-diagram",
|
|
16
|
+
"test-cases",
|
|
17
|
+
"testing-result",
|
|
18
|
+
"review-report",
|
|
19
|
+
"feature-report",
|
|
20
|
+
];
|
|
21
|
+
/** Artifacts that belong to the full feature planning/closure contract only. */
|
|
22
|
+
export const FEATURE_ONLY_ARTIFACTS = [
|
|
23
|
+
"implementation-plan",
|
|
24
|
+
"use-case-specification",
|
|
25
|
+
"use-case-diagram",
|
|
26
|
+
"test-cases",
|
|
27
|
+
"feature-report",
|
|
28
|
+
];
|
|
29
|
+
export const ARTIFACTS = {
|
|
30
|
+
"spec-requirement": {
|
|
31
|
+
id: "spec-requirement",
|
|
32
|
+
file: "phase-1-spec-requirement.md",
|
|
33
|
+
template: "phase-1-spec-requirement.md",
|
|
34
|
+
phase: "brainstorm",
|
|
35
|
+
dueFromStage: 0,
|
|
36
|
+
unlocks: "Phase 1 done — precise requirement agreed with the human",
|
|
37
|
+
},
|
|
38
|
+
"implementation-plan": {
|
|
39
|
+
id: "implementation-plan",
|
|
40
|
+
file: "phase-2-implementation-plan.md",
|
|
41
|
+
template: "phase-2-implementation-plan.md",
|
|
42
|
+
phase: "plan",
|
|
43
|
+
dueFromStage: 1,
|
|
44
|
+
unlocks: "Execution contract — scope, tasks, impact, DoD",
|
|
45
|
+
},
|
|
46
|
+
"use-case-specification": {
|
|
47
|
+
id: "use-case-specification",
|
|
48
|
+
file: "phase-2-use-case-specification.md",
|
|
49
|
+
template: "phase-2-use-case-specification.md",
|
|
50
|
+
phase: "plan",
|
|
51
|
+
dueFromStage: 1,
|
|
52
|
+
unlocks: "Detailed use case contracts (UC-XXX) for tests and implementation",
|
|
53
|
+
},
|
|
54
|
+
"use-case-diagram": {
|
|
55
|
+
id: "use-case-diagram",
|
|
56
|
+
file: "phase-2-use-case-diagram.md",
|
|
57
|
+
template: "phase-2-use-case-diagram.md",
|
|
58
|
+
phase: "plan",
|
|
59
|
+
dueFromStage: 1,
|
|
60
|
+
unlocks: "Visual overview of actors and use cases",
|
|
61
|
+
},
|
|
62
|
+
"test-cases": {
|
|
63
|
+
id: "test-cases",
|
|
64
|
+
file: "phase-2-test-case.md",
|
|
65
|
+
template: "phase-2-test-case.md",
|
|
66
|
+
phase: "plan",
|
|
67
|
+
dueFromStage: 1,
|
|
68
|
+
unlocks: "Traceable tests (TC-XXX → FR-XXX/UC-XXX) driving implementation and testing",
|
|
69
|
+
},
|
|
70
|
+
"testing-result": {
|
|
71
|
+
id: "testing-result",
|
|
72
|
+
file: "phase-4-testing-result.md",
|
|
73
|
+
template: "phase-4-testing-result.md",
|
|
74
|
+
phase: "test",
|
|
75
|
+
dueFromStage: 4,
|
|
76
|
+
unlocks: "Phase 4 done — PASS gates testing → review; FAIL/REJECT loops back to implementation",
|
|
77
|
+
},
|
|
78
|
+
"review-report": {
|
|
79
|
+
id: "review-report",
|
|
80
|
+
file: "phase-5-review-report.md",
|
|
81
|
+
template: "phase-5-review-report.md",
|
|
82
|
+
phase: "review",
|
|
83
|
+
dueFromStage: 5,
|
|
84
|
+
unlocks: "Phase 5 done — review result gates review → dones",
|
|
85
|
+
},
|
|
86
|
+
"feature-report": {
|
|
87
|
+
id: "feature-report",
|
|
88
|
+
file: "phase-6-feature-report.md",
|
|
89
|
+
template: "phase-6-feature-report.md",
|
|
90
|
+
phase: "artifact",
|
|
91
|
+
dueFromStage: 6,
|
|
92
|
+
unlocks: "Phase 6 — overall Feature Report completes the feature",
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* Transition gate: artifacts that must exist & be filled to LEAVE the stage
|
|
97
|
+
* (i.e. to advance to the next stage).
|
|
98
|
+
*/
|
|
99
|
+
export const STAGE_GATES = {
|
|
100
|
+
brainstorm: ["spec-requirement"],
|
|
101
|
+
planning: ["implementation-plan", "use-case-specification", "use-case-diagram", "test-cases"],
|
|
102
|
+
backlog: [],
|
|
103
|
+
implementation: [],
|
|
104
|
+
testing: ["testing-result"],
|
|
105
|
+
review: ["review-report"],
|
|
106
|
+
dones: [],
|
|
107
|
+
cancelled: [],
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* Allowed transitions between stages.
|
|
111
|
+
* Forward: one step at a time. Backtrack (loop) edges per the workflow:
|
|
112
|
+
* a FAIL/REJECT testing/review result sends the feature back to implementation.
|
|
113
|
+
*/
|
|
114
|
+
export const TRANSITIONS = {
|
|
115
|
+
brainstorm: ["planning"],
|
|
116
|
+
planning: ["implementation", "backlog"],
|
|
117
|
+
backlog: ["implementation", "planning"],
|
|
118
|
+
implementation: ["testing", "planning"],
|
|
119
|
+
testing: ["review", "implementation", "planning"],
|
|
120
|
+
review: ["dones", "implementation", "planning"],
|
|
121
|
+
dones: [],
|
|
122
|
+
// Entered through `kf cancel`, left only back to the stage it was cancelled from.
|
|
123
|
+
cancelled: [],
|
|
124
|
+
};
|
|
125
|
+
export const STAGE_INDEX = {
|
|
126
|
+
brainstorm: 0,
|
|
127
|
+
planning: 1,
|
|
128
|
+
backlog: 2,
|
|
129
|
+
implementation: 3,
|
|
130
|
+
testing: 4,
|
|
131
|
+
review: 5,
|
|
132
|
+
dones: 6,
|
|
133
|
+
/** Off the linear track on purpose: a negative index turns every "is this artifact due yet"
|
|
134
|
+
* and "past planning" comparison false, so a cancelled item is never asked for anything. */
|
|
135
|
+
cancelled: -1,
|
|
136
|
+
};
|
|
137
|
+
/** Name used by `kf status` for the current phase. */
|
|
138
|
+
export const PHASE_NAMES = {
|
|
139
|
+
brainstorm: "Phase 1 — Brainstorming",
|
|
140
|
+
planning: "Phase 2 — Planning",
|
|
141
|
+
backlog: "Backlog — Awaiting a decision to start",
|
|
142
|
+
implementation: "Phase 3 — Implement",
|
|
143
|
+
testing: "Phase 4 — Testing",
|
|
144
|
+
review: "Phase 5 — Review",
|
|
145
|
+
dones: "Phase 6 — Artifact",
|
|
146
|
+
cancelled: "Cancelled — stopped for good",
|
|
147
|
+
};
|
|
148
|
+
export const METADATA_FILE = ".kfw.json";
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const SECRET_PATTERNS = [
|
|
2
|
+
{ re: /\bAuthorization\s*:\s*Bearer\s+([A-Za-z0-9._~+/=-]{12,})/i, high: false },
|
|
3
|
+
{ re: /\bCookie\s*:\s*([^;\n]{12,})/i, high: false },
|
|
4
|
+
{ re: /\b(?:API[_-]?KEY|TOKEN|SECRET|PASSWORD)\s*=\s*["']?([^\s"']{8,})/i, high: false },
|
|
5
|
+
{ re: /\b((?:ghp|gho|ghu|ghs|github_pat)_[A-Za-z0-9_]{20,})/, high: true },
|
|
6
|
+
{ re: /\b(sk-[A-Za-z0-9]{20,})/, high: true },
|
|
7
|
+
{ re: /\b(AKIA[0-9A-Z]{16})\b/, high: true },
|
|
8
|
+
{ re: /\b(xox[baprs]-[A-Za-z0-9-]{10,})/, high: true },
|
|
9
|
+
{ re: /(-----BEGIN [A-Z ]*PRIVATE KEY-----)/, high: true },
|
|
10
|
+
// A JWT needs all three base64url segments. `eyJ` alone is just base64 for `{"`, which any
|
|
11
|
+
// encoded JSON blob starts with, so matching the prefix on its own would fire on harmless data.
|
|
12
|
+
{ re: /\b(eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,})/, high: true },
|
|
13
|
+
{ re: /\b(https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9/+_-]{20,})/, high: true },
|
|
14
|
+
{ re: /\b(https:\/\/(?:ptb\.|canary\.)?discord(?:app)?\.com\/api\/webhooks\/[0-9]+\/[A-Za-z0-9_-]{20,})/, high: true },
|
|
15
|
+
// Group 1 is the password, not the whole URL, so the placeholder exemption and the masking
|
|
16
|
+
// done on output both land on the part that actually matters. A URL with no password is not a
|
|
17
|
+
// secret: `postgres://localhost/db` has nothing to leak.
|
|
18
|
+
//
|
|
19
|
+
// This one is NOT high-confidence, unlike the three above. Those match a prefix that only a
|
|
20
|
+
// real credential carries — `ghp_`, `eyJ…`, the Slack host — so no wording on the line should
|
|
21
|
+
// excuse them. A connection string has no such marker: it is pure URL shape, which a perfectly
|
|
22
|
+
// honest template like `scheme://user:<password>@host` also has. Marking it high made the
|
|
23
|
+
// scanner reject documentation for describing the very format it was taught to find.
|
|
24
|
+
{ re: /\b[a-z][a-z0-9+.-]*:\/\/[^\s:@/]*:([^\s:@/]{3,})@/, high: false },
|
|
25
|
+
];
|
|
26
|
+
const PLACEHOLDERISH = /\{[^}\n]{0,60}\}|<[a-z0-9_ -]+>|\*{3,}|x{4,}|\b(?:redacted|masked|example|changeme|placeholder|dummy|sample)\b|\byour[_-]/i;
|
|
27
|
+
const MASKED_VALUE = /^[A-Za-z_-]*?[xX*]{4,}$/;
|
|
28
|
+
function isPlaceholderValue(value, high) {
|
|
29
|
+
return high ? MASKED_VALUE.test(value) : PLACEHOLDERISH.test(value);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Return lines that look like real secrets. The placeholder exemption is applied
|
|
33
|
+
* to the captured value only, so a comment such as "# example" on the same line
|
|
34
|
+
* cannot excuse a real credential. Hits are masked: only the first characters of
|
|
35
|
+
* the value are kept so callers can log them safely.
|
|
36
|
+
*/
|
|
37
|
+
export function findSecretLike(content) {
|
|
38
|
+
const hits = [];
|
|
39
|
+
for (const line of content.split("\n")) {
|
|
40
|
+
for (const { re, high } of SECRET_PATTERNS) {
|
|
41
|
+
const match = re.exec(line);
|
|
42
|
+
if (!match)
|
|
43
|
+
continue;
|
|
44
|
+
const value = match[1];
|
|
45
|
+
if (isPlaceholderValue(value, high))
|
|
46
|
+
continue;
|
|
47
|
+
hits.push(line.trim().replace(value, `${value.slice(0, 4)}…`).slice(0, 80));
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return hits;
|
|
52
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { STAGES, ARTIFACTS, FEATURE_ONLY_ARTIFACTS, STAGE_INDEX, PHASE_NAMES, } from "./schema.js";
|
|
4
|
+
import { splitFrontmatter, isFilledFile } from "../shared/frontmatter.js";
|
|
5
|
+
import { executionContractHash } from "./features.js";
|
|
6
|
+
import { validateFeature } from "./validate.js";
|
|
7
|
+
function readArtifactContent(dir, file) {
|
|
8
|
+
const p = join(dir, file);
|
|
9
|
+
if (!existsSync(p))
|
|
10
|
+
return "";
|
|
11
|
+
return readFileSync(p, "utf8");
|
|
12
|
+
}
|
|
13
|
+
export function countTasks(content) {
|
|
14
|
+
const lines = content.split("\n");
|
|
15
|
+
let done = 0;
|
|
16
|
+
let total = 0;
|
|
17
|
+
for (const line of lines) {
|
|
18
|
+
if (/^\s*- \[ \]/.test(line))
|
|
19
|
+
total += 1;
|
|
20
|
+
else if (/^\s*- \[[xX]\]/.test(line)) {
|
|
21
|
+
total += 1;
|
|
22
|
+
done += 1;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return { done, total };
|
|
26
|
+
}
|
|
27
|
+
function assignedFor(feature, harness) {
|
|
28
|
+
const chain = harness?.stages[feature.stage] ?? [];
|
|
29
|
+
if (chain.length === 1 && chain[0] === harness?.main)
|
|
30
|
+
return [];
|
|
31
|
+
return chain.map((role) => ({ role, runner: harness?.roles[role]?.runner ?? "?" }));
|
|
32
|
+
}
|
|
33
|
+
/** Compute artifact completion status for a feature at its current stage. */
|
|
34
|
+
export function computeStatus(feature, harness) {
|
|
35
|
+
const stageIndex = STAGE_INDEX[feature.stage];
|
|
36
|
+
const artifacts = [];
|
|
37
|
+
let doneCount = 0;
|
|
38
|
+
let dueCount = 0;
|
|
39
|
+
for (const id of Object.keys(ARTIFACTS)) {
|
|
40
|
+
if (feature.meta?.kind === "bug" && FEATURE_ONLY_ARTIFACTS.includes(id))
|
|
41
|
+
continue;
|
|
42
|
+
const def = ARTIFACTS[id];
|
|
43
|
+
const path = join(feature.dir, def.file);
|
|
44
|
+
const exists = existsSync(path);
|
|
45
|
+
const raw = readArtifactContent(feature.dir, def.file);
|
|
46
|
+
const { fm, body } = splitFrontmatter(raw);
|
|
47
|
+
const current = (id !== "testing-result" && id !== "review-report")
|
|
48
|
+
|| (Boolean(feature.meta?.executionId) && fm.execution === feature.meta?.executionId);
|
|
49
|
+
const filled = exists && isFilledFile(raw, body) && current;
|
|
50
|
+
const due = stageIndex >= def.dueFromStage;
|
|
51
|
+
let status;
|
|
52
|
+
let note = "";
|
|
53
|
+
if (exists && filled) {
|
|
54
|
+
status = "done";
|
|
55
|
+
doneCount += 1;
|
|
56
|
+
}
|
|
57
|
+
else if (due) {
|
|
58
|
+
status = "missing";
|
|
59
|
+
note = exists ? current ? "not filled (placeholders or empty)" : "report belongs to a different execution" : "file missing";
|
|
60
|
+
dueCount += 1;
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
status = "waiting";
|
|
64
|
+
const dueStage = STAGES[def.dueFromStage];
|
|
65
|
+
note = `due at ${PHASE_NAMES[dueStage]}`;
|
|
66
|
+
}
|
|
67
|
+
artifacts.push({ id, file: def.file, exists, filled, due, status, path, note });
|
|
68
|
+
}
|
|
69
|
+
const tasksPath = join(feature.dir, "tasks.md");
|
|
70
|
+
const taskProgress = existsSync(tasksPath)
|
|
71
|
+
? countTasks(readFileSync(tasksPath, "utf8"))
|
|
72
|
+
: { done: 0, total: 0 };
|
|
73
|
+
const next = artifacts.find((a) => a.due && a.status !== "done")?.id ?? null;
|
|
74
|
+
// Straight from the real validator — no second set of rules. `approval_required` is the one
|
|
75
|
+
// ERROR left out: it is not a defect in the artifacts but the human gate, and the `Approval:`
|
|
76
|
+
// field and the `Next:` line below already say so. Listing it here would put a red ERROR on
|
|
77
|
+
// every correctly-prepared item sitting in planning, which is exactly where the signal matters.
|
|
78
|
+
const blockers = validateFeature(feature).issues
|
|
79
|
+
.filter((i) => i.severity === "ERROR" && i.code !== "approval_required");
|
|
80
|
+
return {
|
|
81
|
+
feature,
|
|
82
|
+
stageIndex,
|
|
83
|
+
artifacts,
|
|
84
|
+
doneCount,
|
|
85
|
+
dueCount,
|
|
86
|
+
totalCount: artifacts.length,
|
|
87
|
+
next,
|
|
88
|
+
blockers,
|
|
89
|
+
taskProgress,
|
|
90
|
+
assignedRoles: assignedFor(feature, harness),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/** Render a human-readable status snapshot (mirrors `openspec status`). */
|
|
94
|
+
export function renderStatusText(s) {
|
|
95
|
+
const lines = [];
|
|
96
|
+
const ready = s.artifacts.filter((a) => a.status !== "waiting");
|
|
97
|
+
const done = ready.filter((a) => a.status === "done").length;
|
|
98
|
+
const taskStr = s.taskProgress.total > 0
|
|
99
|
+
? `, tasks ${s.taskProgress.done}/${s.taskProgress.total}`
|
|
100
|
+
: "";
|
|
101
|
+
const approval = approvalState(s.feature);
|
|
102
|
+
lines.push(`${s.feature.meta?.kind === "bug" ? "Bug" : "Feature"}: ${s.feature.name} (${s.feature.context ?? "no-context"})`);
|
|
103
|
+
lines.push(`Stage: ${s.feature.stage} (${PHASE_NAMES[s.feature.stage]}) Approval: ${approval} Artifacts: ${done}/${ready.length}${taskStr}`);
|
|
104
|
+
if (s.feature.meta?.executionId)
|
|
105
|
+
lines.push(`Execution: ${s.feature.meta.executionId}`);
|
|
106
|
+
const bypasses = s.feature.meta?.bypasses ?? [];
|
|
107
|
+
if (bypasses.length > 0)
|
|
108
|
+
lines.push(`Bypasses: ${bypasses.length} (${bypasses.map((b) => `--${b.flag} → ${b.to}`).join(", ")})`);
|
|
109
|
+
const cancellation = s.feature.meta?.cancellation;
|
|
110
|
+
if (cancellation) {
|
|
111
|
+
lines.push(`Cancelled: ${cancellation.at} by ${cancellation.by} (was ${cancellation.fromStage}) — ${cancellation.reason.split("\n")[0]}`);
|
|
112
|
+
}
|
|
113
|
+
if (s.assignedRoles.length > 0)
|
|
114
|
+
lines.push(`Assigned: ${s.assignedRoles.map((a) => `${a.role} (${a.runner})`).join(" → ")} (kf run)`);
|
|
115
|
+
const runs = s.feature.meta?.runs ?? [];
|
|
116
|
+
if (runs.length > 0) {
|
|
117
|
+
const byRole = new Map();
|
|
118
|
+
for (const r of runs)
|
|
119
|
+
byRole.set(r.role, (byRole.get(r.role) ?? 0) + 1);
|
|
120
|
+
lines.push(`Runs: ${runs.length} (${[...byRole].map(([a, n]) => `${a}×${n}`).join(", ")})`);
|
|
121
|
+
}
|
|
122
|
+
lines.push("");
|
|
123
|
+
for (const a of s.artifacts) {
|
|
124
|
+
const mark = a.status === "done" ? "[x]" : a.status === "missing" ? "[ ]" : "[-]";
|
|
125
|
+
const suffix = a.status === "done" ? "" : ` ${a.note}`;
|
|
126
|
+
lines.push(`${mark} ${a.id}${suffix}`);
|
|
127
|
+
}
|
|
128
|
+
if (s.blockers.length > 0) {
|
|
129
|
+
lines.push("");
|
|
130
|
+
lines.push(`Blocking (${s.blockers.length}) — kf validate --change "${s.feature.name}" for the full report:`);
|
|
131
|
+
for (const b of s.blockers)
|
|
132
|
+
lines.push(` [ERROR] ${b.file}: ${b.message} (${b.code})`);
|
|
133
|
+
}
|
|
134
|
+
if (s.next) {
|
|
135
|
+
lines.push("");
|
|
136
|
+
lines.push(`Next: kf instruct ${s.next} --change "${s.feature.name}"`);
|
|
137
|
+
}
|
|
138
|
+
else if (s.blockers.length > 0) {
|
|
139
|
+
// Naming a next step that is going to be refused is the whole bug. Say what to fix instead.
|
|
140
|
+
lines.push("");
|
|
141
|
+
lines.push(`Next: clear the blocking findings above, then re-run kf status.`);
|
|
142
|
+
}
|
|
143
|
+
else if (s.feature.stage === "planning" && approval !== "approved") {
|
|
144
|
+
lines.push(`Next: human approval, then kf approve "${s.feature.name}"`);
|
|
145
|
+
}
|
|
146
|
+
else if (s.feature.stage === "planning" && approval === "approved") {
|
|
147
|
+
lines.push(`Next: human decision — kf stage "${s.feature.name}" implementation (start) or backlog (defer)`);
|
|
148
|
+
}
|
|
149
|
+
else if (s.feature.stage === "backlog") {
|
|
150
|
+
lines.push(`Next: human decision — kf stage "${s.feature.name}" implementation (start) or planning (revise)`);
|
|
151
|
+
}
|
|
152
|
+
return lines.join("\n");
|
|
153
|
+
}
|
|
154
|
+
/** Render a JSON-serializable status object. */
|
|
155
|
+
export function statusToJson(s) {
|
|
156
|
+
return {
|
|
157
|
+
feature: s.feature.name,
|
|
158
|
+
kind: s.feature.meta?.kind ?? "feature",
|
|
159
|
+
context: s.feature.context,
|
|
160
|
+
stage: s.feature.stage,
|
|
161
|
+
approval: approvalState(s.feature),
|
|
162
|
+
executionId: s.feature.meta?.executionId ?? null,
|
|
163
|
+
bypasses: s.feature.meta?.bypasses ?? [],
|
|
164
|
+
cancellation: s.feature.meta?.cancellation ?? null,
|
|
165
|
+
assignedRoles: s.assignedRoles,
|
|
166
|
+
runs: s.feature.meta?.runs ?? [],
|
|
167
|
+
artifacts: s.artifacts.map((a) => ({
|
|
168
|
+
id: a.id,
|
|
169
|
+
file: a.file,
|
|
170
|
+
status: a.status,
|
|
171
|
+
due: a.due,
|
|
172
|
+
path: a.path,
|
|
173
|
+
note: a.note,
|
|
174
|
+
})),
|
|
175
|
+
doneCount: s.doneCount,
|
|
176
|
+
dueCount: s.dueCount,
|
|
177
|
+
totalCount: s.totalCount,
|
|
178
|
+
next: s.next,
|
|
179
|
+
blockers: s.blockers,
|
|
180
|
+
tasks: s.taskProgress,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
export function approvalState(feature) {
|
|
184
|
+
const approval = feature.meta?.approval;
|
|
185
|
+
if (approval?.status !== "approved")
|
|
186
|
+
return "pending";
|
|
187
|
+
return approval.contractHash && approval.contractHash === executionContractHash(feature.dir, feature.meta?.kind === "bug" ? "bug" : "feature") ? "approved" : "changed";
|
|
188
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { STAGE_INDEX } from "./schema.js";
|
|
2
|
+
import { executionContractHash } from "./features.js";
|
|
3
|
+
import { finding } from "./findings.js";
|
|
4
|
+
/** Approval binds the requirement and planning artifacts throughout execution. */
|
|
5
|
+
export function checkApproval(feature, requireApproval) {
|
|
6
|
+
if (STAGE_INDEX[feature.stage] < STAGE_INDEX.planning || !requireApproval)
|
|
7
|
+
return [];
|
|
8
|
+
const isBug = feature.meta?.kind === "bug";
|
|
9
|
+
const status = feature.meta?.approval?.status ?? "pending";
|
|
10
|
+
if (status !== "approved") {
|
|
11
|
+
return [finding(feature, "ERROR", "plan", "approval_required", "Execution contract not approved by a human (Phase 2 gate). Return to planning and run: kf approve <feature>")];
|
|
12
|
+
}
|
|
13
|
+
if (!feature.meta?.approval?.contractHash || feature.meta.approval.contractHash !== executionContractHash(feature.dir, isBug ? "bug" : "feature")) {
|
|
14
|
+
return [finding(feature, "ERROR", "plan", "approval_changed", "Execution contract changed or has no approval fingerprint. Return to planning for human approval.")];
|
|
15
|
+
}
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
/** Deliberate bypasses are recorded, never hidden: surface them on every validation. */
|
|
19
|
+
export function checkBypasses(feature) {
|
|
20
|
+
const bypasses = feature.meta?.bypasses ?? [];
|
|
21
|
+
if (bypasses.length === 0)
|
|
22
|
+
return [];
|
|
23
|
+
const last = bypasses[bypasses.length - 1];
|
|
24
|
+
return [finding(feature, "WARNING", ".kfw.json", "gate_bypassed", `${bypasses.length} gate/hook bypass${bypasses.length > 1 ? "es" : ""} recorded (last: --${last.flag} ${last.from ?? "?"} → ${last.to} at ${last.at}); review them before trusting this item`)];
|
|
25
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { ARTIFACTS, STAGE_GATES, STAGE_INDEX, FEATURE_ONLY_ARTIFACTS } from "./schema.js";
|
|
4
|
+
import { splitFrontmatter, isFilledFile } from "../shared/frontmatter.js";
|
|
5
|
+
import { findSecretLike } from "./secrets.js";
|
|
6
|
+
import { finding } from "./findings.js";
|
|
7
|
+
/** Each artifact that is DUE at the current stage must exist, be filled and contain no secrets. */
|
|
8
|
+
export function checkDueArtifacts(feature) {
|
|
9
|
+
const issues = [];
|
|
10
|
+
const stageIndex = STAGE_INDEX[feature.stage];
|
|
11
|
+
const isBug = feature.meta?.kind === "bug";
|
|
12
|
+
for (const id of Object.keys(ARTIFACTS)) {
|
|
13
|
+
const def = ARTIFACTS[id];
|
|
14
|
+
if (isBug && FEATURE_ONLY_ARTIFACTS.includes(id))
|
|
15
|
+
continue;
|
|
16
|
+
const path = join(feature.dir, def.file);
|
|
17
|
+
if (stageIndex < def.dueFromStage)
|
|
18
|
+
continue;
|
|
19
|
+
if (!existsSync(path)) {
|
|
20
|
+
issues.push(finding(feature, "ERROR", def.file, "artifact_missing", `Required artifact ${def.file} is missing`));
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
const content = readFileSync(path, "utf8");
|
|
24
|
+
if (!isFilledFile(content, splitFrontmatter(content).body)) {
|
|
25
|
+
issues.push(finding(feature, "ERROR", def.file, "artifact_unfilled", `${def.file} is empty or still contains template placeholders (needs real content)`));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return issues;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Secrets in any artifact that is on disk, whatever stage the item is in.
|
|
32
|
+
*
|
|
33
|
+
* This deliberately does NOT share the `dueFromStage` guard above. A credential is a credential
|
|
34
|
+
* whether or not the workflow has reached the phase that asks for the file, and `cancelled` sits
|
|
35
|
+
* at `STAGE_INDEX = -1`, which made that guard skip every artifact — so cancelling an item was
|
|
36
|
+
* the quietest way to take a committed token off the radar while leaving it in the repo.
|
|
37
|
+
*/
|
|
38
|
+
export function checkSecrets(feature) {
|
|
39
|
+
const issues = [];
|
|
40
|
+
const isBug = feature.meta?.kind === "bug";
|
|
41
|
+
for (const id of Object.keys(ARTIFACTS)) {
|
|
42
|
+
const def = ARTIFACTS[id];
|
|
43
|
+
if (isBug && FEATURE_ONLY_ARTIFACTS.includes(id))
|
|
44
|
+
continue;
|
|
45
|
+
const path = join(feature.dir, def.file);
|
|
46
|
+
if (!existsSync(path))
|
|
47
|
+
continue;
|
|
48
|
+
const secrets = findSecretLike(readFileSync(path, "utf8"));
|
|
49
|
+
if (secrets.length > 0) {
|
|
50
|
+
issues.push(finding(feature, "ERROR", def.file, "artifact_secret", `${def.file} contains secret-like content (${secrets.length} line${secrets.length > 1 ? "s" : ""}) — remove credentials from workflow artifacts`));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return issues;
|
|
54
|
+
}
|
|
55
|
+
/** Stage gate: to leave the current stage, gate artifacts must be complete and the requirement confirmed. */
|
|
56
|
+
export function checkStageGate(feature) {
|
|
57
|
+
const issues = [];
|
|
58
|
+
const isBug = feature.meta?.kind === "bug";
|
|
59
|
+
for (const id of STAGE_GATES[feature.stage]) {
|
|
60
|
+
if (isBug && FEATURE_ONLY_ARTIFACTS.includes(id))
|
|
61
|
+
continue;
|
|
62
|
+
const def = ARTIFACTS[id];
|
|
63
|
+
const path = join(feature.dir, def.file);
|
|
64
|
+
if (!existsSync(path)) {
|
|
65
|
+
issues.push(finding(feature, "ERROR", def.file, "gate_blocked", `Cannot leave stage "${feature.stage}" — ${def.file} is missing`));
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const raw = readFileSync(path, "utf8");
|
|
69
|
+
if (!isFilledFile(raw, splitFrontmatter(raw).body)) {
|
|
70
|
+
issues.push(finding(feature, "ERROR", def.file, "gate_blocked", `Cannot leave stage "${feature.stage}" — ${def.file} is not filled`));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// Scoped to the stages that still owe a confirmed requirement. Unguarded it fired everywhere:
|
|
74
|
+
// an archived item whose spec reads `status: archived`, or one in review reading `approved`,
|
|
75
|
+
// were both told the requirement must be confirmed "before leaving brainstorm".
|
|
76
|
+
//
|
|
77
|
+
// It must keep firing at `planning`, not only at `brainstorm`: `kf approve` validates there,
|
|
78
|
+
// and this is the only check that reads the spec's status. Narrowing it to brainstorm alone
|
|
79
|
+
// let `kf approve` seal a contract whose requirement had been reopened to `pending`, and the
|
|
80
|
+
// fingerprint cannot catch that — it hashes the spec as it stands at approval time, so the
|
|
81
|
+
// pending state becomes the contract and it is the later *correction* that reads as drift.
|
|
82
|
+
const specPath = join(feature.dir, ARTIFACTS["spec-requirement"].file);
|
|
83
|
+
const owesConfirmation = STAGE_INDEX[feature.stage] <= STAGE_INDEX.planning;
|
|
84
|
+
if (owesConfirmation && existsSync(specPath)
|
|
85
|
+
&& splitFrontmatter(readFileSync(specPath, "utf8")).fm.status !== "confirmed") {
|
|
86
|
+
issues.push(finding(feature, "ERROR", ARTIFACTS["spec-requirement"].file, "requirement_unconfirmed", "Requirement must have status: confirmed before the contract is approved or leaves brainstorm."));
|
|
87
|
+
}
|
|
88
|
+
return issues;
|
|
89
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { finding } from "./findings.js";
|
|
2
|
+
/**
|
|
3
|
+
* A cancelled item is asked for nothing else (its stage index is negative), so
|
|
4
|
+
* the one thing it must carry is why it was stopped. Without that it is just a
|
|
5
|
+
* folder nobody can explain.
|
|
6
|
+
*/
|
|
7
|
+
export function checkCancellation(feature) {
|
|
8
|
+
if (feature.stage !== "cancelled")
|
|
9
|
+
return [];
|
|
10
|
+
const reason = feature.meta?.cancellation?.reason?.trim();
|
|
11
|
+
if (reason)
|
|
12
|
+
return [];
|
|
13
|
+
return [finding(feature, "ERROR", ".kfw.json", "cancellation_missing", "Cancelled work item has no recorded reason. Re-run: kf cancel <feature> --reason \"<why>\" (or move it back to its stage).")];
|
|
14
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { ARTIFACTS, STAGE_INDEX } from "./schema.js";
|
|
4
|
+
import { splitFrontmatter } from "../shared/frontmatter.js";
|
|
5
|
+
import { countTasks } from "./status.js";
|
|
6
|
+
import { finding } from "./findings.js";
|
|
7
|
+
const TEST_RESULTS = /^(PASS|FAIL|REJECT|BLOCKED)$/;
|
|
8
|
+
const REVIEW_RESULTS = /^(PASS|FAIL|REJECT|REQUIREMENT_BUG)$/;
|
|
9
|
+
const COMMANDS_HEADING = /^##\s+Commands and Evidence\b/i;
|
|
10
|
+
/**
|
|
11
|
+
* Exit-code column of every data row in the table under "## Commands and Evidence".
|
|
12
|
+
* Returns null when the section is absent. Cells are split on "|" so an escaped
|
|
13
|
+
* pipe inside a cell is not supported — the template never needs one.
|
|
14
|
+
*/
|
|
15
|
+
export function commandExitCodes(body) {
|
|
16
|
+
const lines = body.split("\n");
|
|
17
|
+
const start = lines.findIndex((line) => COMMANDS_HEADING.test(line));
|
|
18
|
+
if (start === -1)
|
|
19
|
+
return null;
|
|
20
|
+
const codes = [];
|
|
21
|
+
let rows = 0;
|
|
22
|
+
for (const line of lines.slice(start + 1)) {
|
|
23
|
+
if (/^##\s/.test(line))
|
|
24
|
+
break;
|
|
25
|
+
if (!line.trim().startsWith("|"))
|
|
26
|
+
continue;
|
|
27
|
+
rows += 1;
|
|
28
|
+
if (rows <= 2)
|
|
29
|
+
continue;
|
|
30
|
+
codes.push(line.split("|").map((cell) => cell.trim())[2] ?? "");
|
|
31
|
+
}
|
|
32
|
+
return codes;
|
|
33
|
+
}
|
|
34
|
+
/** tasks.md checkbox presence: without checkboxes progress cannot be tracked; unfinished tasks block dones. */
|
|
35
|
+
export function checkTasks(feature) {
|
|
36
|
+
const issues = [];
|
|
37
|
+
const tasksPath = join(feature.dir, "tasks.md");
|
|
38
|
+
if (!existsSync(tasksPath))
|
|
39
|
+
return issues;
|
|
40
|
+
const { done, total } = countTasks(readFileSync(tasksPath, "utf8"));
|
|
41
|
+
if (total === 0) {
|
|
42
|
+
issues.push(finding(feature, "WARNING", "tasks.md", "no_tasks", 'tasks.md has no task checkboxes ("- [ ] 1. Task description"); progress cannot be tracked'));
|
|
43
|
+
}
|
|
44
|
+
if (feature.stage === "dones" && done < total) {
|
|
45
|
+
issues.push(finding(feature, "ERROR", "tasks.md", "tasks_incomplete_archived", `Feature has unfinished tasks (${done}/${total} done)`));
|
|
46
|
+
}
|
|
47
|
+
return issues;
|
|
48
|
+
}
|
|
49
|
+
/** testing-result semantics: PASS allows review; FAIL/REJECT must loop back; BLOCKED is a red flag. */
|
|
50
|
+
export function checkTestingResult(feature) {
|
|
51
|
+
const issues = [];
|
|
52
|
+
const stageIndex = STAGE_INDEX[feature.stage];
|
|
53
|
+
const file = ARTIFACTS["testing-result"].file;
|
|
54
|
+
const trPath = join(feature.dir, file);
|
|
55
|
+
if (!existsSync(trPath) || stageIndex < STAGE_INDEX.testing)
|
|
56
|
+
return issues;
|
|
57
|
+
const { fm, body } = splitFrontmatter(readFileSync(trPath, "utf8"));
|
|
58
|
+
const status = fm.status ?? "";
|
|
59
|
+
if (!TEST_RESULTS.test(status)) {
|
|
60
|
+
issues.push(finding(feature, "ERROR", file, "testing_status", `testing-result status is "${status}"; expected PASS/FAIL/REJECT/BLOCKED`));
|
|
61
|
+
}
|
|
62
|
+
if (status === "PASS") {
|
|
63
|
+
const codes = commandExitCodes(body) ?? [];
|
|
64
|
+
const failing = codes.filter((code) => code !== "0").length;
|
|
65
|
+
if (codes.length === 0) {
|
|
66
|
+
issues.push(finding(feature, "ERROR", file, "testing_exit_code", "PASS requires at least one command row under \"## Commands and Evidence\" with exit code 0."));
|
|
67
|
+
}
|
|
68
|
+
else if (failing > 0) {
|
|
69
|
+
issues.push(finding(feature, "ERROR", file, "testing_exit_code", `PASS requires every command in "## Commands and Evidence" to exit 0 (${failing} row${failing > 1 ? "s" : ""} with a different exit code).`));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (!feature.meta?.executionId || fm.execution !== feature.meta.executionId) {
|
|
73
|
+
issues.push(finding(feature, "ERROR", file, "testing_stale", "Testing report must reference the current execution id from kf status/instruct."));
|
|
74
|
+
}
|
|
75
|
+
if (stageIndex >= STAGE_INDEX.review && status !== "PASS") {
|
|
76
|
+
issues.push(finding(feature, "ERROR", file, "testing_not_pass", "Only a current PASS testing report may enter review or dones."));
|
|
77
|
+
}
|
|
78
|
+
if (/^(FAIL|REJECT)/i.test(status) && feature.stage === "testing") {
|
|
79
|
+
issues.push(finding(feature, "WARNING", file, "testing_loops_back", `Testing ${status.toUpperCase()}: must loop back to implementation (kf stage ${feature.name} implementation)`));
|
|
80
|
+
}
|
|
81
|
+
return issues;
|
|
82
|
+
}
|
|
83
|
+
/** review-report semantics: PASS allows archive; FAIL/REJECT loops back; REQUIREMENT_BUG stops the feature. */
|
|
84
|
+
export function checkReviewReport(feature) {
|
|
85
|
+
const issues = [];
|
|
86
|
+
const stageIndex = STAGE_INDEX[feature.stage];
|
|
87
|
+
const file = ARTIFACTS["review-report"].file;
|
|
88
|
+
const rvPath = join(feature.dir, file);
|
|
89
|
+
if (!existsSync(rvPath) || stageIndex < STAGE_INDEX.review)
|
|
90
|
+
return issues;
|
|
91
|
+
const { fm } = splitFrontmatter(readFileSync(rvPath, "utf8"));
|
|
92
|
+
const status = fm.status ?? "";
|
|
93
|
+
if (!REVIEW_RESULTS.test(status)) {
|
|
94
|
+
issues.push(finding(feature, "ERROR", file, "review_status", `review-report status is "${status}"; expected PASS/FAIL/REJECT/REQUIREMENT_BUG`));
|
|
95
|
+
}
|
|
96
|
+
if (!feature.meta?.executionId || fm.execution !== feature.meta.executionId) {
|
|
97
|
+
issues.push(finding(feature, "ERROR", file, "review_stale", "Review report must reference the current execution id from kf status/instruct."));
|
|
98
|
+
}
|
|
99
|
+
if (feature.stage === "dones" && status !== "PASS") {
|
|
100
|
+
issues.push(finding(feature, "ERROR", file, "review_not_pass", "Only a current PASS review report may enter dones."));
|
|
101
|
+
}
|
|
102
|
+
if (/^(FAIL|REJECT)/i.test(status) && feature.stage === "review") {
|
|
103
|
+
issues.push(finding(feature, "WARNING", file, "review_loops_back", `Review ${status.toUpperCase()}: must loop back to implementation, then testing again (kf stage ${feature.name} implementation)`));
|
|
104
|
+
}
|
|
105
|
+
if (/^REQUIREMENT_BUG/i.test(status)) {
|
|
106
|
+
issues.push(finding(feature, "ERROR", file, "requirement_bug_stop", "REQUIREMENT_BUG: STOP FEATURE. Do not silently rewrite the requirement — report back to the human."));
|
|
107
|
+
}
|
|
108
|
+
return issues;
|
|
109
|
+
}
|
|
110
|
+
/** A feature in dones without its reports (and feature report) is incomplete. */
|
|
111
|
+
export function checkDonesArtifacts(feature) {
|
|
112
|
+
if (feature.stage !== "dones")
|
|
113
|
+
return [];
|
|
114
|
+
const isBug = feature.meta?.kind === "bug";
|
|
115
|
+
const required = isBug
|
|
116
|
+
? ["testing-result", "review-report"]
|
|
117
|
+
: ["testing-result", "review-report", "feature-report"];
|
|
118
|
+
return required
|
|
119
|
+
.filter((id) => !existsSync(join(feature.dir, ARTIFACTS[id].file)))
|
|
120
|
+
.map((id) => finding(feature, "ERROR", ARTIFACTS[id].file, "artifact_missing", `Archived feature is incomplete: ${ARTIFACTS[id].file} is missing`));
|
|
121
|
+
}
|