squackit 0.8.2__tar.gz → 0.8.4__tar.gz

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 (142) hide show
  1. {squackit-0.8.2 → squackit-0.8.4}/CHANGELOG.md +99 -0
  2. {squackit-0.8.2 → squackit-0.8.4}/PKG-INFO +1 -1
  3. {squackit-0.8.2 → squackit-0.8.4}/pyproject.toml +1 -1
  4. squackit-0.8.4/squackit/__init__.py +17 -0
  5. {squackit-0.8.2 → squackit-0.8.4}/squackit/tool_config.py +32 -1
  6. {squackit-0.8.2 → squackit-0.8.4}/squackit/tools.py +17 -4
  7. {squackit-0.8.2 → squackit-0.8.4}/squackit/workflows.py +72 -8
  8. squackit-0.8.4/tests/test_error_visibility.py +253 -0
  9. {squackit-0.8.2 → squackit-0.8.4}/tests/test_sandbox_anchoring.py +95 -0
  10. {squackit-0.8.2 → squackit-0.8.4}/tests/test_smoke.py +17 -1
  11. squackit-0.8.2/squackit/__init__.py +0 -3
  12. {squackit-0.8.2 → squackit-0.8.4}/.github/workflows/release.yml +0 -0
  13. {squackit-0.8.2 → squackit-0.8.4}/.gitignore +0 -0
  14. {squackit-0.8.2 → squackit-0.8.4}/.mcp.json +0 -0
  15. {squackit-0.8.2 → squackit-0.8.4}/.readthedocs.yaml +0 -0
  16. {squackit-0.8.2 → squackit-0.8.4}/.readthedocs.yml +0 -0
  17. {squackit-0.8.2 → squackit-0.8.4}/CLAUDE.md +0 -0
  18. {squackit-0.8.2 → squackit-0.8.4}/LICENSE +0 -0
  19. {squackit-0.8.2 → squackit-0.8.4}/README.md +0 -0
  20. {squackit-0.8.2 → squackit-0.8.4}/docs/agent-system-prompt.md +0 -0
  21. {squackit-0.8.2 → squackit-0.8.4}/docs/architecture.md +0 -0
  22. {squackit-0.8.2 → squackit-0.8.4}/docs/configuration.md +0 -0
  23. {squackit-0.8.2 → squackit-0.8.4}/docs/index.md +0 -0
  24. {squackit-0.8.2 → squackit-0.8.4}/docs/prompts.md +0 -0
  25. {squackit-0.8.2 → squackit-0.8.4}/docs/proposals/2026-06-04-response-type-improvements.md +0 -0
  26. {squackit-0.8.2 → squackit-0.8.4}/docs/quickstart.md +0 -0
  27. {squackit-0.8.2 → squackit-0.8.4}/docs/resources.md +0 -0
  28. {squackit-0.8.2 → squackit-0.8.4}/docs/superpowers/plans/2026-04-10-phase1-handoff.md +0 -0
  29. {squackit-0.8.2 → squackit-0.8.4}/docs/superpowers/plans/2026-04-10-squawkit-extraction.md +0 -0
  30. {squackit-0.8.2 → squackit-0.8.4}/docs/superpowers/plans/2026-04-11-phase3-pluckit-rewire.md +0 -0
  31. {squackit-0.8.2 → squackit-0.8.4}/docs/superpowers/plans/2026-04-12-cli-tool-exposure.md +0 -0
  32. {squackit-0.8.2 → squackit-0.8.4}/docs/superpowers/plans/2026-04-12-pluckit-tool-integration.md +0 -0
  33. {squackit-0.8.2 → squackit-0.8.4}/docs/superpowers/plans/2026-04-12-session-handoff.md +0 -0
  34. {squackit-0.8.2 → squackit-0.8.4}/docs/superpowers/plans/2026-04-16-session-handoff.md +0 -0
  35. {squackit-0.8.2 → squackit-0.8.4}/docs/superpowers/plans/2026-04-26-named-fts-collections.md +0 -0
  36. {squackit-0.8.2 → squackit-0.8.4}/docs/superpowers/plans/2026-04-27-kibitzer-fts-doc-retrieval.md +0 -0
  37. {squackit-0.8.2 → squackit-0.8.4}/docs/superpowers/skills/exploring-unfamiliar-code/SKILL.md +0 -0
  38. {squackit-0.8.2 → squackit-0.8.4}/docs/superpowers/skills/exploring-unfamiliar-docs/SKILL.md +0 -0
  39. {squackit-0.8.2 → squackit-0.8.4}/docs/superpowers/skills/pluck-chains/SKILL.md +0 -0
  40. {squackit-0.8.2 → squackit-0.8.4}/docs/superpowers/skills/refactoring-with-squackit/SKILL.md +0 -0
  41. {squackit-0.8.2 → squackit-0.8.4}/docs/superpowers/skills/using-squackit/SKILL.md +0 -0
  42. {squackit-0.8.2 → squackit-0.8.4}/docs/superpowers/specs/2026-04-10-squawkit-design.md +0 -0
  43. {squackit-0.8.2 → squackit-0.8.4}/docs/superpowers/specs/2026-04-12-cli-tool-exposure-design.md +0 -0
  44. {squackit-0.8.2 → squackit-0.8.4}/docs/superpowers/specs/2026-04-12-pluckit-tool-integration-design.md +0 -0
  45. {squackit-0.8.2 → squackit-0.8.4}/docs/superpowers/specs/2026-04-16-training-data-pipeline-design.md +0 -0
  46. {squackit-0.8.2 → squackit-0.8.4}/docs/superpowers/specs/2026-04-26-named-fts-collections-design.md +0 -0
  47. {squackit-0.8.2 → squackit-0.8.4}/docs/tools.md +0 -0
  48. {squackit-0.8.2 → squackit-0.8.4}/mkdocs.yml +0 -0
  49. {squackit-0.8.2 → squackit-0.8.4}/scripts/EXPERIMENT_LOG.md +0 -0
  50. {squackit-0.8.2 → squackit-0.8.4}/scripts/benchmark_models.py +0 -0
  51. {squackit-0.8.2 → squackit-0.8.4}/scripts/cascade_prototype.py +0 -0
  52. {squackit-0.8.2 → squackit-0.8.4}/scripts/combo_eval.py +0 -0
  53. {squackit-0.8.2 → squackit-0.8.4}/scripts/generate_examples.py +0 -0
  54. {squackit-0.8.2 → squackit-0.8.4}/scripts/grid_generate_examples.py +0 -0
  55. {squackit-0.8.2 → squackit-0.8.4}/scripts/hard_task_results/deepseek-coder-v2_16b.json +0 -0
  56. {squackit-0.8.2 → squackit-0.8.4}/scripts/hard_task_results/qwen2.5-coder_0.5b.json +0 -0
  57. {squackit-0.8.2 → squackit-0.8.4}/scripts/hard_task_results/qwen2.5-coder_3b.json +0 -0
  58. {squackit-0.8.2 → squackit-0.8.4}/scripts/hard_task_results/qwen2.5-coder_7b.json +0 -0
  59. {squackit-0.8.2 → squackit-0.8.4}/scripts/hard_task_results/qwen3.5_2b.json +0 -0
  60. {squackit-0.8.2 → squackit-0.8.4}/scripts/hard_task_results/qwen3_8b.json +0 -0
  61. {squackit-0.8.2 → squackit-0.8.4}/scripts/hard_task_results/scoring_input.json +0 -0
  62. {squackit-0.8.2 → squackit-0.8.4}/scripts/hard_task_results/summary.json +0 -0
  63. {squackit-0.8.2 → squackit-0.8.4}/scripts/hard_tasks.py +0 -0
  64. {squackit-0.8.2 → squackit-0.8.4}/scripts/harvest_grid.py +0 -0
  65. {squackit-0.8.2 → squackit-0.8.4}/scripts/iterative_eval.py +0 -0
  66. {squackit-0.8.2 → squackit-0.8.4}/scripts/iterative_results/summary.json +0 -0
  67. {squackit-0.8.2 → squackit-0.8.4}/scripts/iterative_results/summary_deepseek_plain.json +0 -0
  68. {squackit-0.8.2 → squackit-0.8.4}/scripts/iterative_results/summary_distilled_xml.json +0 -0
  69. {squackit-0.8.2 → squackit-0.8.4}/scripts/iterative_results/summary_editor_3b_7b.json +0 -0
  70. {squackit-0.8.2 → squackit-0.8.4}/scripts/iterative_results/summary_fulldocs.json +0 -0
  71. {squackit-0.8.2 → squackit-0.8.4}/scripts/iterative_results/summary_plaintext_3b7b.json +0 -0
  72. {squackit-0.8.2 → squackit-0.8.4}/scripts/model_card_results/codegemma_2b.json +0 -0
  73. {squackit-0.8.2 → squackit-0.8.4}/scripts/model_card_results/codegemma_7b.json +0 -0
  74. {squackit-0.8.2 → squackit-0.8.4}/scripts/model_card_results/deepseek-coder-v2_16b.json +0 -0
  75. {squackit-0.8.2 → squackit-0.8.4}/scripts/model_card_results/deepseek-llm_7b.json +0 -0
  76. {squackit-0.8.2 → squackit-0.8.4}/scripts/model_card_results/gemma_latest.json +0 -0
  77. {squackit-0.8.2 → squackit-0.8.4}/scripts/model_card_results/glm4_9b.json +0 -0
  78. {squackit-0.8.2 → squackit-0.8.4}/scripts/model_card_results/granite-code_3b.json +0 -0
  79. {squackit-0.8.2 → squackit-0.8.4}/scripts/model_card_results/granite3.1-dense_2b.json +0 -0
  80. {squackit-0.8.2 → squackit-0.8.4}/scripts/model_card_results/granite3.3_2b.json +0 -0
  81. {squackit-0.8.2 → squackit-0.8.4}/scripts/model_card_results/llama2-uncensored_7b.json +0 -0
  82. {squackit-0.8.2 → squackit-0.8.4}/scripts/model_card_results/llama3.2-vision_11b.json +0 -0
  83. {squackit-0.8.2 → squackit-0.8.4}/scripts/model_card_results/llama3.2_1b.json +0 -0
  84. {squackit-0.8.2 → squackit-0.8.4}/scripts/model_card_results/llama3.2_latest.json +0 -0
  85. {squackit-0.8.2 → squackit-0.8.4}/scripts/model_card_results/phi3_latest.json +0 -0
  86. {squackit-0.8.2 → squackit-0.8.4}/scripts/model_card_results/phi4-mini_latest.json +0 -0
  87. {squackit-0.8.2 → squackit-0.8.4}/scripts/model_card_results/qwen2.5-coder_0.5b.json +0 -0
  88. {squackit-0.8.2 → squackit-0.8.4}/scripts/model_card_results/qwen2.5-coder_1.5b.json +0 -0
  89. {squackit-0.8.2 → squackit-0.8.4}/scripts/model_card_results/qwen2.5-coder_3b.json +0 -0
  90. {squackit-0.8.2 → squackit-0.8.4}/scripts/model_card_results/qwen2.5-coder_7b.json +0 -0
  91. {squackit-0.8.2 → squackit-0.8.4}/scripts/model_card_results/qwen2.5_1.5b.json +0 -0
  92. {squackit-0.8.2 → squackit-0.8.4}/scripts/model_card_results/qwen2.5_3b.json +0 -0
  93. {squackit-0.8.2 → squackit-0.8.4}/scripts/model_card_results/qwen2.5_7b.json +0 -0
  94. {squackit-0.8.2 → squackit-0.8.4}/scripts/model_card_results/qwen2_latest.json +0 -0
  95. {squackit-0.8.2 → squackit-0.8.4}/scripts/model_card_results/qwen3.5_0.8b.json +0 -0
  96. {squackit-0.8.2 → squackit-0.8.4}/scripts/model_card_results/qwen3.5_2b.json +0 -0
  97. {squackit-0.8.2 → squackit-0.8.4}/scripts/model_card_results/qwen3.5_4b.json +0 -0
  98. {squackit-0.8.2 → squackit-0.8.4}/scripts/model_card_results/qwen3.5_9b.json +0 -0
  99. {squackit-0.8.2 → squackit-0.8.4}/scripts/model_card_results/qwen3_0.6b.json +0 -0
  100. {squackit-0.8.2 → squackit-0.8.4}/scripts/model_card_results/qwen3_8b.json +0 -0
  101. {squackit-0.8.2 → squackit-0.8.4}/scripts/model_card_results/smollm2_latest.json +0 -0
  102. {squackit-0.8.2 → squackit-0.8.4}/scripts/model_card_results/summary.json +0 -0
  103. {squackit-0.8.2 → squackit-0.8.4}/scripts/model_cards.py +0 -0
  104. {squackit-0.8.2 → squackit-0.8.4}/scripts/multiline_results/deepseek-coder-v2_16b.json +0 -0
  105. {squackit-0.8.2 → squackit-0.8.4}/scripts/multiline_results/qwen2.5-coder_3b.json +0 -0
  106. {squackit-0.8.2 → squackit-0.8.4}/scripts/multiline_results/qwen2.5-coder_7b.json +0 -0
  107. {squackit-0.8.2 → squackit-0.8.4}/scripts/multiline_results/qwen3.5_2b.json +0 -0
  108. {squackit-0.8.2 → squackit-0.8.4}/scripts/multiline_results/qwen3_8b.json +0 -0
  109. {squackit-0.8.2 → squackit-0.8.4}/scripts/multiline_results/summary.json +0 -0
  110. {squackit-0.8.2 → squackit-0.8.4}/scripts/multiline_tasks.py +0 -0
  111. {squackit-0.8.2 → squackit-0.8.4}/scripts/qm_retrieval_eval.py +0 -0
  112. {squackit-0.8.2 → squackit-0.8.4}/scripts/quartermaster_eval.py +0 -0
  113. {squackit-0.8.2 → squackit-0.8.4}/scripts/quartermaster_results/retrieval_summary.json +0 -0
  114. {squackit-0.8.2 → squackit-0.8.4}/scripts/quartermaster_results/summary.json +0 -0
  115. {squackit-0.8.2 → squackit-0.8.4}/scripts/selector_eval.py +0 -0
  116. {squackit-0.8.2 → squackit-0.8.4}/scripts/selector_results/summary.json +0 -0
  117. {squackit-0.8.2 → squackit-0.8.4}/squackit/__main__.py +0 -0
  118. {squackit-0.8.2 → squackit-0.8.4}/squackit/cli.py +0 -0
  119. {squackit-0.8.2 → squackit-0.8.4}/squackit/data/examples.json +0 -0
  120. {squackit-0.8.2 → squackit-0.8.4}/squackit/db.py +0 -0
  121. {squackit-0.8.2 → squackit-0.8.4}/squackit/defaults.py +0 -0
  122. {squackit-0.8.2 → squackit-0.8.4}/squackit/formatting.py +0 -0
  123. {squackit-0.8.2 → squackit-0.8.4}/squackit/lackpy_integration.py +0 -0
  124. {squackit-0.8.2 → squackit-0.8.4}/squackit/prompts.py +0 -0
  125. {squackit-0.8.2 → squackit-0.8.4}/squackit/runtime.py +0 -0
  126. {squackit-0.8.2 → squackit-0.8.4}/squackit/server.py +0 -0
  127. {squackit-0.8.2 → squackit-0.8.4}/squackit/session.py +0 -0
  128. {squackit-0.8.2 → squackit-0.8.4}/tests/conftest.py +0 -0
  129. {squackit-0.8.2 → squackit-0.8.4}/tests/fixtures/has_call_filter.py +0 -0
  130. {squackit-0.8.2 → squackit-0.8.4}/tests/test_cli_tools.py +0 -0
  131. {squackit-0.8.2 → squackit-0.8.4}/tests/test_config_consumption.py +0 -0
  132. {squackit-0.8.2 → squackit-0.8.4}/tests/test_defaults.py +0 -0
  133. {squackit-0.8.2 → squackit-0.8.4}/tests/test_formatting_json.py +0 -0
  134. {squackit-0.8.2 → squackit-0.8.4}/tests/test_fts_root.py +0 -0
  135. {squackit-0.8.2 → squackit-0.8.4}/tests/test_prompts.py +0 -0
  136. {squackit-0.8.2 → squackit-0.8.4}/tests/test_resources.py +0 -0
  137. {squackit-0.8.2 → squackit-0.8.4}/tests/test_runtime_config.py +0 -0
  138. {squackit-0.8.2 → squackit-0.8.4}/tests/test_session.py +0 -0
  139. {squackit-0.8.2 → squackit-0.8.4}/tests/test_tool_config.py +0 -0
  140. {squackit-0.8.2 → squackit-0.8.4}/tests/test_tools.py +0 -0
  141. {squackit-0.8.2 → squackit-0.8.4}/tests/test_truncation.py +0 -0
  142. {squackit-0.8.2 → squackit-0.8.4}/tests/test_workflows.py +0 -0
@@ -1,5 +1,104 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.4
4
+
5
+ A review of 0.8.1–0.8.3 found both fixes had reached the macro-tool layer and
6
+ stopped there. The compound tools have their own parameters and never pass
7
+ through `apply_defaults`, so each gap was reachable through a different door.
8
+
9
+ ### Fixed — the compound tools did not anchor explicit patterns
10
+
11
+ 0.8.1 anchored caller-supplied relative patterns, but only in the macro-tool
12
+ wrapper. `investigate`, `review` and `search` take `file_pattern` themselves and
13
+ handed it to the macro unchanged, so a relative glob still resolved against the
14
+ process working directory. On a server whose cwd is not the corpus — the exact
15
+ deployment 0.8.1 was written for — this reproduced the original failure:
16
+
17
+ investigate(name="parse_config", file_pattern="src/**/*.py")
18
+ -> IOException: Failed to initialize file processing ... Permission
19
+
20
+ Verified against the published 0.8.3 before fixing. The 0.8.1 note that
21
+ "caller-supplied relative patterns are anchored too" was true of the macro path
22
+ only; it is now true of both. Absolute patterns are still passed through
23
+ untouched, and an explicit pattern still works without a defaults object.
24
+
25
+ ### Fixed — briefing sections named no cause
26
+
27
+ `_section` wraps every part of `explore`, `review` and `search`, and
28
+ `investigate`'s Source/Called-by/Calls. It caught everything, logged at DEBUG —
29
+ invisible at normal levels — and rendered a bare `(could not load)`. A
30
+ permission error, a missing git revision and an unloaded macro were
31
+ indistinguishable from each other and from a section that legitimately had
32
+ nothing.
33
+
34
+ That is the same defect 0.8.3 fixed in `investigate`'s primary lookup and did
35
+ not carry to the siblings. Sections now report the exception type and message,
36
+ capped at 200 characters with the truncation marked, and log at WARNING. One
37
+ failing section still cannot take down a briefing.
38
+
39
+
40
+ ## 0.8.3
41
+
42
+ Three fixes with one theme: a failure must not be representable as an ordinary
43
+ result. Nothing here changes what a working call returns.
44
+
45
+ ### Fixed — `investigate` reported failed lookups as confirmed absences (#14)
46
+
47
+ Every exception from the definition query was caught and rendered with the
48
+ sentence used for a genuine miss:
49
+
50
+ No definition found for 'X'. Try a broader pattern or check spelling.
51
+
52
+ So a permission error, a binder error and "that symbol isn't here" were
53
+ indistinguishable, and the advice actively misdirected — the pattern was never
54
+ the problem. This is how the 0.8.1 sandbox bug presented: every symbol in a
55
+ healthy project reported missing, for hours, while the server called all its
56
+ tools healthy.
57
+
58
+ A failed lookup now says so, names the underlying error, and reports the
59
+ pattern it searched. The friendly wording is kept for the genuine empty case,
60
+ where it is good advice.
61
+
62
+ ### Fixed — a raising pluckin lost its tools silently (#14)
63
+
64
+ `collect_pluckin_tools` swallowed per-pluckin exceptions with a bare `pass`,
65
+ under a comment claiming the error would be "surfaced contextually". Nothing
66
+ surfaced it. The guard is right — one broken pluckin must not take down the
67
+ registry — but it now logs a warning naming the pluckin and the error.
68
+
69
+ Not reachable today (no shipped pluckin defines `squackit_tools`), fixed as
70
+ latent. The symptom it would produce — a silently shorter tool surface — was
71
+ independently hit through another mechanism and cost a four-combination version
72
+ bisection, because a shorter tool list doesn't read as a failure. It reads as a
73
+ different, reasonable version of the product.
74
+
75
+ ### Fixed — `ast_replace` / `ast_patch` read as mutating and are not (#16)
76
+
77
+ Connection macros are published with a description generated from the
78
+ signature, so these carried no semantics at all:
79
+
80
+ Query: ast_replace(source, selector, new_text, language)
81
+
82
+ These are sitting_duck SQL macros. A SQL macro cannot write files — it computes
83
+ the patched source and returns it, which is coherent, and sitting_duck is not
84
+ misbehaving. But over MCP the caller reads a verb. One did: it called
85
+ `ast_replace` to repair a bug, got a successful-looking table back, and changed
86
+ nothing on disk.
87
+
88
+ Generated descriptions for macros whose *name* implies an effect now state that
89
+ they return the patched result and modify nothing. Read-only macros are
90
+ unchanged — a disclaimer on all ninety is context every agent pays for.
91
+
92
+ For an actually-mutating primitive, see #17.
93
+
94
+ ### Fixed — `__version__` was four releases stale (#15, #2)
95
+
96
+ `squackit.__version__` was a hand-written `"0.4.1"` against a distribution
97
+ version of `0.8.2`, and survived `--force-reinstall` because it derived from
98
+ nothing. It is now read from `importlib.metadata`, and the smoke test asserts
99
+ that correspondence rather than a literal — which is what let both drift in
100
+ agreement with each other and with nothing else.
101
+
3
102
  ## 0.8.2
4
103
 
5
104
  ### Fixed — AST tools could not read a source outside the working directory
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: squackit
3
- Version: 0.8.2
3
+ Version: 0.8.4
4
4
  Summary: Semi-QUalified Agent Companion Kit — the stateful intelligence + MCP server layer for fledgling-equipped agents.
5
5
  Project-URL: Homepage, https://github.com/teaguesterling/squackit
6
6
  Project-URL: Documentation, https://squackit.readthedocs.io
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "squackit"
3
- version = "0.8.2"
3
+ version = "0.8.4"
4
4
  description = "Semi-QUalified Agent Companion Kit — the stateful intelligence + MCP server layer for fledgling-equipped agents."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -0,0 +1,17 @@
1
+ """squackit: Semi-QUalified Agent Companion Kit — the stateful intelligence + MCP server layer for fledgling-equipped agents."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version as _dist_version
4
+
5
+ # Derived, never written by hand. The literal that used to live here read
6
+ # "0.4.1" while the installed distribution was 0.8.2 — four releases stale, and
7
+ # immune to --force-reinstall because it was not derived from anything. Someone
8
+ # debugging an unrelated problem read it, concluded they had a mixed install,
9
+ # and went looking for a packaging fault that did not exist. A version string
10
+ # that lies makes a healthy install look broken, which is the most expensive
11
+ # direction to send a reader.
12
+ try:
13
+ __version__ = _dist_version("squackit")
14
+ except PackageNotFoundError: # running from a source tree with no install
15
+ __version__ = "0.0.0.dev0"
16
+
17
+ __all__ = ["__version__"]
@@ -37,6 +37,21 @@ _FALLBACK_NUMERIC = {
37
37
  }
38
38
 
39
39
 
40
+ #: Name tokens that read as an effect. A macro whose name contains one of these
41
+ #: gets an explicit "does not modify any file" note in its generated
42
+ #: description. Missing a verb costs nothing beyond the status quo; a false
43
+ #: positive adds a sentence that is true of every SQL macro anyway.
44
+ _MUTATION_VERBS = frozenset({
45
+ "replace", "patch", "apply", "write", "insert", "update", "delete",
46
+ "remove", "rename", "edit", "set", "modify", "save", "commit",
47
+ })
48
+
49
+
50
+ def _name_implies_mutation(name: str) -> bool:
51
+ """True when a tool's NAME reads as an effect it does not perform."""
52
+ return any(token in _MUTATION_VERBS for token in name.split("_"))
53
+
54
+
40
55
  @dataclass
41
56
  class ToolPresentation:
42
57
  """Wraps fledgling ToolInfo with squackit's presentation/UX config."""
@@ -100,7 +115,23 @@ class ToolPresentation:
100
115
  def description(self) -> str:
101
116
  if self.description_override is not None:
102
117
  return self.description_override
103
- return self.info.description or f"Query: {self.name}({', '.join(self.params)})"
118
+ if self.info.description:
119
+ return self.info.description
120
+ signature = f"{self.name}({', '.join(self.params)})"
121
+ if _name_implies_mutation(self.name):
122
+ # SQL macros cannot write files — they compute and return a result
123
+ # set. But a caller reads a verb: `ast_replace` was called to repair
124
+ # a bug, returned a successful-looking table, and changed nothing on
125
+ # disk. The bare signature said nothing either way, and the only
126
+ # hint was the "Query:" prefix — a category label competing against
127
+ # an imperative. Spell it out for the names where it misleads, and
128
+ # only those: a disclaimer on all ~90 macros is context every agent
129
+ # pays for on every call.
130
+ return (
131
+ f"Query: {signature} — computes and RETURNS the patched result "
132
+ f"as text. Does not modify any file."
133
+ )
134
+ return f"Query: {signature}"
104
135
 
105
136
  @property
106
137
  def parameters_schema(self) -> dict | None:
@@ -9,6 +9,7 @@ fledgling macro tools. They take priority over fledgling equivalents
9
9
  from __future__ import annotations
10
10
 
11
11
  import hashlib
12
+ import logging
12
13
  import os
13
14
  import re
14
15
  import tempfile
@@ -16,6 +17,8 @@ import tempfile
16
17
  from fledgling.tools import ToolInfo
17
18
  from squackit.tool_config import ToolPresentation
18
19
 
20
+ log = logging.getLogger(__name__)
21
+
19
22
 
20
23
  #: How much source text ``read_ast`` puts in the ``peek`` column when squackit
21
24
  #: materializes its own table. ``'smart'`` matches what pluckit's cache uses by
@@ -305,10 +308,20 @@ def collect_pluckin_tools(plucker) -> list:
305
308
  if callable(fn):
306
309
  try:
307
310
  tools.extend(fn())
308
- except Exception:
309
- # A broken pluckin shouldn't break the whole registry.
310
- # Squackit's server/CLI will surface the error contextually.
311
- pass
311
+ except Exception as exc:
312
+ # Keep the guard — one broken pluckin must not take down the
313
+ # whole registry — but SAY SO. Dropping its tools silently
314
+ # yields a shorter tool surface, and a shorter tool list does
315
+ # not read as a failure: it reads as a different, reasonable
316
+ # version of the product, so callers reason forward from it
317
+ # instead of doubting it. (An earlier comment here claimed the
318
+ # server/CLI would "surface the error contextually". Nothing
319
+ # did.)
320
+ log.warning(
321
+ "pluckin %s failed to contribute squackit tools (%s); its "
322
+ "tools are missing from this session's registry",
323
+ type(pluckin).__name__, exc, exc_info=True,
324
+ )
312
325
  return tools
313
326
 
314
327
 
@@ -32,20 +32,41 @@ def _format_briefing(title: str, sections: list[tuple[str, str]]) -> str:
32
32
  return "\n".join(parts)
33
33
 
34
34
 
35
+ #: How much of a failing section's error text reaches the briefing. DuckDB
36
+ #: errors run to hundreds of characters and a briefing is prose, but a cause
37
+ #: the reader cannot see is the thing this whole clause exists to avoid.
38
+ _SECTION_ERROR_CHARS = 200
39
+
40
+
35
41
  def _section(heading: str, fn):
36
42
  """Run fn() with error handling, return (heading, content) tuple.
37
43
 
38
- Returns "(no data)" for empty/None results,
39
- "(could not load)" on exceptions.
44
+ Returns "(no data)" for empty/None results, and "(could not load — <type>:
45
+ <message>)" on exceptions.
46
+
47
+ THE CAUSE IS IN THE TEXT ON PURPOSE. This wrapper covers every section of
48
+ explore/review/search and investigate's Source/Called-by/Calls. It used to
49
+ return a bare "(could not load)" and log at DEBUG, which is invisible at
50
+ normal levels — so a permission error, a missing git revision and a macro
51
+ that simply is not loaded all rendered identically, and none of them said
52
+ why. That is the same "a failure must not be representable as an ordinary
53
+ result" defect fixed in investigate's primary lookup in 0.8.3; it was not
54
+ extended to the sibling sections until 0.8.4.
55
+
56
+ Still non-fatal: one broken section must not take down the briefing, which
57
+ is why the guard stays.
40
58
  """
41
59
  try:
42
60
  content = fn()
43
61
  if not content:
44
62
  return (heading, "(no data)")
45
63
  return (heading, content)
46
- except Exception:
47
- log.debug("section %s failed", heading, exc_info=True)
48
- return (heading, "(could not load)")
64
+ except Exception as exc:
65
+ log.warning("section %r failed: %s", heading, exc, exc_info=True)
66
+ detail = str(exc).strip().replace("\n", " ")
67
+ if len(detail) > _SECTION_ERROR_CHARS:
68
+ detail = detail[:_SECTION_ERROR_CHARS] + "…"
69
+ return (heading, f"(could not load — {type(exc).__name__}: {detail})")
49
70
 
50
71
 
51
72
  def _has_module(con, module_name: str) -> bool:
@@ -92,6 +113,31 @@ def _sorted_table(con, macro_name, kwargs, sort_col, max_rows, descending=True):
92
113
  # ── Compound tools ─────────────────────────────────────────────────
93
114
 
94
115
 
116
+ def _anchored(defaults, file_pattern):
117
+ """Root a caller-supplied file pattern, the way apply_defaults does.
118
+
119
+ The compound tools take `file_pattern` as their own parameter and never
120
+ pass through `apply_defaults`, so 0.8.1's anchoring of explicit relative
121
+ patterns reached the macro tools only. A relative glob handed to
122
+ `investigate`/`review`/`search` still resolved against the process cwd and,
123
+ under fledgling's sandbox, failed outright — the same deployment shape
124
+ 0.8.1 was written for, reached through a different door.
125
+
126
+ `anchor()` passes absolute patterns through untouched, so a caller who
127
+ named a path in full still gets it.
128
+
129
+ Tolerates ``defaults=None``. An explicit ``file_pattern`` previously needed
130
+ no defaults object at all — the caller had already said where to look — and
131
+ callers rely on that: several tests drive ``investigate`` with a fake
132
+ connection and no defaults to isolate the name-matching logic. There is
133
+ also nothing to anchor *against* without a root, which is the same reason
134
+ ``anchor()`` itself passes patterns through when ``root`` is None.
135
+ """
136
+ if file_pattern is None or defaults is None:
137
+ return file_pattern
138
+ return defaults.anchor(file_pattern)
139
+
140
+
95
141
  def explore(con, defaults, path=None):
96
142
  """First-contact codebase briefing."""
97
143
  # Scope patterns to path if provided
@@ -144,6 +190,7 @@ def investigate(con, defaults, name, file_pattern=None, path=None):
144
190
  path: Project root to scope the search to. Defaults to process cwd.
145
191
  Only consulted when file_pattern is None.
146
192
  """
193
+ file_pattern = _anchored(defaults, file_pattern)
147
194
  if file_pattern is None:
148
195
  from squackit.runtime import resolve_scope_path
149
196
  # Precedence: explicit path -> runtime.active_root -> process cwd.
@@ -160,13 +207,22 @@ def investigate(con, defaults, name, file_pattern=None, path=None):
160
207
  # the whole briefing describe the wrong symbol. Fall back to the full
161
208
  # substring set only when nothing matches exactly, preserving the
162
209
  # forgiving lookup for partial names / typos.
210
+ # A FAILED LOOKUP IS NOT AN ABSENT SYMBOL. Both used to return the "no
211
+ # definition found" sentence below, which made a permission error, a binder
212
+ # error and a genuine miss indistinguishable — and told the reader to
213
+ # broaden a pattern that was never the problem. That is exactly how the
214
+ # 0.8.1 sandbox bug presented: every symbol in a healthy project reported
215
+ # missing, for hours, while the server called all its tools healthy.
216
+ lookup_error: Exception | None = None
163
217
  try:
164
218
  rel = con.find_definitions(
165
219
  file_pattern=file_pattern, name_pattern=f"%{name}%",
166
220
  )
167
221
  defs = rel.fetchall()
168
222
  def_cols = rel.columns
169
- except Exception:
223
+ except Exception as exc:
224
+ log.debug("find_definitions failed for %r", name, exc_info=True)
225
+ lookup_error = exc
170
226
  defs = []
171
227
  def_cols = []
172
228
 
@@ -176,6 +232,14 @@ def investigate(con, defaults, name, file_pattern=None, path=None):
176
232
  if _exact:
177
233
  defs = _exact
178
234
 
235
+ if lookup_error is not None:
236
+ return (
237
+ f"Could not look up '{name}' — the definition query failed, so "
238
+ f"whether this symbol exists is UNKNOWN.\n"
239
+ f"{type(lookup_error).__name__}: {lookup_error}\n"
240
+ f"Searched: {file_pattern}"
241
+ )
242
+
179
243
  if not defs:
180
244
  return f"No definition found for '{name}'. Try a broader pattern or check spelling."
181
245
 
@@ -271,7 +335,7 @@ def review(con, defaults, from_rev=None, to_rev=None, file_pattern=None):
271
335
  """Code review prep for a revision range."""
272
336
  from_rev = from_rev or defaults.from_rev
273
337
  to_rev = to_rev or defaults.to_rev
274
- file_pattern = file_pattern or defaults.code_glob
338
+ file_pattern = _anchored(defaults, file_pattern) or defaults.code_glob
275
339
 
276
340
  sections = []
277
341
 
@@ -351,7 +415,7 @@ def review(con, defaults, from_rev=None, to_rev=None, file_pattern=None):
351
415
 
352
416
  def search(con, defaults, query, file_pattern=None):
353
417
  """Multi-source search across code, docs, and git."""
354
- file_pattern = file_pattern or defaults.code_glob
418
+ file_pattern = _anchored(defaults, file_pattern) or defaults.code_glob
355
419
 
356
420
  sections = []
357
421
 
@@ -0,0 +1,253 @@
1
+ """A failure must not be representable as an ordinary result.
2
+
3
+ Three places used to violate that, each in a different way:
4
+
5
+ * ``investigate`` caught every query exception and rendered it with the same
6
+ sentence it uses for a genuine miss — so a permission error, a binder error
7
+ and "that symbol isn't here" were indistinguishable, and the advice offered
8
+ ("try a broader pattern") actively misdirected. This is not hypothetical: it
9
+ is exactly how the 0.8.1 sandbox bug presented, for hours, while the server
10
+ reported every tool healthy.
11
+ * ``collect_pluckin_tools`` dropped a raising pluckin's tools with a bare
12
+ ``pass`` under a comment claiming the error would be surfaced elsewhere.
13
+ Nothing surfaced it. A silently shorter tool surface is worse than a silently
14
+ empty result — an empty result prompts "did that work?", a shorter tool list
15
+ reads as a different, reasonable version of the product.
16
+ * Auto-generated macro descriptions were a bare signature, so a SQL macro named
17
+ ``ast_replace`` — which computes patched source and writes nothing, because a
18
+ SQL macro *cannot* write — read as a mutation to any caller.
19
+
20
+ See issues #14 and #16.
21
+ """
22
+
23
+ import logging
24
+
25
+ import pytest
26
+
27
+ from squackit.defaults import ProjectDefaults
28
+ from squackit.tool_config import ToolPresentation
29
+ from squackit.tools import collect_pluckin_tools
30
+ from squackit.workflows import investigate
31
+
32
+
33
+ # ── investigate: a failed query is not a miss ───────────────────────
34
+
35
+
36
+ class _Rel:
37
+ def __init__(self, rows, cols):
38
+ self._rows, self.columns = rows, cols
39
+
40
+ def fetchall(self):
41
+ return self._rows
42
+
43
+
44
+ class _ConRaises:
45
+ """A connection whose lookup fails the way a sandboxed one does."""
46
+
47
+ def __init__(self, exc):
48
+ self._exc = exc
49
+
50
+ def find_definitions(self, **kw):
51
+ raise self._exc
52
+
53
+
54
+ class _ConEmpty:
55
+ """A connection that works and genuinely has no match."""
56
+
57
+ def find_definitions(self, **kw):
58
+ return _Rel([], ["file_path", "name", "kind",
59
+ "start_line", "end_line", "signature"])
60
+
61
+
62
+ @pytest.fixture
63
+ def defaults(tmp_path):
64
+ return ProjectDefaults(code_pattern="**/*.py", root=str(tmp_path))
65
+
66
+
67
+ class TestInvestigateDistinguishesFailureFromMiss:
68
+
69
+ def test_genuine_miss_keeps_the_helpful_message(self, defaults):
70
+ """The friendly wording is right when it IS a miss — keep it."""
71
+ out = investigate(_ConEmpty(), defaults, "nonexistent_symbol")
72
+ assert "No definition found" in out
73
+ assert "nonexistent_symbol" in out
74
+
75
+ def test_query_failure_does_NOT_claim_the_symbol_is_absent(self, defaults):
76
+ out = investigate(_ConRaises(RuntimeError("boom")), defaults, "parse_config")
77
+ assert "No definition found" not in out, (
78
+ "a failed lookup was reported as a confirmed absence"
79
+ )
80
+
81
+ def test_query_failure_says_it_failed(self, defaults):
82
+ out = investigate(_ConRaises(RuntimeError("boom")), defaults, "parse_config")
83
+ assert "could not" in out.lower() or "failed" in out.lower()
84
+
85
+ def test_query_failure_surfaces_the_underlying_error(self, defaults):
86
+ """The 0.8.1 bug was diagnosable only from this text."""
87
+ exc = RuntimeError(
88
+ 'Permission Error: Cannot access file "**/*.py" '
89
+ "- file system operations are disabled by configuration"
90
+ )
91
+ out = investigate(_ConRaises(exc), defaults, "parse_config")
92
+ assert "Permission Error" in out, out
93
+
94
+ def test_query_failure_does_NOT_advise_broadening_the_pattern(self, defaults):
95
+ """That advice sends the reader at the wrong thing entirely."""
96
+ out = investigate(_ConRaises(RuntimeError("boom")), defaults, "parse_config")
97
+ assert "broader pattern" not in out
98
+
99
+
100
+ # ── collect_pluckin_tools: a dropped pluckin is logged ───────────────
101
+
102
+
103
+ class _Pluckin:
104
+ def __init__(self, tools=None, exc=None):
105
+ self._tools, self._exc = tools or [], exc
106
+
107
+ def squackit_tools(self):
108
+ if self._exc:
109
+ raise self._exc
110
+ return self._tools
111
+
112
+
113
+ class _Plucker:
114
+ def __init__(self, pluckins):
115
+ self.pluckins = pluckins
116
+
117
+
118
+ class TestPluckinFailuresAreVisible:
119
+
120
+ def test_a_raising_pluckin_does_not_break_the_registry(self):
121
+ """The guard itself is correct and stays."""
122
+ good = _Pluckin(tools=["t1", "t2"])
123
+ bad = _Pluckin(exc=RuntimeError("plugin exploded"))
124
+ assert collect_pluckin_tools(_Plucker([bad, good])) == ["t1", "t2"]
125
+
126
+ def test_a_raising_pluckin_is_LOGGED(self, caplog):
127
+ bad = _Pluckin(exc=RuntimeError("plugin exploded"))
128
+ with caplog.at_level(logging.WARNING, logger="squackit.tools"):
129
+ collect_pluckin_tools(_Plucker([bad]))
130
+ assert caplog.records, "a pluckin's tools vanished with no log record"
131
+
132
+ def test_the_log_names_the_pluckin_and_the_error(self, caplog):
133
+ bad = _Pluckin(exc=RuntimeError("plugin exploded"))
134
+ with caplog.at_level(logging.WARNING, logger="squackit.tools"):
135
+ collect_pluckin_tools(_Plucker([bad]))
136
+ text = " ".join(r.getMessage() for r in caplog.records)
137
+ assert "_Pluckin" in text, text
138
+ assert "plugin exploded" in text, text
139
+
140
+ def test_healthy_pluckins_log_nothing(self, caplog):
141
+ with caplog.at_level(logging.WARNING, logger="squackit.tools"):
142
+ collect_pluckin_tools(_Plucker([_Pluckin(tools=["t1"])]))
143
+ assert not caplog.records
144
+
145
+
146
+ # ── generated descriptions state their semantics ─────────────────────
147
+
148
+
149
+ class _Info:
150
+ """Stand-in for fledgling's ToolInfo carrying no description of its own.
151
+
152
+ ToolPresentation derives `name` and `params` from the info object, so the
153
+ stub has to supply macro_name/params rather than the presentation taking
154
+ them directly.
155
+ """
156
+
157
+ def __init__(self, macro_name, params, description=None):
158
+ self.macro_name = macro_name
159
+ self.tool_name = None
160
+ self.params = list(params)
161
+ self.description = description
162
+ self.required = None
163
+ self.required_params = []
164
+ self.format = None
165
+ self.parameters_schema = None
166
+
167
+
168
+ def _presentation(name, params):
169
+ return ToolPresentation(info=_Info(name, params))
170
+
171
+
172
+ class TestMutationNamedMacrosSayTheyDoNotMutate:
173
+
174
+ def test_ast_replace_states_it_does_not_modify(self):
175
+ d = _presentation("ast_replace", ["source", "selector", "new_text"])
176
+ assert "does not modify" in d.description.lower(), d.description
177
+
178
+ def test_ast_patch_states_it_does_not_modify(self):
179
+ d = _presentation("ast_patch", ["edits", "files"])
180
+ assert "does not modify" in d.description.lower(), d.description
181
+
182
+ def test_the_signature_is_still_there(self):
183
+ d = _presentation("ast_replace", ["source", "selector", "new_text"])
184
+ assert "ast_replace(source, selector, new_text)" in d.description
185
+
186
+ def test_read_only_macros_are_NOT_cluttered(self):
187
+ """A disclaimer on all 90 macros is context every agent pays for."""
188
+ d = _presentation("find_definitions", ["file_pattern", "name_pattern"])
189
+ assert "does not modify" not in d.description.lower()
190
+
191
+ def test_a_real_description_still_wins(self):
192
+ info = _Info("ast_replace", ["source"], description="The macro's own words.")
193
+ assert ToolPresentation(info=info).description == "The macro's own words."
194
+
195
+ def test_an_override_still_wins(self):
196
+ d = ToolPresentation(info=_Info("ast_replace", ["source"]),
197
+ description_override="Overridden.")
198
+ assert d.description == "Overridden."
199
+
200
+
201
+ # ── Briefing sections name their failure (#2 from the 0.8.3 review) ──
202
+
203
+
204
+ class TestSectionFailuresAreNamed:
205
+ """`_section` wraps every part of explore/review/search/investigate.
206
+
207
+ It caught everything, logged at DEBUG (invisible at normal levels) and
208
+ rendered "(could not load)" — a string that cannot be told apart from a
209
+ section that legitimately has nothing, and which names no cause. 0.8.3
210
+ fixed exactly this shape in investigate's primary lookup but never reached
211
+ the sibling sections, so the same permission error that motivated 0.8.1
212
+ still arrived as undifferentiated text in three of the four compound tools.
213
+ """
214
+
215
+ def test_a_failing_section_still_reports_could_not_load(self):
216
+ """Keep the recognisable phrase — callers and tests match on it."""
217
+ from squackit.workflows import _section
218
+
219
+ heading, content = _section("Diffs", lambda: (_ for _ in ()).throw(
220
+ RuntimeError("boom")))
221
+ assert heading == "Diffs"
222
+ assert "could not load" in content.lower()
223
+
224
+ def test_a_failing_section_names_the_error(self):
225
+ from squackit.workflows import _section
226
+
227
+ _heading, content = _section("Diffs", lambda: (_ for _ in ()).throw(
228
+ RuntimeError("permission denied on **/*.py")))
229
+ assert "RuntimeError" in content, content
230
+ assert "permission denied" in content, content
231
+
232
+ def test_an_empty_section_is_distinguishable_from_a_failed_one(self):
233
+ from squackit.workflows import _section
234
+
235
+ _h, empty = _section("Docs", lambda: "")
236
+ _h2, failed = _section("Docs", lambda: (_ for _ in ()).throw(
237
+ RuntimeError("boom")))
238
+ assert empty != failed
239
+ assert "no data" in empty.lower()
240
+
241
+ def test_a_very_long_error_is_capped_visibly(self):
242
+ """DuckDB errors run to hundreds of characters; a briefing is prose."""
243
+ from squackit.workflows import _section
244
+
245
+ _h, content = _section("Diffs", lambda: (_ for _ in ()).throw(
246
+ RuntimeError("x" * 4000)))
247
+ assert len(content) < 400, len(content)
248
+ assert "…" in content or "..." in content, content
249
+
250
+ def test_a_working_section_is_untouched(self):
251
+ from squackit.workflows import _section
252
+
253
+ assert _section("Docs", lambda: "| a | b |") == ("Docs", "| a | b |")