@infinitedusky/indusk-mcp 1.15.0 → 1.15.1

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.
@@ -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) && existsSync(hooksTarget)) {
189
- const hookFiles = [
190
- "check-gates.js",
191
- "gate-reminder.js",
192
- "validate-impl-structure.js",
193
- "check-catchup.js",
194
- "eval-trigger.js",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@infinitedusky/indusk-mcp",
3
- "version": "1.15.0",
3
+ "version": "1.15.1",
4
4
  "description": "InDusk development system — skills, MCP tools, and CLI for structured AI-assisted development",
5
5
  "type": "module",
6
6
  "files": [