@neat.is/core 0.9.5-dev.20260824 → 0.9.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/dist/cli.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  resolveHost,
7
7
  resolveNeatVersion,
8
8
  writeDaemonRecord
9
- } from "./chunk-TGCWMMF6.js";
9
+ } from "./chunk-4SLKQNG7.js";
10
10
  import {
11
11
  buildSearchIndex
12
12
  } from "./chunk-BC53SCT7.js";
@@ -75,7 +75,7 @@ import {
75
75
  startStalenessLoop,
76
76
  upsertConnectorEntry,
77
77
  validateConnectorEntry
78
- } from "./chunk-IVVF37OU.js";
78
+ } from "./chunk-DGAI4VOE.js";
79
79
  import {
80
80
  startOtelGrpcReceiver
81
81
  } from "./chunk-ERE47MCR.js";
@@ -89,9 +89,9 @@ import {
89
89
  } from "./chunk-GDGUY4T6.js";
90
90
 
91
91
  // src/cli.ts
92
- import path16 from "path";
92
+ import path17 from "path";
93
93
  import os4 from "os";
94
- import { promises as fs14 } from "fs";
94
+ import { promises as fs15 } from "fs";
95
95
 
96
96
  // src/banner.ts
97
97
  import path from "path";
@@ -4827,945 +4827,908 @@ async function runConnectorCommand(rawArgs, deps = {}) {
4827
4827
  }
4828
4828
  }
4829
4829
 
4830
- // src/hooks-cli.ts
4831
- import path11 from "path";
4832
- import os from "os";
4830
+ // src/doctor-cli.ts
4833
4831
  import { promises as fs10 } from "fs";
4834
- import { fileURLToPath as fileURLToPath3 } from "url";
4835
- var HOOK_FILENAME = "neat-search-nudge.mjs";
4836
- var GUIDE_FILENAME = "GRAPH_FIRST.md";
4837
- var GUIDE_INSTALL_NAME = "neat-graph-first.md";
4838
- var HOOK_MATCHER = "Grep|Glob|Bash";
4839
- function moduleDir() {
4840
- return typeof __dirname !== "undefined" ? __dirname : path11.dirname(fileURLToPath3(import.meta.url));
4841
- }
4842
- async function readSkillAsset(rel) {
4843
- const here = moduleDir();
4844
- const candidates = [
4845
- path11.resolve(here, "../../claude-skill", rel),
4846
- path11.resolve(here, "../../../claude-skill", rel),
4847
- path11.resolve(here, "../claude-skill", rel)
4848
- ];
4849
- for (const candidate of candidates) {
4850
- try {
4851
- return await fs10.readFile(candidate, "utf8");
4852
- } catch {
4853
- }
4832
+ import path11 from "path";
4833
+
4834
+ // src/cli-client.ts
4835
+ import { Provenance as Provenance2 } from "@neat.is/types";
4836
+ var HttpError = class extends Error {
4837
+ constructor(status, message, responseBody = "") {
4838
+ super(message);
4839
+ this.status = status;
4840
+ this.responseBody = responseBody;
4841
+ this.name = "HttpError";
4854
4842
  }
4855
- throw new Error(
4856
- `neat hooks: could not find @neat.is/claude-skill/${rel} \u2014 is the package installed?`
4857
- );
4858
- }
4859
- function neatHome() {
4860
- const override = process.env.NEAT_HOME;
4861
- if (override && override.length > 0) return path11.resolve(override);
4862
- return path11.join(os.homedir(), ".neat");
4863
- }
4864
- function claudeSettingsPath() {
4865
- const override = process.env.NEAT_CLAUDE_SETTINGS;
4866
- if (override && override.length > 0) return path11.resolve(override);
4867
- const home = process.env.HOME ?? process.env.USERPROFILE ?? os.homedir();
4868
- return path11.join(home, ".claude", "settings.json");
4843
+ status;
4844
+ responseBody;
4845
+ };
4846
+ var TransportError = class extends Error {
4847
+ constructor(message) {
4848
+ super(message);
4849
+ this.name = "TransportError";
4850
+ }
4851
+ };
4852
+ function resolveAuthToken(env = process.env) {
4853
+ const t = env.NEAT_AUTH_TOKEN;
4854
+ return t && t.length > 0 ? t : void 0;
4869
4855
  }
4870
- function installedHookPath() {
4871
- return path11.join(neatHome(), "hooks", HOOK_FILENAME);
4856
+ function createHttpClient(baseUrl, bearerToken) {
4857
+ const root = baseUrl.replace(/\/$/, "");
4858
+ const authHeader = bearerToken && bearerToken.length > 0 ? { authorization: `Bearer ${bearerToken}` } : {};
4859
+ return {
4860
+ async get(path18) {
4861
+ let res;
4862
+ try {
4863
+ res = await fetch(`${root}${path18}`, {
4864
+ headers: { ...authHeader }
4865
+ });
4866
+ } catch (err) {
4867
+ throw new TransportError(
4868
+ `cannot reach neat-core at ${root}: ${err.message}`
4869
+ );
4870
+ }
4871
+ if (!res.ok) {
4872
+ const body = await res.text().catch(() => "");
4873
+ throw new HttpError(
4874
+ res.status,
4875
+ `${res.status} ${res.statusText} on GET ${path18}: ${body}`,
4876
+ body
4877
+ );
4878
+ }
4879
+ return await res.json();
4880
+ },
4881
+ async post(path18, body) {
4882
+ let res;
4883
+ try {
4884
+ res = await fetch(`${root}${path18}`, {
4885
+ method: "POST",
4886
+ headers: { "content-type": "application/json", ...authHeader },
4887
+ body: JSON.stringify(body)
4888
+ });
4889
+ } catch (err) {
4890
+ throw new TransportError(
4891
+ `cannot reach neat-core at ${root}: ${err.message}`
4892
+ );
4893
+ }
4894
+ if (!res.ok) {
4895
+ const text = await res.text().catch(() => "");
4896
+ throw new HttpError(
4897
+ res.status,
4898
+ `${res.status} ${res.statusText} on POST ${path18}: ${text}`,
4899
+ text
4900
+ );
4901
+ }
4902
+ return await res.json();
4903
+ }
4904
+ };
4872
4905
  }
4873
- function gateFlagPath() {
4874
- return path11.join(neatHome(), "hooks", "gate-enabled");
4906
+ function projectPath(project, suffix) {
4907
+ if (!project) return suffix;
4908
+ return `/projects/${encodeURIComponent(project)}${suffix}`;
4875
4909
  }
4876
- function isNeatSearchEntry(entry2) {
4877
- return (entry2.hooks ?? []).some(
4878
- (h) => typeof h.command === "string" && h.command.includes(HOOK_FILENAME)
4910
+ async function runRootCause(client, input) {
4911
+ const qs = input.errorId ? `?errorId=${encodeURIComponent(input.errorId)}` : "";
4912
+ const path18 = projectPath(
4913
+ input.project,
4914
+ `/graph/root-cause/${encodeURIComponent(input.errorNode)}${qs}`
4879
4915
  );
4880
- }
4881
- function neatHookEntry(command) {
4882
- return { matcher: HOOK_MATCHER, hooks: [{ type: "command", command }] };
4883
- }
4884
- function hookCommand(scriptPath) {
4885
- return `node "${scriptPath}"`;
4886
- }
4887
- async function runHooks(opts) {
4888
- if (opts.printHook) {
4889
- process.stdout.write(await readSkillAsset(`hooks/${HOOK_FILENAME}`));
4890
- return { exitCode: 0 };
4891
- }
4892
- if (opts.printGuide) {
4893
- process.stdout.write(await readSkillAsset(GUIDE_FILENAME));
4894
- return { exitCode: 0 };
4895
- }
4896
- if (opts.printSettings) {
4897
- const block = {
4898
- hooks: { PreToolUse: [neatHookEntry(hookCommand(installedHookPath()))] }
4916
+ try {
4917
+ const result = await client.get(path18);
4918
+ const arrowPath = result.traversalPath.join(" \u2190 ");
4919
+ const provenances = result.edgeProvenances.length ? result.edgeProvenances.join(", ") : "(direct, no edges traversed)";
4920
+ const summary = `Root cause for ${input.errorNode} is ${result.rootCauseNode}. ` + result.rootCauseReason + (result.fixRecommendation ? ` Recommended fix: ${result.fixRecommendation}.` : "");
4921
+ const blockLines = [
4922
+ `Traversal path: ${arrowPath}`,
4923
+ `Edge provenances: ${provenances}`
4924
+ ];
4925
+ if (result.fixRecommendation) blockLines.push(`Recommended fix: ${result.fixRecommendation}`);
4926
+ return {
4927
+ summary,
4928
+ block: blockLines.join("\n"),
4929
+ confidence: result.confidence,
4930
+ provenance: result.edgeProvenances.length ? result.edgeProvenances : void 0
4899
4931
  };
4900
- process.stdout.write(JSON.stringify(block, null, 2) + "\n");
4901
- return { exitCode: 0 };
4902
- }
4903
- if (opts.apply) {
4904
- const hookScript = await readSkillAsset(`hooks/${HOOK_FILENAME}`);
4905
- const guide = await readSkillAsset(GUIDE_FILENAME);
4906
- const scriptPath = installedHookPath();
4907
- await fs10.mkdir(path11.dirname(scriptPath), { recursive: true });
4908
- await fs10.writeFile(scriptPath, hookScript, { mode: 493 });
4909
- const guidePath = path11.join(neatHome(), GUIDE_INSTALL_NAME);
4910
- await fs10.writeFile(guidePath, guide, "utf8");
4911
- const settingsFile = claudeSettingsPath();
4912
- let settings = {};
4913
- try {
4914
- settings = JSON.parse(await fs10.readFile(settingsFile, "utf8"));
4915
- } catch (err) {
4916
- if (err.code !== "ENOENT") {
4917
- console.error(
4918
- `neat hooks: failed to read ${settingsFile} \u2014 ${err.message}`
4919
- );
4920
- return { exitCode: 1 };
4921
- }
4932
+ } catch (err) {
4933
+ if (err instanceof HttpError && err.status === 404) {
4934
+ return {
4935
+ summary: `No root cause found for ${input.errorNode}. The node may be healthy, or it may not exist in the graph.`
4936
+ };
4922
4937
  }
4923
- const hooks = settings.hooks ?? {};
4924
- const preToolUse = Array.isArray(hooks.PreToolUse) ? [...hooks.PreToolUse] : [];
4925
- const command = hookCommand(scriptPath);
4926
- const existingIdx = preToolUse.findIndex(isNeatSearchEntry);
4927
- if (existingIdx >= 0) {
4928
- preToolUse[existingIdx] = neatHookEntry(command);
4929
- } else {
4930
- preToolUse.push(neatHookEntry(command));
4938
+ throw err;
4939
+ }
4940
+ }
4941
+ async function runBlastRadius(client, input) {
4942
+ const qs = input.depth !== void 0 ? `?depth=${input.depth}` : "";
4943
+ const path18 = projectPath(
4944
+ input.project,
4945
+ `/graph/blast-radius/${encodeURIComponent(input.nodeId)}${qs}`
4946
+ );
4947
+ try {
4948
+ const result = await client.get(path18);
4949
+ if (result.totalAffected === 0) {
4950
+ return {
4951
+ summary: `${result.origin} has no dependents. Nothing else would break if it failed.`
4952
+ };
4931
4953
  }
4932
- const merged = {
4933
- ...settings,
4934
- hooks: { ...hooks, PreToolUse: preToolUse }
4954
+ const sorted = [...result.affectedNodes].sort(
4955
+ (a, b) => a.distance - b.distance || a.nodeId.localeCompare(b.nodeId)
4956
+ );
4957
+ const blockLines = sorted.map(formatBlastEntry);
4958
+ const minConfidence = sorted.reduce(
4959
+ (m, n) => Math.min(m, n.confidence),
4960
+ Number.POSITIVE_INFINITY
4961
+ );
4962
+ const provenances = [...new Set(sorted.map((n) => n.edgeProvenance))];
4963
+ return {
4964
+ summary: `Blast radius for ${result.origin}: ${result.totalAffected} dependent node${result.totalAffected === 1 ? "" : "s"} would break if it changed.`,
4965
+ block: blockLines.join("\n"),
4966
+ confidence: Number.isFinite(minConfidence) ? minConfidence : void 0,
4967
+ provenance: provenances.length ? provenances : void 0
4935
4968
  };
4936
- await fs10.mkdir(path11.dirname(settingsFile), { recursive: true });
4937
- await fs10.writeFile(settingsFile, JSON.stringify(merged, null, 2) + "\n", "utf8");
4938
- const flag = gateFlagPath();
4939
- if (opts.gate) {
4940
- await fs10.mkdir(path11.dirname(flag), { recursive: true });
4941
- await fs10.writeFile(flag, "1\n", "utf8");
4942
- } else {
4943
- await fs10.rm(flag, { force: true });
4944
- }
4945
- const mode = opts.gate ? "GATE (deny search until you ask the graph)" : "nudge (search still runs)";
4946
- console.log(`neat hooks: installed the search hook in ${opts.gate ? "gate" : "nudge"} mode`);
4947
- console.log(` script: ${scriptPath}`);
4948
- console.log(` settings: ${settingsFile} (PreToolUse \u2192 ${HOOK_MATCHER})`);
4949
- console.log(` guidance: ${guidePath}`);
4950
- console.log(` mode: ${mode}`);
4951
- console.log("");
4952
- if (opts.gate) {
4953
- console.log("restart Claude Code to load the hook. A Grep/Glob or Bash grep is now DENIED");
4954
- console.log('until you run `neat ask "<question>"` (or the ask MCP tool) once this session;');
4955
- console.log("after that, search is allowed as a fallback. Set NEAT_SEARCH_GATE=0 to fall");
4956
- console.log("back to nudge-only without re-running.");
4957
- } else {
4958
- console.log("restart Claude Code to load the hook. On a Grep/Glob or a Bash grep,");
4959
- console.log("your agent will now be nudged to query NEAT first (the search still runs).");
4960
- console.log("Re-run with --gate to hard-force the graph-first orientation.");
4969
+ } catch (err) {
4970
+ if (err instanceof HttpError && err.status === 404) {
4971
+ return { summary: `Node ${input.nodeId} not found in the graph.` };
4961
4972
  }
4962
- console.log("");
4963
- console.log("The hook is Claude-Code-specific. For agents on other harnesses, paste");
4964
- console.log(`the guidance above into your project instructions (CLAUDE.md / AGENTS.md).`);
4965
- return { exitCode: 0 };
4973
+ throw err;
4966
4974
  }
4967
- usage();
4968
- return { exitCode: 0 };
4969
4975
  }
4970
- function usage() {
4971
- console.log("neat hooks \u2014 wire NEAT into your agent so it queries the graph before grepping");
4972
- console.log("");
4973
- console.log(" --apply install the Claude Code search hook and write the");
4974
- console.log(" graph-first guidance to ~/.neat/, merging into");
4975
- console.log(" ~/.claude/settings.json without touching your other hooks");
4976
- console.log(" --gate with --apply, enable hard-gate mode: DENY Grep/Glob/grep-Bash");
4977
- console.log(" until `neat ask` has run this session (default is nudge-only).");
4978
- console.log(" Toggle off at run time with NEAT_SEARCH_GATE=0.");
4979
- console.log(" --print-hook print the hook script to stdout");
4980
- console.log(" --print-guide print the agent-agnostic graph-first guidance to stdout");
4981
- console.log(" --print-settings print the settings.json PreToolUse block --apply would add");
4982
- console.log("");
4983
- console.log("By default the hook is a gentle, non-blocking nudge \u2014 searches still run.");
4984
- console.log("--gate turns it into a hard forcing mechanism. It is Claude-Code-specific;");
4985
- console.log("other harnesses get the same steer from the graph-first guidance.");
4976
+ function formatBlastEntry(n) {
4977
+ const tag = n.edgeProvenance === Provenance2.STALE ? " [STALE \u2014 last seen too long ago]" : "";
4978
+ return ` \u2022 ${n.nodeId} (distance ${n.distance}, ${n.edgeProvenance})${tag}`;
4986
4979
  }
4987
- async function runHooksCommand(args) {
4988
- const opts = {
4989
- apply: false,
4990
- printHook: false,
4991
- printGuide: false,
4992
- printSettings: false,
4993
- gate: false
4994
- };
4995
- for (const arg of args) {
4996
- switch (arg) {
4997
- case "--apply":
4998
- opts.apply = true;
4999
- break;
5000
- case "--gate":
5001
- opts.gate = true;
5002
- break;
5003
- case "--print-hook":
5004
- opts.printHook = true;
5005
- break;
5006
- case "--print-guide":
5007
- opts.printGuide = true;
5008
- break;
5009
- case "--print-settings":
5010
- opts.printSettings = true;
5011
- break;
5012
- case "-h":
5013
- case "--help":
5014
- usage();
5015
- return 0;
5016
- default:
5017
- console.error(`neat hooks: unknown flag "${arg}"`);
5018
- usage();
5019
- return 2;
5020
- }
5021
- }
4980
+ async function runDependencies(client, input) {
4981
+ const depth = input.depth ?? 3;
4982
+ const path18 = projectPath(
4983
+ input.project,
4984
+ `/graph/dependencies/${encodeURIComponent(input.nodeId)}?depth=${depth}`
4985
+ );
5022
4986
  try {
5023
- const { exitCode } = await runHooks(opts);
5024
- return exitCode;
4987
+ const result = await client.get(path18);
4988
+ if (result.total === 0) {
4989
+ return {
4990
+ summary: depth === 1 ? `${input.nodeId} has no direct dependencies in the graph.` : `${input.nodeId} has no dependencies (BFS to depth ${depth}).`
4991
+ };
4992
+ }
4993
+ const byDistance = /* @__PURE__ */ new Map();
4994
+ for (const dep of result.dependencies) {
4995
+ const ring = byDistance.get(dep.distance) ?? [];
4996
+ ring.push(dep);
4997
+ byDistance.set(dep.distance, ring);
4998
+ }
4999
+ const blockLines = [];
5000
+ for (const distance of [...byDistance.keys()].sort((a, b) => a - b)) {
5001
+ const label = distance === 1 ? "Direct (distance 1)" : `Distance ${distance}`;
5002
+ blockLines.push(`${label}:`);
5003
+ for (const dep of byDistance.get(distance)) {
5004
+ blockLines.push(` \u2022 ${dep.nodeId} \u2014 ${dep.edgeType} (${dep.provenance})`);
5005
+ }
5006
+ }
5007
+ const provenances = [...new Set(result.dependencies.map((d) => d.provenance))];
5008
+ const directCount = byDistance.get(1)?.length ?? 0;
5009
+ const summary = depth === 1 ? `${input.nodeId} has ${directCount} direct dependenc${directCount === 1 ? "y" : "ies"}.` : `${input.nodeId} has ${result.total} dependenc${result.total === 1 ? "y" : "ies"} reachable to depth ${depth} (${directCount} direct).`;
5010
+ return { summary, block: blockLines.join("\n"), provenance: provenances };
5025
5011
  } catch (err) {
5026
- console.error(err.message);
5027
- return 1;
5028
- }
5029
- }
5030
-
5031
- // src/claude-cli.ts
5032
- import path12 from "path";
5033
- import { promises as fs11 } from "fs";
5034
- var NEAT_SECTION_HEADING = "## neat";
5035
- var NEAT_DIRECTIVE_BODY = `This project has NEAT wired in: a live, fused semantic graph of the system \u2014
5036
- code and runtime behaviour (OpenTelemetry) in one model, every fact tagged with
5037
- its provenance (EXTRACTED / OBSERVED / INFERRED / STALE) and a confidence.
5038
-
5039
- For ANY question about this system's behaviour, dependencies, failures, root
5040
- cause, or blast radius, call \`neat ask\` FIRST \u2014 before Read, Grep, Glob, or Bash:
5041
-
5042
- neat ask "why is checkout failing?"
5043
- neat ask "what breaks if I change the orders table?"
5044
- neat ask "what does the payments service depend on at runtime?"
5045
-
5046
- \`ask\` resolves your question to graph nodes and routes it to the right traversal
5047
- \u2014 root cause, dependencies, observed runtime calls, incidents, divergences,
5048
- blast radius \u2014 and returns one compact, provenance-tagged answer. The same door
5049
- is the \`ask\` MCP tool (\`mcp__neat__ask\`) when NEAT's MCP server is wired in.
5050
-
5051
- The graph is live and fused: it is faster and more accurate than scanning files,
5052
- and it can tell you what the system actually does at runtime, not only what the
5053
- source declares. Fall back to Read/Grep only when the graph does not have what
5054
- you need \u2014 comments, string literals, config minutiae. Ask the graph first,
5055
- then scan.
5056
-
5057
- If \`neat ask\` errors, the daemon may not be running (\`neat list\`) \u2014 start it
5058
- with \`neat <path>\`, then re-ask.`;
5059
- function neatSection() {
5060
- return `${NEAT_SECTION_HEADING}
5061
-
5062
- ${NEAT_DIRECTIVE_BODY}
5063
- `;
5064
- }
5065
- function claudeMdPath() {
5066
- const override = process.env.NEAT_CLAUDE_MD;
5067
- if (override && override.length > 0) return path12.resolve(override);
5068
- return path12.join(process.cwd(), "CLAUDE.md");
5069
- }
5070
- function splitAroundSection(raw) {
5071
- const lines = raw.split("\n");
5072
- const startIdx = lines.findIndex((l) => l.replace(/\s+$/, "") === NEAT_SECTION_HEADING);
5073
- if (startIdx === -1) {
5074
- return { before: raw.replace(/\n*$/, ""), after: "", found: false };
5075
- }
5076
- let endIdx = lines.length;
5077
- for (let i = startIdx + 1; i < lines.length; i++) {
5078
- if (/^#{1,2}\s+/.test(lines[i] ?? "")) {
5079
- endIdx = i;
5080
- break;
5012
+ if (err instanceof HttpError && err.status === 404) {
5013
+ return { summary: `Node ${input.nodeId} not found in the graph.` };
5081
5014
  }
5015
+ throw err;
5082
5016
  }
5083
- const before = lines.slice(0, startIdx).join("\n").replace(/\n*$/, "");
5084
- const after = lines.slice(endIdx).join("\n").replace(/^\n*/, "");
5085
- return { before, after, found: true };
5086
5017
  }
5087
- function compose(before, after) {
5088
- const parts = [];
5089
- if (before.length > 0) parts.push(before);
5090
- parts.push(neatSection().replace(/\n+$/, ""));
5091
- if (after.length > 0) parts.push(after);
5092
- return parts.join("\n\n").replace(/\n*$/, "") + "\n";
5018
+ function observedDepLine(nodeId, e) {
5019
+ const via = e.source !== nodeId ? ` (via ${e.source})` : "";
5020
+ return ` \u2022 ${e.target} \u2014 ${e.type}${via}${edgeMeta(e)}`;
5093
5021
  }
5094
- async function readIfExists(file) {
5022
+ async function runObservedDependencies(client, input) {
5095
5023
  try {
5096
- return await fs11.readFile(file, "utf8");
5024
+ const result = await client.get(
5025
+ projectPath(
5026
+ input.project,
5027
+ `/graph/observed-dependencies/${encodeURIComponent(input.nodeId)}`
5028
+ )
5029
+ );
5030
+ if (result.dependencies.length === 0) {
5031
+ if (result.observed) {
5032
+ return {
5033
+ summary: `${input.nodeId} makes no outbound runtime calls, but OTel has observed it receiving traffic on ${result.inboundObservedCount} inbound call path${result.inboundObservedCount === 1 ? "" : "s"} \u2014 it's a pure receiver.`,
5034
+ provenance: Provenance2.OBSERVED
5035
+ };
5036
+ }
5037
+ const note = result.hasExtractedOutbound ? " Static (EXTRACTED) dependencies exist but no runtime traffic has been seen \u2014 is OTel running?" : "";
5038
+ return { summary: `No OBSERVED dependencies for ${input.nodeId}.${note}` };
5039
+ }
5040
+ const blockLines = result.dependencies.map((e) => observedDepLine(input.nodeId, e));
5041
+ return {
5042
+ summary: `${input.nodeId} has ${result.dependencies.length} runtime dependenc${result.dependencies.length === 1 ? "y" : "ies"} confirmed by OTel.`,
5043
+ block: blockLines.join("\n"),
5044
+ provenance: Provenance2.OBSERVED
5045
+ };
5097
5046
  } catch (err) {
5098
- if (err.code === "ENOENT") return null;
5047
+ if (err instanceof HttpError && err.status === 404) {
5048
+ return { summary: `Node ${input.nodeId} not found in the graph.` };
5049
+ }
5099
5050
  throw err;
5100
5051
  }
5101
5052
  }
5102
- async function runInstall() {
5103
- const file = claudeMdPath();
5104
- const raw = await readIfExists(file) ?? "";
5105
- const { before, after, found } = splitAroundSection(raw);
5106
- const next = compose(before, after);
5107
- await fs11.mkdir(path12.dirname(file), { recursive: true });
5108
- await fs11.writeFile(file, next, "utf8");
5109
- const verb = raw.length === 0 ? "created" : found ? "refreshed" : "added";
5110
- console.log(`neat claude: ${verb} the \`${NEAT_SECTION_HEADING}\` section in ${file}`);
5111
- console.log("Your agent will now reach for `neat ask` before Read/Grep/Bash. Restart the");
5112
- console.log("session (or reload CLAUDE.md) to pick it up.");
5113
- return { exitCode: 0 };
5114
- }
5115
- async function runUninstall() {
5116
- const file = claudeMdPath();
5117
- const raw = await readIfExists(file);
5118
- if (raw === null) {
5119
- console.log(`neat claude: no CLAUDE.md at ${file} \u2014 nothing to remove.`);
5120
- return { exitCode: 0 };
5121
- }
5122
- const { before, after, found } = splitAroundSection(raw);
5123
- if (!found) {
5124
- console.log(`neat claude: no \`${NEAT_SECTION_HEADING}\` section in ${file} \u2014 nothing to remove.`);
5125
- return { exitCode: 0 };
5053
+ function edgeMeta(e) {
5054
+ const bits = [];
5055
+ if (e.signal) {
5056
+ bits.push(`spans=${e.signal.spanCount}`);
5057
+ if (e.signal.errorCount > 0) bits.push(`errors=${e.signal.errorCount}`);
5058
+ if (e.signal.lastObservedAgeMs !== void 0) {
5059
+ bits.push(`age=${formatDuration(e.signal.lastObservedAgeMs)}`);
5060
+ }
5061
+ } else if (e.callCount !== void 0) {
5062
+ bits.push(`callCount=${e.callCount}`);
5126
5063
  }
5127
- const remaining = [before, after].filter((s) => s.length > 0).join("\n\n");
5128
- const next = remaining.length > 0 ? remaining.replace(/\n*$/, "") + "\n" : "";
5129
- await fs11.writeFile(file, next, "utf8");
5130
- console.log(`neat claude: removed the \`${NEAT_SECTION_HEADING}\` section from ${file}.`);
5131
- return { exitCode: 0 };
5064
+ if (e.lastObserved) bits.push(`lastObserved=${e.lastObserved}`);
5065
+ if (e.confidence !== void 0) bits.push(`confidence=${e.confidence}`);
5066
+ return bits.length ? ` [${bits.join(", ")}]` : "";
5132
5067
  }
5133
- function usage2() {
5134
- console.log("neat claude \u2014 make the query-first directive always-on in Claude Code");
5135
- console.log("");
5136
- console.log(" install write (or refresh) a `## neat` section in ./CLAUDE.md so your");
5137
- console.log(" agent reaches for `neat ask` before Read/Grep/Bash");
5138
- console.log(" uninstall remove the `## neat` section from ./CLAUDE.md");
5139
- console.log(" print print the directive block to stdout (for a manual paste)");
5140
- console.log("");
5141
- console.log("Idempotent: re-running install replaces its own section, never duplicates it.");
5142
- console.log("Target file overridable via NEAT_CLAUDE_MD.");
5068
+ function formatDuration(ms) {
5069
+ if (ms < 1e3) return `${Math.round(ms)}ms`;
5070
+ const s = Math.round(ms / 1e3);
5071
+ if (s < 60) return `${s}s`;
5072
+ const m = Math.round(s / 60);
5073
+ if (m < 60) return `${m}m`;
5074
+ const h = Math.round(m / 60);
5075
+ if (h < 48) return `${h}h`;
5076
+ return `${Math.round(h / 24)}d`;
5143
5077
  }
5144
- async function runClaudeCommand(args) {
5145
- const sub = args[0];
5146
- if (sub === "-h" || sub === "--help" || sub === void 0) {
5147
- usage2();
5148
- return sub === void 0 ? 2 : 0;
5149
- }
5078
+ async function runIncidents(client, input) {
5079
+ const path18 = input.nodeId ? projectPath(input.project, `/incidents/${encodeURIComponent(input.nodeId)}`) : projectPath(input.project, "/incidents");
5150
5080
  try {
5151
- switch (sub) {
5152
- case "install":
5153
- return (await runInstall()).exitCode;
5154
- case "uninstall":
5155
- return (await runUninstall()).exitCode;
5156
- case "print":
5157
- process.stdout.write(neatSection());
5158
- return 0;
5159
- default:
5160
- console.error(`neat claude: unknown subcommand "${sub}"`);
5161
- usage2();
5162
- return 2;
5081
+ const body = await client.get(path18);
5082
+ const events = body.events;
5083
+ if (events.length === 0) {
5084
+ return {
5085
+ summary: input.nodeId ? `No incidents recorded against ${input.nodeId}.` : "No incidents recorded."
5086
+ };
5087
+ }
5088
+ const ordered = [...events].reverse().slice(0, input.limit ?? 20);
5089
+ const blockLines = [];
5090
+ for (const ev of ordered) {
5091
+ blockLines.push(` ${ev.timestamp} \u2014 ${ev.service}: ${ev.errorMessage}`);
5092
+ blockLines.push(` trace=${ev.traceId} span=${ev.spanId}`);
5163
5093
  }
5094
+ const target = input.nodeId ?? "the project";
5095
+ return {
5096
+ summary: `${target} has ${body.total} recorded incident${body.total === 1 ? "" : "s"}; showing the ${ordered.length} most recent.`,
5097
+ block: blockLines.join("\n"),
5098
+ provenance: Provenance2.OBSERVED
5099
+ };
5164
5100
  } catch (err) {
5165
- console.error(`neat claude: ${err.message}`);
5166
- return 1;
5101
+ if (err instanceof HttpError && err.status === 404) {
5102
+ return { summary: `Node ${input.nodeId ?? ""} not found in the graph.` };
5103
+ }
5104
+ throw err;
5167
5105
  }
5168
5106
  }
5169
-
5170
- // src/codex-cli.ts
5171
- import path13 from "path";
5172
- import os2 from "os";
5173
- import { promises as fs12 } from "fs";
5174
- import { isDeepStrictEqual } from "util";
5175
- import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
5176
- var CODEX_MCP_SERVER = {
5177
- command: "npx",
5178
- args: ["-y", "@neat.is/mcp"],
5179
- env: { NEAT_CORE_URL: "http://localhost:8080" }
5180
- };
5181
- var CODEX_NEAT_BLOCK = [
5182
- "[mcp_servers.neat]",
5183
- 'command = "npx"',
5184
- 'args = ["-y", "@neat.is/mcp"]',
5185
- 'env = { NEAT_CORE_URL = "http://localhost:8080" }'
5186
- ].join("\n");
5187
- var NEAT_GRAPH_FIRST_START = "<!-- neat:graph-first -->";
5188
- var NEAT_GRAPH_FIRST_END = "<!-- /neat:graph-first -->";
5189
- function codexConfigPath() {
5190
- const override = process.env.NEAT_CODEX_CONFIG;
5191
- if (override && override.length > 0) return path13.resolve(override);
5192
- const home = process.env.HOME ?? process.env.USERPROFILE ?? os2.homedir();
5193
- return path13.join(home, ".codex", "config.toml");
5194
- }
5195
- function agentsFilePath() {
5196
- const override = process.env.NEAT_CODEX_AGENTS;
5197
- if (override && override.length > 0) return path13.resolve(override);
5198
- return path13.join(process.cwd(), "AGENTS.md");
5199
- }
5200
- function isTableHeader(line) {
5201
- return /^\s*\[\[?[^\]]+\]\]?\s*$/.test(line);
5202
- }
5203
- function tableName(line) {
5204
- return line.trim().replace(/^\[\[?/, "").replace(/\]\]?$/, "").trim();
5205
- }
5206
- function isNeatHeader(line) {
5207
- return isTableHeader(line) && tableName(line) === "mcp_servers.neat";
5208
- }
5209
- function isNeatChildHeader(line) {
5210
- if (!isTableHeader(line)) return false;
5211
- const name = tableName(line);
5212
- return name === "mcp_servers.neat" || name.startsWith("mcp_servers.neat.");
5213
- }
5214
- function upsertCodexConfig(raw) {
5215
- const trimmed = raw.trim();
5216
- const parsed = trimmed.length > 0 ? parseToml(raw) : {};
5217
- const existingServers = parsed.mcp_servers ?? {};
5218
- const spliced = spliceNeatBlock(raw);
5219
- let text;
5220
- if (verifyPreserved(raw, spliced, parsed)) {
5221
- text = spliced;
5222
- } else {
5223
- const merged = {
5224
- ...parsed,
5225
- mcp_servers: { ...existingServers, neat: CODEX_MCP_SERVER }
5226
- };
5227
- text = stringifyToml(merged);
5228
- if (!text.endsWith("\n")) text += "\n";
5229
- }
5230
- return { text, changed: text !== raw };
5231
- }
5232
- function spliceNeatBlock(raw) {
5233
- const block = CODEX_NEAT_BLOCK;
5234
- const lines = raw.length > 0 ? raw.split("\n") : [];
5235
- const start = lines.findIndex(isNeatHeader);
5236
- if (start === -1) {
5237
- const base = raw.replace(/\n+$/, "");
5238
- return base.length > 0 ? `${base}
5239
-
5240
- ${block}
5241
- ` : `${block}
5242
- `;
5107
+ async function runSearch(client, input) {
5108
+ const result = await client.get(
5109
+ projectPath(input.project, `/search?q=${encodeURIComponent(input.query)}`)
5110
+ );
5111
+ if (result.matches.length === 0) {
5112
+ return { summary: `No matches for "${input.query}".` };
5243
5113
  }
5244
- let end = start + 1;
5245
- for (; ; ) {
5246
- while (end < lines.length && !isTableHeader(lines[end])) end++;
5247
- if (end < lines.length && isNeatChildHeader(lines[end])) {
5248
- end++;
5249
- continue;
5250
- }
5251
- break;
5114
+ const provider = result.provider ?? "substring";
5115
+ const blockLines = [];
5116
+ let topScore;
5117
+ for (const n of result.matches) {
5118
+ const score = provider !== "substring" && typeof n.score === "number" ? n.score : void 0;
5119
+ const scoreBit = score !== void 0 ? ` [score=${score.toFixed(2)}]` : "";
5120
+ if (score !== void 0 && (topScore === void 0 || score > topScore)) topScore = score;
5121
+ blockLines.push(
5122
+ ` \u2022 ${n.id} (${n.type}) \u2014 ${n.name ?? n.id}${scoreBit}`
5123
+ );
5252
5124
  }
5253
- const before = lines.slice(0, start).join("\n").replace(/\n*$/, "");
5254
- const after = lines.slice(end).join("\n").replace(/^\n*/, "");
5255
- let text = "";
5256
- if (before.length > 0) text += `${before}
5257
-
5258
- `;
5259
- text += `${block}
5260
- `;
5261
- if (after.length > 0) text += `
5262
- ${after}`;
5263
- return `${text.replace(/\n*$/, "")}
5264
- `;
5125
+ return {
5126
+ summary: `Found ${result.matches.length} match${result.matches.length === 1 ? "" : "es"} for "${input.query}" via ${provider} provider.`,
5127
+ block: blockLines.join("\n"),
5128
+ confidence: topScore
5129
+ };
5265
5130
  }
5266
- function verifyPreserved(raw, spliced, original) {
5267
- let next;
5268
- try {
5269
- next = parseToml(spliced);
5270
- } catch {
5271
- return false;
5272
- }
5273
- const origServers = original.mcp_servers ?? {};
5274
- const nextServers = next.mcp_servers ?? {};
5275
- if (!isDeepStrictEqual(nextServers.neat, CODEX_MCP_SERVER)) return false;
5276
- for (const name of Object.keys(origServers)) {
5277
- if (name === "neat") continue;
5278
- if (!isDeepStrictEqual(nextServers[name], origServers[name])) return false;
5131
+ async function runDiff(client, input) {
5132
+ const result = await client.get(
5133
+ projectPath(
5134
+ input.project,
5135
+ `/graph/diff?against=${encodeURIComponent(input.againstSnapshot)}`
5136
+ )
5137
+ );
5138
+ const total = result.added.nodes.length + result.added.edges.length + result.removed.nodes.length + result.removed.edges.length + result.changed.nodes.length + result.changed.edges.length;
5139
+ const baseLabel = result.base.exportedAt ?? "unknown";
5140
+ if (total === 0) {
5141
+ return {
5142
+ summary: `No differences between the current graph and ${input.againstSnapshot} (base exportedAt=${baseLabel}).`
5143
+ };
5279
5144
  }
5280
- for (const name of Object.keys(nextServers)) {
5281
- if (name !== "neat" && !(name in origServers)) return false;
5145
+ const blockLines = [
5146
+ ` base exportedAt: ${baseLabel}`,
5147
+ ` current exportedAt: ${result.current.exportedAt}`,
5148
+ ""
5149
+ ];
5150
+ if (result.added.nodes.length || result.added.edges.length) {
5151
+ blockLines.push("Added:");
5152
+ for (const n of result.added.nodes) blockLines.push(` + node ${n.id} (${n.type})`);
5153
+ for (const e of result.added.edges)
5154
+ blockLines.push(` + edge ${e.id} \u2014 ${e.source} -> ${e.target} (${e.type}, ${e.provenance})`);
5155
+ blockLines.push("");
5282
5156
  }
5283
- for (const key of Object.keys(original)) {
5284
- if (key === "mcp_servers") continue;
5285
- if (!isDeepStrictEqual(next[key], original[key])) return false;
5157
+ if (result.removed.nodes.length || result.removed.edges.length) {
5158
+ blockLines.push("Removed:");
5159
+ for (const n of result.removed.nodes) blockLines.push(` - node ${n.id} (${n.type})`);
5160
+ for (const e of result.removed.edges)
5161
+ blockLines.push(` - edge ${e.id} \u2014 ${e.source} -> ${e.target} (${e.type}, ${e.provenance})`);
5162
+ blockLines.push("");
5286
5163
  }
5287
- for (const key of Object.keys(next)) {
5288
- if (key !== "mcp_servers" && !(key in original)) return false;
5164
+ if (result.changed.nodes.length || result.changed.edges.length) {
5165
+ blockLines.push("Changed:");
5166
+ for (const c of result.changed.nodes) {
5167
+ blockLines.push(` ~ node ${c.id} \u2014 ${summariseAttrDiff(c.before, c.after)}`);
5168
+ }
5169
+ for (const c of result.changed.edges) {
5170
+ const provBit = c.before.provenance !== c.after.provenance ? `provenance ${c.before.provenance} \u2192 ${c.after.provenance}` : summariseAttrDiff(c.before, c.after);
5171
+ blockLines.push(` ~ edge ${c.id} \u2014 ${provBit}`);
5172
+ }
5289
5173
  }
5290
- return true;
5291
- }
5292
- function agentsBlock(guide) {
5293
- return `${NEAT_GRAPH_FIRST_START}
5294
- ${guide.replace(/\s+$/, "")}
5295
- ${NEAT_GRAPH_FIRST_END}
5296
- `;
5174
+ return {
5175
+ summary: `Diff against ${input.againstSnapshot}: ${total} change${total === 1 ? "" : "s"} between the snapshot and the live graph.`,
5176
+ block: blockLines.join("\n").trimEnd()
5177
+ };
5297
5178
  }
5298
- function upsertAgents(raw, guide) {
5299
- const block = agentsBlock(guide);
5300
- if (raw.length === 0) return { text: block, changed: true };
5301
- const startIdx = raw.indexOf(NEAT_GRAPH_FIRST_START);
5302
- const endIdx = raw.indexOf(NEAT_GRAPH_FIRST_END);
5303
- if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
5304
- const before = raw.slice(0, startIdx);
5305
- const after = raw.slice(endIdx + NEAT_GRAPH_FIRST_END.length);
5306
- const text2 = `${before}${block.replace(/\n+$/, "")}${after}`;
5307
- return { text: text2, changed: text2 !== raw };
5179
+ function summariseAttrDiff(before, after) {
5180
+ const keys = /* @__PURE__ */ new Set([...Object.keys(before), ...Object.keys(after)]);
5181
+ const changed = [];
5182
+ for (const k of keys) {
5183
+ if (JSON.stringify(before[k]) !== JSON.stringify(after[k])) changed.push(k);
5308
5184
  }
5309
- const base = raw.replace(/\n+$/, "");
5310
- const text = base.length > 0 ? `${base}
5311
-
5312
- ${block}` : block;
5313
- return { text, changed: text !== raw };
5314
- }
5315
- async function readGuide() {
5316
- return readSkillAsset(GUIDE_FILENAME);
5185
+ return changed.length === 0 ? "attributes differ" : `fields changed: ${changed.sort().join(", ")}`;
5317
5186
  }
5318
- async function runCodex(opts) {
5319
- if (opts.printConfig) {
5320
- process.stdout.write(`${CODEX_NEAT_BLOCK}
5321
- `);
5322
- return { exitCode: 0 };
5323
- }
5324
- if (opts.printGuide) {
5325
- process.stdout.write(agentsBlock(await readGuide()));
5326
- return { exitCode: 0 };
5187
+ async function runStaleEdges(client, input) {
5188
+ const params = new URLSearchParams();
5189
+ if (input.limit !== void 0) params.set("limit", String(input.limit));
5190
+ if (input.edgeType) params.set("edgeType", input.edgeType);
5191
+ const qs = params.size > 0 ? `?${params.toString()}` : "";
5192
+ const body = await client.get(
5193
+ projectPath(input.project, `/stale-events${qs}`)
5194
+ );
5195
+ const events = body.events;
5196
+ if (events.length === 0) {
5197
+ return {
5198
+ summary: input.edgeType ? `No stale ${input.edgeType} edges recorded.` : "No stale-edge transitions recorded yet."
5199
+ };
5327
5200
  }
5328
- const configPath = codexConfigPath();
5329
- const agentsPath = agentsFilePath();
5330
- let configRaw = "";
5331
- try {
5332
- configRaw = await fs12.readFile(configPath, "utf8");
5333
- } catch (err) {
5334
- if (err.code !== "ENOENT") {
5335
- console.error(`neat codex: failed to read ${configPath} \u2014 ${err.message}`);
5336
- return { exitCode: 1 };
5201
+ const blockLines = events.map(
5202
+ (e) => ` ${e.transitionedAt} \u2014 ${e.source} -[${e.edgeType}]-> ${e.target} (last seen ${e.lastObserved}, threshold ${formatDuration(e.thresholdMs)})`
5203
+ );
5204
+ return {
5205
+ summary: `${events.length} stale-edge transition${events.length === 1 ? "" : "s"} recorded${input.edgeType ? ` for ${input.edgeType}` : ""}.`,
5206
+ block: blockLines.join("\n"),
5207
+ provenance: Provenance2.STALE
5208
+ };
5209
+ }
5210
+ async function runPolicies(client, input) {
5211
+ let violations;
5212
+ let allowed = true;
5213
+ let hypothetical;
5214
+ if (input.hypotheticalAction) {
5215
+ if (typeof client.post !== "function") {
5216
+ throw new Error("HttpClient does not support POST \u2014 required for policies dry-run");
5337
5217
  }
5338
- }
5339
- let agentsRaw = "";
5340
- try {
5341
- agentsRaw = await fs12.readFile(agentsPath, "utf8");
5342
- } catch (err) {
5343
- if (err.code !== "ENOENT") {
5344
- console.error(`neat codex: failed to read ${agentsPath} \u2014 ${err.message}`);
5345
- return { exitCode: 1 };
5346
- }
5347
- }
5348
- let config;
5349
- try {
5350
- config = upsertCodexConfig(configRaw);
5351
- } catch (err) {
5352
- console.error(
5353
- `neat codex: ${configPath} is not valid TOML \u2014 ${err.message}`
5218
+ const body = await client.post(
5219
+ projectPath(input.project, "/policies/check"),
5220
+ { hypotheticalAction: input.hypotheticalAction }
5354
5221
  );
5355
- console.error("neat codex: fix the file and re-run; nothing was written.");
5356
- return { exitCode: 1 };
5357
- }
5358
- const guide = await readGuide();
5359
- const agents = upsertAgents(agentsRaw, guide);
5360
- if (!opts.apply) {
5361
- console.log("neat codex \u2014 plan (nothing written; re-run with --apply to write)");
5362
- console.log("");
5363
- console.log(` Codex MCP config: ${configPath}`);
5364
- console.log(
5365
- config.changed ? ` ${configRaw ? "update" : "create"} the [mcp_servers.neat] table:` : " already up to date \u2014 [mcp_servers.neat] matches"
5222
+ violations = body.violations;
5223
+ allowed = body.allowed;
5224
+ hypothetical = body.hypotheticalAction;
5225
+ } else {
5226
+ const params = new URLSearchParams();
5227
+ if (input.policyId) params.set("policyId", input.policyId);
5228
+ const qs = params.size > 0 ? `?${params.toString()}` : "";
5229
+ const body = await client.get(
5230
+ projectPath(input.project, `/policies/violations${qs}`)
5366
5231
  );
5367
- if (config.changed) {
5368
- for (const line of CODEX_NEAT_BLOCK.split("\n")) console.log(` ${line}`);
5369
- }
5370
- console.log("");
5371
- console.log(` Project instructions: ${agentsPath}`);
5372
- console.log(
5373
- agents.changed ? ` ${agentsRaw ? "update" : "create"} the graph-first block (between ${NEAT_GRAPH_FIRST_START} markers)` : " already up to date \u2014 graph-first block matches"
5232
+ violations = body.violations;
5233
+ allowed = violations.every((v) => v.onViolation !== "block");
5234
+ }
5235
+ if (input.nodeId) {
5236
+ violations = violations.filter(
5237
+ (v) => v.subject.nodeId === input.nodeId || v.subject.path?.includes(input.nodeId)
5374
5238
  );
5375
- console.log("");
5376
- console.log("The MCP server reads NEAT_CORE_URL for the daemon URL \u2014 edit that value in");
5377
- console.log("the generated table to point Codex at a non-default daemon.");
5378
- return { exitCode: 0 };
5379
5239
  }
5380
- if (config.changed) {
5381
- await fs12.mkdir(path13.dirname(configPath), { recursive: true });
5382
- await fs12.writeFile(configPath, config.text, "utf8");
5383
- console.log(`neat codex: wrote [mcp_servers.neat] to ${configPath}`);
5384
- } else {
5385
- console.log(`neat codex: ${configPath} already has NEAT's MCP server`);
5240
+ if (violations.length === 0) {
5241
+ return {
5242
+ summary: hypothetical ? `No violations would result from the hypothetical action (${hypothetical.kind}).` : "No policy violations recorded."
5243
+ };
5386
5244
  }
5387
- if (agents.changed) {
5388
- await fs12.mkdir(path13.dirname(agentsPath), { recursive: true });
5389
- await fs12.writeFile(agentsPath, agents.text, "utf8");
5390
- console.log(`neat codex: wrote the graph-first block to ${agentsPath}`);
5245
+ const blockCount = violations.filter((v) => v.onViolation === "block").length;
5246
+ const summaryParts = [];
5247
+ if (hypothetical) {
5248
+ summaryParts.push(
5249
+ `Hypothetical ${hypothetical.kind} would surface ${violations.length} violation${violations.length === 1 ? "" : "s"}`
5250
+ );
5391
5251
  } else {
5392
- console.log(`neat codex: ${agentsPath} already has the graph-first block`);
5252
+ summaryParts.push(
5253
+ `${violations.length} policy violation${violations.length === 1 ? "" : "s"} currently recorded`
5254
+ );
5393
5255
  }
5394
- console.log("");
5395
- console.log("restart Codex to pick up the new MCP server. NEAT_CORE_URL in the table");
5396
- console.log("points the server at the local daemon \u2014 edit it for a non-default one.");
5397
- return { exitCode: 0 };
5398
- }
5399
- function usage3() {
5400
- console.log("neat codex \u2014 install NEAT into the OpenAI Codex CLI (MCP server + AGENTS.md)");
5401
- console.log("");
5402
- console.log(" (no flag) plan: print what would change, write nothing");
5403
- console.log(" --apply add [mcp_servers.neat] to ~/.codex/config.toml and write");
5404
- console.log(" the graph-first block into ./AGENTS.md, merging into both");
5405
- console.log(" without touching your other servers or instructions");
5406
- console.log(" --print-config print the [mcp_servers.neat] TOML block to stdout");
5407
- console.log(" --print-guide print the AGENTS.md graph-first block to stdout");
5408
- console.log("");
5409
- console.log("Existing config is preserved and a re-run is a no-op. A malformed");
5410
- console.log("config.toml is a clear error with no partial write.");
5256
+ if (blockCount > 0) summaryParts.push(`${blockCount} of which block`);
5257
+ if (!allowed && hypothetical) summaryParts.push("action denied");
5258
+ const summary = summaryParts.join("; ") + ".";
5259
+ const blockLines = violations.map((v) => {
5260
+ const subject = v.subject.nodeId ?? v.subject.edgeId ?? v.subject.path?.[0] ?? "(global)";
5261
+ return ` \u2022 [${v.severity}/${v.onViolation}] ${v.policyName}: ${v.message} \u2014 ${subject}`;
5262
+ });
5263
+ const severities = [...new Set(violations.map((v) => v.severity))];
5264
+ return {
5265
+ summary,
5266
+ block: blockLines.join("\n"),
5267
+ confidence: hypothetical ? 0.7 : 1,
5268
+ provenance: severities.join(" ")
5269
+ };
5411
5270
  }
5412
- async function runCodexCommand(args) {
5413
- const opts = { apply: false, printConfig: false, printGuide: false };
5414
- for (const arg of args) {
5415
- switch (arg) {
5416
- case "--apply":
5417
- opts.apply = true;
5418
- break;
5419
- case "--print-config":
5420
- opts.printConfig = true;
5421
- break;
5422
- case "--print-guide":
5423
- opts.printGuide = true;
5424
- break;
5425
- case "-h":
5426
- case "--help":
5427
- usage3();
5428
- return 0;
5429
- default:
5430
- console.error(`neat codex: unknown flag "${arg}"`);
5431
- usage3();
5432
- return 2;
5271
+ function formatDivergenceLine(d) {
5272
+ switch (d.type) {
5273
+ case "missing-observed":
5274
+ case "missing-extracted":
5275
+ if (d.column) {
5276
+ return ` \u2022 [${d.type}] ${d.table ?? d.source} column ${d.column} \u2014 confidence ${d.confidence.toFixed(2)}`;
5277
+ }
5278
+ return ` \u2022 [${d.type}] ${d.source} \u2192 ${d.target} (${d.edgeType}) \u2014 confidence ${d.confidence.toFixed(2)}`;
5279
+ case "version-mismatch":
5280
+ return ` \u2022 [${d.type}] ${d.source} \u2192 ${d.target} \u2014 declared ${d.extractedVersion}, observed engine ${d.observedVersion} (${d.compatibility})`;
5281
+ case "host-mismatch":
5282
+ return ` \u2022 [${d.type}] ${d.source} \u2192 ${d.target} \u2014 declared host ${d.extractedHost}, observed host ${d.observedHost}`;
5283
+ case "compat-violation":
5284
+ return ` \u2022 [${d.type}] ${d.source} \u2192 ${d.target} \u2014 ${d.rule.kind}${d.rule.package ? ` (${d.rule.package})` : ""}`;
5285
+ case "observed-symbol-mismatch": {
5286
+ const at = d.location ? ` at ${d.location}` : "";
5287
+ const member = d.symbol ? ` ${d.symbol}` : "";
5288
+ return ` \u2022 [${d.type}] ${d.source}${member}${at} (${d.mismatchKind}) \u2014 confidence ${d.confidence.toFixed(2)}`;
5433
5289
  }
5434
5290
  }
5435
- try {
5436
- const { exitCode } = await runCodex(opts);
5437
- return exitCode;
5438
- } catch (err) {
5439
- console.error(err.message);
5440
- return 1;
5291
+ }
5292
+ async function runDivergences(client, input) {
5293
+ const params = new URLSearchParams();
5294
+ if (input.type && input.type.length > 0) params.set("type", input.type.join(","));
5295
+ if (input.minConfidence !== void 0) {
5296
+ params.set("minConfidence", String(input.minConfidence));
5297
+ }
5298
+ if (input.node) params.set("node", input.node);
5299
+ const qs = params.size > 0 ? `?${params.toString()}` : "";
5300
+ const result = await client.get(
5301
+ projectPath(input.project, `/graph/divergences${qs}`)
5302
+ );
5303
+ if (result.totalAffected === 0) {
5304
+ return {
5305
+ summary: "No divergences found between the declared (EXTRACTED) and observed (OBSERVED) views of the graph."
5306
+ };
5307
+ }
5308
+ const headline = result.divergences[0];
5309
+ const summary = `Found ${result.totalAffected} divergence${result.totalAffected === 1 ? "" : "s"} between code and production. Highest-confidence: ${headline.type} on ${headline.source} \u2192 ${headline.target}. ${headline.reason}`;
5310
+ const blockLines = [];
5311
+ for (const d of result.divergences) {
5312
+ blockLines.push(formatDivergenceLine(d));
5313
+ blockLines.push(` reason: ${d.reason}`);
5314
+ blockLines.push(` recommendation: ${d.recommendation}`);
5441
5315
  }
5316
+ const maxConfidence = result.divergences.reduce(
5317
+ (m, d) => Math.max(m, d.confidence),
5318
+ 0
5319
+ );
5320
+ return {
5321
+ summary,
5322
+ block: blockLines.join("\n"),
5323
+ confidence: maxConfidence,
5324
+ provenance: "composite (EXTRACTED + OBSERVED)"
5325
+ };
5442
5326
  }
5443
-
5444
- // src/editors-cli.ts
5445
- import path14 from "path";
5446
- import os3 from "os";
5447
- import { promises as fs13 } from "fs";
5448
- import { isDeepStrictEqual as isDeepStrictEqual2 } from "util";
5449
- import * as jsonc from "jsonc-parser";
5450
- var NEAT_MCP_SERVER = {
5451
- command: "npx",
5452
- args: ["-y", "@neat.is/mcp"]
5453
- };
5454
- var NEAT_OPENCODE_SERVER = {
5455
- type: "local",
5456
- command: ["npx", "-y", "@neat.is/mcp"],
5457
- enabled: true
5458
- };
5459
- var NEAT_CRUSH_SERVER = {
5460
- type: "stdio",
5461
- command: "npx",
5462
- args: ["-y", "@neat.is/mcp"]
5463
- };
5464
- var GRAPH_FIRST_MARKER_OPEN = "<!-- neat:graph-first -->";
5465
- var GRAPH_FIRST_MARKER_CLOSE = "<!-- /neat:graph-first -->";
5466
- function homeDir() {
5467
- return process.env.HOME ?? process.env.USERPROFILE ?? os3.homedir();
5327
+ async function runAsk(client, input) {
5328
+ const result = await client.get(
5329
+ projectPath(input.project, `/graph/ask?q=${encodeURIComponent(input.question)}`)
5330
+ );
5331
+ const blockLines = [];
5332
+ if (result.matched.length > 0) {
5333
+ blockLines.push(
5334
+ `Matched: ${result.matched.map((m) => `${m.nodeId} [${m.via} ${m.score.toFixed(2)}]`).join(", ")}`
5335
+ );
5336
+ blockLines.push(`Intent: ${result.intent}`);
5337
+ } else if (result.scope === "global") {
5338
+ blockLines.push(`Graph-wide answer (${result.intent}) \u2014 no entity named.`);
5339
+ }
5340
+ for (const section of result.sections) {
5341
+ blockLines.push("", section.heading + ":");
5342
+ for (const fact of section.facts) {
5343
+ const tag = fact.provenance ? ` [${fact.provenance}${fact.confidence !== void 0 ? ` ${fact.confidence.toFixed(2)}` : ""}]` : fact.confidence !== void 0 ? ` [confidence ${fact.confidence.toFixed(2)}]` : "";
5344
+ blockLines.push(` \u2022 ${fact.text}${tag}`);
5345
+ }
5346
+ }
5347
+ return {
5348
+ summary: result.answer,
5349
+ block: blockLines.join("\n").trim(),
5350
+ ...result.confidence !== void 0 ? { confidence: result.confidence } : {},
5351
+ ...result.provenance.length > 0 ? { provenance: result.provenance } : {}
5352
+ };
5468
5353
  }
5469
- function xdgConfigDir() {
5470
- const xdg = process.env.XDG_CONFIG_HOME;
5471
- return xdg && xdg.length > 0 ? path14.resolve(xdg) : path14.join(homeDir(), ".config");
5354
+ function formatFooter(confidence, provenance) {
5355
+ const c = confidence === void 0 ? "n/a" : confidence.toFixed(2);
5356
+ const p = provenance === void 0 ? "n/a" : Array.isArray(provenance) ? [...new Set(provenance)].join(", ") : provenance;
5357
+ return `confidence: ${c} \xB7 provenance: ${p}`;
5472
5358
  }
5473
- function envOverride(name) {
5474
- const v = process.env[name];
5475
- return v && v.length > 0 ? path14.resolve(v) : void 0;
5359
+ function formatHuman(result) {
5360
+ const sections = [result.summary.trim()];
5361
+ if (result.block && result.block.trim().length > 0) sections.push(result.block.trimEnd());
5362
+ sections.push(formatFooter(result.confidence, result.provenance));
5363
+ return sections.join("\n\n");
5476
5364
  }
5477
- var CURSOR_CLIENT = {
5478
- id: "cursor",
5479
- label: "Cursor",
5480
- docsUrl: "https://docs.cursor.com/context/mcp",
5481
- mcpConfigPath: () => envOverride("NEAT_CURSOR_CONFIG") ?? path14.join(homeDir(), ".cursor", "mcp.json"),
5482
- mcpContainerKey: "mcpServers",
5483
- format: "json",
5484
- // Cursor still reads a single `.cursorrules` at the project root (the modern
5485
- // `.cursor/rules/*.mdc` split is one-rule-per-file with frontmatter — a worse
5486
- // fit for a marker-fenced block). GRAPH_FIRST.md names this file directly.
5487
- rulesFileName: ".cursorrules"
5488
- };
5489
- var DEVIN_CLIENT = {
5490
- id: "devin",
5491
- label: "Devin Desktop (Cascade)",
5492
- docsUrl: "https://docs.devin.ai/desktop/cascade/mcp",
5493
- mcpConfigPath: () => envOverride("NEAT_DEVIN_CONFIG") ?? path14.join(homeDir(), ".codeium", "windsurf", "mcp_config.json"),
5494
- mcpContainerKey: "mcpServers",
5495
- format: "json",
5496
- rulesFileName: ".windsurfrules"
5497
- };
5498
- var GEMINI_CLIENT = {
5499
- id: "gemini",
5500
- label: "Gemini CLI",
5501
- docsUrl: "https://github.com/google-gemini/gemini-cli/blob/main/docs/tools/mcp-server.md",
5502
- mcpConfigPath: () => envOverride("NEAT_GEMINI_CONFIG") ?? path14.join(homeDir(), ".gemini", "settings.json"),
5503
- mcpContainerKey: "mcpServers",
5504
- format: "json",
5505
- rulesFileName: "GEMINI.md"
5506
- };
5507
- var QWEN_CLIENT = {
5508
- id: "qwen",
5509
- label: "Qwen Code",
5510
- docsUrl: "https://qwenlm.github.io/qwen-code-docs/en/users/features/mcp/",
5511
- mcpConfigPath: () => envOverride("NEAT_QWEN_CONFIG") ?? path14.join(homeDir(), ".qwen", "settings.json"),
5512
- mcpContainerKey: "mcpServers",
5513
- format: "json",
5514
- rulesFileName: "QWEN.md"
5515
- };
5516
- var AMAZONQ_CLIENT = {
5517
- id: "amazonq",
5518
- label: "Amazon Q Developer CLI",
5519
- docsUrl: "https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/command-line-mcp-configuration.html",
5520
- mcpConfigPath: () => envOverride("NEAT_AMAZONQ_CONFIG") ?? path14.join(homeDir(), ".aws", "amazonq", "mcp.json"),
5521
- mcpContainerKey: "mcpServers",
5522
- format: "json"
5523
- };
5524
- var ROOCODE_CLIENT = {
5525
- id: "roocode",
5526
- label: "Roo Code",
5527
- docsUrl: "https://roocodeinc.github.io/Roo-Code/features/mcp/using-mcp-in-roo",
5528
- mcpConfigPath: () => envOverride("NEAT_ROOCODE_CONFIG") ?? path14.join(process.cwd(), ".roo", "mcp.json"),
5529
- mcpContainerKey: "mcpServers",
5530
- format: "json"
5531
- };
5532
- var ZED_CLIENT = {
5533
- id: "zed",
5534
- label: "Zed",
5535
- docsUrl: "https://zed.dev/docs/ai/mcp",
5536
- mcpConfigPath: () => {
5537
- const override = envOverride("NEAT_ZED_CONFIG");
5538
- if (override) return override;
5539
- if (process.platform === "win32") {
5540
- const appData = process.env.APPDATA;
5541
- if (appData && appData.length > 0) return path14.join(appData, "Zed", "settings.json");
5542
- }
5543
- return path14.join(homeDir(), ".config", "zed", "settings.json");
5544
- },
5545
- mcpContainerKey: "context_servers",
5546
- format: "jsonc",
5547
- rulesFileName: ".rules"
5548
- };
5549
- var OPENCODE_CLIENT = {
5550
- id: "opencode",
5551
- label: "OpenCode",
5552
- docsUrl: "https://opencode.ai/docs/mcp-servers/",
5553
- mcpConfigPath: () => envOverride("NEAT_OPENCODE_CONFIG") ?? path14.join(xdgConfigDir(), "opencode", "opencode.json"),
5554
- mcpContainerKey: "mcp",
5555
- format: "json",
5556
- serverEntry: NEAT_OPENCODE_SERVER,
5557
- rulesFileName: "AGENTS.md"
5558
- };
5559
- var CRUSH_CLIENT = {
5560
- id: "crush",
5561
- label: "Crush",
5562
- docsUrl: "https://charmbracelet-crush.mintlify.app/configuration/mcp",
5563
- mcpConfigPath: () => envOverride("NEAT_CRUSH_CONFIG") ?? path14.join(xdgConfigDir(), "crush", "crush.json"),
5564
- mcpContainerKey: "mcp",
5565
- format: "json",
5566
- serverEntry: NEAT_CRUSH_SERVER,
5567
- rulesFileName: "AGENTS.md"
5568
- };
5569
- var CLIENTS = {
5570
- cursor: CURSOR_CLIENT,
5571
- devin: DEVIN_CLIENT,
5572
- gemini: GEMINI_CLIENT,
5573
- qwen: QWEN_CLIENT,
5574
- amazonq: AMAZONQ_CLIENT,
5575
- roocode: ROOCODE_CLIENT,
5576
- zed: ZED_CLIENT,
5577
- opencode: OPENCODE_CLIENT,
5578
- crush: CRUSH_CLIENT
5579
- };
5580
- function mergeJsonMcp(existing, containerKey, serverEntry) {
5581
- const servers = existing[containerKey] ?? {};
5582
- const already = isDeepStrictEqual2(servers.neat, serverEntry);
5583
- const merged = {
5584
- ...existing,
5585
- [containerKey]: { ...servers, neat: serverEntry }
5586
- };
5587
- return { merged, changed: !already };
5588
- }
5589
- function mergeJsoncMcp(raw, containerKey, serverEntry) {
5590
- const base = raw.trim().length > 0 ? raw : "{}";
5591
- const parsed = jsonc.parse(base) ?? {};
5592
- const servers = parsed[containerKey] ?? {};
5593
- if (isDeepStrictEqual2(servers.neat, serverEntry)) {
5594
- return { text: raw, changed: false };
5595
- }
5596
- const edits = jsonc.modify(base, [containerKey, "neat"], serverEntry, {
5597
- formattingOptions: { tabSize: 2, insertSpaces: true }
5598
- });
5599
- let text = jsonc.applyEdits(base, edits);
5600
- if (!text.endsWith("\n")) text += "\n";
5601
- return { text, changed: text !== raw };
5365
+ function formatJson(result) {
5366
+ return JSON.stringify(
5367
+ {
5368
+ summary: result.summary,
5369
+ block: result.block ?? "",
5370
+ confidence: result.confidence ?? null,
5371
+ provenance: result.provenance ?? null
5372
+ },
5373
+ null,
5374
+ 2
5375
+ );
5602
5376
  }
5603
- function escapeRegExp(s) {
5604
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5377
+ function exitCodeForError(err) {
5378
+ if (err instanceof TransportError) return 3;
5379
+ if (err instanceof HttpError) return 1;
5380
+ return 1;
5605
5381
  }
5606
- function buildGuidanceBlock(guide) {
5607
- return `${GRAPH_FIRST_MARKER_OPEN}
5608
- ${guide.trim()}
5609
- ${GRAPH_FIRST_MARKER_CLOSE}
5610
- `;
5382
+ function createSnapshotPushClient(baseUrl, token) {
5383
+ return createHttpClient(baseUrl, token && token.length > 0 ? token : void 0);
5611
5384
  }
5612
- function mergeRulesFile(existing, block) {
5613
- const region = new RegExp(
5614
- `${escapeRegExp(GRAPH_FIRST_MARKER_OPEN)}[\\s\\S]*?${escapeRegExp(GRAPH_FIRST_MARKER_CLOSE)}\\n?`
5385
+ async function pushSnapshotToRemote(input) {
5386
+ const client = createSnapshotPushClient(input.baseUrl, input.token);
5387
+ if (typeof client.post !== "function") {
5388
+ throw new Error("HttpClient does not support POST \u2014 required for snapshot push");
5389
+ }
5390
+ return client.post(
5391
+ `/projects/${encodeURIComponent(input.project)}/snapshot`,
5392
+ { snapshot: input.snapshot }
5615
5393
  );
5616
- if (region.test(existing)) return existing.replace(region, block);
5617
- if (existing.trim().length === 0) return block;
5618
- return `${existing.replace(/\s+$/, "")}
5394
+ }
5619
5395
 
5620
- ${block}`;
5396
+ // src/doctor-cli.ts
5397
+ function resolveDeps2(deps) {
5398
+ return {
5399
+ cwd: deps.cwd ?? process.cwd(),
5400
+ env: deps.env ?? process.env,
5401
+ nodeVersion: deps.nodeVersion ?? process.versions.node,
5402
+ fetchImpl: deps.fetchImpl ?? fetch,
5403
+ readRecord: deps.readRecord ?? readDaemonRecord,
5404
+ out: deps.out ?? ((line) => console.log(line))
5405
+ };
5621
5406
  }
5622
- async function planMcp(client, mcpPath) {
5623
- const serverEntry = client.serverEntry ?? NEAT_MCP_SERVER;
5624
- let raw = "";
5407
+ var NODE_FLOOR = 20;
5408
+ var HEALTH_TIMEOUT_MS = 3e3;
5409
+ function checkNode(nodeVersion) {
5410
+ const major = Number.parseInt(nodeVersion.split(".")[0] ?? "", 10);
5411
+ const ok = Number.isFinite(major) && major >= NODE_FLOOR;
5412
+ return ok ? { name: "node", ok, detail: `v${nodeVersion} (>= ${NODE_FLOOR} required)` } : {
5413
+ name: "node",
5414
+ ok,
5415
+ detail: `v${nodeVersion} \u2014 NEAT needs Node ${NODE_FLOOR} or newer`,
5416
+ fix: `install Node ${NODE_FLOOR}.x (e.g. \`nvm install ${NODE_FLOOR}\`) and re-run`
5417
+ };
5418
+ }
5419
+ async function neatOutExists(cwd) {
5625
5420
  try {
5626
- raw = await fs13.readFile(mcpPath, "utf8");
5627
- } catch (err) {
5628
- const e = err;
5629
- if (e.code === "ENOENT") {
5630
- raw = "";
5631
- } else {
5632
- console.error(`neat ${client.id}: failed to read ${mcpPath} \u2014 ${e.message}`);
5633
- return null;
5634
- }
5421
+ const st = await fs10.stat(path11.join(cwd, "neat-out"));
5422
+ return st.isDirectory();
5423
+ } catch {
5424
+ return false;
5635
5425
  }
5636
- if (client.format === "jsonc") {
5637
- if (raw.trim().length > 0) {
5638
- const errors = [];
5639
- jsonc.parse(raw, errors, { allowTrailingComma: true });
5640
- if (errors.length > 0) {
5641
- const first = errors[0];
5642
- console.error(
5643
- `neat ${client.id}: ${mcpPath} is not valid JSONC \u2014 ${jsonc.printParseErrorCode(first.error)} at offset ${first.offset}. Fix it (or move it aside) and re-run; nothing was written.`
5644
- );
5645
- return null;
5646
- }
5426
+ }
5427
+ async function checkProject(cwd, record) {
5428
+ if (record) {
5429
+ return {
5430
+ name: "project",
5431
+ ok: true,
5432
+ detail: `"${record.project}" \u2014 set up in this directory (daemon record on REST ${record.ports.rest})`
5433
+ };
5434
+ }
5435
+ if (await neatOutExists(cwd)) {
5436
+ return {
5437
+ name: "project",
5438
+ ok: true,
5439
+ detail: "set up in this directory (no live daemon record \u2014 it may be stopped)"
5440
+ };
5441
+ }
5442
+ return {
5443
+ name: "project",
5444
+ ok: false,
5445
+ detail: "no NEAT project in this directory",
5446
+ fix: "set one up: `neat .`"
5447
+ };
5448
+ }
5449
+ function resolveHealthUrl(env, record) {
5450
+ const explicit = env.NEAT_API_URL ?? env.NEAT_CORE_URL;
5451
+ if (explicit && explicit.length > 0) return explicit.replace(/\/$/, "");
5452
+ if (record) return `http://localhost:${record.ports.rest}`;
5453
+ return "http://localhost:8080";
5454
+ }
5455
+ function summariseHealth(url, body) {
5456
+ const projects = body.projects ?? [];
5457
+ const nodes = projects.reduce((n, p) => n + (p.nodeCount ?? 0), 0);
5458
+ const edges = projects.reduce((n, p) => n + (p.edgeCount ?? 0), 0);
5459
+ const proj = body.project ?? projects[0]?.name;
5460
+ const graph = projects.length > 0 ? ` \u2014 ${nodes} nodes / ${edges} edges` : "";
5461
+ return `up at ${url}${proj ? ` (project "${proj}"${graph})` : ""}`;
5462
+ }
5463
+ async function checkDaemon(deps, record) {
5464
+ const url = resolveHealthUrl(deps.env, record);
5465
+ const token = resolveAuthToken(deps.env);
5466
+ const headers = token ? { authorization: `Bearer ${token}` } : {};
5467
+ try {
5468
+ const res = await deps.fetchImpl(`${url}/health`, {
5469
+ headers,
5470
+ signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS)
5471
+ });
5472
+ if (res.status === 401 || res.status === 403) {
5473
+ return {
5474
+ name: "daemon",
5475
+ ok: false,
5476
+ detail: `up at ${url}, but rejected the request (${res.status})`,
5477
+ fix: "set NEAT_AUTH_TOKEN to the daemon's token"
5478
+ };
5647
5479
  }
5648
- return mergeJsoncMcp(raw, client.mcpContainerKey, serverEntry);
5480
+ if (!res.ok) {
5481
+ return {
5482
+ name: "daemon",
5483
+ ok: false,
5484
+ detail: `reachable at ${url} but /health returned ${res.status}`,
5485
+ fix: "check the daemon logs"
5486
+ };
5487
+ }
5488
+ const body = await res.json().catch(() => ({}));
5489
+ return { name: "daemon", ok: true, detail: summariseHealth(url, body) };
5490
+ } catch {
5491
+ return {
5492
+ name: "daemon",
5493
+ ok: false,
5494
+ detail: `down \u2014 nothing answering at ${url}`,
5495
+ fix: "start it: `neat .` (or `neat watch`)"
5496
+ };
5649
5497
  }
5650
- let existing = {};
5651
- if (raw.trim().length > 0) {
5652
- try {
5653
- existing = JSON.parse(raw);
5654
- } catch (err) {
5655
- console.error(
5656
- `neat ${client.id}: ${mcpPath} is not valid JSON \u2014 ${err.message}. Fix it (or move it aside) and re-run; nothing was written.`
5657
- );
5658
- return null;
5498
+ }
5499
+ async function runDoctorChecks(deps = {}) {
5500
+ const d = resolveDeps2(deps);
5501
+ const record = await d.readRecord(d.cwd).catch(() => null);
5502
+ return [checkNode(d.nodeVersion), await checkProject(d.cwd, record), await checkDaemon(d, record)];
5503
+ }
5504
+ var NAME_COL = "project".length;
5505
+ function renderHuman(checks, out) {
5506
+ out("neat doctor \u2014 checking this project's setup");
5507
+ out("");
5508
+ for (const c of checks) {
5509
+ const mark = c.ok ? "\u2713" : "\u2717";
5510
+ out(` ${mark} ${c.name.padEnd(NAME_COL)} ${c.detail}`);
5511
+ if (!c.ok && c.fix) out(` ${" ".repeat(NAME_COL + 3)}fix: ${c.fix}`);
5512
+ }
5513
+ out("");
5514
+ const failed = checks.filter((c) => !c.ok).length;
5515
+ out(failed === 0 ? "all good." : `${failed} check${failed === 1 ? "" : "s"} failed.`);
5516
+ }
5517
+ async function runDoctorCommand(argv, deps = {}) {
5518
+ const out = deps.out ?? ((line) => console.log(line));
5519
+ let json = false;
5520
+ for (const arg of argv) {
5521
+ if (arg === "--json") json = true;
5522
+ else if (arg === "-h" || arg === "--help") {
5523
+ out("usage: neat doctor [--json]");
5524
+ out(" Probe this directory's NEAT setup: Node version, project, daemon.");
5525
+ out(" Exit 0 when every check passes, 1 when any fails.");
5526
+ return 0;
5527
+ } else {
5528
+ out(`neat doctor: unknown argument "${arg}"`);
5529
+ return 2;
5659
5530
  }
5660
5531
  }
5661
- const { merged, changed } = mergeJsonMcp(existing, client.mcpContainerKey, serverEntry);
5662
- return { text: JSON.stringify(merged, null, 2) + "\n", changed };
5532
+ const checks = await runDoctorChecks(deps);
5533
+ if (json) out(JSON.stringify({ ok: checks.every((c) => c.ok), checks }, null, 2));
5534
+ else renderHuman(checks, out);
5535
+ return checks.every((c) => c.ok) ? 0 : 1;
5663
5536
  }
5664
- async function runEditorInstall(client, opts) {
5665
- const mcpPath = client.mcpConfigPath();
5666
- const serverEntry = client.serverEntry ?? NEAT_MCP_SERVER;
5667
- const hasRules = typeof client.rulesFileName === "string";
5668
- const rulesPath = hasRules ? path14.join(opts.projectDir, client.rulesFileName) : "";
5669
- const mcp = await planMcp(client, mcpPath);
5670
- if (mcp === null) return { exitCode: 1 };
5671
- let existingRules = "";
5672
- let newRules = "";
5673
- let rulesChanged = false;
5674
- let block = "";
5675
- if (hasRules) {
5537
+
5538
+ // src/hooks-cli.ts
5539
+ import path12 from "path";
5540
+ import os from "os";
5541
+ import { promises as fs11 } from "fs";
5542
+ import { fileURLToPath as fileURLToPath3 } from "url";
5543
+ var HOOK_FILENAME = "neat-search-nudge.mjs";
5544
+ var GUIDE_FILENAME = "GRAPH_FIRST.md";
5545
+ var GUIDE_INSTALL_NAME = "neat-graph-first.md";
5546
+ var HOOK_MATCHER = "Grep|Glob|Bash";
5547
+ function moduleDir() {
5548
+ return typeof __dirname !== "undefined" ? __dirname : path12.dirname(fileURLToPath3(import.meta.url));
5549
+ }
5550
+ async function readSkillAsset(rel) {
5551
+ const here = moduleDir();
5552
+ const candidates = [
5553
+ path12.resolve(here, "../../claude-skill", rel),
5554
+ path12.resolve(here, "../../../claude-skill", rel),
5555
+ path12.resolve(here, "../claude-skill", rel)
5556
+ ];
5557
+ for (const candidate of candidates) {
5676
5558
  try {
5677
- existingRules = await fs13.readFile(rulesPath, "utf8");
5678
- } catch (err) {
5679
- if (err.code !== "ENOENT") {
5680
- console.error(`neat ${client.id}: failed to read ${rulesPath} \u2014 ${err.message}`);
5681
- return { exitCode: 1 };
5682
- }
5559
+ return await fs11.readFile(candidate, "utf8");
5560
+ } catch {
5683
5561
  }
5684
- const guide = await readSkillAsset(GUIDE_FILENAME);
5685
- block = buildGuidanceBlock(guide);
5686
- newRules = mergeRulesFile(existingRules, block);
5687
- rulesChanged = newRules !== existingRules;
5688
5562
  }
5689
- if (!opts.apply) {
5690
- console.log(`neat ${client.id} \u2014 wire NEAT into ${client.label} (plan; nothing written)`);
5691
- console.log("");
5692
- console.log(`MCP server \u2192 ${mcpPath}`);
5693
- console.log(
5694
- mcp.changed ? ` would add ${client.mcpContainerKey}.neat:` : ` ${client.mcpContainerKey}.neat already present and current \u2014 no change:`
5695
- );
5696
- console.log(indent(JSON.stringify({ neat: serverEntry }, null, 2)));
5697
- if (hasRules) {
5698
- console.log("");
5699
- console.log(`Graph-first guidance \u2192 ${rulesPath}`);
5700
- console.log(
5701
- rulesChanged ? existingRules.includes(GRAPH_FIRST_MARKER_OPEN) ? " would refresh the neat:graph-first block:" : " would add the neat:graph-first block:" : " neat:graph-first block already present and current \u2014 no change."
5702
- );
5703
- if (rulesChanged) console.log(indent(block.trimEnd()));
5563
+ throw new Error(
5564
+ `neat hooks: could not find @neat.is/claude-skill/${rel} \u2014 is the package installed?`
5565
+ );
5566
+ }
5567
+ function neatHome() {
5568
+ const override = process.env.NEAT_HOME;
5569
+ if (override && override.length > 0) return path12.resolve(override);
5570
+ return path12.join(os.homedir(), ".neat");
5571
+ }
5572
+ function claudeSettingsPath() {
5573
+ const override = process.env.NEAT_CLAUDE_SETTINGS;
5574
+ if (override && override.length > 0) return path12.resolve(override);
5575
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? os.homedir();
5576
+ return path12.join(home, ".claude", "settings.json");
5577
+ }
5578
+ function installedHookPath() {
5579
+ return path12.join(neatHome(), "hooks", HOOK_FILENAME);
5580
+ }
5581
+ function gateFlagPath() {
5582
+ return path12.join(neatHome(), "hooks", "gate-enabled");
5583
+ }
5584
+ function isNeatSearchEntry(entry2) {
5585
+ return (entry2.hooks ?? []).some(
5586
+ (h) => typeof h.command === "string" && h.command.includes(HOOK_FILENAME)
5587
+ );
5588
+ }
5589
+ function neatHookEntry(command) {
5590
+ return { matcher: HOOK_MATCHER, hooks: [{ type: "command", command }] };
5591
+ }
5592
+ function hookCommand(scriptPath) {
5593
+ return `node "${scriptPath}"`;
5594
+ }
5595
+ async function runHooks(opts) {
5596
+ if (opts.printHook) {
5597
+ process.stdout.write(await readSkillAsset(`hooks/${HOOK_FILENAME}`));
5598
+ return { exitCode: 0 };
5599
+ }
5600
+ if (opts.printGuide) {
5601
+ process.stdout.write(await readSkillAsset(GUIDE_FILENAME));
5602
+ return { exitCode: 0 };
5603
+ }
5604
+ if (opts.printSettings) {
5605
+ const block = {
5606
+ hooks: { PreToolUse: [neatHookEntry(hookCommand(installedHookPath()))] }
5607
+ };
5608
+ process.stdout.write(JSON.stringify(block, null, 2) + "\n");
5609
+ return { exitCode: 0 };
5610
+ }
5611
+ if (opts.apply) {
5612
+ const hookScript = await readSkillAsset(`hooks/${HOOK_FILENAME}`);
5613
+ const guide = await readSkillAsset(GUIDE_FILENAME);
5614
+ const scriptPath = installedHookPath();
5615
+ await fs11.mkdir(path12.dirname(scriptPath), { recursive: true });
5616
+ await fs11.writeFile(scriptPath, hookScript, { mode: 493 });
5617
+ const guidePath = path12.join(neatHome(), GUIDE_INSTALL_NAME);
5618
+ await fs11.writeFile(guidePath, guide, "utf8");
5619
+ const settingsFile = claudeSettingsPath();
5620
+ let settings = {};
5621
+ try {
5622
+ settings = JSON.parse(await fs11.readFile(settingsFile, "utf8"));
5623
+ } catch (err) {
5624
+ if (err.code !== "ENOENT") {
5625
+ console.error(
5626
+ `neat hooks: failed to read ${settingsFile} \u2014 ${err.message}`
5627
+ );
5628
+ return { exitCode: 1 };
5629
+ }
5630
+ }
5631
+ const hooks = settings.hooks ?? {};
5632
+ const preToolUse = Array.isArray(hooks.PreToolUse) ? [...hooks.PreToolUse] : [];
5633
+ const command = hookCommand(scriptPath);
5634
+ const existingIdx = preToolUse.findIndex(isNeatSearchEntry);
5635
+ if (existingIdx >= 0) {
5636
+ preToolUse[existingIdx] = neatHookEntry(command);
5637
+ } else {
5638
+ preToolUse.push(neatHookEntry(command));
5639
+ }
5640
+ const merged = {
5641
+ ...settings,
5642
+ hooks: { ...hooks, PreToolUse: preToolUse }
5643
+ };
5644
+ await fs11.mkdir(path12.dirname(settingsFile), { recursive: true });
5645
+ await fs11.writeFile(settingsFile, JSON.stringify(merged, null, 2) + "\n", "utf8");
5646
+ const flag = gateFlagPath();
5647
+ if (opts.gate) {
5648
+ await fs11.mkdir(path12.dirname(flag), { recursive: true });
5649
+ await fs11.writeFile(flag, "1\n", "utf8");
5650
+ } else {
5651
+ await fs11.rm(flag, { force: true });
5704
5652
  }
5653
+ const mode = opts.gate ? "GATE (deny search until you ask the graph)" : "nudge (search still runs)";
5654
+ console.log(`neat hooks: installed the search hook in ${opts.gate ? "gate" : "nudge"} mode`);
5655
+ console.log(` script: ${scriptPath}`);
5656
+ console.log(` settings: ${settingsFile} (PreToolUse \u2192 ${HOOK_MATCHER})`);
5657
+ console.log(` guidance: ${guidePath}`);
5658
+ console.log(` mode: ${mode}`);
5705
5659
  console.log("");
5706
- console.log(
5707
- hasRules ? `Re-run with --apply to write both files. Existing servers and rules are kept.` : `Re-run with --apply to write the config. Existing servers are kept.`
5708
- );
5660
+ if (opts.gate) {
5661
+ console.log("restart Claude Code to load the hook. A Grep/Glob or Bash grep is now DENIED");
5662
+ console.log('until you run `neat ask "<question>"` (or the ask MCP tool) once this session;');
5663
+ console.log("after that, search is allowed as a fallback. Set NEAT_SEARCH_GATE=0 to fall");
5664
+ console.log("back to nudge-only without re-running.");
5665
+ } else {
5666
+ console.log("restart Claude Code to load the hook. On a Grep/Glob or a Bash grep,");
5667
+ console.log("your agent will now be nudged to query NEAT first (the search still runs).");
5668
+ console.log("Re-run with --gate to hard-force the graph-first orientation.");
5669
+ }
5670
+ console.log("");
5671
+ console.log("The hook is Claude-Code-specific. For agents on other harnesses, paste");
5672
+ console.log(`the guidance above into your project instructions (CLAUDE.md / AGENTS.md).`);
5709
5673
  return { exitCode: 0 };
5710
5674
  }
5711
- await fs13.mkdir(path14.dirname(mcpPath), { recursive: true });
5712
- await fs13.writeFile(mcpPath, mcp.text, "utf8");
5713
- if (hasRules) {
5714
- await fs13.mkdir(path14.dirname(rulesPath), { recursive: true });
5715
- await fs13.writeFile(rulesPath, newRules, "utf8");
5716
- }
5717
- console.log(`neat ${client.id}: wired NEAT into ${client.label}`);
5718
- console.log(` MCP server: ${mcpPath} (${client.mcpContainerKey}.neat \u2192 npx -y @neat.is/mcp)`);
5719
- if (hasRules) console.log(` guidance: ${rulesPath} (neat:graph-first block)`);
5720
- console.log("");
5721
- console.log(`restart ${client.label} to pick up the MCP server. Point it at a non-default`);
5722
- console.log(`daemon by setting NEAT_CORE_URL in the neat server's env in that config.`);
5675
+ usage();
5723
5676
  return { exitCode: 0 };
5724
5677
  }
5725
- function indent(text) {
5726
- return text.split("\n").map((line) => line.length > 0 ? ` ${line}` : line).join("\n");
5727
- }
5728
- function usage4(client) {
5729
- const hasRules = typeof client.rulesFileName === "string";
5730
- console.log(
5731
- hasRules ? `neat ${client.id} \u2014 install NEAT's MCP server + graph-first guidance into ${client.label}` : `neat ${client.id} \u2014 install NEAT's MCP server into ${client.label}`
5732
- );
5733
- console.log("");
5734
- console.log(
5735
- hasRules ? " --apply write the MCP config and the rules file (default: plan only)" : " --apply write the MCP config (default: plan only)"
5736
- );
5678
+ function usage() {
5679
+ console.log("neat hooks \u2014 wire NEAT into your agent so it queries the graph before grepping");
5737
5680
  console.log("");
5738
- console.log("Writes NEAT's stdio MCP server (npx -y @neat.is/mcp) into");
5739
- console.log(` ${client.mcpConfigPath()}`);
5740
- if (hasRules) {
5741
- console.log(`and the graph-first guidance block into ./${client.rulesFileName}, both`);
5742
- console.log("additively \u2014 existing servers and rules are preserved, a re-run is a no-op.");
5743
- } else {
5744
- console.log("additively \u2014 existing servers are preserved, a re-run is a no-op.");
5745
- }
5681
+ console.log(" --apply install the Claude Code search hook and write the");
5682
+ console.log(" graph-first guidance to ~/.neat/, merging into");
5683
+ console.log(" ~/.claude/settings.json without touching your other hooks");
5684
+ console.log(" --gate with --apply, enable hard-gate mode: DENY Grep/Glob/grep-Bash");
5685
+ console.log(" until `neat ask` has run this session (default is nudge-only).");
5686
+ console.log(" Toggle off at run time with NEAT_SEARCH_GATE=0.");
5687
+ console.log(" --print-hook print the hook script to stdout");
5688
+ console.log(" --print-guide print the agent-agnostic graph-first guidance to stdout");
5689
+ console.log(" --print-settings print the settings.json PreToolUse block --apply would add");
5746
5690
  console.log("");
5747
- console.log(`See ${client.docsUrl} for ${client.label}'s MCP config format.`);
5691
+ console.log("By default the hook is a gentle, non-blocking nudge \u2014 searches still run.");
5692
+ console.log("--gate turns it into a hard forcing mechanism. It is Claude-Code-specific;");
5693
+ console.log("other harnesses get the same steer from the graph-first guidance.");
5748
5694
  }
5749
- async function runEditorCommand(clientId, args, projectDir = process.cwd()) {
5750
- const client = CLIENTS[clientId];
5751
- let apply6 = false;
5695
+ async function runHooksCommand(args) {
5696
+ const opts = {
5697
+ apply: false,
5698
+ printHook: false,
5699
+ printGuide: false,
5700
+ printSettings: false,
5701
+ gate: false
5702
+ };
5752
5703
  for (const arg of args) {
5753
5704
  switch (arg) {
5754
5705
  case "--apply":
5755
- apply6 = true;
5706
+ opts.apply = true;
5707
+ break;
5708
+ case "--gate":
5709
+ opts.gate = true;
5710
+ break;
5711
+ case "--print-hook":
5712
+ opts.printHook = true;
5713
+ break;
5714
+ case "--print-guide":
5715
+ opts.printGuide = true;
5716
+ break;
5717
+ case "--print-settings":
5718
+ opts.printSettings = true;
5756
5719
  break;
5757
5720
  case "-h":
5758
5721
  case "--help":
5759
- usage4(client);
5722
+ usage();
5760
5723
  return 0;
5761
5724
  default:
5762
- console.error(`neat ${client.id}: unknown flag "${arg}"`);
5763
- usage4(client);
5725
+ console.error(`neat hooks: unknown flag "${arg}"`);
5726
+ usage();
5764
5727
  return 2;
5765
5728
  }
5766
5729
  }
5767
5730
  try {
5768
- const { exitCode } = await runEditorInstall(client, { apply: apply6, projectDir });
5731
+ const { exitCode } = await runHooks(opts);
5769
5732
  return exitCode;
5770
5733
  } catch (err) {
5771
5734
  console.error(err.message);
@@ -5773,572 +5736,753 @@ async function runEditorCommand(clientId, args, projectDir = process.cwd()) {
5773
5736
  }
5774
5737
  }
5775
5738
 
5776
- // src/monitor.ts
5777
- import { EdgeType, parseEdgeId, Provenance as Provenance3 } from "@neat.is/types";
5739
+ // src/claude-cli.ts
5740
+ import path13 from "path";
5741
+ import { promises as fs12 } from "fs";
5742
+ var NEAT_SECTION_HEADING = "## neat";
5743
+ var NEAT_DIRECTIVE_BODY = `This project has NEAT wired in: a live, fused semantic graph of the system \u2014
5744
+ code and runtime behaviour (OpenTelemetry) in one model, every fact tagged with
5745
+ its provenance (EXTRACTED / OBSERVED / INFERRED / STALE) and a confidence.
5778
5746
 
5779
- // src/cli-client.ts
5780
- import { Provenance as Provenance2 } from "@neat.is/types";
5781
- var HttpError = class extends Error {
5782
- constructor(status, message, responseBody = "") {
5783
- super(message);
5784
- this.status = status;
5785
- this.responseBody = responseBody;
5786
- this.name = "HttpError";
5747
+ For ANY question about this system's behaviour, dependencies, failures, root
5748
+ cause, or blast radius, call \`neat ask\` FIRST \u2014 before Read, Grep, Glob, or Bash:
5749
+
5750
+ neat ask "why is checkout failing?"
5751
+ neat ask "what breaks if I change the orders table?"
5752
+ neat ask "what does the payments service depend on at runtime?"
5753
+
5754
+ \`ask\` resolves your question to graph nodes and routes it to the right traversal
5755
+ \u2014 root cause, dependencies, observed runtime calls, incidents, divergences,
5756
+ blast radius \u2014 and returns one compact, provenance-tagged answer. The same door
5757
+ is the \`ask\` MCP tool (\`mcp__neat__ask\`) when NEAT's MCP server is wired in.
5758
+
5759
+ The graph is live and fused: it is faster and more accurate than scanning files,
5760
+ and it can tell you what the system actually does at runtime, not only what the
5761
+ source declares. Fall back to Read/Grep only when the graph does not have what
5762
+ you need \u2014 comments, string literals, config minutiae. Ask the graph first,
5763
+ then scan.
5764
+
5765
+ If \`neat ask\` errors, the daemon may not be running (\`neat list\`) \u2014 start it
5766
+ with \`neat <path>\`, then re-ask.`;
5767
+ function neatSection() {
5768
+ return `${NEAT_SECTION_HEADING}
5769
+
5770
+ ${NEAT_DIRECTIVE_BODY}
5771
+ `;
5772
+ }
5773
+ function claudeMdPath() {
5774
+ const override = process.env.NEAT_CLAUDE_MD;
5775
+ if (override && override.length > 0) return path13.resolve(override);
5776
+ return path13.join(process.cwd(), "CLAUDE.md");
5777
+ }
5778
+ function splitAroundSection(raw) {
5779
+ const lines = raw.split("\n");
5780
+ const startIdx = lines.findIndex((l) => l.replace(/\s+$/, "") === NEAT_SECTION_HEADING);
5781
+ if (startIdx === -1) {
5782
+ return { before: raw.replace(/\n*$/, ""), after: "", found: false };
5787
5783
  }
5788
- status;
5789
- responseBody;
5790
- };
5791
- var TransportError = class extends Error {
5792
- constructor(message) {
5793
- super(message);
5794
- this.name = "TransportError";
5784
+ let endIdx = lines.length;
5785
+ for (let i = startIdx + 1; i < lines.length; i++) {
5786
+ if (/^#{1,2}\s+/.test(lines[i] ?? "")) {
5787
+ endIdx = i;
5788
+ break;
5789
+ }
5795
5790
  }
5796
- };
5797
- function resolveAuthToken(env = process.env) {
5798
- const t = env.NEAT_AUTH_TOKEN;
5799
- return t && t.length > 0 ? t : void 0;
5791
+ const before = lines.slice(0, startIdx).join("\n").replace(/\n*$/, "");
5792
+ const after = lines.slice(endIdx).join("\n").replace(/^\n*/, "");
5793
+ return { before, after, found: true };
5800
5794
  }
5801
- function createHttpClient(baseUrl, bearerToken) {
5802
- const root = baseUrl.replace(/\/$/, "");
5803
- const authHeader = bearerToken && bearerToken.length > 0 ? { authorization: `Bearer ${bearerToken}` } : {};
5804
- return {
5805
- async get(path17) {
5806
- let res;
5807
- try {
5808
- res = await fetch(`${root}${path17}`, {
5809
- headers: { ...authHeader }
5810
- });
5811
- } catch (err) {
5812
- throw new TransportError(
5813
- `cannot reach neat-core at ${root}: ${err.message}`
5814
- );
5815
- }
5816
- if (!res.ok) {
5817
- const body = await res.text().catch(() => "");
5818
- throw new HttpError(
5819
- res.status,
5820
- `${res.status} ${res.statusText} on GET ${path17}: ${body}`,
5821
- body
5822
- );
5823
- }
5824
- return await res.json();
5825
- },
5826
- async post(path17, body) {
5827
- let res;
5828
- try {
5829
- res = await fetch(`${root}${path17}`, {
5830
- method: "POST",
5831
- headers: { "content-type": "application/json", ...authHeader },
5832
- body: JSON.stringify(body)
5833
- });
5834
- } catch (err) {
5835
- throw new TransportError(
5836
- `cannot reach neat-core at ${root}: ${err.message}`
5837
- );
5838
- }
5839
- if (!res.ok) {
5840
- const text = await res.text().catch(() => "");
5841
- throw new HttpError(
5842
- res.status,
5843
- `${res.status} ${res.statusText} on POST ${path17}: ${text}`,
5844
- text
5845
- );
5846
- }
5847
- return await res.json();
5848
- }
5849
- };
5850
- }
5851
- function projectPath(project, suffix) {
5852
- if (!project) return suffix;
5853
- return `/projects/${encodeURIComponent(project)}${suffix}`;
5795
+ function compose(before, after) {
5796
+ const parts = [];
5797
+ if (before.length > 0) parts.push(before);
5798
+ parts.push(neatSection().replace(/\n+$/, ""));
5799
+ if (after.length > 0) parts.push(after);
5800
+ return parts.join("\n\n").replace(/\n*$/, "") + "\n";
5854
5801
  }
5855
- async function runRootCause(client, input) {
5856
- const qs = input.errorId ? `?errorId=${encodeURIComponent(input.errorId)}` : "";
5857
- const path17 = projectPath(
5858
- input.project,
5859
- `/graph/root-cause/${encodeURIComponent(input.errorNode)}${qs}`
5860
- );
5802
+ async function readIfExists(file) {
5861
5803
  try {
5862
- const result = await client.get(path17);
5863
- const arrowPath = result.traversalPath.join(" \u2190 ");
5864
- const provenances = result.edgeProvenances.length ? result.edgeProvenances.join(", ") : "(direct, no edges traversed)";
5865
- const summary = `Root cause for ${input.errorNode} is ${result.rootCauseNode}. ` + result.rootCauseReason + (result.fixRecommendation ? ` Recommended fix: ${result.fixRecommendation}.` : "");
5866
- const blockLines = [
5867
- `Traversal path: ${arrowPath}`,
5868
- `Edge provenances: ${provenances}`
5869
- ];
5870
- if (result.fixRecommendation) blockLines.push(`Recommended fix: ${result.fixRecommendation}`);
5871
- return {
5872
- summary,
5873
- block: blockLines.join("\n"),
5874
- confidence: result.confidence,
5875
- provenance: result.edgeProvenances.length ? result.edgeProvenances : void 0
5876
- };
5804
+ return await fs12.readFile(file, "utf8");
5877
5805
  } catch (err) {
5878
- if (err instanceof HttpError && err.status === 404) {
5879
- return {
5880
- summary: `No root cause found for ${input.errorNode}. The node may be healthy, or it may not exist in the graph.`
5881
- };
5882
- }
5806
+ if (err.code === "ENOENT") return null;
5883
5807
  throw err;
5884
5808
  }
5885
5809
  }
5886
- async function runBlastRadius(client, input) {
5887
- const qs = input.depth !== void 0 ? `?depth=${input.depth}` : "";
5888
- const path17 = projectPath(
5889
- input.project,
5890
- `/graph/blast-radius/${encodeURIComponent(input.nodeId)}${qs}`
5891
- );
5892
- try {
5893
- const result = await client.get(path17);
5894
- if (result.totalAffected === 0) {
5895
- return {
5896
- summary: `${result.origin} has no dependents. Nothing else would break if it failed.`
5897
- };
5898
- }
5899
- const sorted = [...result.affectedNodes].sort(
5900
- (a, b) => a.distance - b.distance || a.nodeId.localeCompare(b.nodeId)
5901
- );
5902
- const blockLines = sorted.map(formatBlastEntry);
5903
- const minConfidence = sorted.reduce(
5904
- (m, n) => Math.min(m, n.confidence),
5905
- Number.POSITIVE_INFINITY
5906
- );
5907
- const provenances = [...new Set(sorted.map((n) => n.edgeProvenance))];
5908
- return {
5909
- summary: `Blast radius for ${result.origin}: ${result.totalAffected} dependent node${result.totalAffected === 1 ? "" : "s"} would break if it changed.`,
5910
- block: blockLines.join("\n"),
5911
- confidence: Number.isFinite(minConfidence) ? minConfidence : void 0,
5912
- provenance: provenances.length ? provenances : void 0
5913
- };
5914
- } catch (err) {
5915
- if (err instanceof HttpError && err.status === 404) {
5916
- return { summary: `Node ${input.nodeId} not found in the graph.` };
5917
- }
5918
- throw err;
5810
+ async function runInstall() {
5811
+ const file = claudeMdPath();
5812
+ const raw = await readIfExists(file) ?? "";
5813
+ const { before, after, found } = splitAroundSection(raw);
5814
+ const next = compose(before, after);
5815
+ await fs12.mkdir(path13.dirname(file), { recursive: true });
5816
+ await fs12.writeFile(file, next, "utf8");
5817
+ const verb = raw.length === 0 ? "created" : found ? "refreshed" : "added";
5818
+ console.log(`neat claude: ${verb} the \`${NEAT_SECTION_HEADING}\` section in ${file}`);
5819
+ console.log("Your agent will now reach for `neat ask` before Read/Grep/Bash. Restart the");
5820
+ console.log("session (or reload CLAUDE.md) to pick it up.");
5821
+ return { exitCode: 0 };
5822
+ }
5823
+ async function runUninstall() {
5824
+ const file = claudeMdPath();
5825
+ const raw = await readIfExists(file);
5826
+ if (raw === null) {
5827
+ console.log(`neat claude: no CLAUDE.md at ${file} \u2014 nothing to remove.`);
5828
+ return { exitCode: 0 };
5829
+ }
5830
+ const { before, after, found } = splitAroundSection(raw);
5831
+ if (!found) {
5832
+ console.log(`neat claude: no \`${NEAT_SECTION_HEADING}\` section in ${file} \u2014 nothing to remove.`);
5833
+ return { exitCode: 0 };
5919
5834
  }
5835
+ const remaining = [before, after].filter((s) => s.length > 0).join("\n\n");
5836
+ const next = remaining.length > 0 ? remaining.replace(/\n*$/, "") + "\n" : "";
5837
+ await fs12.writeFile(file, next, "utf8");
5838
+ console.log(`neat claude: removed the \`${NEAT_SECTION_HEADING}\` section from ${file}.`);
5839
+ return { exitCode: 0 };
5920
5840
  }
5921
- function formatBlastEntry(n) {
5922
- const tag = n.edgeProvenance === Provenance2.STALE ? " [STALE \u2014 last seen too long ago]" : "";
5923
- return ` \u2022 ${n.nodeId} (distance ${n.distance}, ${n.edgeProvenance})${tag}`;
5841
+ function usage2() {
5842
+ console.log("neat claude \u2014 make the query-first directive always-on in Claude Code");
5843
+ console.log("");
5844
+ console.log(" install write (or refresh) a `## neat` section in ./CLAUDE.md so your");
5845
+ console.log(" agent reaches for `neat ask` before Read/Grep/Bash");
5846
+ console.log(" uninstall remove the `## neat` section from ./CLAUDE.md");
5847
+ console.log(" print print the directive block to stdout (for a manual paste)");
5848
+ console.log("");
5849
+ console.log("Idempotent: re-running install replaces its own section, never duplicates it.");
5850
+ console.log("Target file overridable via NEAT_CLAUDE_MD.");
5924
5851
  }
5925
- async function runDependencies(client, input) {
5926
- const depth = input.depth ?? 3;
5927
- const path17 = projectPath(
5928
- input.project,
5929
- `/graph/dependencies/${encodeURIComponent(input.nodeId)}?depth=${depth}`
5930
- );
5852
+ async function runClaudeCommand(args) {
5853
+ const sub = args[0];
5854
+ if (sub === "-h" || sub === "--help" || sub === void 0) {
5855
+ usage2();
5856
+ return sub === void 0 ? 2 : 0;
5857
+ }
5931
5858
  try {
5932
- const result = await client.get(path17);
5933
- if (result.total === 0) {
5934
- return {
5935
- summary: depth === 1 ? `${input.nodeId} has no direct dependencies in the graph.` : `${input.nodeId} has no dependencies (BFS to depth ${depth}).`
5936
- };
5937
- }
5938
- const byDistance = /* @__PURE__ */ new Map();
5939
- for (const dep of result.dependencies) {
5940
- const ring = byDistance.get(dep.distance) ?? [];
5941
- ring.push(dep);
5942
- byDistance.set(dep.distance, ring);
5943
- }
5944
- const blockLines = [];
5945
- for (const distance of [...byDistance.keys()].sort((a, b) => a - b)) {
5946
- const label = distance === 1 ? "Direct (distance 1)" : `Distance ${distance}`;
5947
- blockLines.push(`${label}:`);
5948
- for (const dep of byDistance.get(distance)) {
5949
- blockLines.push(` \u2022 ${dep.nodeId} \u2014 ${dep.edgeType} (${dep.provenance})`);
5950
- }
5859
+ switch (sub) {
5860
+ case "install":
5861
+ return (await runInstall()).exitCode;
5862
+ case "uninstall":
5863
+ return (await runUninstall()).exitCode;
5864
+ case "print":
5865
+ process.stdout.write(neatSection());
5866
+ return 0;
5867
+ default:
5868
+ console.error(`neat claude: unknown subcommand "${sub}"`);
5869
+ usage2();
5870
+ return 2;
5951
5871
  }
5952
- const provenances = [...new Set(result.dependencies.map((d) => d.provenance))];
5953
- const directCount = byDistance.get(1)?.length ?? 0;
5954
- const summary = depth === 1 ? `${input.nodeId} has ${directCount} direct dependenc${directCount === 1 ? "y" : "ies"}.` : `${input.nodeId} has ${result.total} dependenc${result.total === 1 ? "y" : "ies"} reachable to depth ${depth} (${directCount} direct).`;
5955
- return { summary, block: blockLines.join("\n"), provenance: provenances };
5956
5872
  } catch (err) {
5957
- if (err instanceof HttpError && err.status === 404) {
5958
- return { summary: `Node ${input.nodeId} not found in the graph.` };
5959
- }
5960
- throw err;
5873
+ console.error(`neat claude: ${err.message}`);
5874
+ return 1;
5961
5875
  }
5962
5876
  }
5963
- function observedDepLine(nodeId, e) {
5964
- const via = e.source !== nodeId ? ` (via ${e.source})` : "";
5965
- return ` \u2022 ${e.target} \u2014 ${e.type}${via}${edgeMeta(e)}`;
5877
+
5878
+ // src/codex-cli.ts
5879
+ import path14 from "path";
5880
+ import os2 from "os";
5881
+ import { promises as fs13 } from "fs";
5882
+ import { isDeepStrictEqual } from "util";
5883
+ import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
5884
+ var CODEX_MCP_SERVER = {
5885
+ command: "npx",
5886
+ args: ["-y", "@neat.is/mcp"],
5887
+ env: { NEAT_CORE_URL: "http://localhost:8080" }
5888
+ };
5889
+ var CODEX_NEAT_BLOCK = [
5890
+ "[mcp_servers.neat]",
5891
+ 'command = "npx"',
5892
+ 'args = ["-y", "@neat.is/mcp"]',
5893
+ 'env = { NEAT_CORE_URL = "http://localhost:8080" }'
5894
+ ].join("\n");
5895
+ var NEAT_GRAPH_FIRST_START = "<!-- neat:graph-first -->";
5896
+ var NEAT_GRAPH_FIRST_END = "<!-- /neat:graph-first -->";
5897
+ function codexConfigPath() {
5898
+ const override = process.env.NEAT_CODEX_CONFIG;
5899
+ if (override && override.length > 0) return path14.resolve(override);
5900
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? os2.homedir();
5901
+ return path14.join(home, ".codex", "config.toml");
5966
5902
  }
5967
- async function runObservedDependencies(client, input) {
5968
- try {
5969
- const result = await client.get(
5970
- projectPath(
5971
- input.project,
5972
- `/graph/observed-dependencies/${encodeURIComponent(input.nodeId)}`
5973
- )
5974
- );
5975
- if (result.dependencies.length === 0) {
5976
- if (result.observed) {
5977
- return {
5978
- summary: `${input.nodeId} makes no outbound runtime calls, but OTel has observed it receiving traffic on ${result.inboundObservedCount} inbound call path${result.inboundObservedCount === 1 ? "" : "s"} \u2014 it's a pure receiver.`,
5979
- provenance: Provenance2.OBSERVED
5980
- };
5981
- }
5982
- const note = result.hasExtractedOutbound ? " Static (EXTRACTED) dependencies exist but no runtime traffic has been seen \u2014 is OTel running?" : "";
5983
- return { summary: `No OBSERVED dependencies for ${input.nodeId}.${note}` };
5984
- }
5985
- const blockLines = result.dependencies.map((e) => observedDepLine(input.nodeId, e));
5986
- return {
5987
- summary: `${input.nodeId} has ${result.dependencies.length} runtime dependenc${result.dependencies.length === 1 ? "y" : "ies"} confirmed by OTel.`,
5988
- block: blockLines.join("\n"),
5989
- provenance: Provenance2.OBSERVED
5990
- };
5991
- } catch (err) {
5992
- if (err instanceof HttpError && err.status === 404) {
5993
- return { summary: `Node ${input.nodeId} not found in the graph.` };
5994
- }
5995
- throw err;
5996
- }
5903
+ function agentsFilePath() {
5904
+ const override = process.env.NEAT_CODEX_AGENTS;
5905
+ if (override && override.length > 0) return path14.resolve(override);
5906
+ return path14.join(process.cwd(), "AGENTS.md");
5997
5907
  }
5998
- function edgeMeta(e) {
5999
- const bits = [];
6000
- if (e.signal) {
6001
- bits.push(`spans=${e.signal.spanCount}`);
6002
- if (e.signal.errorCount > 0) bits.push(`errors=${e.signal.errorCount}`);
6003
- if (e.signal.lastObservedAgeMs !== void 0) {
6004
- bits.push(`age=${formatDuration(e.signal.lastObservedAgeMs)}`);
6005
- }
6006
- } else if (e.callCount !== void 0) {
6007
- bits.push(`callCount=${e.callCount}`);
6008
- }
6009
- if (e.lastObserved) bits.push(`lastObserved=${e.lastObserved}`);
6010
- if (e.confidence !== void 0) bits.push(`confidence=${e.confidence}`);
6011
- return bits.length ? ` [${bits.join(", ")}]` : "";
5908
+ function isTableHeader(line) {
5909
+ return /^\s*\[\[?[^\]]+\]\]?\s*$/.test(line);
6012
5910
  }
6013
- function formatDuration(ms) {
6014
- if (ms < 1e3) return `${Math.round(ms)}ms`;
6015
- const s = Math.round(ms / 1e3);
6016
- if (s < 60) return `${s}s`;
6017
- const m = Math.round(s / 60);
6018
- if (m < 60) return `${m}m`;
6019
- const h = Math.round(m / 60);
6020
- if (h < 48) return `${h}h`;
6021
- return `${Math.round(h / 24)}d`;
5911
+ function tableName(line) {
5912
+ return line.trim().replace(/^\[\[?/, "").replace(/\]\]?$/, "").trim();
6022
5913
  }
6023
- async function runIncidents(client, input) {
6024
- const path17 = input.nodeId ? projectPath(input.project, `/incidents/${encodeURIComponent(input.nodeId)}`) : projectPath(input.project, "/incidents");
6025
- try {
6026
- const body = await client.get(path17);
6027
- const events = body.events;
6028
- if (events.length === 0) {
6029
- return {
6030
- summary: input.nodeId ? `No incidents recorded against ${input.nodeId}.` : "No incidents recorded."
6031
- };
6032
- }
6033
- const ordered = [...events].reverse().slice(0, input.limit ?? 20);
6034
- const blockLines = [];
6035
- for (const ev of ordered) {
6036
- blockLines.push(` ${ev.timestamp} \u2014 ${ev.service}: ${ev.errorMessage}`);
6037
- blockLines.push(` trace=${ev.traceId} span=${ev.spanId}`);
6038
- }
6039
- const target = input.nodeId ?? "the project";
6040
- return {
6041
- summary: `${target} has ${body.total} recorded incident${body.total === 1 ? "" : "s"}; showing the ${ordered.length} most recent.`,
6042
- block: blockLines.join("\n"),
6043
- provenance: Provenance2.OBSERVED
5914
+ function isNeatHeader(line) {
5915
+ return isTableHeader(line) && tableName(line) === "mcp_servers.neat";
5916
+ }
5917
+ function isNeatChildHeader(line) {
5918
+ if (!isTableHeader(line)) return false;
5919
+ const name = tableName(line);
5920
+ return name === "mcp_servers.neat" || name.startsWith("mcp_servers.neat.");
5921
+ }
5922
+ function upsertCodexConfig(raw) {
5923
+ const trimmed = raw.trim();
5924
+ const parsed = trimmed.length > 0 ? parseToml(raw) : {};
5925
+ const existingServers = parsed.mcp_servers ?? {};
5926
+ const spliced = spliceNeatBlock(raw);
5927
+ let text;
5928
+ if (verifyPreserved(raw, spliced, parsed)) {
5929
+ text = spliced;
5930
+ } else {
5931
+ const merged = {
5932
+ ...parsed,
5933
+ mcp_servers: { ...existingServers, neat: CODEX_MCP_SERVER }
6044
5934
  };
6045
- } catch (err) {
6046
- if (err instanceof HttpError && err.status === 404) {
6047
- return { summary: `Node ${input.nodeId ?? ""} not found in the graph.` };
6048
- }
6049
- throw err;
5935
+ text = stringifyToml(merged);
5936
+ if (!text.endsWith("\n")) text += "\n";
6050
5937
  }
5938
+ return { text, changed: text !== raw };
6051
5939
  }
6052
- async function runSearch(client, input) {
6053
- const result = await client.get(
6054
- projectPath(input.project, `/search?q=${encodeURIComponent(input.query)}`)
6055
- );
6056
- if (result.matches.length === 0) {
6057
- return { summary: `No matches for "${input.query}".` };
5940
+ function spliceNeatBlock(raw) {
5941
+ const block = CODEX_NEAT_BLOCK;
5942
+ const lines = raw.length > 0 ? raw.split("\n") : [];
5943
+ const start = lines.findIndex(isNeatHeader);
5944
+ if (start === -1) {
5945
+ const base = raw.replace(/\n+$/, "");
5946
+ return base.length > 0 ? `${base}
5947
+
5948
+ ${block}
5949
+ ` : `${block}
5950
+ `;
6058
5951
  }
6059
- const provider = result.provider ?? "substring";
6060
- const blockLines = [];
6061
- let topScore;
6062
- for (const n of result.matches) {
6063
- const score = provider !== "substring" && typeof n.score === "number" ? n.score : void 0;
6064
- const scoreBit = score !== void 0 ? ` [score=${score.toFixed(2)}]` : "";
6065
- if (score !== void 0 && (topScore === void 0 || score > topScore)) topScore = score;
6066
- blockLines.push(
6067
- ` \u2022 ${n.id} (${n.type}) \u2014 ${n.name ?? n.id}${scoreBit}`
6068
- );
5952
+ let end = start + 1;
5953
+ for (; ; ) {
5954
+ while (end < lines.length && !isTableHeader(lines[end])) end++;
5955
+ if (end < lines.length && isNeatChildHeader(lines[end])) {
5956
+ end++;
5957
+ continue;
5958
+ }
5959
+ break;
6069
5960
  }
6070
- return {
6071
- summary: `Found ${result.matches.length} match${result.matches.length === 1 ? "" : "es"} for "${input.query}" via ${provider} provider.`,
6072
- block: blockLines.join("\n"),
6073
- confidence: topScore
6074
- };
5961
+ const before = lines.slice(0, start).join("\n").replace(/\n*$/, "");
5962
+ const after = lines.slice(end).join("\n").replace(/^\n*/, "");
5963
+ let text = "";
5964
+ if (before.length > 0) text += `${before}
5965
+
5966
+ `;
5967
+ text += `${block}
5968
+ `;
5969
+ if (after.length > 0) text += `
5970
+ ${after}`;
5971
+ return `${text.replace(/\n*$/, "")}
5972
+ `;
6075
5973
  }
6076
- async function runDiff(client, input) {
6077
- const result = await client.get(
6078
- projectPath(
6079
- input.project,
6080
- `/graph/diff?against=${encodeURIComponent(input.againstSnapshot)}`
6081
- )
6082
- );
6083
- const total = result.added.nodes.length + result.added.edges.length + result.removed.nodes.length + result.removed.edges.length + result.changed.nodes.length + result.changed.edges.length;
6084
- const baseLabel = result.base.exportedAt ?? "unknown";
6085
- if (total === 0) {
6086
- return {
6087
- summary: `No differences between the current graph and ${input.againstSnapshot} (base exportedAt=${baseLabel}).`
6088
- };
5974
+ function verifyPreserved(raw, spliced, original) {
5975
+ let next;
5976
+ try {
5977
+ next = parseToml(spliced);
5978
+ } catch {
5979
+ return false;
6089
5980
  }
6090
- const blockLines = [
6091
- ` base exportedAt: ${baseLabel}`,
6092
- ` current exportedAt: ${result.current.exportedAt}`,
6093
- ""
6094
- ];
6095
- if (result.added.nodes.length || result.added.edges.length) {
6096
- blockLines.push("Added:");
6097
- for (const n of result.added.nodes) blockLines.push(` + node ${n.id} (${n.type})`);
6098
- for (const e of result.added.edges)
6099
- blockLines.push(` + edge ${e.id} \u2014 ${e.source} -> ${e.target} (${e.type}, ${e.provenance})`);
6100
- blockLines.push("");
5981
+ const origServers = original.mcp_servers ?? {};
5982
+ const nextServers = next.mcp_servers ?? {};
5983
+ if (!isDeepStrictEqual(nextServers.neat, CODEX_MCP_SERVER)) return false;
5984
+ for (const name of Object.keys(origServers)) {
5985
+ if (name === "neat") continue;
5986
+ if (!isDeepStrictEqual(nextServers[name], origServers[name])) return false;
6101
5987
  }
6102
- if (result.removed.nodes.length || result.removed.edges.length) {
6103
- blockLines.push("Removed:");
6104
- for (const n of result.removed.nodes) blockLines.push(` - node ${n.id} (${n.type})`);
6105
- for (const e of result.removed.edges)
6106
- blockLines.push(` - edge ${e.id} \u2014 ${e.source} -> ${e.target} (${e.type}, ${e.provenance})`);
6107
- blockLines.push("");
5988
+ for (const name of Object.keys(nextServers)) {
5989
+ if (name !== "neat" && !(name in origServers)) return false;
6108
5990
  }
6109
- if (result.changed.nodes.length || result.changed.edges.length) {
6110
- blockLines.push("Changed:");
6111
- for (const c of result.changed.nodes) {
6112
- blockLines.push(` ~ node ${c.id} \u2014 ${summariseAttrDiff(c.before, c.after)}`);
6113
- }
6114
- for (const c of result.changed.edges) {
6115
- const provBit = c.before.provenance !== c.after.provenance ? `provenance ${c.before.provenance} \u2192 ${c.after.provenance}` : summariseAttrDiff(c.before, c.after);
6116
- blockLines.push(` ~ edge ${c.id} \u2014 ${provBit}`);
6117
- }
5991
+ for (const key of Object.keys(original)) {
5992
+ if (key === "mcp_servers") continue;
5993
+ if (!isDeepStrictEqual(next[key], original[key])) return false;
6118
5994
  }
6119
- return {
6120
- summary: `Diff against ${input.againstSnapshot}: ${total} change${total === 1 ? "" : "s"} between the snapshot and the live graph.`,
6121
- block: blockLines.join("\n").trimEnd()
6122
- };
6123
- }
6124
- function summariseAttrDiff(before, after) {
6125
- const keys = /* @__PURE__ */ new Set([...Object.keys(before), ...Object.keys(after)]);
6126
- const changed = [];
6127
- for (const k of keys) {
6128
- if (JSON.stringify(before[k]) !== JSON.stringify(after[k])) changed.push(k);
5995
+ for (const key of Object.keys(next)) {
5996
+ if (key !== "mcp_servers" && !(key in original)) return false;
6129
5997
  }
6130
- return changed.length === 0 ? "attributes differ" : `fields changed: ${changed.sort().join(", ")}`;
5998
+ return true;
6131
5999
  }
6132
- async function runStaleEdges(client, input) {
6133
- const params = new URLSearchParams();
6134
- if (input.limit !== void 0) params.set("limit", String(input.limit));
6135
- if (input.edgeType) params.set("edgeType", input.edgeType);
6136
- const qs = params.size > 0 ? `?${params.toString()}` : "";
6137
- const body = await client.get(
6138
- projectPath(input.project, `/stale-events${qs}`)
6139
- );
6140
- const events = body.events;
6141
- if (events.length === 0) {
6142
- return {
6143
- summary: input.edgeType ? `No stale ${input.edgeType} edges recorded.` : "No stale-edge transitions recorded yet."
6144
- };
6000
+ function agentsBlock(guide) {
6001
+ return `${NEAT_GRAPH_FIRST_START}
6002
+ ${guide.replace(/\s+$/, "")}
6003
+ ${NEAT_GRAPH_FIRST_END}
6004
+ `;
6005
+ }
6006
+ function upsertAgents(raw, guide) {
6007
+ const block = agentsBlock(guide);
6008
+ if (raw.length === 0) return { text: block, changed: true };
6009
+ const startIdx = raw.indexOf(NEAT_GRAPH_FIRST_START);
6010
+ const endIdx = raw.indexOf(NEAT_GRAPH_FIRST_END);
6011
+ if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
6012
+ const before = raw.slice(0, startIdx);
6013
+ const after = raw.slice(endIdx + NEAT_GRAPH_FIRST_END.length);
6014
+ const text2 = `${before}${block.replace(/\n+$/, "")}${after}`;
6015
+ return { text: text2, changed: text2 !== raw };
6145
6016
  }
6146
- const blockLines = events.map(
6147
- (e) => ` ${e.transitionedAt} \u2014 ${e.source} -[${e.edgeType}]-> ${e.target} (last seen ${e.lastObserved}, threshold ${formatDuration(e.thresholdMs)})`
6148
- );
6149
- return {
6150
- summary: `${events.length} stale-edge transition${events.length === 1 ? "" : "s"} recorded${input.edgeType ? ` for ${input.edgeType}` : ""}.`,
6151
- block: blockLines.join("\n"),
6152
- provenance: Provenance2.STALE
6153
- };
6017
+ const base = raw.replace(/\n+$/, "");
6018
+ const text = base.length > 0 ? `${base}
6019
+
6020
+ ${block}` : block;
6021
+ return { text, changed: text !== raw };
6154
6022
  }
6155
- async function runPolicies(client, input) {
6156
- let violations;
6157
- let allowed = true;
6158
- let hypothetical;
6159
- if (input.hypotheticalAction) {
6160
- if (typeof client.post !== "function") {
6161
- throw new Error("HttpClient does not support POST \u2014 required for policies dry-run");
6162
- }
6163
- const body = await client.post(
6164
- projectPath(input.project, "/policies/check"),
6165
- { hypotheticalAction: input.hypotheticalAction }
6166
- );
6167
- violations = body.violations;
6168
- allowed = body.allowed;
6169
- hypothetical = body.hypotheticalAction;
6170
- } else {
6171
- const params = new URLSearchParams();
6172
- if (input.policyId) params.set("policyId", input.policyId);
6173
- const qs = params.size > 0 ? `?${params.toString()}` : "";
6174
- const body = await client.get(
6175
- projectPath(input.project, `/policies/violations${qs}`)
6023
+ async function readGuide() {
6024
+ return readSkillAsset(GUIDE_FILENAME);
6025
+ }
6026
+ async function runCodex(opts) {
6027
+ if (opts.printConfig) {
6028
+ process.stdout.write(`${CODEX_NEAT_BLOCK}
6029
+ `);
6030
+ return { exitCode: 0 };
6031
+ }
6032
+ if (opts.printGuide) {
6033
+ process.stdout.write(agentsBlock(await readGuide()));
6034
+ return { exitCode: 0 };
6035
+ }
6036
+ const configPath = codexConfigPath();
6037
+ const agentsPath = agentsFilePath();
6038
+ let configRaw = "";
6039
+ try {
6040
+ configRaw = await fs13.readFile(configPath, "utf8");
6041
+ } catch (err) {
6042
+ if (err.code !== "ENOENT") {
6043
+ console.error(`neat codex: failed to read ${configPath} \u2014 ${err.message}`);
6044
+ return { exitCode: 1 };
6045
+ }
6046
+ }
6047
+ let agentsRaw = "";
6048
+ try {
6049
+ agentsRaw = await fs13.readFile(agentsPath, "utf8");
6050
+ } catch (err) {
6051
+ if (err.code !== "ENOENT") {
6052
+ console.error(`neat codex: failed to read ${agentsPath} \u2014 ${err.message}`);
6053
+ return { exitCode: 1 };
6054
+ }
6055
+ }
6056
+ let config;
6057
+ try {
6058
+ config = upsertCodexConfig(configRaw);
6059
+ } catch (err) {
6060
+ console.error(
6061
+ `neat codex: ${configPath} is not valid TOML \u2014 ${err.message}`
6176
6062
  );
6177
- violations = body.violations;
6178
- allowed = violations.every((v) => v.onViolation !== "block");
6063
+ console.error("neat codex: fix the file and re-run; nothing was written.");
6064
+ return { exitCode: 1 };
6179
6065
  }
6180
- if (input.nodeId) {
6181
- violations = violations.filter(
6182
- (v) => v.subject.nodeId === input.nodeId || v.subject.path?.includes(input.nodeId)
6066
+ const guide = await readGuide();
6067
+ const agents = upsertAgents(agentsRaw, guide);
6068
+ if (!opts.apply) {
6069
+ console.log("neat codex \u2014 plan (nothing written; re-run with --apply to write)");
6070
+ console.log("");
6071
+ console.log(` Codex MCP config: ${configPath}`);
6072
+ console.log(
6073
+ config.changed ? ` ${configRaw ? "update" : "create"} the [mcp_servers.neat] table:` : " already up to date \u2014 [mcp_servers.neat] matches"
6074
+ );
6075
+ if (config.changed) {
6076
+ for (const line of CODEX_NEAT_BLOCK.split("\n")) console.log(` ${line}`);
6077
+ }
6078
+ console.log("");
6079
+ console.log(` Project instructions: ${agentsPath}`);
6080
+ console.log(
6081
+ agents.changed ? ` ${agentsRaw ? "update" : "create"} the graph-first block (between ${NEAT_GRAPH_FIRST_START} markers)` : " already up to date \u2014 graph-first block matches"
6183
6082
  );
6083
+ console.log("");
6084
+ console.log("The MCP server reads NEAT_CORE_URL for the daemon URL \u2014 edit that value in");
6085
+ console.log("the generated table to point Codex at a non-default daemon.");
6086
+ return { exitCode: 0 };
6184
6087
  }
6185
- if (violations.length === 0) {
6186
- return {
6187
- summary: hypothetical ? `No violations would result from the hypothetical action (${hypothetical.kind}).` : "No policy violations recorded."
6188
- };
6088
+ if (config.changed) {
6089
+ await fs13.mkdir(path14.dirname(configPath), { recursive: true });
6090
+ await fs13.writeFile(configPath, config.text, "utf8");
6091
+ console.log(`neat codex: wrote [mcp_servers.neat] to ${configPath}`);
6092
+ } else {
6093
+ console.log(`neat codex: ${configPath} already has NEAT's MCP server`);
6189
6094
  }
6190
- const blockCount = violations.filter((v) => v.onViolation === "block").length;
6191
- const summaryParts = [];
6192
- if (hypothetical) {
6193
- summaryParts.push(
6194
- `Hypothetical ${hypothetical.kind} would surface ${violations.length} violation${violations.length === 1 ? "" : "s"}`
6195
- );
6095
+ if (agents.changed) {
6096
+ await fs13.mkdir(path14.dirname(agentsPath), { recursive: true });
6097
+ await fs13.writeFile(agentsPath, agents.text, "utf8");
6098
+ console.log(`neat codex: wrote the graph-first block to ${agentsPath}`);
6196
6099
  } else {
6197
- summaryParts.push(
6198
- `${violations.length} policy violation${violations.length === 1 ? "" : "s"} currently recorded`
6199
- );
6100
+ console.log(`neat codex: ${agentsPath} already has the graph-first block`);
6200
6101
  }
6201
- if (blockCount > 0) summaryParts.push(`${blockCount} of which block`);
6202
- if (!allowed && hypothetical) summaryParts.push("action denied");
6203
- const summary = summaryParts.join("; ") + ".";
6204
- const blockLines = violations.map((v) => {
6205
- const subject = v.subject.nodeId ?? v.subject.edgeId ?? v.subject.path?.[0] ?? "(global)";
6206
- return ` \u2022 [${v.severity}/${v.onViolation}] ${v.policyName}: ${v.message} \u2014 ${subject}`;
6207
- });
6208
- const severities = [...new Set(violations.map((v) => v.severity))];
6209
- return {
6210
- summary,
6211
- block: blockLines.join("\n"),
6212
- confidence: hypothetical ? 0.7 : 1,
6213
- provenance: severities.join(" ")
6214
- };
6102
+ console.log("");
6103
+ console.log("restart Codex to pick up the new MCP server. NEAT_CORE_URL in the table");
6104
+ console.log("points the server at the local daemon \u2014 edit it for a non-default one.");
6105
+ return { exitCode: 0 };
6215
6106
  }
6216
- function formatDivergenceLine(d) {
6217
- switch (d.type) {
6218
- case "missing-observed":
6219
- case "missing-extracted":
6220
- if (d.column) {
6221
- return ` \u2022 [${d.type}] ${d.table ?? d.source} column ${d.column} \u2014 confidence ${d.confidence.toFixed(2)}`;
6222
- }
6223
- return ` \u2022 [${d.type}] ${d.source} \u2192 ${d.target} (${d.edgeType}) \u2014 confidence ${d.confidence.toFixed(2)}`;
6224
- case "version-mismatch":
6225
- return ` \u2022 [${d.type}] ${d.source} \u2192 ${d.target} \u2014 declared ${d.extractedVersion}, observed engine ${d.observedVersion} (${d.compatibility})`;
6226
- case "host-mismatch":
6227
- return ` \u2022 [${d.type}] ${d.source} \u2192 ${d.target} \u2014 declared host ${d.extractedHost}, observed host ${d.observedHost}`;
6228
- case "compat-violation":
6229
- return ` \u2022 [${d.type}] ${d.source} \u2192 ${d.target} \u2014 ${d.rule.kind}${d.rule.package ? ` (${d.rule.package})` : ""}`;
6230
- case "observed-symbol-mismatch": {
6231
- const at = d.location ? ` at ${d.location}` : "";
6232
- const member = d.symbol ? ` ${d.symbol}` : "";
6233
- return ` \u2022 [${d.type}] ${d.source}${member}${at} (${d.mismatchKind}) \u2014 confidence ${d.confidence.toFixed(2)}`;
6107
+ function usage3() {
6108
+ console.log("neat codex \u2014 install NEAT into the OpenAI Codex CLI (MCP server + AGENTS.md)");
6109
+ console.log("");
6110
+ console.log(" (no flag) plan: print what would change, write nothing");
6111
+ console.log(" --apply add [mcp_servers.neat] to ~/.codex/config.toml and write");
6112
+ console.log(" the graph-first block into ./AGENTS.md, merging into both");
6113
+ console.log(" without touching your other servers or instructions");
6114
+ console.log(" --print-config print the [mcp_servers.neat] TOML block to stdout");
6115
+ console.log(" --print-guide print the AGENTS.md graph-first block to stdout");
6116
+ console.log("");
6117
+ console.log("Existing config is preserved and a re-run is a no-op. A malformed");
6118
+ console.log("config.toml is a clear error with no partial write.");
6119
+ }
6120
+ async function runCodexCommand(args) {
6121
+ const opts = { apply: false, printConfig: false, printGuide: false };
6122
+ for (const arg of args) {
6123
+ switch (arg) {
6124
+ case "--apply":
6125
+ opts.apply = true;
6126
+ break;
6127
+ case "--print-config":
6128
+ opts.printConfig = true;
6129
+ break;
6130
+ case "--print-guide":
6131
+ opts.printGuide = true;
6132
+ break;
6133
+ case "-h":
6134
+ case "--help":
6135
+ usage3();
6136
+ return 0;
6137
+ default:
6138
+ console.error(`neat codex: unknown flag "${arg}"`);
6139
+ usage3();
6140
+ return 2;
6141
+ }
6142
+ }
6143
+ try {
6144
+ const { exitCode } = await runCodex(opts);
6145
+ return exitCode;
6146
+ } catch (err) {
6147
+ console.error(err.message);
6148
+ return 1;
6149
+ }
6150
+ }
6151
+
6152
+ // src/editors-cli.ts
6153
+ import path15 from "path";
6154
+ import os3 from "os";
6155
+ import { promises as fs14 } from "fs";
6156
+ import { isDeepStrictEqual as isDeepStrictEqual2 } from "util";
6157
+ import * as jsonc from "jsonc-parser";
6158
+ var NEAT_MCP_SERVER = {
6159
+ command: "npx",
6160
+ args: ["-y", "@neat.is/mcp"]
6161
+ };
6162
+ var NEAT_OPENCODE_SERVER = {
6163
+ type: "local",
6164
+ command: ["npx", "-y", "@neat.is/mcp"],
6165
+ enabled: true
6166
+ };
6167
+ var NEAT_CRUSH_SERVER = {
6168
+ type: "stdio",
6169
+ command: "npx",
6170
+ args: ["-y", "@neat.is/mcp"]
6171
+ };
6172
+ var GRAPH_FIRST_MARKER_OPEN = "<!-- neat:graph-first -->";
6173
+ var GRAPH_FIRST_MARKER_CLOSE = "<!-- /neat:graph-first -->";
6174
+ function homeDir() {
6175
+ return process.env.HOME ?? process.env.USERPROFILE ?? os3.homedir();
6176
+ }
6177
+ function xdgConfigDir() {
6178
+ const xdg = process.env.XDG_CONFIG_HOME;
6179
+ return xdg && xdg.length > 0 ? path15.resolve(xdg) : path15.join(homeDir(), ".config");
6180
+ }
6181
+ function envOverride(name) {
6182
+ const v = process.env[name];
6183
+ return v && v.length > 0 ? path15.resolve(v) : void 0;
6184
+ }
6185
+ var CURSOR_CLIENT = {
6186
+ id: "cursor",
6187
+ label: "Cursor",
6188
+ docsUrl: "https://docs.cursor.com/context/mcp",
6189
+ mcpConfigPath: () => envOverride("NEAT_CURSOR_CONFIG") ?? path15.join(homeDir(), ".cursor", "mcp.json"),
6190
+ mcpContainerKey: "mcpServers",
6191
+ format: "json",
6192
+ // Cursor still reads a single `.cursorrules` at the project root (the modern
6193
+ // `.cursor/rules/*.mdc` split is one-rule-per-file with frontmatter — a worse
6194
+ // fit for a marker-fenced block). GRAPH_FIRST.md names this file directly.
6195
+ rulesFileName: ".cursorrules"
6196
+ };
6197
+ var DEVIN_CLIENT = {
6198
+ id: "devin",
6199
+ label: "Devin Desktop (Cascade)",
6200
+ docsUrl: "https://docs.devin.ai/desktop/cascade/mcp",
6201
+ mcpConfigPath: () => envOverride("NEAT_DEVIN_CONFIG") ?? path15.join(homeDir(), ".codeium", "windsurf", "mcp_config.json"),
6202
+ mcpContainerKey: "mcpServers",
6203
+ format: "json",
6204
+ rulesFileName: ".windsurfrules"
6205
+ };
6206
+ var GEMINI_CLIENT = {
6207
+ id: "gemini",
6208
+ label: "Gemini CLI",
6209
+ docsUrl: "https://github.com/google-gemini/gemini-cli/blob/main/docs/tools/mcp-server.md",
6210
+ mcpConfigPath: () => envOverride("NEAT_GEMINI_CONFIG") ?? path15.join(homeDir(), ".gemini", "settings.json"),
6211
+ mcpContainerKey: "mcpServers",
6212
+ format: "json",
6213
+ rulesFileName: "GEMINI.md"
6214
+ };
6215
+ var QWEN_CLIENT = {
6216
+ id: "qwen",
6217
+ label: "Qwen Code",
6218
+ docsUrl: "https://qwenlm.github.io/qwen-code-docs/en/users/features/mcp/",
6219
+ mcpConfigPath: () => envOverride("NEAT_QWEN_CONFIG") ?? path15.join(homeDir(), ".qwen", "settings.json"),
6220
+ mcpContainerKey: "mcpServers",
6221
+ format: "json",
6222
+ rulesFileName: "QWEN.md"
6223
+ };
6224
+ var AMAZONQ_CLIENT = {
6225
+ id: "amazonq",
6226
+ label: "Amazon Q Developer CLI",
6227
+ docsUrl: "https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/command-line-mcp-configuration.html",
6228
+ mcpConfigPath: () => envOverride("NEAT_AMAZONQ_CONFIG") ?? path15.join(homeDir(), ".aws", "amazonq", "mcp.json"),
6229
+ mcpContainerKey: "mcpServers",
6230
+ format: "json"
6231
+ };
6232
+ var ROOCODE_CLIENT = {
6233
+ id: "roocode",
6234
+ label: "Roo Code",
6235
+ docsUrl: "https://roocodeinc.github.io/Roo-Code/features/mcp/using-mcp-in-roo",
6236
+ mcpConfigPath: () => envOverride("NEAT_ROOCODE_CONFIG") ?? path15.join(process.cwd(), ".roo", "mcp.json"),
6237
+ mcpContainerKey: "mcpServers",
6238
+ format: "json"
6239
+ };
6240
+ var ZED_CLIENT = {
6241
+ id: "zed",
6242
+ label: "Zed",
6243
+ docsUrl: "https://zed.dev/docs/ai/mcp",
6244
+ mcpConfigPath: () => {
6245
+ const override = envOverride("NEAT_ZED_CONFIG");
6246
+ if (override) return override;
6247
+ if (process.platform === "win32") {
6248
+ const appData = process.env.APPDATA;
6249
+ if (appData && appData.length > 0) return path15.join(appData, "Zed", "settings.json");
6234
6250
  }
6251
+ return path15.join(homeDir(), ".config", "zed", "settings.json");
6252
+ },
6253
+ mcpContainerKey: "context_servers",
6254
+ format: "jsonc",
6255
+ rulesFileName: ".rules"
6256
+ };
6257
+ var OPENCODE_CLIENT = {
6258
+ id: "opencode",
6259
+ label: "OpenCode",
6260
+ docsUrl: "https://opencode.ai/docs/mcp-servers/",
6261
+ mcpConfigPath: () => envOverride("NEAT_OPENCODE_CONFIG") ?? path15.join(xdgConfigDir(), "opencode", "opencode.json"),
6262
+ mcpContainerKey: "mcp",
6263
+ format: "json",
6264
+ serverEntry: NEAT_OPENCODE_SERVER,
6265
+ rulesFileName: "AGENTS.md"
6266
+ };
6267
+ var CRUSH_CLIENT = {
6268
+ id: "crush",
6269
+ label: "Crush",
6270
+ docsUrl: "https://charmbracelet-crush.mintlify.app/configuration/mcp",
6271
+ mcpConfigPath: () => envOverride("NEAT_CRUSH_CONFIG") ?? path15.join(xdgConfigDir(), "crush", "crush.json"),
6272
+ mcpContainerKey: "mcp",
6273
+ format: "json",
6274
+ serverEntry: NEAT_CRUSH_SERVER,
6275
+ rulesFileName: "AGENTS.md"
6276
+ };
6277
+ var CLIENTS = {
6278
+ cursor: CURSOR_CLIENT,
6279
+ devin: DEVIN_CLIENT,
6280
+ gemini: GEMINI_CLIENT,
6281
+ qwen: QWEN_CLIENT,
6282
+ amazonq: AMAZONQ_CLIENT,
6283
+ roocode: ROOCODE_CLIENT,
6284
+ zed: ZED_CLIENT,
6285
+ opencode: OPENCODE_CLIENT,
6286
+ crush: CRUSH_CLIENT
6287
+ };
6288
+ function mergeJsonMcp(existing, containerKey, serverEntry) {
6289
+ const servers = existing[containerKey] ?? {};
6290
+ const already = isDeepStrictEqual2(servers.neat, serverEntry);
6291
+ const merged = {
6292
+ ...existing,
6293
+ [containerKey]: { ...servers, neat: serverEntry }
6294
+ };
6295
+ return { merged, changed: !already };
6296
+ }
6297
+ function mergeJsoncMcp(raw, containerKey, serverEntry) {
6298
+ const base = raw.trim().length > 0 ? raw : "{}";
6299
+ const parsed = jsonc.parse(base) ?? {};
6300
+ const servers = parsed[containerKey] ?? {};
6301
+ if (isDeepStrictEqual2(servers.neat, serverEntry)) {
6302
+ return { text: raw, changed: false };
6235
6303
  }
6304
+ const edits = jsonc.modify(base, [containerKey, "neat"], serverEntry, {
6305
+ formattingOptions: { tabSize: 2, insertSpaces: true }
6306
+ });
6307
+ let text = jsonc.applyEdits(base, edits);
6308
+ if (!text.endsWith("\n")) text += "\n";
6309
+ return { text, changed: text !== raw };
6236
6310
  }
6237
- async function runDivergences(client, input) {
6238
- const params = new URLSearchParams();
6239
- if (input.type && input.type.length > 0) params.set("type", input.type.join(","));
6240
- if (input.minConfidence !== void 0) {
6241
- params.set("minConfidence", String(input.minConfidence));
6242
- }
6243
- if (input.node) params.set("node", input.node);
6244
- const qs = params.size > 0 ? `?${params.toString()}` : "";
6245
- const result = await client.get(
6246
- projectPath(input.project, `/graph/divergences${qs}`)
6311
+ function escapeRegExp(s) {
6312
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6313
+ }
6314
+ function buildGuidanceBlock(guide) {
6315
+ return `${GRAPH_FIRST_MARKER_OPEN}
6316
+ ${guide.trim()}
6317
+ ${GRAPH_FIRST_MARKER_CLOSE}
6318
+ `;
6319
+ }
6320
+ function mergeRulesFile(existing, block) {
6321
+ const region = new RegExp(
6322
+ `${escapeRegExp(GRAPH_FIRST_MARKER_OPEN)}[\\s\\S]*?${escapeRegExp(GRAPH_FIRST_MARKER_CLOSE)}\\n?`
6247
6323
  );
6248
- if (result.totalAffected === 0) {
6249
- return {
6250
- summary: "No divergences found between the declared (EXTRACTED) and observed (OBSERVED) views of the graph."
6251
- };
6324
+ if (region.test(existing)) return existing.replace(region, block);
6325
+ if (existing.trim().length === 0) return block;
6326
+ return `${existing.replace(/\s+$/, "")}
6327
+
6328
+ ${block}`;
6329
+ }
6330
+ async function planMcp(client, mcpPath) {
6331
+ const serverEntry = client.serverEntry ?? NEAT_MCP_SERVER;
6332
+ let raw = "";
6333
+ try {
6334
+ raw = await fs14.readFile(mcpPath, "utf8");
6335
+ } catch (err) {
6336
+ const e = err;
6337
+ if (e.code === "ENOENT") {
6338
+ raw = "";
6339
+ } else {
6340
+ console.error(`neat ${client.id}: failed to read ${mcpPath} \u2014 ${e.message}`);
6341
+ return null;
6342
+ }
6252
6343
  }
6253
- const headline = result.divergences[0];
6254
- const summary = `Found ${result.totalAffected} divergence${result.totalAffected === 1 ? "" : "s"} between code and production. Highest-confidence: ${headline.type} on ${headline.source} \u2192 ${headline.target}. ${headline.reason}`;
6255
- const blockLines = [];
6256
- for (const d of result.divergences) {
6257
- blockLines.push(formatDivergenceLine(d));
6258
- blockLines.push(` reason: ${d.reason}`);
6259
- blockLines.push(` recommendation: ${d.recommendation}`);
6344
+ if (client.format === "jsonc") {
6345
+ if (raw.trim().length > 0) {
6346
+ const errors = [];
6347
+ jsonc.parse(raw, errors, { allowTrailingComma: true });
6348
+ if (errors.length > 0) {
6349
+ const first = errors[0];
6350
+ console.error(
6351
+ `neat ${client.id}: ${mcpPath} is not valid JSONC \u2014 ${jsonc.printParseErrorCode(first.error)} at offset ${first.offset}. Fix it (or move it aside) and re-run; nothing was written.`
6352
+ );
6353
+ return null;
6354
+ }
6355
+ }
6356
+ return mergeJsoncMcp(raw, client.mcpContainerKey, serverEntry);
6260
6357
  }
6261
- const maxConfidence = result.divergences.reduce(
6262
- (m, d) => Math.max(m, d.confidence),
6263
- 0
6264
- );
6265
- return {
6266
- summary,
6267
- block: blockLines.join("\n"),
6268
- confidence: maxConfidence,
6269
- provenance: "composite (EXTRACTED + OBSERVED)"
6270
- };
6358
+ let existing = {};
6359
+ if (raw.trim().length > 0) {
6360
+ try {
6361
+ existing = JSON.parse(raw);
6362
+ } catch (err) {
6363
+ console.error(
6364
+ `neat ${client.id}: ${mcpPath} is not valid JSON \u2014 ${err.message}. Fix it (or move it aside) and re-run; nothing was written.`
6365
+ );
6366
+ return null;
6367
+ }
6368
+ }
6369
+ const { merged, changed } = mergeJsonMcp(existing, client.mcpContainerKey, serverEntry);
6370
+ return { text: JSON.stringify(merged, null, 2) + "\n", changed };
6271
6371
  }
6272
- async function runAsk(client, input) {
6273
- const result = await client.get(
6274
- projectPath(input.project, `/graph/ask?q=${encodeURIComponent(input.question)}`)
6275
- );
6276
- const blockLines = [];
6277
- if (result.matched.length > 0) {
6278
- blockLines.push(
6279
- `Matched: ${result.matched.map((m) => `${m.nodeId} [${m.via} ${m.score.toFixed(2)}]`).join(", ")}`
6280
- );
6281
- blockLines.push(`Intent: ${result.intent}`);
6282
- } else if (result.scope === "global") {
6283
- blockLines.push(`Graph-wide answer (${result.intent}) \u2014 no entity named.`);
6372
+ async function runEditorInstall(client, opts) {
6373
+ const mcpPath = client.mcpConfigPath();
6374
+ const serverEntry = client.serverEntry ?? NEAT_MCP_SERVER;
6375
+ const hasRules = typeof client.rulesFileName === "string";
6376
+ const rulesPath = hasRules ? path15.join(opts.projectDir, client.rulesFileName) : "";
6377
+ const mcp = await planMcp(client, mcpPath);
6378
+ if (mcp === null) return { exitCode: 1 };
6379
+ let existingRules = "";
6380
+ let newRules = "";
6381
+ let rulesChanged = false;
6382
+ let block = "";
6383
+ if (hasRules) {
6384
+ try {
6385
+ existingRules = await fs14.readFile(rulesPath, "utf8");
6386
+ } catch (err) {
6387
+ if (err.code !== "ENOENT") {
6388
+ console.error(`neat ${client.id}: failed to read ${rulesPath} \u2014 ${err.message}`);
6389
+ return { exitCode: 1 };
6390
+ }
6391
+ }
6392
+ const guide = await readSkillAsset(GUIDE_FILENAME);
6393
+ block = buildGuidanceBlock(guide);
6394
+ newRules = mergeRulesFile(existingRules, block);
6395
+ rulesChanged = newRules !== existingRules;
6284
6396
  }
6285
- for (const section of result.sections) {
6286
- blockLines.push("", section.heading + ":");
6287
- for (const fact of section.facts) {
6288
- const tag = fact.provenance ? ` [${fact.provenance}${fact.confidence !== void 0 ? ` ${fact.confidence.toFixed(2)}` : ""}]` : fact.confidence !== void 0 ? ` [confidence ${fact.confidence.toFixed(2)}]` : "";
6289
- blockLines.push(` \u2022 ${fact.text}${tag}`);
6397
+ if (!opts.apply) {
6398
+ console.log(`neat ${client.id} \u2014 wire NEAT into ${client.label} (plan; nothing written)`);
6399
+ console.log("");
6400
+ console.log(`MCP server \u2192 ${mcpPath}`);
6401
+ console.log(
6402
+ mcp.changed ? ` would add ${client.mcpContainerKey}.neat:` : ` ${client.mcpContainerKey}.neat already present and current \u2014 no change:`
6403
+ );
6404
+ console.log(indent(JSON.stringify({ neat: serverEntry }, null, 2)));
6405
+ if (hasRules) {
6406
+ console.log("");
6407
+ console.log(`Graph-first guidance \u2192 ${rulesPath}`);
6408
+ console.log(
6409
+ rulesChanged ? existingRules.includes(GRAPH_FIRST_MARKER_OPEN) ? " would refresh the neat:graph-first block:" : " would add the neat:graph-first block:" : " neat:graph-first block already present and current \u2014 no change."
6410
+ );
6411
+ if (rulesChanged) console.log(indent(block.trimEnd()));
6290
6412
  }
6413
+ console.log("");
6414
+ console.log(
6415
+ hasRules ? `Re-run with --apply to write both files. Existing servers and rules are kept.` : `Re-run with --apply to write the config. Existing servers are kept.`
6416
+ );
6417
+ return { exitCode: 0 };
6291
6418
  }
6292
- return {
6293
- summary: result.answer,
6294
- block: blockLines.join("\n").trim(),
6295
- ...result.confidence !== void 0 ? { confidence: result.confidence } : {},
6296
- ...result.provenance.length > 0 ? { provenance: result.provenance } : {}
6297
- };
6298
- }
6299
- function formatFooter(confidence, provenance) {
6300
- const c = confidence === void 0 ? "n/a" : confidence.toFixed(2);
6301
- const p = provenance === void 0 ? "n/a" : Array.isArray(provenance) ? [...new Set(provenance)].join(", ") : provenance;
6302
- return `confidence: ${c} \xB7 provenance: ${p}`;
6419
+ await fs14.mkdir(path15.dirname(mcpPath), { recursive: true });
6420
+ await fs14.writeFile(mcpPath, mcp.text, "utf8");
6421
+ if (hasRules) {
6422
+ await fs14.mkdir(path15.dirname(rulesPath), { recursive: true });
6423
+ await fs14.writeFile(rulesPath, newRules, "utf8");
6424
+ }
6425
+ console.log(`neat ${client.id}: wired NEAT into ${client.label}`);
6426
+ console.log(` MCP server: ${mcpPath} (${client.mcpContainerKey}.neat \u2192 npx -y @neat.is/mcp)`);
6427
+ if (hasRules) console.log(` guidance: ${rulesPath} (neat:graph-first block)`);
6428
+ console.log("");
6429
+ console.log(`restart ${client.label} to pick up the MCP server. Point it at a non-default`);
6430
+ console.log(`daemon by setting NEAT_CORE_URL in the neat server's env in that config.`);
6431
+ return { exitCode: 0 };
6303
6432
  }
6304
- function formatHuman(result) {
6305
- const sections = [result.summary.trim()];
6306
- if (result.block && result.block.trim().length > 0) sections.push(result.block.trimEnd());
6307
- sections.push(formatFooter(result.confidence, result.provenance));
6308
- return sections.join("\n\n");
6433
+ function indent(text) {
6434
+ return text.split("\n").map((line) => line.length > 0 ? ` ${line}` : line).join("\n");
6309
6435
  }
6310
- function formatJson(result) {
6311
- return JSON.stringify(
6312
- {
6313
- summary: result.summary,
6314
- block: result.block ?? "",
6315
- confidence: result.confidence ?? null,
6316
- provenance: result.provenance ?? null
6317
- },
6318
- null,
6319
- 2
6436
+ function usage4(client) {
6437
+ const hasRules = typeof client.rulesFileName === "string";
6438
+ console.log(
6439
+ hasRules ? `neat ${client.id} \u2014 install NEAT's MCP server + graph-first guidance into ${client.label}` : `neat ${client.id} \u2014 install NEAT's MCP server into ${client.label}`
6320
6440
  );
6441
+ console.log("");
6442
+ console.log(
6443
+ hasRules ? " --apply write the MCP config and the rules file (default: plan only)" : " --apply write the MCP config (default: plan only)"
6444
+ );
6445
+ console.log("");
6446
+ console.log("Writes NEAT's stdio MCP server (npx -y @neat.is/mcp) into");
6447
+ console.log(` ${client.mcpConfigPath()}`);
6448
+ if (hasRules) {
6449
+ console.log(`and the graph-first guidance block into ./${client.rulesFileName}, both`);
6450
+ console.log("additively \u2014 existing servers and rules are preserved, a re-run is a no-op.");
6451
+ } else {
6452
+ console.log("additively \u2014 existing servers are preserved, a re-run is a no-op.");
6453
+ }
6454
+ console.log("");
6455
+ console.log(`See ${client.docsUrl} for ${client.label}'s MCP config format.`);
6321
6456
  }
6322
- function exitCodeForError(err) {
6323
- if (err instanceof TransportError) return 3;
6324
- if (err instanceof HttpError) return 1;
6325
- return 1;
6326
- }
6327
- function createSnapshotPushClient(baseUrl, token) {
6328
- return createHttpClient(baseUrl, token && token.length > 0 ? token : void 0);
6329
- }
6330
- async function pushSnapshotToRemote(input) {
6331
- const client = createSnapshotPushClient(input.baseUrl, input.token);
6332
- if (typeof client.post !== "function") {
6333
- throw new Error("HttpClient does not support POST \u2014 required for snapshot push");
6457
+ async function runEditorCommand(clientId, args, projectDir = process.cwd()) {
6458
+ const client = CLIENTS[clientId];
6459
+ let apply6 = false;
6460
+ for (const arg of args) {
6461
+ switch (arg) {
6462
+ case "--apply":
6463
+ apply6 = true;
6464
+ break;
6465
+ case "-h":
6466
+ case "--help":
6467
+ usage4(client);
6468
+ return 0;
6469
+ default:
6470
+ console.error(`neat ${client.id}: unknown flag "${arg}"`);
6471
+ usage4(client);
6472
+ return 2;
6473
+ }
6474
+ }
6475
+ try {
6476
+ const { exitCode } = await runEditorInstall(client, { apply: apply6, projectDir });
6477
+ return exitCode;
6478
+ } catch (err) {
6479
+ console.error(err.message);
6480
+ return 1;
6334
6481
  }
6335
- return client.post(
6336
- `/projects/${encodeURIComponent(input.project)}/snapshot`,
6337
- { snapshot: input.snapshot }
6338
- );
6339
6482
  }
6340
6483
 
6341
6484
  // src/monitor.ts
6485
+ import { EdgeType, parseEdgeId, Provenance as Provenance3 } from "@neat.is/types";
6342
6486
  var OBSERVED_DEP_EDGE_TYPES = /* @__PURE__ */ new Set([
6343
6487
  EdgeType.CALLS,
6344
6488
  EdgeType.CONNECTS_TO,
@@ -6713,7 +6857,7 @@ function sleep(ms, signal) {
6713
6857
  }
6714
6858
 
6715
6859
  // src/cli-verbs.ts
6716
- import path15 from "path";
6860
+ import path16 from "path";
6717
6861
  async function resolveProjectEntry(opts) {
6718
6862
  const entries = await listProjects();
6719
6863
  if (opts.project) {
@@ -6723,7 +6867,7 @@ async function resolveProjectEntry(opts) {
6723
6867
  const cwd = opts.cwd ?? process.cwd();
6724
6868
  const resolvedCwd = await normalizeProjectPath(cwd);
6725
6869
  for (const entry2 of entries) {
6726
- if (resolvedCwd === entry2.path || resolvedCwd.startsWith(`${entry2.path}${path15.sep}`)) {
6870
+ if (resolvedCwd === entry2.path || resolvedCwd.startsWith(`${entry2.path}${path16.sep}`)) {
6727
6871
  return entry2;
6728
6872
  }
6729
6873
  }
@@ -7025,6 +7169,9 @@ function usage5() {
7025
7169
  console.log(" test <id> re-check an existing connector's credential");
7026
7170
  console.log(" Credentials default to an env-var reference ($VAR) resolved at");
7027
7171
  console.log(" run time; the config file is written owner-only (0600).");
7172
+ console.log(" doctor Preflight this directory's setup \u2014 Node version, project,");
7173
+ console.log(" and daemon reachability \u2014 and print a fix for anything down.");
7174
+ console.log(" Flags: --json. Exits 0 when all pass, 1 when a check fails.");
7028
7175
  console.log("");
7029
7176
  console.log("query commands (mirror the MCP tools, ADR-050):");
7030
7177
  console.log(" ask <question> Plain-language door: resolves the question to");
@@ -7250,7 +7397,7 @@ async function buildPatchSections(services, project) {
7250
7397
  }
7251
7398
  async function runInit(opts) {
7252
7399
  const written = [];
7253
- const stat = await fs14.stat(opts.scanPath).catch(() => null);
7400
+ const stat = await fs15.stat(opts.scanPath).catch(() => null);
7254
7401
  if (!stat || !stat.isDirectory()) {
7255
7402
  console.error(`neat init: ${opts.scanPath} is not a directory`);
7256
7403
  return { exitCode: 2, writtenFiles: written };
@@ -7259,13 +7406,13 @@ async function runInit(opts) {
7259
7406
  printDiscoveryReport(opts, services);
7260
7407
  const sections = opts.noInstall ? [] : await buildPatchSections(services, opts.project);
7261
7408
  const patch = renderPatch(sections);
7262
- const patchPath = path16.join(opts.scanPath, "neat.patch");
7409
+ const patchPath = path17.join(opts.scanPath, "neat.patch");
7263
7410
  if (opts.dryRun) {
7264
- await fs14.writeFile(patchPath, patch, "utf8");
7411
+ await fs15.writeFile(patchPath, patch, "utf8");
7265
7412
  written.push(patchPath);
7266
7413
  console.log(`dry-run: patch written to ${patchPath}`);
7267
- const gitignorePath = path16.join(opts.scanPath, ".gitignore");
7268
- const gitignoreExists = await fs14.stat(gitignorePath).then(() => true).catch(() => false);
7414
+ const gitignorePath = path17.join(opts.scanPath, ".gitignore");
7415
+ const gitignoreExists = await fs15.stat(gitignorePath).then(() => true).catch(() => false);
7269
7416
  const verb = gitignoreExists ? "append" : "create";
7270
7417
  console.log(`dry-run: would ${verb} ${gitignorePath} (add neat-out/)`);
7271
7418
  console.log("rerun without --dry-run to register and snapshot.");
@@ -7276,9 +7423,9 @@ async function runInit(opts) {
7276
7423
  const graph = getGraph(graphKey);
7277
7424
  const projectPaths = pathsForProject(
7278
7425
  graphKey,
7279
- path16.join(opts.scanPath, "neat-out")
7426
+ path17.join(opts.scanPath, "neat-out")
7280
7427
  );
7281
- const errorsPath = path16.join(path16.dirname(opts.outPath), path16.basename(projectPaths.errorsPath));
7428
+ const errorsPath = path17.join(path17.dirname(opts.outPath), path17.basename(projectPaths.errorsPath));
7282
7429
  const result = await extractFromDirectory(graph, opts.scanPath, { errorsPath });
7283
7430
  await saveGraphToDisk(graph, opts.outPath);
7284
7431
  written.push(opts.outPath);
@@ -7357,7 +7504,7 @@ async function runInit(opts) {
7357
7504
  console.log("Run `npm install` (or your language equivalent) to refresh lockfiles.");
7358
7505
  }
7359
7506
  } else {
7360
- await fs14.writeFile(patchPath, patch, "utf8");
7507
+ await fs15.writeFile(patchPath, patch, "utf8");
7361
7508
  written.push(patchPath);
7362
7509
  }
7363
7510
  }
@@ -7398,9 +7545,9 @@ var CLAUDE_SKILL_CONFIG = {
7398
7545
  };
7399
7546
  function claudeConfigPath() {
7400
7547
  const override = process.env.NEAT_CLAUDE_CONFIG;
7401
- if (override && override.length > 0) return path16.resolve(override);
7548
+ if (override && override.length > 0) return path17.resolve(override);
7402
7549
  const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
7403
- return path16.join(home, ".claude.json");
7550
+ return path17.join(home, ".claude.json");
7404
7551
  }
7405
7552
  async function runSkill(opts) {
7406
7553
  const snippet = JSON.stringify(CLAUDE_SKILL_CONFIG, null, 2) + "\n";
@@ -7412,7 +7559,7 @@ async function runSkill(opts) {
7412
7559
  const target = claudeConfigPath();
7413
7560
  let existing = {};
7414
7561
  try {
7415
- existing = JSON.parse(await fs14.readFile(target, "utf8"));
7562
+ existing = JSON.parse(await fs15.readFile(target, "utf8"));
7416
7563
  } catch (err) {
7417
7564
  if (err.code !== "ENOENT") {
7418
7565
  console.error(`neat skill: failed to read ${target} \u2014 ${err.message}`);
@@ -7424,8 +7571,8 @@ async function runSkill(opts) {
7424
7571
  ...existing,
7425
7572
  mcpServers: { ...mcp, neat: CLAUDE_SKILL_CONFIG.mcpServers.neat }
7426
7573
  };
7427
- await fs14.mkdir(path16.dirname(target), { recursive: true });
7428
- await fs14.writeFile(target, JSON.stringify(merged, null, 2) + "\n", "utf8");
7574
+ await fs15.mkdir(path17.dirname(target), { recursive: true });
7575
+ await fs15.writeFile(target, JSON.stringify(merged, null, 2) + "\n", "utf8");
7429
7576
  console.log(`neat skill: wrote mcpServers.neat to ${target}`);
7430
7577
  console.log("restart Claude Code to pick up the new MCP server.");
7431
7578
  console.log("");
@@ -7464,6 +7611,11 @@ async function main() {
7464
7611
  if (code !== 0) process.exit(code);
7465
7612
  return;
7466
7613
  }
7614
+ if (cmd0 === "doctor") {
7615
+ const code = await runDoctorCommand(argv.slice(1));
7616
+ if (code !== 0) process.exit(code);
7617
+ return;
7618
+ }
7467
7619
  if (cmd0 === "hooks") {
7468
7620
  const code = await runHooksCommand(argv.slice(1));
7469
7621
  if (code !== 0) process.exit(code);
@@ -7516,12 +7668,12 @@ async function main() {
7516
7668
  console.error("neat init: --apply and --dry-run are mutually exclusive");
7517
7669
  process.exit(2);
7518
7670
  }
7519
- const scanPath = path16.resolve(target);
7671
+ const scanPath = path17.resolve(target);
7520
7672
  const projectExplicit = parsed.project !== null;
7521
- const projectName = projectExplicit ? project : path16.basename(scanPath);
7673
+ const projectName = projectExplicit ? project : path17.basename(scanPath);
7522
7674
  const projectKey = projectExplicit ? project : DEFAULT_PROJECT;
7523
- const fallback = pathsForProject(projectKey, path16.join(scanPath, "neat-out")).snapshotPath;
7524
- const outPath = path16.resolve(process.env.NEAT_OUT_PATH ?? fallback);
7675
+ const fallback = pathsForProject(projectKey, path17.join(scanPath, "neat-out")).snapshotPath;
7676
+ const outPath = path17.resolve(process.env.NEAT_OUT_PATH ?? fallback);
7525
7677
  const result = await runInit({
7526
7678
  scanPath,
7527
7679
  outPath,
@@ -7542,21 +7694,21 @@ async function main() {
7542
7694
  usage5();
7543
7695
  process.exit(2);
7544
7696
  }
7545
- const scanPath = path16.resolve(target);
7546
- const stat = await fs14.stat(scanPath).catch(() => null);
7697
+ const scanPath = path17.resolve(target);
7698
+ const stat = await fs15.stat(scanPath).catch(() => null);
7547
7699
  if (!stat || !stat.isDirectory()) {
7548
7700
  console.error(`neat watch: ${scanPath} is not a directory`);
7549
7701
  process.exit(2);
7550
7702
  }
7551
- const projectPaths = pathsForProject(project, path16.join(scanPath, "neat-out"));
7552
- const outPath = path16.resolve(process.env.NEAT_OUT_PATH ?? projectPaths.snapshotPath);
7553
- const errorsPath = path16.resolve(
7554
- process.env.NEAT_ERRORS_PATH ?? path16.join(path16.dirname(outPath), path16.basename(projectPaths.errorsPath))
7703
+ const projectPaths = pathsForProject(project, path17.join(scanPath, "neat-out"));
7704
+ const outPath = path17.resolve(process.env.NEAT_OUT_PATH ?? projectPaths.snapshotPath);
7705
+ const errorsPath = path17.resolve(
7706
+ process.env.NEAT_ERRORS_PATH ?? path17.join(path17.dirname(outPath), path17.basename(projectPaths.errorsPath))
7555
7707
  );
7556
- const staleEventsPath = path16.resolve(
7557
- process.env.NEAT_STALE_EVENTS_PATH ?? path16.join(path16.dirname(outPath), path16.basename(projectPaths.staleEventsPath))
7708
+ const staleEventsPath = path17.resolve(
7709
+ process.env.NEAT_STALE_EVENTS_PATH ?? path17.join(path17.dirname(outPath), path17.basename(projectPaths.staleEventsPath))
7558
7710
  );
7559
- const embeddingsCachePath = process.env.NEAT_EMBEDDINGS_CACHE_PATH ? path16.resolve(process.env.NEAT_EMBEDDINGS_CACHE_PATH) : void 0;
7711
+ const embeddingsCachePath = process.env.NEAT_EMBEDDINGS_CACHE_PATH ? path17.resolve(process.env.NEAT_EMBEDDINGS_CACHE_PATH) : void 0;
7560
7712
  const handle = await startWatch(getGraph(project), {
7561
7713
  scanPath,
7562
7714
  outPath,
@@ -7565,7 +7717,7 @@ async function main() {
7565
7717
  project,
7566
7718
  // Resolve NEAT_HOME so a `neat watch` picks up connectors added to
7567
7719
  // ~/.neat/connectors.json (#871). Same resolution the rest of the CLI uses.
7568
- neatHome: process.env.NEAT_HOME ? path16.resolve(process.env.NEAT_HOME) : path16.join(os4.homedir(), ".neat"),
7720
+ neatHome: process.env.NEAT_HOME ? path17.resolve(process.env.NEAT_HOME) : path17.join(os4.homedir(), ".neat"),
7569
7721
  ...embeddingsCachePath ? { embeddingsCachePath } : {},
7570
7722
  host: process.env.HOST ?? "0.0.0.0",
7571
7723
  port: Number(process.env.PORT ?? 8080),
@@ -7747,11 +7899,11 @@ async function main() {
7747
7899
  process.exit(1);
7748
7900
  }
7749
7901
  async function tryOrchestrator(cmd, parsed) {
7750
- const scanPath = path16.resolve(cmd);
7751
- const stat = await fs14.stat(scanPath).catch(() => null);
7902
+ const scanPath = path17.resolve(cmd);
7903
+ const stat = await fs15.stat(scanPath).catch(() => null);
7752
7904
  if (!stat || !stat.isDirectory()) return null;
7753
7905
  const projectExplicit = parsed.project !== null;
7754
- const projectName = projectExplicit ? parsed.project : path16.basename(scanPath);
7906
+ const projectName = projectExplicit ? parsed.project : path17.basename(scanPath);
7755
7907
  const result = await runOrchestrator({
7756
7908
  scanPath,
7757
7909
  project: projectName,