ctxloom-pro 1.3.1 → 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.
@@ -3460,8 +3460,8 @@ var CoChangeIndex = class _CoChangeIndex {
3460
3460
  if (event.isBulk || event.isMerge) return;
3461
3461
  const paths = event.files.map((f) => f.path);
3462
3462
  if (paths.length === 0) return;
3463
- for (const path36 of paths) {
3464
- 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);
3465
3465
  }
3466
3466
  for (let i = 0; i < paths.length; i++) {
3467
3467
  for (let j = i + 1; j < paths.length; j++) {
@@ -3608,8 +3608,8 @@ var ChurnIndex = class _ChurnIndex {
3608
3608
  */
3609
3609
  snapshot() {
3610
3610
  const nodes = {};
3611
- for (const [path36, raw] of this.nodes) {
3612
- nodes[path36] = {
3611
+ for (const [path37, raw] of this.nodes) {
3612
+ nodes[path37] = {
3613
3613
  commits: raw.commits,
3614
3614
  churnLines: raw.churnLines,
3615
3615
  bugCommits: raw.bugCommits,
@@ -3624,8 +3624,8 @@ var ChurnIndex = class _ChurnIndex {
3624
3624
  */
3625
3625
  static load(s) {
3626
3626
  const idx = new _ChurnIndex();
3627
- for (const [path36, raw] of Object.entries(s.nodes)) {
3628
- idx.nodes.set(path36, {
3627
+ for (const [path37, raw] of Object.entries(s.nodes)) {
3628
+ idx.nodes.set(path37, {
3629
3629
  commits: raw.commits,
3630
3630
  churnLines: raw.churnLines,
3631
3631
  bugCommits: raw.bugCommits,
@@ -3638,8 +3638,8 @@ var ChurnIndex = class _ChurnIndex {
3638
3638
  // -------------------------------------------------------------------------
3639
3639
  // Private helpers
3640
3640
  // -------------------------------------------------------------------------
3641
- getOrCreate(path36) {
3642
- const existing = this.nodes.get(path36);
3641
+ getOrCreate(path37) {
3642
+ const existing = this.nodes.get(path37);
3643
3643
  if (existing !== void 0) return existing;
3644
3644
  const fresh = {
3645
3645
  commits: 0,
@@ -3648,7 +3648,7 @@ var ChurnIndex = class _ChurnIndex {
3648
3648
  authorCounts: {},
3649
3649
  lastTouch: 0
3650
3650
  };
3651
- this.nodes.set(path36, fresh);
3651
+ this.nodes.set(path37, fresh);
3652
3652
  return fresh;
3653
3653
  }
3654
3654
  };
@@ -3729,12 +3729,12 @@ var OwnershipIndex = class _OwnershipIndex {
3729
3729
  */
3730
3730
  snapshot() {
3731
3731
  const nodes = {};
3732
- for (const [path36, raw] of this.nodes) {
3732
+ for (const [path37, raw] of this.nodes) {
3733
3733
  const authorWeights = {};
3734
3734
  for (const [email, entry] of Object.entries(raw.authorWeights)) {
3735
3735
  authorWeights[email] = { ...entry };
3736
3736
  }
3737
- nodes[path36] = { authorWeights, lastTouch: raw.lastTouch };
3737
+ nodes[path37] = { authorWeights, lastTouch: raw.lastTouch };
3738
3738
  }
3739
3739
  return { version: 1, nodes };
3740
3740
  }
@@ -3743,23 +3743,23 @@ var OwnershipIndex = class _OwnershipIndex {
3743
3743
  */
3744
3744
  static load(s) {
3745
3745
  const idx = new _OwnershipIndex();
3746
- for (const [path36, raw] of Object.entries(s.nodes)) {
3746
+ for (const [path37, raw] of Object.entries(s.nodes)) {
3747
3747
  const authorWeights = {};
3748
3748
  for (const [email, entry] of Object.entries(raw.authorWeights)) {
3749
3749
  authorWeights[email] = { ...entry };
3750
3750
  }
3751
- idx.nodes.set(path36, { authorWeights, lastTouch: raw.lastTouch });
3751
+ idx.nodes.set(path37, { authorWeights, lastTouch: raw.lastTouch });
3752
3752
  }
3753
3753
  return idx;
3754
3754
  }
3755
3755
  // -------------------------------------------------------------------------
3756
3756
  // Private helpers
3757
3757
  // -------------------------------------------------------------------------
3758
- getOrCreate(path36) {
3759
- const existing = this.nodes.get(path36);
3758
+ getOrCreate(path37) {
3759
+ const existing = this.nodes.get(path37);
3760
3760
  if (existing !== void 0) return existing;
3761
3761
  const fresh = { authorWeights: {}, lastTouch: 0 };
3762
- this.nodes.set(path36, fresh);
3762
+ this.nodes.set(path37, fresh);
3763
3763
  return fresh;
3764
3764
  }
3765
3765
  };
@@ -4565,7 +4565,7 @@ function renderStatusXml(input) {
4565
4565
  return lines.join("\n");
4566
4566
  }
4567
4567
  function registerStatusTool(registry, ctx) {
4568
- const Schema30 = z2.object({ project_root: ProjectRootField });
4568
+ const Schema31 = z2.object({ project_root: ProjectRootField });
4569
4569
  registry.register(
4570
4570
  "ctx_status",
4571
4571
  {
@@ -4579,7 +4579,7 @@ function registerStatusTool(registry, ctx) {
4579
4579
  }
4580
4580
  },
4581
4581
  async (args) => {
4582
- const { project_root } = Schema30.parse(args ?? {});
4582
+ const { project_root } = Schema31.parse(args ?? {});
4583
4583
  void project_root;
4584
4584
  return renderStatusXml({
4585
4585
  defaultRoot: ctx.noDefaultMode ? null : ctx.projectRoot,
@@ -4614,6 +4614,325 @@ var ToolRegistry = class {
4614
4614
  // packages/core/src/tools/search.ts
4615
4615
  import { z as z3 } from "zod";
4616
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
+
4617
4936
  // packages/core/src/budget/budget.ts
4618
4937
  var defaultTokenEstimator = (text) => Math.ceil(text.length / 4);
4619
4938
  function hasBudgetArgs(args) {
@@ -4649,16 +4968,21 @@ async function enforceBudget(opts) {
4649
4968
  const { full, args, toolName, defaultMaxTokens, skeletonProducer } = opts;
4650
4969
  const estimate = opts.estimator ?? defaultTokenEstimator;
4651
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
+ };
4652
4976
  const originalTokens = estimate(full);
4653
4977
  if (isBudgetDisabled()) {
4654
4978
  return {
4655
4979
  text: full,
4656
- meta: {
4980
+ meta: withSuggestions({
4657
4981
  format: "full",
4658
4982
  original_tokens_est: originalTokens,
4659
4983
  returned_tokens_est: originalTokens,
4660
4984
  fallback_reason: null
4661
- }
4985
+ })
4662
4986
  };
4663
4987
  }
4664
4988
  if (args.response_format === "skeleton" && skeletonProducer) {
@@ -4667,45 +4991,45 @@ async function enforceBudget(opts) {
4667
4991
  const skTokens = estimate(skeleton2);
4668
4992
  return {
4669
4993
  text: skeleton2,
4670
- meta: {
4994
+ meta: withSuggestions({
4671
4995
  format: "skeleton",
4672
4996
  original_tokens_est: originalTokens,
4673
4997
  returned_tokens_est: skTokens,
4674
4998
  fallback_reason: null
4675
- }
4999
+ })
4676
5000
  };
4677
5001
  }
4678
5002
  return {
4679
5003
  text: full,
4680
- meta: {
5004
+ meta: withSuggestions({
4681
5005
  format: "full",
4682
5006
  original_tokens_est: originalTokens,
4683
5007
  returned_tokens_est: originalTokens,
4684
5008
  fallback_reason: "skeleton_failed"
4685
- }
5009
+ })
4686
5010
  };
4687
5011
  }
4688
5012
  const budget = args.max_response_tokens ?? defaultMaxTokens;
4689
5013
  if (budget === void 0) {
4690
5014
  return {
4691
5015
  text: full,
4692
- meta: {
5016
+ meta: withSuggestions({
4693
5017
  format: "full",
4694
5018
  original_tokens_est: originalTokens,
4695
5019
  returned_tokens_est: originalTokens,
4696
5020
  fallback_reason: null
4697
- }
5021
+ })
4698
5022
  };
4699
5023
  }
4700
5024
  if (originalTokens <= budget) {
4701
5025
  return {
4702
5026
  text: full,
4703
- meta: {
5027
+ meta: withSuggestions({
4704
5028
  format: "full",
4705
5029
  original_tokens_est: originalTokens,
4706
5030
  returned_tokens_est: originalTokens,
4707
5031
  fallback_reason: null
4708
- }
5032
+ })
4709
5033
  };
4710
5034
  }
4711
5035
  emitTelemetry({
@@ -4736,12 +5060,12 @@ async function enforceBudget(opts) {
4736
5060
  }, sink);
4737
5061
  return {
4738
5062
  text: sliced2,
4739
- meta: {
5063
+ meta: withSuggestions({
4740
5064
  format: "truncated",
4741
5065
  original_tokens_est: originalTokens,
4742
5066
  returned_tokens_est: slicedTokens,
4743
5067
  fallback_reason: "budget_exceeded"
4744
- }
5068
+ })
4745
5069
  };
4746
5070
  }
4747
5071
  const skeleton = skeletonProducer ? await safeSkeleton(skeletonProducer, toolName) : null;
@@ -4756,12 +5080,12 @@ async function enforceBudget(opts) {
4756
5080
  }, sink);
4757
5081
  return {
4758
5082
  text: skeleton,
4759
- meta: {
5083
+ meta: withSuggestions({
4760
5084
  format: "skeleton",
4761
5085
  original_tokens_est: originalTokens,
4762
5086
  returned_tokens_est: skTokens,
4763
5087
  fallback_reason: "budget_exceeded"
4764
- }
5088
+ })
4765
5089
  };
4766
5090
  }
4767
5091
  const slicedSk = skeleton.slice(0, budget * 4);
@@ -4773,12 +5097,12 @@ async function enforceBudget(opts) {
4773
5097
  }, sink);
4774
5098
  return {
4775
5099
  text: slicedSk,
4776
- meta: {
5100
+ meta: withSuggestions({
4777
5101
  format: "truncated",
4778
5102
  original_tokens_est: originalTokens,
4779
5103
  returned_tokens_est: estimate(slicedSk),
4780
5104
  fallback_reason: "budget_exceeded"
4781
- }
5105
+ })
4782
5106
  };
4783
5107
  }
4784
5108
  const sliced = full.slice(0, budget * 4);
@@ -4790,12 +5114,12 @@ async function enforceBudget(opts) {
4790
5114
  }, sink);
4791
5115
  return {
4792
5116
  text: sliced,
4793
- meta: {
5117
+ meta: withSuggestions({
4794
5118
  format: "truncated",
4795
5119
  original_tokens_est: originalTokens,
4796
5120
  returned_tokens_est: estimate(sliced),
4797
5121
  fallback_reason: "skeleton_failed"
4798
- }
5122
+ })
4799
5123
  };
4800
5124
  }
4801
5125
  async function safeSkeleton(producer, toolName) {
@@ -6454,7 +6778,7 @@ function registerGitDiffReviewTool(registry, ctx) {
6454
6778
  skeleton: include_skeletons && i < skeletonLimit ? await trySkeletonize(ctx, file, project_root) : ""
6455
6779
  }))
6456
6780
  );
6457
- const render = (withSkeletons, withTransitive) => {
6781
+ const render2 = (withSkeletons, withTransitive) => {
6458
6782
  const out = [`<git_diff_review changed_files="${files.length}" depth="${depth}">`];
6459
6783
  out.push(` <changed_files count="${files.length}">`);
6460
6784
  for (const cd of changedFileData) {
@@ -6500,8 +6824,8 @@ function registerGitDiffReviewTool(registry, ctx) {
6500
6824
  out.push("</git_diff_review>");
6501
6825
  return out.join("\n");
6502
6826
  };
6503
- const full = render(include_skeletons, true);
6504
- return maybeBudget(full, async () => render(false, false));
6827
+ const full = render2(include_skeletons, true);
6828
+ return maybeBudget(full, async () => render2(false, false));
6505
6829
  }
6506
6830
  );
6507
6831
  }
@@ -6603,7 +6927,7 @@ function registerRefactorPreviewTool(registry, ctx) {
6603
6927
  totalOccurrences += occurrences.length;
6604
6928
  }
6605
6929
  }
6606
- const render = (includeChanges) => {
6930
+ const render2 = (includeChanges) => {
6607
6931
  const xmlLines = [
6608
6932
  `<refactor_preview symbol="${escapeXML17(symbol)}" new_name="${escapeXML17(new_name)}" total_files="${fileChanges.length}" total_occurrences="${totalOccurrences}">`
6609
6933
  ];
@@ -6633,7 +6957,7 @@ function registerRefactorPreviewTool(registry, ctx) {
6633
6957
  xmlLines.push("</refactor_preview>");
6634
6958
  return xmlLines.join("\n");
6635
6959
  };
6636
- const full = render(true);
6960
+ const full = render2(true);
6637
6961
  if (!hasBudgetArgs(args)) return full;
6638
6962
  const result = await enforceBudget({
6639
6963
  ctx,
@@ -6641,7 +6965,7 @@ function registerRefactorPreviewTool(registry, ctx) {
6641
6965
  args: readBudgetArgs(args),
6642
6966
  toolName: "ctx_refactor_preview",
6643
6967
  defaultMaxTokens: DEFAULT_MAX_RESPONSE_TOKENS7,
6644
- skeletonProducer: async () => render(false)
6968
+ skeletonProducer: async () => render2(false)
6645
6969
  });
6646
6970
  return wrapResponse(result);
6647
6971
  }
@@ -7004,7 +7328,7 @@ function registerCrossRepoSearchTool(registry, ctx, registryFilePath) {
7004
7328
  );
7005
7329
  }
7006
7330
  xmlLines.push(" </repos>");
7007
- const render = (includeContent) => {
7331
+ const render2 = (includeContent) => {
7008
7332
  const out = [...xmlLines];
7009
7333
  out.push(` <results count="${topResults.length}">`);
7010
7334
  for (const r of topResults) {
@@ -7020,7 +7344,7 @@ function registerCrossRepoSearchTool(registry, ctx, registryFilePath) {
7020
7344
  out.push("</cross_repo_search>");
7021
7345
  return out.join("\n");
7022
7346
  };
7023
- const full = render(true);
7347
+ const full = render2(true);
7024
7348
  if (!hasBudgetArgs(args)) return full;
7025
7349
  const result = await enforceBudget({
7026
7350
  ctx,
@@ -7028,7 +7352,7 @@ function registerCrossRepoSearchTool(registry, ctx, registryFilePath) {
7028
7352
  args: readBudgetArgs(args),
7029
7353
  toolName: "ctx_cross_repo_search",
7030
7354
  defaultMaxTokens: DEFAULT_MAX_RESPONSE_TOKENS9,
7031
- skeletonProducer: async () => render(false)
7355
+ skeletonProducer: async () => render2(false)
7032
7356
  });
7033
7357
  return wrapResponse(result);
7034
7358
  }
@@ -7415,7 +7739,7 @@ function registerFullTextSearchTool(registry, ctx) {
7415
7739
  } catch {
7416
7740
  }
7417
7741
  }
7418
- const render = (includeSnippets) => {
7742
+ const render2 = (includeSnippets) => {
7419
7743
  const xml = [
7420
7744
  `<full_text_search query="${escapeXML22(query)}" mode="${mode}" case_sensitive="${case_sensitive}" count="${merged.length}">`
7421
7745
  ];
@@ -7434,8 +7758,8 @@ function registerFullTextSearchTool(registry, ctx) {
7434
7758
  return xml.join("\n");
7435
7759
  };
7436
7760
  return maybeBudget(
7437
- render(true),
7438
- async () => render(false)
7761
+ render2(true),
7762
+ async () => render2(false)
7439
7763
  );
7440
7764
  }
7441
7765
  );
@@ -8468,6 +8792,279 @@ function registerGetAffectedFlowsTool(registry, ctx) {
8468
8792
  );
8469
8793
  }
8470
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
+
8471
9068
  // packages/core/src/tools/index.ts
8472
9069
  function createToolRegistry(ctx) {
8473
9070
  const registry = new ToolRegistry();
@@ -8504,6 +9101,7 @@ function createToolRegistry(ctx) {
8504
9101
  registerGitCouplingTool(registry, ctx);
8505
9102
  registerRiskOverlayTool(registry, ctx);
8506
9103
  registerGetAffectedFlowsTool(registry, ctx);
9104
+ registerMinimalContextTool(registry, ctx);
8507
9105
  return registry;
8508
9106
  }
8509
9107
 
@@ -9136,20 +9734,20 @@ import { readFileSync, writeFileSync, unlinkSync, mkdirSync, chmodSync, existsSy
9136
9734
  import path29 from "path";
9137
9735
 
9138
9736
  // packages/core/src/license/types.ts
9139
- import { z as z36 } from "zod";
9737
+ import { z as z37 } from "zod";
9140
9738
  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)
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)
9153
9751
  });
9154
9752
 
9155
9753
  // packages/core/src/license/LicenseStore.ts
@@ -9362,7 +9960,7 @@ var TELEMETRY_DISABLED = TELEMETRY_LEVEL === "off";
9362
9960
  function getTelemetryLevel() {
9363
9961
  return TELEMETRY_LEVEL;
9364
9962
  }
9365
- var CTXLOOM_VERSION = "1.3.1".length > 0 ? "1.3.1" : "dev";
9963
+ var CTXLOOM_VERSION = "1.4.0".length > 0 ? "1.4.0" : "dev";
9366
9964
  var POSTHOG_HOST = "https://eu.i.posthog.com";
9367
9965
  var POSTHOG_KEY = process.env["POSTHOG_API_KEY"] ?? (true ? "phc_CiDkmFLcZ2K6uCpcoSUQLmFrnnUvsyXGhSxopX5TVKE6" : "");
9368
9966
  var SENTRY_DSN = process.env["SENTRY_DSN"] ?? (true ? "https://81c94a0f04a8e242dee493ac1e17f733@o4508531702497280.ingest.de.sentry.io/4511256875368528" : "");
@@ -9986,6 +10584,732 @@ var EmittedOnceTracker = class {
9986
10584
  }
9987
10585
  };
9988
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
+
9989
11313
  export {
9990
11314
  GRAMMAR_MANIFEST,
9991
11315
  findGrammar,
@@ -10090,6 +11414,20 @@ export {
10090
11414
  noParseableSourcesWarning,
10091
11415
  wrapWithIndexingEnvelope,
10092
11416
  FirstTouchTracker,
10093
- 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
10094
11432
  };
10095
- //# sourceMappingURL=chunk-TIYTPWYN.js.map
11433
+ //# sourceMappingURL=chunk-7GZVGIQL.js.map