@infinitedusky/indusk-mcp 1.9.4 → 1.10.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.
@@ -3,7 +3,7 @@ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, write
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { globSync } from "glob";
6
- import { disableExtension, disabledDir, enableExtension, ensureExtensionsDirs, extensionConfigDir, extensionsDir, getEnabledExtensions, isEnabled, loadExtension, loadExtensions, resolveManifestPath, } from "../../lib/extension-loader.js";
6
+ import { disabledDir, disableExtension, enableExtension, ensureExtensionsDirs, extensionConfigDir, extensionsDir, getEnabledExtensions, isEnabled, loadExtension, loadExtensions, resolveManifestPath, } from "../../lib/extension-loader.js";
7
7
  const __dirname = dirname(fileURLToPath(import.meta.url));
8
8
  const packageRoot = join(__dirname, "../../..");
9
9
  const builtinDir = join(packageRoot, "extensions");
@@ -635,20 +635,31 @@ function printMcpInstructions(name, manifest) {
635
635
  }
636
636
  if (allResolved) {
637
637
  const args = [
638
- "mcp", "add", "-t", "http",
639
- ...headerArgs.flatMap(h => {
638
+ "mcp",
639
+ "add",
640
+ "-t",
641
+ "http",
642
+ ...headerArgs.flatMap((h) => {
640
643
  const match = h.match(/--header "(.+): (.+)"/);
641
644
  if (match)
642
645
  return ["--header", `${match[1]}: ${match[2]}`];
643
646
  return [];
644
647
  }),
645
- "-s", "project", "--", name, url,
648
+ "-s",
649
+ "project",
650
+ "--",
651
+ name,
652
+ url,
646
653
  ];
647
- const cmd = `claude ${args.map(a => a.includes(" ") ? `"${a}"` : a).join(" ")}`;
654
+ const cmd = `claude ${args.map((a) => (a.includes(" ") ? `"${a}"` : a)).join(" ")}`;
648
655
  console.info(`\n ${name}: adding MCP server with credentials from .env...`);
649
656
  // Remove existing entry first so we always write fresh credentials
650
657
  try {
651
- execSync(`claude mcp remove -s project ${name}`, { cwd: process.cwd(), timeout: 10000, stdio: ["ignore", "pipe", "pipe"] });
658
+ execSync(`claude mcp remove -s project ${name}`, {
659
+ cwd: process.cwd(),
660
+ timeout: 10000,
661
+ stdio: ["ignore", "pipe", "pipe"],
662
+ });
652
663
  }
653
664
  catch {
654
665
  // Ignore — may not exist
@@ -299,6 +299,21 @@ export async function init(projectRoot, options = {}) {
299
299
  else {
300
300
  console.info(" skip: codegraphcontext MCP server (already exists)");
301
301
  }
302
+ // Add graphiti MCP server (Phase 5.5 — Streamable HTTP, runs in indusk-infra container)
303
+ const graphitiAddCommand = "claude mcp add -t http -s project graphiti http://localhost:8100/mcp";
304
+ if (!existingServers.has("graphiti") || force) {
305
+ try {
306
+ execSync(graphitiAddCommand, { cwd: projectRoot, stdio: "pipe", timeout: 10000 });
307
+ console.info(" added: graphiti MCP server (http://localhost:8100/mcp)");
308
+ }
309
+ catch {
310
+ console.info(" failed: could not add graphiti MCP server — run manually:");
311
+ console.info(` ${graphitiAddCommand}`);
312
+ }
313
+ }
314
+ else {
315
+ console.info(" skip: graphiti MCP server (already exists)");
316
+ }
302
317
  // 4b. Check infrastructure container
303
318
  console.info("\n[Infrastructure]");
304
319
  try {
@@ -274,7 +274,38 @@ export async function update(projectRoot) {
274
274
  console.info(` ${name}: update hook failed`);
275
275
  }
276
276
  }
277
- if (manifest?.mcp_server?.setup_instructions) {
277
+ // Phase 5.5: ensure declared MCP server is registered in .mcp.json.
278
+ // If the manifest's top-level mcp_server.add_command is set and the server
279
+ // is missing from .mcp.json, run the command. Idempotent — skips if present.
280
+ // The MCP server's name in .mcp.json is the extension name (matches init's
281
+ // `claude mcp add ... <extName> ...` convention).
282
+ const mcpServer = manifest?.mcp_server;
283
+ if (mcpServer?.add_command) {
284
+ const mcpJsonPath = join(projectRoot, ".mcp.json");
285
+ let alreadyRegistered = false;
286
+ if (existsSync(mcpJsonPath)) {
287
+ try {
288
+ const mcpJson = JSON.parse(readFileSync(mcpJsonPath, "utf-8"));
289
+ alreadyRegistered = !!mcpJson.mcpServers?.[name];
290
+ }
291
+ catch { }
292
+ }
293
+ if (!alreadyRegistered) {
294
+ try {
295
+ execSync(mcpServer.add_command, {
296
+ cwd: projectRoot,
297
+ stdio: "pipe",
298
+ timeout: 10000,
299
+ });
300
+ console.info(` ${name}: registered MCP server`);
301
+ }
302
+ catch {
303
+ console.info(` ${name}: failed to register MCP server — run manually:`);
304
+ console.info(` ${mcpServer.add_command}`);
305
+ }
306
+ }
307
+ }
308
+ else if (mcpServer?.setup_instructions) {
278
309
  console.info(` ${name}: MCP server setup — see .claude/skills/${name}/SKILL.md`);
279
310
  }
280
311
  }
@@ -14,8 +14,59 @@ export interface InduskConfig {
14
14
  testRunner?: string;
15
15
  linter?: string;
16
16
  };
17
+ graphiti?: {
18
+ /**
19
+ * Group id used for project-specific Graphiti episodes. Defaults to the
20
+ * project directory basename. Override here if the directory name differs
21
+ * from the desired group id (e.g. shared monorepo, renamed project).
22
+ */
23
+ groupId?: string;
24
+ };
25
+ otel?: {
26
+ /**
27
+ * Project's relationship to OpenTelemetry. Controls whether the OTel gate
28
+ * fires when the planner writes impl phases and when the validate-impl-structure
29
+ * / check-gates hooks evaluate them.
30
+ *
31
+ * - `service`: produces telemetry I want to collect (default behavior; gate fires)
32
+ * - `library`: ships to other people, never produces telemetry (gate silent)
33
+ * - `tool`: short-lived script, telemetry overhead exceeds value (gate silent)
34
+ * - `none`: explicit opt-out for legacy/prototype/internal experiments (gate silent)
35
+ *
36
+ * **If unset, behaves as `service`** (gate fires). This preserves backwards
37
+ * compatibility — existing projects without the field continue to get the OTel
38
+ * gate enforced. Opt-out is explicit; opt-in is implicit.
39
+ */
40
+ role?: "service" | "library" | "tool" | "none";
41
+ };
17
42
  }
18
43
  export declare function getConfigPath(projectRoot: string): string;
19
44
  export declare function readConfig(projectRoot: string): InduskConfig | null;
20
45
  export declare function writeConfig(projectRoot: string, config: InduskConfig): void;
21
46
  export declare function getPlanningDir(projectRoot: string): string;
47
+ /**
48
+ * Get the Graphiti group id for project-specific episodes.
49
+ *
50
+ * Resolution order:
51
+ * 1. .indusk/config.json `graphiti.groupId` if set
52
+ * 2. Project directory basename
53
+ *
54
+ * Use `[getProjectGroupId(root), "shared"]` as the default group_ids list when
55
+ * searching Graphiti — this gives both project-scoped and cross-project knowledge.
56
+ */
57
+ export declare function getProjectGroupId(projectRoot: string): string;
58
+ /**
59
+ * Whether the OTel gate should fire for this project.
60
+ *
61
+ * Returns `true` if `.indusk/config.json` is missing, missing `otel.role`, or
62
+ * has `otel.role: "service"`. Returns `false` only when the project explicitly
63
+ * opts out via `otel.role: "library" | "tool" | "none"`.
64
+ *
65
+ * Used by:
66
+ * - planner skill (whether to write `#### Phase N OTel` sections into impl.md)
67
+ * - validate-impl-structure hook (whether to require an OTel section at write time)
68
+ * - check-gates hook (whether to block phase advancement on missing OTel)
69
+ *
70
+ * Backwards compatible: projects without the new field behave exactly as before.
71
+ */
72
+ export declare function shouldEmitOtelGate(projectRoot: string): boolean;
@@ -1,5 +1,5 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
2
+ import { basename, dirname, join } from "node:path";
3
3
  const CONFIG_PATH = ".indusk/config.json";
4
4
  export function getConfigPath(projectRoot) {
5
5
  return join(projectRoot, CONFIG_PATH);
@@ -26,3 +26,38 @@ export function getPlanningDir(projectRoot) {
26
26
  // Default to new path (will be created by init)
27
27
  return newPath;
28
28
  }
29
+ /**
30
+ * Get the Graphiti group id for project-specific episodes.
31
+ *
32
+ * Resolution order:
33
+ * 1. .indusk/config.json `graphiti.groupId` if set
34
+ * 2. Project directory basename
35
+ *
36
+ * Use `[getProjectGroupId(root), "shared"]` as the default group_ids list when
37
+ * searching Graphiti — this gives both project-scoped and cross-project knowledge.
38
+ */
39
+ export function getProjectGroupId(projectRoot) {
40
+ const config = readConfig(projectRoot);
41
+ if (config?.graphiti?.groupId)
42
+ return config.graphiti.groupId;
43
+ return basename(projectRoot);
44
+ }
45
+ /**
46
+ * Whether the OTel gate should fire for this project.
47
+ *
48
+ * Returns `true` if `.indusk/config.json` is missing, missing `otel.role`, or
49
+ * has `otel.role: "service"`. Returns `false` only when the project explicitly
50
+ * opts out via `otel.role: "library" | "tool" | "none"`.
51
+ *
52
+ * Used by:
53
+ * - planner skill (whether to write `#### Phase N OTel` sections into impl.md)
54
+ * - validate-impl-structure hook (whether to require an OTel section at write time)
55
+ * - check-gates hook (whether to block phase advancement on missing OTel)
56
+ *
57
+ * Backwards compatible: projects without the new field behave exactly as before.
58
+ */
59
+ export function shouldEmitOtelGate(projectRoot) {
60
+ const config = readConfig(projectRoot);
61
+ const role = config?.otel?.role;
62
+ return role === undefined || role === "service";
63
+ }
@@ -53,6 +53,7 @@ export interface ExtensionManifest {
53
53
  headers?: Record<string, string>;
54
54
  env?: Record<string, string>;
55
55
  env_from_shell?: string[];
56
+ add_command?: string;
56
57
  setup_instructions?: string[];
57
58
  };
58
59
  }
@@ -100,10 +100,7 @@ function loadFromDir(baseDir, enabled) {
100
100
  export function loadExtensions(projectRoot) {
101
101
  const dir = extensionsDir(projectRoot);
102
102
  const disDir = disabledDir(projectRoot);
103
- return [
104
- ...loadFromDir(dir, true),
105
- ...loadFromDir(disDir, false),
106
- ];
103
+ return [...loadFromDir(dir, true), ...loadFromDir(disDir, false)];
107
104
  }
108
105
  export function getEnabledExtensions(projectRoot) {
109
106
  return loadExtensions(projectRoot).filter((e) => e.enabled);
@@ -7,8 +7,6 @@
7
7
  "mcp_server": {
8
8
  "type": "http",
9
9
  "url": "https://mcp.excalidraw.com",
10
- "setup_instructions": [
11
- "claude mcp add -t http -- excalidraw https://mcp.excalidraw.com"
12
- ]
10
+ "setup_instructions": ["claude mcp add -t http -- excalidraw https://mcp.excalidraw.com"]
13
11
  }
14
12
  }
@@ -18,6 +18,16 @@
18
18
  }
19
19
  ]
20
20
  },
21
+ "mcp_server": {
22
+ "type": "http",
23
+ "url": "http://localhost:8100/mcp",
24
+ "add_command": "claude mcp add -t http -s project graphiti http://localhost:8100/mcp",
25
+ "setup_instructions": [
26
+ "Registered automatically by `indusk init` and re-added by `indusk update` if missing.",
27
+ "The Graphiti MCP server runs inside the `indusk-infra` container on port 8100.",
28
+ "Exposes 9 tools: add_memory, search_nodes, search_memory_facts, get_episodes, get_entity_edge, delete_episode, delete_entity_edge, clear_graph, get_status."
29
+ ]
30
+ },
21
31
  "detect": {
22
32
  "command": "docker inspect --format='{{.State.Running}}' indusk-infra 2>/dev/null | grep true"
23
33
  }
@@ -2,16 +2,32 @@
2
2
 
3
3
  Graphiti is an episodic memory system backed by FalkorDB. It extracts entities and facts from text, detects contradictions, and supports semantic search across project-specific and shared knowledge.
4
4
 
5
+ The Graphiti MCP server runs inside the `indusk-infra` container on `http://localhost:8100/mcp` and is registered automatically by `indusk init`. The agent has direct access via `mcp__graphiti__*` tools — there is no wrapping layer.
6
+
5
7
  ## When to Use
6
8
 
7
- - **Episode capture**: After a decision, retro finding, or correction — anything worth remembering across sessions
9
+ - **Capture**: After a decision, retro finding, or correction — anything worth remembering across sessions
8
10
  - **Search**: Before making assumptions — check what's already known about a topic
9
- - **Context retrieval**: At session start or when working in an unfamiliar area
11
+ - **Recall**: At session start (`/catchup` does this automatically) or when working in an unfamiliar area
12
+
13
+ ## Tools
14
+
15
+ The agent has nine `mcp__graphiti__*` tools. The five you'll use most:
16
+
17
+ | Tool | Purpose |
18
+ |------|---------|
19
+ | `add_memory` | Capture an episode (text/JSON/message) — entities and facts are extracted asynchronously |
20
+ | `search_nodes` | Find entities by natural-language query |
21
+ | `search_memory_facts` | Find facts (relationships between entities) by query |
22
+ | `get_episodes` | List recent episodes by group |
23
+ | `get_status` | Check that Graphiti is reachable and the database is healthy |
24
+
25
+ The other four (`delete_episode`, `delete_entity_edge`, `get_entity_edge`, `clear_graph`) are for cleanup — use sparingly, never in normal flow.
10
26
 
11
27
  ## Core Concepts
12
28
 
13
29
  ### Episodes
14
- An episode is a chunk of text that Graphiti processes into entities and facts. Think of it as "something that happened" — a decision was made, a bug was found, a convention was established.
30
+ An episode is a chunk of text that Graphiti processes into entities and facts. Think of it as "something that happened" — a decision was made, a bug was found, a convention was established. Episodes are processed in the background; entities appear in search results once extraction completes (a few seconds).
15
31
 
16
32
  ### Group IDs
17
33
  Every episode belongs to a group. Groups isolate knowledge:
@@ -21,54 +37,123 @@ Every episode belongs to a group. Groups isolate knowledge:
21
37
  | `{project-name}` | Project-specific knowledge | `infinitedusky`, `numero` |
22
38
  | `shared` | Cross-project conventions | Developer preferences, universal patterns |
23
39
 
24
- When searching, always include both the project group and `shared` to get the full picture. The `GraphitiClient` does this automatically.
40
+ When searching, always include both the project group and `shared` to get the full picture. Use `getProjectGroupId(projectRoot)` (from `apps/indusk-mcp/src/lib/config.ts`) to get the project group consistently — it reads `.indusk/config.json` `graphiti.groupId` if set, otherwise falls back to the project directory basename.
25
41
 
26
42
  ### Entities and Facts
27
43
  Graphiti extracts:
28
44
  - **Entities**: Named things (tools, patterns, files, concepts)
29
45
  - **Facts**: Relationships between entities with temporal validity
30
46
 
31
- Facts can be contradicted — if you add "the parser handles three gate types" and later "the parser handles four gate types", Graphiti invalidates the old fact.
47
+ Facts can be contradicted — if you add "the parser handles three gate types" and later "the parser handles four gate types", Graphiti invalidates the old fact. The contradicted fact gets `invalid_at` set; the new one gets `valid_at` set. Search results respect this — invalid facts are excluded by default.
32
48
 
33
49
  ## Patterns
34
50
 
35
51
  ### Capturing a Decision
36
- After an ADR is accepted or a significant choice is made:
52
+
53
+ After an ADR is accepted or a significant choice is made, the planner skill calls this automatically. To do it manually:
54
+
37
55
  ```
38
- addEpisode("auth-approach-decision",
39
- "We chose JWT with refresh tokens over session cookies because the API serves both web and mobile clients. Session cookies don't work well with React Native.",
40
- { groupId: "myproject" })
56
+ mcp__graphiti__add_memory({
57
+ name: "auth-approach-decision",
58
+ episode_body: "We chose JWT with refresh tokens over session cookies because the API serves both web and mobile clients. Session cookies don't work well with React Native.",
59
+ group_id: "myproject",
60
+ source: "text",
61
+ source_description: "ADR"
62
+ })
41
63
  ```
42
64
 
65
+ The `name` should be short and topical — the agent uses it as a handle when re-discussing the decision. The `episode_body` should be detailed enough that someone reading it cold understands the decision and the reasoning.
66
+
43
67
  ### Capturing a Correction
44
- When someone corrects the agent or a mistake is found:
68
+
69
+ When the user corrects the agent, the work skill prompts `context learn` AND captures the correction as an episode:
70
+
45
71
  ```
46
- addEpisode("correction-test-database",
47
- "Integration tests must use a real database, not mocks. We got burned when mocked tests passed but the production migration failed.",
48
- { groupId: "shared" })
72
+ mcp__graphiti__add_memory({
73
+ name: "correction-pnpm-ce",
74
+ episode_body: "Always use `pnpm ce`, never `npx ce`. The composable.env skill specifies pnpm and the project's package.json maps `ce` to the binary. npx invokes a different code path.",
75
+ group_id: "shared",
76
+ source: "text",
77
+ source_description: "user correction"
78
+ })
49
79
  ```
50
80
 
81
+ Choosing `shared` vs project group:
82
+ - **`shared`**: tools, conventions, patterns that apply across projects ("always use pnpm ce", "never mock the database in integration tests")
83
+ - **`{project-name}`**: facts specific to one project's code, data, or domain ("the impl-parser handles four gate types per phase", "the bet matching engine is order-book based")
84
+
85
+ When in doubt, ask: "Would this correction make sense to a different project?" Yes → `shared`. No → project group.
86
+
51
87
  ### Searching Before Acting
88
+
52
89
  Before making assumptions about how something works:
90
+
53
91
  ```
54
- searchNodes("authentication middleware")
55
- searchFacts("how does auth work in this project")
92
+ mcp__graphiti__search_nodes({
93
+ query: "authentication middleware",
94
+ group_ids: ["myproject", "shared"],
95
+ max_nodes: 10
96
+ })
97
+
98
+ mcp__graphiti__search_memory_facts({
99
+ query: "how does auth work in this project",
100
+ group_ids: ["myproject", "shared"],
101
+ max_facts: 10
102
+ })
56
103
  ```
57
104
 
105
+ Always search both the project group and `shared` — knowledge is split across them. The wrapper class `GraphitiClient` in `apps/indusk-mcp/src/lib/graphiti-client.ts` does this automatically when called internally; agents calling `mcp__graphiti__*` tools directly need to pass both group ids in the request.
106
+
58
107
  ### Capturing a Retrospective Finding
59
- After a plan retrospective surfaces a useful insight:
108
+
109
+ After a plan retrospective surfaces a useful insight, the retrospective skill captures it as an episode:
110
+
111
+ ```
112
+ mcp__graphiti__add_memory({
113
+ name: "retro-gate-enforcement-1",
114
+ episode_body: "Plan gates need hook-based enforcement, not just instructions. The agent skipped gates when they were advisory only. PreToolUse hooks that block phase transitions are the fix.",
115
+ group_id: "infinitedusky",
116
+ source: "text",
117
+ source_description: "retrospective insight"
118
+ })
119
+ ```
120
+
121
+ One episode per insight, named `retro-{plan}-{n}`. If the retro surfaces a contradiction (we thought X, found Y), capture both framings as separate episodes — Graphiti's contradiction detection invalidates the older one.
122
+
123
+ ### Recall at Session Start
124
+
125
+ The `/catchup` skill calls this automatically after reading project context, but you can also trigger it manually:
126
+
60
127
  ```
61
- addEpisode("retro-gate-enforcement",
62
- "Plan gates need hook-based enforcement, not just instructions. The agent skipped gates when they were advisory only. PreToolUse hooks that block phase transitions are the fix.",
63
- { groupId: "myproject" })
128
+ mcp__graphiti__search_nodes({
129
+ query: "recent decisions",
130
+ group_ids: ["myproject", "shared"],
131
+ max_nodes: 5
132
+ })
64
133
  ```
65
134
 
135
+ Surface anything notable to the user. Pay extra attention to facts where `invalid_at` is recent — those are areas where prior decisions changed and active plans may be working from stale assumptions.
136
+
137
+ ## Capture Triggers (Where Episodes Come From)
138
+
139
+ In normal workflow, episodes are written automatically by other skills. The agent rarely calls `add_memory` directly:
140
+
141
+ | Trigger | Skill | Episode |
142
+ |---------|-------|---------|
143
+ | Brief accepted | `planner` | `brief-accepted-{plan}` — Problem + Proposed Direction in project group |
144
+ | ADR accepted | `planner` | `adr-{plan}` — Y-statement in project group |
145
+ | User correction (`context learn`) | `work` | `correction-{slug}` — lesson body in `shared` or project group |
146
+ | Retrospective lesson | `retrospective` | `retro-{plan}-{n}` — one per insight in project group |
147
+ | Retrospective "would do differently" | `retrospective` | `retro-{plan}-wdid-{n}` — one per item in project group |
148
+
149
+ The agent should call `add_memory` directly **only** when something worth remembering happens outside these trigger points. Most of the time, just trust the skills to capture and let `/catchup` recall.
150
+
66
151
  ## What NOT to Capture
67
152
 
68
- - Code structure (CGC handles this)
69
- - Git history (git log handles this)
70
- - Ephemeral state (current task, in-progress work)
71
- - Things already in CLAUDE.md or lessons
153
+ - **Code structure** CGC handles this. `function X calls function Y` is a graph relationship, not an episode.
154
+ - **Git history** — `git log` is authoritative.
155
+ - **Ephemeral state** current task, in-progress work, todo lists. Use the TodoWrite tool, not Graphiti.
156
+ - **Things already in CLAUDE.md or lessons** — those layers exist for stable, project-wide truths. Graphiti is for things that have temporal context and might be contradicted.
72
157
 
73
158
  Graphiti is for knowledge that has temporal context — decisions that might change, facts that might be contradicted, insights that accumulate over time.
74
159
 
@@ -86,6 +171,13 @@ Global config (API keys, OTel): `~/.indusk/config.env`
86
171
 
87
172
  ### Graceful Degradation
88
173
  If the `indusk-infra` container is down:
89
- - CGC graph tools still check FalkorDB directly
90
- - Graphiti client methods return null/empty (never throw)
91
- - The agent continues working with flat-file context (CLAUDE.md, lessons, skills)
174
+ - `mcp__graphiti__*` tools will report a clean error (the MCP transport fails to connect)
175
+ - The agent should fall back to flat-file context (CLAUDE.md, lessons, skills) and continue working
176
+ - The `GraphitiClient` wrapper class (used internally by indusk-mcp) returns null/empty instead of throwing
177
+ - Don't pretend the call succeeded — if Graphiti is down, capture is lost. Tell the user to `indusk infra start`.
178
+
179
+ ### Health Check
180
+ ```
181
+ mcp__graphiti__get_status({})
182
+ ```
183
+ Returns `{ status: "healthy", message: "..." }` when Graphiti is reachable and the database is connected.
@@ -2,11 +2,16 @@
2
2
  /**
3
3
  * PreToolUse hook: blocks phase transitions in impl.md when gates are incomplete.
4
4
  *
5
+ * The OTel gate is conditional on the project's `otel.role` in .indusk/config.json:
6
+ * - unset or "service": OTel gate is enforced (default)
7
+ * - "library" / "tool" / "none": OTel gate is silenced (mirrors validate-impl-structure)
8
+ *
5
9
  * Exit 0 = allow the edit
6
10
  * Exit 2 = block the edit (stderr sent to agent as feedback)
7
11
  */
8
12
 
9
- import { readFileSync } from "node:fs";
13
+ import { existsSync, readFileSync } from "node:fs";
14
+ import { resolve } from "node:path";
10
15
 
11
16
  // Read hook input from stdin
12
17
  let input = "";
@@ -115,14 +120,57 @@ function detectWorkflow(content) {
115
120
  return m ? m[1] : "feature";
116
121
  }
117
122
 
118
- // Which gate types are required per workflow
119
- const WORKFLOW_GATES = {
123
+ /**
124
+ * Find the project root by walking up from a starting directory looking for
125
+ * a .indusk/ or .claude/ directory. Falls back to startDir if none found.
126
+ */
127
+ function findProjectRoot(startDir) {
128
+ let dir = startDir;
129
+ for (let i = 0; i < 10; i++) {
130
+ if (existsSync(`${dir}/.indusk`) || existsSync(`${dir}/.claude`)) return dir;
131
+ const parent = resolve(dir, "..");
132
+ if (parent === dir) break;
133
+ dir = parent;
134
+ }
135
+ return startDir;
136
+ }
137
+
138
+ /**
139
+ * Whether the OTel gate should fire for this project. Reads .indusk/config.json
140
+ * and checks otel.role. Returns true if missing/unset/"service", false for
141
+ * library/tool/none. Mirrors shouldEmitOtelGate() in apps/indusk-mcp/src/lib/config.ts.
142
+ */
143
+ function shouldEmitOtelGate(projectRoot) {
144
+ const configPath = `${projectRoot}/.indusk/config.json`;
145
+ if (!existsSync(configPath)) return true;
146
+ try {
147
+ const config = JSON.parse(readFileSync(configPath, "utf-8"));
148
+ const role = config?.otel?.role;
149
+ return role === undefined || role === "service";
150
+ } catch {
151
+ return true;
152
+ }
153
+ }
154
+
155
+ const projectRoot = findProjectRoot(event.cwd ?? process.cwd());
156
+ const otelGateEnabled = shouldEmitOtelGate(projectRoot);
157
+
158
+ // Which gate types are required per workflow.
159
+ // OTel is filtered out below when the project opts out via otel.role.
160
+ const WORKFLOW_GATES_BASE = {
120
161
  feature: ["verification", "otel", "context", "document"],
121
162
  refactor: ["verification", "otel", "context", "document"],
122
163
  bugfix: ["verification", "document"],
123
164
  spike: [],
124
165
  };
125
166
 
167
+ const WORKFLOW_GATES = Object.fromEntries(
168
+ Object.entries(WORKFLOW_GATES_BASE).map(([wf, gates]) => [
169
+ wf,
170
+ otelGateEnabled ? gates : gates.filter((g) => g !== "otel"),
171
+ ]),
172
+ );
173
+
126
174
  // Parse phases from the NEW content (after edit) and OLD content (before edit)
127
175
  function parsePhases(content) {
128
176
  // Strip frontmatter
@@ -2,14 +2,20 @@
2
2
  /**
3
3
  * PreToolUse hook: validates that impl phases have all four gate sections.
4
4
  *
5
- * Every phase must have: implementation items, Verification, OTel, Context, Document.
5
+ * Every phase must have: implementation items, Verification, OTel*, Context, Document.
6
6
  * Sections can opt out with (none needed), (not applicable), or skip-reason: {why}.
7
7
  *
8
+ * *OTel section is conditional on the project's `otel.role` in .indusk/config.json:
9
+ * - unset or "service": OTel section is required (default behavior)
10
+ * - "library" / "tool" / "none": OTel section is NOT required (gate silenced)
11
+ * This mirrors `shouldEmitOtelGate()` in apps/indusk-mcp/src/lib/config.ts.
12
+ *
8
13
  * Exit 0 = allow the edit
9
14
  * Exit 2 = block the edit (stderr sent to agent as feedback)
10
15
  */
11
16
 
12
- import { readFileSync } from "node:fs";
17
+ import { existsSync, readFileSync } from "node:fs";
18
+ import { resolve } from "node:path";
13
19
 
14
20
  // Read hook input from stdin
15
21
  let input = "";
@@ -26,6 +32,43 @@ if (!filePath.endsWith("/impl.md") && !filePath.endsWith("\\impl.md")) {
26
32
  process.exit(0);
27
33
  }
28
34
 
35
+ /**
36
+ * Find the project root by walking up from a starting directory looking for
37
+ * a .indusk/ or .claude/ directory. Falls back to event.cwd if none found.
38
+ * Mirrors the pattern used in check-catchup.js.
39
+ */
40
+ function findProjectRoot(startDir) {
41
+ let dir = startDir;
42
+ for (let i = 0; i < 10; i++) {
43
+ if (existsSync(`${dir}/.indusk`) || existsSync(`${dir}/.claude`)) return dir;
44
+ const parent = resolve(dir, "..");
45
+ if (parent === dir) break;
46
+ dir = parent;
47
+ }
48
+ return startDir;
49
+ }
50
+
51
+ /**
52
+ * Whether the OTel gate should fire for this project. Reads .indusk/config.json
53
+ * and checks otel.role. Returns true if the config is missing, if otel.role is
54
+ * unset, or if otel.role is "service" — matches shouldEmitOtelGate() in
55
+ * apps/indusk-mcp/src/lib/config.ts exactly.
56
+ */
57
+ function shouldEmitOtelGate(projectRoot) {
58
+ const configPath = `${projectRoot}/.indusk/config.json`;
59
+ if (!existsSync(configPath)) return true;
60
+ try {
61
+ const config = JSON.parse(readFileSync(configPath, "utf-8"));
62
+ const role = config?.otel?.role;
63
+ return role === undefined || role === "service";
64
+ } catch {
65
+ return true; // on parse error, preserve default behavior
66
+ }
67
+ }
68
+
69
+ const projectRoot = findProjectRoot(event.cwd ?? process.cwd());
70
+ const otelGateEnabled = shouldEmitOtelGate(projectRoot);
71
+
29
72
  // Check for skip-gates escape hatch
30
73
  const newContent = toolInput.new_string ?? toolInput.content ?? "";
31
74
 
@@ -100,10 +143,13 @@ const body = fmMatch ? newFullContent.slice(fmMatch[0].length) : newFullContent;
100
143
  const workflowMatch = frontmatter.match(/workflow:\s*(bugfix|refactor|feature|spike)/);
101
144
  const workflow = workflowMatch ? workflowMatch[1] : "feature";
102
145
 
103
- // Different workflows have different requirements
146
+ // Different workflows have different requirements.
147
+ // OTel is further gated on the project's `otel.role` in .indusk/config.json —
148
+ // libraries/tools/none opt out of the OTel gate entirely. Workflows that normally
149
+ // require OTel (feature, refactor) will only require it when otelGateEnabled is true.
104
150
  const requirements = {
105
- feature: { verification: true, otel: true, context: true, document: true },
106
- refactor: { verification: true, otel: true, context: true, document: true },
151
+ feature: { verification: true, otel: otelGateEnabled, context: true, document: true },
152
+ refactor: { verification: true, otel: otelGateEnabled, context: true, document: true },
107
153
  bugfix: { verification: true, otel: false, context: false, document: true },
108
154
  spike: { verification: false, otel: false, context: false, document: false },
109
155
  }[workflow];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@infinitedusky/indusk-mcp",
3
- "version": "1.9.4",
3
+ "version": "1.10.1",
4
4
  "description": "InDusk development system — skills, MCP tools, and CLI for structured AI-assisted development",
5
5
  "type": "module",
6
6
  "files": [
package/skills/catchup.md CHANGED
@@ -64,6 +64,29 @@ Read it fully. Don't skim.
64
64
 
65
65
  **After reading, edit the handoff to check off:** `- [x] context`
66
66
 
67
+ ### 4.5. Recall from Graphiti
68
+
69
+ CLAUDE.md is the stable, slow-changing layer of project memory. Graphiti is the fast, temporal layer — it captures decisions, corrections, and retrospective insights as they happen. Catchup pulls both layers so the agent starts the session with full context.
70
+
71
+ **Recall recent decisions and lessons:**
72
+ ```
73
+ mcp__graphiti__search_nodes({
74
+ query: "recent decisions and lessons",
75
+ group_ids: ["{project-group}", "shared"],
76
+ max_nodes: 8
77
+ })
78
+ ```
79
+
80
+ The project group comes from the `getProjectGroupId(projectRoot)` helper (in `apps/indusk-mcp/src/lib/config.ts`). Always include both the project group and `shared` so cross-project conventions surface alongside project-specific knowledge.
81
+
82
+ **Surface contradictions:** look at the returned nodes for any whose `attributes` reference recently invalidated facts (Graphiti marks superseded facts with `invalid_at`). If a recently invalidated fact relates to an active plan or current code area, flag it to the user — those are places where assumptions changed.
83
+
84
+ **Output format:** include a "Graphiti recall" section in the catchup summary with the most relevant 3-5 nodes by name + summary. Don't dump everything — surface what's actionable.
85
+
86
+ **Graceful degradation:** If `mcp__graphiti__search_nodes` is unavailable (Graphiti container down, transport error), skip this step silently and add a note to the catchup summary: `Graphiti: unavailable (run \`indusk infra start\` to recall episodic memory)`. Catchup should not fail if Graphiti is down — the rest of the layers are still valid.
87
+
88
+ **After completing recall, edit the handoff to check off:** `- [x] graphiti` (added to the catchup status box section)
89
+
67
90
  ### 5. Check Active Plans
68
91
  Call `list_plans`. This shows every plan and its status. Pay attention to:
69
92
  - Plans with status `in-progress` — these are actively being worked on
@@ -104,6 +127,7 @@ After completing all steps, present a brief summary to the user:
104
127
  - Extensions: N enabled [list names]
105
128
  - Active plans: [list with current phase]
106
129
  - Codebase: [N files indexed]
130
+ - Graphiti recall: [3-5 most relevant nodes by name + summary, or "unavailable" if Graphiti is down]
107
131
 
108
132
  Ready to pick up. What would you like to do?
109
133
  ```
package/skills/planner.md CHANGED
@@ -74,8 +74,32 @@ Workflow templates are in `templates/workflows/` in the package. They describe w
74
74
 
75
75
  4. **If research is done**, write the brief. This is where a direction emerges from the research. The brief proposes what we're building and why, informed by what the research uncovered. **Consider creating a visual sketch** of the proposed architecture with Excalidraw (if the extension is enabled) — a hand-drawn diagram makes the proposal concrete and easier to discuss. **Present the brief and have a conversation about it.** Don't just ask "does this look good?" — walk the user through it: "Here's what I'm proposing we build. Does this match what you had in mind? Is there anything missing, or anything here you don't want?" Iterate until the user is genuinely happy with the direction, then mark it as `accepted`.
76
76
 
77
+ **When the brief moves from `draft` to `accepted`**, capture the decision in Graphiti:
78
+ ```
79
+ mcp__graphiti__add_memory({
80
+ name: "brief-accepted-{plan-name}",
81
+ episode_body: "{Problem}\n\n{Proposed Direction}\n\n{Scope summary if helpful}",
82
+ group_id: "{project-group}",
83
+ source: "text",
84
+ source_description: "brief acceptance"
85
+ })
86
+ ```
87
+ The `group_id` is the project group (use `getProjectGroupId(projectRoot)` from `apps/indusk-mcp/src/lib/config.ts` — defaults to project directory basename, override via `.indusk/config.json` `graphiti.groupId`). Skip silently if `mcp__graphiti__add_memory` is unavailable (Graphiti may be down — degrade gracefully, do not fail the brief acceptance).
88
+
77
89
  5. **If brief is accepted** and the workflow includes an ADR (feature only), write the ADR. The ADR formalizes the decisions that were discussed during research and led to the brief. It records what was chosen, what was rejected, and why. **After the ADR is accepted**, add a one-liner to CLAUDE.md's Key Decisions section per the context skill: `- {decision summary} — see .indusk/planning/{plan}/adr.md`
78
90
 
91
+ **When the ADR moves from `proposed` to `accepted`**, capture the Y-statement in Graphiti:
92
+ ```
93
+ mcp__graphiti__add_memory({
94
+ name: "adr-{plan-name}",
95
+ episode_body: "In the context of {use case}, facing {constraint}, we decided for {chosen option} and against {rejected alternatives}, to achieve {desired outcome}, accepting {tradeoff}, because {rationale}.",
96
+ group_id: "{project-group}",
97
+ source: "text",
98
+ source_description: "ADR acceptance"
99
+ })
100
+ ```
101
+ The Y-statement is rich enough that Graphiti will extract entities for the chosen option, rejected alternatives, constraint, and rationale — and will detect contradictions if a later ADR overrides this one. Skip silently on Graphiti unavailability.
102
+
79
103
  6. **If ADR is accepted** (or brief is accepted for bugfix/refactor), write the impl. Break into phased checklists with concrete tasks. For refactor workflows, include a `## Boundary Map` section. For multi-phase impls of any type, consider adding a boundary map.
80
104
 
81
105
  **Gate policy applies when writing impls.** Set `gate_policy` in the impl frontmatter (`strict`, `ask`, or `auto`). The `validate-impl-structure` hook enforces this at write time:
@@ -84,6 +108,8 @@ Workflow templates are in `templates/workflows/` in the package. They describe w
84
108
 
85
109
  Default is `ask`. See the work skill "Gate Override Policy" for full details on what each mode enforces at execution time.
86
110
 
111
+ **OTel gate is conditional on `otel.role`.** Read `.indusk/config.json` for the project's `otel.role` field (or use the `shouldEmitOtelGate(projectRoot)` helper from `apps/indusk-mcp/src/lib/config.ts`). The OTel gate fires for projects whose `otel.role` is unset or `"service"` — these are user-facing apps that produce telemetry you want to collect. **Do NOT write `#### Phase N OTel` sections** for projects whose `otel.role` is `"library"`, `"tool"`, or `"none"` — these are libraries, CLIs, or scripts that should never emit telemetry and writing OTel gates for them is friction without value. The `validate-impl-structure` and `check-gates` hooks apply the same rule. The other gates (verify, context, document) always apply regardless of `otel.role`.
112
+
87
113
  7. **If impl is completed** (all items checked off by `/work`), invoke the retrospective skill (`/retrospective {plan-name}`). This handles the structured audit (docs, tests, quality, context), knowledge handoff to the docs site, and archival. Do not write a freeform retrospective — use the skill. (Bugfix and refactor workflows may skip retrospective for small changes — user's call.)
88
114
 
89
115
  8. **Always present each document for review** before moving to the next stage. The user signs off on each step.
@@ -256,6 +282,8 @@ For multi-phase impls, include a boundary map showing what each phase produces a
256
282
  function withdrawFor(wallet: address, player: address, amount: uint256, historyHash: bytes32)
257
283
  ```
258
284
 
285
+ {OPTIONAL: #### Phase 1 OTel — include ONLY if the project's `otel.role` in `.indusk/config.json` is unset or `"service"`. Skip the entire OTel block for projects with `otel.role: "library" | "tool" | "none"`. Use `shouldEmitOtelGate(projectRoot)` from `apps/indusk-mcp/src/lib/config.ts` to decide.}
286
+
259
287
  #### Phase 1 OTel
260
288
  - [ ] {Instrumentation check — are new code paths observable? See the OTel skill for patterns. Example items: "New endpoints have manual spans with `otel.category` and domain attributes", "Errors recorded with `recordException` + `setStatus(ERROR)` + trace-correlated log". Ask: "did this phase add endpoints, business logic, state transitions, or error paths?" If not, this section can be opted out per gate policy.}
261
289
 
@@ -91,6 +91,36 @@ If yes, call `add_lesson` for each one. These become personal lessons in `.claud
91
91
 
92
92
  If no lessons emerged, that's fine — not every plan produces new knowledge. Move on.
93
93
 
94
+ **Also capture each retrospective insight in Graphiti** so it becomes part of the temporal knowledge graph and can surface in future searches and contradictions.
95
+
96
+ For each item in the retrospective's **What We Learned** section:
97
+ ```
98
+ mcp__graphiti__add_memory({
99
+ name: "retro-{plan-name}-{n}",
100
+ episode_body: "{the full insight, including context — not just a one-line summary}",
101
+ group_id: "{project-group}",
102
+ source: "text",
103
+ source_description: "retrospective lesson"
104
+ })
105
+ ```
106
+
107
+ For each item in the retrospective's **What We'd Do Differently** section:
108
+ ```
109
+ mcp__graphiti__add_memory({
110
+ name: "retro-{plan-name}-wdid-{n}",
111
+ episode_body: "{the hindsight item, with reasoning}",
112
+ group_id: "{project-group}",
113
+ source: "text",
114
+ source_description: "retrospective hindsight"
115
+ })
116
+ ```
117
+
118
+ Use the project group (from `getProjectGroupId(projectRoot)`) — most retrospective insights are project-specific. Promote to `shared` only if the insight is clearly cross-project (those should also become a personal lesson via `add_lesson`).
119
+
120
+ **Contradictions:** If the retrospective surfaces a moment where "we thought X but found Y", capture both as separate episodes. Graphiti's contradiction detection will invalidate the older fact when it sees the conflicting one. This is one of Graphiti's most useful features — it remembers that a previous assumption was overturned, so the agent doesn't accidentally re-introduce it later.
121
+
122
+ Skip silently if `mcp__graphiti__add_memory` is unavailable — Graphiti capture is best-effort, and lesson recording via `add_lesson` is the canonical path. Graphiti capture is supplementary.
123
+
94
124
  ### Step 7: Context Audit
95
125
 
96
126
  Re-read CLAUDE.md in full. After the entire impl is done, verify:
package/skills/work.md CHANGED
@@ -202,6 +202,27 @@ When you are corrected mid-work — the user says "no, not that way" or "don't d
202
202
 
203
203
  Don't wait to be told. Corrections are the most valuable source of project knowledge.
204
204
 
205
+ **When the user confirms `context learn`, ALSO capture the correction in Graphiti:**
206
+ ```
207
+ mcp__graphiti__add_memory({
208
+ name: "correction-{slug}",
209
+ episode_body: "{lesson text}",
210
+ group_id: "{shared OR project-group}",
211
+ source: "text",
212
+ source_description: "user correction"
213
+ })
214
+ ```
215
+
216
+ Where `{slug}` is a short kebab-case label for the topic (e.g. `correction-pnpm-ce`, `correction-graphiti-error-handling`).
217
+
218
+ **Choosing `shared` vs project group:**
219
+ - **`shared`**: tools, conventions, patterns that apply across projects. Examples: "always use pnpm ce", "never mock the database in integration tests", "use jj describe-then-do for commits". The lesson is generally true and a different project would benefit from it.
220
+ - **`{project-group}`**: facts specific to this project's code, data, or domain. Examples: "the impl-parser handles four gate types per phase", "graph_ensure auto-repairs the indusk-infra container". The lesson only makes sense in the context of this project.
221
+
222
+ When in doubt, ask: "Would this correction make sense to a different project?" Yes → `shared`. No → project group.
223
+
224
+ Use `getProjectGroupId(projectRoot)` from `apps/indusk-mcp/src/lib/config.ts` to get the project group consistently. Skip silently if `mcp__graphiti__add_memory` is unavailable — Graphiti capture is best-effort, do not fail the work item.
225
+
205
226
  ## Commits (jj)
206
227
 
207
228
  Use the **describe-then-do** workflow from the jj skill: