@infinitedusky/indusk-mcp 1.15.1 → 1.16.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.
- package/dist/bin/cli.js +48 -24
- package/dist/lib/config.d.ts +17 -0
- package/dist/lib/config.js +29 -1
- package/dist/lib/falsification/log.d.ts +51 -0
- package/dist/lib/falsification/log.js +207 -0
- package/dist/lib/falsification/skip.d.ts +23 -0
- package/dist/lib/falsification/skip.js +37 -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
package/dist/bin/cli.js
CHANGED
|
@@ -3,8 +3,31 @@ import { readFileSync } from "node:fs";
|
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { Command } from "commander";
|
|
6
|
+
import { resolveProjectRoot } from "../lib/config.js";
|
|
6
7
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
8
|
const pkg = JSON.parse(readFileSync(join(__dirname, "../../package.json"), "utf-8"));
|
|
9
|
+
/**
|
|
10
|
+
* Resolve the InDusk project root for commands that operate on an existing
|
|
11
|
+
* project. Walks up from cwd looking for `.indusk/config.json`. If not
|
|
12
|
+
* found, errors out — prevents accidental writes to the wrong `.claude/`
|
|
13
|
+
* when invoked from a sub-directory like `apps/indusk-mcp/`.
|
|
14
|
+
*
|
|
15
|
+
* Commands that CREATE the project root (currently only `init`) use
|
|
16
|
+
* `process.cwd()` directly — init is responsible for creating the marker.
|
|
17
|
+
*/
|
|
18
|
+
function rootOrExit() {
|
|
19
|
+
const cwd = process.cwd();
|
|
20
|
+
const root = resolveProjectRoot(cwd);
|
|
21
|
+
if (root === null) {
|
|
22
|
+
console.error(`Not inside an InDusk project (no .indusk/config.json found walking up from ${cwd}).\n` +
|
|
23
|
+
"Run 'indusk init' here to initialize a new project, or cd to an existing one.");
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
if (root !== cwd) {
|
|
27
|
+
console.info(`[indusk] Using project root: ${root}\n`);
|
|
28
|
+
}
|
|
29
|
+
return root;
|
|
30
|
+
}
|
|
8
31
|
const program = new Command();
|
|
9
32
|
program
|
|
10
33
|
.name("dev-system")
|
|
@@ -29,7 +52,7 @@ program
|
|
|
29
52
|
.description("Update skills from package without touching project content")
|
|
30
53
|
.action(async () => {
|
|
31
54
|
const { update } = await import("./commands/update.js");
|
|
32
|
-
await update(
|
|
55
|
+
await update(rootOrExit());
|
|
33
56
|
});
|
|
34
57
|
const ext = program
|
|
35
58
|
.command("extensions")
|
|
@@ -39,28 +62,28 @@ ext
|
|
|
39
62
|
.description("Show all available extensions")
|
|
40
63
|
.action(async () => {
|
|
41
64
|
const { extensionsList } = await import("./commands/extensions.js");
|
|
42
|
-
await extensionsList(
|
|
65
|
+
await extensionsList(rootOrExit());
|
|
43
66
|
});
|
|
44
67
|
ext
|
|
45
68
|
.command("status")
|
|
46
69
|
.description("Show enabled extensions with health")
|
|
47
70
|
.action(async () => {
|
|
48
71
|
const { extensionsStatus } = await import("./commands/extensions.js");
|
|
49
|
-
await extensionsStatus(
|
|
72
|
+
await extensionsStatus(rootOrExit());
|
|
50
73
|
});
|
|
51
74
|
ext
|
|
52
75
|
.command("enable <names...>")
|
|
53
76
|
.description("Enable extensions")
|
|
54
77
|
.action(async (names) => {
|
|
55
78
|
const { extensionsEnable } = await import("./commands/extensions.js");
|
|
56
|
-
await extensionsEnable(
|
|
79
|
+
await extensionsEnable(rootOrExit(), names);
|
|
57
80
|
});
|
|
58
81
|
ext
|
|
59
82
|
.command("disable <names...>")
|
|
60
83
|
.description("Disable extensions")
|
|
61
84
|
.action(async (names) => {
|
|
62
85
|
const { extensionsDisable } = await import("./commands/extensions.js");
|
|
63
|
-
await extensionsDisable(
|
|
86
|
+
await extensionsDisable(rootOrExit(), names);
|
|
64
87
|
});
|
|
65
88
|
ext
|
|
66
89
|
.command("add <name>")
|
|
@@ -68,35 +91,35 @@ ext
|
|
|
68
91
|
.requiredOption("--from <source>", "Source: npm:pkg, github:user/repo, URL, or local path")
|
|
69
92
|
.action(async (name, opts) => {
|
|
70
93
|
const { extensionsAdd } = await import("./commands/extensions.js");
|
|
71
|
-
await extensionsAdd(
|
|
94
|
+
await extensionsAdd(rootOrExit(), name, opts.from);
|
|
72
95
|
});
|
|
73
96
|
ext
|
|
74
97
|
.command("remove <names...>")
|
|
75
98
|
.description("Remove extensions")
|
|
76
99
|
.action(async (names) => {
|
|
77
100
|
const { extensionsRemove } = await import("./commands/extensions.js");
|
|
78
|
-
await extensionsRemove(
|
|
101
|
+
await extensionsRemove(rootOrExit(), names);
|
|
79
102
|
});
|
|
80
103
|
ext
|
|
81
104
|
.command("update [names...]")
|
|
82
105
|
.description("Update third-party extensions from their original source")
|
|
83
106
|
.action(async (names) => {
|
|
84
107
|
const { extensionsUpdate } = await import("./commands/extensions.js");
|
|
85
|
-
await extensionsUpdate(
|
|
108
|
+
await extensionsUpdate(rootOrExit(), names);
|
|
86
109
|
});
|
|
87
110
|
ext
|
|
88
111
|
.command("suggest")
|
|
89
112
|
.description("Recommend extensions based on project contents")
|
|
90
113
|
.action(async () => {
|
|
91
114
|
const { extensionsSuggest } = await import("./commands/extensions.js");
|
|
92
|
-
await extensionsSuggest(
|
|
115
|
+
await extensionsSuggest(rootOrExit());
|
|
93
116
|
});
|
|
94
117
|
program
|
|
95
118
|
.command("init-docs")
|
|
96
119
|
.description("Scaffold a VitePress documentation site with Mermaid, llms.txt, and FullscreenDiagram")
|
|
97
120
|
.action(async () => {
|
|
98
121
|
const { initDocs } = await import("./commands/init-docs.js");
|
|
99
|
-
await initDocs(
|
|
122
|
+
await initDocs(rootOrExit());
|
|
100
123
|
});
|
|
101
124
|
program
|
|
102
125
|
.command("check-gates")
|
|
@@ -105,7 +128,7 @@ program
|
|
|
105
128
|
.option("--phase <number>", "Check a specific phase number", Number.parseInt)
|
|
106
129
|
.action(async (opts) => {
|
|
107
130
|
const { checkGates } = await import("./commands/check-gates.js");
|
|
108
|
-
await checkGates(
|
|
131
|
+
await checkGates(rootOrExit(), { file: opts.file, phase: opts.phase });
|
|
109
132
|
});
|
|
110
133
|
const infra = program
|
|
111
134
|
.command("infra")
|
|
@@ -144,7 +167,7 @@ graph
|
|
|
144
167
|
const { getLogPath } = await import("../lib/semantic-graph/paths.js");
|
|
145
168
|
const { SemanticGraphClient } = await import("../lib/semantic-graph/runtime-client.js");
|
|
146
169
|
const { runSync } = await import("../lib/semantic-graph/sync-engine.js");
|
|
147
|
-
const projectRoot =
|
|
170
|
+
const projectRoot = rootOrExit();
|
|
148
171
|
const projectName = basename(projectRoot);
|
|
149
172
|
const adapter = new CgcAdapter();
|
|
150
173
|
const logWriter = new LogWriter(getLogPath(projectRoot));
|
|
@@ -164,7 +187,7 @@ graph
|
|
|
164
187
|
const { getLogPath } = await import("../lib/semantic-graph/paths.js");
|
|
165
188
|
const { replay } = await import("../lib/semantic-graph/replay.js");
|
|
166
189
|
const { SemanticGraphClient } = await import("../lib/semantic-graph/runtime-client.js");
|
|
167
|
-
const projectRoot =
|
|
190
|
+
const projectRoot = rootOrExit();
|
|
168
191
|
const projectName = basename(projectRoot);
|
|
169
192
|
const logPath = getLogPath(projectRoot);
|
|
170
193
|
const client = new SemanticGraphClient(projectName);
|
|
@@ -188,7 +211,7 @@ graph
|
|
|
188
211
|
const { getLogPath } = await import("../lib/semantic-graph/paths.js");
|
|
189
212
|
const { readAllEvents } = await import("../lib/semantic-graph/log-reader.js");
|
|
190
213
|
const { SemanticGraphClient } = await import("../lib/semantic-graph/runtime-client.js");
|
|
191
|
-
const projectRoot =
|
|
214
|
+
const projectRoot = rootOrExit();
|
|
192
215
|
const projectName = basename(projectRoot);
|
|
193
216
|
const logPath = getLogPath(projectRoot);
|
|
194
217
|
console.info(`Project: ${projectName}`);
|
|
@@ -223,7 +246,7 @@ program
|
|
|
223
246
|
.description("Strip InDusk settings overlay before a PR")
|
|
224
247
|
.action(async () => {
|
|
225
248
|
const { stripOverlay } = await import("../lib/settings-overlay.js");
|
|
226
|
-
stripOverlay(
|
|
249
|
+
stripOverlay(rootOrExit());
|
|
227
250
|
console.info("Stripped InDusk overlay from .claude/settings.json");
|
|
228
251
|
});
|
|
229
252
|
program
|
|
@@ -231,7 +254,7 @@ program
|
|
|
231
254
|
.description("Re-apply InDusk settings overlay after a PR")
|
|
232
255
|
.action(async () => {
|
|
233
256
|
const { applyOverlay } = await import("../lib/settings-overlay.js");
|
|
234
|
-
applyOverlay(
|
|
257
|
+
applyOverlay(rootOrExit());
|
|
235
258
|
console.info("Re-applied InDusk overlay to .claude/settings.json");
|
|
236
259
|
});
|
|
237
260
|
program
|
|
@@ -239,13 +262,14 @@ program
|
|
|
239
262
|
.description("Install extensions (shorthand for extensions enable / add)")
|
|
240
263
|
.option("--from <source>", "Source for third-party extension (npm:pkg, github:user/repo, URL, or path)")
|
|
241
264
|
.action(async (names, opts) => {
|
|
265
|
+
const root = rootOrExit();
|
|
242
266
|
if (opts.from) {
|
|
243
267
|
const { extensionsAdd } = await import("./commands/extensions.js");
|
|
244
|
-
await extensionsAdd(
|
|
268
|
+
await extensionsAdd(root, names[0], opts.from);
|
|
245
269
|
}
|
|
246
270
|
else {
|
|
247
271
|
const { extensionsEnable } = await import("./commands/extensions.js");
|
|
248
|
-
await extensionsEnable(
|
|
272
|
+
await extensionsEnable(root, names);
|
|
249
273
|
}
|
|
250
274
|
});
|
|
251
275
|
const eval_ = program.command("eval").description("Context evaluation and quality scoring");
|
|
@@ -257,7 +281,7 @@ eval_
|
|
|
257
281
|
.option("--json", "Output as JSON")
|
|
258
282
|
.action(async (opts) => {
|
|
259
283
|
const { evalSummary } = await import("./commands/eval.js");
|
|
260
|
-
await evalSummary(
|
|
284
|
+
await evalSummary(rootOrExit(), opts);
|
|
261
285
|
});
|
|
262
286
|
eval_
|
|
263
287
|
.command("findings")
|
|
@@ -265,21 +289,21 @@ eval_
|
|
|
265
289
|
.option("--all", "Show all findings including fixed/ignored")
|
|
266
290
|
.action(async (opts) => {
|
|
267
291
|
const { evalFindings } = await import("./commands/eval.js");
|
|
268
|
-
await evalFindings(
|
|
292
|
+
await evalFindings(rootOrExit(), opts);
|
|
269
293
|
});
|
|
270
294
|
eval_
|
|
271
295
|
.command("fix <key>")
|
|
272
296
|
.description("Mark an eval finding as fixed")
|
|
273
297
|
.action(async (key) => {
|
|
274
298
|
const { evalMark } = await import("./commands/eval.js");
|
|
275
|
-
await evalMark(
|
|
299
|
+
await evalMark(rootOrExit(), key, "fixed");
|
|
276
300
|
});
|
|
277
301
|
eval_
|
|
278
302
|
.command("ignore <key>")
|
|
279
303
|
.description("Mark an eval finding as ignored")
|
|
280
304
|
.action(async (key) => {
|
|
281
305
|
const { evalMark } = await import("./commands/eval.js");
|
|
282
|
-
await evalMark(
|
|
306
|
+
await evalMark(rootOrExit(), key, "ignored");
|
|
283
307
|
});
|
|
284
308
|
eval_
|
|
285
309
|
.command("baseline")
|
|
@@ -288,7 +312,7 @@ eval_
|
|
|
288
312
|
.option("--keep", "Keep baseline worktree after eval")
|
|
289
313
|
.action(async (opts) => {
|
|
290
314
|
const { evalBaseline } = await import("./commands/eval.js");
|
|
291
|
-
await evalBaseline(
|
|
315
|
+
await evalBaseline(rootOrExit(), opts);
|
|
292
316
|
});
|
|
293
317
|
program
|
|
294
318
|
.command("beam <file>")
|
|
@@ -299,7 +323,7 @@ program
|
|
|
299
323
|
const { runBeam } = await import("../lib/beam/runner.js");
|
|
300
324
|
const { formatBeamMarkdown, formatBeamTrace } = await import("../lib/beam/format.js");
|
|
301
325
|
const result = await runBeam({
|
|
302
|
-
projectRoot:
|
|
326
|
+
projectRoot: rootOrExit(),
|
|
303
327
|
targetPath: file,
|
|
304
328
|
trace: opts.trace ?? false,
|
|
305
329
|
});
|
package/dist/lib/config.d.ts
CHANGED
|
@@ -1,3 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the InDusk project root by walking up from the given directory
|
|
3
|
+
* until `.indusk/config.json` is found. Returns the directory containing
|
|
4
|
+
* `.indusk/config.json`, or `null` if none is found up to the filesystem
|
|
5
|
+
* root.
|
|
6
|
+
*
|
|
7
|
+
* `.indusk/config.json` is the authoritative "this is an InDusk project"
|
|
8
|
+
* marker — created by `indusk init`, never by sub-apps that happen to
|
|
9
|
+
* have their own `.claude/` scaffolding. Walking up to find it prevents
|
|
10
|
+
* bugs like `indusk update` syncing to the wrong `.claude/` when the user
|
|
11
|
+
* runs it from a sub-directory (e.g. `apps/indusk-mcp/`).
|
|
12
|
+
*
|
|
13
|
+
* For `indusk init` itself, use the raw cwd — init creates the marker, so
|
|
14
|
+
* walk-up would either find nothing or (worse) match an ancestor project
|
|
15
|
+
* the user doesn't intend to re-init.
|
|
16
|
+
*/
|
|
17
|
+
export declare function resolveProjectRoot(startDir: string): string | null;
|
|
1
18
|
export interface VerifyToolConfig {
|
|
2
19
|
tool: string;
|
|
3
20
|
config: string;
|
package/dist/lib/config.js
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { basename, dirname, join } from "node:path";
|
|
2
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Resolve the InDusk project root by walking up from the given directory
|
|
5
|
+
* until `.indusk/config.json` is found. Returns the directory containing
|
|
6
|
+
* `.indusk/config.json`, or `null` if none is found up to the filesystem
|
|
7
|
+
* root.
|
|
8
|
+
*
|
|
9
|
+
* `.indusk/config.json` is the authoritative "this is an InDusk project"
|
|
10
|
+
* marker — created by `indusk init`, never by sub-apps that happen to
|
|
11
|
+
* have their own `.claude/` scaffolding. Walking up to find it prevents
|
|
12
|
+
* bugs like `indusk update` syncing to the wrong `.claude/` when the user
|
|
13
|
+
* runs it from a sub-directory (e.g. `apps/indusk-mcp/`).
|
|
14
|
+
*
|
|
15
|
+
* For `indusk init` itself, use the raw cwd — init creates the marker, so
|
|
16
|
+
* walk-up would either find nothing or (worse) match an ancestor project
|
|
17
|
+
* the user doesn't intend to re-init.
|
|
18
|
+
*/
|
|
19
|
+
export function resolveProjectRoot(startDir) {
|
|
20
|
+
let dir = startDir;
|
|
21
|
+
for (let i = 0; i < 20; i++) {
|
|
22
|
+
if (existsSync(join(dir, ".indusk/config.json")))
|
|
23
|
+
return dir;
|
|
24
|
+
const parent = resolve(dir, "..");
|
|
25
|
+
if (parent === dir)
|
|
26
|
+
return null;
|
|
27
|
+
dir = parent;
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
3
31
|
const CONFIG_PATH = ".indusk/config.json";
|
|
4
32
|
export function getConfigPath(projectRoot) {
|
|
5
33
|
return join(projectRoot, CONFIG_PATH);
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export type HypothesisOutcome = "fix-in-scope" | "spawn-plan" | "accept-finding";
|
|
2
|
+
export interface HypothesisEntry {
|
|
3
|
+
kind: "hypothesis";
|
|
4
|
+
hypothesis: string;
|
|
5
|
+
testPath: string | null;
|
|
6
|
+
outcome: HypothesisOutcome;
|
|
7
|
+
note?: string;
|
|
8
|
+
timestamp: string;
|
|
9
|
+
}
|
|
10
|
+
export interface TerminatorEntry {
|
|
11
|
+
kind: "terminator";
|
|
12
|
+
reason: string;
|
|
13
|
+
timestamp: string;
|
|
14
|
+
}
|
|
15
|
+
export type LogEntry = HypothesisEntry | TerminatorEntry;
|
|
16
|
+
export interface MalformedLine {
|
|
17
|
+
lineNumber: number;
|
|
18
|
+
content: string;
|
|
19
|
+
reason: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Append a confirmed-hypothesis entry to the plan's falsification log.
|
|
23
|
+
* Creates the log file with a header if it doesn't yet exist. Throws if
|
|
24
|
+
* the log is already terminated (a new hypothesis after a terminator is a
|
|
25
|
+
* sign the ritual was restarted incorrectly — see `isFalsificationComplete`
|
|
26
|
+
* and start a new plan or explicitly un-terminate first).
|
|
27
|
+
*/
|
|
28
|
+
export declare function appendHypothesis(planRoot: string, entry: Omit<HypothesisEntry, "kind" | "timestamp">): HypothesisEntry;
|
|
29
|
+
/**
|
|
30
|
+
* Append a terminator entry marking the falsification ritual complete for
|
|
31
|
+
* this plan. No further hypotheses can be appended after this. The reason
|
|
32
|
+
* is the user-confirmed rationale for termination (e.g., "investigated
|
|
33
|
+
* concurrency, race conditions, partial-write paths, and type-narrowing
|
|
34
|
+
* gaps; no in-scope failure remained").
|
|
35
|
+
*/
|
|
36
|
+
export declare function markTerminated(planRoot: string, reason: string): TerminatorEntry;
|
|
37
|
+
/**
|
|
38
|
+
* Read the falsification log for a plan. Returns an empty array if the log
|
|
39
|
+
* file does not exist. Malformed entries are skipped (not thrown) and
|
|
40
|
+
* surfaced via the optional `onMalformed` callback, matching the semantic
|
|
41
|
+
* graph event log's resilience pattern.
|
|
42
|
+
*/
|
|
43
|
+
export declare function readFalsificationLog(planRoot: string, opts?: {
|
|
44
|
+
onMalformed?: (malformed: MalformedLine) => void;
|
|
45
|
+
}): LogEntry[];
|
|
46
|
+
/**
|
|
47
|
+
* True iff the plan's falsification log exists AND its last entry is a
|
|
48
|
+
* terminator. False for a missing log, a log with only hypotheses (ritual
|
|
49
|
+
* started but not terminated), or an empty log file.
|
|
50
|
+
*/
|
|
51
|
+
export declare function isFalsificationComplete(planRoot: string): boolean;
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
const VALID_OUTCOMES = new Set([
|
|
4
|
+
"fix-in-scope",
|
|
5
|
+
"spawn-plan",
|
|
6
|
+
"accept-finding",
|
|
7
|
+
]);
|
|
8
|
+
/**
|
|
9
|
+
* Reject multiline content at the library boundary. The log's on-disk
|
|
10
|
+
* format is markdown sections with bold-labeled single-line fields; any
|
|
11
|
+
* line-separator character in hypothesis / note / reason silently
|
|
12
|
+
* truncates during parse (the regex uses /m mode, where $ matches before
|
|
13
|
+
* LF, CR, LS, and PS). Throwing here forces callers to sanitize — either
|
|
14
|
+
* collapse to a single line or split across multiple entries.
|
|
15
|
+
*
|
|
16
|
+
* Line separators rejected: LF (\n), CR (\r), LS (U+2028), PS (U+2029).
|
|
17
|
+
*/
|
|
18
|
+
const LINE_SEPARATOR_RE = /[\n\r\u2028\u2029]/;
|
|
19
|
+
function assertSingleLine(field, value) {
|
|
20
|
+
if (LINE_SEPARATOR_RE.test(value)) {
|
|
21
|
+
throw new Error(`Falsification log ${field} must be single-line (got a value containing a line separator). Either collapse to one line (replace '\\n' with '; '), or split the content across multiple entries. The log's parser is line-oriented; any line-separator (LF, CR, LS, PS) would silently truncate.`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function logPath(planRoot) {
|
|
25
|
+
return join(planRoot, "falsification.md");
|
|
26
|
+
}
|
|
27
|
+
function headerFor(planRoot) {
|
|
28
|
+
return `# Falsification Log — ${basename(planRoot)}\n\nAppend-only record of the /falsify bounty hunt for this plan. Never edit in place; entries are appended via \`appendHypothesis\` and \`markTerminated\` from \`apps/indusk-mcp/src/lib/falsification/log.ts\`.\n\n`;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Append a confirmed-hypothesis entry to the plan's falsification log.
|
|
32
|
+
* Creates the log file with a header if it doesn't yet exist. Throws if
|
|
33
|
+
* the log is already terminated (a new hypothesis after a terminator is a
|
|
34
|
+
* sign the ritual was restarted incorrectly — see `isFalsificationComplete`
|
|
35
|
+
* and start a new plan or explicitly un-terminate first).
|
|
36
|
+
*/
|
|
37
|
+
export function appendHypothesis(planRoot, entry) {
|
|
38
|
+
assertSingleLine("hypothesis", entry.hypothesis);
|
|
39
|
+
if (entry.note !== undefined)
|
|
40
|
+
assertSingleLine("note", entry.note);
|
|
41
|
+
const path = logPath(planRoot);
|
|
42
|
+
const existing = existsSync(path) ? readFalsificationLog(planRoot) : [];
|
|
43
|
+
if (existing.length > 0 && existing[existing.length - 1].kind === "terminator") {
|
|
44
|
+
throw new Error(`Falsification log at ${path} is already terminated. Start a new plan or remove the terminator before appending.`);
|
|
45
|
+
}
|
|
46
|
+
if (!existsSync(path)) {
|
|
47
|
+
writeFileSync(path, headerFor(planRoot), "utf-8");
|
|
48
|
+
}
|
|
49
|
+
const stored = {
|
|
50
|
+
kind: "hypothesis",
|
|
51
|
+
hypothesis: entry.hypothesis,
|
|
52
|
+
testPath: entry.testPath,
|
|
53
|
+
outcome: entry.outcome,
|
|
54
|
+
note: entry.note,
|
|
55
|
+
timestamp: new Date().toISOString(),
|
|
56
|
+
};
|
|
57
|
+
appendFileSync(path, renderHypothesis(stored), "utf-8");
|
|
58
|
+
return stored;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Append a terminator entry marking the falsification ritual complete for
|
|
62
|
+
* this plan. No further hypotheses can be appended after this. The reason
|
|
63
|
+
* is the user-confirmed rationale for termination (e.g., "investigated
|
|
64
|
+
* concurrency, race conditions, partial-write paths, and type-narrowing
|
|
65
|
+
* gaps; no in-scope failure remained").
|
|
66
|
+
*/
|
|
67
|
+
export function markTerminated(planRoot, reason) {
|
|
68
|
+
if (!reason.trim()) {
|
|
69
|
+
throw new Error("markTerminated requires a non-empty reason.");
|
|
70
|
+
}
|
|
71
|
+
assertSingleLine("reason", reason);
|
|
72
|
+
const path = logPath(planRoot);
|
|
73
|
+
const existing = existsSync(path) ? readFalsificationLog(planRoot) : [];
|
|
74
|
+
if (existing.length > 0 && existing[existing.length - 1].kind === "terminator") {
|
|
75
|
+
throw new Error(`Falsification log at ${path} is already terminated.`);
|
|
76
|
+
}
|
|
77
|
+
if (!existsSync(path)) {
|
|
78
|
+
writeFileSync(path, headerFor(planRoot), "utf-8");
|
|
79
|
+
}
|
|
80
|
+
const stored = {
|
|
81
|
+
kind: "terminator",
|
|
82
|
+
reason: reason.trim(),
|
|
83
|
+
timestamp: new Date().toISOString(),
|
|
84
|
+
};
|
|
85
|
+
appendFileSync(path, renderTerminator(stored), "utf-8");
|
|
86
|
+
return stored;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Read the falsification log for a plan. Returns an empty array if the log
|
|
90
|
+
* file does not exist. Malformed entries are skipped (not thrown) and
|
|
91
|
+
* surfaced via the optional `onMalformed` callback, matching the semantic
|
|
92
|
+
* graph event log's resilience pattern.
|
|
93
|
+
*/
|
|
94
|
+
export function readFalsificationLog(planRoot, opts) {
|
|
95
|
+
const path = logPath(planRoot);
|
|
96
|
+
if (!existsSync(path))
|
|
97
|
+
return [];
|
|
98
|
+
const content = readFileSync(path, "utf-8");
|
|
99
|
+
const entries = [];
|
|
100
|
+
const sectionRegex = /^##\s+(Hypothesis|Terminated)\s+(.+?)\s*$/gm;
|
|
101
|
+
const matches = [...content.matchAll(sectionRegex)];
|
|
102
|
+
for (let i = 0; i < matches.length; i++) {
|
|
103
|
+
const match = matches[i];
|
|
104
|
+
const [, kind, timestamp] = match;
|
|
105
|
+
const start = (match.index ?? 0) + match[0].length;
|
|
106
|
+
const end = i + 1 < matches.length ? (matches[i + 1].index ?? content.length) : content.length;
|
|
107
|
+
const body = content.slice(start, end).trim();
|
|
108
|
+
if (kind === "Hypothesis") {
|
|
109
|
+
const entry = parseHypothesisBody(body, timestamp);
|
|
110
|
+
if ("lineNumber" in entry) {
|
|
111
|
+
opts?.onMalformed?.(entry);
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
entries.push(entry);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
else if (kind === "Terminated") {
|
|
118
|
+
const entry = parseTerminatorBody(body, timestamp);
|
|
119
|
+
if ("lineNumber" in entry) {
|
|
120
|
+
opts?.onMalformed?.(entry);
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
entries.push(entry);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return entries;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* True iff the plan's falsification log exists AND its last entry is a
|
|
131
|
+
* terminator. False for a missing log, a log with only hypotheses (ritual
|
|
132
|
+
* started but not terminated), or an empty log file.
|
|
133
|
+
*/
|
|
134
|
+
export function isFalsificationComplete(planRoot) {
|
|
135
|
+
const entries = readFalsificationLog(planRoot);
|
|
136
|
+
if (entries.length === 0)
|
|
137
|
+
return false;
|
|
138
|
+
return entries[entries.length - 1].kind === "terminator";
|
|
139
|
+
}
|
|
140
|
+
// ---------------------------------------------------------------
|
|
141
|
+
// Rendering (writing entries to markdown)
|
|
142
|
+
// ---------------------------------------------------------------
|
|
143
|
+
function renderHypothesis(entry) {
|
|
144
|
+
const lines = [
|
|
145
|
+
`## Hypothesis ${entry.timestamp}`,
|
|
146
|
+
"",
|
|
147
|
+
`**Hypothesis:** ${entry.hypothesis}`,
|
|
148
|
+
`**Test:** ${entry.testPath ?? "(not written)"}`,
|
|
149
|
+
`**Outcome:** ${entry.outcome}`,
|
|
150
|
+
];
|
|
151
|
+
if (entry.note) {
|
|
152
|
+
lines.push(`**Note:** ${entry.note}`);
|
|
153
|
+
}
|
|
154
|
+
lines.push("", "");
|
|
155
|
+
return lines.join("\n");
|
|
156
|
+
}
|
|
157
|
+
function renderTerminator(entry) {
|
|
158
|
+
return [`## Terminated ${entry.timestamp}`, "", `**Reason:** ${entry.reason}`, "", ""].join("\n");
|
|
159
|
+
}
|
|
160
|
+
// ---------------------------------------------------------------
|
|
161
|
+
// Parsing (reading entries from markdown)
|
|
162
|
+
// ---------------------------------------------------------------
|
|
163
|
+
function parseHypothesisBody(body, timestamp) {
|
|
164
|
+
const hypothesisMatch = body.match(/^\*\*Hypothesis:\*\*\s+(.+)$/m);
|
|
165
|
+
const testMatch = body.match(/^\*\*Test:\*\*\s+(.+)$/m);
|
|
166
|
+
const outcomeMatch = body.match(/^\*\*Outcome:\*\*\s+([a-z-]+)$/m);
|
|
167
|
+
const noteMatch = body.match(/^\*\*Note:\*\*\s+(.+)$/m);
|
|
168
|
+
if (!hypothesisMatch || !outcomeMatch) {
|
|
169
|
+
return {
|
|
170
|
+
lineNumber: 0,
|
|
171
|
+
content: body,
|
|
172
|
+
reason: "Hypothesis entry missing required fields (hypothesis or outcome)",
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
const outcome = outcomeMatch[1];
|
|
176
|
+
if (!VALID_OUTCOMES.has(outcome)) {
|
|
177
|
+
return {
|
|
178
|
+
lineNumber: 0,
|
|
179
|
+
content: body,
|
|
180
|
+
reason: `Invalid outcome "${outcome}"; must be one of ${[...VALID_OUTCOMES].join(", ")}`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
const testRaw = testMatch?.[1]?.trim() ?? "(not written)";
|
|
184
|
+
return {
|
|
185
|
+
kind: "hypothesis",
|
|
186
|
+
hypothesis: hypothesisMatch[1].trim(),
|
|
187
|
+
testPath: testRaw === "(not written)" ? null : testRaw,
|
|
188
|
+
outcome,
|
|
189
|
+
note: noteMatch?.[1]?.trim(),
|
|
190
|
+
timestamp,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
function parseTerminatorBody(body, timestamp) {
|
|
194
|
+
const reasonMatch = body.match(/^\*\*Reason:\*\*\s+(.+)$/m);
|
|
195
|
+
if (!reasonMatch) {
|
|
196
|
+
return {
|
|
197
|
+
lineNumber: 0,
|
|
198
|
+
content: body,
|
|
199
|
+
reason: "Terminator entry missing required Reason field",
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
kind: "terminator",
|
|
204
|
+
reason: reasonMatch[1].trim(),
|
|
205
|
+
timestamp,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface SkipCheck {
|
|
2
|
+
skipped: boolean;
|
|
3
|
+
reason: string | null;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Parse an impl.md body (the full file content including frontmatter) and
|
|
7
|
+
* return whether the author has explicitly opted out of the falsification
|
|
8
|
+
* ritual. Opt-out requires both fields in frontmatter:
|
|
9
|
+
*
|
|
10
|
+
* falsification: skipped
|
|
11
|
+
* falsification_reason: "a non-empty reason"
|
|
12
|
+
*
|
|
13
|
+
* Returns `{ skipped: true, reason }` only if both fields are present and
|
|
14
|
+
* the reason is non-empty after trimming. Any other state — missing
|
|
15
|
+
* `falsification`, falsification set to anything other than `skipped`,
|
|
16
|
+
* missing or empty reason — returns `{ skipped: false, reason: null }`.
|
|
17
|
+
*
|
|
18
|
+
* The two-field shape matches the planner skill's precedent (gate_policy
|
|
19
|
+
* as a single enum value) while keeping the reason unambiguous against
|
|
20
|
+
* YAML parsers. Colons inside quoted YAML strings are fragile across
|
|
21
|
+
* parsers; two fields avoid that class of bug entirely.
|
|
22
|
+
*/
|
|
23
|
+
export declare function isFalsificationSkipped(implContent: string): SkipCheck;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import matter from "gray-matter";
|
|
2
|
+
/**
|
|
3
|
+
* Parse an impl.md body (the full file content including frontmatter) and
|
|
4
|
+
* return whether the author has explicitly opted out of the falsification
|
|
5
|
+
* ritual. Opt-out requires both fields in frontmatter:
|
|
6
|
+
*
|
|
7
|
+
* falsification: skipped
|
|
8
|
+
* falsification_reason: "a non-empty reason"
|
|
9
|
+
*
|
|
10
|
+
* Returns `{ skipped: true, reason }` only if both fields are present and
|
|
11
|
+
* the reason is non-empty after trimming. Any other state — missing
|
|
12
|
+
* `falsification`, falsification set to anything other than `skipped`,
|
|
13
|
+
* missing or empty reason — returns `{ skipped: false, reason: null }`.
|
|
14
|
+
*
|
|
15
|
+
* The two-field shape matches the planner skill's precedent (gate_policy
|
|
16
|
+
* as a single enum value) while keeping the reason unambiguous against
|
|
17
|
+
* YAML parsers. Colons inside quoted YAML strings are fragile across
|
|
18
|
+
* parsers; two fields avoid that class of bug entirely.
|
|
19
|
+
*/
|
|
20
|
+
export function isFalsificationSkipped(implContent) {
|
|
21
|
+
try {
|
|
22
|
+
const { data } = matter(implContent);
|
|
23
|
+
const flag = data.falsification;
|
|
24
|
+
const reasonRaw = data.falsification_reason;
|
|
25
|
+
if (flag !== "skipped")
|
|
26
|
+
return { skipped: false, reason: null };
|
|
27
|
+
if (typeof reasonRaw !== "string")
|
|
28
|
+
return { skipped: false, reason: null };
|
|
29
|
+
const reason = reasonRaw.trim();
|
|
30
|
+
if (!reason)
|
|
31
|
+
return { skipped: false, reason: null };
|
|
32
|
+
return { skipped: true, reason };
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return { skipped: false, reason: null };
|
|
36
|
+
}
|
|
37
|
+
}
|
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
|
|