pi-lens 3.3.0 → 3.6.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/README.md +175 -13
  3. package/clients/cache/rule-cache.js +72 -0
  4. package/clients/cache/rule-cache.ts +104 -0
  5. package/clients/dispatch/integration.js +48 -1
  6. package/clients/dispatch/integration.ts +60 -2
  7. package/clients/dispatch/plan.js +5 -2
  8. package/clients/dispatch/plan.ts +5 -2
  9. package/clients/dispatch/runners/ast-grep-napi.js +175 -56
  10. package/clients/dispatch/runners/ast-grep-napi.test.js +2 -1
  11. package/clients/dispatch/runners/ast-grep-napi.test.ts +2 -1
  12. package/clients/dispatch/runners/ast-grep-napi.ts +191 -79
  13. package/clients/dispatch/runners/similarity.js +1 -1
  14. package/clients/dispatch/runners/similarity.ts +2 -2
  15. package/clients/dispatch/runners/tree-sitter.js +137 -10
  16. package/clients/dispatch/runners/tree-sitter.ts +168 -13
  17. package/clients/dispatch/runners/ts-lsp.js +3 -2
  18. package/clients/dispatch/runners/ts-lsp.ts +3 -2
  19. package/clients/dispatch/runners/yaml-rule-parser.js +70 -2
  20. package/clients/dispatch/runners/yaml-rule-parser.ts +71 -2
  21. package/clients/dispatch/types.js +1 -1
  22. package/clients/dispatch/types.ts +1 -1
  23. package/clients/lsp/__tests__/service.test.js +3 -0
  24. package/clients/lsp/__tests__/service.test.ts +3 -0
  25. package/clients/lsp/client.js +42 -0
  26. package/clients/lsp/client.ts +79 -0
  27. package/clients/lsp/index.js +27 -0
  28. package/clients/lsp/index.ts +35 -0
  29. package/clients/lsp/launch.js +11 -6
  30. package/clients/lsp/launch.ts +11 -6
  31. package/clients/metrics-client.js +3 -160
  32. package/clients/metrics-client.tdr.test.js +78 -0
  33. package/clients/metrics-client.test.js +30 -43
  34. package/clients/metrics-client.test.ts +30 -54
  35. package/clients/metrics-client.ts +5 -219
  36. package/clients/metrics-history.js +33 -7
  37. package/clients/metrics-history.ts +47 -10
  38. package/clients/pipeline.js +272 -0
  39. package/clients/pipeline.ts +371 -0
  40. package/clients/sg-runner.js +21 -3
  41. package/clients/sg-runner.ts +22 -3
  42. package/clients/tree-sitter-client.js +23 -2
  43. package/clients/tree-sitter-client.ts +27 -2
  44. package/index.ts +604 -771
  45. package/package.json +1 -1
  46. package/rules/ast-grep-rules/rules/no-architecture-violation.yml +7 -4
  47. package/rules/ast-grep-rules/rules/no-single-char-var.yml +3 -3
  48. package/rules/ast-grep-rules/slop-patterns.yml +85 -62
  49. package/skills/ast-grep/SKILL.md +42 -1
  50. package/skills/lsp-navigation/SKILL.md +62 -0
  51. package/tsconfig.json +1 -1
  52. package/rules/ast-grep-rules/rules/no-console-log.yml +0 -10
  53. package/rules/ast-grep-rules/rules/no-default-export.yml +0 -19
package/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as nodeFs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
+ import { fileURLToPath } from "node:url";
4
5
  // RELOADED: Testing format/lsp flow on large file
5
6
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
6
7
  import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
@@ -12,7 +13,8 @@ import { BiomeClient } from "./clients/biome-client.js";
12
13
  import { CacheManager } from "./clients/cache-manager.js";
13
14
  import { ComplexityClient } from "./clients/complexity-client.js";
14
15
  import { DependencyChecker } from "./clients/dependency-checker.js";
15
- import { dispatchLint } from "./clients/dispatch/integration.js";
16
+ import { resetDispatchBaselines } from "./clients/dispatch/integration.js";
17
+ import { extractFunctions } from "./clients/dispatch/runners/similarity.js";
16
18
  import { createFileTime, FileTimeError } from "./clients/file-time.js";
17
19
  import {
18
20
  getFormatService,
@@ -28,6 +30,13 @@ import { logLatency } from "./clients/latency-logger.js";
28
30
  import { getLSPService, resetLSPService } from "./clients/lsp/index.js";
29
31
  import { MetricsClient } from "./clients/metrics-client.js";
30
32
  import { captureSnapshot } from "./clients/metrics-history.js";
33
+ import { runPipeline } from "./clients/pipeline.js";
34
+ import {
35
+ buildProjectIndex,
36
+ findSimilarFunctions,
37
+ loadIndex,
38
+ type ProjectIndex,
39
+ } from "./clients/project-index.js";
31
40
  import { RuffClient } from "./clients/ruff-client.js";
32
41
  import {
33
42
  formatRulesForPrompt,
@@ -36,7 +45,6 @@ import {
36
45
  } from "./clients/rules-scanner.js";
37
46
  import { RustClient } from "./clients/rust-client.js";
38
47
  import { getSourceFiles } from "./clients/scan-utils.js";
39
- import { formatSecrets, scanForSecrets } from "./clients/secrets-scanner.js";
40
48
  import { TestRunnerClient } from "./clients/test-runner-client.js";
41
49
  import { TodoScanner } from "./clients/todo-scanner.js";
42
50
  import { TypeCoverageClient } from "./clients/type-coverage-client.js";
@@ -349,9 +357,11 @@ export default function (pi: ExtensionAPI) {
349
357
  `Total cognitive complexity: ${tdi.totalCognitive}`,
350
358
  ``,
351
359
  `Debt breakdown:`,
352
- ` Maintainability: ${tdi.byCategory.maintainability}%`,
353
- ` Complexity: ${tdi.byCategory.complexity}%`,
360
+ ` Maintainability: ${tdi.byCategory.maintainability}% (MI-based)`,
361
+ ` Cognitive: ${tdi.byCategory.cognitive}%`,
354
362
  ` Nesting: ${tdi.byCategory.nesting}%`,
363
+ ` Max Cyclomatic: ${tdi.byCategory.maxCyclomatic}% (worst function)`,
364
+ ` Entropy: ${tdi.byCategory.entropy}% (code unpredictability)`,
355
365
  ``,
356
366
  tdi.score <= 30
357
367
  ? "✅ Codebase is healthy!"
@@ -453,7 +463,20 @@ export default function (pi: ExtensionAPI) {
453
463
  name: "ast_grep_search",
454
464
  label: "AST Search",
455
465
  description:
456
- "Search code using AST-aware pattern matching. IMPORTANT: Use specific AST patterns, NOT text search. Examples:\n- Find function: 'function $NAME() { $$$BODY }'\n- Find call: 'fetchMetrics($ARGS)'\n- Find import: 'import { $NAMES } from \"$PATH\"'\n- Generic identifier (broad): 'fetchMetrics'\n\nAlways prefer specific patterns with context over bare identifiers. Use 'paths' to scope to specific files/folders. Use 'selector' to extract specific nodes (e.g., just the function name). Use 'context' to show surrounding lines.",
466
+ "Search code using AST-aware pattern matching. IMPORTANT: Use specific AST patterns, NOT text search.\n\n" +
467
+ "✅ GOOD patterns (single AST node):\n" +
468
+ " - function $NAME() { $$$BODY } (function declaration)\n" +
469
+ " - fetchMetrics($ARGS) (function call)\n" +
470
+ ' - import { $NAMES } from "$PATH" (import statement)\n' +
471
+ " - console.log($MSG) (method call)\n\n" +
472
+ "❌ BAD patterns (multiple nodes / raw text):\n" +
473
+ ' - it"test name" (missing parens - use it($TEST))\n' +
474
+ " - console.log without args (incomplete code)\n" +
475
+ " - arbitrary text without code structure\n\n" +
476
+ "Always prefer specific patterns with context over bare identifiers. " +
477
+ "Use 'paths' to scope to specific files/folders. " +
478
+ "Use 'selector' to extract specific nodes (e.g., just the function name). " +
479
+ "Use 'context' to show surrounding lines.",
457
480
  promptSnippet: "Use ast_grep_search for AST-aware code search",
458
481
  parameters: Type.Object({
459
482
  pattern: Type.String({
@@ -527,7 +550,16 @@ export default function (pi: ExtensionAPI) {
527
550
  name: "ast_grep_replace",
528
551
  label: "AST Replace",
529
552
  description:
530
- "Replace code using AST-aware pattern matching. IMPORTANT: Use specific AST patterns, not text. Dry-run by default (use apply=true to apply).\n\nExamples:\n- pattern='console.log($MSG)' rewrite='logger.info($MSG)'\n- pattern='var $X' rewrite='let $X'\n- pattern='function $NAME() { }' rewrite='' (delete)\n\nAlways use 'paths' to scope to specific files/folders. Dry-run first to preview changes.",
553
+ "Replace code using AST-aware pattern matching. IMPORTANT: Use specific AST patterns, not text. Dry-run by default (use apply=true to apply).\n\n" +
554
+ "✅ GOOD patterns (single AST node):\n" +
555
+ " - pattern='console.log($MSG)' rewrite='logger.info($MSG)'\n" +
556
+ " - pattern='var $X' rewrite='let $X'\n" +
557
+ " - pattern='function $NAME() { }' rewrite='' (delete)\n\n" +
558
+ "❌ BAD patterns (will error):\n" +
559
+ " - Raw text without code structure\n" +
560
+ ' - Missing parentheses: use it($TEST) not it"text"\n' +
561
+ " - Incomplete code fragments\n\n" +
562
+ "Always use 'paths' to scope to specific files/folders. Dry-run first to preview changes.",
531
563
  promptSnippet: "Use ast_grep_replace for AST-aware find-and-replace",
532
564
  parameters: Type.Object({
533
565
  pattern: Type.String({
@@ -612,7 +644,10 @@ export default function (pi: ExtensionAPI) {
612
644
  "- hover: Get type/doc info at a position\n" +
613
645
  "- documentSymbol: List all symbols (functions/classes/vars) in a file\n" +
614
646
  "- workspaceSymbol: Search symbols across the whole project\n" +
615
- "- implementation: Jump to interface implementations\n\n" +
647
+ "- implementation: Jump to interface implementations\n" +
648
+ "- prepareCallHierarchy: Get callable item at position (for incoming/outgoing)\n" +
649
+ "- incomingCalls: Find all functions/methods that CALL this function\n" +
650
+ "- outgoingCalls: Find all functions/methods CALLED by this function\n\n" +
616
651
  "Line and character are 1-based (as shown in editors).",
617
652
  promptSnippet:
618
653
  "Use lsp_navigation to find definitions, references, and hover info via LSP",
@@ -625,6 +660,9 @@ export default function (pi: ExtensionAPI) {
625
660
  Type.Literal("documentSymbol"),
626
661
  Type.Literal("workspaceSymbol"),
627
662
  Type.Literal("implementation"),
663
+ Type.Literal("prepareCallHierarchy"),
664
+ Type.Literal("incomingCalls"),
665
+ Type.Literal("outgoingCalls"),
628
666
  ],
629
667
  { description: "LSP operation to perform" },
630
668
  ),
@@ -648,6 +686,39 @@ export default function (pi: ExtensionAPI) {
648
686
  description: "Symbol name to search. Used by workspaceSymbol",
649
687
  }),
650
688
  ),
689
+ callHierarchyItem: Type.Optional(
690
+ Type.Object(
691
+ {
692
+ name: Type.String(),
693
+ kind: Type.Number(),
694
+ uri: Type.String(),
695
+ range: Type.Object({
696
+ start: Type.Object({
697
+ line: Type.Number(),
698
+ character: Type.Number(),
699
+ }),
700
+ end: Type.Object({
701
+ line: Type.Number(),
702
+ character: Type.Number(),
703
+ }),
704
+ }),
705
+ selectionRange: Type.Object({
706
+ start: Type.Object({
707
+ line: Type.Number(),
708
+ character: Type.Number(),
709
+ }),
710
+ end: Type.Object({
711
+ line: Type.Number(),
712
+ character: Type.Number(),
713
+ }),
714
+ }),
715
+ },
716
+ {
717
+ description:
718
+ "Call hierarchy item. Required for incomingCalls/outgoingCalls",
719
+ },
720
+ ),
721
+ ),
651
722
  }),
652
723
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
653
724
  if (!pi.getFlag("lens-lsp")) {
@@ -734,6 +805,43 @@ export default function (pi: ExtensionAPI) {
734
805
  lspChar,
735
806
  );
736
807
  break;
808
+ case "prepareCallHierarchy":
809
+ result = await lspService.prepareCallHierarchy(
810
+ filePath,
811
+ lspLine,
812
+ lspChar,
813
+ );
814
+ break;
815
+ case "incomingCalls":
816
+ if (!params.callHierarchyItem) {
817
+ return {
818
+ content: [
819
+ {
820
+ type: "text" as const,
821
+ text: "callHierarchyItem parameter required for incomingCalls",
822
+ },
823
+ ],
824
+ isError: true,
825
+ details: {},
826
+ };
827
+ }
828
+ result = await lspService.incomingCalls(params.callHierarchyItem);
829
+ break;
830
+ case "outgoingCalls":
831
+ if (!params.callHierarchyItem) {
832
+ return {
833
+ content: [
834
+ {
835
+ type: "text" as const,
836
+ text: "callHierarchyItem parameter required for outgoingCalls",
837
+ },
838
+ ],
839
+ isError: true,
840
+ details: {},
841
+ };
842
+ }
843
+ result = await lspService.outgoingCalls(params.callHierarchyItem);
844
+ break;
737
845
  default:
738
846
  result = [];
739
847
  }
@@ -768,6 +876,7 @@ export default function (pi: ExtensionAPI) {
768
876
  let _cachedJscpdClones: import("./clients/jscpd-client.js").DuplicateClone[] =
769
877
  [];
770
878
  const cachedExports = new Map<string, string>(); // function name -> file path
879
+ let cachedProjectIndex: ProjectIndex | null = null; // similarity index for pre-write checks
771
880
  const complexityBaselines: Map<
772
881
  string,
773
882
  import("./clients/complexity-client.js").FileComplexity
@@ -789,247 +898,298 @@ export default function (pi: ExtensionAPI) {
789
898
  // Project rules scan result (from .claude/rules, .agents/rules, etc.)
790
899
  let projectRulesScan: RuleScanResult = { rules: [], hasCustomRules: false };
791
900
 
901
+ // --- Register skills with pi ---
902
+ pi.on("resources_discover", async (_event, _ctx) => {
903
+ // Get the extension directory (where this file is located)
904
+ const extensionDir = path.dirname(fileURLToPath(import.meta.url));
905
+ const skillsDir = path.join(extensionDir, "skills");
906
+
907
+ return {
908
+ skillPaths: [skillsDir],
909
+ };
910
+ });
911
+
792
912
  // --- Events ---
793
913
 
794
914
  pi.on("session_start", async (_event, ctx) => {
795
- _verbose = !!pi.getFlag("lens-verbose");
796
- dbg("session_start fired");
797
-
798
- // Reset session state
799
- metricsClient.reset();
800
- complexityBaselines.clear();
801
-
802
- // Reset LSP service so the new session starts with fresh diagnostics.
803
- // Without this, stale cascade errors from a previous session persist
804
- // if the extension module stayed hot between reloads.
805
- if (pi.getFlag("lens-lsp")) {
806
- resetLSPService();
807
- dbg("session_start: LSP service reset");
808
- }
809
-
810
- // Log available tools
811
- const tools: string[] = [];
812
- tools.push("TypeScript LSP"); // Always available
813
- if (biomeClient.isAvailable()) tools.push("Biome");
814
- if (astGrepClient.isAvailable()) tools.push("ast-grep");
815
- if (ruffClient.isAvailable()) tools.push("Ruff");
816
- if (knipClient.isAvailable()) tools.push("Knip");
817
- if (depChecker.isAvailable()) tools.push("Madge");
818
- if (jscpdClient.isAvailable()) tools.push("jscpd");
819
- if (typeCoverageClient.isAvailable()) tools.push("type-coverage");
820
-
821
- log(`Active tools: ${tools.join(", ")}`);
822
- dbg(`session_start tools: ${tools.join(", ")}`);
823
-
824
- // Clean up stale TypeScript build caches before LSP starts.
825
- // tsconfig.tsbuildinfo caches the full file list from the last build.
826
- // If files have been deleted since then, the LSP reads the stale list
827
- // and reports phantom "Cannot find module" cascade errors for files
828
- // the agent never touched.
829
- if (pi.getFlag("lens-lsp")) {
830
- const cleaned = cleanStaleTsBuildInfo(ctx.cwd ?? process.cwd());
831
- if (cleaned.length > 0) {
832
- ctx.ui.notify(
833
- `🧹 Deleted stale TypeScript build cache (${cleaned.map((f) => path.basename(f)).join(", ")}) — phantom errors suppressed.`,
834
- "info",
835
- );
836
- dbg(`session_start: cleaned stale tsbuildinfo: ${cleaned.join(", ")}`);
915
+ try {
916
+ _verbose = !!pi.getFlag("lens-verbose");
917
+ dbg("session_start fired");
918
+
919
+ // Reset session state
920
+ metricsClient.reset();
921
+ complexityBaselines.clear();
922
+ resetDispatchBaselines();
923
+ cachedProjectIndex = null;
924
+
925
+ // Reset LSP service so the new session starts with fresh diagnostics.
926
+ // Without this, stale cascade errors from a previous session persist
927
+ // if the extension module stayed hot between reloads.
928
+ if (pi.getFlag("lens-lsp")) {
929
+ resetLSPService();
930
+ dbg("session_start: LSP service reset");
837
931
  }
838
- }
839
932
 
840
- // Pre-install TypeScript LSP if --lens-lsp flag is set (avoid delay on first use)
841
- if (pi.getFlag("lens-lsp")) {
842
- dbg("session_start: pre-installing TypeScript LSP...");
843
- // Fire-and-forget: don't block session start, just warm up the cache
844
- ensureTool("typescript-language-server")
845
- .then((path) => {
846
- if (path) {
847
- dbg(`session_start: TypeScript LSP ready at ${path}`);
848
- } else {
849
- console.error("[lens] TypeScript LSP installation failed");
850
- }
851
- })
852
- .catch((err) => {
853
- console.error("[lens] TypeScript LSP pre-install error:", err);
854
- });
855
- }
933
+ // Log available tools
934
+ const tools: string[] = [];
935
+ tools.push("TypeScript LSP"); // Always available
936
+ if (biomeClient.isAvailable()) tools.push("Biome");
937
+ if (astGrepClient.isAvailable()) tools.push("ast-grep");
938
+ if (ruffClient.isAvailable()) tools.push("Ruff");
939
+ if (knipClient.isAvailable()) tools.push("Knip");
940
+ if (depChecker.isAvailable()) tools.push("Madge");
941
+ if (jscpdClient.isAvailable()) tools.push("jscpd");
942
+ if (typeCoverageClient.isAvailable()) tools.push("type-coverage");
943
+
944
+ log(`Active tools: ${tools.join(", ")}`);
945
+ dbg(`session_start tools: ${tools.join(", ")}`);
946
+
947
+ // Clean up stale TypeScript build caches before LSP starts.
948
+ // tsconfig.tsbuildinfo caches the full file list from the last build.
949
+ // If files have been deleted since then, the LSP reads the stale list
950
+ // and reports phantom "Cannot find module" cascade errors for files
951
+ // the agent never touched.
952
+ if (pi.getFlag("lens-lsp")) {
953
+ const cleaned = cleanStaleTsBuildInfo(ctx.cwd ?? process.cwd());
954
+ if (cleaned.length > 0) {
955
+ ctx.ui.notify(
956
+ `🧹 Deleted stale TypeScript build cache (${cleaned.map((f) => path.basename(f)).join(", ")}) — phantom errors suppressed.`,
957
+ "info",
958
+ );
959
+ dbg(
960
+ `session_start: cleaned stale tsbuildinfo: ${cleaned.join(", ")}`,
961
+ );
962
+ }
963
+ }
856
964
 
857
- const cwd = ctx.cwd ?? process.cwd();
858
- projectRoot = cwd; // Module-level for architect client
859
- dbg(`session_start cwd: ${cwd}`);
965
+ // Pre-install TypeScript LSP if --lens-lsp flag is set (avoid delay on first use)
966
+ if (pi.getFlag("lens-lsp")) {
967
+ dbg("session_start: pre-installing TypeScript LSP...");
968
+ // Fire-and-forget: don't block session start, just warm up the cache
969
+ ensureTool("typescript-language-server")
970
+ .then((path) => {
971
+ if (path) {
972
+ dbg(`session_start: TypeScript LSP ready at ${path}`);
973
+ } else {
974
+ console.error("[lens] TypeScript LSP installation failed");
975
+ }
976
+ })
977
+ .catch((err) => {
978
+ console.error("[lens] TypeScript LSP pre-install error:", err);
979
+ });
980
+ }
860
981
 
861
- // Load architect rules if present
862
- const hasArchitectRules = architectClient.loadConfig(cwd);
863
- if (hasArchitectRules) tools.push("Architect rules");
982
+ const cwd = ctx.cwd ?? process.cwd();
983
+ projectRoot = cwd; // Module-level for architect client
984
+ dbg(`session_start cwd: ${cwd}`);
864
985
 
865
- // Log test runner if detected
866
- const detectedRunner = testRunnerClient.detectRunner(cwd);
867
- if (detectedRunner) {
868
- tools.push(`Test runner (${detectedRunner.runner})`);
869
- }
870
- if (goClient.isGoAvailable()) tools.push("Go (go vet)");
871
- if (rustClient.isAvailable()) tools.push("Rust (cargo)");
872
- log(`Active tools: ${tools.join(", ")}`);
873
- dbg(`session_start tools: ${tools.join(", ")}`);
986
+ // Load architect rules if present
987
+ const hasArchitectRules = architectClient.loadConfig(cwd);
988
+ if (hasArchitectRules) tools.push("Architect rules");
874
989
 
875
- const parts: string[] = [];
990
+ // Log test runner if detected
991
+ const detectedRunner = testRunnerClient.detectRunner(cwd);
992
+ if (detectedRunner) {
993
+ tools.push(`Test runner (${detectedRunner.runner})`);
994
+ }
995
+ if (goClient.isGoAvailable()) tools.push("Go (go vet)");
996
+ if (rustClient.isAvailable()) tools.push("Rust (cargo)");
997
+ log(`Active tools: ${tools.join(", ")}`);
998
+ dbg(`session_start tools: ${tools.join(", ")}`);
876
999
 
877
- // --- Error ownership reminder ---
878
- // Shown on every session start to encourage fixing existing errors
879
- parts.push(
880
- "📌 Remember: If you find ANY errors (test failures, compile errors, lint issues) in this codebase, fix them — even if you didn't cause them. Don't skip errors as 'not my fault'.",
881
- );
1000
+ const parts: string[] = [];
882
1001
 
883
- // Scan for project-specific rules (.claude/rules, .agents/rules, CLAUDE.md, etc.)
884
- projectRulesScan = scanProjectRules(cwd);
885
- if (projectRulesScan.hasCustomRules) {
886
- const ruleCount = projectRulesScan.rules.length;
887
- const sources = [...new Set(projectRulesScan.rules.map((r) => r.source))];
888
- dbg(
889
- `session_start: found ${ruleCount} project rule(s) from ${sources.join(", ")}`,
890
- );
1002
+ // --- Error ownership reminder ---
1003
+ // Shown on every session start to encourage fixing existing errors
891
1004
  parts.push(
892
- `📋 Project rules found: ${ruleCount} file(s) in ${sources.join(", ")}. These apply alongside pi-lens defaults.`,
1005
+ "📌 Remember: If you find ANY errors (test failures, compile errors, lint issues) in this codebase, fix them — even if you didn't cause them. Don't skip errors as 'not my fault'.",
893
1006
  );
894
- } else {
895
- dbg("session_start: no project rules found");
896
- }
897
1007
 
898
- // TODO/FIXME scan fast, no deps
899
- const todoResult = todoScanner.scanDirectory(cwd);
900
- const todoReport = todoScanner.formatResult(todoResult);
901
- dbg(`session_start TODO scan: ${todoResult.items.length} items`);
902
- if (todoReport) parts.push(todoReport);
903
-
904
- // Dead code scan — use cache if fresh, auto-install if needed
905
- if (await knipClient.ensureAvailable()) {
906
- const cached = cacheManager.readCache<ReturnType<KnipClient["analyze"]>>(
907
- "knip",
908
- cwd,
909
- );
910
- if (cached) {
1008
+ // Scan for project-specific rules (.claude/rules, .agents/rules, CLAUDE.md, etc.)
1009
+ projectRulesScan = scanProjectRules(cwd);
1010
+ if (projectRulesScan.hasCustomRules) {
1011
+ const ruleCount = projectRulesScan.rules.length;
1012
+ const sources = [
1013
+ ...new Set(projectRulesScan.rules.map((r) => r.source)),
1014
+ ];
911
1015
  dbg(
912
- `session_start Knip: cache hit (${Math.round((Date.now() - new Date(cached.meta.timestamp).getTime()) / 1000)}s ago)`,
1016
+ `session_start: found ${ruleCount} project rule(s) from ${sources.join(", ")}`,
1017
+ );
1018
+ parts.push(
1019
+ `📋 Project rules found: ${ruleCount} file(s) in ${sources.join(", ")}. These apply alongside pi-lens defaults.`,
913
1020
  );
914
- const knipReport = knipClient.formatResult(cached.data);
915
- if (knipReport) parts.push(knipReport);
916
1021
  } else {
917
- const startMs = Date.now();
918
- const knipResult = knipClient.analyze(cwd);
919
- cacheManager.writeCache("knip", knipResult, cwd, {
920
- scanDurationMs: Date.now() - startMs,
921
- });
922
- const knipReport = knipClient.formatResult(knipResult);
923
- dbg(`session_start Knip scan done`);
924
- if (knipReport) parts.push(knipReport);
1022
+ dbg("session_start: no project rules found");
925
1023
  }
926
- } else {
927
- dbg(`session_start Knip: not available`);
928
- }
929
1024
 
930
- // Duplicate code detection use cache if fresh, auto-install if needed
931
- if (await jscpdClient.ensureAvailable()) {
932
- const cached = cacheManager.readCache<ReturnType<JscpdClient["scan"]>>(
933
- "jscpd",
934
- cwd,
935
- );
936
- if (cached) {
937
- dbg(`session_start jscpd: cache hit`);
938
- _cachedJscpdClones = cached.data.clones;
939
- const jscpdReport = jscpdClient.formatResult(cached.data);
940
- if (jscpdReport) parts.push(jscpdReport);
1025
+ // TODO/FIXME scanfast, no deps
1026
+ const todoResult = todoScanner.scanDirectory(cwd);
1027
+ const todoReport = todoScanner.formatResult(todoResult);
1028
+ dbg(`session_start TODO scan: ${todoResult.items.length} items`);
1029
+ if (todoReport) parts.push(todoReport);
1030
+
1031
+ // Dead code scan — use cache if fresh, auto-install if needed
1032
+ if (await knipClient.ensureAvailable()) {
1033
+ const cached = cacheManager.readCache<
1034
+ ReturnType<KnipClient["analyze"]>
1035
+ >("knip", cwd);
1036
+ if (cached) {
1037
+ dbg(
1038
+ `session_start Knip: cache hit (${Math.round((Date.now() - new Date(cached.meta.timestamp).getTime()) / 1000)}s ago)`,
1039
+ );
1040
+ const knipReport = knipClient.formatResult(cached.data);
1041
+ if (knipReport) parts.push(knipReport);
1042
+ } else {
1043
+ const startMs = Date.now();
1044
+ const knipResult = knipClient.analyze(cwd);
1045
+ cacheManager.writeCache("knip", knipResult, cwd, {
1046
+ scanDurationMs: Date.now() - startMs,
1047
+ });
1048
+ const knipReport = knipClient.formatResult(knipResult);
1049
+ dbg(`session_start Knip scan done`);
1050
+ if (knipReport) parts.push(knipReport);
1051
+ }
941
1052
  } else {
942
- const startMs = Date.now();
943
- const jscpdResult = jscpdClient.scan(cwd);
944
- _cachedJscpdClones = jscpdResult.clones;
945
- cacheManager.writeCache("jscpd", jscpdResult, cwd, {
946
- scanDurationMs: Date.now() - startMs,
947
- });
948
- const jscpdReport = jscpdClient.formatResult(jscpdResult);
949
- dbg(`session_start jscpd scan done`);
950
- if (jscpdReport) parts.push(jscpdReport);
1053
+ dbg(`session_start Knip: not available`);
951
1054
  }
952
- } else {
953
- dbg(`session_start jscpd: not available`);
954
- }
955
-
956
- // Note: type-coverage runs on-demand via /lens-booboo only (not at session_start)
957
1055
 
958
- // Scan for exported functions (cached for duplicate detection on write)
959
- if (await astGrepClient.ensureAvailable()) {
960
- const exports = await astGrepClient.scanExports(cwd, "typescript");
961
- dbg(`session_start exports scan: ${exports.size} functions found`);
962
- for (const [name, file] of exports) {
963
- cachedExports.set(name, file);
1056
+ // Duplicate code detection use cache if fresh, auto-install if needed
1057
+ if (await jscpdClient.ensureAvailable()) {
1058
+ const cached = cacheManager.readCache<ReturnType<JscpdClient["scan"]>>(
1059
+ "jscpd",
1060
+ cwd,
1061
+ );
1062
+ if (cached) {
1063
+ dbg(`session_start jscpd: cache hit`);
1064
+ _cachedJscpdClones = cached.data.clones;
1065
+ const jscpdReport = jscpdClient.formatResult(cached.data);
1066
+ if (jscpdReport) parts.push(jscpdReport);
1067
+ } else {
1068
+ const startMs = Date.now();
1069
+ const jscpdResult = jscpdClient.scan(cwd);
1070
+ _cachedJscpdClones = jscpdResult.clones;
1071
+ cacheManager.writeCache("jscpd", jscpdResult, cwd, {
1072
+ scanDurationMs: Date.now() - startMs,
1073
+ });
1074
+ const jscpdReport = jscpdClient.formatResult(jscpdResult);
1075
+ dbg(`session_start jscpd scan done`);
1076
+ if (jscpdReport) parts.push(jscpdReport);
1077
+ }
1078
+ } else {
1079
+ dbg(`session_start jscpd: not available`);
964
1080
  }
965
- }
966
1081
 
967
- dbg(
968
- `session_start: scans complete (${parts.length} part(s)), cached for commands`,
969
- );
1082
+ // Note: type-coverage runs on-demand via /lens-booboo only (not at session_start)
970
1083
 
971
- // Output the assembled parts to user
972
- if (parts.length > 0) {
973
- for (const part of parts) {
974
- ctx.ui.notify(part, "info");
1084
+ // Scan for exported functions (cached for duplicate detection on write)
1085
+ if (await astGrepClient.ensureAvailable()) {
1086
+ const exports = await astGrepClient.scanExports(cwd, "typescript");
1087
+ dbg(`session_start exports scan: ${exports.size} functions found`);
1088
+ for (const [name, file] of exports) {
1089
+ cachedExports.set(name, file);
1090
+ }
975
1091
  }
976
- }
977
1092
 
978
- // --- Error debt: check if tests ran since last session ---
979
- // If files were modified in previous turn, run tests and check for regression
980
- const errorDebtEnabled = pi.getFlag("error-debt");
981
- const pendingDebt = cacheManager.readCache<{
982
- pendingCheck: boolean;
983
- baselineTestsPassed: boolean;
984
- }>("errorDebt", cwd);
985
-
986
- if (errorDebtEnabled && detectedRunner && pendingDebt?.data?.pendingCheck) {
987
- dbg("session_start: running pending error debt check");
988
- const testResult = testRunnerClient.runTestFile(
989
- ".",
990
- cwd,
991
- detectedRunner.runner,
992
- detectedRunner.config,
993
- );
994
- const testsPassed = testResult.failed === 0 && !testResult.error;
995
- const baselinePassed = pendingDebt.data.baselineTestsPassed;
996
-
997
- // Regression detected!
998
- if (baselinePassed && !testsPassed) {
999
- const msg = `🔴 ERROR DEBT: Tests were passing but now failing (${testResult.failed} failure(s)). Fix before continuing.`;
1000
- dbg(`session_start ERROR DEBT: ${msg}`);
1001
- parts.push(msg);
1093
+ // Build similarity index for pre-write structural duplicate detection.
1094
+ // Uses the same source files as the exports scan. The index is ~50ms
1095
+ // to query but seconds to build, so we do it once at session start.
1096
+ try {
1097
+ const existing = await loadIndex(cwd);
1098
+ if (existing && existing.entries.size > 0) {
1099
+ cachedProjectIndex = existing;
1100
+ dbg(
1101
+ `session_start: loaded project index from disk (${existing.entries.size} entries)`,
1102
+ );
1103
+ } else {
1104
+ const sourceFiles = getSourceFiles(cwd, true);
1105
+ const tsFiles = sourceFiles.filter(
1106
+ (f) => f.endsWith(".ts") || f.endsWith(".tsx"),
1107
+ );
1108
+ if (tsFiles.length > 0 && tsFiles.length <= 500) {
1109
+ cachedProjectIndex = await buildProjectIndex(cwd, tsFiles);
1110
+ dbg(
1111
+ `session_start: built project index (${cachedProjectIndex.entries.size} entries from ${tsFiles.length} files)`,
1112
+ );
1113
+ } else {
1114
+ dbg(
1115
+ `session_start: skipped project index (${tsFiles.length} files — ${tsFiles.length === 0 ? "none" : "too many"})`,
1116
+ );
1117
+ }
1118
+ }
1119
+ } catch (err) {
1120
+ dbg(`session_start: project index build failed: ${err}`);
1002
1121
  }
1003
1122
 
1004
- // Update baseline
1005
- errorDebtBaseline = {
1006
- testsPassed: testsPassed,
1007
- buildPassed: true,
1008
- };
1009
- } else if (errorDebtEnabled && detectedRunner) {
1010
- // No pending check - establish fresh baseline
1011
- dbg("session_start: establishing fresh error debt baseline");
1012
- const testResult = testRunnerClient.runTestFile(
1013
- ".",
1014
- cwd,
1015
- detectedRunner.runner,
1016
- detectedRunner.config,
1017
- );
1018
- const testsPassed = testResult.failed === 0 && !testResult.error;
1019
- errorDebtBaseline = {
1020
- testsPassed: testsPassed,
1021
- buildPassed: true,
1022
- };
1023
1123
  dbg(
1024
- `session_start error debt baseline: testsPassed=${errorDebtBaseline.testsPassed}`,
1124
+ `session_start: scans complete (${parts.length} part(s)), cached for commands`,
1025
1125
  );
1126
+
1127
+ // Output the assembled parts to user
1128
+ if (parts.length > 0) {
1129
+ for (const part of parts) {
1130
+ ctx.ui.notify(part, "info");
1131
+ }
1132
+ }
1133
+
1134
+ // --- Error debt: check if tests ran since last session ---
1135
+ // If files were modified in previous turn, run tests and check for regression
1136
+ const errorDebtEnabled = pi.getFlag("error-debt");
1137
+ const pendingDebt = cacheManager.readCache<{
1138
+ pendingCheck: boolean;
1139
+ baselineTestsPassed: boolean;
1140
+ }>("errorDebt", cwd);
1141
+
1142
+ if (
1143
+ errorDebtEnabled &&
1144
+ detectedRunner &&
1145
+ pendingDebt?.data?.pendingCheck
1146
+ ) {
1147
+ dbg("session_start: running pending error debt check");
1148
+ const testResult = testRunnerClient.runTestFile(
1149
+ ".",
1150
+ cwd,
1151
+ detectedRunner.runner,
1152
+ detectedRunner.config,
1153
+ );
1154
+ const testsPassed = testResult.failed === 0 && !testResult.error;
1155
+ const baselinePassed = pendingDebt.data.baselineTestsPassed;
1156
+
1157
+ // Regression detected!
1158
+ if (baselinePassed && !testsPassed) {
1159
+ const msg = `🔴 ERROR DEBT: Tests were passing but now failing (${testResult.failed} failure(s)). Fix before continuing.`;
1160
+ dbg(`session_start ERROR DEBT: ${msg}`);
1161
+ parts.push(msg);
1162
+ }
1163
+
1164
+ // Update baseline
1165
+ errorDebtBaseline = {
1166
+ testsPassed: testsPassed,
1167
+ buildPassed: true,
1168
+ };
1169
+ } else if (errorDebtEnabled && detectedRunner) {
1170
+ // No pending check - establish fresh baseline
1171
+ dbg("session_start: establishing fresh error debt baseline");
1172
+ const testResult = testRunnerClient.runTestFile(
1173
+ ".",
1174
+ cwd,
1175
+ detectedRunner.runner,
1176
+ detectedRunner.config,
1177
+ );
1178
+ const testsPassed = testResult.failed === 0 && !testResult.error;
1179
+ errorDebtBaseline = {
1180
+ testsPassed: testsPassed,
1181
+ buildPassed: true,
1182
+ };
1183
+ dbg(
1184
+ `session_start error debt baseline: testsPassed=${errorDebtBaseline.testsPassed}`,
1185
+ );
1186
+ }
1187
+ } catch (sessionErr) {
1188
+ dbg(`session_start crashed: ${sessionErr}`);
1189
+ dbg(`session_start crash stack: ${(sessionErr as Error).stack}`);
1026
1190
  }
1027
1191
  });
1028
1192
 
1029
- // --- Pre-write proactive hints ---
1030
- // Stored during tool_call, prepended to tool_result output so the agent sees them.
1031
- const preWriteHints = new Map<string, string>();
1032
-
1033
1193
  pi.on("tool_call", async (event, _ctx) => {
1034
1194
  const filePath =
1035
1195
  isToolCallEventType("write", event) || isToolCallEventType("edit", event)
@@ -1038,14 +1198,13 @@ export default function (pi: ExtensionAPI) {
1038
1198
 
1039
1199
  if (!filePath) return;
1040
1200
 
1041
- const _toolCallStart = Date.now();
1042
1201
  dbg(
1043
1202
  `tool_call fired for: ${filePath} (exists: ${nodeFs.existsSync(filePath)})`,
1044
1203
  );
1045
1204
  if (!nodeFs.existsSync(filePath)) return;
1046
1205
 
1047
- // Record complexity baseline for TS/JS files + capture history snapshot
1048
- const complexityBaselineStart = Date.now();
1206
+ // Record complexity baseline for historical tracking (booboo/tdi).
1207
+ // Not shown inline — just captured for delta analysis.
1049
1208
  if (
1050
1209
  complexityClient.isSupportedFile(filePath) &&
1051
1210
  !complexityBaselines.has(filePath)
@@ -1053,161 +1212,100 @@ export default function (pi: ExtensionAPI) {
1053
1212
  const baseline = complexityClient.analyzeFile(filePath);
1054
1213
  if (baseline) {
1055
1214
  complexityBaselines.set(filePath, baseline);
1056
- // Capture snapshot for historical tracking (async, non-blocking)
1057
1215
  captureSnapshot(filePath, {
1058
1216
  maintainabilityIndex: baseline.maintainabilityIndex,
1059
1217
  cognitiveComplexity: baseline.cognitiveComplexity,
1060
1218
  maxNestingDepth: baseline.maxNestingDepth,
1061
1219
  linesOfCode: baseline.linesOfCode,
1220
+ maxCyclomatic: baseline.maxCyclomaticComplexity,
1221
+ entropy: baseline.codeEntropy,
1062
1222
  });
1063
1223
  }
1064
- logLatency({
1065
- type: "phase",
1066
- toolName: event.toolName,
1067
- filePath,
1068
- phase: "complexity_baseline",
1069
- durationMs: Date.now() - complexityBaselineStart,
1070
- metadata: { hasBaseline: complexityBaselines.has(filePath) },
1071
- });
1072
1224
  }
1073
1225
 
1074
- const hints: string[] = [];
1075
- let _preCheckDuration = 0;
1076
-
1077
- if (/\.(ts|tsx|js|jsx)$/.test(filePath) && !pi.getFlag("no-lsp")) {
1078
- const tsPreCheckStart = Date.now();
1079
- tsClient.updateFile(filePath, nodeFs.readFileSync(filePath, "utf-8"));
1080
- const diags = tsClient.getDiagnostics(filePath);
1081
- // Pass pre-computed diags to avoid a second getSemanticDiagnostics call (~1s on large files)
1082
- const fixes = tsClient.getAllCodeFixes(filePath, diags);
1083
- const errorDiags = diags.filter((d) => d.severity === 1);
1084
- if (errorDiags.length > 0) {
1085
- hints.push(
1086
- `⚠ Pre-write: file has ${errorDiags.length} TypeScript error(s):`,
1087
- );
1088
- // Show first 3 errors with quick fixes
1089
- for (const d of errorDiags.slice(0, 3)) {
1090
- const lineFixes = fixes.get(d.range.start.line);
1091
- const fixHint = lineFixes?.[0]?.description
1092
- ? ` 💡 ${lineFixes[0].description}`
1093
- : "";
1094
- hints.push(
1095
- ` L${d.range.start.line + 1}: ${d.message.split("\n")[0].substring(0, 80)}${fixHint}`,
1096
- );
1226
+ // --- Pre-write duplicate detection ---
1227
+ // Check if new content redefines functions that already exist elsewhere.
1228
+ // Uses cachedExports (populated at session_start via ast-grep scan).
1229
+ if (isToolCallEventType("write", event) && cachedExports.size > 0) {
1230
+ const newContent = (event.input as { content?: string }).content;
1231
+ if (newContent) {
1232
+ const dupeWarnings: string[] = [];
1233
+ const exportRe =
1234
+ /export\s+(?:async\s+)?(?:function|class|const|let|type|interface)\s+(\w+)/g;
1235
+ let m: RegExpExecArray | null;
1236
+ while ((m = exportRe.exec(newContent))) {
1237
+ const name = m[1];
1238
+ const existingFile = cachedExports.get(name);
1239
+ if (
1240
+ existingFile &&
1241
+ path.resolve(existingFile) !== path.resolve(filePath)
1242
+ ) {
1243
+ dupeWarnings.push(
1244
+ `\`${name}\` already exists in ${path.relative(projectRoot, existingFile)}`,
1245
+ );
1246
+ }
1097
1247
  }
1098
- if (errorDiags.length > 3) {
1099
- hints.push(` ... and ${errorDiags.length - 3} more errors`);
1248
+ if (dupeWarnings.length > 0) {
1249
+ return {
1250
+ block: true,
1251
+ reason: `🔴 STOP — Redefining existing export(s). Import instead:\n${dupeWarnings.map((w) => ` • ${w}`).join("\n")}`,
1252
+ };
1100
1253
  }
1101
- }
1102
- _preCheckDuration += Date.now() - tsPreCheckStart;
1103
- logLatency({
1104
- type: "phase",
1105
- toolName: event.toolName,
1106
- filePath,
1107
- phase: "ts_pre_check",
1108
- durationMs: Date.now() - tsPreCheckStart,
1109
- metadata: { errorCount: errorDiags.length, hintCount: hints.length },
1110
- });
1111
- }
1112
1254
 
1113
- // Unified LSP pre-write check (when --lens-lsp enabled)
1114
- // This provides pre-write hints for all LSP languages (TypeScript, Python, Go, Rust, etc.)
1115
- if (pi.getFlag("lens-lsp") && !pi.getFlag("no-lsp")) {
1116
- const lspPreCheckStart = Date.now();
1117
- const lspService = getLSPService();
1118
- lspService
1119
- .hasLSP(filePath)
1120
- .then(async (hasLSP) => {
1121
- if (hasLSP) {
1122
- const content = nodeFs.readFileSync(filePath, "utf-8");
1123
- await lspService.openFile(filePath, content);
1124
- await new Promise((r) => setTimeout(r, 300));
1125
- const diags = await lspService.getDiagnostics(filePath);
1126
- const errorDiags = diags.filter((d) => d.severity === 1);
1127
- if (errorDiags.length > 0) {
1128
- hints.push(
1129
- `⚠ Pre-write: ${errorDiags.length} LSP error(s) detected:`,
1255
+ // --- Structural similarity check (Phase 7b) ---
1256
+ // If the project index was built at session_start, check new
1257
+ // functions against it for structural clones (~50ms).
1258
+ if (
1259
+ cachedProjectIndex &&
1260
+ cachedProjectIndex.entries.size > 0 &&
1261
+ /\.(ts|tsx)$/.test(filePath)
1262
+ ) {
1263
+ try {
1264
+ const ts = await import("typescript");
1265
+ const sourceFile = ts.createSourceFile(
1266
+ filePath,
1267
+ newContent,
1268
+ ts.ScriptTarget.Latest,
1269
+ true,
1270
+ );
1271
+ const newFunctions = extractFunctions(sourceFile, newContent);
1272
+ const simWarnings: string[] = [];
1273
+ const relPath = path.relative(projectRoot, filePath);
1274
+
1275
+ for (const func of newFunctions) {
1276
+ if (func.transitionCount < 20) continue;
1277
+ const matches = findSimilarFunctions(
1278
+ func.matrix,
1279
+ cachedProjectIndex,
1280
+ 0.8,
1281
+ 1,
1130
1282
  );
1131
- for (const d of errorDiags.slice(0, 3)) {
1132
- hints.push(
1133
- ` L${d.range?.start?.line ?? 0 + 1}: ${d.message.slice(0, 80)}`,
1283
+ for (const match of matches) {
1284
+ // Skip self-matches
1285
+ if (match.targetId === `${relPath}:${func.name}`) continue;
1286
+ const pct = Math.round(match.similarity * 100);
1287
+ simWarnings.push(
1288
+ `\`${func.name}\` is ${pct}% similar to \`${match.targetName}\` in ${match.targetLocation}`,
1134
1289
  );
1135
1290
  }
1136
- if (errorDiags.length > 3) {
1137
- hints.push(` ... and ${errorDiags.length - 3} more`);
1138
- }
1139
1291
  }
1140
- _preCheckDuration += Date.now() - lspPreCheckStart;
1141
- logLatency({
1142
- type: "phase",
1143
- toolName: event.toolName,
1144
- filePath,
1145
- phase: "lsp_pre_check",
1146
- durationMs: Date.now() - lspPreCheckStart,
1147
- metadata: { hasLSP, errorCount: errorDiags?.length ?? 0 },
1148
- });
1149
- }
1150
- })
1151
- .catch((err) => {
1152
- logLatency({
1153
- type: "phase",
1154
- toolName: event.toolName,
1155
- filePath,
1156
- phase: "lsp_pre_check",
1157
- durationMs: Date.now() - lspPreCheckStart,
1158
- status: "failed",
1159
- metadata: { error: String(err) },
1160
- });
1161
- });
1162
- }
1163
-
1164
- // Note: ast-grep baseline removed - we use ast-grep-napi in dispatch instead
1165
- // Note: biome baseline removed - auto-fix handles this in post-write
1166
1292
 
1167
- // Architectural rules: Skip pre-write hints (too noisy)
1168
- // Post-write violations will be shown via architect runner in dispatch
1169
-
1170
- dbg(` pre-write hints: ${hints.length} ${hints.join(" | ") || "none"}`);
1171
- if (hints.length > 0) {
1172
- preWriteHints.set(filePath, hints.join("\n"));
1293
+ if (simWarnings.length > 0) {
1294
+ return {
1295
+ block: false,
1296
+ reason: `⚠️ Structural similarity detected — consider reusing existing code:\n${simWarnings.map((w) => ` • ${w}`).join("\n")}`,
1297
+ };
1298
+ }
1299
+ } catch {
1300
+ // Parsing failed — skip similarity check silently
1301
+ }
1302
+ }
1303
+ }
1173
1304
  }
1174
1305
  });
1175
1306
 
1176
1307
  // Real-time feedback on file writes/edits
1177
1308
  pi.on("tool_result", async (event) => {
1178
- // ═══════════════════════════════════════════════════════════════════
1179
- // LATENCY TRACKING: Comprehensive phase-based logging
1180
- // ═══════════════════════════════════════════════════════════════════
1181
- const toolResultStart = Date.now();
1182
- const toolName = event.toolName;
1183
- const phases: Array<{
1184
- name: string;
1185
- start: number;
1186
- end?: number;
1187
- duration?: number;
1188
- }> = [];
1189
-
1190
- function phaseStart(name: string) {
1191
- phases.push({ name, start: Date.now() });
1192
- }
1193
- function phaseEnd(name: string, metadata?: Record<string, unknown>) {
1194
- const p = phases.find((x) => x.name === name && !x.end);
1195
- if (p) {
1196
- p.end = Date.now();
1197
- p.duration = p.end - p.start;
1198
- if (filePath) {
1199
- logLatency({
1200
- type: "phase",
1201
- toolName,
1202
- filePath,
1203
- phase: name,
1204
- durationMs: p.duration,
1205
- metadata,
1206
- });
1207
- }
1208
- }
1209
- }
1210
-
1211
1309
  // Track tool call for behavior analysis (all tool types)
1212
1310
  const filePath = (event.input as { path?: string }).path;
1213
1311
  const behaviorWarnings = agentBehaviorClient.recordToolCall(
@@ -1243,11 +1341,10 @@ export default function (pi: ExtensionAPI) {
1243
1341
  }
1244
1342
  // Record this write so future assertions know the agent has the current state
1245
1343
  sessionFileTime.read(filePath);
1344
+ const toolResultStart = Date.now();
1246
1345
  dbg(
1247
1346
  `tool_result: tracking turn state for ${event.toolName} on ${filePath}`,
1248
1347
  );
1249
- phaseStart("total");
1250
- phaseStart("turn_state_tracking");
1251
1348
 
1252
1349
  // --- Track modified ranges in turn state for async jscpd/madge at turn_end ---
1253
1350
  const cwd = projectRoot;
@@ -1292,344 +1389,73 @@ export default function (pi: ExtensionAPI) {
1292
1389
  dbg(`turn state tracking error stack: ${(err as Error).stack}`);
1293
1390
  }
1294
1391
 
1295
- dbg(`tool_result fired for: ${filePath}`);
1296
- dbg(` cwd: ${process.cwd()}`);
1297
- dbg(
1298
- ` __dirname: ${typeof __dirname !== "undefined" ? __dirname : "undefined"}`,
1299
- );
1300
-
1301
- // Prepend any pre-write hints collected during tool_call
1302
- const preHint = preWriteHints.get(filePath);
1303
- preWriteHints.delete(filePath);
1304
-
1305
- // Record write for metrics (silent tracking)
1306
- phaseEnd("turn_state_tracking");
1307
- phaseStart("read_file");
1392
+ const turnStateMs = Date.now() - toolResultStart;
1393
+ logLatency({
1394
+ type: "phase",
1395
+ toolName: event.toolName,
1396
+ filePath,
1397
+ phase: "turn_state_tracking",
1398
+ durationMs: turnStateMs,
1399
+ });
1400
+ dbg(`tool_result fired for: ${filePath} (turn_state: ${turnStateMs}ms)`);
1308
1401
 
1309
- let fileContent: string | undefined;
1402
+ let result: { output: string; isError?: boolean };
1310
1403
  try {
1311
- fileContent = nodeFs.readFileSync(filePath, "utf-8");
1312
- metricsClient.recordWrite(filePath, fileContent);
1313
- } catch (err) {
1314
- void err;
1315
- }
1316
- phaseEnd("read_file");
1317
-
1318
- // --- Auto-format on write (default enabled) ---
1319
- phaseStart("format");
1320
- // Runs detected formatters concurrently via Effect-TS
1321
- let formatChanged = false;
1322
- let formattersUsed: string[] = [];
1323
- if (!pi.getFlag("no-autoformat") && fileContent) {
1324
- const formatService = getFormatService();
1325
- try {
1326
- // Record file read to establish FileTime baseline before formatting
1327
- // This prevents "modified externally" false positives when agent writes file
1328
- formatService.recordRead(filePath);
1329
- const result = await formatService.formatFile(filePath);
1330
- formattersUsed = result.formatters.map((f) => f.name);
1331
- if (result.anyChanged) {
1332
- formatChanged = true;
1333
- dbg(
1334
- `autoformat: ${result.formatters.map((f) => `${f.name}(${f.changed ? "changed" : "unchanged"})`).join(", ")}`,
1335
- );
1336
- // Re-read content after formatting for downstream processing
1337
- fileContent = nodeFs.readFileSync(filePath, "utf-8");
1338
- }
1339
- } catch (err) {
1340
- dbg(`autoformat error: ${err}`);
1341
- }
1342
- }
1343
- phaseEnd("format", { formattersUsed, formatChanged });
1344
-
1345
- // --- Publish file modified event to bus (Phase 1) ---
1346
- // --- LSP integration (Phase 3) ---
1347
- if (pi.getFlag("lens-lsp") && fileContent) {
1348
- const lspService = getLSPService();
1349
- lspService
1350
- .hasLSP(filePath)
1351
- .then(async (hasLSP) => {
1352
- if (hasLSP) {
1353
- // Open or update file in LSP
1354
- if (event.toolName === "write") {
1355
- await lspService.openFile(filePath, fileContent);
1356
- } else {
1357
- await lspService.updateFile(filePath, fileContent);
1358
- }
1359
- }
1360
- })
1361
- .catch((err) => {
1362
- dbg(`LSP error: ${err}`);
1363
- });
1364
- }
1365
-
1366
- // --- Secrets scan (blocking - must check before other linting) ---
1367
- if (fileContent) {
1368
- const secretFindings = scanForSecrets(fileContent, filePath);
1369
- if (secretFindings.length > 0) {
1370
- const secretsOutput = formatSecrets(secretFindings, filePath);
1371
- const elapsed = Date.now() - toolResultStart;
1372
- logLatency({
1373
- type: "tool_result",
1374
- toolName,
1404
+ result = await runPipeline(
1405
+ {
1375
1406
  filePath,
1376
- durationMs: elapsed,
1377
- result: "blocked_secrets",
1378
- metadata: { secretsFound: secretFindings.length },
1379
- });
1380
- return {
1381
- content: [
1382
- ...event.content,
1383
- { type: "text" as const, text: `\n\n${secretsOutput}` },
1384
- ],
1385
- isError: true,
1386
- };
1387
- }
1388
- }
1389
-
1390
- let lspOutput = preHint ? `\n\n${preHint}` : "";
1391
-
1392
- // --- Auto-fix on write (safely - track to prevent loops) ---
1393
- phaseStart("autofix");
1394
- // Apply fixes BEFORE dispatch so dispatch only reports remaining issues
1395
- // Autofix is enabled by default, use --no-autofix to disable
1396
- const noAutofix = pi.getFlag("no-autofix");
1397
- const noAutofixBiome = pi.getFlag("no-autofix-biome");
1398
- const noAutofixRuff = pi.getFlag("no-autofix-ruff");
1399
- let fixedCount = 0;
1400
-
1401
- if (!fixedThisTurn.has(filePath) && !noAutofix) {
1402
- // Python: Ruff auto-fix (enabled by default)
1403
- if (
1404
- !noAutofixRuff &&
1405
- (await ruffClient.ensureAvailable()) &&
1406
- ruffClient.isPythonFile(filePath)
1407
- ) {
1408
- const result = ruffClient.fixFile(filePath);
1409
- if (result.success && result.fixed > 0) {
1410
- fixedCount += result.fixed;
1411
- fixedThisTurn.add(filePath);
1412
- dbg(`autofix: ruff fixed ${result.fixed} issue(s) in ${filePath}`);
1413
- }
1414
- }
1415
-
1416
- // JS/TS/JSON: Biome auto-fix (enabled by default)
1417
- if (
1418
- !noAutofixBiome &&
1419
- biomeClient.isAvailable() &&
1420
- biomeClient.isSupportedFile(filePath)
1421
- ) {
1422
- const result = biomeClient.fixFile(filePath);
1423
- if (result.success && result.fixed > 0) {
1424
- fixedCount += result.fixed;
1425
- fixedThisTurn.add(filePath);
1426
- dbg(`autofix: biome fixed ${result.fixed} issue(s) in ${filePath}`);
1427
- }
1428
- }
1429
- }
1430
- phaseEnd("autofix", { fixedCount, tools: ["ruff", "biome"] });
1431
-
1432
- // --- Declarative dispatch: run all applicable lint tools ---
1433
- phaseStart("dispatch_lint");
1434
- // Phase 2: Replaced ~400 lines of if/else with unified dispatch system
1435
- dbg(`dispatch: running lint tools for ${filePath}`);
1436
-
1437
- const dispatchOutput = await dispatchLint(filePath, projectRoot, pi);
1438
-
1439
- if (dispatchOutput) {
1440
- lspOutput += `\n\n${dispatchOutput}`;
1441
- }
1442
-
1443
- // Report autofix results
1444
- if (fixedCount > 0) {
1445
- lspOutput += `\n\n✅ Auto-fixed ${fixedCount} issue(s) in ${path.basename(filePath)}`;
1446
- }
1447
-
1448
- // Warn agent if file was modified by auto-format or auto-fix
1449
- // This ensures they know to re-read before next edit
1450
- if (formatChanged || fixedCount > 0) {
1451
- lspOutput += `\n\n⚠️ **File modified by auto-format/fix. Re-read before next edit.**`;
1407
+ cwd: projectRoot,
1408
+ toolName: event.toolName,
1409
+ getFlag: (name: string) => pi.getFlag(name),
1410
+ dbg,
1411
+ },
1412
+ {
1413
+ biomeClient,
1414
+ ruffClient,
1415
+ testRunnerClient,
1416
+ metricsClient,
1417
+ getFormatService,
1418
+ fixedThisTurn,
1419
+ },
1420
+ );
1421
+ } catch (pipelineErr) {
1422
+ dbg(`runPipeline crashed: ${pipelineErr}`);
1423
+ dbg(`runPipeline crash stack: ${(pipelineErr as Error).stack}`);
1424
+ return;
1452
1425
  }
1453
- phaseEnd("dispatch_lint", {
1454
- hasOutput: !!dispatchOutput,
1455
- });
1456
1426
 
1457
- // --- Test runner: run corresponding tests on write ---
1458
- phaseStart("test_runner");
1459
- let testInfoFound = false;
1460
- let testRunnerRan = false;
1461
- if (!pi.getFlag("no-tests")) {
1462
- const testInfo = testRunnerClient.findTestFile(filePath, cwd);
1463
- testInfoFound = !!testInfo;
1464
- if (testInfo) {
1465
- dbg(
1466
- `test-runner: found test file ${testInfo.testFile} for ${filePath}`,
1467
- );
1468
- const detectedRunner = testRunnerClient.detectRunner(cwd);
1469
- if (detectedRunner) {
1470
- testRunnerRan = true;
1471
- const testStart = Date.now();
1472
- const testResult = testRunnerClient.runTestFile(
1473
- testInfo.testFile,
1474
- cwd,
1475
- detectedRunner.runner,
1476
- detectedRunner.config,
1477
- );
1478
- const testDuration = Date.now() - testStart;
1479
- logLatency({
1480
- type: "phase",
1481
- toolName,
1482
- filePath,
1483
- phase: "test_runner",
1484
- durationMs: testDuration,
1485
- metadata: {
1486
- testFile: testInfo.testFile,
1487
- runner: detectedRunner.runner,
1488
- success: !testResult?.error,
1489
- },
1490
- });
1491
- if (testResult && !testResult.error) {
1492
- const testOutput = testRunnerClient.formatResult(testResult);
1493
- if (testOutput) {
1494
- lspOutput += `\n\n${testOutput}`;
1495
- }
1496
- }
1497
- }
1498
- }
1499
- }
1500
- phaseEnd("test_runner", { found: testInfoFound, ran: testRunnerRan });
1501
-
1502
- // Note: TypeScript diagnostics are handled by the ts-lsp dispatch runner above.
1503
- // No inline TypeScriptClient check here — dispatch already covers it.
1504
-
1505
- // --- Complexity tracking (post-write) ---
1506
- phaseStart("complexity_check");
1507
- if (complexityClient.isSupportedFile(filePath)) {
1508
- const oldBaseline = complexityBaselines.get(filePath);
1509
- const newComplexity = complexityClient.analyzeFile(filePath);
1510
- if (oldBaseline && newComplexity) {
1511
- const complexityDelta = {
1512
- cognitive:
1513
- newComplexity.cognitiveComplexity - oldBaseline.cognitiveComplexity,
1514
- maintainability:
1515
- newComplexity.maintainabilityIndex -
1516
- oldBaseline.maintainabilityIndex,
1517
- lines: newComplexity.linesOfCode - oldBaseline.linesOfCode,
1518
- };
1519
- // Warn if complexity significantly increased
1520
- if (
1521
- complexityDelta.cognitive > 3 ||
1522
- complexityDelta.maintainability < -5
1523
- ) {
1524
- lspOutput += `\n\n⚠️ Complexity increased: +${complexityDelta.cognitive} cognitive, ${complexityDelta.maintainability.toFixed(1)} maintainability`;
1525
- }
1526
- phaseEnd("complexity_check", {
1527
- delta: complexityDelta,
1528
- warned:
1529
- complexityDelta.cognitive > 3 ||
1530
- complexityDelta.maintainability < -5,
1531
- });
1532
- } else {
1533
- phaseEnd("complexity_check", { delta: null, warned: false });
1534
- }
1535
- } else {
1536
- phaseEnd("complexity_check", { skipped: true });
1427
+ // Secrets found block immediately
1428
+ if (result.isError) {
1429
+ return {
1430
+ content: [
1431
+ ...event.content,
1432
+ { type: "text" as const, text: result.output },
1433
+ ],
1434
+ isError: true,
1435
+ };
1537
1436
  }
1538
1437
 
1539
- // Agent behavior warnings (blind writes, thrashing)
1438
+ // Append behavior warnings
1439
+ let output = result.output;
1540
1440
  if (behaviorWarnings.length > 0) {
1541
- lspOutput += `\n\n${agentBehaviorClient.formatWarnings(behaviorWarnings)}`;
1542
- }
1543
-
1544
- // --- Cascade diagnostics: check other files for errors (when --lens-lsp) ---
1545
- if (pi.getFlag("lens-lsp") && !pi.getFlag("no-lsp")) {
1546
- const MAX_CASCADE_FILES = 5;
1547
- const MAX_DIAGNOSTICS_PER_FILE = 20;
1548
- const cascadeStart = Date.now();
1549
-
1550
- try {
1551
- const lspService = getLSPService();
1552
- const allDiags = await lspService.getAllDiagnostics();
1553
- const normalizedEditedPath = path.resolve(filePath);
1554
- const otherFileErrors: Array<{
1555
- file: string;
1556
- errors: import("./clients/lsp/client.js").LSPDiagnostic[];
1557
- }> = [];
1558
-
1559
- for (const [diagPath, diags] of allDiags) {
1560
- if (path.resolve(diagPath) === normalizedEditedPath) continue; // Skip edited file (dispatch already covered it)
1561
- const errors = diags.filter((d) => d.severity === 1);
1562
- if (errors.length > 0) {
1563
- otherFileErrors.push({ file: diagPath, errors });
1564
- }
1565
- }
1566
-
1567
- if (otherFileErrors.length > 0) {
1568
- lspOutput += `\n\n📐 Cascade errors detected in ${otherFileErrors.length} other file(s):`;
1569
- for (const { file, errors } of otherFileErrors.slice(
1570
- 0,
1571
- MAX_CASCADE_FILES,
1572
- )) {
1573
- const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE);
1574
- const suffix =
1575
- errors.length > MAX_DIAGNOSTICS_PER_FILE
1576
- ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more`
1577
- : "";
1578
- // Structured XML format (like OpenCode) for cleaner parsing
1579
- lspOutput += `\n<diagnostics file="${file}">`;
1580
- for (const e of limited) {
1581
- const line = (e.range?.start?.line ?? 0) + 1;
1582
- const col = (e.range?.start?.character ?? 0) + 1;
1583
- const code = e.code ? ` [${e.code}]` : "";
1584
- lspOutput += `\n ${code} (${line}:${col}) ${e.message.split("\n")[0].slice(0, 100)}`;
1585
- }
1586
- lspOutput += `${suffix}\n</diagnostics>`;
1587
- }
1588
- if (otherFileErrors.length > MAX_CASCADE_FILES) {
1589
- lspOutput += `\n... and ${otherFileErrors.length - MAX_CASCADE_FILES} more files with errors`;
1590
- }
1591
- }
1592
-
1593
- logLatency({
1594
- type: "phase",
1595
- toolName,
1596
- filePath,
1597
- phase: "cascade_diagnostics",
1598
- durationMs: Date.now() - cascadeStart,
1599
- metadata: { filesWithErrors: otherFileErrors.length },
1600
- });
1601
- } catch (err) {
1602
- dbg(`cascade diagnostics error: ${err}`);
1603
- }
1604
- }
1605
-
1606
- // LATENCY TRACKING: Log timing before returning
1607
- const elapsed = Date.now() - toolResultStart;
1608
- phaseEnd("total", { hasOutput: !!lspOutput });
1609
- if (!lspOutput) {
1610
- logLatency({
1611
- type: "tool_result",
1612
- toolName,
1613
- filePath,
1614
- durationMs: elapsed,
1615
- result: "no_output",
1616
- });
1617
- return;
1441
+ output += `\n\n${agentBehaviorClient.formatWarnings(behaviorWarnings)}`;
1618
1442
  }
1619
1443
 
1444
+ const totalMs = Date.now() - toolResultStart;
1620
1445
  logLatency({
1621
1446
  type: "tool_result",
1622
- toolName,
1447
+ toolName: event.toolName,
1623
1448
  filePath,
1624
- durationMs: elapsed,
1625
- result: "completed",
1449
+ durationMs: totalMs,
1450
+ result: output ? "completed" : "no_output",
1626
1451
  });
1627
1452
 
1453
+ if (!output) return;
1454
+
1628
1455
  return {
1629
- content: [...event.content, { type: "text" as const, text: lspOutput }],
1456
+ content: [...event.content, { type: "text" as const, text: output }],
1630
1457
  };
1631
1458
  });
1632
-
1633
1459
  // --- Inject project rules into system prompt ---
1634
1460
  pi.on("before_agent_start", async (event) => {
1635
1461
  if (!projectRulesScan.hasCustomRules) return;
@@ -1642,123 +1468,130 @@ export default function (pi: ExtensionAPI) {
1642
1468
 
1643
1469
  // --- Turn end: batch jscpd/madge on collected files, then clear state ---
1644
1470
  pi.on("turn_end", async (_event, ctx) => {
1645
- const cwd = ctx.cwd ?? process.cwd();
1646
- const turnState = cacheManager.readTurnState(cwd);
1647
- const files = Object.keys(turnState.files);
1648
-
1649
- if (files.length === 0) return;
1471
+ try {
1472
+ const cwd = ctx.cwd ?? process.cwd();
1473
+ const turnState = cacheManager.readTurnState(cwd);
1474
+ const files = Object.keys(turnState.files);
1650
1475
 
1651
- dbg(
1652
- `turn_end: ${files.length} file(s) modified, cycles: ${turnState.turnCycles}/${turnState.maxCycles}`,
1653
- );
1476
+ if (files.length === 0) return;
1654
1477
 
1655
- // Max cycles guard — force through after N turns with unresolved issues
1656
- if (cacheManager.isMaxCyclesExceeded(cwd)) {
1657
- dbg("turn_end: max cycles exceeded, clearing state and forcing through");
1658
- cacheManager.clearTurnState(cwd);
1659
- return;
1660
- }
1478
+ dbg(
1479
+ `turn_end: ${files.length} file(s) modified, cycles: ${turnState.turnCycles}/${turnState.maxCycles}`,
1480
+ );
1661
1481
 
1662
- const parts: string[] = [];
1663
-
1664
- // jscpd: scan modified files, filter results to modified line ranges
1665
- if (jscpdClient.isAvailable()) {
1666
- const jscpdFiles = cacheManager.getFilesForJscpd(cwd);
1667
- if (jscpdFiles.length > 0) {
1668
- dbg(`turn_end: jscpd scanning ${jscpdFiles.length} file(s)`);
1669
- // Use full scan then filter — jscpd doesn't support per-file scanning
1670
- const result = jscpdClient.scan(cwd);
1671
- // Filter clones to only those intersecting modified ranges
1672
- const jscpdFileSet = new Set(
1673
- jscpdFiles.map((f) => path.resolve(cwd, f)),
1482
+ // Max cycles guard — force through after N turns with unresolved issues
1483
+ if (cacheManager.isMaxCyclesExceeded(cwd)) {
1484
+ dbg(
1485
+ "turn_end: max cycles exceeded, clearing state and forcing through",
1674
1486
  );
1675
- const filtered = result.clones.filter((clone) => {
1676
- const resolvedA = path.resolve(clone.fileA);
1677
- if (!jscpdFileSet.has(resolvedA)) return false;
1678
- const relA = path.relative(cwd, resolvedA).replace(/\\/g, "/");
1679
- const state = turnState.files[relA];
1680
- if (!state) return false;
1681
- return cacheManager.isLineInModifiedRange(
1682
- clone.startA,
1683
- state.modifiedRanges,
1487
+ cacheManager.clearTurnState(cwd);
1488
+ return;
1489
+ }
1490
+
1491
+ const parts: string[] = [];
1492
+
1493
+ // jscpd: scan modified files, filter results to modified line ranges
1494
+ if (jscpdClient.isAvailable()) {
1495
+ const jscpdFiles = cacheManager.getFilesForJscpd(cwd);
1496
+ if (jscpdFiles.length > 0) {
1497
+ dbg(`turn_end: jscpd scanning ${jscpdFiles.length} file(s)`);
1498
+ // Use full scan then filter — jscpd doesn't support per-file scanning
1499
+ const result = jscpdClient.scan(cwd);
1500
+ // Filter clones to only those intersecting modified ranges
1501
+ const jscpdFileSet = new Set(
1502
+ jscpdFiles.map((f) => path.resolve(cwd, f)),
1684
1503
  );
1685
- });
1686
- if (filtered.length > 0) {
1687
- let report = `🔴 New duplicates in modified code:\n`;
1688
- for (const clone of filtered.slice(0, 5)) {
1689
- report += ` ${path.basename(clone.fileA)}:${clone.startA} ↔ ${path.basename(clone.fileB)}:${clone.startB} (${clone.lines} lines)\n`;
1504
+ const filtered = result.clones.filter((clone) => {
1505
+ const resolvedA = path.resolve(clone.fileA);
1506
+ if (!jscpdFileSet.has(resolvedA)) return false;
1507
+ const relA = path.relative(cwd, resolvedA).replace(/\\/g, "/");
1508
+ const state = turnState.files[relA];
1509
+ if (!state) return false;
1510
+ return cacheManager.isLineInModifiedRange(
1511
+ clone.startA,
1512
+ state.modifiedRanges,
1513
+ );
1514
+ });
1515
+ if (filtered.length > 0) {
1516
+ let report = `🔴 New duplicates in modified code:\n`;
1517
+ for (const clone of filtered.slice(0, 5)) {
1518
+ report += ` ${path.basename(clone.fileA)}:${clone.startA} ↔ ${path.basename(clone.fileB)}:${clone.startB} (${clone.lines} lines)\n`;
1519
+ }
1520
+ parts.push(report);
1690
1521
  }
1691
- parts.push(report);
1522
+ // Update the global cache with fresh results
1523
+ _cachedJscpdClones = result.clones;
1524
+ cacheManager.writeCache("jscpd", result, cwd);
1692
1525
  }
1693
- // Update the global cache with fresh results
1694
- _cachedJscpdClones = result.clones;
1695
- cacheManager.writeCache("jscpd", result, cwd);
1696
1526
  }
1697
- }
1698
1527
 
1699
- // madge: only check files where imports changed
1700
- if (await depChecker.ensureAvailable()) {
1701
- const madgeFiles = cacheManager.getFilesForMadge(cwd);
1702
- if (madgeFiles.length > 0) {
1703
- dbg(
1704
- `turn_end: madge checking ${madgeFiles.length} file(s) for circular deps`,
1705
- );
1706
- for (const file of madgeFiles) {
1707
- const absPath = path.resolve(cwd, file);
1708
- const depResult = depChecker.checkFile(absPath);
1709
- if (depResult.hasCircular && depResult.circular.length > 0) {
1710
- const circularDeps = depResult.circular
1711
- .flatMap((d) => d.path)
1712
- .filter((p: string) => !absPath.endsWith(path.basename(p)));
1713
- const uniqueDeps = [...new Set(circularDeps)];
1714
- if (uniqueDeps.length > 0) {
1715
- parts.push(
1716
- `🟡 Circular dependency in ${file}: imports ${uniqueDeps.join(", ")}`,
1717
- );
1528
+ // madge: only check files where imports changed
1529
+ if (await depChecker.ensureAvailable()) {
1530
+ const madgeFiles = cacheManager.getFilesForMadge(cwd);
1531
+ if (madgeFiles.length > 0) {
1532
+ dbg(
1533
+ `turn_end: madge checking ${madgeFiles.length} file(s) for circular deps`,
1534
+ );
1535
+ for (const file of madgeFiles) {
1536
+ const absPath = path.resolve(cwd, file);
1537
+ const depResult = depChecker.checkFile(absPath);
1538
+ if (depResult.hasCircular && depResult.circular.length > 0) {
1539
+ const circularDeps = depResult.circular
1540
+ .flatMap((d) => d.path)
1541
+ .filter((p: string) => !absPath.endsWith(path.basename(p)));
1542
+ const uniqueDeps = [...new Set(circularDeps)];
1543
+ if (uniqueDeps.length > 0) {
1544
+ parts.push(
1545
+ `🟡 Circular dependency in ${file}: imports ${uniqueDeps.join(", ")}`,
1546
+ );
1547
+ }
1718
1548
  }
1719
1549
  }
1720
1550
  }
1721
1551
  }
1722
- }
1723
1552
 
1724
- // Increment turn cycle and persist
1725
- cacheManager.incrementTurnCycle(cwd);
1553
+ // Increment turn cycle and persist
1554
+ cacheManager.incrementTurnCycle(cwd);
1726
1555
 
1727
- if (parts.length > 0) {
1728
- dbg(`turn_end: ${parts.length} issue(s) found`);
1729
- // Issues found — state persists so next turn re-checks.
1730
- // After maxCycles, clearTurnState forces through.
1731
- } else {
1732
- // No issues — clear state for next batch of edits
1733
- cacheManager.clearTurnState(cwd);
1734
- }
1556
+ if (parts.length > 0) {
1557
+ dbg(`turn_end: ${parts.length} issue(s) found`);
1558
+ // Issues found — state persists so next turn re-checks.
1559
+ // After maxCycles, clearTurnState forces through.
1560
+ } else {
1561
+ // No issues — clear state for next batch of edits
1562
+ cacheManager.clearTurnState(cwd);
1563
+ }
1735
1564
 
1736
- // --- Error debt: trigger background test run for next session ---
1737
- // We don't wait - just set a flag that tests should run at next session_start
1738
- // This way tests run async (session_start is when agent is idle)
1739
- if (errorDebtBaseline && files.length > 0) {
1740
- dbg("turn_end: marking error debt check for next session");
1741
- // Write a marker file - next session_start will pick this up
1742
- cacheManager.writeCache(
1743
- "errorDebt",
1744
- {
1745
- pendingCheck: true,
1746
- baselineTestsPassed: errorDebtBaseline.testsPassed,
1747
- },
1748
- cwd,
1749
- );
1750
- }
1565
+ // --- Error debt: trigger background test run for next session ---
1566
+ // We don't wait - just set a flag that tests should run at next session_start
1567
+ // This way tests run async (session_start is when agent is idle)
1568
+ if (errorDebtBaseline && files.length > 0) {
1569
+ dbg("turn_end: marking error debt check for next session");
1570
+ // Write a marker file - next session_start will pick this up
1571
+ cacheManager.writeCache(
1572
+ "errorDebt",
1573
+ {
1574
+ pendingCheck: true,
1575
+ baselineTestsPassed: errorDebtBaseline.testsPassed,
1576
+ },
1577
+ cwd,
1578
+ );
1579
+ }
1751
1580
 
1752
- // Clear fixed tracking so files can be fixed again on next turn
1753
- fixedThisTurn.clear();
1581
+ // Clear fixed tracking so files can be fixed again on next turn
1582
+ fixedThisTurn.clear();
1754
1583
 
1755
- // --- LSP cleanup on turn end (Phase 3) ---
1756
- // Only shutdown if no files are being actively edited
1757
- if (pi.getFlag("lens-lsp") && files.length === 0) {
1758
- resetLSPService();
1759
- }
1584
+ // --- LSP cleanup on turn end (Phase 3) ---
1585
+ // Only shutdown if no files are being actively edited
1586
+ if (pi.getFlag("lens-lsp") && files.length === 0) {
1587
+ resetLSPService();
1588
+ }
1760
1589
 
1761
- // --- Format service cleanup ---
1762
- resetFormatService();
1590
+ // --- Format service cleanup ---
1591
+ resetFormatService();
1592
+ } catch (turnEndErr) {
1593
+ dbg(`turn_end crashed: ${turnEndErr}`);
1594
+ dbg(`turn_end crash stack: ${(turnEndErr as Error).stack}`);
1595
+ }
1763
1596
  });
1764
1597
  }