ctxloom-pro 1.3.0 → 1.4.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.
@@ -5,6 +5,9 @@ import {
5
5
  collectFiles,
6
6
  generateEmbedding
7
7
  } from "./chunk-UVR65QBJ.js";
8
+ import {
9
+ diskSink
10
+ } from "./chunk-5I6CJITG.js";
8
11
  import {
9
12
  logger
10
13
  } from "./chunk-TYDMSHV7.js";
@@ -3457,8 +3460,8 @@ var CoChangeIndex = class _CoChangeIndex {
3457
3460
  if (event.isBulk || event.isMerge) return;
3458
3461
  const paths = event.files.map((f) => f.path);
3459
3462
  if (paths.length === 0) return;
3460
- for (const path36 of paths) {
3461
- this.nodeCounts.set(path36, (this.nodeCounts.get(path36) ?? 0) + 1);
3463
+ for (const path37 of paths) {
3464
+ this.nodeCounts.set(path37, (this.nodeCounts.get(path37) ?? 0) + 1);
3462
3465
  }
3463
3466
  for (let i = 0; i < paths.length; i++) {
3464
3467
  for (let j = i + 1; j < paths.length; j++) {
@@ -3605,8 +3608,8 @@ var ChurnIndex = class _ChurnIndex {
3605
3608
  */
3606
3609
  snapshot() {
3607
3610
  const nodes = {};
3608
- for (const [path36, raw] of this.nodes) {
3609
- nodes[path36] = {
3611
+ for (const [path37, raw] of this.nodes) {
3612
+ nodes[path37] = {
3610
3613
  commits: raw.commits,
3611
3614
  churnLines: raw.churnLines,
3612
3615
  bugCommits: raw.bugCommits,
@@ -3621,8 +3624,8 @@ var ChurnIndex = class _ChurnIndex {
3621
3624
  */
3622
3625
  static load(s) {
3623
3626
  const idx = new _ChurnIndex();
3624
- for (const [path36, raw] of Object.entries(s.nodes)) {
3625
- idx.nodes.set(path36, {
3627
+ for (const [path37, raw] of Object.entries(s.nodes)) {
3628
+ idx.nodes.set(path37, {
3626
3629
  commits: raw.commits,
3627
3630
  churnLines: raw.churnLines,
3628
3631
  bugCommits: raw.bugCommits,
@@ -3635,8 +3638,8 @@ var ChurnIndex = class _ChurnIndex {
3635
3638
  // -------------------------------------------------------------------------
3636
3639
  // Private helpers
3637
3640
  // -------------------------------------------------------------------------
3638
- getOrCreate(path36) {
3639
- const existing = this.nodes.get(path36);
3641
+ getOrCreate(path37) {
3642
+ const existing = this.nodes.get(path37);
3640
3643
  if (existing !== void 0) return existing;
3641
3644
  const fresh = {
3642
3645
  commits: 0,
@@ -3645,7 +3648,7 @@ var ChurnIndex = class _ChurnIndex {
3645
3648
  authorCounts: {},
3646
3649
  lastTouch: 0
3647
3650
  };
3648
- this.nodes.set(path36, fresh);
3651
+ this.nodes.set(path37, fresh);
3649
3652
  return fresh;
3650
3653
  }
3651
3654
  };
@@ -3726,12 +3729,12 @@ var OwnershipIndex = class _OwnershipIndex {
3726
3729
  */
3727
3730
  snapshot() {
3728
3731
  const nodes = {};
3729
- for (const [path36, raw] of this.nodes) {
3732
+ for (const [path37, raw] of this.nodes) {
3730
3733
  const authorWeights = {};
3731
3734
  for (const [email, entry] of Object.entries(raw.authorWeights)) {
3732
3735
  authorWeights[email] = { ...entry };
3733
3736
  }
3734
- nodes[path36] = { authorWeights, lastTouch: raw.lastTouch };
3737
+ nodes[path37] = { authorWeights, lastTouch: raw.lastTouch };
3735
3738
  }
3736
3739
  return { version: 1, nodes };
3737
3740
  }
@@ -3740,23 +3743,23 @@ var OwnershipIndex = class _OwnershipIndex {
3740
3743
  */
3741
3744
  static load(s) {
3742
3745
  const idx = new _OwnershipIndex();
3743
- for (const [path36, raw] of Object.entries(s.nodes)) {
3746
+ for (const [path37, raw] of Object.entries(s.nodes)) {
3744
3747
  const authorWeights = {};
3745
3748
  for (const [email, entry] of Object.entries(raw.authorWeights)) {
3746
3749
  authorWeights[email] = { ...entry };
3747
3750
  }
3748
- idx.nodes.set(path36, { authorWeights, lastTouch: raw.lastTouch });
3751
+ idx.nodes.set(path37, { authorWeights, lastTouch: raw.lastTouch });
3749
3752
  }
3750
3753
  return idx;
3751
3754
  }
3752
3755
  // -------------------------------------------------------------------------
3753
3756
  // Private helpers
3754
3757
  // -------------------------------------------------------------------------
3755
- getOrCreate(path36) {
3756
- const existing = this.nodes.get(path36);
3758
+ getOrCreate(path37) {
3759
+ const existing = this.nodes.get(path37);
3757
3760
  if (existing !== void 0) return existing;
3758
3761
  const fresh = { authorWeights: {}, lastTouch: 0 };
3759
- this.nodes.set(path36, fresh);
3762
+ this.nodes.set(path37, fresh);
3760
3763
  return fresh;
3761
3764
  }
3762
3765
  };
@@ -4562,7 +4565,7 @@ function renderStatusXml(input) {
4562
4565
  return lines.join("\n");
4563
4566
  }
4564
4567
  function registerStatusTool(registry, ctx) {
4565
- const Schema30 = z2.object({ project_root: ProjectRootField });
4568
+ const Schema31 = z2.object({ project_root: ProjectRootField });
4566
4569
  registry.register(
4567
4570
  "ctx_status",
4568
4571
  {
@@ -4576,7 +4579,7 @@ function registerStatusTool(registry, ctx) {
4576
4579
  }
4577
4580
  },
4578
4581
  async (args) => {
4579
- const { project_root } = Schema30.parse(args ?? {});
4582
+ const { project_root } = Schema31.parse(args ?? {});
4580
4583
  void project_root;
4581
4584
  return renderStatusXml({
4582
4585
  defaultRoot: ctx.noDefaultMode ? null : ctx.projectRoot,
@@ -4611,6 +4614,325 @@ var ToolRegistry = class {
4611
4614
  // packages/core/src/tools/search.ts
4612
4615
  import { z as z3 } from "zod";
4613
4616
 
4617
+ // packages/core/src/budget/nextToolSuggestions.ts
4618
+ var STATIC_RULES = {
4619
+ // ─── Source-returning / file-shaped tools ────────────────────────
4620
+ ctx_get_file: [
4621
+ {
4622
+ tool: "ctx_get_call_graph",
4623
+ why: "Check who depends on this file before modifying.",
4624
+ estimated_tokens: 800
4625
+ },
4626
+ {
4627
+ tool: "ctx_get_definition",
4628
+ why: "Cheaper view if you need a specific symbol, not the whole file.",
4629
+ estimated_tokens: 2e3
4630
+ },
4631
+ {
4632
+ tool: "ctx_blast_radius",
4633
+ why: "Transitive impact analysis before a write.",
4634
+ estimated_tokens: 1500
4635
+ }
4636
+ ],
4637
+ ctx_get_definition: [
4638
+ {
4639
+ tool: "ctx_get_call_graph",
4640
+ why: "Who calls this symbol? Almost always your next step.",
4641
+ estimated_tokens: 800
4642
+ },
4643
+ {
4644
+ tool: "ctx_blast_radius",
4645
+ why: "What would break if this signature changes?",
4646
+ estimated_tokens: 1500
4647
+ }
4648
+ ],
4649
+ ctx_get_context_packet: [
4650
+ {
4651
+ tool: "ctx_get_call_graph",
4652
+ why: "Surface external callers not visible inside the packet.",
4653
+ estimated_tokens: 800
4654
+ },
4655
+ {
4656
+ tool: "ctx_get_affected_flows",
4657
+ why: "Execution-flow coverage of the packet files.",
4658
+ estimated_tokens: 2e3
4659
+ }
4660
+ ],
4661
+ ctx_search: [
4662
+ {
4663
+ tool: "ctx_get_definition",
4664
+ why: "Pull the canonical definition of a top result.",
4665
+ estimated_tokens: 2e3
4666
+ },
4667
+ {
4668
+ tool: "ctx_similar_files",
4669
+ why: "Find related files outside the keyword/vector hit set.",
4670
+ estimated_tokens: 1e3
4671
+ }
4672
+ ],
4673
+ ctx_full_text_search: [
4674
+ {
4675
+ tool: "ctx_get_file",
4676
+ why: "Inspect a specific match in context.",
4677
+ estimated_tokens: 8e3
4678
+ },
4679
+ {
4680
+ tool: "ctx_get_call_graph",
4681
+ why: "Caller graph for matched symbols.",
4682
+ estimated_tokens: 800
4683
+ }
4684
+ ],
4685
+ ctx_similar_files: [
4686
+ {
4687
+ tool: "ctx_get_context_packet",
4688
+ why: "Bundle the cluster into a single packet for review.",
4689
+ estimated_tokens: 6e3
4690
+ }
4691
+ ],
4692
+ // ─── Graph / structural queries ──────────────────────────────────
4693
+ // ctx_get_call_graph is the canonical "find callers" tool — pass
4694
+ // direction: 'callers' or 'callees' in args. The follow-ups below
4695
+ // assume the caller is investigating impact / coverage on the
4696
+ // returned caller set.
4697
+ ctx_get_call_graph: [
4698
+ {
4699
+ tool: "ctx_blast_radius",
4700
+ why: "Transitive dependents \u2014 callers of the callers you just found.",
4701
+ estimated_tokens: 1500
4702
+ },
4703
+ {
4704
+ tool: "ctx_get_affected_flows",
4705
+ why: "Which execution paths break if any caller is removed?",
4706
+ estimated_tokens: 2e3
4707
+ },
4708
+ {
4709
+ tool: "ctx_execution_flow",
4710
+ why: "Linearize the caller set into ordered execution sequences.",
4711
+ estimated_tokens: 4e3
4712
+ }
4713
+ ],
4714
+ ctx_blast_radius: [
4715
+ {
4716
+ tool: "ctx_get_affected_flows",
4717
+ why: "Execution-flow impact on the affected files.",
4718
+ estimated_tokens: 2e3
4719
+ },
4720
+ { tool: "ctx_knowledge_gaps", why: "Identify test-coverage gaps on affected files.", estimated_tokens: 1200 }
4721
+ ],
4722
+ ctx_get_affected_flows: [
4723
+ {
4724
+ tool: "ctx_execution_flow",
4725
+ why: "Drill into a specific affected flow.",
4726
+ estimated_tokens: 4e3
4727
+ },
4728
+ {
4729
+ tool: "ctx_blast_radius",
4730
+ why: "Reverse direction \u2014 what affects this flow?",
4731
+ estimated_tokens: 1500
4732
+ }
4733
+ ],
4734
+ ctx_execution_flow: [
4735
+ {
4736
+ tool: "ctx_get_call_graph",
4737
+ why: "External callers of the flow entry-point.",
4738
+ estimated_tokens: 800
4739
+ }
4740
+ ],
4741
+ // ─── Architecture / overview tools ───────────────────────────────
4742
+ ctx_architecture_overview: [
4743
+ {
4744
+ tool: "ctx_community_list",
4745
+ why: "Drill into a specific community.",
4746
+ estimated_tokens: 1e3
4747
+ },
4748
+ {
4749
+ tool: "ctx_hub_nodes",
4750
+ why: "Top fan-in/out nodes deserving deeper inspection.",
4751
+ estimated_tokens: 1200
4752
+ },
4753
+ {
4754
+ tool: "ctx_bridge_nodes",
4755
+ why: "Cross-community bridges (high architectural leverage).",
4756
+ estimated_tokens: 1e3
4757
+ }
4758
+ ],
4759
+ ctx_community_list: [
4760
+ {
4761
+ tool: "ctx_get_context_packet",
4762
+ why: "Bundle a community into a single review packet.",
4763
+ estimated_tokens: 6e3
4764
+ }
4765
+ ],
4766
+ ctx_hub_nodes: [
4767
+ {
4768
+ tool: "ctx_get_call_graph",
4769
+ why: "Who depends on the top hub?",
4770
+ estimated_tokens: 800
4771
+ },
4772
+ {
4773
+ tool: "ctx_blast_radius",
4774
+ why: "Hub change-impact analysis.",
4775
+ estimated_tokens: 1500
4776
+ }
4777
+ ],
4778
+ ctx_bridge_nodes: [
4779
+ {
4780
+ tool: "ctx_get_call_graph",
4781
+ why: "Callers across the bridge.",
4782
+ estimated_tokens: 800
4783
+ }
4784
+ ],
4785
+ ctx_surprising_connections: [
4786
+ {
4787
+ tool: "ctx_blast_radius",
4788
+ why: "Impact analysis on a surprising-connection target.",
4789
+ estimated_tokens: 1500
4790
+ }
4791
+ ],
4792
+ // ─── Review / diff tools ─────────────────────────────────────────
4793
+ ctx_detect_changes: [
4794
+ {
4795
+ tool: "ctx_get_file",
4796
+ why: "Inspect a specific risky file.",
4797
+ estimated_tokens: 8e3
4798
+ },
4799
+ {
4800
+ tool: "ctx_get_affected_flows",
4801
+ why: "Which execution paths the change touches.",
4802
+ estimated_tokens: 2e3
4803
+ },
4804
+ {
4805
+ tool: "ctx_git_diff_review",
4806
+ why: "Full diff packet for the changeset.",
4807
+ estimated_tokens: 8e3
4808
+ }
4809
+ ],
4810
+ ctx_git_diff_review: [
4811
+ {
4812
+ tool: "ctx_risk_overlay",
4813
+ why: "Score the changed files by historical churn + coupling.",
4814
+ estimated_tokens: 1500
4815
+ },
4816
+ {
4817
+ tool: "ctx_get_call_graph",
4818
+ why: "Caller-side impact of the changes.",
4819
+ estimated_tokens: 800
4820
+ }
4821
+ ],
4822
+ ctx_risk_overlay: [
4823
+ {
4824
+ tool: "ctx_get_file",
4825
+ why: "Inspect the highest-risk file in detail.",
4826
+ estimated_tokens: 8e3
4827
+ }
4828
+ ],
4829
+ ctx_git_coupling: [
4830
+ {
4831
+ tool: "ctx_blast_radius",
4832
+ why: "Static impact analysis to complement co-change signal.",
4833
+ estimated_tokens: 1500
4834
+ }
4835
+ ],
4836
+ // ─── Refactor tools ──────────────────────────────────────────────
4837
+ ctx_refactor_preview: [
4838
+ {
4839
+ tool: "ctx_apply_refactor",
4840
+ why: "Commit the preview after you reviewed the rename.",
4841
+ estimated_tokens: 2e3
4842
+ }
4843
+ ],
4844
+ ctx_apply_refactor: [
4845
+ {
4846
+ tool: "ctx_detect_changes",
4847
+ why: "Verify the refactor produced the expected risk profile.",
4848
+ estimated_tokens: 1500
4849
+ }
4850
+ ],
4851
+ // ─── Knowledge / coverage tools ──────────────────────────────────
4852
+ ctx_knowledge_gaps: [
4853
+ { tool: "ctx_knowledge_gaps", why: "Identify test-coverage gaps on affected files.", estimated_tokens: 1200 },
4854
+ {
4855
+ tool: "ctx_get_call_graph",
4856
+ why: "Caller frequency on untested symbols (impact ranking).",
4857
+ estimated_tokens: 800
4858
+ }
4859
+ ],
4860
+ ctx_find_large_functions: [
4861
+ {
4862
+ tool: "ctx_get_definition",
4863
+ why: "Inspect the largest function.",
4864
+ estimated_tokens: 2e3
4865
+ }
4866
+ ],
4867
+ // ─── Metadata / status ───────────────────────────────────────────
4868
+ ctx_get_minimal_context: [
4869
+ // intentionally empty — the suggested_first_tool field IS the
4870
+ // next-step suggestion. Adding rules here would be redundant.
4871
+ ],
4872
+ ctx_status: [],
4873
+ ctx_get_rules: [
4874
+ {
4875
+ tool: "ctx_rules_check",
4876
+ why: "Validate code against rules.",
4877
+ estimated_tokens: 1200
4878
+ }
4879
+ ],
4880
+ ctx_rules_check: [],
4881
+ ctx_get_workflow: [],
4882
+ ctx_suggested_questions: [],
4883
+ // ─── Wiki / export ───────────────────────────────────────────────
4884
+ ctx_wiki_generate: [
4885
+ {
4886
+ tool: "ctx_architecture_overview",
4887
+ why: "Confirm the wiki structure against the live overview.",
4888
+ estimated_tokens: 2e3
4889
+ }
4890
+ ],
4891
+ ctx_graph_export: [],
4892
+ ctx_graph_snapshot: [
4893
+ {
4894
+ tool: "ctx_graph_diff",
4895
+ why: "Compare against a later snapshot.",
4896
+ estimated_tokens: 2e3
4897
+ }
4898
+ ],
4899
+ ctx_graph_diff: [
4900
+ {
4901
+ tool: "ctx_detect_changes",
4902
+ why: "Risk-scored view of the graph delta.",
4903
+ estimated_tokens: 1500
4904
+ }
4905
+ ],
4906
+ // ─── Cross-repo ──────────────────────────────────────────────────
4907
+ ctx_cross_repo_search: [
4908
+ {
4909
+ tool: "ctx_get_file",
4910
+ why: "Inspect a hit in a specific repo.",
4911
+ estimated_tokens: 8e3
4912
+ }
4913
+ ]
4914
+ // ─── Query primitive ─────────────────────────────────────────────
4915
+ // ─── Affected flows (sibling to get_affected_flows but worth pinning) ─
4916
+ // ─── Definition aliases / structurals not listed above are deliberate
4917
+ // — the test enforces the full registered-tool list is covered.
4918
+ };
4919
+ var TOKEN_ESTIMATE_MIN = 0;
4920
+ var TOKEN_ESTIMATE_MAX = 1e5;
4921
+ function clampEstimate(n) {
4922
+ if (!Number.isFinite(n)) return 0;
4923
+ return Math.max(TOKEN_ESTIMATE_MIN, Math.min(TOKEN_ESTIMATE_MAX, Math.round(n)));
4924
+ }
4925
+ function suggestNext(fromTool, registeredTools) {
4926
+ const raw = STATIC_RULES[fromTool] ?? [];
4927
+ const filtered = registeredTools ? raw.filter((s) => registeredTools.has(s.tool)) : raw;
4928
+ return filtered.slice(0, 3).map((s) => ({
4929
+ tool: s.tool,
4930
+ args: s.args,
4931
+ why: s.why,
4932
+ estimated_tokens: clampEstimate(s.estimated_tokens)
4933
+ }));
4934
+ }
4935
+
4614
4936
  // packages/core/src/budget/budget.ts
4615
4937
  var defaultTokenEstimator = (text) => Math.ceil(text.length / 4);
4616
4938
  function hasBudgetArgs(args) {
@@ -4634,23 +4956,33 @@ function readBudgetArgs(args) {
4634
4956
  function isBudgetDisabled() {
4635
4957
  return process.env.CTXLOOM_DISABLE_BUDGET === "1";
4636
4958
  }
4637
- function emitTelemetry(event) {
4959
+ function emitTelemetry(event, sink = diskSink) {
4638
4960
  if (process.env.CTXLOOM_TELEMETRY_LEVEL !== "full") return;
4639
4961
  logger.info(event.event, event);
4962
+ try {
4963
+ sink.append(event);
4964
+ } catch {
4965
+ }
4640
4966
  }
4641
4967
  async function enforceBudget(opts) {
4642
4968
  const { full, args, toolName, defaultMaxTokens, skeletonProducer } = opts;
4643
4969
  const estimate = opts.estimator ?? defaultTokenEstimator;
4970
+ const sink = opts.sink ?? opts.ctx?.telemetrySink ?? diskSink;
4971
+ const nextToolSuggestions = suggestNext(toolName);
4972
+ const withSuggestions = (base) => {
4973
+ if (nextToolSuggestions.length === 0) return base;
4974
+ return { ...base, next_tool_suggestions: nextToolSuggestions };
4975
+ };
4644
4976
  const originalTokens = estimate(full);
4645
4977
  if (isBudgetDisabled()) {
4646
4978
  return {
4647
4979
  text: full,
4648
- meta: {
4980
+ meta: withSuggestions({
4649
4981
  format: "full",
4650
4982
  original_tokens_est: originalTokens,
4651
4983
  returned_tokens_est: originalTokens,
4652
4984
  fallback_reason: null
4653
- }
4985
+ })
4654
4986
  };
4655
4987
  }
4656
4988
  if (args.response_format === "skeleton" && skeletonProducer) {
@@ -4659,45 +4991,45 @@ async function enforceBudget(opts) {
4659
4991
  const skTokens = estimate(skeleton2);
4660
4992
  return {
4661
4993
  text: skeleton2,
4662
- meta: {
4994
+ meta: withSuggestions({
4663
4995
  format: "skeleton",
4664
4996
  original_tokens_est: originalTokens,
4665
4997
  returned_tokens_est: skTokens,
4666
4998
  fallback_reason: null
4667
- }
4999
+ })
4668
5000
  };
4669
5001
  }
4670
5002
  return {
4671
5003
  text: full,
4672
- meta: {
5004
+ meta: withSuggestions({
4673
5005
  format: "full",
4674
5006
  original_tokens_est: originalTokens,
4675
5007
  returned_tokens_est: originalTokens,
4676
5008
  fallback_reason: "skeleton_failed"
4677
- }
5009
+ })
4678
5010
  };
4679
5011
  }
4680
5012
  const budget = args.max_response_tokens ?? defaultMaxTokens;
4681
5013
  if (budget === void 0) {
4682
5014
  return {
4683
5015
  text: full,
4684
- meta: {
5016
+ meta: withSuggestions({
4685
5017
  format: "full",
4686
5018
  original_tokens_est: originalTokens,
4687
5019
  returned_tokens_est: originalTokens,
4688
5020
  fallback_reason: null
4689
- }
5021
+ })
4690
5022
  };
4691
5023
  }
4692
5024
  if (originalTokens <= budget) {
4693
5025
  return {
4694
5026
  text: full,
4695
- meta: {
5027
+ meta: withSuggestions({
4696
5028
  format: "full",
4697
5029
  original_tokens_est: originalTokens,
4698
5030
  returned_tokens_est: originalTokens,
4699
5031
  fallback_reason: null
4700
- }
5032
+ })
4701
5033
  };
4702
5034
  }
4703
5035
  emitTelemetry({
@@ -4706,7 +5038,7 @@ async function enforceBudget(opts) {
4706
5038
  original_tokens: originalTokens,
4707
5039
  budget,
4708
5040
  ratio: originalTokens / budget
4709
- });
5041
+ }, sink);
4710
5042
  const mode = args.on_budget_exceeded ?? "skeleton";
4711
5043
  if (mode === "error") {
4712
5044
  const err = new Error(
@@ -4725,15 +5057,15 @@ async function enforceBudget(opts) {
4725
5057
  tool: toolName,
4726
5058
  fallback_reason: "budget_exceeded",
4727
5059
  mode: "truncate"
4728
- });
5060
+ }, sink);
4729
5061
  return {
4730
5062
  text: sliced2,
4731
- meta: {
5063
+ meta: withSuggestions({
4732
5064
  format: "truncated",
4733
5065
  original_tokens_est: originalTokens,
4734
5066
  returned_tokens_est: slicedTokens,
4735
5067
  fallback_reason: "budget_exceeded"
4736
- }
5068
+ })
4737
5069
  };
4738
5070
  }
4739
5071
  const skeleton = skeletonProducer ? await safeSkeleton(skeletonProducer, toolName) : null;
@@ -4745,15 +5077,15 @@ async function enforceBudget(opts) {
4745
5077
  tool: toolName,
4746
5078
  fallback_reason: "budget_exceeded",
4747
5079
  mode: "skeleton"
4748
- });
5080
+ }, sink);
4749
5081
  return {
4750
5082
  text: skeleton,
4751
- meta: {
5083
+ meta: withSuggestions({
4752
5084
  format: "skeleton",
4753
5085
  original_tokens_est: originalTokens,
4754
5086
  returned_tokens_est: skTokens,
4755
5087
  fallback_reason: "budget_exceeded"
4756
- }
5088
+ })
4757
5089
  };
4758
5090
  }
4759
5091
  const slicedSk = skeleton.slice(0, budget * 4);
@@ -4762,15 +5094,15 @@ async function enforceBudget(opts) {
4762
5094
  tool: toolName,
4763
5095
  fallback_reason: "budget_exceeded",
4764
5096
  mode: "skeleton+truncate"
4765
- });
5097
+ }, sink);
4766
5098
  return {
4767
5099
  text: slicedSk,
4768
- meta: {
5100
+ meta: withSuggestions({
4769
5101
  format: "truncated",
4770
5102
  original_tokens_est: originalTokens,
4771
5103
  returned_tokens_est: estimate(slicedSk),
4772
5104
  fallback_reason: "budget_exceeded"
4773
- }
5105
+ })
4774
5106
  };
4775
5107
  }
4776
5108
  const sliced = full.slice(0, budget * 4);
@@ -4779,15 +5111,15 @@ async function enforceBudget(opts) {
4779
5111
  tool: toolName,
4780
5112
  fallback_reason: "skeleton_failed",
4781
5113
  mode: "truncate-fallback"
4782
- });
5114
+ }, sink);
4783
5115
  return {
4784
5116
  text: sliced,
4785
- meta: {
5117
+ meta: withSuggestions({
4786
5118
  format: "truncated",
4787
5119
  original_tokens_est: originalTokens,
4788
5120
  returned_tokens_est: estimate(sliced),
4789
5121
  fallback_reason: "skeleton_failed"
4790
- }
5122
+ })
4791
5123
  };
4792
5124
  }
4793
5125
  async function safeSkeleton(producer, toolName) {
@@ -4887,6 +5219,7 @@ function registerSearchTool(registry, ctx) {
4887
5219
  if (!hasBudgetArgs(args)) return full;
4888
5220
  const skeletonProducer = async () => renderResults(parsed.query, ranked, false);
4889
5221
  const result = await enforceBudget({
5222
+ ctx,
4890
5223
  full,
4891
5224
  args: readBudgetArgs(args),
4892
5225
  toolName: "ctx_search",
@@ -4946,6 +5279,7 @@ function registerFileTool(registry, ctx) {
4946
5279
  const absPath = validator.validate(parsed.path);
4947
5280
  const skeletonizer = await ctx.getSkeletonizer(parsed.project_root);
4948
5281
  const result = await enforceBudget({
5282
+ ctx,
4949
5283
  full,
4950
5284
  args: readBudgetArgs(args),
4951
5285
  toolName: "ctx_get_file",
@@ -5054,6 +5388,7 @@ ${sk}`;
5054
5388
  return renderPacket({ ...parts, primaryContent: primarySkeleton });
5055
5389
  };
5056
5390
  const result = await enforceBudget({
5391
+ ctx,
5057
5392
  full,
5058
5393
  args: readBudgetArgs(args),
5059
5394
  toolName: "ctx_get_context_packet",
@@ -5217,6 +5552,7 @@ function registerDefinitionTool(registry, ctx) {
5217
5552
  }
5218
5553
  if (!hasBudgetArgs(args)) return full;
5219
5554
  const result = await enforceBudget({
5555
+ ctx,
5220
5556
  full,
5221
5557
  args: readBudgetArgs(args),
5222
5558
  toolName: "ctx_get_definition",
@@ -6258,6 +6594,7 @@ function registerWikiGenerateTool(registry, ctx) {
6258
6594
  const full = detail_level === "minimal" ? renderMinimal() : renderStandard();
6259
6595
  if (!hasBudgetArgs(args)) return full;
6260
6596
  const budgetResult = await enforceBudget({
6597
+ ctx,
6261
6598
  full,
6262
6599
  args: readBudgetArgs(args),
6263
6600
  toolName: "ctx_wiki_generate",
@@ -6405,6 +6742,7 @@ function registerGitDiffReviewTool(registry, ctx) {
6405
6742
  const maybeBudget = async (full2, skeletonProducer) => {
6406
6743
  if (!hasBudgetArgs(args)) return full2;
6407
6744
  const result = await enforceBudget({
6745
+ ctx,
6408
6746
  full: full2,
6409
6747
  args: readBudgetArgs(args),
6410
6748
  toolName: "ctx_git_diff_review",
@@ -6440,7 +6778,7 @@ function registerGitDiffReviewTool(registry, ctx) {
6440
6778
  skeleton: include_skeletons && i < skeletonLimit ? await trySkeletonize(ctx, file, project_root) : ""
6441
6779
  }))
6442
6780
  );
6443
- const render = (withSkeletons, withTransitive) => {
6781
+ const render2 = (withSkeletons, withTransitive) => {
6444
6782
  const out = [`<git_diff_review changed_files="${files.length}" depth="${depth}">`];
6445
6783
  out.push(` <changed_files count="${files.length}">`);
6446
6784
  for (const cd of changedFileData) {
@@ -6486,8 +6824,8 @@ function registerGitDiffReviewTool(registry, ctx) {
6486
6824
  out.push("</git_diff_review>");
6487
6825
  return out.join("\n");
6488
6826
  };
6489
- const full = render(include_skeletons, true);
6490
- return maybeBudget(full, async () => render(false, false));
6827
+ const full = render2(include_skeletons, true);
6828
+ return maybeBudget(full, async () => render2(false, false));
6491
6829
  }
6492
6830
  );
6493
6831
  }
@@ -6589,7 +6927,7 @@ function registerRefactorPreviewTool(registry, ctx) {
6589
6927
  totalOccurrences += occurrences.length;
6590
6928
  }
6591
6929
  }
6592
- const render = (includeChanges) => {
6930
+ const render2 = (includeChanges) => {
6593
6931
  const xmlLines = [
6594
6932
  `<refactor_preview symbol="${escapeXML17(symbol)}" new_name="${escapeXML17(new_name)}" total_files="${fileChanges.length}" total_occurrences="${totalOccurrences}">`
6595
6933
  ];
@@ -6619,14 +6957,15 @@ function registerRefactorPreviewTool(registry, ctx) {
6619
6957
  xmlLines.push("</refactor_preview>");
6620
6958
  return xmlLines.join("\n");
6621
6959
  };
6622
- const full = render(true);
6960
+ const full = render2(true);
6623
6961
  if (!hasBudgetArgs(args)) return full;
6624
6962
  const result = await enforceBudget({
6963
+ ctx,
6625
6964
  full,
6626
6965
  args: readBudgetArgs(args),
6627
6966
  toolName: "ctx_refactor_preview",
6628
6967
  defaultMaxTokens: DEFAULT_MAX_RESPONSE_TOKENS7,
6629
- skeletonProducer: async () => render(false)
6968
+ skeletonProducer: async () => render2(false)
6630
6969
  });
6631
6970
  return wrapResponse(result);
6632
6971
  }
@@ -6743,6 +7082,7 @@ function registerExecutionFlowTool(registry, ctx) {
6743
7082
  const maybeBudget = async (full) => {
6744
7083
  if (!hasBudgetArgs(args)) return full;
6745
7084
  const result = await enforceBudget({
7085
+ ctx,
6746
7086
  full,
6747
7087
  args: readBudgetArgs(args),
6748
7088
  toolName: "ctx_execution_flow",
@@ -6899,7 +7239,7 @@ var Schema20 = z22.object({
6899
7239
  function escapeXML19(text) {
6900
7240
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
6901
7241
  }
6902
- function registerCrossRepoSearchTool(registry, _ctx, registryFilePath) {
7242
+ function registerCrossRepoSearchTool(registry, ctx, registryFilePath) {
6903
7243
  const repoRegistryPath = registryFilePath ?? path18.join(process.env.HOME ?? process.env.USERPROFILE ?? "", ".ctxloom", "repos.json");
6904
7244
  registry.register(
6905
7245
  "ctx_cross_repo_search",
@@ -6988,7 +7328,7 @@ function registerCrossRepoSearchTool(registry, _ctx, registryFilePath) {
6988
7328
  );
6989
7329
  }
6990
7330
  xmlLines.push(" </repos>");
6991
- const render = (includeContent) => {
7331
+ const render2 = (includeContent) => {
6992
7332
  const out = [...xmlLines];
6993
7333
  out.push(` <results count="${topResults.length}">`);
6994
7334
  for (const r of topResults) {
@@ -7004,14 +7344,15 @@ function registerCrossRepoSearchTool(registry, _ctx, registryFilePath) {
7004
7344
  out.push("</cross_repo_search>");
7005
7345
  return out.join("\n");
7006
7346
  };
7007
- const full = render(true);
7347
+ const full = render2(true);
7008
7348
  if (!hasBudgetArgs(args)) return full;
7009
7349
  const result = await enforceBudget({
7350
+ ctx,
7010
7351
  full,
7011
7352
  args: readBudgetArgs(args),
7012
7353
  toolName: "ctx_cross_repo_search",
7013
7354
  defaultMaxTokens: DEFAULT_MAX_RESPONSE_TOKENS9,
7014
- skeletonProducer: async () => render(false)
7355
+ skeletonProducer: async () => render2(false)
7015
7356
  });
7016
7357
  return wrapResponse(result);
7017
7358
  }
@@ -7116,6 +7457,7 @@ function registerApplyRefactorTool(registry, ctx) {
7116
7457
  const full = xml.join("\n");
7117
7458
  if (!hasBudgetArgs(args)) return full;
7118
7459
  const result = await enforceBudget({
7460
+ ctx,
7119
7461
  full,
7120
7462
  args: readBudgetArgs(args),
7121
7463
  toolName: "ctx_apply_refactor",
@@ -7333,6 +7675,7 @@ function registerFullTextSearchTool(registry, ctx) {
7333
7675
  const maybeBudget = async (full, skeletonProducer) => {
7334
7676
  if (!hasBudgetArgs(args)) return full;
7335
7677
  const result = await enforceBudget({
7678
+ ctx,
7336
7679
  full,
7337
7680
  args: readBudgetArgs(args),
7338
7681
  toolName: "ctx_full_text_search",
@@ -7396,7 +7739,7 @@ function registerFullTextSearchTool(registry, ctx) {
7396
7739
  } catch {
7397
7740
  }
7398
7741
  }
7399
- const render = (includeSnippets) => {
7742
+ const render2 = (includeSnippets) => {
7400
7743
  const xml = [
7401
7744
  `<full_text_search query="${escapeXML22(query)}" mode="${mode}" case_sensitive="${case_sensitive}" count="${merged.length}">`
7402
7745
  ];
@@ -7415,8 +7758,8 @@ function registerFullTextSearchTool(registry, ctx) {
7415
7758
  return xml.join("\n");
7416
7759
  };
7417
7760
  return maybeBudget(
7418
- render(true),
7419
- async () => render(false)
7761
+ render2(true),
7762
+ async () => render2(false)
7420
7763
  );
7421
7764
  }
7422
7765
  );
@@ -7897,6 +8240,7 @@ function registerFindLargeFunctionsTool(registry, ctx) {
7897
8240
  }
7898
8241
  if (!hasBudgetArgs(args)) return full;
7899
8242
  const result = await enforceBudget({
8243
+ ctx,
7900
8244
  full,
7901
8245
  args: readBudgetArgs(args),
7902
8246
  toolName: "ctx_find_large_functions",
@@ -8448,6 +8792,279 @@ function registerGetAffectedFlowsTool(registry, ctx) {
8448
8792
  );
8449
8793
  }
8450
8794
 
8795
+ // packages/core/src/tools/minimal-context.ts
8796
+ import { execSync } from "child_process";
8797
+ import { z as z36 } from "zod";
8798
+ var Schema30 = z36.object({
8799
+ task: z36.string().max(200).optional().describe(
8800
+ "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."
8801
+ ),
8802
+ project_root: ProjectRootField,
8803
+ max_response_tokens: z36.number().int().positive().optional(),
8804
+ on_budget_exceeded: z36.enum(["skeleton", "truncate", "error"]).optional(),
8805
+ response_format: z36.enum(["full", "skeleton", "auto"]).optional()
8806
+ });
8807
+ var DEFAULT_MAX_RESPONSE_TOKENS13 = 250;
8808
+ function routeFirstTool(task, hasDirtyChanges) {
8809
+ const t = (task ?? "").toLowerCase();
8810
+ if (/\b(rename|refactor|move\s+\w+|extract)\b/.test(t)) {
8811
+ return {
8812
+ tool: "ctx_get_call_graph",
8813
+ why: "Renames + refactors need every caller surfaced before the edit. Start here.",
8814
+ estimated_tokens: 800
8815
+ };
8816
+ }
8817
+ if (/\b(blast|impact|breaks?|affects?)\b/.test(t)) {
8818
+ return {
8819
+ tool: "ctx_blast_radius",
8820
+ why: "Blast-radius analysis gives transitive dependents; start here for impact questions.",
8821
+ estimated_tokens: 1500
8822
+ };
8823
+ }
8824
+ if (/\b(architect|overview|explore|onboard|tour)\b/.test(t)) {
8825
+ return {
8826
+ tool: "ctx_architecture_overview",
8827
+ why: "Top-down map of communities + hub nodes; the natural starting point for exploration.",
8828
+ estimated_tokens: 2e3
8829
+ };
8830
+ }
8831
+ if (/\b(test|coverage|tested)\b/.test(t)) {
8832
+ return {
8833
+ tool: "ctx_knowledge_gaps",
8834
+ why: "Knowledge-gap report highlights files lacking test coverage.",
8835
+ estimated_tokens: 1200
8836
+ };
8837
+ }
8838
+ if (/\b(review|audit|check|diff)\b/.test(t)) {
8839
+ return {
8840
+ tool: "ctx_detect_changes",
8841
+ why: "Risk-scored change analysis is the canonical start for reviews.",
8842
+ estimated_tokens: 1500
8843
+ };
8844
+ }
8845
+ if (hasDirtyChanges) {
8846
+ return {
8847
+ tool: "ctx_detect_changes",
8848
+ why: "Working tree has uncommitted changes \u2014 review-mode is the most-likely intent.",
8849
+ estimated_tokens: 1500
8850
+ };
8851
+ }
8852
+ return {
8853
+ tool: "ctx_architecture_overview",
8854
+ why: "Clean working tree + no task hint \u2014 orientation is the safe default.",
8855
+ estimated_tokens: 2e3
8856
+ };
8857
+ }
8858
+ function classifyStaleness(lastBuildIso) {
8859
+ if (!lastBuildIso) return "unbuilt";
8860
+ const ms = Date.now() - new Date(lastBuildIso).getTime();
8861
+ if (!Number.isFinite(ms) || ms < 0) return "unbuilt";
8862
+ if (ms < 5 * 60 * 1e3) return "fresh";
8863
+ if (ms < 60 * 60 * 1e3) return "stale_minutes";
8864
+ return "stale_hours";
8865
+ }
8866
+ function readRecentChanges(projectRoot) {
8867
+ try {
8868
+ const stdout = execSync("git status --porcelain", {
8869
+ cwd: projectRoot,
8870
+ timeout: 2e3,
8871
+ stdio: ["ignore", "pipe", "ignore"],
8872
+ encoding: "utf-8"
8873
+ });
8874
+ const lines = stdout.split("\n").filter((l) => l.trim() !== "");
8875
+ return lines.slice(0, 20).map((line) => {
8876
+ const x = line[0];
8877
+ const y = line[1];
8878
+ const path37 = line.slice(3).trim();
8879
+ let status = "?";
8880
+ const xy = x === " " ? y : x;
8881
+ if (xy === "M" || xy === "A" || xy === "D" || xy === "R") status = xy;
8882
+ return { file: path37, status };
8883
+ });
8884
+ } catch {
8885
+ return [];
8886
+ }
8887
+ }
8888
+ function computeTopHubs(graph) {
8889
+ const files = graph.allFiles();
8890
+ const scored = files.map((file) => {
8891
+ const inDeg = graph.getImporters(file).length;
8892
+ const outDeg = graph.getImports(file).length;
8893
+ return { file, inDeg, outDeg, total: inDeg + outDeg };
8894
+ }).filter((s) => s.total >= 2).sort((a, b) => b.total - a.total).slice(0, 5);
8895
+ return scored.map((s) => ({
8896
+ name: s.file,
8897
+ reason: s.inDeg > s.outDeg ? "fan_in" : s.outDeg > s.inDeg ? "fan_out" : "bridge"
8898
+ }));
8899
+ }
8900
+ var CACHE_TTL_MS = 1e4;
8901
+ var responseCache = /* @__PURE__ */ new Map();
8902
+ function cacheKey(projectRoot, task) {
8903
+ return `${projectRoot}|${task ?? ""}`;
8904
+ }
8905
+ function cacheGet(key) {
8906
+ const e = responseCache.get(key);
8907
+ if (!e) return null;
8908
+ if (e.expiresAt < Date.now()) {
8909
+ responseCache.delete(key);
8910
+ return null;
8911
+ }
8912
+ return e.body;
8913
+ }
8914
+ function cachePut(key, body) {
8915
+ responseCache.set(key, { expiresAt: Date.now() + CACHE_TTL_MS, body });
8916
+ }
8917
+ function sanitizeTask(raw) {
8918
+ if (raw == null) return void 0;
8919
+ const stripped = raw.replace(/[\x00-\x1f\x7f]/g, "").slice(0, 200);
8920
+ return stripped === "" ? void 0 : stripped;
8921
+ }
8922
+ function escapeXML26(text) {
8923
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
8924
+ }
8925
+ function render(input) {
8926
+ const lines = ["<minimal_context>"];
8927
+ lines.push(
8928
+ ` <graph ready="${input.graphReady}" nodes="${input.nodes}" edges="${input.edges}" last_build="${escapeXML26(input.lastBuildIso ?? "never")}" staleness="${input.staleness}" />`
8929
+ );
8930
+ if (input.languages.length > 0) {
8931
+ lines.push(` <languages>${input.languages.map(escapeXML26).join(", ")}</languages>`);
8932
+ }
8933
+ if (input.recentChanges.length > 0) {
8934
+ lines.push(` <recent_changes count="${input.recentChanges.length}">`);
8935
+ for (const c of input.recentChanges) {
8936
+ lines.push(` <change status="${c.status}" file="${escapeXML26(c.file)}" />`);
8937
+ }
8938
+ lines.push(" </recent_changes>");
8939
+ } else {
8940
+ lines.push(' <recent_changes count="0" />');
8941
+ }
8942
+ if (input.topHubs.length > 0) {
8943
+ lines.push(` <top_hubs count="${input.topHubs.length}">`);
8944
+ for (const h of input.topHubs) {
8945
+ lines.push(` <hub reason="${h.reason}" name="${escapeXML26(h.name)}" />`);
8946
+ }
8947
+ lines.push(" </top_hubs>");
8948
+ }
8949
+ lines.push(" <suggested_first_tool>");
8950
+ lines.push(` <tool>${escapeXML26(input.suggested.tool)}</tool>`);
8951
+ lines.push(` <why>${escapeXML26(input.suggested.why)}</why>`);
8952
+ lines.push(` <estimated_tokens>${input.suggested.estimated_tokens}</estimated_tokens>`);
8953
+ if (input.suggested.args && Object.keys(input.suggested.args).length > 0) {
8954
+ lines.push(` <args>${escapeXML26(JSON.stringify(input.suggested.args))}</args>`);
8955
+ }
8956
+ lines.push(" </suggested_first_tool>");
8957
+ lines.push("</minimal_context>");
8958
+ return lines.join("\n");
8959
+ }
8960
+ function renderSkeleton(input) {
8961
+ const lines = ['<minimal_context format="skeleton">'];
8962
+ lines.push(
8963
+ ` <graph ready="${input.graphReady}" nodes="${input.nodes}" edges="${input.edges}" staleness="${input.staleness}" />`
8964
+ );
8965
+ lines.push(` <recent_changes count="${input.recentChanges.length}" />`);
8966
+ lines.push(` <top_hubs count="${input.topHubs.length}" />`);
8967
+ lines.push(" <suggested_first_tool>");
8968
+ lines.push(` <tool>${escapeXML26(input.suggested.tool)}</tool>`);
8969
+ lines.push(` <why>${escapeXML26(input.suggested.why)}</why>`);
8970
+ lines.push(` <estimated_tokens>${input.suggested.estimated_tokens}</estimated_tokens>`);
8971
+ lines.push(" </suggested_first_tool>");
8972
+ lines.push("</minimal_context>");
8973
+ return lines.join("\n");
8974
+ }
8975
+ function registerMinimalContextTool(registry, ctx) {
8976
+ registry.register(
8977
+ "ctx_get_minimal_context",
8978
+ {
8979
+ name: "ctx_get_minimal_context",
8980
+ 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.',
8981
+ inputSchema: {
8982
+ type: "object",
8983
+ properties: {
8984
+ task: {
8985
+ type: "string",
8986
+ 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."
8987
+ },
8988
+ project_root: PROJECT_ROOT_JSON_SCHEMA,
8989
+ max_response_tokens: {
8990
+ type: "number",
8991
+ description: "Optional response token budget. Default 250."
8992
+ },
8993
+ on_budget_exceeded: {
8994
+ type: "string",
8995
+ enum: ["skeleton", "truncate", "error"]
8996
+ },
8997
+ response_format: {
8998
+ type: "string",
8999
+ enum: ["full", "skeleton", "auto"]
9000
+ }
9001
+ }
9002
+ }
9003
+ },
9004
+ async (args) => {
9005
+ const parsed = Schema30.parse(args);
9006
+ const task = sanitizeTask(parsed.task);
9007
+ const projectRoot = parsed.project_root ?? ctx.projectRoot;
9008
+ const key = cacheKey(projectRoot, task);
9009
+ const cached = cacheGet(key);
9010
+ if (cached) {
9011
+ return cached;
9012
+ }
9013
+ const graphReady = ctx.isGraphInitialized();
9014
+ let nodes = 0;
9015
+ let edges = 0;
9016
+ let lastBuildIso = null;
9017
+ const languages = [];
9018
+ let topHubs = [];
9019
+ if (graphReady) {
9020
+ try {
9021
+ const graph = await ctx.getGraph(projectRoot);
9022
+ nodes = graph.allFiles().length;
9023
+ edges = graph.allFiles().reduce((acc, f) => acc + graph.getImports(f).length, 0);
9024
+ const extensions = /* @__PURE__ */ new Set();
9025
+ for (const f of graph.allFiles()) {
9026
+ const ext = f.split(".").pop();
9027
+ if (ext && ext.length <= 4) extensions.add(ext);
9028
+ }
9029
+ languages.push(...Array.from(extensions).sort());
9030
+ topHubs = computeTopHubs(graph);
9031
+ } catch {
9032
+ }
9033
+ }
9034
+ const recentChanges = readRecentChanges(projectRoot);
9035
+ const suggested = routeFirstTool(task, recentChanges.length > 0);
9036
+ const staleness = classifyStaleness(lastBuildIso);
9037
+ const renderInput = {
9038
+ graphReady,
9039
+ nodes,
9040
+ edges,
9041
+ lastBuildIso,
9042
+ staleness,
9043
+ languages,
9044
+ recentChanges,
9045
+ topHubs,
9046
+ suggested
9047
+ };
9048
+ const full = render(renderInput);
9049
+ if (!hasBudgetArgs(parsed)) {
9050
+ cachePut(key, full);
9051
+ return full;
9052
+ }
9053
+ const result = await enforceBudget({
9054
+ ctx,
9055
+ full,
9056
+ args: readBudgetArgs(parsed),
9057
+ toolName: "ctx_get_minimal_context",
9058
+ defaultMaxTokens: DEFAULT_MAX_RESPONSE_TOKENS13,
9059
+ skeletonProducer: async () => renderSkeleton(renderInput)
9060
+ });
9061
+ const body = wrapResponse(result);
9062
+ cachePut(key, body);
9063
+ return body;
9064
+ }
9065
+ );
9066
+ }
9067
+
8451
9068
  // packages/core/src/tools/index.ts
8452
9069
  function createToolRegistry(ctx) {
8453
9070
  const registry = new ToolRegistry();
@@ -8484,6 +9101,7 @@ function createToolRegistry(ctx) {
8484
9101
  registerGitCouplingTool(registry, ctx);
8485
9102
  registerRiskOverlayTool(registry, ctx);
8486
9103
  registerGetAffectedFlowsTool(registry, ctx);
9104
+ registerMinimalContextTool(registry, ctx);
8487
9105
  return registry;
8488
9106
  }
8489
9107
 
@@ -9116,20 +9734,20 @@ import { readFileSync, writeFileSync, unlinkSync, mkdirSync, chmodSync, existsSy
9116
9734
  import path29 from "path";
9117
9735
 
9118
9736
  // packages/core/src/license/types.ts
9119
- import { z as z36 } from "zod";
9737
+ import { z as z37 } from "zod";
9120
9738
  var FINGERPRINT_RE = /^sha256:[0-9a-f]{64}$/;
9121
- var LicenseFileSchema = z36.object({
9122
- schemaVersion: z36.literal(1),
9123
- key: z36.string().min(1),
9124
- tier: z36.enum(["pro", "team", "enterprise", "trial"]),
9125
- status: z36.enum(["active", "trialing", "expired"]),
9126
- fingerprint: z36.string().regex(FINGERPRINT_RE),
9127
- seats: z36.number().int().positive(),
9128
- issuedAt: z36.string().datetime(),
9129
- expiresAt: z36.string().datetime(),
9130
- lastValidatedAt: z36.string().datetime(),
9131
- licenseId: z36.string().min(1),
9132
- instanceId: z36.string().min(1)
9739
+ var LicenseFileSchema = z37.object({
9740
+ schemaVersion: z37.literal(1),
9741
+ key: z37.string().min(1),
9742
+ tier: z37.enum(["pro", "team", "enterprise", "trial"]),
9743
+ status: z37.enum(["active", "trialing", "expired"]),
9744
+ fingerprint: z37.string().regex(FINGERPRINT_RE),
9745
+ seats: z37.number().int().positive(),
9746
+ issuedAt: z37.string().datetime(),
9747
+ expiresAt: z37.string().datetime(),
9748
+ lastValidatedAt: z37.string().datetime(),
9749
+ licenseId: z37.string().min(1),
9750
+ instanceId: z37.string().min(1)
9133
9751
  });
9134
9752
 
9135
9753
  // packages/core/src/license/LicenseStore.ts
@@ -9342,7 +9960,7 @@ var TELEMETRY_DISABLED = TELEMETRY_LEVEL === "off";
9342
9960
  function getTelemetryLevel() {
9343
9961
  return TELEMETRY_LEVEL;
9344
9962
  }
9345
- var CTXLOOM_VERSION = "1.3.0".length > 0 ? "1.3.0" : "dev";
9963
+ var CTXLOOM_VERSION = "1.4.0".length > 0 ? "1.4.0" : "dev";
9346
9964
  var POSTHOG_HOST = "https://eu.i.posthog.com";
9347
9965
  var POSTHOG_KEY = process.env["POSTHOG_API_KEY"] ?? (true ? "phc_CiDkmFLcZ2K6uCpcoSUQLmFrnnUvsyXGhSxopX5TVKE6" : "");
9348
9966
  var SENTRY_DSN = process.env["SENTRY_DSN"] ?? (true ? "https://81c94a0f04a8e242dee493ac1e17f733@o4508531702497280.ingest.de.sentry.io/4511256875368528" : "");
@@ -9966,6 +10584,732 @@ var EmittedOnceTracker = class {
9966
10584
  }
9967
10585
  };
9968
10586
 
10587
+ // packages/core/src/install/installer.ts
10588
+ import fs28 from "fs";
10589
+ import path36 from "path";
10590
+
10591
+ // packages/core/src/install/templates.ts
10592
+ var RULES_BLOCK_NAME = "CTXLOOM-RULES";
10593
+ var RULES_BLOCK_CONTENT = `## MCP Tools: ctxloom
10594
+
10595
+ **IMPORTANT: This project has a knowledge graph. ALWAYS use the
10596
+ ctxloom MCP tools BEFORE Grep/Glob/Read to explore the codebase.**
10597
+ The graph is faster, cheaper (fewer tokens), and gives you
10598
+ structural context (callers, dependents, test coverage) that file
10599
+ scanning cannot.
10600
+
10601
+ ### Start every workflow with \`ctx_get_minimal_context\`
10602
+
10603
+ The first MCP call into ctxloom should always be
10604
+ \`ctx_get_minimal_context(task="<what you're about to do>")\`. It
10605
+ returns ~150 tokens of orientation plus a task-aware
10606
+ \`suggested_first_tool\` you should call next instead of guessing.
10607
+
10608
+ ### When to use graph tools FIRST
10609
+
10610
+ - **Code review**: \`ctx_detect_changes\` + \`ctx_get_review_context\`
10611
+ instead of reading whole files
10612
+ - **Understanding impact**: \`ctx_blast_radius\` + \`ctx_get_affected_flows\`
10613
+ instead of manually tracing imports
10614
+ - **Refactor planning**: \`ctx_get_call_graph(direction: 'callers')\`
10615
+ + \`ctx_refactor_preview\` before any rename
10616
+ - **Architecture questions**: \`ctx_architecture_overview\`,
10617
+ \`ctx_community_list\`, \`ctx_hub_nodes\`
10618
+ - **Finding code**: \`ctx_search\` or \`ctx_full_text_search\` instead
10619
+ of \`Grep\`
10620
+
10621
+ Fall back to Grep/Glob/Read **only** when the graph doesn't cover
10622
+ what you need.
10623
+
10624
+ ### Follow the \`next_tool_suggestions\` in every response
10625
+
10626
+ Every budget-wrapped ctxloom response includes
10627
+ \`meta.next_tool_suggestions\` \u2014 author-curated follow-ups with
10628
+ \`why\` reasoning and \`estimated_tokens\` per entry. Pick from
10629
+ those instead of guessing.
10630
+
10631
+ ### Token-budget protocol
10632
+
10633
+ - Target: \u22648 tool calls per task, \u22642000 total tokens of graph context
10634
+ - Pass \`max_response_tokens\` on calls that might return large
10635
+ responses; the budget surface returns skeletons instead of dumping
10636
+ - Use \`response_format: 'skeleton'\` when you know you only need
10637
+ signatures, not bodies
10638
+
10639
+ ### Key tools at a glance
10640
+
10641
+ | Tool | Use when |
10642
+ |------|----------|
10643
+ | \`ctx_get_minimal_context\` | START HERE \u2014 orientation anchor |
10644
+ | \`ctx_detect_changes\` | Reviewing code changes; risk-scored |
10645
+ | \`ctx_get_review_context\` | Token-efficient review snippets |
10646
+ | \`ctx_blast_radius\` | Blast radius of a change |
10647
+ | \`ctx_get_affected_flows\` | Execution paths impacted |
10648
+ | \`ctx_get_call_graph\` | Callers / callees of a symbol |
10649
+ | \`ctx_search\` / \`ctx_full_text_search\` | Find code |
10650
+ | \`ctx_architecture_overview\` | High-level codebase map |
10651
+ | \`ctx_refactor_preview\` / \`ctx_apply_refactor\` | Plan a rename |
10652
+
10653
+ ### Hooks keep the graph fresh
10654
+
10655
+ \`ctxloom init\` installed a PostToolUse hook on \`Write|Edit\` that
10656
+ runs \`ctxloom update --incremental --quiet\` \u2014 so the graph is
10657
+ always up to date when you query it. No "did the index update yet?"
10658
+ guessing.`;
10659
+ var SESSION_START_HEADER = `#!/usr/bin/env bash
10660
+ # ctxloom \u2014 agent-harness session-start hook
10661
+ # Generated by \`ctxloom init\`. Re-run \`ctxloom init\` to update.
10662
+ # Manual edits will be overwritten on the next install.
10663
+
10664
+ set -e
10665
+ `;
10666
+ var SESSION_START_BODY = `DB=".ctxloom/graph.db"
10667
+
10668
+ if [ -f "$DB" ]; then
10669
+ # \`ctxloom status --json\` is cached + sub-100ms \u2014 keeps the hook
10670
+ # under its 2s timeout even on cold disk.
10671
+ STATS=$(ctxloom status --json 2>/dev/null || echo '{"nodes":0,"edges":0}')
10672
+ NODES=$(echo "$STATS" | grep -oE '"nodes":\\s*[0-9]+' | grep -oE '[0-9]+' || echo "?")
10673
+ EDGES=$(echo "$STATS" | grep -oE '"edges":\\s*[0-9]+' | grep -oE '[0-9]+' || echo "?")
10674
+
10675
+ cat <<EOF
10676
+ [ctxloom] Knowledge graph ready (\${NODES} nodes, \${EDGES} edges).
10677
+
10678
+ Start every workflow with \\\`ctx_get_minimal_context(task="...")\\\`.
10679
+ It returns ~150 tokens of orientation + a task-aware
10680
+ suggested_first_tool. Follow the meta.next_tool_suggestions on
10681
+ every response.
10682
+
10683
+ Prefer ctxloom MCP tools over Grep/Glob/Read:
10684
+ - ctx_detect_changes for code review
10685
+ - ctx_blast_radius / ctx_get_call_graph before refactoring
10686
+ - ctx_architecture_overview for orientation
10687
+ EOF
10688
+ else
10689
+ cat <<EOF
10690
+ [ctxloom] No knowledge graph found here.
10691
+ Run: ctxloom build
10692
+
10693
+ Then restart this session to enable graph-powered queries.
10694
+ EOF
10695
+ fi
10696
+ `;
10697
+ var SESSION_START_FULL = SESSION_START_HEADER + "\n" + SESSION_START_BODY;
10698
+ var CTXLOOM_HOOK_ENTRIES = {
10699
+ SessionStart: {
10700
+ matcher: "",
10701
+ hooks: [{ type: "command", command: ".claude/hooks/session-start.sh", timeout: 2 }]
10702
+ },
10703
+ PostToolUse: {
10704
+ matcher: "Write|Edit",
10705
+ hooks: [
10706
+ {
10707
+ type: "command",
10708
+ command: "ctxloom update --incremental --quiet",
10709
+ timeout: 30
10710
+ }
10711
+ ]
10712
+ }
10713
+ };
10714
+
10715
+ // packages/core/src/install/hmacBlock.ts
10716
+ import crypto6 from "crypto";
10717
+ var DEFAULT_HMAC_KEY = "ctxloom-agent-harness-v1-published";
10718
+ function resolveHmacKey() {
10719
+ return process.env.CTXLOOM_INSTALL_KEY ?? DEFAULT_HMAC_KEY;
10720
+ }
10721
+ function computeBlockHmac(content, key = resolveHmacKey()) {
10722
+ return crypto6.createHmac("sha256", key).update(content, "utf-8").digest("hex");
10723
+ }
10724
+ function wrapBlock(name, content) {
10725
+ const hmac = computeBlockHmac(content);
10726
+ return [
10727
+ `<!-- BEGIN ${name} v:1 hmac:sha256:${hmac} -->`,
10728
+ content,
10729
+ `<!-- END ${name} -->`
10730
+ ].join("\n");
10731
+ }
10732
+ var START_RE_TEMPLATE = (name) => new RegExp(`<!-- BEGIN ${escapeRegex(name)} v:(\\d+) hmac:sha256:([0-9a-f]{64}) -->`);
10733
+ var END_RE_TEMPLATE = (name) => new RegExp(`<!-- END ${escapeRegex(name)} -->`);
10734
+ function escapeRegex(s) {
10735
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
10736
+ }
10737
+ function extractBlock(fileContent, name) {
10738
+ const startRe = START_RE_TEMPLATE(name);
10739
+ const endRe = END_RE_TEMPLATE(name);
10740
+ const startMatch = startRe.exec(fileContent);
10741
+ if (!startMatch) return null;
10742
+ const startIdx = startMatch.index;
10743
+ const afterStart = startIdx + startMatch[0].length;
10744
+ endRe.lastIndex = afterStart;
10745
+ const endMatch = endRe.exec(fileContent.slice(afterStart));
10746
+ if (!endMatch) return null;
10747
+ const endIdx = afterStart + endMatch.index + endMatch[0].length;
10748
+ let inner = fileContent.slice(afterStart, afterStart + endMatch.index);
10749
+ if (inner.startsWith("\n")) inner = inner.slice(1);
10750
+ if (inner.endsWith("\n")) inner = inner.slice(0, -1);
10751
+ return {
10752
+ content: inner,
10753
+ declaredHmac: startMatch[2],
10754
+ version: Number(startMatch[1]),
10755
+ start: startIdx,
10756
+ end: endIdx
10757
+ };
10758
+ }
10759
+ function verifyBlock(block) {
10760
+ return computeBlockHmac(block.content) === block.declaredHmac;
10761
+ }
10762
+ function upsertBlock(fileContent, name, newContent) {
10763
+ const wrapped = wrapBlock(name, newContent);
10764
+ const existing = extractBlock(fileContent, name);
10765
+ if (existing) {
10766
+ return fileContent.slice(0, existing.start) + wrapped + fileContent.slice(existing.end);
10767
+ }
10768
+ const sep = fileContent.length === 0 || fileContent.endsWith("\n\n") ? "" : fileContent.endsWith("\n") ? "\n" : "\n\n";
10769
+ return fileContent + sep + wrapped + "\n";
10770
+ }
10771
+
10772
+ // packages/core/src/install/skillTemplates.ts
10773
+ var EXPLORE_CONTENT = `---
10774
+ name: ctxloom-explore
10775
+ description: Orient yourself to an unfamiliar codebase using ctxloom's structural graph. Architecture overview + communities + top hubs in \u22645 tool calls.
10776
+ ---
10777
+
10778
+ # Explore Codebase
10779
+
10780
+ Use this when you need to understand a codebase you haven't worked in
10781
+ before, or when re-orienting after time away.
10782
+
10783
+ ## Steps
10784
+
10785
+ 1. **Orientation anchor**: call \`ctx_get_minimal_context(task="explore this codebase")\`.
10786
+ The response includes graph stats, top hubs, and a
10787
+ \`suggested_first_tool\` \u2014 follow it (likely
10788
+ \`ctx_architecture_overview\`).
10789
+
10790
+ 2. **Architecture overview**: call \`ctx_architecture_overview(max_response_tokens=2000)\`.
10791
+ Returns the community structure + hub nodes + cross-community bridges.
10792
+
10793
+ 3. **Drill into the biggest communities**: from the overview's
10794
+ \`meta.next_tool_suggestions\`, call \`ctx_community_list\` for the
10795
+ top 1\u20132 communities. Skip communities labeled "tests" or
10796
+ "config" \u2014 they're usually peripheral.
10797
+
10798
+ 4. **Investigate the architectural bridges**: call \`ctx_bridge_nodes\`.
10799
+ Bridge nodes are high-leverage \u2014 changing one affects multiple
10800
+ communities. Read these first to understand the codebase's
10801
+ coupling story.
10802
+
10803
+ 5. **Tour the hubs**: call \`ctx_hub_nodes(limit=5, detail_level="minimal")\`.
10804
+ Top 5 most-depended-upon files. These are usually the heart of
10805
+ the codebase.
10806
+
10807
+ ## Budget
10808
+
10809
+ - \u22645 ctxloom tool calls
10810
+ - \u22642000 tokens total response budget
10811
+ - Don't \`ctx_get_file\` anything during exploration \u2014 signatures are
10812
+ enough. Drop to file reads only if a specific symbol needs
10813
+ inspection (and only via \`ctx_get_definition\`, not raw read).
10814
+
10815
+ ## Output
10816
+
10817
+ Summarize for the user:
10818
+ - Main communities (3\u20135 named clusters)
10819
+ - Top hubs by fan-in (the load-bearing files)
10820
+ - Top bridges (the architectural seams)
10821
+ - Recommended deep-dive starting points
10822
+ `;
10823
+ var BLAST_CONTENT = `---
10824
+ name: ctxloom-blast
10825
+ description: Compute blast radius + affected execution flows for a symbol or file before changing it. Pinpoints what will break.
10826
+ argument-hint: "<symbol-name | file-path>"
10827
+ ---
10828
+
10829
+ # Blast Radius
10830
+
10831
+ Use this before any change to a public function, type, or file
10832
+ where you're not sure who depends on it.
10833
+
10834
+ ## Inputs
10835
+
10836
+ - \`$ARGUMENTS\` \u2014 the symbol name (e.g. \`emitTelemetry\`) or file
10837
+ path (e.g. \`src/server.ts\`) you're about to modify.
10838
+
10839
+ ## Steps
10840
+
10841
+ 1. **Orientation**: call \`ctx_get_minimal_context(task="blast radius for $ARGUMENTS")\`.
10842
+
10843
+ 2. **Blast radius**: call \`ctx_blast_radius(target="$ARGUMENTS", max_response_tokens=1500)\`.
10844
+ Returns transitive dependents \u2014 every file (and indirectly,
10845
+ every flow) that would be affected by a breaking change.
10846
+
10847
+ 3. **Caller graph**: call \`ctx_get_call_graph(symbol="$ARGUMENTS", direction="callers", depth=2)\`.
10848
+ Direct + grandparent callers. Pair with the blast radius
10849
+ to distinguish "many transitive deps" from "load-bearing
10850
+ direct API."
10851
+
10852
+ 4. **Affected execution flows**: call \`ctx_get_affected_flows(target="$ARGUMENTS")\`.
10853
+ Maps the change to ordered execution sequences \u2014 useful for
10854
+ debugging "what user-facing path breaks?"
10855
+
10856
+ 5. **Test coverage check**: call \`ctx_knowledge_gaps(scope="$ARGUMENTS")\`.
10857
+ Highlights affected files lacking test coverage \u2014 those are the
10858
+ real risk surface.
10859
+
10860
+ ## Budget
10861
+
10862
+ - \u22645 ctxloom tool calls
10863
+ - \u22642000 tokens total
10864
+
10865
+ ## Output
10866
+
10867
+ Report to the user:
10868
+ - Total transitive dependents (number + top 5 by depth)
10869
+ - Direct callers (the API consumers)
10870
+ - Affected execution flows (named user-facing paths)
10871
+ - Coverage gaps on the affected files (the risk surface)
10872
+ - Recommendation: "safe to change" / "review carefully" / "needs migration plan"
10873
+ `;
10874
+ var REFACTOR_CONTENT = `---
10875
+ name: ctxloom-refactor-safely
10876
+ description: Plan and execute a rename or signature change with full caller-aware safety. Preview before applying.
10877
+ argument-hint: "<old-name> <new-name>"
10878
+ ---
10879
+
10880
+ # Refactor Safely
10881
+
10882
+ Use this for renames, signature changes, or function moves. The
10883
+ skill enforces preview-before-apply.
10884
+
10885
+ ## Inputs
10886
+
10887
+ - \`$1\` \u2014 current symbol name (e.g. \`emitTelemetry\`)
10888
+ - \`$2\` \u2014 target name (e.g. \`emitTelemetryEvent\`)
10889
+
10890
+ ## Steps
10891
+
10892
+ 1. **Orientation**: call \`ctx_get_minimal_context(task="refactor $1 to $2")\`.
10893
+
10894
+ 2. **Surface every caller**: call \`ctx_get_call_graph(symbol="$1", direction="callers", depth=1)\`.
10895
+ The exhaustive caller list. Without this, a rename can break
10896
+ files you didn't notice depended on the symbol.
10897
+
10898
+ 3. **Blast radius**: call \`ctx_blast_radius(target="$1")\`.
10899
+ Transitive impact \u2014 useful to gauge whether this should be a
10900
+ single PR or split with a deprecation period.
10901
+
10902
+ 4. **Generate the refactor preview**: call \`ctx_refactor_preview(symbol="$1", new_name="$2")\`.
10903
+ Returns a diff preview WITHOUT writing anything. Inspect it.
10904
+
10905
+ 5. **Confirm with the user**: show the preview summary
10906
+ (N files changed, M call sites updated). **DO NOT proceed to
10907
+ step 6 without explicit user confirmation.**
10908
+
10909
+ 6. **Apply the refactor**: call \`ctx_apply_refactor(symbol="$1", new_name="$2")\`.
10910
+ This writes the changes to disk. Irreversible without a git
10911
+ reset.
10912
+
10913
+ 7. **Verify**: call \`ctx_detect_changes\` and confirm the diff
10914
+ matches what the preview said. Surface any unexpected changes.
10915
+
10916
+ ## Safety rails
10917
+
10918
+ - ALWAYS run step 4 (preview) before step 6 (apply)
10919
+ - ALWAYS ask the user for confirmation between preview and apply
10920
+ - If preview shows >50 files affected, recommend splitting into
10921
+ a deprecation-style migration instead of a single rename
10922
+ - If the symbol is exported from a public API package, refuse to
10923
+ proceed and recommend the user open a tracked migration plan
10924
+
10925
+ ## Budget
10926
+
10927
+ - \u22647 ctxloom tool calls
10928
+ - \u22643000 tokens total (preview output can be larger than other skills)
10929
+ `;
10930
+ var COVERAGE_GAP_CONTENT = `---
10931
+ name: ctxloom-coverage-gap
10932
+ description: Identify code that lacks test coverage, prioritized by caller frequency and risk.
10933
+ ---
10934
+
10935
+ # Coverage Gap Analysis
10936
+
10937
+ Use this to find untested code that genuinely matters \u2014 the
10938
+ intersection of "no tests" + "many callers" + "high risk score."
10939
+
10940
+ ## Steps
10941
+
10942
+ 1. **Orientation**: call \`ctx_get_minimal_context(task="check test coverage")\`.
10943
+
10944
+ 2. **Knowledge gaps**: call \`ctx_knowledge_gaps(max_response_tokens=1200)\`.
10945
+ Lists every file lacking a \`tests_for\` graph edge. Raw list
10946
+ without prioritization.
10947
+
10948
+ 3. **Score by impact**: for each gap, call
10949
+ \`ctx_get_call_graph(symbol=<gap_symbol>, direction="callers")\`
10950
+ to count callers. High caller-count + no tests = high priority.
10951
+
10952
+ 4. **Cross-reference with churn**: call
10953
+ \`ctx_git_coupling(file=<gap_file>)\` for the top 5 gaps.
10954
+ Files churning often without tests are the urgent ones.
10955
+
10956
+ 5. **Risk overlay**: call \`ctx_risk_overlay(scope=<top_gap_files>)\`.
10957
+ Combines churn + coupling into a single risk score.
10958
+
10959
+ ## Output
10960
+
10961
+ Tabular report:
10962
+
10963
+ \`\`\`
10964
+ | File / Symbol | Callers | Churn | Risk | Recommendation |
10965
+ |---|---|---|---|---|
10966
+ | ... | ... | ... | ... | ... |
10967
+ \`\`\`
10968
+
10969
+ Recommendations should distinguish:
10970
+ - "Add tests now" (high caller count + high churn)
10971
+ - "Add tests during next change" (high caller count, low churn)
10972
+ - "Acceptable gap" (low caller count, low churn)
10973
+
10974
+ ## Budget
10975
+
10976
+ - \u22646 ctxloom tool calls
10977
+ - \u22642500 tokens total
10978
+ `;
10979
+ var REVIEW_PR_CONTENT = `---
10980
+ name: ctxloom-review-pr
10981
+ description: Multi-tier code review of a PR using ctxloom's structural graph. Risk-scored, blast-radius-aware, coverage-conscious.
10982
+ argument-hint: "<PR number | branch name>"
10983
+ ---
10984
+
10985
+ # Review PR
10986
+
10987
+ Comprehensive PR review using ctxloom's graph. Mirrors the
10988
+ multi-agent review the ctxloom-bot posts automatically \u2014 useful
10989
+ when reviewing manually or when the bot isn't wired up.
10990
+
10991
+ ## Inputs
10992
+
10993
+ - \`$ARGUMENTS\` \u2014 PR number (e.g. \`142\`) or branch name (e.g. \`feat/foo\`).
10994
+ Defaults to the current branch if unset.
10995
+
10996
+ ## Steps
10997
+
10998
+ 1. **Orientation**: call \`ctx_get_minimal_context(task="review PR $ARGUMENTS")\`.
10999
+
11000
+ 2. **Detect changes**: call \`ctx_detect_changes(base="main")\`.
11001
+ Risk-scored per-file analysis. Take the top 5 highest-risk files.
11002
+
11003
+ 3. **Pull source for the risky files**: call \`ctx_git_diff_review(base="main", max_response_tokens=4000)\`.
11004
+ Token-efficient diff packet covering the changed files.
11005
+
11006
+ 4. **Blast radius per high-risk file**: for the top 3 risky files,
11007
+ call \`ctx_blast_radius(target=<file>)\`. Surfaces files that the
11008
+ change indirectly affects but don't appear in the diff.
11009
+
11010
+ 5. **Affected flows**: call \`ctx_get_affected_flows(base="main")\`.
11011
+ Which execution paths the PR touches. Use to identify what
11012
+ integration tests should pass.
11013
+
11014
+ 6. **Coverage check**: call \`ctx_knowledge_gaps(scope=<changed_files>)\`.
11015
+ Surface changed files lacking tests.
11016
+
11017
+ 7. **Generate the review**: structured output with:
11018
+ - Risk summary (low/medium/high overall)
11019
+ - File-by-file findings (severity-ranked)
11020
+ - Coverage gaps that need addressing
11021
+ - Blast-radius observations the diff doesn't show
11022
+
11023
+ ## Tier discipline
11024
+
11025
+ This skill is the agent-driven equivalent of the bot's
11026
+ multi-specialist review. Use the same tier ladder:
11027
+
11028
+ - **T0 (structural)**: use the tools listed above \u2014 they're
11029
+ pre-fetched and cheap
11030
+ - **T1 (skeleton)**: \`ctx_get_definition\` for individual symbols
11031
+ - **T2 (full body)**: \`ctx_get_file\` only for files where the
11032
+ skeleton view is insufficient
11033
+ - **T3 (raw read)**: avoid; if the graph can't answer the question,
11034
+ prefer \`ctx_git_diff_review\` (token-efficient diff packet) over
11035
+ raw \`Read\`
11036
+
11037
+ ## Budget
11038
+
11039
+ - \u22648 ctxloom tool calls
11040
+ - \u22645000 tokens total (review needs more headroom than other skills)
11041
+
11042
+ ## Output format
11043
+
11044
+ \`\`\`
11045
+ ## PR Review: <title>
11046
+
11047
+ ### Summary
11048
+ <1\u20133 sentence overview>
11049
+
11050
+ ### Risk Assessment
11051
+ - Overall: Low / Medium / High
11052
+ - Blast radius: X files, Y flows impacted
11053
+ - Coverage: N changed symbols covered / M total
11054
+
11055
+ ### Findings
11056
+
11057
+ #### <file_path>
11058
+ - **Severity**: ...
11059
+ - **Issue**: ...
11060
+ - **Suggested fix**: ...
11061
+
11062
+ ### Coverage Gaps
11063
+
11064
+ <table>
11065
+
11066
+ ### Suggested follow-ups
11067
+
11068
+ <list>
11069
+ \`\`\`
11070
+ `;
11071
+ var BUDGET_STATS_CONTENT = `---
11072
+ name: ctxloom-budget-stats
11073
+ description: Inspect ctxloom's per-tool budget telemetry \u2014 fallback distribution + original-token p50/p75/p95 \u2014 to tune defaults from real usage.
11074
+ ---
11075
+
11076
+ # Budget Stats
11077
+
11078
+ Wrapper around \`ctxloom budget-stats\` for inline use inside a
11079
+ Claude Code session. Useful when:
11080
+
11081
+ - Tuning per-tool \`DEFAULT_MAX_RESPONSE_TOKENS\` from real usage
11082
+ (the Phase B follow-up)
11083
+ - Diagnosing why a tool keeps falling back to skeleton mode
11084
+ - Understanding which tools dominate the user's token budget
11085
+
11086
+ ## Steps
11087
+
11088
+ 1. **Orientation**: call \`ctx_get_minimal_context(task="inspect budget telemetry")\`.
11089
+ Cheap (~150 tokens). Confirms the graph is wired up \u2014 if it's
11090
+ not, the user's MCP server probably can't emit budget events
11091
+ either, and the stats will be empty.
11092
+
11093
+ 3. **Window selection**: ask the user how far back to look
11094
+ (default: 14d). Accept \`1d\`, \`7d\`, \`14d\`, \`30d\`.
11095
+
11096
+ 4. **Optional tool filter**: ask if they want stats for a specific
11097
+ tool (e.g. \`ctx_get_file\`) or all tools.
11098
+
11099
+ 5. **Run the CLI**: \`Bash\`-tool execute:
11100
+ \`ctxloom budget-stats --window=<N>d [--tool=<name>]\`
11101
+
11102
+ 6. **Parse + summarize the output**:
11103
+ - Top 3 tools by breach count \u2192 these are the candidates for
11104
+ budget tuning
11105
+ - For each top tool, the p75 column is the suggested next
11106
+ \`DEFAULT_MAX_RESPONSE_TOKENS\` value (rationale: 75% of
11107
+ real-world calls fit under p75; the rest fall back gracefully
11108
+ to skeletons)
11109
+
11110
+ 7. **Suggest concrete edits**: for each top tool, point to the
11111
+ source file (\`packages/core/src/tools/<tool>.ts\`) and the
11112
+ current constant. Don't apply edits without user confirmation.
11113
+
11114
+ ## Budget
11115
+
11116
+ - \u22642 ctxloom tool calls (this skill is mostly Bash + parsing)
11117
+ - \u22641500 tokens response total
11118
+
11119
+ ## Output
11120
+
11121
+ \`\`\`
11122
+ ## Budget stats \u2014 <window>
11123
+
11124
+ ### Top tools by breach count
11125
+ 1. <tool>: N breaches, skeleton%, p75=<tokens>
11126
+ 2. ...
11127
+
11128
+ ### Suggested DEFAULT_MAX_RESPONSE_TOKENS tuning
11129
+ - packages/core/src/tools/<tool>.ts: <current> \u2192 <suggested-p75>
11130
+ (rationale: ...)
11131
+ \`\`\`
11132
+ `;
11133
+ var CTXLOOM_SKILLS = [
11134
+ { name: "ctxloom-explore", content: EXPLORE_CONTENT },
11135
+ { name: "ctxloom-blast", content: BLAST_CONTENT },
11136
+ { name: "ctxloom-refactor-safely", content: REFACTOR_CONTENT },
11137
+ { name: "ctxloom-coverage-gap", content: COVERAGE_GAP_CONTENT },
11138
+ { name: "ctxloom-review-pr", content: REVIEW_PR_CONTENT },
11139
+ { name: "ctxloom-budget-stats", content: BUDGET_STATS_CONTENT }
11140
+ ];
11141
+ function skillFilePath(name) {
11142
+ return `.claude/skills/${name}/SKILL.md`;
11143
+ }
11144
+
11145
+ // packages/core/src/install/installer.ts
11146
+ function installHarness(opts = {}) {
11147
+ const cwd = opts.cwd ?? process.cwd();
11148
+ const projectRoot = path36.resolve(cwd);
11149
+ const stat = fs28.statSync(projectRoot);
11150
+ if (!stat.isDirectory()) {
11151
+ throw new Error(`installHarness: ${projectRoot} is not a directory`);
11152
+ }
11153
+ const dryRun = opts.dryRun === true;
11154
+ const force = opts.force === true;
11155
+ const warnings = [];
11156
+ const claudeMd = writeRulesBlock(projectRoot, "CLAUDE.md", { dryRun, force, warnings });
11157
+ const agentsMd = writeRulesBlock(projectRoot, "AGENTS.md", { dryRun, force, warnings });
11158
+ const geminiMd = writeRulesBlock(projectRoot, "GEMINI.md", { dryRun, force, warnings });
11159
+ const hooksJson = writeHooksJson(projectRoot, { dryRun, warnings });
11160
+ const sessionStartSh = writeSessionStartScript(projectRoot, { dryRun });
11161
+ const skills = CTXLOOM_SKILLS.map((s) => writeSkill(projectRoot, s, { dryRun }));
11162
+ return {
11163
+ projectRoot,
11164
+ claudeMd,
11165
+ agentsMd,
11166
+ geminiMd,
11167
+ hooksJson,
11168
+ sessionStartSh,
11169
+ skills,
11170
+ warnings
11171
+ };
11172
+ }
11173
+ function safeJoin(root, name) {
11174
+ const target = path36.resolve(root, name);
11175
+ const rootResolved = path36.resolve(root);
11176
+ if (!target.startsWith(rootResolved + path36.sep) && target !== rootResolved) {
11177
+ throw new Error(`installHarness: refusing to write outside project root: ${target}`);
11178
+ }
11179
+ return target;
11180
+ }
11181
+ function writeRulesBlock(projectRoot, filename, opts) {
11182
+ const filePath = safeJoin(projectRoot, filename);
11183
+ const existed = fs28.existsSync(filePath);
11184
+ const existing = existed ? fs28.readFileSync(filePath, "utf-8") : "";
11185
+ if (existed) {
11186
+ const block = extractBlock(existing, RULES_BLOCK_NAME);
11187
+ if (block) {
11188
+ const intact = verifyBlock(block);
11189
+ if (intact && block.content === RULES_BLOCK_CONTENT) {
11190
+ return { path: filePath, created: false, updated: false, alreadyCorrect: true, dryRun: opts.dryRun };
11191
+ }
11192
+ if (!intact && !opts.force) {
11193
+ opts.warnings.push(
11194
+ `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.`
11195
+ );
11196
+ return { path: filePath, created: false, updated: false, alreadyCorrect: false, dryRun: opts.dryRun };
11197
+ }
11198
+ }
11199
+ }
11200
+ const next = upsertBlock(existing, RULES_BLOCK_NAME, RULES_BLOCK_CONTENT);
11201
+ if (!opts.dryRun) {
11202
+ fs28.writeFileSync(filePath, next, "utf-8");
11203
+ }
11204
+ return {
11205
+ path: filePath,
11206
+ created: !existed,
11207
+ updated: existed,
11208
+ alreadyCorrect: false,
11209
+ dryRun: opts.dryRun
11210
+ };
11211
+ }
11212
+ function writeHooksJson(projectRoot, opts) {
11213
+ const dir = safeJoin(projectRoot, ".claude");
11214
+ const filePath = safeJoin(projectRoot, ".claude/hooks.json");
11215
+ const existed = fs28.existsSync(filePath);
11216
+ let current = {};
11217
+ if (existed) {
11218
+ try {
11219
+ const text = fs28.readFileSync(filePath, "utf-8");
11220
+ current = JSON.parse(text);
11221
+ } catch (err) {
11222
+ opts.warnings.push(
11223
+ `Could not parse existing ${path36.relative(projectRoot, filePath)}; treating as empty. (${err instanceof Error ? err.message : String(err)})`
11224
+ );
11225
+ current = {};
11226
+ }
11227
+ }
11228
+ const merged = { ...current };
11229
+ for (const event of ["SessionStart", "PostToolUse"]) {
11230
+ const incoming = CTXLOOM_HOOK_ENTRIES[event];
11231
+ const existingArr = Array.isArray(merged[event]) ? merged[event] : [];
11232
+ const filtered = existingArr.filter(
11233
+ (entry) => !isCtxloomEntry(entry, incoming.matcher)
11234
+ );
11235
+ merged[event] = [...filtered, incoming];
11236
+ }
11237
+ const nextJson = JSON.stringify(merged, null, 2) + "\n";
11238
+ let alreadyCorrect = false;
11239
+ if (existed) {
11240
+ const currentText = fs28.readFileSync(filePath, "utf-8");
11241
+ if (currentText === nextJson) alreadyCorrect = true;
11242
+ }
11243
+ if (!opts.dryRun && !alreadyCorrect) {
11244
+ fs28.mkdirSync(dir, { recursive: true });
11245
+ fs28.writeFileSync(filePath, nextJson, "utf-8");
11246
+ }
11247
+ return {
11248
+ path: filePath,
11249
+ created: !existed,
11250
+ updated: existed && !alreadyCorrect,
11251
+ alreadyCorrect,
11252
+ dryRun: opts.dryRun
11253
+ };
11254
+ }
11255
+ function isCtxloomEntry(entry, expectedMatcher) {
11256
+ if (entry.matcher !== expectedMatcher) return false;
11257
+ if (!Array.isArray(entry.hooks)) return false;
11258
+ return entry.hooks.some((h) => {
11259
+ if (!h || typeof h !== "object") return false;
11260
+ const cmd = h.command;
11261
+ if (typeof cmd !== "string") return false;
11262
+ return cmd.includes("ctxloom") || cmd.includes(".claude/hooks/session-start.sh");
11263
+ });
11264
+ }
11265
+ function writeSkill(projectRoot, skill, opts) {
11266
+ const dir = safeJoin(projectRoot, `.claude/skills/${skill.name}`);
11267
+ const filePath = safeJoin(projectRoot, skillFilePath(skill.name));
11268
+ const existed = fs28.existsSync(filePath);
11269
+ let alreadyCorrect = false;
11270
+ if (existed) {
11271
+ if (fs28.readFileSync(filePath, "utf-8") === skill.content) {
11272
+ alreadyCorrect = true;
11273
+ }
11274
+ }
11275
+ if (!opts.dryRun && !alreadyCorrect) {
11276
+ fs28.mkdirSync(dir, { recursive: true });
11277
+ fs28.writeFileSync(filePath, skill.content, "utf-8");
11278
+ }
11279
+ return {
11280
+ path: filePath,
11281
+ created: !existed,
11282
+ updated: existed && !alreadyCorrect,
11283
+ alreadyCorrect,
11284
+ dryRun: opts.dryRun
11285
+ };
11286
+ }
11287
+ function writeSessionStartScript(projectRoot, opts) {
11288
+ const dir = safeJoin(projectRoot, ".claude/hooks");
11289
+ const filePath = safeJoin(projectRoot, ".claude/hooks/session-start.sh");
11290
+ const existed = fs28.existsSync(filePath);
11291
+ let alreadyCorrect = false;
11292
+ if (existed) {
11293
+ const current = fs28.readFileSync(filePath, "utf-8");
11294
+ if (current === SESSION_START_FULL) alreadyCorrect = true;
11295
+ }
11296
+ if (!opts.dryRun && !alreadyCorrect) {
11297
+ fs28.mkdirSync(dir, { recursive: true });
11298
+ fs28.writeFileSync(filePath, SESSION_START_FULL, "utf-8");
11299
+ try {
11300
+ fs28.chmodSync(filePath, 493);
11301
+ } catch {
11302
+ }
11303
+ }
11304
+ return {
11305
+ path: filePath,
11306
+ created: !existed,
11307
+ updated: existed && !alreadyCorrect,
11308
+ alreadyCorrect,
11309
+ dryRun: opts.dryRun
11310
+ };
11311
+ }
11312
+
9969
11313
  export {
9970
11314
  GRAMMAR_MANIFEST,
9971
11315
  findGrammar,
@@ -10070,6 +11414,20 @@ export {
10070
11414
  noParseableSourcesWarning,
10071
11415
  wrapWithIndexingEnvelope,
10072
11416
  FirstTouchTracker,
10073
- EmittedOnceTracker
11417
+ EmittedOnceTracker,
11418
+ RULES_BLOCK_NAME,
11419
+ RULES_BLOCK_CONTENT,
11420
+ SESSION_START_FULL,
11421
+ CTXLOOM_HOOK_ENTRIES,
11422
+ DEFAULT_HMAC_KEY,
11423
+ resolveHmacKey,
11424
+ computeBlockHmac,
11425
+ wrapBlock,
11426
+ extractBlock,
11427
+ verifyBlock,
11428
+ upsertBlock,
11429
+ CTXLOOM_SKILLS,
11430
+ skillFilePath,
11431
+ installHarness
10074
11432
  };
10075
- //# sourceMappingURL=chunk-Q2KTZNNU.js.map
11433
+ //# sourceMappingURL=chunk-7GZVGIQL.js.map