ctxloom-pro 1.3.1 → 1.5.0

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.
@@ -6,7 +6,8 @@ import {
6
6
  generateEmbedding
7
7
  } from "./chunk-UVR65QBJ.js";
8
8
  import {
9
- diskSink
9
+ diskSink,
10
+ readEvents
10
11
  } from "./chunk-5I6CJITG.js";
11
12
  import {
12
13
  logger
@@ -3460,8 +3461,8 @@ var CoChangeIndex = class _CoChangeIndex {
3460
3461
  if (event.isBulk || event.isMerge) return;
3461
3462
  const paths = event.files.map((f) => f.path);
3462
3463
  if (paths.length === 0) return;
3463
- for (const path36 of paths) {
3464
- this.nodeCounts.set(path36, (this.nodeCounts.get(path36) ?? 0) + 1);
3464
+ for (const path37 of paths) {
3465
+ this.nodeCounts.set(path37, (this.nodeCounts.get(path37) ?? 0) + 1);
3465
3466
  }
3466
3467
  for (let i = 0; i < paths.length; i++) {
3467
3468
  for (let j = i + 1; j < paths.length; j++) {
@@ -3608,8 +3609,8 @@ var ChurnIndex = class _ChurnIndex {
3608
3609
  */
3609
3610
  snapshot() {
3610
3611
  const nodes = {};
3611
- for (const [path36, raw] of this.nodes) {
3612
- nodes[path36] = {
3612
+ for (const [path37, raw] of this.nodes) {
3613
+ nodes[path37] = {
3613
3614
  commits: raw.commits,
3614
3615
  churnLines: raw.churnLines,
3615
3616
  bugCommits: raw.bugCommits,
@@ -3624,8 +3625,8 @@ var ChurnIndex = class _ChurnIndex {
3624
3625
  */
3625
3626
  static load(s) {
3626
3627
  const idx = new _ChurnIndex();
3627
- for (const [path36, raw] of Object.entries(s.nodes)) {
3628
- idx.nodes.set(path36, {
3628
+ for (const [path37, raw] of Object.entries(s.nodes)) {
3629
+ idx.nodes.set(path37, {
3629
3630
  commits: raw.commits,
3630
3631
  churnLines: raw.churnLines,
3631
3632
  bugCommits: raw.bugCommits,
@@ -3638,8 +3639,8 @@ var ChurnIndex = class _ChurnIndex {
3638
3639
  // -------------------------------------------------------------------------
3639
3640
  // Private helpers
3640
3641
  // -------------------------------------------------------------------------
3641
- getOrCreate(path36) {
3642
- const existing = this.nodes.get(path36);
3642
+ getOrCreate(path37) {
3643
+ const existing = this.nodes.get(path37);
3643
3644
  if (existing !== void 0) return existing;
3644
3645
  const fresh = {
3645
3646
  commits: 0,
@@ -3648,7 +3649,7 @@ var ChurnIndex = class _ChurnIndex {
3648
3649
  authorCounts: {},
3649
3650
  lastTouch: 0
3650
3651
  };
3651
- this.nodes.set(path36, fresh);
3652
+ this.nodes.set(path37, fresh);
3652
3653
  return fresh;
3653
3654
  }
3654
3655
  };
@@ -3729,12 +3730,12 @@ var OwnershipIndex = class _OwnershipIndex {
3729
3730
  */
3730
3731
  snapshot() {
3731
3732
  const nodes = {};
3732
- for (const [path36, raw] of this.nodes) {
3733
+ for (const [path37, raw] of this.nodes) {
3733
3734
  const authorWeights = {};
3734
3735
  for (const [email, entry] of Object.entries(raw.authorWeights)) {
3735
3736
  authorWeights[email] = { ...entry };
3736
3737
  }
3737
- nodes[path36] = { authorWeights, lastTouch: raw.lastTouch };
3738
+ nodes[path37] = { authorWeights, lastTouch: raw.lastTouch };
3738
3739
  }
3739
3740
  return { version: 1, nodes };
3740
3741
  }
@@ -3743,23 +3744,23 @@ var OwnershipIndex = class _OwnershipIndex {
3743
3744
  */
3744
3745
  static load(s) {
3745
3746
  const idx = new _OwnershipIndex();
3746
- for (const [path36, raw] of Object.entries(s.nodes)) {
3747
+ for (const [path37, raw] of Object.entries(s.nodes)) {
3747
3748
  const authorWeights = {};
3748
3749
  for (const [email, entry] of Object.entries(raw.authorWeights)) {
3749
3750
  authorWeights[email] = { ...entry };
3750
3751
  }
3751
- idx.nodes.set(path36, { authorWeights, lastTouch: raw.lastTouch });
3752
+ idx.nodes.set(path37, { authorWeights, lastTouch: raw.lastTouch });
3752
3753
  }
3753
3754
  return idx;
3754
3755
  }
3755
3756
  // -------------------------------------------------------------------------
3756
3757
  // Private helpers
3757
3758
  // -------------------------------------------------------------------------
3758
- getOrCreate(path36) {
3759
- const existing = this.nodes.get(path36);
3759
+ getOrCreate(path37) {
3760
+ const existing = this.nodes.get(path37);
3760
3761
  if (existing !== void 0) return existing;
3761
3762
  const fresh = { authorWeights: {}, lastTouch: 0 };
3762
- this.nodes.set(path36, fresh);
3763
+ this.nodes.set(path37, fresh);
3763
3764
  return fresh;
3764
3765
  }
3765
3766
  };
@@ -4565,7 +4566,7 @@ function renderStatusXml(input) {
4565
4566
  return lines.join("\n");
4566
4567
  }
4567
4568
  function registerStatusTool(registry, ctx) {
4568
- const Schema30 = z2.object({ project_root: ProjectRootField });
4569
+ const Schema31 = z2.object({ project_root: ProjectRootField });
4569
4570
  registry.register(
4570
4571
  "ctx_status",
4571
4572
  {
@@ -4579,7 +4580,7 @@ function registerStatusTool(registry, ctx) {
4579
4580
  }
4580
4581
  },
4581
4582
  async (args) => {
4582
- const { project_root } = Schema30.parse(args ?? {});
4583
+ const { project_root } = Schema31.parse(args ?? {});
4583
4584
  void project_root;
4584
4585
  return renderStatusXml({
4585
4586
  defaultRoot: ctx.noDefaultMode ? null : ctx.projectRoot,
@@ -4590,29 +4591,440 @@ function registerStatusTool(registry, ctx) {
4590
4591
  );
4591
4592
  }
4592
4593
 
4593
- // packages/core/src/tools/registry.ts
4594
- var ToolRegistry = class {
4595
- tools = /* @__PURE__ */ new Map();
4596
- register(name, schema4, handler) {
4597
- this.tools.set(name, { schema: schema4, handler });
4594
+ // packages/core/src/budget/learnedSuggestions.ts
4595
+ var DEFAULT_WINDOW_DAYS2 = 14;
4596
+ var SESSION_GAP_MS = 9e4;
4597
+ var MIN_SAMPLES_PER_PAIR = 3;
4598
+ var TOP_K = 3;
4599
+ var CACHE_TTL_MS = 60 * 60 * 1e3;
4600
+ var _cache = null;
4601
+ function __resetLearnedSuggestionsCacheForTests() {
4602
+ _cache = null;
4603
+ }
4604
+ function clampTokens(n) {
4605
+ if (!Number.isFinite(n)) return 0;
4606
+ return Math.max(0, Math.min(1e5, Math.round(n)));
4607
+ }
4608
+ function learnSuggestionsFromTelemetry(opts = {}) {
4609
+ const windowDays = opts.windowDays ?? DEFAULT_WINDOW_DAYS2;
4610
+ const sessionGapMs = opts.sessionGapMs ?? SESSION_GAP_MS;
4611
+ const minSamples = opts.minSamples ?? MIN_SAMPLES_PER_PAIR;
4612
+ const allowlist = opts.registeredTools;
4613
+ const events = opts.events ?? safeReadEvents(windowDays);
4614
+ if (events.length === 0) return {};
4615
+ const sorted = events.map((e) => ({ ts: Date.parse(e.ts), tool: e.tool })).filter((e) => Number.isFinite(e.ts) && typeof e.tool === "string" && e.tool.length > 0).sort((a, b) => a.ts - b.ts);
4616
+ const transitions = /* @__PURE__ */ new Map();
4617
+ const tokenSums = /* @__PURE__ */ new Map();
4618
+ let prevTool = null;
4619
+ let prevTs = -Infinity;
4620
+ for (const e of sorted) {
4621
+ if (prevTool && e.tool !== prevTool && e.ts - prevTs <= sessionGapMs) {
4622
+ let row = transitions.get(prevTool);
4623
+ if (!row) {
4624
+ row = /* @__PURE__ */ new Map();
4625
+ transitions.set(prevTool, row);
4626
+ }
4627
+ row.set(e.tool, (row.get(e.tool) ?? 0) + 1);
4628
+ }
4629
+ prevTool = e.tool;
4630
+ prevTs = e.ts;
4631
+ }
4632
+ for (const raw of events) {
4633
+ const tok = raw.original_tokens;
4634
+ if (typeof tok === "number" && Number.isFinite(tok)) {
4635
+ const acc = tokenSums.get(raw.tool) ?? { sum: 0, n: 0 };
4636
+ acc.sum += tok;
4637
+ acc.n += 1;
4638
+ tokenSums.set(raw.tool, acc);
4639
+ }
4598
4640
  }
4599
- list() {
4600
- return Array.from(this.tools.values()).map((t) => t.schema);
4641
+ const out = {};
4642
+ for (const [from, row] of transitions) {
4643
+ if (allowlist && !allowlist.has(from)) continue;
4644
+ const candidates = [];
4645
+ for (const [to, count] of row) {
4646
+ if (count < minSamples) continue;
4647
+ if (allowlist && !allowlist.has(to)) continue;
4648
+ const tokenAcc = tokenSums.get(to);
4649
+ const avgTokens = tokenAcc && tokenAcc.n > 0 ? tokenAcc.sum / tokenAcc.n : 0;
4650
+ candidates.push({
4651
+ tool: to,
4652
+ why: `Learned from telemetry: ${count} agents followed ${from} with ${to}.`,
4653
+ estimated_tokens: clampTokens(avgTokens)
4654
+ });
4655
+ }
4656
+ if (candidates.length === 0) continue;
4657
+ candidates.sort((a, b) => {
4658
+ const matchA = a.why.match(/(\d+) agents/);
4659
+ const matchB = b.why.match(/(\d+) agents/);
4660
+ const ca = matchA ? parseInt(matchA[1], 10) : 0;
4661
+ const cb = matchB ? parseInt(matchB[1], 10) : 0;
4662
+ return cb - ca;
4663
+ });
4664
+ out[from] = candidates.slice(0, TOP_K);
4601
4665
  }
4602
- async dispatch(name, args) {
4603
- const def = this.tools.get(name);
4604
- if (!def) throw new Error(`Unknown tool: ${name}`);
4605
- const projectRoot = args && typeof args === "object" && "project_root" in args ? args.project_root : void 0;
4606
- logger.debug("tool.dispatch", { tool: name, project_root: projectRoot });
4607
- return def.handler(args);
4666
+ return out;
4667
+ }
4668
+ function safeReadEvents(windowDays) {
4669
+ try {
4670
+ const until = /* @__PURE__ */ new Date();
4671
+ const since = new Date(until.getTime() - windowDays * 24 * 60 * 60 * 1e3);
4672
+ return readEvents({ since, until });
4673
+ } catch {
4674
+ return [];
4608
4675
  }
4609
- has(name) {
4610
- return this.tools.has(name);
4676
+ }
4677
+ function getLearnedRules(opts = {}) {
4678
+ if (_cache && _cache.expiresAt > Date.now()) {
4679
+ return filterRulesByAllowlist(_cache.rules, opts.registeredTools);
4611
4680
  }
4612
- };
4681
+ const unfilteredRules = learnSuggestionsFromTelemetry({
4682
+ ...opts,
4683
+ registeredTools: void 0
4684
+ });
4685
+ _cache = { rules: unfilteredRules, expiresAt: Date.now() + CACHE_TTL_MS };
4686
+ return filterRulesByAllowlist(unfilteredRules, opts.registeredTools);
4687
+ }
4688
+ function filterRulesByAllowlist(rules, allowlist) {
4689
+ if (!allowlist) return rules;
4690
+ const out = {};
4691
+ for (const [from, suggestions] of Object.entries(rules)) {
4692
+ if (!allowlist.has(from)) continue;
4693
+ const kept = suggestions.filter((s) => allowlist.has(s.tool));
4694
+ if (kept.length > 0) out[from] = kept;
4695
+ }
4696
+ return out;
4697
+ }
4613
4698
 
4614
- // packages/core/src/tools/search.ts
4615
- import { z as z3 } from "zod";
4699
+ // packages/core/src/budget/nextToolSuggestions.ts
4700
+ var STATIC_RULES = {
4701
+ // ─── Source-returning / file-shaped tools ────────────────────────
4702
+ ctx_get_file: [
4703
+ {
4704
+ tool: "ctx_get_call_graph",
4705
+ why: "Check who depends on this file before modifying.",
4706
+ estimated_tokens: 800
4707
+ },
4708
+ {
4709
+ tool: "ctx_get_definition",
4710
+ why: "Cheaper view if you need a specific symbol, not the whole file.",
4711
+ estimated_tokens: 2e3
4712
+ },
4713
+ {
4714
+ tool: "ctx_blast_radius",
4715
+ why: "Transitive impact analysis before a write.",
4716
+ estimated_tokens: 1500
4717
+ }
4718
+ ],
4719
+ ctx_get_definition: [
4720
+ {
4721
+ tool: "ctx_get_call_graph",
4722
+ why: "Who calls this symbol? Almost always your next step.",
4723
+ estimated_tokens: 800
4724
+ },
4725
+ {
4726
+ tool: "ctx_blast_radius",
4727
+ why: "What would break if this signature changes?",
4728
+ estimated_tokens: 1500
4729
+ }
4730
+ ],
4731
+ ctx_get_context_packet: [
4732
+ {
4733
+ tool: "ctx_get_call_graph",
4734
+ why: "Surface external callers not visible inside the packet.",
4735
+ estimated_tokens: 800
4736
+ },
4737
+ {
4738
+ tool: "ctx_get_affected_flows",
4739
+ why: "Execution-flow coverage of the packet files.",
4740
+ estimated_tokens: 2e3
4741
+ }
4742
+ ],
4743
+ ctx_search: [
4744
+ {
4745
+ tool: "ctx_get_definition",
4746
+ why: "Pull the canonical definition of a top result.",
4747
+ estimated_tokens: 2e3
4748
+ },
4749
+ {
4750
+ tool: "ctx_similar_files",
4751
+ why: "Find related files outside the keyword/vector hit set.",
4752
+ estimated_tokens: 1e3
4753
+ }
4754
+ ],
4755
+ ctx_full_text_search: [
4756
+ {
4757
+ tool: "ctx_get_file",
4758
+ why: "Inspect a specific match in context.",
4759
+ estimated_tokens: 8e3
4760
+ },
4761
+ {
4762
+ tool: "ctx_get_call_graph",
4763
+ why: "Caller graph for matched symbols.",
4764
+ estimated_tokens: 800
4765
+ }
4766
+ ],
4767
+ ctx_similar_files: [
4768
+ {
4769
+ tool: "ctx_get_context_packet",
4770
+ why: "Bundle the cluster into a single packet for review.",
4771
+ estimated_tokens: 6e3
4772
+ }
4773
+ ],
4774
+ // ─── Graph / structural queries ──────────────────────────────────
4775
+ // ctx_get_call_graph is the canonical "find callers" tool — pass
4776
+ // direction: 'callers' or 'callees' in args. The follow-ups below
4777
+ // assume the caller is investigating impact / coverage on the
4778
+ // returned caller set.
4779
+ ctx_get_call_graph: [
4780
+ {
4781
+ tool: "ctx_blast_radius",
4782
+ why: "Transitive dependents \u2014 callers of the callers you just found.",
4783
+ estimated_tokens: 1500
4784
+ },
4785
+ {
4786
+ tool: "ctx_get_affected_flows",
4787
+ why: "Which execution paths break if any caller is removed?",
4788
+ estimated_tokens: 2e3
4789
+ },
4790
+ {
4791
+ tool: "ctx_execution_flow",
4792
+ why: "Linearize the caller set into ordered execution sequences.",
4793
+ estimated_tokens: 4e3
4794
+ }
4795
+ ],
4796
+ ctx_blast_radius: [
4797
+ {
4798
+ tool: "ctx_get_affected_flows",
4799
+ why: "Execution-flow impact on the affected files.",
4800
+ estimated_tokens: 2e3
4801
+ },
4802
+ { tool: "ctx_knowledge_gaps", why: "Identify test-coverage gaps on affected files.", estimated_tokens: 1200 }
4803
+ ],
4804
+ ctx_get_affected_flows: [
4805
+ {
4806
+ tool: "ctx_execution_flow",
4807
+ why: "Drill into a specific affected flow.",
4808
+ estimated_tokens: 4e3
4809
+ },
4810
+ {
4811
+ tool: "ctx_blast_radius",
4812
+ why: "Reverse direction \u2014 what affects this flow?",
4813
+ estimated_tokens: 1500
4814
+ }
4815
+ ],
4816
+ ctx_execution_flow: [
4817
+ {
4818
+ tool: "ctx_get_call_graph",
4819
+ why: "External callers of the flow entry-point.",
4820
+ estimated_tokens: 800
4821
+ }
4822
+ ],
4823
+ // ─── Architecture / overview tools ───────────────────────────────
4824
+ ctx_architecture_overview: [
4825
+ {
4826
+ tool: "ctx_community_list",
4827
+ why: "Drill into a specific community.",
4828
+ estimated_tokens: 1e3
4829
+ },
4830
+ {
4831
+ tool: "ctx_hub_nodes",
4832
+ why: "Top fan-in/out nodes deserving deeper inspection.",
4833
+ estimated_tokens: 1200
4834
+ },
4835
+ {
4836
+ tool: "ctx_bridge_nodes",
4837
+ why: "Cross-community bridges (high architectural leverage).",
4838
+ estimated_tokens: 1e3
4839
+ }
4840
+ ],
4841
+ ctx_community_list: [
4842
+ {
4843
+ tool: "ctx_get_context_packet",
4844
+ why: "Bundle a community into a single review packet.",
4845
+ estimated_tokens: 6e3
4846
+ }
4847
+ ],
4848
+ ctx_hub_nodes: [
4849
+ {
4850
+ tool: "ctx_get_call_graph",
4851
+ why: "Who depends on the top hub?",
4852
+ estimated_tokens: 800
4853
+ },
4854
+ {
4855
+ tool: "ctx_blast_radius",
4856
+ why: "Hub change-impact analysis.",
4857
+ estimated_tokens: 1500
4858
+ }
4859
+ ],
4860
+ ctx_bridge_nodes: [
4861
+ {
4862
+ tool: "ctx_get_call_graph",
4863
+ why: "Callers across the bridge.",
4864
+ estimated_tokens: 800
4865
+ }
4866
+ ],
4867
+ ctx_surprising_connections: [
4868
+ {
4869
+ tool: "ctx_blast_radius",
4870
+ why: "Impact analysis on a surprising-connection target.",
4871
+ estimated_tokens: 1500
4872
+ }
4873
+ ],
4874
+ // ─── Review / diff tools ─────────────────────────────────────────
4875
+ ctx_detect_changes: [
4876
+ {
4877
+ tool: "ctx_get_file",
4878
+ why: "Inspect a specific risky file.",
4879
+ estimated_tokens: 8e3
4880
+ },
4881
+ {
4882
+ tool: "ctx_get_affected_flows",
4883
+ why: "Which execution paths the change touches.",
4884
+ estimated_tokens: 2e3
4885
+ },
4886
+ {
4887
+ tool: "ctx_git_diff_review",
4888
+ why: "Full diff packet for the changeset.",
4889
+ estimated_tokens: 8e3
4890
+ }
4891
+ ],
4892
+ ctx_git_diff_review: [
4893
+ {
4894
+ tool: "ctx_risk_overlay",
4895
+ why: "Score the changed files by historical churn + coupling.",
4896
+ estimated_tokens: 1500
4897
+ },
4898
+ {
4899
+ tool: "ctx_get_call_graph",
4900
+ why: "Caller-side impact of the changes.",
4901
+ estimated_tokens: 800
4902
+ }
4903
+ ],
4904
+ ctx_risk_overlay: [
4905
+ {
4906
+ tool: "ctx_get_file",
4907
+ why: "Inspect the highest-risk file in detail.",
4908
+ estimated_tokens: 8e3
4909
+ }
4910
+ ],
4911
+ ctx_git_coupling: [
4912
+ {
4913
+ tool: "ctx_blast_radius",
4914
+ why: "Static impact analysis to complement co-change signal.",
4915
+ estimated_tokens: 1500
4916
+ }
4917
+ ],
4918
+ // ─── Refactor tools ──────────────────────────────────────────────
4919
+ ctx_refactor_preview: [
4920
+ {
4921
+ tool: "ctx_apply_refactor",
4922
+ why: "Commit the preview after you reviewed the rename.",
4923
+ estimated_tokens: 2e3
4924
+ }
4925
+ ],
4926
+ ctx_apply_refactor: [
4927
+ {
4928
+ tool: "ctx_detect_changes",
4929
+ why: "Verify the refactor produced the expected risk profile.",
4930
+ estimated_tokens: 1500
4931
+ }
4932
+ ],
4933
+ // ─── Knowledge / coverage tools ──────────────────────────────────
4934
+ ctx_knowledge_gaps: [
4935
+ { tool: "ctx_knowledge_gaps", why: "Identify test-coverage gaps on affected files.", estimated_tokens: 1200 },
4936
+ {
4937
+ tool: "ctx_get_call_graph",
4938
+ why: "Caller frequency on untested symbols (impact ranking).",
4939
+ estimated_tokens: 800
4940
+ }
4941
+ ],
4942
+ ctx_find_large_functions: [
4943
+ {
4944
+ tool: "ctx_get_definition",
4945
+ why: "Inspect the largest function.",
4946
+ estimated_tokens: 2e3
4947
+ }
4948
+ ],
4949
+ // ─── Metadata / status ───────────────────────────────────────────
4950
+ ctx_get_minimal_context: [
4951
+ // intentionally empty — the suggested_first_tool field IS the
4952
+ // next-step suggestion. Adding rules here would be redundant.
4953
+ ],
4954
+ ctx_status: [],
4955
+ ctx_get_rules: [
4956
+ {
4957
+ tool: "ctx_rules_check",
4958
+ why: "Validate code against rules.",
4959
+ estimated_tokens: 1200
4960
+ }
4961
+ ],
4962
+ ctx_rules_check: [],
4963
+ ctx_get_workflow: [],
4964
+ ctx_suggested_questions: [],
4965
+ // ─── Wiki / export ───────────────────────────────────────────────
4966
+ ctx_wiki_generate: [
4967
+ {
4968
+ tool: "ctx_architecture_overview",
4969
+ why: "Confirm the wiki structure against the live overview.",
4970
+ estimated_tokens: 2e3
4971
+ }
4972
+ ],
4973
+ ctx_graph_export: [],
4974
+ ctx_graph_snapshot: [
4975
+ {
4976
+ tool: "ctx_graph_diff",
4977
+ why: "Compare against a later snapshot.",
4978
+ estimated_tokens: 2e3
4979
+ }
4980
+ ],
4981
+ ctx_graph_diff: [
4982
+ {
4983
+ tool: "ctx_detect_changes",
4984
+ why: "Risk-scored view of the graph delta.",
4985
+ estimated_tokens: 1500
4986
+ }
4987
+ ],
4988
+ // ─── Cross-repo ──────────────────────────────────────────────────
4989
+ ctx_cross_repo_search: [
4990
+ {
4991
+ tool: "ctx_get_file",
4992
+ why: "Inspect a hit in a specific repo.",
4993
+ estimated_tokens: 8e3
4994
+ }
4995
+ ]
4996
+ // ─── Query primitive ─────────────────────────────────────────────
4997
+ // ─── Affected flows (sibling to get_affected_flows but worth pinning) ─
4998
+ // ─── Definition aliases / structurals not listed above are deliberate
4999
+ // — the test enforces the full registered-tool list is covered.
5000
+ };
5001
+ var TOKEN_ESTIMATE_MIN = 0;
5002
+ var TOKEN_ESTIMATE_MAX = 1e5;
5003
+ function clampEstimate(n) {
5004
+ if (!Number.isFinite(n)) return 0;
5005
+ return Math.max(TOKEN_ESTIMATE_MIN, Math.min(TOKEN_ESTIMATE_MAX, Math.round(n)));
5006
+ }
5007
+ function suggestNext(fromTool, registeredTools) {
5008
+ if (process.env.CTXLOOM_LEARNED_SUGGESTIONS === "1") {
5009
+ const learned = getLearnedRules({ registeredTools })[fromTool];
5010
+ if (learned && learned.length > 0) {
5011
+ return learned.slice(0, 3).map((s) => ({
5012
+ tool: s.tool,
5013
+ args: s.args,
5014
+ why: s.why,
5015
+ estimated_tokens: clampEstimate(s.estimated_tokens)
5016
+ }));
5017
+ }
5018
+ }
5019
+ const raw = STATIC_RULES[fromTool] ?? [];
5020
+ const filtered = registeredTools ? raw.filter((s) => registeredTools.has(s.tool)) : raw;
5021
+ return filtered.slice(0, 3).map((s) => ({
5022
+ tool: s.tool,
5023
+ args: s.args,
5024
+ why: s.why,
5025
+ estimated_tokens: clampEstimate(s.estimated_tokens)
5026
+ }));
5027
+ }
4616
5028
 
4617
5029
  // packages/core/src/budget/budget.ts
4618
5030
  var defaultTokenEstimator = (text) => Math.ceil(text.length / 4);
@@ -4649,16 +5061,21 @@ async function enforceBudget(opts) {
4649
5061
  const { full, args, toolName, defaultMaxTokens, skeletonProducer } = opts;
4650
5062
  const estimate = opts.estimator ?? defaultTokenEstimator;
4651
5063
  const sink = opts.sink ?? opts.ctx?.telemetrySink ?? diskSink;
5064
+ const nextToolSuggestions = suggestNext(toolName);
5065
+ const withSuggestions = (base) => {
5066
+ if (nextToolSuggestions.length === 0) return base;
5067
+ return { ...base, next_tool_suggestions: nextToolSuggestions };
5068
+ };
4652
5069
  const originalTokens = estimate(full);
4653
5070
  if (isBudgetDisabled()) {
4654
5071
  return {
4655
5072
  text: full,
4656
- meta: {
5073
+ meta: withSuggestions({
4657
5074
  format: "full",
4658
5075
  original_tokens_est: originalTokens,
4659
5076
  returned_tokens_est: originalTokens,
4660
5077
  fallback_reason: null
4661
- }
5078
+ })
4662
5079
  };
4663
5080
  }
4664
5081
  if (args.response_format === "skeleton" && skeletonProducer) {
@@ -4667,45 +5084,45 @@ async function enforceBudget(opts) {
4667
5084
  const skTokens = estimate(skeleton2);
4668
5085
  return {
4669
5086
  text: skeleton2,
4670
- meta: {
5087
+ meta: withSuggestions({
4671
5088
  format: "skeleton",
4672
5089
  original_tokens_est: originalTokens,
4673
5090
  returned_tokens_est: skTokens,
4674
5091
  fallback_reason: null
4675
- }
5092
+ })
4676
5093
  };
4677
5094
  }
4678
5095
  return {
4679
5096
  text: full,
4680
- meta: {
5097
+ meta: withSuggestions({
4681
5098
  format: "full",
4682
5099
  original_tokens_est: originalTokens,
4683
5100
  returned_tokens_est: originalTokens,
4684
5101
  fallback_reason: "skeleton_failed"
4685
- }
5102
+ })
4686
5103
  };
4687
5104
  }
4688
5105
  const budget = args.max_response_tokens ?? defaultMaxTokens;
4689
5106
  if (budget === void 0) {
4690
5107
  return {
4691
5108
  text: full,
4692
- meta: {
5109
+ meta: withSuggestions({
4693
5110
  format: "full",
4694
5111
  original_tokens_est: originalTokens,
4695
5112
  returned_tokens_est: originalTokens,
4696
5113
  fallback_reason: null
4697
- }
5114
+ })
4698
5115
  };
4699
5116
  }
4700
5117
  if (originalTokens <= budget) {
4701
5118
  return {
4702
5119
  text: full,
4703
- meta: {
5120
+ meta: withSuggestions({
4704
5121
  format: "full",
4705
5122
  original_tokens_est: originalTokens,
4706
5123
  returned_tokens_est: originalTokens,
4707
5124
  fallback_reason: null
4708
- }
5125
+ })
4709
5126
  };
4710
5127
  }
4711
5128
  emitTelemetry({
@@ -4736,12 +5153,12 @@ async function enforceBudget(opts) {
4736
5153
  }, sink);
4737
5154
  return {
4738
5155
  text: sliced2,
4739
- meta: {
5156
+ meta: withSuggestions({
4740
5157
  format: "truncated",
4741
5158
  original_tokens_est: originalTokens,
4742
5159
  returned_tokens_est: slicedTokens,
4743
5160
  fallback_reason: "budget_exceeded"
4744
- }
5161
+ })
4745
5162
  };
4746
5163
  }
4747
5164
  const skeleton = skeletonProducer ? await safeSkeleton(skeletonProducer, toolName) : null;
@@ -4756,12 +5173,12 @@ async function enforceBudget(opts) {
4756
5173
  }, sink);
4757
5174
  return {
4758
5175
  text: skeleton,
4759
- meta: {
5176
+ meta: withSuggestions({
4760
5177
  format: "skeleton",
4761
5178
  original_tokens_est: originalTokens,
4762
5179
  returned_tokens_est: skTokens,
4763
5180
  fallback_reason: "budget_exceeded"
4764
- }
5181
+ })
4765
5182
  };
4766
5183
  }
4767
5184
  const slicedSk = skeleton.slice(0, budget * 4);
@@ -4773,12 +5190,12 @@ async function enforceBudget(opts) {
4773
5190
  }, sink);
4774
5191
  return {
4775
5192
  text: slicedSk,
4776
- meta: {
5193
+ meta: withSuggestions({
4777
5194
  format: "truncated",
4778
5195
  original_tokens_est: originalTokens,
4779
5196
  returned_tokens_est: estimate(slicedSk),
4780
5197
  fallback_reason: "budget_exceeded"
4781
- }
5198
+ })
4782
5199
  };
4783
5200
  }
4784
5201
  const sliced = full.slice(0, budget * 4);
@@ -4790,12 +5207,12 @@ async function enforceBudget(opts) {
4790
5207
  }, sink);
4791
5208
  return {
4792
5209
  text: sliced,
4793
- meta: {
5210
+ meta: withSuggestions({
4794
5211
  format: "truncated",
4795
5212
  original_tokens_est: originalTokens,
4796
5213
  returned_tokens_est: estimate(sliced),
4797
5214
  fallback_reason: "skeleton_failed"
4798
- }
5215
+ })
4799
5216
  };
4800
5217
  }
4801
5218
  async function safeSkeleton(producer, toolName) {
@@ -4817,7 +5234,159 @@ function wrapResponse(result) {
4817
5234
  return JSON.stringify(envelope);
4818
5235
  }
4819
5236
 
5237
+ // packages/core/src/budget/taskBudget.ts
5238
+ var DEFAULT_MAX_CALLS = 8;
5239
+ var DEFAULT_RESET_GAP_MS = 9e4;
5240
+ var ENV_VAR = "CTXLOOM_TASK_TOOL_BUDGET";
5241
+ function parseEnvBudget() {
5242
+ const raw = process.env[ENV_VAR];
5243
+ if (!raw) return null;
5244
+ const n = parseInt(raw, 10);
5245
+ if (!Number.isFinite(n) || n <= 0) return null;
5246
+ return n;
5247
+ }
5248
+ var TaskBudgetTracker = class {
5249
+ state = /* @__PURE__ */ new Map();
5250
+ maxCalls;
5251
+ resetGapMs;
5252
+ constructor(opts = {}) {
5253
+ this.maxCalls = opts.maxCalls ?? parseEnvBudget() ?? DEFAULT_MAX_CALLS;
5254
+ this.resetGapMs = opts.resetGapMs ?? DEFAULT_RESET_GAP_MS;
5255
+ }
5256
+ /**
5257
+ * Record a tool call against the budget. Returns the enforcement
5258
+ * decision the dispatch layer should act on.
5259
+ *
5260
+ * @param sessionId — opaque session identifier. Currently a
5261
+ * single global key ('process'); reserved for future multi-
5262
+ * session enforcement.
5263
+ * @param now — milliseconds since epoch. Test hook; defaults to
5264
+ * `Date.now()`.
5265
+ */
5266
+ recordCall(sessionId = "process", now = Date.now()) {
5267
+ if (isBudgetDisabled()) {
5268
+ return {
5269
+ overBudget: false,
5270
+ callCount: 0,
5271
+ maxCalls: this.maxCalls,
5272
+ firstBreach: false
5273
+ };
5274
+ }
5275
+ const existing = this.state.get(sessionId);
5276
+ if (existing && now - existing.lastCallTs > this.resetGapMs) {
5277
+ const fresh = { count: 1, lastCallTs: now, breachEmitted: false };
5278
+ this.state.set(sessionId, fresh);
5279
+ return {
5280
+ overBudget: false,
5281
+ callCount: 1,
5282
+ maxCalls: this.maxCalls,
5283
+ firstBreach: false
5284
+ };
5285
+ }
5286
+ const next = (existing?.count ?? 0) + 1;
5287
+ const wasBreached = existing?.breachEmitted ?? false;
5288
+ const overBudget = next > this.maxCalls;
5289
+ const firstBreach = overBudget && !wasBreached;
5290
+ this.state.set(sessionId, {
5291
+ count: next,
5292
+ lastCallTs: now,
5293
+ breachEmitted: wasBreached || firstBreach
5294
+ });
5295
+ return { overBudget, callCount: next, maxCalls: this.maxCalls, firstBreach };
5296
+ }
5297
+ /**
5298
+ * Test-only: drop all state. Lets tests run in isolation without
5299
+ * depending on order.
5300
+ *
5301
+ * @internal
5302
+ */
5303
+ reset() {
5304
+ this.state.clear();
5305
+ }
5306
+ /**
5307
+ * Test/diagnostic — current call count for the default session.
5308
+ * @internal
5309
+ */
5310
+ __getCount(sessionId = "process") {
5311
+ return this.state.get(sessionId)?.count ?? 0;
5312
+ }
5313
+ };
5314
+ var _singleton = null;
5315
+ function getTaskBudgetTracker() {
5316
+ if (!_singleton) _singleton = new TaskBudgetTracker();
5317
+ return _singleton;
5318
+ }
5319
+ function __resetTaskBudgetTrackerForTests() {
5320
+ _singleton = null;
5321
+ }
5322
+ var OVER_BUDGET_ARG_OVERRIDES = Object.freeze({
5323
+ // Budget-surface tools (the 12 source-returning ones).
5324
+ max_response_tokens: 200,
5325
+ response_format: "skeleton",
5326
+ on_budget_exceeded: "skeleton",
5327
+ // Tools with detail_level (hub_nodes, bridge_nodes, etc).
5328
+ detail_level: "minimal"
5329
+ });
5330
+ function applyOverBudgetOverrides(args) {
5331
+ if (!args || typeof args !== "object") {
5332
+ return { ...OVER_BUDGET_ARG_OVERRIDES };
5333
+ }
5334
+ return { ...args, ...OVER_BUDGET_ARG_OVERRIDES };
5335
+ }
5336
+ function emitTaskBudgetBreached(toolName, callCount, maxCalls) {
5337
+ emitTelemetry({
5338
+ event: "mcp.task_budget.exceeded",
5339
+ tool: toolName,
5340
+ calls: callCount,
5341
+ budget: maxCalls
5342
+ });
5343
+ }
5344
+
5345
+ // packages/core/src/tools/registry.ts
5346
+ var TASK_BUDGET_EXEMPT = /* @__PURE__ */ new Set([
5347
+ "ctx_get_minimal_context",
5348
+ "ctx_status",
5349
+ "ctx_get_workflow",
5350
+ "ctx_get_rules",
5351
+ "ctx_suggested_questions"
5352
+ ]);
5353
+ var ToolRegistry = class {
5354
+ tools = /* @__PURE__ */ new Map();
5355
+ register(name, schema4, handler) {
5356
+ this.tools.set(name, { schema: schema4, handler });
5357
+ }
5358
+ list() {
5359
+ return Array.from(this.tools.values()).map((t) => t.schema);
5360
+ }
5361
+ async dispatch(name, args) {
5362
+ const def = this.tools.get(name);
5363
+ if (!def) throw new Error(`Unknown tool: ${name}`);
5364
+ const projectRoot = args && typeof args === "object" && "project_root" in args ? args.project_root : void 0;
5365
+ logger.debug("tool.dispatch", { tool: name, project_root: projectRoot });
5366
+ let dispatchArgs = args;
5367
+ if (!TASK_BUDGET_EXEMPT.has(name)) {
5368
+ const decision = getTaskBudgetTracker().recordCall();
5369
+ if (decision.overBudget) {
5370
+ dispatchArgs = applyOverBudgetOverrides(args);
5371
+ if (decision.firstBreach) {
5372
+ logger.warn("task tool budget exceeded \u2014 auto-throttling responses to skeleton/minimal", {
5373
+ tool: name,
5374
+ calls: decision.callCount,
5375
+ budget: decision.maxCalls
5376
+ });
5377
+ emitTaskBudgetBreached(name, decision.callCount, decision.maxCalls);
5378
+ }
5379
+ }
5380
+ }
5381
+ return def.handler(dispatchArgs);
5382
+ }
5383
+ has(name) {
5384
+ return this.tools.has(name);
5385
+ }
5386
+ };
5387
+
4820
5388
  // packages/core/src/tools/search.ts
5389
+ import { z as z3 } from "zod";
4821
5390
  var DEFAULT_MAX_RESPONSE_TOKENS = 4e3;
4822
5391
  var Schema = z3.object({
4823
5392
  query: z3.string().describe("Search query \u2014 natural language or code fragment"),
@@ -6454,7 +7023,7 @@ function registerGitDiffReviewTool(registry, ctx) {
6454
7023
  skeleton: include_skeletons && i < skeletonLimit ? await trySkeletonize(ctx, file, project_root) : ""
6455
7024
  }))
6456
7025
  );
6457
- const render = (withSkeletons, withTransitive) => {
7026
+ const render2 = (withSkeletons, withTransitive) => {
6458
7027
  const out = [`<git_diff_review changed_files="${files.length}" depth="${depth}">`];
6459
7028
  out.push(` <changed_files count="${files.length}">`);
6460
7029
  for (const cd of changedFileData) {
@@ -6500,8 +7069,8 @@ function registerGitDiffReviewTool(registry, ctx) {
6500
7069
  out.push("</git_diff_review>");
6501
7070
  return out.join("\n");
6502
7071
  };
6503
- const full = render(include_skeletons, true);
6504
- return maybeBudget(full, async () => render(false, false));
7072
+ const full = render2(include_skeletons, true);
7073
+ return maybeBudget(full, async () => render2(false, false));
6505
7074
  }
6506
7075
  );
6507
7076
  }
@@ -6603,7 +7172,7 @@ function registerRefactorPreviewTool(registry, ctx) {
6603
7172
  totalOccurrences += occurrences.length;
6604
7173
  }
6605
7174
  }
6606
- const render = (includeChanges) => {
7175
+ const render2 = (includeChanges) => {
6607
7176
  const xmlLines = [
6608
7177
  `<refactor_preview symbol="${escapeXML17(symbol)}" new_name="${escapeXML17(new_name)}" total_files="${fileChanges.length}" total_occurrences="${totalOccurrences}">`
6609
7178
  ];
@@ -6633,7 +7202,7 @@ function registerRefactorPreviewTool(registry, ctx) {
6633
7202
  xmlLines.push("</refactor_preview>");
6634
7203
  return xmlLines.join("\n");
6635
7204
  };
6636
- const full = render(true);
7205
+ const full = render2(true);
6637
7206
  if (!hasBudgetArgs(args)) return full;
6638
7207
  const result = await enforceBudget({
6639
7208
  ctx,
@@ -6641,7 +7210,7 @@ function registerRefactorPreviewTool(registry, ctx) {
6641
7210
  args: readBudgetArgs(args),
6642
7211
  toolName: "ctx_refactor_preview",
6643
7212
  defaultMaxTokens: DEFAULT_MAX_RESPONSE_TOKENS7,
6644
- skeletonProducer: async () => render(false)
7213
+ skeletonProducer: async () => render2(false)
6645
7214
  });
6646
7215
  return wrapResponse(result);
6647
7216
  }
@@ -7004,7 +7573,7 @@ function registerCrossRepoSearchTool(registry, ctx, registryFilePath) {
7004
7573
  );
7005
7574
  }
7006
7575
  xmlLines.push(" </repos>");
7007
- const render = (includeContent) => {
7576
+ const render2 = (includeContent) => {
7008
7577
  const out = [...xmlLines];
7009
7578
  out.push(` <results count="${topResults.length}">`);
7010
7579
  for (const r of topResults) {
@@ -7020,7 +7589,7 @@ function registerCrossRepoSearchTool(registry, ctx, registryFilePath) {
7020
7589
  out.push("</cross_repo_search>");
7021
7590
  return out.join("\n");
7022
7591
  };
7023
- const full = render(true);
7592
+ const full = render2(true);
7024
7593
  if (!hasBudgetArgs(args)) return full;
7025
7594
  const result = await enforceBudget({
7026
7595
  ctx,
@@ -7028,7 +7597,7 @@ function registerCrossRepoSearchTool(registry, ctx, registryFilePath) {
7028
7597
  args: readBudgetArgs(args),
7029
7598
  toolName: "ctx_cross_repo_search",
7030
7599
  defaultMaxTokens: DEFAULT_MAX_RESPONSE_TOKENS9,
7031
- skeletonProducer: async () => render(false)
7600
+ skeletonProducer: async () => render2(false)
7032
7601
  });
7033
7602
  return wrapResponse(result);
7034
7603
  }
@@ -7415,7 +7984,7 @@ function registerFullTextSearchTool(registry, ctx) {
7415
7984
  } catch {
7416
7985
  }
7417
7986
  }
7418
- const render = (includeSnippets) => {
7987
+ const render2 = (includeSnippets) => {
7419
7988
  const xml = [
7420
7989
  `<full_text_search query="${escapeXML22(query)}" mode="${mode}" case_sensitive="${case_sensitive}" count="${merged.length}">`
7421
7990
  ];
@@ -7434,8 +8003,8 @@ function registerFullTextSearchTool(registry, ctx) {
7434
8003
  return xml.join("\n");
7435
8004
  };
7436
8005
  return maybeBudget(
7437
- render(true),
7438
- async () => render(false)
8006
+ render2(true),
8007
+ async () => render2(false)
7439
8008
  );
7440
8009
  }
7441
8010
  );
@@ -8468,66 +9037,340 @@ function registerGetAffectedFlowsTool(registry, ctx) {
8468
9037
  );
8469
9038
  }
8470
9039
 
8471
- // packages/core/src/tools/index.ts
8472
- function createToolRegistry(ctx) {
8473
- const registry = new ToolRegistry();
8474
- registerSearchTool(registry, ctx);
8475
- registerFileTool(registry, ctx);
8476
- registerContextPacketTool(registry, ctx);
8477
- registerCallGraphTool(registry, ctx);
8478
- registerDefinitionTool(registry, ctx);
8479
- registerRulesTool(registry, ctx);
8480
- registerRulesCheckTool(registry, ctx);
8481
- registerSimilarFilesTool(registry, ctx);
8482
- registerStatusTool(registry, ctx);
8483
- registerBlastRadiusTool(registry, ctx);
8484
- registerHubNodesTool(registry, ctx);
8485
- registerBridgeNodesTool(registry, ctx);
8486
- registerCommunityListTool(registry, ctx);
8487
- registerArchitectureOverviewTool(registry, ctx);
8488
- registerKnowledgeGapsTool(registry, ctx);
8489
- registerSurprisingConnectionsTool(registry, ctx);
8490
- registerWikiGenerateTool(registry, ctx);
8491
- registerGraphExportTool(registry, ctx);
8492
- registerGitDiffReviewTool(registry, ctx);
8493
- registerRefactorPreviewTool(registry, ctx);
8494
- registerExecutionFlowTool(registry, ctx);
8495
- registerCrossRepoSearchTool(registry, ctx);
8496
- registerApplyRefactorTool(registry, ctx);
8497
- registerDetectChangesTool(registry, ctx);
8498
- registerFullTextSearchTool(registry, ctx);
8499
- registerSuggestedQuestionsTool(registry, ctx);
8500
- registerGetWorkflowTool(registry, ctx);
8501
- registerGraphSnapshotTool(registry, ctx);
8502
- registerGraphDiffTool(registry, ctx);
8503
- registerFindLargeFunctionsTool(registry, ctx);
8504
- registerGitCouplingTool(registry, ctx);
8505
- registerRiskOverlayTool(registry, ctx);
8506
- registerGetAffectedFlowsTool(registry, ctx);
8507
- return registry;
8508
- }
8509
-
8510
- // packages/core/src/tools/ruleManager.ts
8511
- import fs22 from "fs";
8512
- import path24 from "path";
8513
- var RULE_FILES = [
8514
- ".cursorrules",
8515
- "CLAUDE.md",
8516
- "CONTEXT.md",
8517
- ".ctxloomrc",
8518
- ".cursor/rules",
8519
- ".claude/CLAUDE.md"
8520
- ];
8521
- var RuleManager = class {
8522
- projectRoot;
8523
- pathValidator;
8524
- cachedRules = null;
8525
- constructor(projectRoot, pathValidator) {
8526
- this.projectRoot = projectRoot;
8527
- this.pathValidator = pathValidator;
8528
- }
8529
- /**
8530
- * Scan for all rule files in the project root.
9040
+ // packages/core/src/tools/minimal-context.ts
9041
+ import { execSync } from "child_process";
9042
+ import { z as z36 } from "zod";
9043
+ var Schema30 = z36.object({
9044
+ task: z36.string().max(200).optional().describe(
9045
+ "Free-text description of what you're about to do (e.g. 'review PR 142', 'rename emitTelemetry'). The tool routes by regex to the most-fitting suggested-first-tool. Capped at 200 chars; control characters stripped."
9046
+ ),
9047
+ project_root: ProjectRootField,
9048
+ max_response_tokens: z36.number().int().positive().optional(),
9049
+ on_budget_exceeded: z36.enum(["skeleton", "truncate", "error"]).optional(),
9050
+ response_format: z36.enum(["full", "skeleton", "auto"]).optional()
9051
+ });
9052
+ var DEFAULT_MAX_RESPONSE_TOKENS13 = 250;
9053
+ function routeFirstTool(task, hasDirtyChanges) {
9054
+ const t = (task ?? "").toLowerCase();
9055
+ if (/\b(rename|refactor|move\s+\w+|extract)\b/.test(t)) {
9056
+ return {
9057
+ tool: "ctx_get_call_graph",
9058
+ why: "Renames + refactors need every caller surfaced before the edit. Start here.",
9059
+ estimated_tokens: 800
9060
+ };
9061
+ }
9062
+ if (/\b(blast|impact|breaks?|affects?)\b/.test(t)) {
9063
+ return {
9064
+ tool: "ctx_blast_radius",
9065
+ why: "Blast-radius analysis gives transitive dependents; start here for impact questions.",
9066
+ estimated_tokens: 1500
9067
+ };
9068
+ }
9069
+ if (/\b(architect|overview|explore|onboard|tour)\b/.test(t)) {
9070
+ return {
9071
+ tool: "ctx_architecture_overview",
9072
+ why: "Top-down map of communities + hub nodes; the natural starting point for exploration.",
9073
+ estimated_tokens: 2e3
9074
+ };
9075
+ }
9076
+ if (/\b(test|coverage|tested)\b/.test(t)) {
9077
+ return {
9078
+ tool: "ctx_knowledge_gaps",
9079
+ why: "Knowledge-gap report highlights files lacking test coverage.",
9080
+ estimated_tokens: 1200
9081
+ };
9082
+ }
9083
+ if (/\b(review|audit|check|diff)\b/.test(t)) {
9084
+ return {
9085
+ tool: "ctx_detect_changes",
9086
+ why: "Risk-scored change analysis is the canonical start for reviews.",
9087
+ estimated_tokens: 1500
9088
+ };
9089
+ }
9090
+ if (hasDirtyChanges) {
9091
+ return {
9092
+ tool: "ctx_detect_changes",
9093
+ why: "Working tree has uncommitted changes \u2014 review-mode is the most-likely intent.",
9094
+ estimated_tokens: 1500
9095
+ };
9096
+ }
9097
+ return {
9098
+ tool: "ctx_architecture_overview",
9099
+ why: "Clean working tree + no task hint \u2014 orientation is the safe default.",
9100
+ estimated_tokens: 2e3
9101
+ };
9102
+ }
9103
+ function classifyStaleness(lastBuildIso) {
9104
+ if (!lastBuildIso) return "unbuilt";
9105
+ const ms = Date.now() - new Date(lastBuildIso).getTime();
9106
+ if (!Number.isFinite(ms) || ms < 0) return "unbuilt";
9107
+ if (ms < 5 * 60 * 1e3) return "fresh";
9108
+ if (ms < 60 * 60 * 1e3) return "stale_minutes";
9109
+ return "stale_hours";
9110
+ }
9111
+ function readRecentChanges(projectRoot) {
9112
+ try {
9113
+ const stdout = execSync("git status --porcelain", {
9114
+ cwd: projectRoot,
9115
+ timeout: 2e3,
9116
+ stdio: ["ignore", "pipe", "ignore"],
9117
+ encoding: "utf-8"
9118
+ });
9119
+ const lines = stdout.split("\n").filter((l) => l.trim() !== "");
9120
+ return lines.slice(0, 20).map((line) => {
9121
+ const x = line[0];
9122
+ const y = line[1];
9123
+ const path37 = line.slice(3).trim();
9124
+ let status = "?";
9125
+ const xy = x === " " ? y : x;
9126
+ if (xy === "M" || xy === "A" || xy === "D" || xy === "R") status = xy;
9127
+ return { file: path37, status };
9128
+ });
9129
+ } catch {
9130
+ return [];
9131
+ }
9132
+ }
9133
+ function computeTopHubs(graph) {
9134
+ const files = graph.allFiles();
9135
+ const scored = files.map((file) => {
9136
+ const inDeg = graph.getImporters(file).length;
9137
+ const outDeg = graph.getImports(file).length;
9138
+ return { file, inDeg, outDeg, total: inDeg + outDeg };
9139
+ }).filter((s) => s.total >= 2).sort((a, b) => b.total - a.total).slice(0, 5);
9140
+ return scored.map((s) => ({
9141
+ name: s.file,
9142
+ reason: s.inDeg > s.outDeg ? "fan_in" : s.outDeg > s.inDeg ? "fan_out" : "bridge"
9143
+ }));
9144
+ }
9145
+ var CACHE_TTL_MS2 = 1e4;
9146
+ var responseCache = /* @__PURE__ */ new Map();
9147
+ function cacheKey(projectRoot, task) {
9148
+ return `${projectRoot}|${task ?? ""}`;
9149
+ }
9150
+ function cacheGet(key) {
9151
+ const e = responseCache.get(key);
9152
+ if (!e) return null;
9153
+ if (e.expiresAt < Date.now()) {
9154
+ responseCache.delete(key);
9155
+ return null;
9156
+ }
9157
+ return e.body;
9158
+ }
9159
+ function cachePut(key, body) {
9160
+ responseCache.set(key, { expiresAt: Date.now() + CACHE_TTL_MS2, body });
9161
+ }
9162
+ function sanitizeTask(raw) {
9163
+ if (raw == null) return void 0;
9164
+ const stripped = raw.replace(/[\x00-\x1f\x7f]/g, "").slice(0, 200);
9165
+ return stripped === "" ? void 0 : stripped;
9166
+ }
9167
+ function escapeXML26(text) {
9168
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
9169
+ }
9170
+ function render(input) {
9171
+ const lines = ["<minimal_context>"];
9172
+ lines.push(
9173
+ ` <graph ready="${input.graphReady}" nodes="${input.nodes}" edges="${input.edges}" last_build="${escapeXML26(input.lastBuildIso ?? "never")}" staleness="${input.staleness}" />`
9174
+ );
9175
+ if (input.languages.length > 0) {
9176
+ lines.push(` <languages>${input.languages.map(escapeXML26).join(", ")}</languages>`);
9177
+ }
9178
+ if (input.recentChanges.length > 0) {
9179
+ lines.push(` <recent_changes count="${input.recentChanges.length}">`);
9180
+ for (const c of input.recentChanges) {
9181
+ lines.push(` <change status="${c.status}" file="${escapeXML26(c.file)}" />`);
9182
+ }
9183
+ lines.push(" </recent_changes>");
9184
+ } else {
9185
+ lines.push(' <recent_changes count="0" />');
9186
+ }
9187
+ if (input.topHubs.length > 0) {
9188
+ lines.push(` <top_hubs count="${input.topHubs.length}">`);
9189
+ for (const h of input.topHubs) {
9190
+ lines.push(` <hub reason="${h.reason}" name="${escapeXML26(h.name)}" />`);
9191
+ }
9192
+ lines.push(" </top_hubs>");
9193
+ }
9194
+ lines.push(" <suggested_first_tool>");
9195
+ lines.push(` <tool>${escapeXML26(input.suggested.tool)}</tool>`);
9196
+ lines.push(` <why>${escapeXML26(input.suggested.why)}</why>`);
9197
+ lines.push(` <estimated_tokens>${input.suggested.estimated_tokens}</estimated_tokens>`);
9198
+ if (input.suggested.args && Object.keys(input.suggested.args).length > 0) {
9199
+ lines.push(` <args>${escapeXML26(JSON.stringify(input.suggested.args))}</args>`);
9200
+ }
9201
+ lines.push(" </suggested_first_tool>");
9202
+ lines.push("</minimal_context>");
9203
+ return lines.join("\n");
9204
+ }
9205
+ function renderSkeleton(input) {
9206
+ const lines = ['<minimal_context format="skeleton">'];
9207
+ lines.push(
9208
+ ` <graph ready="${input.graphReady}" nodes="${input.nodes}" edges="${input.edges}" staleness="${input.staleness}" />`
9209
+ );
9210
+ lines.push(` <recent_changes count="${input.recentChanges.length}" />`);
9211
+ lines.push(` <top_hubs count="${input.topHubs.length}" />`);
9212
+ lines.push(" <suggested_first_tool>");
9213
+ lines.push(` <tool>${escapeXML26(input.suggested.tool)}</tool>`);
9214
+ lines.push(` <why>${escapeXML26(input.suggested.why)}</why>`);
9215
+ lines.push(` <estimated_tokens>${input.suggested.estimated_tokens}</estimated_tokens>`);
9216
+ lines.push(" </suggested_first_tool>");
9217
+ lines.push("</minimal_context>");
9218
+ return lines.join("\n");
9219
+ }
9220
+ function registerMinimalContextTool(registry, ctx) {
9221
+ registry.register(
9222
+ "ctx_get_minimal_context",
9223
+ {
9224
+ name: "ctx_get_minimal_context",
9225
+ description: 'Orientation anchor \u2014 call this FIRST in any workflow. Returns ~150 tokens covering graph readiness, recent working-tree changes, top hub nodes, and a task-aware suggested-first-tool. Cached for 10s. Pass `task` as a free-text intent description ("review PR 142", "rename X", "check coverage") and the tool routes to the most-fitting follow-up. The agent should call the suggested tool next rather than guessing.',
9226
+ inputSchema: {
9227
+ type: "object",
9228
+ properties: {
9229
+ task: {
9230
+ type: "string",
9231
+ description: "Free-text intent (max 200 chars). Routes the suggested-first-tool by regex. Keywords: review/audit, rename/refactor, blast/impact, architect/explore, test/coverage."
9232
+ },
9233
+ project_root: PROJECT_ROOT_JSON_SCHEMA,
9234
+ max_response_tokens: {
9235
+ type: "number",
9236
+ description: "Optional response token budget. Default 250."
9237
+ },
9238
+ on_budget_exceeded: {
9239
+ type: "string",
9240
+ enum: ["skeleton", "truncate", "error"]
9241
+ },
9242
+ response_format: {
9243
+ type: "string",
9244
+ enum: ["full", "skeleton", "auto"]
9245
+ }
9246
+ }
9247
+ }
9248
+ },
9249
+ async (args) => {
9250
+ const parsed = Schema30.parse(args);
9251
+ const task = sanitizeTask(parsed.task);
9252
+ const projectRoot = parsed.project_root ?? ctx.projectRoot;
9253
+ const key = cacheKey(projectRoot, task);
9254
+ const cached = cacheGet(key);
9255
+ if (cached) {
9256
+ return cached;
9257
+ }
9258
+ const graphReady = ctx.isGraphInitialized();
9259
+ let nodes = 0;
9260
+ let edges = 0;
9261
+ let lastBuildIso = null;
9262
+ const languages = [];
9263
+ let topHubs = [];
9264
+ if (graphReady) {
9265
+ try {
9266
+ const graph = await ctx.getGraph(projectRoot);
9267
+ nodes = graph.allFiles().length;
9268
+ edges = graph.allFiles().reduce((acc, f) => acc + graph.getImports(f).length, 0);
9269
+ const extensions = /* @__PURE__ */ new Set();
9270
+ for (const f of graph.allFiles()) {
9271
+ const ext = f.split(".").pop();
9272
+ if (ext && ext.length <= 4) extensions.add(ext);
9273
+ }
9274
+ languages.push(...Array.from(extensions).sort());
9275
+ topHubs = computeTopHubs(graph);
9276
+ } catch {
9277
+ }
9278
+ }
9279
+ const recentChanges = readRecentChanges(projectRoot);
9280
+ const suggested = routeFirstTool(task, recentChanges.length > 0);
9281
+ const staleness = classifyStaleness(lastBuildIso);
9282
+ const renderInput = {
9283
+ graphReady,
9284
+ nodes,
9285
+ edges,
9286
+ lastBuildIso,
9287
+ staleness,
9288
+ languages,
9289
+ recentChanges,
9290
+ topHubs,
9291
+ suggested
9292
+ };
9293
+ const full = render(renderInput);
9294
+ if (!hasBudgetArgs(parsed)) {
9295
+ cachePut(key, full);
9296
+ return full;
9297
+ }
9298
+ const result = await enforceBudget({
9299
+ ctx,
9300
+ full,
9301
+ args: readBudgetArgs(parsed),
9302
+ toolName: "ctx_get_minimal_context",
9303
+ defaultMaxTokens: DEFAULT_MAX_RESPONSE_TOKENS13,
9304
+ skeletonProducer: async () => renderSkeleton(renderInput)
9305
+ });
9306
+ const body = wrapResponse(result);
9307
+ cachePut(key, body);
9308
+ return body;
9309
+ }
9310
+ );
9311
+ }
9312
+
9313
+ // packages/core/src/tools/index.ts
9314
+ function createToolRegistry(ctx) {
9315
+ const registry = new ToolRegistry();
9316
+ registerSearchTool(registry, ctx);
9317
+ registerFileTool(registry, ctx);
9318
+ registerContextPacketTool(registry, ctx);
9319
+ registerCallGraphTool(registry, ctx);
9320
+ registerDefinitionTool(registry, ctx);
9321
+ registerRulesTool(registry, ctx);
9322
+ registerRulesCheckTool(registry, ctx);
9323
+ registerSimilarFilesTool(registry, ctx);
9324
+ registerStatusTool(registry, ctx);
9325
+ registerBlastRadiusTool(registry, ctx);
9326
+ registerHubNodesTool(registry, ctx);
9327
+ registerBridgeNodesTool(registry, ctx);
9328
+ registerCommunityListTool(registry, ctx);
9329
+ registerArchitectureOverviewTool(registry, ctx);
9330
+ registerKnowledgeGapsTool(registry, ctx);
9331
+ registerSurprisingConnectionsTool(registry, ctx);
9332
+ registerWikiGenerateTool(registry, ctx);
9333
+ registerGraphExportTool(registry, ctx);
9334
+ registerGitDiffReviewTool(registry, ctx);
9335
+ registerRefactorPreviewTool(registry, ctx);
9336
+ registerExecutionFlowTool(registry, ctx);
9337
+ registerCrossRepoSearchTool(registry, ctx);
9338
+ registerApplyRefactorTool(registry, ctx);
9339
+ registerDetectChangesTool(registry, ctx);
9340
+ registerFullTextSearchTool(registry, ctx);
9341
+ registerSuggestedQuestionsTool(registry, ctx);
9342
+ registerGetWorkflowTool(registry, ctx);
9343
+ registerGraphSnapshotTool(registry, ctx);
9344
+ registerGraphDiffTool(registry, ctx);
9345
+ registerFindLargeFunctionsTool(registry, ctx);
9346
+ registerGitCouplingTool(registry, ctx);
9347
+ registerRiskOverlayTool(registry, ctx);
9348
+ registerGetAffectedFlowsTool(registry, ctx);
9349
+ registerMinimalContextTool(registry, ctx);
9350
+ return registry;
9351
+ }
9352
+
9353
+ // packages/core/src/tools/ruleManager.ts
9354
+ import fs22 from "fs";
9355
+ import path24 from "path";
9356
+ var RULE_FILES = [
9357
+ ".cursorrules",
9358
+ "CLAUDE.md",
9359
+ "CONTEXT.md",
9360
+ ".ctxloomrc",
9361
+ ".cursor/rules",
9362
+ ".claude/CLAUDE.md"
9363
+ ];
9364
+ var RuleManager = class {
9365
+ projectRoot;
9366
+ pathValidator;
9367
+ cachedRules = null;
9368
+ constructor(projectRoot, pathValidator) {
9369
+ this.projectRoot = projectRoot;
9370
+ this.pathValidator = pathValidator;
9371
+ }
9372
+ /**
9373
+ * Scan for all rule files in the project root.
8531
9374
  */
8532
9375
  async loadRules() {
8533
9376
  if (this.cachedRules) return this.cachedRules;
@@ -9136,20 +9979,20 @@ import { readFileSync, writeFileSync, unlinkSync, mkdirSync, chmodSync, existsSy
9136
9979
  import path29 from "path";
9137
9980
 
9138
9981
  // packages/core/src/license/types.ts
9139
- import { z as z36 } from "zod";
9982
+ import { z as z37 } from "zod";
9140
9983
  var FINGERPRINT_RE = /^sha256:[0-9a-f]{64}$/;
9141
- var LicenseFileSchema = z36.object({
9142
- schemaVersion: z36.literal(1),
9143
- key: z36.string().min(1),
9144
- tier: z36.enum(["pro", "team", "enterprise", "trial"]),
9145
- status: z36.enum(["active", "trialing", "expired"]),
9146
- fingerprint: z36.string().regex(FINGERPRINT_RE),
9147
- seats: z36.number().int().positive(),
9148
- issuedAt: z36.string().datetime(),
9149
- expiresAt: z36.string().datetime(),
9150
- lastValidatedAt: z36.string().datetime(),
9151
- licenseId: z36.string().min(1),
9152
- instanceId: z36.string().min(1)
9984
+ var LicenseFileSchema = z37.object({
9985
+ schemaVersion: z37.literal(1),
9986
+ key: z37.string().min(1),
9987
+ tier: z37.enum(["pro", "team", "enterprise", "trial"]),
9988
+ status: z37.enum(["active", "trialing", "expired"]),
9989
+ fingerprint: z37.string().regex(FINGERPRINT_RE),
9990
+ seats: z37.number().int().positive(),
9991
+ issuedAt: z37.string().datetime(),
9992
+ expiresAt: z37.string().datetime(),
9993
+ lastValidatedAt: z37.string().datetime(),
9994
+ licenseId: z37.string().min(1),
9995
+ instanceId: z37.string().min(1)
9153
9996
  });
9154
9997
 
9155
9998
  // packages/core/src/license/LicenseStore.ts
@@ -9362,7 +10205,7 @@ var TELEMETRY_DISABLED = TELEMETRY_LEVEL === "off";
9362
10205
  function getTelemetryLevel() {
9363
10206
  return TELEMETRY_LEVEL;
9364
10207
  }
9365
- var CTXLOOM_VERSION = "1.3.1".length > 0 ? "1.3.1" : "dev";
10208
+ var CTXLOOM_VERSION = "1.5.0".length > 0 ? "1.5.0" : "dev";
9366
10209
  var POSTHOG_HOST = "https://eu.i.posthog.com";
9367
10210
  var POSTHOG_KEY = process.env["POSTHOG_API_KEY"] ?? (true ? "phc_CiDkmFLcZ2K6uCpcoSUQLmFrnnUvsyXGhSxopX5TVKE6" : "");
9368
10211
  var SENTRY_DSN = process.env["SENTRY_DSN"] ?? (true ? "https://81c94a0f04a8e242dee493ac1e17f733@o4508531702497280.ingest.de.sentry.io/4511256875368528" : "");
@@ -9986,6 +10829,845 @@ var EmittedOnceTracker = class {
9986
10829
  }
9987
10830
  };
9988
10831
 
10832
+ // packages/core/src/install/installer.ts
10833
+ import fs28 from "fs";
10834
+ import path36 from "path";
10835
+
10836
+ // packages/core/src/install/templates.ts
10837
+ var RULES_BLOCK_NAME = "CTXLOOM-RULES";
10838
+ var RULES_BLOCK_CONTENT = `## MCP Tools: ctxloom
10839
+
10840
+ **IMPORTANT: This project has a knowledge graph. ALWAYS use the
10841
+ ctxloom MCP tools BEFORE Grep/Glob/Read to explore the codebase.**
10842
+ The graph is faster, cheaper (fewer tokens), and gives you
10843
+ structural context (callers, dependents, test coverage) that file
10844
+ scanning cannot.
10845
+
10846
+ ### Start every workflow with \`ctx_get_minimal_context\`
10847
+
10848
+ The first MCP call into ctxloom should always be
10849
+ \`ctx_get_minimal_context(task="<what you're about to do>")\`. It
10850
+ returns ~150 tokens of orientation plus a task-aware
10851
+ \`suggested_first_tool\` you should call next instead of guessing.
10852
+
10853
+ ### When to use graph tools FIRST
10854
+
10855
+ - **Code review**: \`ctx_detect_changes\` + \`ctx_get_review_context\`
10856
+ instead of reading whole files
10857
+ - **Understanding impact**: \`ctx_blast_radius\` + \`ctx_get_affected_flows\`
10858
+ instead of manually tracing imports
10859
+ - **Refactor planning**: \`ctx_get_call_graph(direction: 'callers')\`
10860
+ + \`ctx_refactor_preview\` before any rename
10861
+ - **Architecture questions**: \`ctx_architecture_overview\`,
10862
+ \`ctx_community_list\`, \`ctx_hub_nodes\`
10863
+ - **Finding code**: \`ctx_search\` or \`ctx_full_text_search\` instead
10864
+ of \`Grep\`
10865
+
10866
+ Fall back to Grep/Glob/Read **only** when the graph doesn't cover
10867
+ what you need.
10868
+
10869
+ ### Follow the \`next_tool_suggestions\` in every response
10870
+
10871
+ Every budget-wrapped ctxloom response includes
10872
+ \`meta.next_tool_suggestions\` \u2014 author-curated follow-ups with
10873
+ \`why\` reasoning and \`estimated_tokens\` per entry. Pick from
10874
+ those instead of guessing.
10875
+
10876
+ ### Token-budget protocol
10877
+
10878
+ - Target: \u22648 tool calls per task, \u22642000 total tokens of graph context
10879
+ - Pass \`max_response_tokens\` on calls that might return large
10880
+ responses; the budget surface returns skeletons instead of dumping
10881
+ - Use \`response_format: 'skeleton'\` when you know you only need
10882
+ signatures, not bodies
10883
+
10884
+ ### Key tools at a glance
10885
+
10886
+ | Tool | Use when |
10887
+ |------|----------|
10888
+ | \`ctx_get_minimal_context\` | START HERE \u2014 orientation anchor |
10889
+ | \`ctx_detect_changes\` | Reviewing code changes; risk-scored |
10890
+ | \`ctx_get_review_context\` | Token-efficient review snippets |
10891
+ | \`ctx_blast_radius\` | Blast radius of a change |
10892
+ | \`ctx_get_affected_flows\` | Execution paths impacted |
10893
+ | \`ctx_get_call_graph\` | Callers / callees of a symbol |
10894
+ | \`ctx_search\` / \`ctx_full_text_search\` | Find code |
10895
+ | \`ctx_architecture_overview\` | High-level codebase map |
10896
+ | \`ctx_refactor_preview\` / \`ctx_apply_refactor\` | Plan a rename |
10897
+
10898
+ ### Hooks keep the graph fresh
10899
+
10900
+ \`ctxloom init\` installed a PostToolUse hook on \`Write|Edit\` that
10901
+ runs \`ctxloom update --incremental --quiet\` \u2014 so the graph is
10902
+ always up to date when you query it. No "did the index update yet?"
10903
+ guessing.`;
10904
+ var SESSION_START_HEADER = `#!/usr/bin/env bash
10905
+ # ctxloom \u2014 agent-harness session-start hook
10906
+ # Generated by \`ctxloom init\`. Re-run \`ctxloom init\` to update.
10907
+ # Manual edits will be overwritten on the next install.
10908
+
10909
+ set -e
10910
+ `;
10911
+ var SESSION_START_BODY = `DB=".ctxloom/graph.db"
10912
+
10913
+ if [ -f "$DB" ]; then
10914
+ # \`ctxloom status --json\` is cached + sub-100ms \u2014 keeps the hook
10915
+ # under its 2s timeout even on cold disk.
10916
+ STATS=$(ctxloom status --json 2>/dev/null || echo '{"nodes":0,"edges":0}')
10917
+ NODES=$(echo "$STATS" | grep -oE '"nodes":\\s*[0-9]+' | grep -oE '[0-9]+' || echo "?")
10918
+ EDGES=$(echo "$STATS" | grep -oE '"edges":\\s*[0-9]+' | grep -oE '[0-9]+' || echo "?")
10919
+
10920
+ cat <<EOF
10921
+ [ctxloom] Knowledge graph ready (\${NODES} nodes, \${EDGES} edges).
10922
+
10923
+ Start every workflow with \\\`ctx_get_minimal_context(task="...")\\\`.
10924
+ It returns ~150 tokens of orientation + a task-aware
10925
+ suggested_first_tool. Follow the meta.next_tool_suggestions on
10926
+ every response.
10927
+
10928
+ Prefer ctxloom MCP tools over Grep/Glob/Read:
10929
+ - ctx_detect_changes for code review
10930
+ - ctx_blast_radius / ctx_get_call_graph before refactoring
10931
+ - ctx_architecture_overview for orientation
10932
+ EOF
10933
+ else
10934
+ cat <<EOF
10935
+ [ctxloom] No knowledge graph found here.
10936
+ Run: ctxloom build
10937
+
10938
+ Then restart this session to enable graph-powered queries.
10939
+ EOF
10940
+ fi
10941
+ `;
10942
+ var SESSION_START_FULL = SESSION_START_HEADER + "\n" + SESSION_START_BODY;
10943
+ var CTXLOOM_HOOK_ENTRIES = {
10944
+ SessionStart: {
10945
+ matcher: "",
10946
+ hooks: [{ type: "command", command: ".claude/hooks/session-start.sh", timeout: 2 }]
10947
+ },
10948
+ PostToolUse: {
10949
+ matcher: "Write|Edit",
10950
+ hooks: [
10951
+ {
10952
+ type: "command",
10953
+ command: "ctxloom update --incremental --quiet",
10954
+ timeout: 30
10955
+ }
10956
+ ]
10957
+ }
10958
+ };
10959
+ function plainBody() {
10960
+ return RULES_BLOCK_CONTENT.replace(/^##\s+/gm, "").replace(/^###\s+/gm, "").replace(/^####\s+/gm, "").replace(/\*\*(.*?)\*\*/g, "$1").replace(/`([^`]+)`/g, "$1");
10961
+ }
10962
+ var CURSOR_HEADER = `# ctxloom \u2014 Cursor agent rules
10963
+ # Generated by \`ctxloom init --host=cursor\`. Re-run to update.
10964
+
10965
+ `;
10966
+ var AIDER_HEADER = `# Project Conventions
10967
+
10968
+ Generated by \`ctxloom init --host=aider\`. Re-run to update.
10969
+
10970
+ `;
10971
+ var COPILOT_HEADER = `# ctxloom \u2014 GitHub Copilot instructions
10972
+
10973
+ Generated by \`ctxloom init --host=copilot\`. Re-run to update.
10974
+
10975
+ `;
10976
+ var WINDSURF_HEADER = `# ctxloom \u2014 Windsurf agent rules
10977
+ # Generated by \`ctxloom init --host=windsurf\`. Re-run to update.
10978
+
10979
+ `;
10980
+ var HOST_ADAPTERS = [
10981
+ // Note: claude / agents / gemini are NOT defined as HostAdapters in
10982
+ // this list — they predate the matrix and live in their own writeRulesBlock
10983
+ // path with HMAC-wrapped Markdown blocks. Future refactor may unify.
10984
+ {
10985
+ id: "cursor",
10986
+ path: ".cursorrules",
10987
+ defaultEnabled: false,
10988
+ render: () => CURSOR_HEADER + plainBody() + "\n",
10989
+ isCanonical(current) {
10990
+ return current === this.render();
10991
+ }
10992
+ },
10993
+ {
10994
+ id: "aider",
10995
+ path: "CONVENTIONS.md",
10996
+ defaultEnabled: false,
10997
+ render: () => AIDER_HEADER + RULES_BLOCK_CONTENT + "\n",
10998
+ isCanonical(current) {
10999
+ return current === this.render();
11000
+ }
11001
+ },
11002
+ {
11003
+ id: "copilot",
11004
+ path: ".github/copilot-instructions.md",
11005
+ defaultEnabled: false,
11006
+ render: () => COPILOT_HEADER + RULES_BLOCK_CONTENT + "\n",
11007
+ isCanonical(current) {
11008
+ return current === this.render();
11009
+ }
11010
+ },
11011
+ {
11012
+ id: "windsurf",
11013
+ path: ".windsurfrules",
11014
+ defaultEnabled: false,
11015
+ render: () => WINDSURF_HEADER + plainBody() + "\n",
11016
+ isCanonical(current) {
11017
+ return current === this.render();
11018
+ }
11019
+ }
11020
+ ];
11021
+ function getHostAdapter(id) {
11022
+ return HOST_ADAPTERS.find((h) => h.id === id);
11023
+ }
11024
+ var SUPPORTED_HOST_IDS = HOST_ADAPTERS.map((h) => h.id);
11025
+
11026
+ // packages/core/src/install/hmacBlock.ts
11027
+ import crypto6 from "crypto";
11028
+ var DEFAULT_HMAC_KEY = "ctxloom-agent-harness-v1-published";
11029
+ function resolveHmacKey() {
11030
+ return process.env.CTXLOOM_INSTALL_KEY ?? DEFAULT_HMAC_KEY;
11031
+ }
11032
+ function computeBlockHmac(content, key = resolveHmacKey()) {
11033
+ return crypto6.createHmac("sha256", key).update(content, "utf-8").digest("hex");
11034
+ }
11035
+ function wrapBlock(name, content) {
11036
+ const hmac = computeBlockHmac(content);
11037
+ return [
11038
+ `<!-- BEGIN ${name} v:1 hmac:sha256:${hmac} -->`,
11039
+ content,
11040
+ `<!-- END ${name} -->`
11041
+ ].join("\n");
11042
+ }
11043
+ var START_RE_TEMPLATE = (name) => new RegExp(`<!-- BEGIN ${escapeRegex(name)} v:(\\d+) hmac:sha256:([0-9a-f]{64}) -->`);
11044
+ var END_RE_TEMPLATE = (name) => new RegExp(`<!-- END ${escapeRegex(name)} -->`);
11045
+ function escapeRegex(s) {
11046
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
11047
+ }
11048
+ function extractBlock(fileContent, name) {
11049
+ const startRe = START_RE_TEMPLATE(name);
11050
+ const endRe = END_RE_TEMPLATE(name);
11051
+ const startMatch = startRe.exec(fileContent);
11052
+ if (!startMatch) return null;
11053
+ const startIdx = startMatch.index;
11054
+ const afterStart = startIdx + startMatch[0].length;
11055
+ endRe.lastIndex = afterStart;
11056
+ const endMatch = endRe.exec(fileContent.slice(afterStart));
11057
+ if (!endMatch) return null;
11058
+ const endIdx = afterStart + endMatch.index + endMatch[0].length;
11059
+ let inner = fileContent.slice(afterStart, afterStart + endMatch.index);
11060
+ if (inner.startsWith("\n")) inner = inner.slice(1);
11061
+ if (inner.endsWith("\n")) inner = inner.slice(0, -1);
11062
+ return {
11063
+ content: inner,
11064
+ declaredHmac: startMatch[2],
11065
+ version: Number(startMatch[1]),
11066
+ start: startIdx,
11067
+ end: endIdx
11068
+ };
11069
+ }
11070
+ function verifyBlock(block) {
11071
+ return computeBlockHmac(block.content) === block.declaredHmac;
11072
+ }
11073
+ function upsertBlock(fileContent, name, newContent) {
11074
+ const wrapped = wrapBlock(name, newContent);
11075
+ const existing = extractBlock(fileContent, name);
11076
+ if (existing) {
11077
+ return fileContent.slice(0, existing.start) + wrapped + fileContent.slice(existing.end);
11078
+ }
11079
+ const sep = fileContent.length === 0 || fileContent.endsWith("\n\n") ? "" : fileContent.endsWith("\n") ? "\n" : "\n\n";
11080
+ return fileContent + sep + wrapped + "\n";
11081
+ }
11082
+
11083
+ // packages/core/src/install/skillTemplates.ts
11084
+ var EXPLORE_CONTENT = `---
11085
+ name: ctxloom-explore
11086
+ description: Orient yourself to an unfamiliar codebase using ctxloom's structural graph. Architecture overview + communities + top hubs in \u22645 tool calls.
11087
+ ---
11088
+
11089
+ # Explore Codebase
11090
+
11091
+ Use this when you need to understand a codebase you haven't worked in
11092
+ before, or when re-orienting after time away.
11093
+
11094
+ ## Steps
11095
+
11096
+ 1. **Orientation anchor**: call \`ctx_get_minimal_context(task="explore this codebase")\`.
11097
+ The response includes graph stats, top hubs, and a
11098
+ \`suggested_first_tool\` \u2014 follow it (likely
11099
+ \`ctx_architecture_overview\`).
11100
+
11101
+ 2. **Architecture overview**: call \`ctx_architecture_overview(max_response_tokens=2000)\`.
11102
+ Returns the community structure + hub nodes + cross-community bridges.
11103
+
11104
+ 3. **Drill into the biggest communities**: from the overview's
11105
+ \`meta.next_tool_suggestions\`, call \`ctx_community_list\` for the
11106
+ top 1\u20132 communities. Skip communities labeled "tests" or
11107
+ "config" \u2014 they're usually peripheral.
11108
+
11109
+ 4. **Investigate the architectural bridges**: call \`ctx_bridge_nodes\`.
11110
+ Bridge nodes are high-leverage \u2014 changing one affects multiple
11111
+ communities. Read these first to understand the codebase's
11112
+ coupling story.
11113
+
11114
+ 5. **Tour the hubs**: call \`ctx_hub_nodes(limit=5, detail_level="minimal")\`.
11115
+ Top 5 most-depended-upon files. These are usually the heart of
11116
+ the codebase.
11117
+
11118
+ ## Budget
11119
+
11120
+ - \u22645 ctxloom tool calls
11121
+ - \u22642000 tokens total response budget
11122
+ - Don't \`ctx_get_file\` anything during exploration \u2014 signatures are
11123
+ enough. Drop to file reads only if a specific symbol needs
11124
+ inspection (and only via \`ctx_get_definition\`, not raw read).
11125
+
11126
+ ## Output
11127
+
11128
+ Summarize for the user:
11129
+ - Main communities (3\u20135 named clusters)
11130
+ - Top hubs by fan-in (the load-bearing files)
11131
+ - Top bridges (the architectural seams)
11132
+ - Recommended deep-dive starting points
11133
+ `;
11134
+ var BLAST_CONTENT = `---
11135
+ name: ctxloom-blast
11136
+ description: Compute blast radius + affected execution flows for a symbol or file before changing it. Pinpoints what will break.
11137
+ argument-hint: "<symbol-name | file-path>"
11138
+ ---
11139
+
11140
+ # Blast Radius
11141
+
11142
+ Use this before any change to a public function, type, or file
11143
+ where you're not sure who depends on it.
11144
+
11145
+ ## Inputs
11146
+
11147
+ - \`$ARGUMENTS\` \u2014 the symbol name (e.g. \`emitTelemetry\`) or file
11148
+ path (e.g. \`src/server.ts\`) you're about to modify.
11149
+
11150
+ ## Steps
11151
+
11152
+ 1. **Orientation**: call \`ctx_get_minimal_context(task="blast radius for $ARGUMENTS")\`.
11153
+
11154
+ 2. **Blast radius**: call \`ctx_blast_radius(target="$ARGUMENTS", max_response_tokens=1500)\`.
11155
+ Returns transitive dependents \u2014 every file (and indirectly,
11156
+ every flow) that would be affected by a breaking change.
11157
+
11158
+ 3. **Caller graph**: call \`ctx_get_call_graph(symbol="$ARGUMENTS", direction="callers", depth=2)\`.
11159
+ Direct + grandparent callers. Pair with the blast radius
11160
+ to distinguish "many transitive deps" from "load-bearing
11161
+ direct API."
11162
+
11163
+ 4. **Affected execution flows**: call \`ctx_get_affected_flows(target="$ARGUMENTS")\`.
11164
+ Maps the change to ordered execution sequences \u2014 useful for
11165
+ debugging "what user-facing path breaks?"
11166
+
11167
+ 5. **Test coverage check**: call \`ctx_knowledge_gaps(scope="$ARGUMENTS")\`.
11168
+ Highlights affected files lacking test coverage \u2014 those are the
11169
+ real risk surface.
11170
+
11171
+ ## Budget
11172
+
11173
+ - \u22645 ctxloom tool calls
11174
+ - \u22642000 tokens total
11175
+
11176
+ ## Output
11177
+
11178
+ Report to the user:
11179
+ - Total transitive dependents (number + top 5 by depth)
11180
+ - Direct callers (the API consumers)
11181
+ - Affected execution flows (named user-facing paths)
11182
+ - Coverage gaps on the affected files (the risk surface)
11183
+ - Recommendation: "safe to change" / "review carefully" / "needs migration plan"
11184
+ `;
11185
+ var REFACTOR_CONTENT = `---
11186
+ name: ctxloom-refactor-safely
11187
+ description: Plan and execute a rename or signature change with full caller-aware safety. Preview before applying.
11188
+ argument-hint: "<old-name> <new-name>"
11189
+ ---
11190
+
11191
+ # Refactor Safely
11192
+
11193
+ Use this for renames, signature changes, or function moves. The
11194
+ skill enforces preview-before-apply.
11195
+
11196
+ ## Inputs
11197
+
11198
+ - \`$1\` \u2014 current symbol name (e.g. \`emitTelemetry\`)
11199
+ - \`$2\` \u2014 target name (e.g. \`emitTelemetryEvent\`)
11200
+
11201
+ ## Steps
11202
+
11203
+ 1. **Orientation**: call \`ctx_get_minimal_context(task="refactor $1 to $2")\`.
11204
+
11205
+ 2. **Surface every caller**: call \`ctx_get_call_graph(symbol="$1", direction="callers", depth=1)\`.
11206
+ The exhaustive caller list. Without this, a rename can break
11207
+ files you didn't notice depended on the symbol.
11208
+
11209
+ 3. **Blast radius**: call \`ctx_blast_radius(target="$1")\`.
11210
+ Transitive impact \u2014 useful to gauge whether this should be a
11211
+ single PR or split with a deprecation period.
11212
+
11213
+ 4. **Generate the refactor preview**: call \`ctx_refactor_preview(symbol="$1", new_name="$2")\`.
11214
+ Returns a diff preview WITHOUT writing anything. Inspect it.
11215
+
11216
+ 5. **Confirm with the user**: show the preview summary
11217
+ (N files changed, M call sites updated). **DO NOT proceed to
11218
+ step 6 without explicit user confirmation.**
11219
+
11220
+ 6. **Apply the refactor**: call \`ctx_apply_refactor(symbol="$1", new_name="$2")\`.
11221
+ This writes the changes to disk. Irreversible without a git
11222
+ reset.
11223
+
11224
+ 7. **Verify**: call \`ctx_detect_changes\` and confirm the diff
11225
+ matches what the preview said. Surface any unexpected changes.
11226
+
11227
+ ## Safety rails
11228
+
11229
+ - ALWAYS run step 4 (preview) before step 6 (apply)
11230
+ - ALWAYS ask the user for confirmation between preview and apply
11231
+ - If preview shows >50 files affected, recommend splitting into
11232
+ a deprecation-style migration instead of a single rename
11233
+ - If the symbol is exported from a public API package, refuse to
11234
+ proceed and recommend the user open a tracked migration plan
11235
+
11236
+ ## Budget
11237
+
11238
+ - \u22647 ctxloom tool calls
11239
+ - \u22643000 tokens total (preview output can be larger than other skills)
11240
+ `;
11241
+ var COVERAGE_GAP_CONTENT = `---
11242
+ name: ctxloom-coverage-gap
11243
+ description: Identify code that lacks test coverage, prioritized by caller frequency and risk.
11244
+ ---
11245
+
11246
+ # Coverage Gap Analysis
11247
+
11248
+ Use this to find untested code that genuinely matters \u2014 the
11249
+ intersection of "no tests" + "many callers" + "high risk score."
11250
+
11251
+ ## Steps
11252
+
11253
+ 1. **Orientation**: call \`ctx_get_minimal_context(task="check test coverage")\`.
11254
+
11255
+ 2. **Knowledge gaps**: call \`ctx_knowledge_gaps(max_response_tokens=1200)\`.
11256
+ Lists every file lacking a \`tests_for\` graph edge. Raw list
11257
+ without prioritization.
11258
+
11259
+ 3. **Score by impact**: for each gap, call
11260
+ \`ctx_get_call_graph(symbol=<gap_symbol>, direction="callers")\`
11261
+ to count callers. High caller-count + no tests = high priority.
11262
+
11263
+ 4. **Cross-reference with churn**: call
11264
+ \`ctx_git_coupling(file=<gap_file>)\` for the top 5 gaps.
11265
+ Files churning often without tests are the urgent ones.
11266
+
11267
+ 5. **Risk overlay**: call \`ctx_risk_overlay(scope=<top_gap_files>)\`.
11268
+ Combines churn + coupling into a single risk score.
11269
+
11270
+ ## Output
11271
+
11272
+ Tabular report:
11273
+
11274
+ \`\`\`
11275
+ | File / Symbol | Callers | Churn | Risk | Recommendation |
11276
+ |---|---|---|---|---|
11277
+ | ... | ... | ... | ... | ... |
11278
+ \`\`\`
11279
+
11280
+ Recommendations should distinguish:
11281
+ - "Add tests now" (high caller count + high churn)
11282
+ - "Add tests during next change" (high caller count, low churn)
11283
+ - "Acceptable gap" (low caller count, low churn)
11284
+
11285
+ ## Budget
11286
+
11287
+ - \u22646 ctxloom tool calls
11288
+ - \u22642500 tokens total
11289
+ `;
11290
+ var REVIEW_PR_CONTENT = `---
11291
+ name: ctxloom-review-pr
11292
+ description: Multi-tier code review of a PR using ctxloom's structural graph. Risk-scored, blast-radius-aware, coverage-conscious.
11293
+ argument-hint: "<PR number | branch name>"
11294
+ ---
11295
+
11296
+ # Review PR
11297
+
11298
+ Comprehensive PR review using ctxloom's graph. Mirrors the
11299
+ multi-agent review the ctxloom-bot posts automatically \u2014 useful
11300
+ when reviewing manually or when the bot isn't wired up.
11301
+
11302
+ ## Inputs
11303
+
11304
+ - \`$ARGUMENTS\` \u2014 PR number (e.g. \`142\`) or branch name (e.g. \`feat/foo\`).
11305
+ Defaults to the current branch if unset.
11306
+
11307
+ ## Steps
11308
+
11309
+ 1. **Orientation**: call \`ctx_get_minimal_context(task="review PR $ARGUMENTS")\`.
11310
+
11311
+ 2. **Detect changes**: call \`ctx_detect_changes(base="main")\`.
11312
+ Risk-scored per-file analysis. Take the top 5 highest-risk files.
11313
+
11314
+ 3. **Pull source for the risky files**: call \`ctx_git_diff_review(base="main", max_response_tokens=4000)\`.
11315
+ Token-efficient diff packet covering the changed files.
11316
+
11317
+ 4. **Blast radius per high-risk file**: for the top 3 risky files,
11318
+ call \`ctx_blast_radius(target=<file>)\`. Surfaces files that the
11319
+ change indirectly affects but don't appear in the diff.
11320
+
11321
+ 5. **Affected flows**: call \`ctx_get_affected_flows(base="main")\`.
11322
+ Which execution paths the PR touches. Use to identify what
11323
+ integration tests should pass.
11324
+
11325
+ 6. **Coverage check**: call \`ctx_knowledge_gaps(scope=<changed_files>)\`.
11326
+ Surface changed files lacking tests.
11327
+
11328
+ 7. **Generate the review**: structured output with:
11329
+ - Risk summary (low/medium/high overall)
11330
+ - File-by-file findings (severity-ranked)
11331
+ - Coverage gaps that need addressing
11332
+ - Blast-radius observations the diff doesn't show
11333
+
11334
+ ## Tier discipline
11335
+
11336
+ This skill is the agent-driven equivalent of the bot's
11337
+ multi-specialist review. Use the same tier ladder:
11338
+
11339
+ - **T0 (structural)**: use the tools listed above \u2014 they're
11340
+ pre-fetched and cheap
11341
+ - **T1 (skeleton)**: \`ctx_get_definition\` for individual symbols
11342
+ - **T2 (full body)**: \`ctx_get_file\` only for files where the
11343
+ skeleton view is insufficient
11344
+ - **T3 (raw read)**: avoid; if the graph can't answer the question,
11345
+ prefer \`ctx_git_diff_review\` (token-efficient diff packet) over
11346
+ raw \`Read\`
11347
+
11348
+ ## Budget
11349
+
11350
+ - \u22648 ctxloom tool calls
11351
+ - \u22645000 tokens total (review needs more headroom than other skills)
11352
+
11353
+ ## Output format
11354
+
11355
+ \`\`\`
11356
+ ## PR Review: <title>
11357
+
11358
+ ### Summary
11359
+ <1\u20133 sentence overview>
11360
+
11361
+ ### Risk Assessment
11362
+ - Overall: Low / Medium / High
11363
+ - Blast radius: X files, Y flows impacted
11364
+ - Coverage: N changed symbols covered / M total
11365
+
11366
+ ### Findings
11367
+
11368
+ #### <file_path>
11369
+ - **Severity**: ...
11370
+ - **Issue**: ...
11371
+ - **Suggested fix**: ...
11372
+
11373
+ ### Coverage Gaps
11374
+
11375
+ <table>
11376
+
11377
+ ### Suggested follow-ups
11378
+
11379
+ <list>
11380
+ \`\`\`
11381
+ `;
11382
+ var BUDGET_STATS_CONTENT = `---
11383
+ name: ctxloom-budget-stats
11384
+ description: Inspect ctxloom's per-tool budget telemetry \u2014 fallback distribution + original-token p50/p75/p95 \u2014 to tune defaults from real usage.
11385
+ ---
11386
+
11387
+ # Budget Stats
11388
+
11389
+ Wrapper around \`ctxloom budget-stats\` for inline use inside a
11390
+ Claude Code session. Useful when:
11391
+
11392
+ - Tuning per-tool \`DEFAULT_MAX_RESPONSE_TOKENS\` from real usage
11393
+ (the Phase B follow-up)
11394
+ - Diagnosing why a tool keeps falling back to skeleton mode
11395
+ - Understanding which tools dominate the user's token budget
11396
+
11397
+ ## Steps
11398
+
11399
+ 1. **Orientation**: call \`ctx_get_minimal_context(task="inspect budget telemetry")\`.
11400
+ Cheap (~150 tokens). Confirms the graph is wired up \u2014 if it's
11401
+ not, the user's MCP server probably can't emit budget events
11402
+ either, and the stats will be empty.
11403
+
11404
+ 3. **Window selection**: ask the user how far back to look
11405
+ (default: 14d). Accept \`1d\`, \`7d\`, \`14d\`, \`30d\`.
11406
+
11407
+ 4. **Optional tool filter**: ask if they want stats for a specific
11408
+ tool (e.g. \`ctx_get_file\`) or all tools.
11409
+
11410
+ 5. **Run the CLI**: \`Bash\`-tool execute:
11411
+ \`ctxloom budget-stats --window=<N>d [--tool=<name>]\`
11412
+
11413
+ 6. **Parse + summarize the output**:
11414
+ - Top 3 tools by breach count \u2192 these are the candidates for
11415
+ budget tuning
11416
+ - For each top tool, the p75 column is the suggested next
11417
+ \`DEFAULT_MAX_RESPONSE_TOKENS\` value (rationale: 75% of
11418
+ real-world calls fit under p75; the rest fall back gracefully
11419
+ to skeletons)
11420
+
11421
+ 7. **Suggest concrete edits**: for each top tool, point to the
11422
+ source file (\`packages/core/src/tools/<tool>.ts\`) and the
11423
+ current constant. Don't apply edits without user confirmation.
11424
+
11425
+ ## Budget
11426
+
11427
+ - \u22642 ctxloom tool calls (this skill is mostly Bash + parsing)
11428
+ - \u22641500 tokens response total
11429
+
11430
+ ## Output
11431
+
11432
+ \`\`\`
11433
+ ## Budget stats \u2014 <window>
11434
+
11435
+ ### Top tools by breach count
11436
+ 1. <tool>: N breaches, skeleton%, p75=<tokens>
11437
+ 2. ...
11438
+
11439
+ ### Suggested DEFAULT_MAX_RESPONSE_TOKENS tuning
11440
+ - packages/core/src/tools/<tool>.ts: <current> \u2192 <suggested-p75>
11441
+ (rationale: ...)
11442
+ \`\`\`
11443
+ `;
11444
+ var CTXLOOM_SKILLS = [
11445
+ { name: "ctxloom-explore", content: EXPLORE_CONTENT },
11446
+ { name: "ctxloom-blast", content: BLAST_CONTENT },
11447
+ { name: "ctxloom-refactor-safely", content: REFACTOR_CONTENT },
11448
+ { name: "ctxloom-coverage-gap", content: COVERAGE_GAP_CONTENT },
11449
+ { name: "ctxloom-review-pr", content: REVIEW_PR_CONTENT },
11450
+ { name: "ctxloom-budget-stats", content: BUDGET_STATS_CONTENT }
11451
+ ];
11452
+ function skillFilePath(name) {
11453
+ return `.claude/skills/${name}/SKILL.md`;
11454
+ }
11455
+
11456
+ // packages/core/src/install/installer.ts
11457
+ function installHarness(opts = {}) {
11458
+ const cwd = opts.cwd ?? process.cwd();
11459
+ const projectRoot = path36.resolve(cwd);
11460
+ const stat = fs28.statSync(projectRoot);
11461
+ if (!stat.isDirectory()) {
11462
+ throw new Error(`installHarness: ${projectRoot} is not a directory`);
11463
+ }
11464
+ const dryRun = opts.dryRun === true;
11465
+ const force = opts.force === true;
11466
+ const warnings = [];
11467
+ const claudeMd = writeRulesBlock(projectRoot, "CLAUDE.md", { dryRun, force, warnings });
11468
+ const agentsMd = writeRulesBlock(projectRoot, "AGENTS.md", { dryRun, force, warnings });
11469
+ const geminiMd = writeRulesBlock(projectRoot, "GEMINI.md", { dryRun, force, warnings });
11470
+ const hooksJson = writeHooksJson(projectRoot, { dryRun, warnings });
11471
+ const sessionStartSh = writeSessionStartScript(projectRoot, { dryRun });
11472
+ const skills = CTXLOOM_SKILLS.map((s) => writeSkill(projectRoot, s, { dryRun }));
11473
+ const extraHosts = resolveExtraHosts(opts.extraHosts ?? [], warnings).map(
11474
+ (adapter) => Object.assign(writeHostAdapter(projectRoot, adapter, { dryRun }), { hostId: adapter.id })
11475
+ );
11476
+ return {
11477
+ projectRoot,
11478
+ claudeMd,
11479
+ agentsMd,
11480
+ geminiMd,
11481
+ hooksJson,
11482
+ sessionStartSh,
11483
+ skills,
11484
+ extraHosts,
11485
+ warnings
11486
+ };
11487
+ }
11488
+ function resolveExtraHosts(ids, warnings) {
11489
+ const requested = /* @__PURE__ */ new Set();
11490
+ for (const raw of ids) {
11491
+ const id = raw.trim().toLowerCase();
11492
+ if (id === "") continue;
11493
+ if (id === "all") {
11494
+ for (const a of HOST_ADAPTERS) requested.add(a.id);
11495
+ continue;
11496
+ }
11497
+ if (!getHostAdapter(id)) {
11498
+ warnings.push(
11499
+ `Unknown --host id "${id}" \u2014 ignored. Supported: ${HOST_ADAPTERS.map((a) => a.id).join(", ")}, or "all".`
11500
+ );
11501
+ continue;
11502
+ }
11503
+ requested.add(id);
11504
+ }
11505
+ return HOST_ADAPTERS.filter((a) => requested.has(a.id));
11506
+ }
11507
+ function safeJoin(root, name) {
11508
+ const target = path36.resolve(root, name);
11509
+ const rootResolved = path36.resolve(root);
11510
+ if (!target.startsWith(rootResolved + path36.sep) && target !== rootResolved) {
11511
+ throw new Error(`installHarness: refusing to write outside project root: ${target}`);
11512
+ }
11513
+ return target;
11514
+ }
11515
+ function writeRulesBlock(projectRoot, filename, opts) {
11516
+ const filePath = safeJoin(projectRoot, filename);
11517
+ const existed = fs28.existsSync(filePath);
11518
+ const existing = existed ? fs28.readFileSync(filePath, "utf-8") : "";
11519
+ if (existed) {
11520
+ const block = extractBlock(existing, RULES_BLOCK_NAME);
11521
+ if (block) {
11522
+ const intact = verifyBlock(block);
11523
+ if (intact && block.content === RULES_BLOCK_CONTENT) {
11524
+ return { path: filePath, created: false, updated: false, alreadyCorrect: true, dryRun: opts.dryRun };
11525
+ }
11526
+ if (!intact && !opts.force) {
11527
+ opts.warnings.push(
11528
+ `Drift detected in ${filename}: the CTXLOOM-RULES block has been hand-edited. Re-run \`ctxloom init --force\` to overwrite, or commit your changes and re-run.`
11529
+ );
11530
+ return { path: filePath, created: false, updated: false, alreadyCorrect: false, dryRun: opts.dryRun };
11531
+ }
11532
+ }
11533
+ }
11534
+ const next = upsertBlock(existing, RULES_BLOCK_NAME, RULES_BLOCK_CONTENT);
11535
+ if (!opts.dryRun) {
11536
+ fs28.writeFileSync(filePath, next, "utf-8");
11537
+ }
11538
+ return {
11539
+ path: filePath,
11540
+ created: !existed,
11541
+ updated: existed,
11542
+ alreadyCorrect: false,
11543
+ dryRun: opts.dryRun
11544
+ };
11545
+ }
11546
+ function writeHooksJson(projectRoot, opts) {
11547
+ const dir = safeJoin(projectRoot, ".claude");
11548
+ const filePath = safeJoin(projectRoot, ".claude/hooks.json");
11549
+ const existed = fs28.existsSync(filePath);
11550
+ let current = {};
11551
+ if (existed) {
11552
+ try {
11553
+ const text = fs28.readFileSync(filePath, "utf-8");
11554
+ current = JSON.parse(text);
11555
+ } catch (err) {
11556
+ opts.warnings.push(
11557
+ `Could not parse existing ${path36.relative(projectRoot, filePath)}; treating as empty. (${err instanceof Error ? err.message : String(err)})`
11558
+ );
11559
+ current = {};
11560
+ }
11561
+ }
11562
+ const merged = { ...current };
11563
+ for (const event of ["SessionStart", "PostToolUse"]) {
11564
+ const incoming = CTXLOOM_HOOK_ENTRIES[event];
11565
+ const existingArr = Array.isArray(merged[event]) ? merged[event] : [];
11566
+ const filtered = existingArr.filter(
11567
+ (entry) => !isCtxloomEntry(entry, incoming.matcher)
11568
+ );
11569
+ merged[event] = [...filtered, incoming];
11570
+ }
11571
+ const nextJson = JSON.stringify(merged, null, 2) + "\n";
11572
+ let alreadyCorrect = false;
11573
+ if (existed) {
11574
+ const currentText = fs28.readFileSync(filePath, "utf-8");
11575
+ if (currentText === nextJson) alreadyCorrect = true;
11576
+ }
11577
+ if (!opts.dryRun && !alreadyCorrect) {
11578
+ fs28.mkdirSync(dir, { recursive: true });
11579
+ fs28.writeFileSync(filePath, nextJson, "utf-8");
11580
+ }
11581
+ return {
11582
+ path: filePath,
11583
+ created: !existed,
11584
+ updated: existed && !alreadyCorrect,
11585
+ alreadyCorrect,
11586
+ dryRun: opts.dryRun
11587
+ };
11588
+ }
11589
+ function isCtxloomEntry(entry, expectedMatcher) {
11590
+ if (entry.matcher !== expectedMatcher) return false;
11591
+ if (!Array.isArray(entry.hooks)) return false;
11592
+ return entry.hooks.some((h) => {
11593
+ if (!h || typeof h !== "object") return false;
11594
+ const cmd = h.command;
11595
+ if (typeof cmd !== "string") return false;
11596
+ return cmd.includes("ctxloom") || cmd.includes(".claude/hooks/session-start.sh");
11597
+ });
11598
+ }
11599
+ function writeHostAdapter(projectRoot, adapter, opts) {
11600
+ const filePath = safeJoin(projectRoot, adapter.path);
11601
+ const dir = path36.dirname(filePath);
11602
+ const existed = fs28.existsSync(filePath);
11603
+ const rendered = adapter.render();
11604
+ let alreadyCorrect = false;
11605
+ if (existed) {
11606
+ const current = fs28.readFileSync(filePath, "utf-8");
11607
+ if (adapter.isCanonical(current)) {
11608
+ alreadyCorrect = true;
11609
+ }
11610
+ }
11611
+ if (!opts.dryRun && !alreadyCorrect) {
11612
+ fs28.mkdirSync(dir, { recursive: true });
11613
+ fs28.writeFileSync(filePath, rendered, "utf-8");
11614
+ }
11615
+ return {
11616
+ path: filePath,
11617
+ created: !existed,
11618
+ updated: existed && !alreadyCorrect,
11619
+ alreadyCorrect,
11620
+ dryRun: opts.dryRun
11621
+ };
11622
+ }
11623
+ function writeSkill(projectRoot, skill, opts) {
11624
+ const dir = safeJoin(projectRoot, `.claude/skills/${skill.name}`);
11625
+ const filePath = safeJoin(projectRoot, skillFilePath(skill.name));
11626
+ const existed = fs28.existsSync(filePath);
11627
+ let alreadyCorrect = false;
11628
+ if (existed) {
11629
+ if (fs28.readFileSync(filePath, "utf-8") === skill.content) {
11630
+ alreadyCorrect = true;
11631
+ }
11632
+ }
11633
+ if (!opts.dryRun && !alreadyCorrect) {
11634
+ fs28.mkdirSync(dir, { recursive: true });
11635
+ fs28.writeFileSync(filePath, skill.content, "utf-8");
11636
+ }
11637
+ return {
11638
+ path: filePath,
11639
+ created: !existed,
11640
+ updated: existed && !alreadyCorrect,
11641
+ alreadyCorrect,
11642
+ dryRun: opts.dryRun
11643
+ };
11644
+ }
11645
+ function writeSessionStartScript(projectRoot, opts) {
11646
+ const dir = safeJoin(projectRoot, ".claude/hooks");
11647
+ const filePath = safeJoin(projectRoot, ".claude/hooks/session-start.sh");
11648
+ const existed = fs28.existsSync(filePath);
11649
+ let alreadyCorrect = false;
11650
+ if (existed) {
11651
+ const current = fs28.readFileSync(filePath, "utf-8");
11652
+ if (current === SESSION_START_FULL) alreadyCorrect = true;
11653
+ }
11654
+ if (!opts.dryRun && !alreadyCorrect) {
11655
+ fs28.mkdirSync(dir, { recursive: true });
11656
+ fs28.writeFileSync(filePath, SESSION_START_FULL, "utf-8");
11657
+ try {
11658
+ fs28.chmodSync(filePath, 493);
11659
+ } catch {
11660
+ }
11661
+ }
11662
+ return {
11663
+ path: filePath,
11664
+ created: !existed,
11665
+ updated: existed && !alreadyCorrect,
11666
+ alreadyCorrect,
11667
+ dryRun: opts.dryRun
11668
+ };
11669
+ }
11670
+
9989
11671
  export {
9990
11672
  GRAMMAR_MANIFEST,
9991
11673
  findGrammar,
@@ -10023,6 +11705,15 @@ export {
10023
11705
  loadFileRiskHistory,
10024
11706
  Skeletonizer,
10025
11707
  renderStatusXml,
11708
+ __resetLearnedSuggestionsCacheForTests,
11709
+ learnSuggestionsFromTelemetry,
11710
+ getLearnedRules,
11711
+ TaskBudgetTracker,
11712
+ getTaskBudgetTracker,
11713
+ __resetTaskBudgetTrackerForTests,
11714
+ OVER_BUDGET_ARG_OVERRIDES,
11715
+ applyOverBudgetOverrides,
11716
+ emitTaskBudgetBreached,
10026
11717
  ToolRegistry,
10027
11718
  detectChanges,
10028
11719
  getImpactRadius,
@@ -10090,6 +11781,23 @@ export {
10090
11781
  noParseableSourcesWarning,
10091
11782
  wrapWithIndexingEnvelope,
10092
11783
  FirstTouchTracker,
10093
- EmittedOnceTracker
11784
+ EmittedOnceTracker,
11785
+ RULES_BLOCK_NAME,
11786
+ RULES_BLOCK_CONTENT,
11787
+ SESSION_START_FULL,
11788
+ CTXLOOM_HOOK_ENTRIES,
11789
+ HOST_ADAPTERS,
11790
+ getHostAdapter,
11791
+ SUPPORTED_HOST_IDS,
11792
+ DEFAULT_HMAC_KEY,
11793
+ resolveHmacKey,
11794
+ computeBlockHmac,
11795
+ wrapBlock,
11796
+ extractBlock,
11797
+ verifyBlock,
11798
+ upsertBlock,
11799
+ CTXLOOM_SKILLS,
11800
+ skillFilePath,
11801
+ installHarness
10094
11802
  };
10095
- //# sourceMappingURL=chunk-TIYTPWYN.js.map
11803
+ //# sourceMappingURL=chunk-J2NLNQ4I.js.map