@minhpnq1807/contextos 0.1.9 → 0.3.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.
@@ -0,0 +1,478 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import readline from "node:readline/promises";
4
+ import { stdin as input, stdout as output } from "node:process";
5
+ import { execFileSync } from "node:child_process";
6
+
7
+ const DEFAULT_AGENTS = ["codex", "claude", "antigravity"];
8
+ const CTX_MCP_NAME = "ctx-mcp";
9
+ const CONTEXTOS_PROXY_MARKER = "/contextos/plugins/ctx/mcp/proxy.js";
10
+
11
+ function statusLine(label, value) {
12
+ return `[ctx] ${label.padEnd(38)} ${value}`;
13
+ }
14
+
15
+ function runCommand(command, args, { cwd = process.cwd(), stdio = "pipe", dryRun = false } = {}) {
16
+ if (dryRun) return { stdout: "", skipped: true };
17
+ const stdout = execFileSync(command, args, { cwd, stdio, encoding: "utf8" });
18
+ return { stdout: stdout || "" };
19
+ }
20
+
21
+ export function parseSyncRulesArgs(args = []) {
22
+ const agentsFlag = args.indexOf("--agents");
23
+ const agents = agentsFlag >= 0
24
+ ? String(args[agentsFlag + 1] || "").split(",").map((item) => item.trim()).filter(Boolean)
25
+ : DEFAULT_AGENTS;
26
+ return {
27
+ rules: args.includes("--rules"),
28
+ agents,
29
+ dryRun: args.includes("--dry-run"),
30
+ force: args.includes("--force"),
31
+ importCodexMcp: !args.includes("--no-import-codex-mcp"),
32
+ yes: args.includes("--yes") || args.includes("-y")
33
+ };
34
+ }
35
+
36
+ function codexConfigPath() {
37
+ return path.join(process.env.CODEX_HOME || path.join(process.env.HOME || process.cwd(), ".codex"), "config.toml");
38
+ }
39
+
40
+ export function rulerTomlPath(cwd = process.cwd()) {
41
+ return path.join(cwd, ".ruler", "ruler.toml");
42
+ }
43
+
44
+ export function checkRulerInstalled({ run = runCommand } = {}) {
45
+ try {
46
+ const result = run("ruler", ["--version"]);
47
+ return { installed: true, version: result.stdout.trim() || "installed" };
48
+ } catch {
49
+ return { installed: false, version: "" };
50
+ }
51
+ }
52
+
53
+ async function shouldInstallRuler({ yes = false } = {}) {
54
+ if (yes) return true;
55
+ if (!process.stdin.isTTY) return false;
56
+ const rl = readline.createInterface({ input, output });
57
+ try {
58
+ const answer = await rl.question("[ctx] Ruler is not installed. Install @intellectronica/ruler globally? [Y/n] ");
59
+ return !/^n(o)?$/i.test(answer.trim());
60
+ } finally {
61
+ rl.close();
62
+ }
63
+ }
64
+
65
+ export async function installRuler({ run = runCommand, yes = false, dryRun = false } = {}) {
66
+ const accepted = await shouldInstallRuler({ yes });
67
+ if (!accepted) {
68
+ throw new Error("Ruler is required for ctx sync --rules. Install it with `npm install -g @intellectronica/ruler` or rerun with --yes.");
69
+ }
70
+ run("npm", ["install", "-g", "@intellectronica/ruler"], { stdio: "inherit", dryRun });
71
+ }
72
+
73
+ export function ensureRulerInit({ cwd = process.cwd(), run = runCommand, dryRun = false } = {}) {
74
+ const tomlPath = rulerTomlPath(cwd);
75
+ if (fs.existsSync(tomlPath)) return { created: false, tomlPath };
76
+ run("ruler", ["init"], { cwd, stdio: "inherit", dryRun });
77
+ return { created: true, tomlPath };
78
+ }
79
+
80
+ function removeTomlSection(content, sectionName) {
81
+ const lines = content.split(/\r?\n/);
82
+ const result = [];
83
+ let skipping = false;
84
+ const header = `[${sectionName}]`;
85
+ for (const line of lines) {
86
+ const trimmed = line.trim();
87
+ if (trimmed === header) {
88
+ skipping = true;
89
+ continue;
90
+ }
91
+ if (skipping && /^\[[^\]]+\]\s*$/.test(trimmed)) {
92
+ skipping = false;
93
+ }
94
+ if (!skipping) result.push(line);
95
+ }
96
+ return result.join("\n").replace(/\n{3,}/g, "\n\n");
97
+ }
98
+
99
+ function hasTomlSection(content, sectionName) {
100
+ return new RegExp(`^\\[${sectionName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\]\\s*$`, "m").test(content);
101
+ }
102
+
103
+ function findMcpServerSections(content) {
104
+ const lines = String(content || "").split(/\r?\n/);
105
+ const sections = [];
106
+ for (let index = 0; index < lines.length; index += 1) {
107
+ const match = lines[index].match(/^\[mcp_servers\.([^\].]+)\]\s*$/);
108
+ if (!match) continue;
109
+ let end = lines.length;
110
+ for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
111
+ if (/^\[/.test(lines[cursor])) {
112
+ end = cursor;
113
+ break;
114
+ }
115
+ }
116
+ sections.push({
117
+ name: unquoteTomlKey(match[1]),
118
+ body: lines.slice(index + 1, end)
119
+ });
120
+ }
121
+ return sections;
122
+ }
123
+
124
+ function findStringValue(lines, key) {
125
+ const line = lines.find((item) => new RegExp(`^\\s*${escapeRegExp(key)}\\s*=`).test(item));
126
+ if (!line) return null;
127
+ const match = line.match(/=\s*"((?:\\.|[^"\\])*)"/);
128
+ return match ? unescapeTomlString(match[1]) : null;
129
+ }
130
+
131
+ function findArrayValue(lines, key) {
132
+ const line = lines.find((item) => new RegExp(`^\\s*${escapeRegExp(key)}\\s*=`).test(item));
133
+ if (!line) return [];
134
+ const arrayMatch = line.match(/=\s*\[(.*)\]\s*$/);
135
+ if (!arrayMatch) return [];
136
+ const values = [];
137
+ const pattern = /"((?:\\.|[^"\\])*)"/g;
138
+ let match;
139
+ while ((match = pattern.exec(arrayMatch[1]))) values.push(unescapeTomlString(match[1]));
140
+ return values;
141
+ }
142
+
143
+ function tomlString(value) {
144
+ return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
145
+ }
146
+
147
+ function tomlArray(values = []) {
148
+ return `[${values.map(tomlString).join(", ")}]`;
149
+ }
150
+
151
+ function unescapeTomlString(value) {
152
+ return String(value).replace(/\\"/g, '"').replace(/\\\\/g, "\\");
153
+ }
154
+
155
+ function unquoteTomlKey(value) {
156
+ return value.replace(/^"|"$/g, "");
157
+ }
158
+
159
+ function escapeRegExp(value) {
160
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
161
+ }
162
+
163
+ function unwrapContextOSProxy(command, args = []) {
164
+ if (command !== "node" || !String(args[0] || "").includes(CONTEXTOS_PROXY_MARKER)) {
165
+ return { command, args };
166
+ }
167
+ const separator = args.indexOf("--");
168
+ if (separator < 0 || separator >= args.length - 1) return { command, args };
169
+ return {
170
+ command: args[separator + 1],
171
+ args: args.slice(separator + 2)
172
+ };
173
+ }
174
+
175
+ export function readCodexMcpServers({ configPath = codexConfigPath() } = {}) {
176
+ if (!fs.existsSync(configPath)) return [];
177
+ const content = fs.readFileSync(configPath, "utf8");
178
+ const servers = [];
179
+ for (const section of findMcpServerSections(content)) {
180
+ const command = findStringValue(section.body, "command");
181
+ if (!command) continue;
182
+ const args = findArrayValue(section.body, "args");
183
+ const unwrapped = unwrapContextOSProxy(command, args);
184
+ servers.push({
185
+ name: section.name,
186
+ command: unwrapped.command,
187
+ args: unwrapped.args
188
+ });
189
+ }
190
+ return servers;
191
+ }
192
+
193
+ export function readProjectMcpJsonServers({ cwd = process.cwd(), configPath = path.join(cwd, ".mcp.json") } = {}) {
194
+ if (!fs.existsSync(configPath)) return [];
195
+ const config = readJsonFile(configPath, {});
196
+ const mcpServers = config.mcpServers && typeof config.mcpServers === "object" ? config.mcpServers : {};
197
+ return Object.entries(mcpServers)
198
+ .filter(([, server]) => server && typeof server.command === "string")
199
+ .map(([name, server]) => ({
200
+ name,
201
+ command: server.command,
202
+ args: Array.isArray(server.args) ? server.args : []
203
+ }));
204
+ }
205
+
206
+ function mergeMcpServers(...groups) {
207
+ const merged = new Map();
208
+ for (const group of groups) {
209
+ for (const server of group || []) {
210
+ if (!server?.name || !server?.command) continue;
211
+ if (!merged.has(server.name)) merged.set(server.name, server);
212
+ }
213
+ }
214
+ return [...merged.values()];
215
+ }
216
+
217
+ function readRulerMcpServers({ tomlPath } = {}) {
218
+ if (!tomlPath || !fs.existsSync(tomlPath)) return [];
219
+ const content = fs.readFileSync(tomlPath, "utf8");
220
+ const servers = [];
221
+ for (const section of findMcpServerSections(content)) {
222
+ const command = findStringValue(section.body, "command");
223
+ if (!command) continue;
224
+ servers.push({
225
+ name: section.name,
226
+ command,
227
+ args: findArrayValue(section.body, "args")
228
+ });
229
+ }
230
+ return servers;
231
+ }
232
+
233
+ function antigravityMcpConfigPaths() {
234
+ const home = process.env.HOME || process.cwd();
235
+ return [
236
+ path.join(home, ".gemini", "antigravity", "mcp_config.json"),
237
+ path.join(home, ".gemini", "antigravity-cli", "mcp_config.json"),
238
+ path.join(home, ".gemini", "config", "mcp_config.json")
239
+ ];
240
+ }
241
+
242
+ function readJsonFile(filePath, fallback = {}) {
243
+ try {
244
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
245
+ } catch {
246
+ return fallback;
247
+ }
248
+ }
249
+
250
+ function writeJsonFile(filePath, value) {
251
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
252
+ fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
253
+ }
254
+
255
+ export function syncAntigravityMcpFromRuler({ tomlPath, configPaths = antigravityMcpConfigPaths(), dryRun = false } = {}) {
256
+ const servers = readRulerMcpServers({ tomlPath });
257
+ if (!servers.length) return { changed: false, servers: [], configPaths };
258
+
259
+ for (const configPath of configPaths) {
260
+ const config = readJsonFile(configPath, {});
261
+ if (!config.mcpServers || typeof config.mcpServers !== "object") config.mcpServers = {};
262
+ for (const server of servers) {
263
+ config.mcpServers[server.name] = {
264
+ command: server.command,
265
+ args: server.args || []
266
+ };
267
+ }
268
+ if (!dryRun) writeJsonFile(configPath, config);
269
+ }
270
+
271
+ return { changed: true, servers: servers.map((server) => server.name), configPaths };
272
+ }
273
+
274
+ export function buildCtxMcpToml({ mcpServerPath, agents = DEFAULT_AGENTS } = {}) {
275
+ const blocks = [
276
+ "# Added by ctx sync --rules",
277
+ "[mcp]",
278
+ "enabled = true",
279
+ 'merge_strategy = "merge"',
280
+ "",
281
+ `[mcp_servers.${CTX_MCP_NAME}]`,
282
+ 'command = "node"',
283
+ `args = [${JSON.stringify(mcpServerPath)}]`
284
+ ];
285
+
286
+ for (const agent of agents) {
287
+ const outputPath = agent === "claude" ? "CLAUDE.md" : "AGENTS.md";
288
+ blocks.push(
289
+ "",
290
+ `[agents.${agent}]`,
291
+ "enabled = true",
292
+ `output_path = "${outputPath}"`,
293
+ "",
294
+ `[agents.${agent}.mcp]`,
295
+ "enabled = true",
296
+ 'merge_strategy = "merge"'
297
+ );
298
+ }
299
+
300
+ return `${blocks.join("\n")}\n`;
301
+ }
302
+
303
+ export function buildMcpServerToml(server) {
304
+ return [
305
+ `# Imported by ctx sync --rules from Codex MCP config`,
306
+ `[mcp_servers.${server.name}]`,
307
+ `command = ${tomlString(server.command)}`,
308
+ `args = ${tomlArray(server.args || [])}`
309
+ ].join("\n");
310
+ }
311
+
312
+ export function injectMcpServers({ tomlPath, servers = [], force = false, dryRun = false } = {}) {
313
+ if (!servers.length) return { changed: false, added: [], skipped: [] };
314
+ let content = fs.existsSync(tomlPath) ? fs.readFileSync(tomlPath, "utf8") : "";
315
+ const added = [];
316
+ const skipped = [];
317
+
318
+ for (const server of servers) {
319
+ if (!server?.name || !server?.command) continue;
320
+ const sectionName = `mcp_servers.${server.name}`;
321
+ const exists = hasTomlSection(content, sectionName);
322
+ if (exists && !force) {
323
+ skipped.push(server.name);
324
+ continue;
325
+ }
326
+ if (exists && force) content = removeTomlSection(content, sectionName);
327
+ const prefix = content.trim() ? "\n\n" : "";
328
+ content = `${content.trimEnd()}${prefix}${buildMcpServerToml(server)}\n`;
329
+ added.push(server.name);
330
+ }
331
+
332
+ if (added.length && !dryRun) {
333
+ fs.mkdirSync(path.dirname(tomlPath), { recursive: true });
334
+ fs.writeFileSync(tomlPath, content, "utf8");
335
+ }
336
+ return { changed: added.length > 0, added, skipped, content };
337
+ }
338
+
339
+ export function injectCtxMcp({ tomlPath, mcpServerPath, agents = DEFAULT_AGENTS, force = false, dryRun = false } = {}) {
340
+ if (!fs.existsSync(tomlPath)) {
341
+ if (dryRun) return { changed: true, existed: false, content: buildCtxMcpToml({ mcpServerPath, agents }) };
342
+ fs.mkdirSync(path.dirname(tomlPath), { recursive: true });
343
+ fs.writeFileSync(tomlPath, buildCtxMcpToml({ mcpServerPath, agents }), "utf8");
344
+ return { changed: true, existed: false };
345
+ }
346
+
347
+ let content = fs.readFileSync(tomlPath, "utf8");
348
+ const sectionExists = hasTomlSection(content, `mcp_servers.${CTX_MCP_NAME}`);
349
+ if (sectionExists && !force) return { changed: false, existed: true };
350
+
351
+ if (force) {
352
+ content = removeTomlSection(content, "mcp");
353
+ content = removeTomlSection(content, `mcp_servers.${CTX_MCP_NAME}`);
354
+ for (const agent of agents) {
355
+ content = removeTomlSection(content, `agents.${agent}`);
356
+ content = removeTomlSection(content, `agents.${agent}.mcp`);
357
+ }
358
+ }
359
+
360
+ const next = `${content.trimEnd()}\n\n${buildCtxMcpToml({ mcpServerPath, agents })}`;
361
+ if (!dryRun) fs.writeFileSync(tomlPath, next, "utf8");
362
+ return { changed: true, existed: sectionExists, content: next };
363
+ }
364
+
365
+ export function runRulerApply({ agents = DEFAULT_AGENTS, cwd = process.cwd(), run = runCommand, dryRun = false } = {}) {
366
+ run("ruler", ["apply", "--agents", agents.join(",")], { cwd, stdio: "inherit", dryRun });
367
+ }
368
+
369
+ function fileContains(filePath, pattern) {
370
+ try {
371
+ return fs.readFileSync(filePath, "utf8").includes(pattern);
372
+ } catch {
373
+ return false;
374
+ }
375
+ }
376
+
377
+ export function verifySync({ cwd = process.cwd(), agents = DEFAULT_AGENTS } = {}) {
378
+ const checks = [];
379
+ const definitions = {
380
+ codex: [path.join(cwd, ".codex", "config.toml")],
381
+ claude: [path.join(cwd, ".mcp.json"), path.join(cwd, ".claude", "settings.json"), path.join(process.env.HOME || "", ".claude.json")],
382
+ antigravity: [
383
+ path.join(cwd, ".gemini", "settings.json"),
384
+ path.join(cwd, ".gemini", "mcp.json"),
385
+ ...antigravityMcpConfigPaths(),
386
+ path.join(cwd, "AGENTS.md")
387
+ ]
388
+ };
389
+
390
+ for (const agent of agents) {
391
+ const files = definitions[agent] || [];
392
+ const found = files.find((filePath) => fileContains(filePath, CTX_MCP_NAME));
393
+ checks.push({ agent, ok: Boolean(found), filePath: found || files[0] || "" });
394
+ }
395
+ return checks;
396
+ }
397
+
398
+ export async function syncRules({
399
+ cwd = process.cwd(),
400
+ rootDir,
401
+ args = [],
402
+ run = runCommand,
403
+ logger = console.log
404
+ } = {}) {
405
+ const options = parseSyncRulesArgs(args);
406
+ if (!options.rules) throw new Error("Usage: ctx sync --rules [--agents codex,claude,antigravity] [--dry-run] [--force]");
407
+
408
+ logger("");
409
+ const ruler = checkRulerInstalled({ run });
410
+ if (!ruler.installed) {
411
+ logger(statusLine("Checking ruler installation...", options.dryRun ? "missing (dry-run)" : "missing"));
412
+ if (!options.dryRun) await installRuler({ run, yes: options.yes });
413
+ } else {
414
+ logger(statusLine("Checking ruler installation...", `✓ ${ruler.version}`));
415
+ }
416
+
417
+ const init = ensureRulerInit({ cwd, run, dryRun: options.dryRun });
418
+ logger(statusLine("Checking .ruler/ruler.toml...", init.created ? "✓ created" : "✓ found"));
419
+
420
+ const mcpServerPath = path.join(rootDir, "plugins", "ctx", "mcp", "server.js");
421
+ const injected = injectCtxMcp({
422
+ tomlPath: init.tomlPath,
423
+ mcpServerPath,
424
+ agents: options.agents,
425
+ force: options.force,
426
+ dryRun: options.dryRun
427
+ });
428
+ logger(statusLine("Injecting ctx-mcp into ruler.toml...", injected.changed ? "✓ added" : "✓ already configured"));
429
+
430
+ let importedMcp = { added: [], skipped: [] };
431
+ let importedServers = [];
432
+ if (options.importCodexMcp) {
433
+ importedServers = mergeMcpServers(
434
+ readCodexMcpServers(),
435
+ readProjectMcpJsonServers({ cwd })
436
+ ).filter((server) => server.name !== CTX_MCP_NAME);
437
+ importedMcp = injectMcpServers({
438
+ tomlPath: init.tomlPath,
439
+ servers: importedServers,
440
+ force: options.force,
441
+ dryRun: options.dryRun
442
+ });
443
+ const importedLabel = importedMcp.added.length
444
+ ? `✓ added ${importedMcp.added.join(", ")}`
445
+ : importedServers.length
446
+ ? "✓ already configured"
447
+ : "none found";
448
+ logger(statusLine("Importing existing MCP servers...", importedLabel));
449
+ }
450
+
451
+ logger("[ctx] Running ruler apply...");
452
+ runRulerApply({ agents: options.agents, cwd, run, dryRun: options.dryRun });
453
+
454
+ let antigravityMcp = { changed: false, servers: [], configPaths: [] };
455
+ if (options.agents.includes("antigravity")) {
456
+ antigravityMcp = options.dryRun
457
+ ? {
458
+ changed: true,
459
+ servers: [CTX_MCP_NAME, ...importedServers.map((server) => server.name)],
460
+ configPaths: antigravityMcpConfigPaths()
461
+ }
462
+ : syncAntigravityMcpFromRuler({ tomlPath: init.tomlPath });
463
+ logger(statusLine("Syncing Antigravity MCP config...", antigravityMcp.servers.length ? `✓ ${antigravityMcp.servers.join(", ")}` : "none found"));
464
+ }
465
+
466
+ logger("[ctx] Verifying sync...");
467
+ const checks = options.dryRun ? options.agents.map((agent) => ({ agent, ok: true, filePath: "(dry-run)" })) : verifySync({ cwd, agents: options.agents });
468
+ for (const check of checks) {
469
+ logger(` → ctx-mcp in ${check.agent.padEnd(12)} ${check.ok ? "✓" : "not found"}${check.filePath ? ` ${check.filePath}` : ""}`);
470
+ }
471
+
472
+ const okCount = checks.filter((check) => check.ok).length;
473
+ logger("");
474
+ logger(`[ctx] ${options.dryRun ? "Dry run complete" : "Done"}. Rules ${options.dryRun ? "would sync" : "synced"} to ${okCount}/${options.agents.length} agents.`);
475
+ logger(` ${options.dryRun ? "No files were changed." : "Restart each agent to activate ctx-mcp."}`);
476
+
477
+ return { options, ruler, init, injected, importedMcp, antigravityMcp, checks };
478
+ }
@@ -57,6 +57,7 @@ export function loadStats(dataDir) {
57
57
  const followed = reports.reduce((sum, report) => sum + (report.followed?.length || 0), 0);
58
58
  const ignored = reports.reduce((sum, report) => sum + (report.ignored?.length || 0), 0);
59
59
  const unknown = reports.reduce((sum, report) => sum + (report.unknown?.length || 0), 0);
60
+ const unmeasurable = reports.reduce((sum, report) => sum + (report.unmeasurable?.length || 0), 0);
60
61
 
61
62
  return {
62
63
  dataDir,
@@ -71,6 +72,7 @@ export function loadStats(dataDir) {
71
72
  followed,
72
73
  ignored,
73
74
  unknown,
75
+ unmeasurable,
74
76
  lastPrompt: analyzedPrompts.at(-1) || null,
75
77
  lastReport: reports.at(-1) || null
76
78
  };
@@ -85,7 +87,7 @@ export function formatStats(stats) {
85
87
  lines.push(`Prompt mode: ${stats.injectedCount} injected, ${stats.quietCount} quiet (${stats.injectionRate}% injected)`);
86
88
  lines.push(`Average prompt analysis: ${stats.averagePromptMs == null ? "unknown" : `${stats.averagePromptMs}ms`}`);
87
89
  lines.push(`Average efficiency: ${formatAverageEfficiency(stats)}`);
88
- lines.push(`Rule outcomes: ${stats.followed} followed, ${stats.ignored} ignored, ${stats.unknown} unknown`);
90
+ lines.push(`Rule outcomes: ${stats.followed} followed, ${stats.ignored} ignored, ${stats.unknown} unknown, ${stats.unmeasurable || 0} unmeasurable`);
89
91
 
90
92
  const eventSummary = Object.entries(stats.events)
91
93
  .map(([event, count]) => `${event}:${count}`)
@@ -103,6 +105,7 @@ export function formatStats(stats) {
103
105
  lines.push(`Last report efficiency: ${stats.lastReport.efficiencyScore == null ? "unknown" : `${stats.lastReport.efficiencyScore}%`}`);
104
106
  lines.push(`Last report measured rules: ${stats.lastReport.measuredRuleCount ?? ((stats.lastReport.followed?.length || 0) + (stats.lastReport.ignored?.length || 0))}`);
105
107
  lines.push(`Last report unknown rules: ${stats.lastReport.unknownRuleCount ?? (stats.lastReport.unknown?.length || 0)}`);
108
+ lines.push(`Last report unmeasurable rules: ${stats.lastReport.unmeasurableRuleCount ?? (stats.lastReport.unmeasurable?.length || 0)}`);
106
109
  const changed = stats.lastReport.changedFiles?.join(", ");
107
110
  if (changed) lines.push(`Last changed files: ${changed}`);
108
111
  }
@@ -5,9 +5,10 @@ import { readGitSnapshot, checkCompliance } from "./measure.js";
5
5
  import { buildReport, formatReport } from "./reporter.js";
6
6
  import { loadRuntimeEvidence } from "./telemetry.js";
7
7
  import { filterActionableRules } from "./analyzer.js";
8
+ import { resolveHookCwd } from "./hook-io.js";
8
9
 
9
10
  export function handleStopPayload(payload, { contextPath, reportPath, historyPath, telemetryPath } = {}) {
10
- const cwd = payload.cwd || payload.working_directory || process.cwd();
11
+ const cwd = resolveHookCwd(payload);
11
12
  const promptContext = contextPath && fs.existsSync(contextPath) ? readJsonFile(contextPath) : null;
12
13
  const rawScheduledRules = [
13
14
  ...(promptContext?.scheduled?.highRules || []),