@danielblomma/cortex-mcp 2.0.3 → 2.0.5

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/bin/cortex.mjs CHANGED
@@ -22,17 +22,20 @@ const PACKAGE_ROOT = path.resolve(__dirname, "..");
22
22
  const SCAFFOLD_ROOT = path.join(PACKAGE_ROOT, "scaffold");
23
23
  const PACKAGE_JSON_PATH = path.join(PACKAGE_ROOT, "package.json");
24
24
 
25
+ // v2.0.5: project layout moved mcp/ under .context/mcp/, and the
26
+ // gitignore policy flipped to "ignore everything in .context/, whitelist
27
+ // only the three editable config files". Generated artifacts (db,
28
+ // embeddings, cache, hooks, mcp/, govern.local.json) never land in git.
29
+ const MCP_PROJECT_REL = path.join(".context", "mcp");
30
+
25
31
  const GITIGNORE_LINES = [
26
32
  "",
27
33
  "# Cortex local storage",
28
- ".context/db/",
29
- ".context/embeddings/",
30
- ".context/cache/",
31
- ".context/hooks/",
32
- ".npm-cache/",
33
- "mcp/.npm-cache/",
34
- "mcp/dist/",
35
- "mcp/node_modules/"
34
+ ".context/",
35
+ "!.context/config.yaml",
36
+ "!.context/rules.yaml",
37
+ "!.context/ontology.cypher",
38
+ ".npm-cache/"
36
39
  ];
37
40
 
38
41
  function printBanner(title) {
@@ -199,7 +202,6 @@ const INIT_SKIP_DIRECTORIES = new Set([
199
202
  ".cache",
200
203
  ".context",
201
204
  "scripts",
202
- "mcp",
203
205
  ".githooks",
204
206
  "bin",
205
207
  "obj"
@@ -439,11 +441,26 @@ function mergeGitignore(targetDir) {
439
441
  fs.writeFileSync(gitignorePath, merged, "utf8");
440
442
  }
441
443
 
444
+ function migrateLegacyMcpLocation(targetDir) {
445
+ const legacyMcp = path.join(targetDir, "mcp");
446
+ const newMcp = path.join(targetDir, MCP_PROJECT_REL);
447
+ if (!fs.existsSync(legacyMcp)) return;
448
+ if (fs.existsSync(newMcp)) return;
449
+ fs.mkdirSync(path.join(targetDir, ".context"), { recursive: true });
450
+ fs.renameSync(legacyMcp, newMcp);
451
+ console.log(
452
+ "[cortex] migrated legacy mcp/ → .context/mcp/ to keep project root clean. " +
453
+ "Re-run 'cortex connect' if Claude/Codex MCP registrations need to be refreshed.",
454
+ );
455
+ }
456
+
442
457
  function installScaffold(targetDir, force) {
458
+ migrateLegacyMcpLocation(targetDir);
459
+
443
460
  const copyMap = [
444
461
  [path.join(SCAFFOLD_ROOT, ".context"), path.join(targetDir, ".context")],
445
462
  [path.join(SCAFFOLD_ROOT, "scripts"), path.join(targetDir, "scripts")],
446
- [path.join(SCAFFOLD_ROOT, "mcp"), path.join(targetDir, "mcp")],
463
+ [path.join(SCAFFOLD_ROOT, "mcp"), path.join(targetDir, MCP_PROJECT_REL)],
447
464
  [path.join(SCAFFOLD_ROOT, ".githooks"), path.join(targetDir, ".githooks")]
448
465
  ];
449
466
 
@@ -633,7 +650,7 @@ async function connectClaude(targetDir) {
633
650
  }
634
651
 
635
652
  const serverName = "cortex";
636
- const projectServerEntry = path.join("mcp", "dist", "server.js");
653
+ const projectServerEntry = path.join(MCP_PROJECT_REL, "dist", "server.js");
637
654
  await runCommandResult("claude", ["mcp", "remove", "-s", "project", serverName], targetDir, "ignore");
638
655
  await runCommand(
639
656
  "claude",
@@ -646,7 +663,7 @@ async function connectClaude(targetDir) {
646
663
 
647
664
  async function connectMcpClients(targetDir, options = {}) {
648
665
  const { skipBuild = false } = options;
649
- const mcpDir = path.join(targetDir, "mcp");
666
+ const mcpDir = path.join(targetDir, MCP_PROJECT_REL);
650
667
  const packageJson = path.join(mcpDir, "package.json");
651
668
  const nodeModules = path.join(mcpDir, "node_modules");
652
669
  const serverEntry = path.join(mcpDir, "dist", "server.js");
@@ -662,7 +679,7 @@ async function connectMcpClients(targetDir, options = {}) {
662
679
  console.log(`[cortex] MCP build failed, continuing with existing dist output: ${toErrorMessage(error)}`);
663
680
  }
664
681
  } else if (!skipBuild) {
665
- console.log("[cortex] mcp/node_modules not found, skipping build (run cortex bootstrap first)");
682
+ console.log("[cortex] .context/mcp/node_modules not found, skipping build (run cortex bootstrap first)");
666
683
  }
667
684
 
668
685
  if (!fs.existsSync(serverEntry)) {
@@ -716,7 +733,7 @@ async function maybeInstallGitHooks(targetDir) {
716
733
  }
717
734
 
718
735
  function ensureProjectInitialized(targetDir) {
719
- const mcpPackageJson = path.join(targetDir, "mcp", "package.json");
736
+ const mcpPackageJson = path.join(targetDir, MCP_PROJECT_REL, "package.json");
720
737
  if (!fs.existsSync(mcpPackageJson)) {
721
738
  throw new Error(`Missing ${mcpPackageJson}. Run 'cortex init --bootstrap' first.`);
722
739
  }
@@ -731,7 +748,9 @@ function isTruthyEnv(value) {
731
748
  }
732
749
 
733
750
  function canAutoInitialize(targetDir) {
734
- const scaffoldPaths = [".context", "scripts", "mcp", ".githooks"].map((entry) => path.join(targetDir, entry));
751
+ // Legacy mcp/ at root no longer counted — pre-v2.0.5 projects are migrated
752
+ // by installScaffold rather than blocking auto-init.
753
+ const scaffoldPaths = [".context", "scripts", ".githooks"].map((entry) => path.join(targetDir, entry));
735
754
  return scaffoldPaths.every((entryPath) => !fs.existsSync(entryPath));
736
755
  }
737
756
 
@@ -744,7 +763,12 @@ function isScaffoldOutOfDate(targetDir) {
744
763
  if (!fs.existsSync(doctorScript)) {
745
764
  return true;
746
765
  }
747
- const mcpPackage = path.join(targetDir, "mcp", "package.json");
766
+ // Treat legacy mcp/ at project root as out-of-date so existing installs
767
+ // get migrated into .context/mcp/ on the next bootstrap.
768
+ if (fs.existsSync(path.join(targetDir, "mcp", "package.json"))) {
769
+ return true;
770
+ }
771
+ const mcpPackage = path.join(targetDir, MCP_PROJECT_REL, "package.json");
748
772
  if (!fs.existsSync(mcpPackage)) {
749
773
  return true;
750
774
  }
@@ -780,7 +804,8 @@ async function maybeMigrateScaffold(targetDir, command) {
780
804
 
781
805
  console.error(
782
806
  `[cortex] scaffold in ${targetDir} is out of date ` +
783
- `(missing scripts/doctor.sh, mcp/package.json, or doctor subcommand in context.sh).`
807
+ `(missing scripts/doctor.sh, .context/mcp/package.json, doctor subcommand in context.sh, ` +
808
+ `or carries a legacy mcp/ directory at the project root).`
784
809
  );
785
810
 
786
811
  let proceed = autoYes;
@@ -808,8 +833,8 @@ async function maybeMigrateScaffold(targetDir, command) {
808
833
  }
809
834
 
810
835
  async function ensureProjectInitializedForMcp(targetDir) {
811
- const mcpPackageJson = path.join(targetDir, "mcp", "package.json");
812
- const serverEntry = path.join(targetDir, "mcp", "dist", "server.js");
836
+ const mcpPackageJson = path.join(targetDir, MCP_PROJECT_REL, "package.json");
837
+ const serverEntry = path.join(targetDir, MCP_PROJECT_REL, "dist", "server.js");
813
838
 
814
839
  if (fs.existsSync(mcpPackageJson) && fs.existsSync(serverEntry)) {
815
840
  return;
@@ -947,7 +972,7 @@ async function run() {
947
972
  process.env.CORTEX_PROJECT_ROOT = target;
948
973
  await ensureProjectInitializedForMcp(target);
949
974
  ensureProjectInitialized(target);
950
- const serverEntry = path.join(target, "mcp", "dist", "server.js");
975
+ const serverEntry = path.join(target, MCP_PROJECT_REL, "dist", "server.js");
951
976
  if (!fs.existsSync(serverEntry)) {
952
977
  throw new Error(`Missing ${serverEntry}. Run 'cortex bootstrap' in ${target} first.`);
953
978
  }
@@ -1038,12 +1063,12 @@ function isPidAlive(pid) {
1038
1063
  }
1039
1064
 
1040
1065
  function resolveProjectMcpDist() {
1041
- // v2.0.0: daemon/hooks/cli are built into the project's mcp/dist/
1042
- // (via 'cortex bootstrap'). PACKAGE_ROOT/scaffold/mcp/ is the source
1043
- // tree the scaffold is copied from. The actual built code lives in
1044
- // each project's <cwd>/mcp/dist/ after bootstrap.
1066
+ // v2.0.5: project layout was moved from <cwd>/mcp/ to <cwd>/.context/mcp/.
1067
+ // PACKAGE_ROOT/scaffold/mcp/ is still the source tree the scaffold is
1068
+ // copied from; the actual built code lives in each project's
1069
+ // <cwd>/.context/mcp/dist/ after bootstrap.
1045
1070
  const target = process.env.CORTEX_PROJECT_ROOT?.trim() || process.cwd();
1046
- return path.join(target, "mcp", "dist");
1071
+ return path.join(target, MCP_PROJECT_REL, "dist");
1047
1072
  }
1048
1073
 
1049
1074
  function resolveDaemonEntry() {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@danielblomma/cortex-mcp",
3
3
  "mcpName": "io.github.DanielBlomma/cortex",
4
- "version": "2.0.3",
4
+ "version": "2.0.5",
5
5
  "description": "Local, repo-scoped context platform for coding assistants. Semantic search, graph relationships, and architectural rule context.",
6
6
  "type": "module",
7
7
  "author": "Daniel Blomma",
@@ -27,6 +27,7 @@ import {
27
27
  emitTamperAudit,
28
28
  } from "./heartbeat-tracker.js";
29
29
  import { startSyncTimer } from "./sync-checker.js";
30
+ import { startSkillSyncTimer } from "./skill-sync-checker.js";
30
31
  import { startHostEventsPusher } from "./host-events-pusher.js";
31
32
  import { startEgressProxy } from "./egress-proxy.js";
32
33
  import { startHeartbeatPusher } from "./heartbeat-pusher.js";
@@ -345,6 +346,16 @@ async function main(): Promise<void> {
345
346
  if (process.env.CORTEX_DISABLE_HOST_EVENTS_PUSH !== "1") {
346
347
  startHostEventsPusher(process.cwd(), pushIntervalMs);
347
348
  }
349
+ // Skills v3: poll cortex-web for org-authored skills, write SKILL.md
350
+ // files into per-CLI user-scope directories. Runs at the same cadence
351
+ // as the govern-config sync check by default but is independently
352
+ // configurable.
353
+ const skillSyncRaw = parseInt(process.env.CORTEX_SKILL_SYNC_MS ?? "", 10);
354
+ const skillSyncMs =
355
+ Number.isFinite(skillSyncRaw) && skillSyncRaw > 0 ? skillSyncRaw : syncIntervalMs;
356
+ if (process.env.CORTEX_DISABLE_SKILL_SYNC !== "1") {
357
+ startSkillSyncTimer(process.cwd(), skillSyncMs);
358
+ }
348
359
 
349
360
  // Govern host heartbeat — fills host_enrollment on cortex-web so the
350
361
  // dashboard at /dashboard/govern actually shows this host.
@@ -0,0 +1,375 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ rmSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+ import { homedir, hostname } from "node:os";
9
+ import { join } from "node:path";
10
+ import { loadEnterpriseConfig } from "../core/config.js";
11
+ import { writeHostAuditEvent } from "./ungoverned-scanner.js";
12
+ import { daemonDir } from "./paths.js";
13
+
14
+ /**
15
+ * Skills v3 sync flow — daemon side.
16
+ *
17
+ * The daemon polls cortex-web /api/v1/govern/skills/manifest each tick to
18
+ * learn what skills the org has authored. It diffs against a local state
19
+ * file, then for each new/changed skill it fetches the assembled SKILL.md
20
+ * and writes it to the appropriate per-CLI skills directory. Removed
21
+ * skills are unlinked. Unlike govern-config sync, this does NOT need
22
+ * root: SKILL.md files live in user-owned directories the daemon can
23
+ * write to directly.
24
+ *
25
+ * Three audit outcomes per tick:
26
+ * - skills_unchanged — manifest matches local state
27
+ * - skills_synced — at least one skill was written or removed
28
+ * (metadata: added/changed/removed counts)
29
+ * - skills_sync_failed — network / auth / disk error
30
+ *
31
+ * When something changes, a notification file is written so
32
+ * 'cortex enterprise status' can prompt the user to restart Claude
33
+ * Code / Codex CLI to pick up the new skills.
34
+ */
35
+
36
+ const STATE_FILENAME = "skills.local.json";
37
+ const NOTIFICATION_FILENAME = ".skills-update-applied.json";
38
+
39
+ const SUPPORTED_CLIS = ["claude", "codex"] as const;
40
+ type SkillCli = (typeof SUPPORTED_CLIS)[number];
41
+
42
+ type ManifestEntry = {
43
+ name: string;
44
+ scope: string;
45
+ updated_at: string;
46
+ };
47
+
48
+ type LocalSkillRecord = {
49
+ scope: string;
50
+ updated_at: string;
51
+ path: string;
52
+ };
53
+
54
+ type LocalSkillsState = {
55
+ skills: Record<string, LocalSkillRecord>;
56
+ last_synced_at?: string;
57
+ };
58
+
59
+ export type SkillSyncOutcome =
60
+ | {
61
+ kind: "unchanged";
62
+ cli: SkillCli;
63
+ count: number;
64
+ }
65
+ | {
66
+ kind: "synced";
67
+ cli: SkillCli;
68
+ added: string[];
69
+ changed: string[];
70
+ removed: string[];
71
+ }
72
+ | {
73
+ kind: "failed";
74
+ cli: SkillCli;
75
+ error: string;
76
+ };
77
+
78
+ function stateFilePath(): string {
79
+ return join(daemonDir(), STATE_FILENAME);
80
+ }
81
+
82
+ function notificationFilePath(): string {
83
+ return join(daemonDir(), NOTIFICATION_FILENAME);
84
+ }
85
+
86
+ function readState(): LocalSkillsState {
87
+ const path = stateFilePath();
88
+ if (!existsSync(path)) return { skills: {} };
89
+ try {
90
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as LocalSkillsState;
91
+ return { skills: parsed.skills ?? {}, last_synced_at: parsed.last_synced_at };
92
+ } catch {
93
+ return { skills: {} };
94
+ }
95
+ }
96
+
97
+ function writeState(state: LocalSkillsState): void {
98
+ writeFileSync(
99
+ stateFilePath(),
100
+ JSON.stringify(state, null, 2) + "\n",
101
+ "utf8",
102
+ );
103
+ }
104
+
105
+ /**
106
+ * Resolve the on-disk SKILL.md path for a skill. Global skills live under
107
+ * ~/.claude/skills (Claude Code's user-scope skills directory); cli:codex
108
+ * skills live under ~/.codex/skills. cli:claude scope is treated as
109
+ * Claude-only and lands in ~/.claude/skills.
110
+ */
111
+ function skillFilePath(scope: string, name: string): string {
112
+ const root =
113
+ scope === "cli:codex"
114
+ ? join(homedir(), ".codex", "skills")
115
+ : join(homedir(), ".claude", "skills");
116
+ return join(root, name, "SKILL.md");
117
+ }
118
+
119
+ function shouldSyncForCli(scope: string, cli: SkillCli): boolean {
120
+ if (scope === "global") return true;
121
+ return scope === `cli:${cli}`;
122
+ }
123
+
124
+ async function fetchManifest(
125
+ baseUrl: string,
126
+ apiKey: string,
127
+ cli: SkillCli,
128
+ ): Promise<ManifestEntry[]> {
129
+ const url = new URL(
130
+ baseUrl.replace(/\/$/, "") + "/api/v1/govern/skills/manifest",
131
+ );
132
+ url.searchParams.set("cli", cli);
133
+ const res = await fetch(url, {
134
+ headers: { Authorization: `Bearer ${apiKey}` },
135
+ });
136
+ if (!res.ok) {
137
+ throw new Error(`HTTP ${res.status} ${res.statusText}`);
138
+ }
139
+ const body = (await res.json()) as { skills?: ManifestEntry[] };
140
+ return body.skills ?? [];
141
+ }
142
+
143
+ async function fetchSkillBody(
144
+ baseUrl: string,
145
+ apiKey: string,
146
+ name: string,
147
+ ): Promise<string> {
148
+ const url = new URL(
149
+ baseUrl.replace(/\/$/, "") +
150
+ "/api/v1/govern/skills/" +
151
+ encodeURIComponent(name),
152
+ );
153
+ const res = await fetch(url, {
154
+ headers: { Authorization: `Bearer ${apiKey}` },
155
+ });
156
+ if (!res.ok) {
157
+ throw new Error(`HTTP ${res.status} ${res.statusText}`);
158
+ }
159
+ return res.text();
160
+ }
161
+
162
+ function writeSkillFile(path: string, content: string): void {
163
+ const dir = path.replace(/\/SKILL\.md$/, "");
164
+ mkdirSync(dir, { recursive: true });
165
+ writeFileSync(path, content, "utf8");
166
+ }
167
+
168
+ function removeSkillFile(path: string): void {
169
+ if (!existsSync(path)) return;
170
+ // Remove the per-skill directory (parent of SKILL.md). The skills root
171
+ // is shared with non-Cortex skills so we never recurse beyond the
172
+ // skill's own directory.
173
+ const dir = path.replace(/\/SKILL\.md$/, "");
174
+ rmSync(dir, { recursive: true, force: true });
175
+ }
176
+
177
+ function writeNotification(data: {
178
+ added: number;
179
+ changed: number;
180
+ removed: number;
181
+ cli: SkillCli;
182
+ detected_at: string;
183
+ }): void {
184
+ writeFileSync(
185
+ notificationFilePath(),
186
+ JSON.stringify(data, null, 2) + "\n",
187
+ "utf8",
188
+ );
189
+ }
190
+
191
+ export async function runSkillSyncForCli(
192
+ cwd: string,
193
+ cli: SkillCli,
194
+ ): Promise<SkillSyncOutcome> {
195
+ const config = loadEnterpriseConfig(join(cwd, ".context"));
196
+ const apiKey = config.enterprise.api_key.trim();
197
+ const baseUrl = (config.enterprise.base_url || config.enterprise.endpoint).trim();
198
+ if (!apiKey || !baseUrl) {
199
+ return { kind: "failed", cli, error: "enterprise not configured" };
200
+ }
201
+
202
+ let manifest: ManifestEntry[];
203
+ try {
204
+ manifest = await fetchManifest(baseUrl, apiKey, cli);
205
+ } catch (err) {
206
+ return {
207
+ kind: "failed",
208
+ cli,
209
+ error: err instanceof Error ? err.message : String(err),
210
+ };
211
+ }
212
+
213
+ const state = readState();
214
+ const relevantManifest = manifest.filter((entry) =>
215
+ shouldSyncForCli(entry.scope, cli),
216
+ );
217
+ const remoteByName = new Map(relevantManifest.map((e) => [e.name, e]));
218
+
219
+ const added: string[] = [];
220
+ const changed: string[] = [];
221
+ const removed: string[] = [];
222
+
223
+ // Detect adds + changes
224
+ for (const entry of relevantManifest) {
225
+ const local = state.skills[entry.name];
226
+ const isNew = !local;
227
+ const isChanged =
228
+ Boolean(local) &&
229
+ (local.updated_at !== entry.updated_at || local.scope !== entry.scope);
230
+ if (!isNew && !isChanged) continue;
231
+
232
+ let body: string;
233
+ try {
234
+ body = await fetchSkillBody(baseUrl, apiKey, entry.name);
235
+ } catch (err) {
236
+ return {
237
+ kind: "failed",
238
+ cli,
239
+ error:
240
+ err instanceof Error
241
+ ? `fetch ${entry.name}: ${err.message}`
242
+ : `fetch ${entry.name}: ${String(err)}`,
243
+ };
244
+ }
245
+
246
+ const path = skillFilePath(entry.scope, entry.name);
247
+ try {
248
+ writeSkillFile(path, body);
249
+ } catch (err) {
250
+ return {
251
+ kind: "failed",
252
+ cli,
253
+ error:
254
+ err instanceof Error
255
+ ? `write ${entry.name}: ${err.message}`
256
+ : `write ${entry.name}: ${String(err)}`,
257
+ };
258
+ }
259
+
260
+ state.skills[entry.name] = {
261
+ scope: entry.scope,
262
+ updated_at: entry.updated_at,
263
+ path,
264
+ };
265
+ (isNew ? added : changed).push(entry.name);
266
+ }
267
+
268
+ // Detect removes — entries we have locally for this cli but the manifest
269
+ // dropped (or disabled). We only consider state entries whose scope
270
+ // matches this cli, so we don't accidentally remove the other CLI's
271
+ // skills when running a per-cli tick.
272
+ for (const [name, record] of Object.entries(state.skills)) {
273
+ if (!shouldSyncForCli(record.scope, cli)) continue;
274
+ if (remoteByName.has(name)) continue;
275
+ try {
276
+ removeSkillFile(record.path);
277
+ } catch {
278
+ // best-effort; if unlink fails the next tick will retry
279
+ }
280
+ delete state.skills[name];
281
+ removed.push(name);
282
+ }
283
+
284
+ const totalChanged = added.length + changed.length + removed.length;
285
+ if (totalChanged === 0) {
286
+ return { kind: "unchanged", cli, count: relevantManifest.length };
287
+ }
288
+
289
+ state.last_synced_at = new Date().toISOString();
290
+ writeState(state);
291
+ return { kind: "synced", cli, added, changed, removed };
292
+ }
293
+
294
+ export async function runSkillSyncOnce(
295
+ cwd: string,
296
+ clis: ReadonlyArray<SkillCli> = SUPPORTED_CLIS,
297
+ ): Promise<SkillSyncOutcome[]> {
298
+ const outcomes: SkillSyncOutcome[] = [];
299
+ const now = new Date().toISOString();
300
+
301
+ for (const cli of clis) {
302
+ const outcome = await runSkillSyncForCli(cwd, cli);
303
+ outcomes.push(outcome);
304
+
305
+ const eventBase = {
306
+ timestamp: now,
307
+ host_id: hostname(),
308
+ cli,
309
+ };
310
+
311
+ if (outcome.kind === "unchanged") {
312
+ await writeHostAuditEvent(cwd, {
313
+ ...eventBase,
314
+ event_type: "skills_unchanged",
315
+ count: outcome.count,
316
+ }).catch(() => undefined);
317
+ } else if (outcome.kind === "synced") {
318
+ await writeHostAuditEvent(cwd, {
319
+ ...eventBase,
320
+ event_type: "skills_synced",
321
+ added: outcome.added,
322
+ changed: outcome.changed,
323
+ removed: outcome.removed,
324
+ }).catch(() => undefined);
325
+ writeNotification({
326
+ added: outcome.added.length,
327
+ changed: outcome.changed.length,
328
+ removed: outcome.removed.length,
329
+ cli,
330
+ detected_at: now,
331
+ });
332
+ } else {
333
+ await writeHostAuditEvent(cwd, {
334
+ ...eventBase,
335
+ event_type: "skills_sync_failed",
336
+ error: outcome.error,
337
+ }).catch(() => undefined);
338
+ }
339
+ }
340
+
341
+ // We deliberately leave the notification file in place when this tick
342
+ // had no changes — it represents "restart pending" from a prior sync,
343
+ // not current drift. `cortex enterprise status --acknowledge-skills`
344
+ // (future CLI) will be the explicit clear path.
345
+
346
+ return outcomes;
347
+ }
348
+
349
+ export type SkillSyncTimerHandle = {
350
+ stop(): void;
351
+ };
352
+
353
+ export function startSkillSyncTimer(
354
+ cwd: string,
355
+ intervalMs: number,
356
+ ): SkillSyncTimerHandle {
357
+ const tick = () => {
358
+ void runSkillSyncOnce(cwd).catch((err) => {
359
+ process.stderr.write(
360
+ `[cortex-daemon] skill sync failed: ${
361
+ err instanceof Error ? err.message : String(err)
362
+ }\n`,
363
+ );
364
+ });
365
+ };
366
+
367
+ void Promise.resolve().then(tick);
368
+ const handle = setInterval(tick, intervalMs);
369
+ if (typeof handle.unref === "function") handle.unref();
370
+ return {
371
+ stop() {
372
+ clearInterval(handle);
373
+ },
374
+ };
375
+ }
@@ -2,7 +2,7 @@
2
2
  set -euo pipefail
3
3
 
4
4
  REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5
- MCP_DIR="$REPO_ROOT/mcp"
5
+ MCP_DIR="$REPO_ROOT/.context/mcp"
6
6
  TOTAL_STEPS=6
7
7
  STEP_INDEX=0
8
8
 
@@ -3,7 +3,7 @@ set -euo pipefail
3
3
 
4
4
  REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5
5
  CONTEXT_DIR="$REPO_ROOT/.context"
6
- MCP_DIR="$REPO_ROOT/mcp"
6
+ MCP_DIR="$CONTEXT_DIR/mcp"
7
7
 
8
8
  PASS=0
9
9
  FAIL=0
@@ -165,15 +165,15 @@ echo ""
165
165
  echo " MCP Server"
166
166
 
167
167
  if [[ -f "$MCP_DIR/dist/server.js" ]]; then
168
- pass "mcp/dist/server.js exists"
168
+ pass ".context/mcp/dist/server.js exists"
169
169
  else
170
- fail "mcp/dist/server.js missing — run: cd mcp && npm run build"
170
+ fail ".context/mcp/dist/server.js missing — run: cd .context/mcp && npm run build"
171
171
  fi
172
172
 
173
173
  if [[ -d "$MCP_DIR/node_modules" ]]; then
174
- pass "mcp/node_modules present"
174
+ pass ".context/mcp/node_modules present"
175
175
  else
176
- fail "mcp/node_modules missing — run: cd mcp && npm install"
176
+ fail ".context/mcp/node_modules missing — run: cd .context/mcp && npm install"
177
177
  fi
178
178
 
179
179
  # Quick MCP import check
@@ -181,7 +181,7 @@ if [[ -f "$MCP_DIR/dist/server.js" ]] && [[ -d "$MCP_DIR/node_modules" ]]; then
181
181
  MCP_CHECK=$(cd "$REPO_ROOT" && timeout 10 node -e '
182
182
  const start = Date.now();
183
183
  try {
184
- require("./mcp/dist/graph.js");
184
+ require("./.context/mcp/dist/graph.js");
185
185
  console.log("ok " + (Date.now() - start));
186
186
  } catch(e) {
187
187
  console.log("fail " + e.message);
@@ -2,7 +2,7 @@
2
2
  set -euo pipefail
3
3
 
4
4
  REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5
- MCP_DIR="$REPO_ROOT/mcp"
5
+ MCP_DIR="$REPO_ROOT/.context/mcp"
6
6
 
7
7
  if ! command -v npm >/dev/null 2>&1; then
8
8
  echo "[embed] npm is required but not found on PATH"
@@ -11,5 +11,5 @@ fi
11
11
 
12
12
  mkdir -p "$MCP_DIR/.npm-cache"
13
13
 
14
- echo "[embed] generating embeddings via mcp/embed"
14
+ echo "[embed] generating embeddings via .context/mcp/embed"
15
15
  NPM_CONFIG_CACHE="$MCP_DIR/.npm-cache" npm --prefix "$MCP_DIR" run embed --silent -- "$@"
@@ -2,7 +2,7 @@
2
2
  set -euo pipefail
3
3
 
4
4
  REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5
- MCP_DIR="$REPO_ROOT/mcp"
5
+ MCP_DIR="$REPO_ROOT/.context/mcp"
6
6
 
7
7
  if [[ ! -f "$MCP_DIR/package.json" ]]; then
8
8
  echo "[graph-load] missing $MCP_DIR/package.json"
@@ -10,8 +10,8 @@ if [[ ! -f "$MCP_DIR/package.json" ]]; then
10
10
  fi
11
11
 
12
12
  if [[ ! -d "$MCP_DIR/node_modules" ]]; then
13
- echo "[graph-load] node_modules missing in mcp/"
14
- echo "[graph-load] run: cd mcp && NPM_CONFIG_CACHE=$MCP_DIR/.npm-cache npm install"
13
+ echo "[graph-load] node_modules missing in .context/mcp/"
14
+ echo "[graph-load] run: cd .context/mcp && NPM_CONFIG_CACHE=$MCP_DIR/.npm-cache npm install"
15
15
  exit 1
16
16
  fi
17
17
 
@@ -2,7 +2,7 @@
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { parseFrontmatter, parseStringList } from "../mcp/dist/frontmatter.js";
5
+ import { parseFrontmatter, parseStringList } from "../.context/mcp/dist/frontmatter.js";
6
6
 
7
7
  const __filename = fileURLToPath(import.meta.url);
8
8
  const __dirname = path.dirname(__filename);
@@ -2,7 +2,7 @@
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { parseFrontmatter, parseStringList } from "../mcp/dist/frontmatter.js";
5
+ import { parseFrontmatter, parseStringList } from "../.context/mcp/dist/frontmatter.js";
6
6
 
7
7
  const __filename = fileURLToPath(import.meta.url);
8
8
  const __dirname = path.dirname(__filename);
@@ -146,11 +146,9 @@ status_digest() {
146
146
  fi
147
147
 
148
148
  # Fallback for non-git directories.
149
+ # .context/ excludes the relocated .context/mcp/ tree as well.
149
150
  find "$REPO_ROOT" -type f \
150
151
  ! -path "$REPO_ROOT/.context/*" \
151
- ! -path "$REPO_ROOT/mcp/node_modules/*" \
152
- ! -path "$REPO_ROOT/mcp/dist/*" \
153
- ! -path "$REPO_ROOT/mcp/.npm-cache/*" \
154
152
  ! -path "$REPO_ROOT/scripts/parsers/node_modules/*" \
155
153
  ! -path "$REPO_ROOT/scripts/parsers/.npm-cache/*" \
156
154
  -print \
@@ -201,16 +199,13 @@ wait_for_change_event() {
201
199
  inotifywait)
202
200
  inotifywait -q -r \
203
201
  -e modify,create,delete,move \
204
- --exclude '(^|/)\\.git(/|$)|(^|/)\\.context(/|$)|(^|/)mcp/(node_modules|dist|\\.npm-cache)(/|$)|(^|/)scripts/parsers/(node_modules|\\.npm-cache)(/|$)' \
202
+ --exclude '(^|/)\\.git(/|$)|(^|/)\\.context(/|$)|(^|/)scripts/parsers/(node_modules|\\.npm-cache)(/|$)' \
205
203
  "$REPO_ROOT" >/dev/null 2>&1 || true
206
204
  ;;
207
205
  fswatch)
208
206
  fswatch -1 -r \
209
207
  --exclude '(^|/)\\.git(/|$)' \
210
208
  --exclude '(^|/)\\.context(/|$)' \
211
- --exclude '(^|/)mcp/node_modules(/|$)' \
212
- --exclude '(^|/)mcp/dist(/|$)' \
213
- --exclude '(^|/)mcp/\\.npm-cache(/|$)' \
214
209
  --exclude '(^|/)scripts/parsers/node_modules(/|$)' \
215
210
  --exclude '(^|/)scripts/parsers/\\.npm-cache(/|$)' \
216
211
  "$REPO_ROOT" >/dev/null 2>&1 || true