@infinitedusky/indusk-mcp 1.15.0 → 1.16.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/dist/bin/commands/update.js +21 -13
- package/dist/lib/trajectory/audit.d.ts +51 -0
- package/dist/lib/trajectory/audit.js +96 -0
- package/dist/lib/trajectory/parser.d.ts +30 -0
- package/dist/lib/trajectory/parser.js +251 -0
- package/dist/lib/trajectory/state-ops.d.ts +49 -0
- package/dist/lib/trajectory/state-ops.js +132 -0
- package/dist/lib/trajectory/validator.d.ts +36 -0
- package/dist/lib/trajectory/validator.js +202 -0
- package/package.json +1 -1
- package/skills/falsify.md +87 -0
- package/skills/planner.md +29 -8
- package/skills/retrospective.md +28 -0
- package/skills/work.md +2 -1
|
@@ -183,21 +183,32 @@ export async function update(projectRoot) {
|
|
|
183
183
|
console.info("\n[Hooks]\n");
|
|
184
184
|
const hooksSource = join(packageRoot, "hooks");
|
|
185
185
|
const hooksTarget = join(projectRoot, ".claude/hooks");
|
|
186
|
+
console.info(` source: ${hooksSource}`);
|
|
186
187
|
let hooksUpdated = 0;
|
|
187
188
|
let hooksCurrent = 0;
|
|
188
|
-
if (existsSync(hooksSource)
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
189
|
+
if (!existsSync(hooksSource)) {
|
|
190
|
+
console.info(` source missing — the package install is broken`);
|
|
191
|
+
}
|
|
192
|
+
else if (!existsSync(hooksTarget)) {
|
|
193
|
+
// Never initialized — create the dir and copy all bundled hooks
|
|
194
|
+
mkdirSync(hooksTarget, { recursive: true });
|
|
195
|
+
console.info(` created: ${hooksTarget}`);
|
|
196
|
+
const bundled = globSync("*.js", { cwd: hooksSource });
|
|
197
|
+
for (const file of bundled) {
|
|
198
|
+
cpSync(join(hooksSource, file), join(hooksTarget, file));
|
|
199
|
+
console.info(` added: ${file}`);
|
|
200
|
+
hooksUpdated++;
|
|
201
|
+
}
|
|
202
|
+
console.info(`\n ${hooksUpdated} added.`);
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
// Both dirs exist — sync by hash compare. Discover bundled hooks from
|
|
206
|
+
// the source dir rather than hardcoding names, so new hooks added to
|
|
207
|
+
// the package get synced on update without code changes here.
|
|
208
|
+
const hookFiles = globSync("*.js", { cwd: hooksSource });
|
|
196
209
|
for (const file of hookFiles) {
|
|
197
210
|
const sourceFile = join(hooksSource, file);
|
|
198
211
|
const targetFile = join(hooksTarget, file);
|
|
199
|
-
if (!existsSync(sourceFile))
|
|
200
|
-
continue;
|
|
201
212
|
if (!existsSync(targetFile)) {
|
|
202
213
|
cpSync(sourceFile, targetFile);
|
|
203
214
|
console.info(` added: ${file}`);
|
|
@@ -244,9 +255,6 @@ export async function update(projectRoot) {
|
|
|
244
255
|
}
|
|
245
256
|
}
|
|
246
257
|
}
|
|
247
|
-
else {
|
|
248
|
-
console.info(" not installed (run init to install)");
|
|
249
|
-
}
|
|
250
258
|
// 5b. Migrate stale MCP configs
|
|
251
259
|
const mcpJsonPath = join(projectRoot, ".mcp.json");
|
|
252
260
|
if (existsSync(mcpJsonPath)) {
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { DeferredRow, Trajectory, TrajectoryRow } from "./parser.js";
|
|
2
|
+
export type MitigationKind = "telemetry-alert" | "scheduled-review" | "downstream-plan" | "canary-or-staging" | "feedback-signal" | "unclassified";
|
|
3
|
+
export interface MitigationClassification {
|
|
4
|
+
row: DeferredRow;
|
|
5
|
+
kind: MitigationKind;
|
|
6
|
+
/** Hints extracted from the text: plan names, file paths, metric names. */
|
|
7
|
+
hints: string[];
|
|
8
|
+
/** Non-null when the mitigation is too vague — needs a more concrete commitment. */
|
|
9
|
+
warning: string | null;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Retrospective audit: classify every Deferred Verification row, flag any
|
|
13
|
+
* whose mitigation text is too vague or unclassifiable. Returns findings
|
|
14
|
+
* suitable for inclusion in a retrospective's "What We Learned" section
|
|
15
|
+
* or a Graphiti audit episode.
|
|
16
|
+
*/
|
|
17
|
+
export declare function auditDeferredMitigations(trajectory: Trajectory): MitigationClassification[];
|
|
18
|
+
export interface BlockedRowFinding {
|
|
19
|
+
row: TrajectoryRow;
|
|
20
|
+
message: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Retrospective audit: surface trajectory rows that ended the plan in
|
|
24
|
+
* `blocked` state. Blocked means "was writable/written but regressed or
|
|
25
|
+
* changed" — if the plan closes with blocked rows, they should be
|
|
26
|
+
* explicitly resolved (fix, move passesAt, or move to Deferred Verification).
|
|
27
|
+
*/
|
|
28
|
+
export declare function findBlockedRows(trajectory: Trajectory): BlockedRowFinding[];
|
|
29
|
+
export interface TestIdResolution {
|
|
30
|
+
id: string;
|
|
31
|
+
asserts: string;
|
|
32
|
+
/** Best-effort test file name glob (e.g. "**\/*reconciler*.test.ts") or null. */
|
|
33
|
+
fileGlob: string | null;
|
|
34
|
+
/** Vitest command with a filter, if one can be derived from the asserts text. */
|
|
35
|
+
suggestedCommand: string;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Derive a concrete test file glob + runnable command from a trajectory
|
|
39
|
+
* row's asserts text. Heuristic: extract identifiers (camelCase, kebab-case,
|
|
40
|
+
* backtick-quoted code) and use the longest as a filename hint. Returns a
|
|
41
|
+
* fallback `pnpm test` with a `-t` name filter if no filename hint found.
|
|
42
|
+
*
|
|
43
|
+
* The verify skill uses this to resolve phase-Verification items that say
|
|
44
|
+
* "T3 passes (...)" into a real invocation. Best-effort — human can override.
|
|
45
|
+
*/
|
|
46
|
+
export declare function resolveTestIdCommand(trajectory: Trajectory, id: string): TestIdResolution | null;
|
|
47
|
+
/** Convenience — parse + audit + resolve in one call for the retrospective skill. */
|
|
48
|
+
export declare function auditPlanAtClose(body: string): {
|
|
49
|
+
deferred: MitigationClassification[];
|
|
50
|
+
blocked: BlockedRowFinding[];
|
|
51
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { parseTrajectory } from "./parser.js";
|
|
2
|
+
const TELEMETRY_KEYWORDS = ["alert", "metric", "otel", "dash0", "grafana", "threshold"];
|
|
3
|
+
const REVIEW_KEYWORDS = [
|
|
4
|
+
"weekly",
|
|
5
|
+
"monthly",
|
|
6
|
+
"quarterly",
|
|
7
|
+
"daily",
|
|
8
|
+
"review",
|
|
9
|
+
"spot-check",
|
|
10
|
+
"audit",
|
|
11
|
+
];
|
|
12
|
+
const PLAN_REF = /\b[a-z][a-z0-9-]*-[a-z][a-z0-9-]*\b/g; // kebab-case identifiers that look like plan slugs
|
|
13
|
+
const CANARY_KEYWORDS = ["staging", "canary", "smoke", "preflight", "pre-release"];
|
|
14
|
+
const FEEDBACK_KEYWORDS = ["feedback", "ticket", "support", "channel", "signal"];
|
|
15
|
+
function classifyMitigation(row) {
|
|
16
|
+
const text = row.mitigation.toLowerCase();
|
|
17
|
+
const hints = [];
|
|
18
|
+
const hasAny = (words) => words.some((w) => text.includes(w));
|
|
19
|
+
let kind = "unclassified";
|
|
20
|
+
if (hasAny(TELEMETRY_KEYWORDS))
|
|
21
|
+
kind = "telemetry-alert";
|
|
22
|
+
else if (hasAny(CANARY_KEYWORDS))
|
|
23
|
+
kind = "canary-or-staging";
|
|
24
|
+
else if (hasAny(FEEDBACK_KEYWORDS))
|
|
25
|
+
kind = "feedback-signal";
|
|
26
|
+
else if (hasAny(REVIEW_KEYWORDS))
|
|
27
|
+
kind = "scheduled-review";
|
|
28
|
+
const planRefs = [...row.mitigation.matchAll(PLAN_REF)].map((m) => m[0]);
|
|
29
|
+
if (planRefs.length > 0) {
|
|
30
|
+
hints.push(...planRefs);
|
|
31
|
+
if (kind === "unclassified")
|
|
32
|
+
kind = "downstream-plan";
|
|
33
|
+
}
|
|
34
|
+
let warning = null;
|
|
35
|
+
if (kind === "unclassified" || row.mitigation.trim().length < 20) {
|
|
36
|
+
warning = `Mitigation for "${row.name}" is vague — expected a specific alert, review cadence, plan reference, or procedure. Got: "${row.mitigation}"`;
|
|
37
|
+
}
|
|
38
|
+
return { row, kind, hints, warning };
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Retrospective audit: classify every Deferred Verification row, flag any
|
|
42
|
+
* whose mitigation text is too vague or unclassifiable. Returns findings
|
|
43
|
+
* suitable for inclusion in a retrospective's "What We Learned" section
|
|
44
|
+
* or a Graphiti audit episode.
|
|
45
|
+
*/
|
|
46
|
+
export function auditDeferredMitigations(trajectory) {
|
|
47
|
+
return trajectory.deferred.map(classifyMitigation);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Retrospective audit: surface trajectory rows that ended the plan in
|
|
51
|
+
* `blocked` state. Blocked means "was writable/written but regressed or
|
|
52
|
+
* changed" — if the plan closes with blocked rows, they should be
|
|
53
|
+
* explicitly resolved (fix, move passesAt, or move to Deferred Verification).
|
|
54
|
+
*/
|
|
55
|
+
export function findBlockedRows(trajectory) {
|
|
56
|
+
return trajectory.rows
|
|
57
|
+
.filter((row) => row.state === "blocked")
|
|
58
|
+
.map((row) => ({
|
|
59
|
+
row,
|
|
60
|
+
message: `Row ${row.id} ended plan in 'blocked' state — needs resolution (fix, reschedule, or move to Deferred Verification)`,
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Derive a concrete test file glob + runnable command from a trajectory
|
|
65
|
+
* row's asserts text. Heuristic: extract identifiers (camelCase, kebab-case,
|
|
66
|
+
* backtick-quoted code) and use the longest as a filename hint. Returns a
|
|
67
|
+
* fallback `pnpm test` with a `-t` name filter if no filename hint found.
|
|
68
|
+
*
|
|
69
|
+
* The verify skill uses this to resolve phase-Verification items that say
|
|
70
|
+
* "T3 passes (...)" into a real invocation. Best-effort — human can override.
|
|
71
|
+
*/
|
|
72
|
+
export function resolveTestIdCommand(trajectory, id) {
|
|
73
|
+
const row = trajectory.rows.find((r) => r.id === id);
|
|
74
|
+
if (!row)
|
|
75
|
+
return null;
|
|
76
|
+
const backtickMatches = [...row.asserts.matchAll(/`([^`]+)`/g)].map((m) => m[1]);
|
|
77
|
+
const identifiers = [...row.asserts.matchAll(/\b[a-zA-Z][a-zA-Z0-9_]{3,}\b/g)].map((m) => m[0]);
|
|
78
|
+
const keyword = backtickMatches
|
|
79
|
+
.filter((s) => /[a-zA-Z]/.test(s))
|
|
80
|
+
.sort((a, b) => b.length - a.length)[0] ??
|
|
81
|
+
identifiers.sort((a, b) => b.length - a.length)[0] ??
|
|
82
|
+
null;
|
|
83
|
+
const fileGlob = keyword ? `**/*${keyword.toLowerCase().replace(/[^a-z0-9]/g, "")}*.test.ts` : null;
|
|
84
|
+
const suggestedCommand = keyword
|
|
85
|
+
? `pnpm test -t "${keyword}"`
|
|
86
|
+
: `pnpm test -t "${row.asserts.slice(0, 40)}"`;
|
|
87
|
+
return { id, asserts: row.asserts, fileGlob, suggestedCommand };
|
|
88
|
+
}
|
|
89
|
+
/** Convenience — parse + audit + resolve in one call for the retrospective skill. */
|
|
90
|
+
export function auditPlanAtClose(body) {
|
|
91
|
+
const trajectory = parseTrajectory(body);
|
|
92
|
+
return {
|
|
93
|
+
deferred: auditDeferredMitigations(trajectory),
|
|
94
|
+
blocked: findBlockedRows(trajectory),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type TrajectoryState = "planned" | "writable" | "written" | "passing" | "blocked" | "skipped" | "unknown";
|
|
2
|
+
export type TrajectoryKind = "example" | "property" | "contract" | "approval" | "formal";
|
|
3
|
+
export type TrajectoryScope = "unit" | "integration" | "e2e";
|
|
4
|
+
export interface TrajectoryRow {
|
|
5
|
+
id: string;
|
|
6
|
+
asserts: string;
|
|
7
|
+
writableAt: number;
|
|
8
|
+
passesAt: number;
|
|
9
|
+
state: TrajectoryState;
|
|
10
|
+
kind?: TrajectoryKind;
|
|
11
|
+
scope?: TrajectoryScope;
|
|
12
|
+
}
|
|
13
|
+
export interface DeferredRow {
|
|
14
|
+
name: string;
|
|
15
|
+
reason: string;
|
|
16
|
+
wouldRequire: string;
|
|
17
|
+
mitigation: string;
|
|
18
|
+
}
|
|
19
|
+
export interface Trajectory {
|
|
20
|
+
rows: TrajectoryRow[];
|
|
21
|
+
deferred: DeferredRow[];
|
|
22
|
+
present: boolean;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Parse a Test Trajectory from an impl.md body (the content after the
|
|
26
|
+
* frontmatter). Returns an empty trajectory with `present: false` when the
|
|
27
|
+
* `## Test Trajectory` section is absent. Never throws — errors are surfaced
|
|
28
|
+
* by the validator, not the parser.
|
|
29
|
+
*/
|
|
30
|
+
export declare function parseTrajectory(body: string): Trajectory;
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
const TRAJECTORY_HEADING = /^##\s+Test Trajectory\b/;
|
|
2
|
+
const DEFERRED_HEADING = /^###\s+Deferred Verification\b/;
|
|
3
|
+
const NEXT_SECTION_HEADING = /^#{1,3}\s+/;
|
|
4
|
+
const PHASE_REFERENCE = /^\s*Phase\s+(\d+)\s*$/i;
|
|
5
|
+
const VALID_STATES = new Set([
|
|
6
|
+
"planned",
|
|
7
|
+
"writable",
|
|
8
|
+
"written",
|
|
9
|
+
"passing",
|
|
10
|
+
"blocked",
|
|
11
|
+
"skipped",
|
|
12
|
+
"unknown",
|
|
13
|
+
]);
|
|
14
|
+
const VALID_KINDS = new Set([
|
|
15
|
+
"example",
|
|
16
|
+
"property",
|
|
17
|
+
"contract",
|
|
18
|
+
"approval",
|
|
19
|
+
"formal",
|
|
20
|
+
]);
|
|
21
|
+
const VALID_SCOPES = new Set(["unit", "integration", "e2e"]);
|
|
22
|
+
/**
|
|
23
|
+
* Parse a markdown table row into cells. Strips the leading/trailing pipe and
|
|
24
|
+
* splits on unescaped pipes. Does NOT handle escaped pipes inside cell content
|
|
25
|
+
* — the trajectory asserts column does not contain pipes in practice.
|
|
26
|
+
*/
|
|
27
|
+
function parseTableRow(line) {
|
|
28
|
+
const trimmed = line.trim();
|
|
29
|
+
if (!trimmed.startsWith("|") || !trimmed.endsWith("|"))
|
|
30
|
+
return [];
|
|
31
|
+
return trimmed
|
|
32
|
+
.slice(1, -1)
|
|
33
|
+
.split("|")
|
|
34
|
+
.map((cell) => cell.trim());
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Normalize a header cell to a canonical key. Case-insensitive, trims
|
|
38
|
+
* whitespace, maps variant spellings to canonical names.
|
|
39
|
+
*/
|
|
40
|
+
function normalizeHeader(header) {
|
|
41
|
+
const normalized = header.toLowerCase().replace(/\s+/g, " ").trim();
|
|
42
|
+
const aliases = {
|
|
43
|
+
id: "id",
|
|
44
|
+
asserts: "asserts",
|
|
45
|
+
"writable at": "writableAt",
|
|
46
|
+
"passes at": "passesAt",
|
|
47
|
+
state: "state",
|
|
48
|
+
kind: "kind",
|
|
49
|
+
scope: "scope",
|
|
50
|
+
};
|
|
51
|
+
return aliases[normalized] ?? normalized;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Parse a "Phase N" cell into a number. Returns NaN if the cell is not a
|
|
55
|
+
* valid phase reference. The validator catches NaN and emits a specific error.
|
|
56
|
+
*/
|
|
57
|
+
function parsePhaseReference(cell) {
|
|
58
|
+
const match = cell.match(PHASE_REFERENCE);
|
|
59
|
+
if (!match)
|
|
60
|
+
return Number.NaN;
|
|
61
|
+
return Number.parseInt(match[1], 10);
|
|
62
|
+
}
|
|
63
|
+
function parseState(cell) {
|
|
64
|
+
const normalized = cell.toLowerCase().trim();
|
|
65
|
+
if (VALID_STATES.has(normalized)) {
|
|
66
|
+
return normalized;
|
|
67
|
+
}
|
|
68
|
+
return "unknown";
|
|
69
|
+
}
|
|
70
|
+
function parseOptionalKind(cell) {
|
|
71
|
+
const normalized = cell.toLowerCase().trim();
|
|
72
|
+
if (!normalized)
|
|
73
|
+
return undefined;
|
|
74
|
+
if (VALID_KINDS.has(normalized)) {
|
|
75
|
+
return normalized;
|
|
76
|
+
}
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
function parseOptionalScope(cell) {
|
|
80
|
+
const normalized = cell.toLowerCase().trim();
|
|
81
|
+
if (!normalized)
|
|
82
|
+
return undefined;
|
|
83
|
+
if (VALID_SCOPES.has(normalized)) {
|
|
84
|
+
return normalized;
|
|
85
|
+
}
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Extract the block of lines under `## Test Trajectory` up to the next
|
|
90
|
+
* heading of equal or lesser depth. The trajectory block includes the
|
|
91
|
+
* main table; the `### Deferred Verification` subsection is extracted
|
|
92
|
+
* separately.
|
|
93
|
+
*/
|
|
94
|
+
function extractTrajectoryBlock(lines) {
|
|
95
|
+
let inTrajectory = false;
|
|
96
|
+
let inDeferred = false;
|
|
97
|
+
const tableLines = [];
|
|
98
|
+
const deferredLines = [];
|
|
99
|
+
for (const line of lines) {
|
|
100
|
+
if (TRAJECTORY_HEADING.test(line)) {
|
|
101
|
+
inTrajectory = true;
|
|
102
|
+
inDeferred = false;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (!inTrajectory)
|
|
106
|
+
continue;
|
|
107
|
+
if (DEFERRED_HEADING.test(line)) {
|
|
108
|
+
inDeferred = true;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
// A new top-level or second-level heading ends the trajectory block.
|
|
112
|
+
// A third-level heading other than Deferred Verification also ends it.
|
|
113
|
+
if (NEXT_SECTION_HEADING.test(line) && !DEFERRED_HEADING.test(line)) {
|
|
114
|
+
const depth = line.match(/^(#{1,6})/)?.[1].length ?? 0;
|
|
115
|
+
if (depth <= 2)
|
|
116
|
+
break;
|
|
117
|
+
// A ### heading that isn't Deferred Verification ends the trajectory
|
|
118
|
+
if (depth === 3)
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
if (inDeferred) {
|
|
122
|
+
deferredLines.push(line);
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
tableLines.push(line);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return { tableLines, deferredLines };
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Parse a Deferred Verification block. Each deferred item is a top-level
|
|
132
|
+
* bullet with three sub-bullets:
|
|
133
|
+
*
|
|
134
|
+
* - **{name}**
|
|
135
|
+
* - reason: {text}
|
|
136
|
+
* - would require: {text}
|
|
137
|
+
* - mitigation: {text}
|
|
138
|
+
*/
|
|
139
|
+
function parseDeferredBlock(lines) {
|
|
140
|
+
const rows = [];
|
|
141
|
+
let current = null;
|
|
142
|
+
const flush = () => {
|
|
143
|
+
if (current && current.name !== undefined) {
|
|
144
|
+
rows.push({
|
|
145
|
+
name: current.name,
|
|
146
|
+
reason: current.reason ?? "",
|
|
147
|
+
wouldRequire: current.wouldRequire ?? "",
|
|
148
|
+
mitigation: current.mitigation ?? "",
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
current = null;
|
|
152
|
+
};
|
|
153
|
+
for (const rawLine of lines) {
|
|
154
|
+
const line = rawLine.replace(/\s+$/, "");
|
|
155
|
+
// Top-level bullet with bolded name: - **{name}**
|
|
156
|
+
const nameMatch = line.match(/^-\s+\*\*(.+?)\*\*\s*(?:—\s*(.*))?$/);
|
|
157
|
+
if (nameMatch) {
|
|
158
|
+
flush();
|
|
159
|
+
current = { name: nameMatch[1].trim() };
|
|
160
|
+
// Handle inline one-line form: - **Name** — reason: X — would require: Y — mitigation: Z
|
|
161
|
+
const rest = nameMatch[2];
|
|
162
|
+
if (rest) {
|
|
163
|
+
const reasonMatch = rest.match(/reason:\s*([^—]+?)(?:\s*—|$)/i);
|
|
164
|
+
const wrMatch = rest.match(/would require:\s*([^—]+?)(?:\s*—|$)/i);
|
|
165
|
+
const mitMatch = rest.match(/mitigation:\s*(.+)$/i);
|
|
166
|
+
if (reasonMatch)
|
|
167
|
+
current.reason = reasonMatch[1].trim();
|
|
168
|
+
if (wrMatch)
|
|
169
|
+
current.wouldRequire = wrMatch[1].trim();
|
|
170
|
+
if (mitMatch)
|
|
171
|
+
current.mitigation = mitMatch[1].trim();
|
|
172
|
+
}
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (!current)
|
|
176
|
+
continue;
|
|
177
|
+
// Sub-bullet: - reason: / - would require: / - mitigation:
|
|
178
|
+
const subMatch = line.match(/^\s+-\s+(reason|would require|mitigation):\s*(.*)$/i);
|
|
179
|
+
if (subMatch) {
|
|
180
|
+
const key = subMatch[1].toLowerCase();
|
|
181
|
+
const value = subMatch[2].trim();
|
|
182
|
+
if (key === "reason")
|
|
183
|
+
current.reason = value;
|
|
184
|
+
else if (key === "would require")
|
|
185
|
+
current.wouldRequire = value;
|
|
186
|
+
else if (key === "mitigation")
|
|
187
|
+
current.mitigation = value;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
flush();
|
|
191
|
+
return rows;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Parse the trajectory table. Returns rows parsed from the GFM table under
|
|
195
|
+
* `## Test Trajectory`. Unknown column headers are ignored. Rows with
|
|
196
|
+
* missing required columns are skipped — the validator catches structural
|
|
197
|
+
* problems and surfaces them.
|
|
198
|
+
*/
|
|
199
|
+
function parseTrajectoryTable(lines) {
|
|
200
|
+
const tableLines = lines.filter((line) => line.trim().startsWith("|"));
|
|
201
|
+
if (tableLines.length < 2)
|
|
202
|
+
return [];
|
|
203
|
+
const headerCells = parseTableRow(tableLines[0]);
|
|
204
|
+
const separator = parseTableRow(tableLines[1]);
|
|
205
|
+
// Separator row looks like |----|----|... — every cell is dashes/colons
|
|
206
|
+
const isSeparator = separator.every((cell) => /^:?-+:?$/.test(cell));
|
|
207
|
+
if (!isSeparator)
|
|
208
|
+
return [];
|
|
209
|
+
const columnKeys = headerCells.map(normalizeHeader);
|
|
210
|
+
const rows = [];
|
|
211
|
+
for (let i = 2; i < tableLines.length; i++) {
|
|
212
|
+
const cells = parseTableRow(tableLines[i]);
|
|
213
|
+
if (cells.length !== columnKeys.length)
|
|
214
|
+
continue;
|
|
215
|
+
const record = {};
|
|
216
|
+
for (let j = 0; j < columnKeys.length; j++) {
|
|
217
|
+
record[columnKeys[j]] = cells[j];
|
|
218
|
+
}
|
|
219
|
+
const id = record.id?.trim();
|
|
220
|
+
const asserts = record.asserts?.trim();
|
|
221
|
+
if (!id || !asserts)
|
|
222
|
+
continue;
|
|
223
|
+
rows.push({
|
|
224
|
+
id,
|
|
225
|
+
asserts,
|
|
226
|
+
writableAt: parsePhaseReference(record.writableAt ?? ""),
|
|
227
|
+
passesAt: parsePhaseReference(record.passesAt ?? ""),
|
|
228
|
+
state: parseState(record.state ?? ""),
|
|
229
|
+
kind: parseOptionalKind(record.kind ?? ""),
|
|
230
|
+
scope: parseOptionalScope(record.scope ?? ""),
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
return rows;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Parse a Test Trajectory from an impl.md body (the content after the
|
|
237
|
+
* frontmatter). Returns an empty trajectory with `present: false` when the
|
|
238
|
+
* `## Test Trajectory` section is absent. Never throws — errors are surfaced
|
|
239
|
+
* by the validator, not the parser.
|
|
240
|
+
*/
|
|
241
|
+
export function parseTrajectory(body) {
|
|
242
|
+
const lines = body.split("\n");
|
|
243
|
+
const hasTrajectory = lines.some((line) => TRAJECTORY_HEADING.test(line));
|
|
244
|
+
if (!hasTrajectory) {
|
|
245
|
+
return { rows: [], deferred: [], present: false };
|
|
246
|
+
}
|
|
247
|
+
const { tableLines, deferredLines } = extractTrajectoryBlock(lines);
|
|
248
|
+
const rows = parseTrajectoryTable(tableLines);
|
|
249
|
+
const deferred = parseDeferredBlock(deferredLines);
|
|
250
|
+
return { rows, deferred, present: true };
|
|
251
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Trajectory, TrajectoryRow, TrajectoryState } from "./parser.js";
|
|
2
|
+
/**
|
|
3
|
+
* Rows whose `Writable at` equals the given phase and whose state is not
|
|
4
|
+
* yet `written` or beyond. These are tests the author should commit as
|
|
5
|
+
* failing at phase start (before implementation work).
|
|
6
|
+
*/
|
|
7
|
+
export declare function getRowsWritableAt(trajectory: Trajectory, phase: number): TrajectoryRow[];
|
|
8
|
+
/**
|
|
9
|
+
* Rows whose `Passes at` equals the given phase, regardless of current state.
|
|
10
|
+
* These are the tests that must be in `passing` (or `skipped` with reason)
|
|
11
|
+
* before the phase can close.
|
|
12
|
+
*/
|
|
13
|
+
export declare function getRowsPassingAt(trajectory: Trajectory, phase: number): TrajectoryRow[];
|
|
14
|
+
/**
|
|
15
|
+
* Rows that block phase-close for the given phase. A row blocks if its
|
|
16
|
+
* `Passes at` equals the phase AND its state is not one of the terminal
|
|
17
|
+
* states (`passing`, `skipped`, `blocked` — blocked implies a tracked
|
|
18
|
+
* investigation, not a silent fail).
|
|
19
|
+
*/
|
|
20
|
+
export declare function getRowsBlockingPhaseClose(trajectory: Trajectory, phase: number): TrajectoryRow[];
|
|
21
|
+
/**
|
|
22
|
+
* Update the `State` column for a specific row ID in an impl.md body.
|
|
23
|
+
* Returns the new body. The input must be the full impl body (after
|
|
24
|
+
* frontmatter); the output is body markdown with exactly one cell changed.
|
|
25
|
+
*
|
|
26
|
+
* If the row ID is not found, returns the body unchanged.
|
|
27
|
+
*/
|
|
28
|
+
export declare function updateRowState(body: string, id: string, newState: TrajectoryState): string;
|
|
29
|
+
/**
|
|
30
|
+
* Convenience: parse the body, compute blocking rows, and return error
|
|
31
|
+
* messages suitable for a hook's stderr. Returns empty array if phase can
|
|
32
|
+
* close cleanly.
|
|
33
|
+
*/
|
|
34
|
+
export declare function computePhaseCloseBlockers(body: string, phase: number): {
|
|
35
|
+
row: TrajectoryRow;
|
|
36
|
+
message: string;
|
|
37
|
+
}[];
|
|
38
|
+
/**
|
|
39
|
+
* Convenience: nudge text for phase start. Returns a string listing rows
|
|
40
|
+
* whose `Writable at` equals the phase and are not yet written, or null
|
|
41
|
+
* if nothing needs authoring.
|
|
42
|
+
*/
|
|
43
|
+
export declare function getPhaseStartNudge(body: string, phase: number): string | null;
|
|
44
|
+
/**
|
|
45
|
+
* Convenience: nudge text for near-phase-close. Returns a string listing
|
|
46
|
+
* rows whose `Passes at` equals the phase but state is not yet `passing`
|
|
47
|
+
* or `skipped`, or null if the trajectory for this phase is green.
|
|
48
|
+
*/
|
|
49
|
+
export declare function getPhaseCloseNudge(body: string, phase: number): string | null;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { parseTrajectory } from "./parser.js";
|
|
2
|
+
/**
|
|
3
|
+
* Rows whose `Writable at` equals the given phase and whose state is not
|
|
4
|
+
* yet `written` or beyond. These are tests the author should commit as
|
|
5
|
+
* failing at phase start (before implementation work).
|
|
6
|
+
*/
|
|
7
|
+
export function getRowsWritableAt(trajectory, phase) {
|
|
8
|
+
return trajectory.rows.filter((row) => row.writableAt === phase &&
|
|
9
|
+
(row.state === "planned" || row.state === "writable" || row.state === "unknown"));
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Rows whose `Passes at` equals the given phase, regardless of current state.
|
|
13
|
+
* These are the tests that must be in `passing` (or `skipped` with reason)
|
|
14
|
+
* before the phase can close.
|
|
15
|
+
*/
|
|
16
|
+
export function getRowsPassingAt(trajectory, phase) {
|
|
17
|
+
return trajectory.rows.filter((row) => row.passesAt === phase);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Rows that block phase-close for the given phase. A row blocks if its
|
|
21
|
+
* `Passes at` equals the phase AND its state is not one of the terminal
|
|
22
|
+
* states (`passing`, `skipped`, `blocked` — blocked implies a tracked
|
|
23
|
+
* investigation, not a silent fail).
|
|
24
|
+
*/
|
|
25
|
+
export function getRowsBlockingPhaseClose(trajectory, phase) {
|
|
26
|
+
return trajectory.rows.filter((row) => row.passesAt === phase &&
|
|
27
|
+
row.state !== "passing" &&
|
|
28
|
+
row.state !== "skipped" &&
|
|
29
|
+
row.state !== "blocked");
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Update the `State` column for a specific row ID in an impl.md body.
|
|
33
|
+
* Returns the new body. The input must be the full impl body (after
|
|
34
|
+
* frontmatter); the output is body markdown with exactly one cell changed.
|
|
35
|
+
*
|
|
36
|
+
* If the row ID is not found, returns the body unchanged.
|
|
37
|
+
*/
|
|
38
|
+
export function updateRowState(body, id, newState) {
|
|
39
|
+
const lines = body.split("\n");
|
|
40
|
+
let inTrajectoryTable = false;
|
|
41
|
+
let headerCells = null;
|
|
42
|
+
let stateIndex = -1;
|
|
43
|
+
for (let i = 0; i < lines.length; i++) {
|
|
44
|
+
const line = lines[i];
|
|
45
|
+
if (/^##\s+Test Trajectory\b/.test(line)) {
|
|
46
|
+
inTrajectoryTable = true;
|
|
47
|
+
headerCells = null;
|
|
48
|
+
stateIndex = -1;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (!inTrajectoryTable)
|
|
52
|
+
continue;
|
|
53
|
+
// A new heading ends the trajectory block
|
|
54
|
+
if (/^#{1,3}\s+/.test(line) && !/^##\s+Test Trajectory\b/.test(line)) {
|
|
55
|
+
inTrajectoryTable = false;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const trimmed = line.trim();
|
|
59
|
+
if (!trimmed.startsWith("|") || !trimmed.endsWith("|"))
|
|
60
|
+
continue;
|
|
61
|
+
const cells = trimmed
|
|
62
|
+
.slice(1, -1)
|
|
63
|
+
.split("|")
|
|
64
|
+
.map((c) => c.trim());
|
|
65
|
+
// Header row: find the State column index
|
|
66
|
+
if (!headerCells) {
|
|
67
|
+
headerCells = cells;
|
|
68
|
+
stateIndex = cells.findIndex((c) => c.toLowerCase() === "state");
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
// Separator row: skip
|
|
72
|
+
if (cells.every((c) => /^:?-+:?$/.test(c)))
|
|
73
|
+
continue;
|
|
74
|
+
// Data row: check ID and update State
|
|
75
|
+
if (cells[0] !== id || stateIndex === -1)
|
|
76
|
+
continue;
|
|
77
|
+
if (cells[stateIndex] === newState)
|
|
78
|
+
return body;
|
|
79
|
+
const newCells = [...cells];
|
|
80
|
+
newCells[stateIndex] = newState;
|
|
81
|
+
// Preserve the leading/trailing pipe and surrounding whitespace
|
|
82
|
+
const leadingWs = line.match(/^\s*/)?.[0] ?? "";
|
|
83
|
+
lines[i] = `${leadingWs}| ${newCells.join(" | ")} |`;
|
|
84
|
+
return lines.join("\n");
|
|
85
|
+
}
|
|
86
|
+
return body;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Convenience: parse the body, compute blocking rows, and return error
|
|
90
|
+
* messages suitable for a hook's stderr. Returns empty array if phase can
|
|
91
|
+
* close cleanly.
|
|
92
|
+
*/
|
|
93
|
+
export function computePhaseCloseBlockers(body, phase) {
|
|
94
|
+
const trajectory = parseTrajectory(body);
|
|
95
|
+
if (!trajectory.present)
|
|
96
|
+
return [];
|
|
97
|
+
const blocking = getRowsBlockingPhaseClose(trajectory, phase);
|
|
98
|
+
return blocking.map((row) => ({
|
|
99
|
+
row,
|
|
100
|
+
message: ` [${row.id}] ${row.asserts} — state: ${row.state} (must be 'passing' or 'skipped' to close Phase ${phase})`,
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Convenience: nudge text for phase start. Returns a string listing rows
|
|
105
|
+
* whose `Writable at` equals the phase and are not yet written, or null
|
|
106
|
+
* if nothing needs authoring.
|
|
107
|
+
*/
|
|
108
|
+
export function getPhaseStartNudge(body, phase) {
|
|
109
|
+
const trajectory = parseTrajectory(body);
|
|
110
|
+
if (!trajectory.present)
|
|
111
|
+
return null;
|
|
112
|
+
const rows = getRowsWritableAt(trajectory, phase);
|
|
113
|
+
if (rows.length === 0)
|
|
114
|
+
return null;
|
|
115
|
+
const lines = rows.map((r) => ` [${r.id}] ${r.asserts}`);
|
|
116
|
+
return `Phase ${phase} opens with these tests to author (commit as failing before implementation work):\n${lines.join("\n")}`;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Convenience: nudge text for near-phase-close. Returns a string listing
|
|
120
|
+
* rows whose `Passes at` equals the phase but state is not yet `passing`
|
|
121
|
+
* or `skipped`, or null if the trajectory for this phase is green.
|
|
122
|
+
*/
|
|
123
|
+
export function getPhaseCloseNudge(body, phase) {
|
|
124
|
+
const trajectory = parseTrajectory(body);
|
|
125
|
+
if (!trajectory.present)
|
|
126
|
+
return null;
|
|
127
|
+
const blocking = getRowsBlockingPhaseClose(trajectory, phase);
|
|
128
|
+
if (blocking.length === 0)
|
|
129
|
+
return null;
|
|
130
|
+
const lines = blocking.map((r) => ` [${r.id}] ${r.asserts} — state: ${r.state}`);
|
|
131
|
+
return `Phase ${phase} cannot close until these trajectory rows are 'passing' or 'skipped':\n${lines.join("\n")}`;
|
|
132
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type Trajectory } from "./parser.js";
|
|
2
|
+
export interface ValidationError {
|
|
3
|
+
rule: "trajectory-presence" | "cross-reference-integrity" | "temporal-coherence" | "deferred-completeness";
|
|
4
|
+
message: string;
|
|
5
|
+
/** The rough line number in the impl body, if known. */
|
|
6
|
+
line?: number;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Rule 1: Every impl document must have a `## Test Trajectory` section.
|
|
10
|
+
*/
|
|
11
|
+
export declare function validateTrajectoryPresence(body: string): ValidationError[];
|
|
12
|
+
/**
|
|
13
|
+
* Rule 2: Every test ID referenced in a phase Verification block must exist
|
|
14
|
+
* in the Trajectory table. Phases with no test-ID references must declare
|
|
15
|
+
* `(no tests flip at this phase — reason: {allowed-reason})`.
|
|
16
|
+
*/
|
|
17
|
+
export declare function validateCrossReferenceIntegrity(body: string, trajectory: Trajectory): ValidationError[];
|
|
18
|
+
/**
|
|
19
|
+
* Rule 3: For every Trajectory row, the phase number in `Writable at` must
|
|
20
|
+
* be ≤ the phase number in `Passes at`. A test cannot pass before its
|
|
21
|
+
* dependencies exist. Also catches NaN from malformed `Phase N` references.
|
|
22
|
+
*/
|
|
23
|
+
export declare function validateTemporalCoherence(trajectory: Trajectory): ValidationError[];
|
|
24
|
+
/**
|
|
25
|
+
* Rule 4: Every Deferred Verification row must have non-empty `reason:`,
|
|
26
|
+
* `would require:`, and `mitigation:` fields. The mitigation field is the
|
|
27
|
+
* compensating control — without it, deferring a test means flying blind.
|
|
28
|
+
*/
|
|
29
|
+
export declare function validateDeferredCompleteness(trajectory: Trajectory): ValidationError[];
|
|
30
|
+
/**
|
|
31
|
+
* Run all four trajectory validation rules against an impl body. The body
|
|
32
|
+
* is the markdown content after the frontmatter — pass the output of
|
|
33
|
+
* `gray-matter` or equivalent. Returns combined errors; empty array means
|
|
34
|
+
* the trajectory is valid.
|
|
35
|
+
*/
|
|
36
|
+
export declare function validateTrajectory(body: string): ValidationError[];
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { parseTrajectory } from "./parser.js";
|
|
2
|
+
const TRAJECTORY_HEADING = /^##\s+Test Trajectory\b/;
|
|
3
|
+
const PHASE_HEADING = /^###\s+Phase\s+(\d+)\b/;
|
|
4
|
+
const VERIFICATION_HEADING = /^####\s+Phase\s+(\d+)\s+Verification\b/;
|
|
5
|
+
const NEXT_GATE_HEADING = /^####\s+Phase\s+\d+\s+(OTel|Context|Document|Forward Intelligence)\b/;
|
|
6
|
+
const CHECKLIST_ITEM = /^-\s+\[[ xX]\]\s+(.*)/;
|
|
7
|
+
const TEST_ID_PATTERN = /\bT\d+\b/g;
|
|
8
|
+
const ALLOWED_NO_TESTS_REASONS = new Set([
|
|
9
|
+
"schema-only",
|
|
10
|
+
"delete",
|
|
11
|
+
"refactor",
|
|
12
|
+
"infra",
|
|
13
|
+
]);
|
|
14
|
+
// Match both em-dash and hyphen with flexible whitespace around the separator
|
|
15
|
+
const NO_TESTS_DECLARATION = /\(no tests flip at this phase\s*[—–-]+\s*reason:\s*([a-z-]+)\s*\)/i;
|
|
16
|
+
/**
|
|
17
|
+
* Rule 1: Every impl document must have a `## Test Trajectory` section.
|
|
18
|
+
*/
|
|
19
|
+
export function validateTrajectoryPresence(body) {
|
|
20
|
+
const lines = body.split("\n");
|
|
21
|
+
const hasTrajectory = lines.some((line) => TRAJECTORY_HEADING.test(line));
|
|
22
|
+
if (hasTrajectory)
|
|
23
|
+
return [];
|
|
24
|
+
return [
|
|
25
|
+
{
|
|
26
|
+
rule: "trajectory-presence",
|
|
27
|
+
message: "Impl is missing the `## Test Trajectory` section. Every impl must declare its tests at the top as a table with columns: ID | Asserts | Writable at | Passes at | State. See `.indusk/planning/tests-first-planning/adr.md` Section 3.",
|
|
28
|
+
},
|
|
29
|
+
];
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Extract each phase's Verification block as a list of checklist items.
|
|
33
|
+
*/
|
|
34
|
+
function extractPhaseVerifications(body) {
|
|
35
|
+
const lines = body.split("\n");
|
|
36
|
+
const result = [];
|
|
37
|
+
let currentPhase = null;
|
|
38
|
+
let currentVerification = null;
|
|
39
|
+
let inVerification = false;
|
|
40
|
+
for (let i = 0; i < lines.length; i++) {
|
|
41
|
+
const line = lines[i];
|
|
42
|
+
const phaseMatch = line.match(PHASE_HEADING);
|
|
43
|
+
if (phaseMatch) {
|
|
44
|
+
if (currentVerification)
|
|
45
|
+
result.push(currentVerification);
|
|
46
|
+
currentPhase = Number.parseInt(phaseMatch[1], 10);
|
|
47
|
+
currentVerification = null;
|
|
48
|
+
inVerification = false;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const verMatch = line.match(VERIFICATION_HEADING);
|
|
52
|
+
if (verMatch && currentPhase !== null) {
|
|
53
|
+
if (currentVerification)
|
|
54
|
+
result.push(currentVerification);
|
|
55
|
+
currentVerification = {
|
|
56
|
+
phase: currentPhase,
|
|
57
|
+
items: [],
|
|
58
|
+
headingLine: i + 1,
|
|
59
|
+
};
|
|
60
|
+
inVerification = true;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (inVerification && NEXT_GATE_HEADING.test(line)) {
|
|
64
|
+
if (currentVerification)
|
|
65
|
+
result.push(currentVerification);
|
|
66
|
+
currentVerification = null;
|
|
67
|
+
inVerification = false;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (inVerification && currentVerification) {
|
|
71
|
+
const itemMatch = line.match(CHECKLIST_ITEM);
|
|
72
|
+
if (itemMatch) {
|
|
73
|
+
currentVerification.items.push({ text: itemMatch[1].trim(), line: i + 1 });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (currentVerification)
|
|
78
|
+
result.push(currentVerification);
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Rule 2: Every test ID referenced in a phase Verification block must exist
|
|
83
|
+
* in the Trajectory table. Phases with no test-ID references must declare
|
|
84
|
+
* `(no tests flip at this phase — reason: {allowed-reason})`.
|
|
85
|
+
*/
|
|
86
|
+
export function validateCrossReferenceIntegrity(body, trajectory) {
|
|
87
|
+
const errors = [];
|
|
88
|
+
const knownIds = new Set(trajectory.rows.map((row) => row.id));
|
|
89
|
+
const verifications = extractPhaseVerifications(body);
|
|
90
|
+
for (const ver of verifications) {
|
|
91
|
+
let foundTestReference = false;
|
|
92
|
+
let foundNoTestsDeclaration = false;
|
|
93
|
+
for (const item of ver.items) {
|
|
94
|
+
const noTestsMatch = item.text.match(NO_TESTS_DECLARATION);
|
|
95
|
+
if (noTestsMatch) {
|
|
96
|
+
foundNoTestsDeclaration = true;
|
|
97
|
+
const reason = noTestsMatch[1].toLowerCase();
|
|
98
|
+
if (!ALLOWED_NO_TESTS_REASONS.has(reason)) {
|
|
99
|
+
errors.push({
|
|
100
|
+
rule: "cross-reference-integrity",
|
|
101
|
+
line: item.line,
|
|
102
|
+
message: `Phase ${ver.phase} Verification: "(no tests flip at this phase — reason: ${reason})" uses disallowed reason. Allowed: ${[...ALLOWED_NO_TESTS_REASONS].join(", ")}.`,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const idMatches = item.text.match(TEST_ID_PATTERN);
|
|
108
|
+
if (idMatches) {
|
|
109
|
+
foundTestReference = true;
|
|
110
|
+
for (const id of idMatches) {
|
|
111
|
+
if (!knownIds.has(id)) {
|
|
112
|
+
errors.push({
|
|
113
|
+
rule: "cross-reference-integrity",
|
|
114
|
+
line: item.line,
|
|
115
|
+
message: `Phase ${ver.phase} Verification references test ID \`${id}\` but no such row exists in the Test Trajectory table.`,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (!foundTestReference && !foundNoTestsDeclaration && ver.items.length > 0) {
|
|
122
|
+
errors.push({
|
|
123
|
+
rule: "cross-reference-integrity",
|
|
124
|
+
line: ver.headingLine,
|
|
125
|
+
message: `Phase ${ver.phase} Verification has no test ID references and no "(no tests flip at this phase — reason: {schema-only|delete|refactor|infra})" declaration. Every phase's Verification must either flip named tests from the trajectory or explicitly declare no tests flip with an allowed reason.`,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return errors;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Rule 3: For every Trajectory row, the phase number in `Writable at` must
|
|
133
|
+
* be ≤ the phase number in `Passes at`. A test cannot pass before its
|
|
134
|
+
* dependencies exist. Also catches NaN from malformed `Phase N` references.
|
|
135
|
+
*/
|
|
136
|
+
export function validateTemporalCoherence(trajectory) {
|
|
137
|
+
const errors = [];
|
|
138
|
+
for (const row of trajectory.rows) {
|
|
139
|
+
if (!Number.isFinite(row.writableAt)) {
|
|
140
|
+
errors.push({
|
|
141
|
+
rule: "temporal-coherence",
|
|
142
|
+
message: `Trajectory row \`${row.id}\` has invalid "Writable at" — expected "Phase N" where N is a number.`,
|
|
143
|
+
});
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (!Number.isFinite(row.passesAt)) {
|
|
147
|
+
errors.push({
|
|
148
|
+
rule: "temporal-coherence",
|
|
149
|
+
message: `Trajectory row \`${row.id}\` has invalid "Passes at" — expected "Phase N" where N is a number.`,
|
|
150
|
+
});
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (row.writableAt > row.passesAt) {
|
|
154
|
+
errors.push({
|
|
155
|
+
rule: "temporal-coherence",
|
|
156
|
+
message: `Trajectory row \`${row.id}\` has "Writable at" Phase ${row.writableAt} > "Passes at" Phase ${row.passesAt}. A test cannot pass before its dependencies exist. If phases were reordered, update the trajectory to reflect the new dependency order.`,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return errors;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Rule 4: Every Deferred Verification row must have non-empty `reason:`,
|
|
164
|
+
* `would require:`, and `mitigation:` fields. The mitigation field is the
|
|
165
|
+
* compensating control — without it, deferring a test means flying blind.
|
|
166
|
+
*/
|
|
167
|
+
export function validateDeferredCompleteness(trajectory) {
|
|
168
|
+
const errors = [];
|
|
169
|
+
for (const row of trajectory.deferred) {
|
|
170
|
+
const missing = [];
|
|
171
|
+
if (!row.reason)
|
|
172
|
+
missing.push("reason");
|
|
173
|
+
if (!row.wouldRequire)
|
|
174
|
+
missing.push("would require");
|
|
175
|
+
if (!row.mitigation)
|
|
176
|
+
missing.push("mitigation");
|
|
177
|
+
if (missing.length > 0) {
|
|
178
|
+
errors.push({
|
|
179
|
+
rule: "deferred-completeness",
|
|
180
|
+
message: `Deferred Verification row "${row.name}" is missing: ${missing.join(", ")}. Every deferred row requires all three fields — reason (why not testable here), would require (what would unlock a proper test), and mitigation (compensating control that keeps us from flying blind).`,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return errors;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Run all four trajectory validation rules against an impl body. The body
|
|
188
|
+
* is the markdown content after the frontmatter — pass the output of
|
|
189
|
+
* `gray-matter` or equivalent. Returns combined errors; empty array means
|
|
190
|
+
* the trajectory is valid.
|
|
191
|
+
*/
|
|
192
|
+
export function validateTrajectory(body) {
|
|
193
|
+
const presenceErrors = validateTrajectoryPresence(body);
|
|
194
|
+
if (presenceErrors.length > 0)
|
|
195
|
+
return presenceErrors;
|
|
196
|
+
const trajectory = parseTrajectory(body);
|
|
197
|
+
return [
|
|
198
|
+
...validateCrossReferenceIntegrity(body, trajectory),
|
|
199
|
+
...validateTemporalCoherence(trajectory),
|
|
200
|
+
...validateDeferredCompleteness(trajectory),
|
|
201
|
+
];
|
|
202
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: falsify
|
|
3
|
+
description: Run the falsification ritual against a completed plan. Goal-flip from "prove it works" to "find a failing test" — investigate the code, form a specific hypothesis about what should be broken, write the test that confirms it, run it. Required between /work completion and /retrospective.
|
|
4
|
+
argument-hint: "{plan-name}"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
You are about to run the **falsification ritual** against a plan whose `/work` has completed. The plan has an attested state — the goal, the Trajectory rows (all in terminal state), the claims it makes about what is now true. Your job is **not** to confirm those claims. Your job is to **falsify them**.
|
|
8
|
+
|
|
9
|
+
This is a goal-flip, not a persona switch. Same agent, different question. Instead of "does this work?" — "what specific thing, with what specific inputs, makes this fail?"
|
|
10
|
+
|
|
11
|
+
## How to hunt
|
|
12
|
+
|
|
13
|
+
This is bounty hunting, not candidate generation. **Do not write hopeful tests and see what fails.** Each iteration hunts a specific target:
|
|
14
|
+
|
|
15
|
+
1. **Read the attested state.** Open the plan's `impl.md`. Read the Goal. Read every Trajectory row — what does each claim? Read the ADR if one exists — what invariants does the plan promise?
|
|
16
|
+
2. **Investigate the code.** Read the actual implementation. Compare what the code does against what the attestation claims. Look for gaps.
|
|
17
|
+
3. **Form a specific hypothesis.** Not "what could go wrong?" — "*this specific condition, with these specific inputs, will violate this specific invariant.*" Name the failure before writing any test.
|
|
18
|
+
4. **Write the test that confirms the hypothesis.** If the hypothesis is right, the test fails. If the hypothesis is wrong, the test passes.
|
|
19
|
+
5. **Run the test.**
|
|
20
|
+
|
|
21
|
+
Prompts to ask yourself while investigating (use these as starting points, not a checklist):
|
|
22
|
+
|
|
23
|
+
- **What's an edge case not covered by T1–Tn?** List every row. For each: what inputs did the author think of? What inputs did they miss?
|
|
24
|
+
- **What's an implicit invariant the attestation makes that the Trajectory doesn't test?** "Recoverable from crash" implies "recoverable from partial write." Is there a test for partial writes?
|
|
25
|
+
- **What about concurrent, partial, or malformed inputs?** Two callers at once. A half-written file. A valid-shape but semantically-wrong input.
|
|
26
|
+
- **What would a malicious user try?** If this accepts input, what input breaks the parser, or traverses paths, or exhausts memory?
|
|
27
|
+
- **What does the attestation assume about the environment?** Time monotonicity. Disk not full. Network present. Clock skew. Is any assumption documented vs. silently-assumed?
|
|
28
|
+
- **What's the first thing someone would try if they were paid $100 to find one failure here?** Specifically. Concretely.
|
|
29
|
+
- **What invariants are only enforced in one direction?** (E.g., "create calls validate, but update bypasses validation.")
|
|
30
|
+
- **What claim does the Goal make that's not expressed as a Trajectory row?** That's often the unguarded surface.
|
|
31
|
+
|
|
32
|
+
**Anti-pattern — do NOT do this:** "I'll write several tests and see which ones fail." That's candidate generation. It's cheap and useless. Every candidate you write without a specific hypothesis is noise. Investigate first, hypothesize specifically, write the test that targets *that* hypothesis.
|
|
33
|
+
|
|
34
|
+
## Three outcomes per failing test
|
|
35
|
+
|
|
36
|
+
When a test fails (your hypothesis is confirmed), pick one outcome — recommend one, but the user decides:
|
|
37
|
+
|
|
38
|
+
1. **Fix in scope** — the gap is small and clearly in-scope for the plan's original goal. Add a new phase to the current `impl.md`, flip the impl status back to `in-progress`, return to `/work`. This is "build the plane while flying" — the plan grows during its own closure.
|
|
39
|
+
2. **Spawn a new plan** — the gap is large, touches unrelated areas, or deserves its own planning lifecycle. Create `.indusk/planning/{new-slug}/brief.md` with the failing test as its core motivation. Link via `blocks:` in the current plan's brief.
|
|
40
|
+
3. **Accept as finding** — rare. The gap is small, unambiguously out-of-scope, and the cost of a new plan isn't justified. Record in the falsification log and note in retrospective. Use only when the other two genuinely don't fit.
|
|
41
|
+
|
|
42
|
+
After choosing the outcome, record the hypothesis via `appendHypothesis(planRoot, { hypothesis, testPath, outcome, note? })` from `apps/indusk-mcp/src/lib/falsification/log.ts`. The log file at `.indusk/planning/{plan}/falsification.md` captures the session's history.
|
|
43
|
+
|
|
44
|
+
## Loop exit (hybrid)
|
|
45
|
+
|
|
46
|
+
Continue hunting until you genuinely **cannot form a specific in-scope hypothesis** about what should be broken. Not "I've tried enough tests" — "I have investigated the code and cannot name a concrete attack vector remaining."
|
|
47
|
+
|
|
48
|
+
When you reach that point, present the user with a summary:
|
|
49
|
+
|
|
50
|
+
- Hypotheses investigated and their outcomes (confirmed → fix/spawn/accept; wrong → the hypothesis was rejected, note what held up)
|
|
51
|
+
- Regions of code you searched without finding an attack vector
|
|
52
|
+
- Any areas you did NOT investigate and why (e.g., "didn't investigate serialization because no serialization code was changed")
|
|
53
|
+
|
|
54
|
+
The user confirms termination — or points at an area you didn't investigate. Not "write another test" — they should point at a *region* you missed. If that produces a new hypothesis, the loop continues. If nothing new surfaces, call `markTerminated(planRoot, reason)` to close the log and hand off to `/retrospective`.
|
|
55
|
+
|
|
56
|
+
## When to skip the ritual entirely
|
|
57
|
+
|
|
58
|
+
For genuinely trivial plans (two-line typo fix, changelog entry, variable rename with no behavioral change), the ritual's cost may exceed its discipline value. To skip, the plan's `impl.md` frontmatter must contain BOTH:
|
|
59
|
+
|
|
60
|
+
```yaml
|
|
61
|
+
falsification: skipped
|
|
62
|
+
falsification_reason: "a non-empty reason, quoted as a YAML string"
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The retrospective skill's Step 0 gate accepts either a completed falsification log OR the two-field skip frontmatter. Skipping is a confession, not a bypass — use sparingly.
|
|
66
|
+
|
|
67
|
+
## Output
|
|
68
|
+
|
|
69
|
+
By the time you hand off to `/retrospective`, one of these must be true:
|
|
70
|
+
|
|
71
|
+
- `.indusk/planning/{plan}/falsification.md` exists with a terminator entry (log is closed cleanly), OR
|
|
72
|
+
- The plan reopened (`impl` status flipped to `in-progress`) via a "fix in scope" outcome and `/work` is active again (deferring falsification until the fix lands)
|
|
73
|
+
|
|
74
|
+
The `/retrospective` skill's Step 0 hard-blocks without this. Don't bypass.
|
|
75
|
+
|
|
76
|
+
## Why this exists
|
|
77
|
+
|
|
78
|
+
See the [Falsification Ritual guide](apps/indusk-docs/src/guide/falsification-ritual.md) for the full motivation. Short version: the Test Trajectory made universal deferral structurally impossible, but authors only write tests they can think of — and the author is the last person likely to notice the gaps in their own thinking. The ritual is a bullshit detector. Its purpose is rigor through self-examination.
|
|
79
|
+
|
|
80
|
+
## Important
|
|
81
|
+
|
|
82
|
+
- Same agent, flipped goal. No persona, no separate session. The same you that built the plan, asked a different question.
|
|
83
|
+
- Bounty hunting, not candidate generation. Investigate first, hypothesize specifically, write the test that targets *that*.
|
|
84
|
+
- Exit criterion is "can't form a specific in-scope hypothesis" — not "ran out of candidates" or "tried N things."
|
|
85
|
+
- The log is append-only. Never edit `falsification.md` by hand. Write via `appendHypothesis` / `markTerminated` from the library.
|
|
86
|
+
- If you find a gap, pick an outcome. Do not log a failing test and then continue looking for more failing tests as if the first didn't matter — each failure demands a decision before moving on.
|
|
87
|
+
- The user's input is: $ARGUMENTS
|
package/skills/planner.md
CHANGED
|
@@ -92,7 +92,7 @@ Workflow templates are in `templates/workflows/` in the package. They describe w
|
|
|
92
92
|
```
|
|
93
93
|
mcp__graphiti__add_memory({
|
|
94
94
|
name: "adr-{plan-name}",
|
|
95
|
-
episode_body: "In
|
|
95
|
+
episode_body: "In context facing: {use case AND constraint}. We decided for: {chosen option}. And against: {rejected alternatives}. To achieve: {desired outcome}. Accepting: {tradeoff}. Because: {rationale}.",
|
|
96
96
|
group_id: "{project-group}",
|
|
97
97
|
source: "text",
|
|
98
98
|
source_description: "ADR acceptance"
|
|
@@ -207,13 +207,34 @@ status: proposed | accepted | deprecated | superseded | abandoned
|
|
|
207
207
|
# {Title}
|
|
208
208
|
|
|
209
209
|
## Y-Statement
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
210
|
+
|
|
211
|
+
**In the context of:**
|
|
212
|
+
{the use case — one paragraph, plain text, not bold}
|
|
213
|
+
|
|
214
|
+
**Facing:**
|
|
215
|
+
{the constraint or problem the use case presents — one paragraph}
|
|
216
|
+
|
|
217
|
+
**We decided for:**
|
|
218
|
+
{the chosen option — one paragraph}
|
|
219
|
+
|
|
220
|
+
**And against:**
|
|
221
|
+
{the rejected alternatives — one paragraph}
|
|
222
|
+
|
|
223
|
+
**To achieve:**
|
|
224
|
+
{the desired outcome — one paragraph}
|
|
225
|
+
|
|
226
|
+
**Accepting:**
|
|
227
|
+
{the tradeoff — one paragraph}
|
|
228
|
+
|
|
229
|
+
**Because:**
|
|
230
|
+
{the rationale — one paragraph}
|
|
231
|
+
|
|
232
|
+
Format rules (the standard Y-statement format for every ADR in every project going forward):
|
|
233
|
+
- Use all seven canonical clauses: In the context of, Facing, We decided for, And against, To achieve, Accepting, Because. These are the standard Y-statement fields — do not collapse, rename, or omit them.
|
|
234
|
+
- Each clause is its own section. The clause label is bold and ends with a colon.
|
|
235
|
+
- The paragraph body begins on the next line immediately after the bold label — no blank line between the label and the paragraph.
|
|
236
|
+
- The paragraph body is plain text — not bold, no inline label.
|
|
237
|
+
- A blank line separates each clause (between the end of one paragraph and the next bold label).
|
|
217
238
|
|
|
218
239
|
## Context
|
|
219
240
|
{Situation and background. Reference research and brief.}
|
package/skills/retrospective.md
CHANGED
|
@@ -28,6 +28,34 @@ The retrospective skill replaces the freeform "write a retrospective" step with
|
|
|
28
28
|
|
|
29
29
|
Work through these steps in order. Each step is blocking — do not skip ahead.
|
|
30
30
|
|
|
31
|
+
### Step 0: Falsification Gate
|
|
32
|
+
|
|
33
|
+
**This gate blocks everything below. Do not proceed to Step 1 until it passes.**
|
|
34
|
+
|
|
35
|
+
Before writing a single word of the retrospective, confirm that the plan has completed the falsification ritual or has an explicit, recorded skip-reason.
|
|
36
|
+
|
|
37
|
+
Check the gate by reading two sources:
|
|
38
|
+
|
|
39
|
+
1. **Completion:** Does `.indusk/planning/{plan-name}/falsification.md` exist with a terminator entry? Use `isFalsificationComplete(planRoot)` from `apps/indusk-mcp/src/lib/falsification/log.js` (invoke via `tsx` or an MCP tool wrapper).
|
|
40
|
+
2. **Skip:** Does the impl's frontmatter contain BOTH `falsification: skipped` AND `falsification_reason: "{non-empty text}"`? Use `isFalsificationSkipped(implContent)` from `apps/indusk-mcp/src/lib/falsification/skip.js`.
|
|
41
|
+
|
|
42
|
+
The gate passes if either condition holds. If neither holds, refuse to run the retrospective and surface this message to the user:
|
|
43
|
+
|
|
44
|
+
> **Retrospective blocked: falsification gate not satisfied for `{plan-name}`.**
|
|
45
|
+
>
|
|
46
|
+
> Before closing out a plan, run `/falsify {plan-name}` to exercise the bounty-hunting ritual — investigate the code, form a specific hypothesis about what should be broken, write the test that confirms it. The ritual may surface gaps worth addressing before archival (fix in scope, spawn a new plan, or accept as finding).
|
|
47
|
+
>
|
|
48
|
+
> To skip the ritual intentionally, add these two fields to the impl's frontmatter:
|
|
49
|
+
>
|
|
50
|
+
> ```yaml
|
|
51
|
+
> falsification: skipped
|
|
52
|
+
> falsification_reason: "why skipping is acceptable for this specific plan"
|
|
53
|
+
> ```
|
|
54
|
+
>
|
|
55
|
+
> The skip-reason is recorded in the archive and surfaced in retrospectives. Use sparingly — typically only for trivial typo-fix plans where the ritual cost exceeds the discipline value.
|
|
56
|
+
|
|
57
|
+
Do not proceed to Step 1 until the gate passes. This is structural enforcement of the discipline documented in the [Falsification Ritual guide](apps/indusk-docs/src/guide/falsification-ritual.md) — happy-path authoring produces happy-path tests, and the ritual is the mechanism for surfacing the gaps the author couldn't think of.
|
|
58
|
+
|
|
31
59
|
### Step 1: Write the Retrospective Document
|
|
32
60
|
|
|
33
61
|
Create `.indusk/planning/{plan-name}/retrospective.md` using the template from the plan skill. This is the reflective writing — what we set out to do, what actually happened, what we learned.
|
package/skills/work.md
CHANGED
|
@@ -204,7 +204,8 @@ The hook validates that both `asked:` and `user:` are present with non-empty quo
|
|
|
204
204
|
- Update impl status to `completed`
|
|
205
205
|
- Summarize what was done
|
|
206
206
|
- If this plan included an ADR, confirm CLAUDE.md's Key Decisions was updated
|
|
207
|
-
-
|
|
207
|
+
- **Run `/falsify {plan}` next, before `/retrospective`.** The falsification ritual is the bridge between "impl done" and "plan archived." It drives the same working agent through a goal-flipped bounty hunt — investigate the code, form a specific hypothesis about what should be broken, write the test that confirms it. The ritual may surface gaps worth addressing, which can reopen the impl (status flips back to `in-progress`) for a fix-in-scope phase, or spawn a new plan, or be recorded as a finding. Only after `/falsify` terminates cleanly — or has been explicitly skipped via `falsification: skipped` + `falsification_reason: "..."` in the impl frontmatter — is the plan ready for `/retrospective`. See the [Falsification Ritual guide](apps/indusk-docs/src/guide/falsification-ritual.md) and `.indusk/planning/archive/falsification-ritual/adr.md`.
|
|
208
|
+
- Let the user know: "Impl complete. Run `/falsify {plan}` next. If it terminates cleanly, then `/retrospective {plan}` will close out the plan."
|
|
208
209
|
|
|
209
210
|
## Teach Mode
|
|
210
211
|
|