squackit 0.8.3__tar.gz → 0.9.0__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.3 → squackit-0.9.0}/CHANGELOG.md +74 -0
  2. {squackit-0.8.3 → squackit-0.9.0}/PKG-INFO +1 -1
  3. {squackit-0.8.3 → squackit-0.9.0}/pyproject.toml +1 -1
  4. {squackit-0.8.3 → squackit-0.9.0}/squackit/server.py +122 -3
  5. {squackit-0.8.3 → squackit-0.9.0}/squackit/workflows.py +54 -7
  6. {squackit-0.8.3 → squackit-0.9.0}/tests/test_error_visibility.py +55 -0
  7. {squackit-0.8.3 → squackit-0.9.0}/tests/test_sandbox_anchoring.py +95 -0
  8. squackit-0.9.0/tests/test_structured_output.py +191 -0
  9. {squackit-0.8.3 → squackit-0.9.0}/.github/workflows/release.yml +0 -0
  10. {squackit-0.8.3 → squackit-0.9.0}/.gitignore +0 -0
  11. {squackit-0.8.3 → squackit-0.9.0}/.mcp.json +0 -0
  12. {squackit-0.8.3 → squackit-0.9.0}/.readthedocs.yaml +0 -0
  13. {squackit-0.8.3 → squackit-0.9.0}/.readthedocs.yml +0 -0
  14. {squackit-0.8.3 → squackit-0.9.0}/CLAUDE.md +0 -0
  15. {squackit-0.8.3 → squackit-0.9.0}/LICENSE +0 -0
  16. {squackit-0.8.3 → squackit-0.9.0}/README.md +0 -0
  17. {squackit-0.8.3 → squackit-0.9.0}/docs/agent-system-prompt.md +0 -0
  18. {squackit-0.8.3 → squackit-0.9.0}/docs/architecture.md +0 -0
  19. {squackit-0.8.3 → squackit-0.9.0}/docs/configuration.md +0 -0
  20. {squackit-0.8.3 → squackit-0.9.0}/docs/index.md +0 -0
  21. {squackit-0.8.3 → squackit-0.9.0}/docs/prompts.md +0 -0
  22. {squackit-0.8.3 → squackit-0.9.0}/docs/proposals/2026-06-04-response-type-improvements.md +0 -0
  23. {squackit-0.8.3 → squackit-0.9.0}/docs/quickstart.md +0 -0
  24. {squackit-0.8.3 → squackit-0.9.0}/docs/resources.md +0 -0
  25. {squackit-0.8.3 → squackit-0.9.0}/docs/superpowers/plans/2026-04-10-phase1-handoff.md +0 -0
  26. {squackit-0.8.3 → squackit-0.9.0}/docs/superpowers/plans/2026-04-10-squawkit-extraction.md +0 -0
  27. {squackit-0.8.3 → squackit-0.9.0}/docs/superpowers/plans/2026-04-11-phase3-pluckit-rewire.md +0 -0
  28. {squackit-0.8.3 → squackit-0.9.0}/docs/superpowers/plans/2026-04-12-cli-tool-exposure.md +0 -0
  29. {squackit-0.8.3 → squackit-0.9.0}/docs/superpowers/plans/2026-04-12-pluckit-tool-integration.md +0 -0
  30. {squackit-0.8.3 → squackit-0.9.0}/docs/superpowers/plans/2026-04-12-session-handoff.md +0 -0
  31. {squackit-0.8.3 → squackit-0.9.0}/docs/superpowers/plans/2026-04-16-session-handoff.md +0 -0
  32. {squackit-0.8.3 → squackit-0.9.0}/docs/superpowers/plans/2026-04-26-named-fts-collections.md +0 -0
  33. {squackit-0.8.3 → squackit-0.9.0}/docs/superpowers/plans/2026-04-27-kibitzer-fts-doc-retrieval.md +0 -0
  34. {squackit-0.8.3 → squackit-0.9.0}/docs/superpowers/skills/exploring-unfamiliar-code/SKILL.md +0 -0
  35. {squackit-0.8.3 → squackit-0.9.0}/docs/superpowers/skills/exploring-unfamiliar-docs/SKILL.md +0 -0
  36. {squackit-0.8.3 → squackit-0.9.0}/docs/superpowers/skills/pluck-chains/SKILL.md +0 -0
  37. {squackit-0.8.3 → squackit-0.9.0}/docs/superpowers/skills/refactoring-with-squackit/SKILL.md +0 -0
  38. {squackit-0.8.3 → squackit-0.9.0}/docs/superpowers/skills/using-squackit/SKILL.md +0 -0
  39. {squackit-0.8.3 → squackit-0.9.0}/docs/superpowers/specs/2026-04-10-squawkit-design.md +0 -0
  40. {squackit-0.8.3 → squackit-0.9.0}/docs/superpowers/specs/2026-04-12-cli-tool-exposure-design.md +0 -0
  41. {squackit-0.8.3 → squackit-0.9.0}/docs/superpowers/specs/2026-04-12-pluckit-tool-integration-design.md +0 -0
  42. {squackit-0.8.3 → squackit-0.9.0}/docs/superpowers/specs/2026-04-16-training-data-pipeline-design.md +0 -0
  43. {squackit-0.8.3 → squackit-0.9.0}/docs/superpowers/specs/2026-04-26-named-fts-collections-design.md +0 -0
  44. {squackit-0.8.3 → squackit-0.9.0}/docs/tools.md +0 -0
  45. {squackit-0.8.3 → squackit-0.9.0}/mkdocs.yml +0 -0
  46. {squackit-0.8.3 → squackit-0.9.0}/scripts/EXPERIMENT_LOG.md +0 -0
  47. {squackit-0.8.3 → squackit-0.9.0}/scripts/benchmark_models.py +0 -0
  48. {squackit-0.8.3 → squackit-0.9.0}/scripts/cascade_prototype.py +0 -0
  49. {squackit-0.8.3 → squackit-0.9.0}/scripts/combo_eval.py +0 -0
  50. {squackit-0.8.3 → squackit-0.9.0}/scripts/generate_examples.py +0 -0
  51. {squackit-0.8.3 → squackit-0.9.0}/scripts/grid_generate_examples.py +0 -0
  52. {squackit-0.8.3 → squackit-0.9.0}/scripts/hard_task_results/deepseek-coder-v2_16b.json +0 -0
  53. {squackit-0.8.3 → squackit-0.9.0}/scripts/hard_task_results/qwen2.5-coder_0.5b.json +0 -0
  54. {squackit-0.8.3 → squackit-0.9.0}/scripts/hard_task_results/qwen2.5-coder_3b.json +0 -0
  55. {squackit-0.8.3 → squackit-0.9.0}/scripts/hard_task_results/qwen2.5-coder_7b.json +0 -0
  56. {squackit-0.8.3 → squackit-0.9.0}/scripts/hard_task_results/qwen3.5_2b.json +0 -0
  57. {squackit-0.8.3 → squackit-0.9.0}/scripts/hard_task_results/qwen3_8b.json +0 -0
  58. {squackit-0.8.3 → squackit-0.9.0}/scripts/hard_task_results/scoring_input.json +0 -0
  59. {squackit-0.8.3 → squackit-0.9.0}/scripts/hard_task_results/summary.json +0 -0
  60. {squackit-0.8.3 → squackit-0.9.0}/scripts/hard_tasks.py +0 -0
  61. {squackit-0.8.3 → squackit-0.9.0}/scripts/harvest_grid.py +0 -0
  62. {squackit-0.8.3 → squackit-0.9.0}/scripts/iterative_eval.py +0 -0
  63. {squackit-0.8.3 → squackit-0.9.0}/scripts/iterative_results/summary.json +0 -0
  64. {squackit-0.8.3 → squackit-0.9.0}/scripts/iterative_results/summary_deepseek_plain.json +0 -0
  65. {squackit-0.8.3 → squackit-0.9.0}/scripts/iterative_results/summary_distilled_xml.json +0 -0
  66. {squackit-0.8.3 → squackit-0.9.0}/scripts/iterative_results/summary_editor_3b_7b.json +0 -0
  67. {squackit-0.8.3 → squackit-0.9.0}/scripts/iterative_results/summary_fulldocs.json +0 -0
  68. {squackit-0.8.3 → squackit-0.9.0}/scripts/iterative_results/summary_plaintext_3b7b.json +0 -0
  69. {squackit-0.8.3 → squackit-0.9.0}/scripts/model_card_results/codegemma_2b.json +0 -0
  70. {squackit-0.8.3 → squackit-0.9.0}/scripts/model_card_results/codegemma_7b.json +0 -0
  71. {squackit-0.8.3 → squackit-0.9.0}/scripts/model_card_results/deepseek-coder-v2_16b.json +0 -0
  72. {squackit-0.8.3 → squackit-0.9.0}/scripts/model_card_results/deepseek-llm_7b.json +0 -0
  73. {squackit-0.8.3 → squackit-0.9.0}/scripts/model_card_results/gemma_latest.json +0 -0
  74. {squackit-0.8.3 → squackit-0.9.0}/scripts/model_card_results/glm4_9b.json +0 -0
  75. {squackit-0.8.3 → squackit-0.9.0}/scripts/model_card_results/granite-code_3b.json +0 -0
  76. {squackit-0.8.3 → squackit-0.9.0}/scripts/model_card_results/granite3.1-dense_2b.json +0 -0
  77. {squackit-0.8.3 → squackit-0.9.0}/scripts/model_card_results/granite3.3_2b.json +0 -0
  78. {squackit-0.8.3 → squackit-0.9.0}/scripts/model_card_results/llama2-uncensored_7b.json +0 -0
  79. {squackit-0.8.3 → squackit-0.9.0}/scripts/model_card_results/llama3.2-vision_11b.json +0 -0
  80. {squackit-0.8.3 → squackit-0.9.0}/scripts/model_card_results/llama3.2_1b.json +0 -0
  81. {squackit-0.8.3 → squackit-0.9.0}/scripts/model_card_results/llama3.2_latest.json +0 -0
  82. {squackit-0.8.3 → squackit-0.9.0}/scripts/model_card_results/phi3_latest.json +0 -0
  83. {squackit-0.8.3 → squackit-0.9.0}/scripts/model_card_results/phi4-mini_latest.json +0 -0
  84. {squackit-0.8.3 → squackit-0.9.0}/scripts/model_card_results/qwen2.5-coder_0.5b.json +0 -0
  85. {squackit-0.8.3 → squackit-0.9.0}/scripts/model_card_results/qwen2.5-coder_1.5b.json +0 -0
  86. {squackit-0.8.3 → squackit-0.9.0}/scripts/model_card_results/qwen2.5-coder_3b.json +0 -0
  87. {squackit-0.8.3 → squackit-0.9.0}/scripts/model_card_results/qwen2.5-coder_7b.json +0 -0
  88. {squackit-0.8.3 → squackit-0.9.0}/scripts/model_card_results/qwen2.5_1.5b.json +0 -0
  89. {squackit-0.8.3 → squackit-0.9.0}/scripts/model_card_results/qwen2.5_3b.json +0 -0
  90. {squackit-0.8.3 → squackit-0.9.0}/scripts/model_card_results/qwen2.5_7b.json +0 -0
  91. {squackit-0.8.3 → squackit-0.9.0}/scripts/model_card_results/qwen2_latest.json +0 -0
  92. {squackit-0.8.3 → squackit-0.9.0}/scripts/model_card_results/qwen3.5_0.8b.json +0 -0
  93. {squackit-0.8.3 → squackit-0.9.0}/scripts/model_card_results/qwen3.5_2b.json +0 -0
  94. {squackit-0.8.3 → squackit-0.9.0}/scripts/model_card_results/qwen3.5_4b.json +0 -0
  95. {squackit-0.8.3 → squackit-0.9.0}/scripts/model_card_results/qwen3.5_9b.json +0 -0
  96. {squackit-0.8.3 → squackit-0.9.0}/scripts/model_card_results/qwen3_0.6b.json +0 -0
  97. {squackit-0.8.3 → squackit-0.9.0}/scripts/model_card_results/qwen3_8b.json +0 -0
  98. {squackit-0.8.3 → squackit-0.9.0}/scripts/model_card_results/smollm2_latest.json +0 -0
  99. {squackit-0.8.3 → squackit-0.9.0}/scripts/model_card_results/summary.json +0 -0
  100. {squackit-0.8.3 → squackit-0.9.0}/scripts/model_cards.py +0 -0
  101. {squackit-0.8.3 → squackit-0.9.0}/scripts/multiline_results/deepseek-coder-v2_16b.json +0 -0
  102. {squackit-0.8.3 → squackit-0.9.0}/scripts/multiline_results/qwen2.5-coder_3b.json +0 -0
  103. {squackit-0.8.3 → squackit-0.9.0}/scripts/multiline_results/qwen2.5-coder_7b.json +0 -0
  104. {squackit-0.8.3 → squackit-0.9.0}/scripts/multiline_results/qwen3.5_2b.json +0 -0
  105. {squackit-0.8.3 → squackit-0.9.0}/scripts/multiline_results/qwen3_8b.json +0 -0
  106. {squackit-0.8.3 → squackit-0.9.0}/scripts/multiline_results/summary.json +0 -0
  107. {squackit-0.8.3 → squackit-0.9.0}/scripts/multiline_tasks.py +0 -0
  108. {squackit-0.8.3 → squackit-0.9.0}/scripts/qm_retrieval_eval.py +0 -0
  109. {squackit-0.8.3 → squackit-0.9.0}/scripts/quartermaster_eval.py +0 -0
  110. {squackit-0.8.3 → squackit-0.9.0}/scripts/quartermaster_results/retrieval_summary.json +0 -0
  111. {squackit-0.8.3 → squackit-0.9.0}/scripts/quartermaster_results/summary.json +0 -0
  112. {squackit-0.8.3 → squackit-0.9.0}/scripts/selector_eval.py +0 -0
  113. {squackit-0.8.3 → squackit-0.9.0}/scripts/selector_results/summary.json +0 -0
  114. {squackit-0.8.3 → squackit-0.9.0}/squackit/__init__.py +0 -0
  115. {squackit-0.8.3 → squackit-0.9.0}/squackit/__main__.py +0 -0
  116. {squackit-0.8.3 → squackit-0.9.0}/squackit/cli.py +0 -0
  117. {squackit-0.8.3 → squackit-0.9.0}/squackit/data/examples.json +0 -0
  118. {squackit-0.8.3 → squackit-0.9.0}/squackit/db.py +0 -0
  119. {squackit-0.8.3 → squackit-0.9.0}/squackit/defaults.py +0 -0
  120. {squackit-0.8.3 → squackit-0.9.0}/squackit/formatting.py +0 -0
  121. {squackit-0.8.3 → squackit-0.9.0}/squackit/lackpy_integration.py +0 -0
  122. {squackit-0.8.3 → squackit-0.9.0}/squackit/prompts.py +0 -0
  123. {squackit-0.8.3 → squackit-0.9.0}/squackit/runtime.py +0 -0
  124. {squackit-0.8.3 → squackit-0.9.0}/squackit/session.py +0 -0
  125. {squackit-0.8.3 → squackit-0.9.0}/squackit/tool_config.py +0 -0
  126. {squackit-0.8.3 → squackit-0.9.0}/squackit/tools.py +0 -0
  127. {squackit-0.8.3 → squackit-0.9.0}/tests/conftest.py +0 -0
  128. {squackit-0.8.3 → squackit-0.9.0}/tests/fixtures/has_call_filter.py +0 -0
  129. {squackit-0.8.3 → squackit-0.9.0}/tests/test_cli_tools.py +0 -0
  130. {squackit-0.8.3 → squackit-0.9.0}/tests/test_config_consumption.py +0 -0
  131. {squackit-0.8.3 → squackit-0.9.0}/tests/test_defaults.py +0 -0
  132. {squackit-0.8.3 → squackit-0.9.0}/tests/test_formatting_json.py +0 -0
  133. {squackit-0.8.3 → squackit-0.9.0}/tests/test_fts_root.py +0 -0
  134. {squackit-0.8.3 → squackit-0.9.0}/tests/test_prompts.py +0 -0
  135. {squackit-0.8.3 → squackit-0.9.0}/tests/test_resources.py +0 -0
  136. {squackit-0.8.3 → squackit-0.9.0}/tests/test_runtime_config.py +0 -0
  137. {squackit-0.8.3 → squackit-0.9.0}/tests/test_session.py +0 -0
  138. {squackit-0.8.3 → squackit-0.9.0}/tests/test_smoke.py +0 -0
  139. {squackit-0.8.3 → squackit-0.9.0}/tests/test_tool_config.py +0 -0
  140. {squackit-0.8.3 → squackit-0.9.0}/tests/test_tools.py +0 -0
  141. {squackit-0.8.3 → squackit-0.9.0}/tests/test_truncation.py +0 -0
  142. {squackit-0.8.3 → squackit-0.9.0}/tests/test_workflows.py +0 -0
@@ -1,5 +1,79 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.0
4
+
5
+ ### Added — structured output for programmatic callers (#18)
6
+
7
+ Every tool accepts `as_json=true` and returns one envelope:
8
+
9
+ {"rows": [...], "columns": [...], "omitted": <int>}
10
+
11
+ squackit renders for a human reader — markdown tables and newline-joined text.
12
+ That is right for an agent reading a briefing and close to useless to a program
13
+ composing calls. Measured by a session driving four local models through
14
+ lackpy: **0/24 correct while 17/24 called the correct tool**. `len()` on
15
+ `find_names`' newline-joined string gives 67 (characters) where the answer is
16
+ 8; on `find`'s table it gives 2239.
17
+
18
+ The structure was never lost, only discarded at the last step: the executors
19
+ already return DuckDB relations and View objects, and the CLI already had
20
+ `--json`. This exposes that over MCP, on both the macro and pluckit wrappers.
21
+
22
+ Deliberate choices:
23
+
24
+ - **One envelope for every tool**, so a caller learns the shape once.
25
+ - **`omitted` rather than a bare array.** A truncated array is
26
+ indistinguishable from a complete one, and shipping silent truncation on the
27
+ programmatic path would reproduce the defect this mode exists to fix.
28
+ - **The shape is stated in every tool's description.** The cost of the
29
+ envelope is that `len()` of it is 3, not the row count — the same misread
30
+ that produced the 0/24 — so it is named beside the signature rather than left
31
+ to be inferred.
32
+ - **An empty result is `[]`**, not the prose `(no results)`.
33
+ - **The default is unchanged.** Existing callers see exactly what they saw
34
+ before; `as_json` is opt-in, so this is additive rather than a migration.
35
+
36
+ The `compact_columns` projection and `verbose` still apply, so a JSON caller
37
+ sees the same columns a markdown caller would unless it asks for more.
38
+
39
+
40
+ ## 0.8.4
41
+
42
+ A review of 0.8.1–0.8.3 found both fixes had reached the macro-tool layer and
43
+ stopped there. The compound tools have their own parameters and never pass
44
+ through `apply_defaults`, so each gap was reachable through a different door.
45
+
46
+ ### Fixed — the compound tools did not anchor explicit patterns
47
+
48
+ 0.8.1 anchored caller-supplied relative patterns, but only in the macro-tool
49
+ wrapper. `investigate`, `review` and `search` take `file_pattern` themselves and
50
+ handed it to the macro unchanged, so a relative glob still resolved against the
51
+ process working directory. On a server whose cwd is not the corpus — the exact
52
+ deployment 0.8.1 was written for — this reproduced the original failure:
53
+
54
+ investigate(name="parse_config", file_pattern="src/**/*.py")
55
+ -> IOException: Failed to initialize file processing ... Permission
56
+
57
+ Verified against the published 0.8.3 before fixing. The 0.8.1 note that
58
+ "caller-supplied relative patterns are anchored too" was true of the macro path
59
+ only; it is now true of both. Absolute patterns are still passed through
60
+ untouched, and an explicit pattern still works without a defaults object.
61
+
62
+ ### Fixed — briefing sections named no cause
63
+
64
+ `_section` wraps every part of `explore`, `review` and `search`, and
65
+ `investigate`'s Source/Called-by/Calls. It caught everything, logged at DEBUG —
66
+ invisible at normal levels — and rendered a bare `(could not load)`. A
67
+ permission error, a missing git revision and an unloaded macro were
68
+ indistinguishable from each other and from a section that legitimately had
69
+ nothing.
70
+
71
+ That is the same defect 0.8.3 fixed in `investigate`'s primary lookup and did
72
+ not carry to the siblings. Sections now report the exception type and message,
73
+ capped at 200 characters with the truncation marked, and log at WARNING. One
74
+ failing section still cannot take down a briefing.
75
+
76
+
3
77
  ## 0.8.3
4
78
 
5
79
  Three fixes with one theme: a failure must not be representable as an ordinary
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: squackit
3
- Version: 0.8.3
3
+ Version: 0.9.0
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.3"
3
+ version = "0.9.0"
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"
@@ -21,6 +21,7 @@ Usage::
21
21
  from __future__ import annotations
22
22
 
23
23
  import inspect
24
+ import json as _json
24
25
  import threading
25
26
  from collections import OrderedDict
26
27
  from pathlib import Path
@@ -246,6 +247,8 @@ def _register_executor_tool(mcp, presentation: ToolPresentation):
246
247
  elif v is not None:
247
248
  verbose = bool(v)
248
249
 
250
+ as_json = _as_json_requested(kwargs)
251
+
249
252
  filtered = {k: v for k, v in kwargs.items() if v is not None}
250
253
 
251
254
  try:
@@ -259,6 +262,16 @@ def _register_executor_tool(mcp, presentation: ToolPresentation):
259
262
  # View -> markdown (truncate by blocks if too many)
260
263
  if hasattr(result, 'markdown') and hasattr(result, 'blocks'):
261
264
  blocks = result.blocks
265
+ if as_json:
266
+ tabular = getattr(result, "tabular", None)
267
+ if tabular is not None:
268
+ vcols, vrows = tabular
269
+ kept, omitted = _limit_rows(list(vrows), max_limit)
270
+ return _json_envelope(
271
+ [dict(zip(vcols, r)) for r in kept], vcols, omitted)
272
+ kept, omitted = _limit_rows(list(blocks), max_limit)
273
+ return _json_envelope(
274
+ [b.markdown for b in kept if b.markdown], (), omitted)
262
275
  if max_limit > 0 and len(blocks) > max_limit:
263
276
  kept = blocks[:max_limit]
264
277
  omitted = len(blocks) - max_limit
@@ -269,6 +282,13 @@ def _register_executor_tool(mcp, presentation: ToolPresentation):
269
282
  if hasattr(result, 'columns') and hasattr(result, 'fetchall'):
270
283
  cols = result.columns
271
284
  rows = result.fetchall()
285
+ if as_json:
286
+ # Projection still applies, so a JSON caller gets the same
287
+ # columns a markdown caller would unless it asks for verbose.
288
+ jcols, jrows = _project(cols, rows, presentation, verbose)
289
+ kept, omitted = _limit_rows(jrows, max_limit)
290
+ return _json_envelope(
291
+ [dict(zip(jcols, r)) for r in kept], jcols, omitted)
272
292
  if not rows:
273
293
  return "(no results)"
274
294
  omission = None
@@ -312,6 +332,9 @@ def _register_executor_tool(mcp, presentation: ToolPresentation):
312
332
  return text
313
333
  # list -> joined (truncate)
314
334
  if isinstance(result, list):
335
+ if as_json:
336
+ kept, omitted = _limit_rows(list(result), max_limit)
337
+ return _json_envelope(kept, (), omitted)
315
338
  if not result:
316
339
  return "(no results)"
317
340
  if max_limit > 0 and len(result) > max_limit:
@@ -324,7 +347,7 @@ def _register_executor_tool(mcp, presentation: ToolPresentation):
324
347
 
325
348
  tool_fn.__name__ = tool_name
326
349
  tool_fn.__qualname__ = tool_name
327
- tool_fn.__doc__ = description
350
+ tool_fn.__doc__ = description + _AS_JSON_NOTE
328
351
 
329
352
  required_set = set(presentation.required)
330
353
  annotations = {}
@@ -354,6 +377,13 @@ def _register_executor_tool(mcp, presentation: ToolPresentation):
354
377
  "verbose", inspect.Parameter.KEYWORD_ONLY,
355
378
  default=None, annotation=Optional[bool],
356
379
  ))
380
+ # Every one of these tools returns data, so the structured mode is offered
381
+ # unconditionally rather than per-tool.
382
+ annotations["as_json"] = Optional[bool]
383
+ sig_params.append(inspect.Parameter(
384
+ "as_json", inspect.Parameter.KEYWORD_ONLY,
385
+ default=None, annotation=Optional[bool],
386
+ ))
357
387
  tool_fn.__annotations__ = {**annotations, "return": str}
358
388
  tool_fn.__signature__ = inspect.Signature(
359
389
  sig_params, return_annotation=str,
@@ -448,6 +478,76 @@ def _fts_con_for_root(mcp, root: str):
448
478
  return con
449
479
 
450
480
 
481
+ #: Appended to every tool's description. A caller that has to GUESS the return
482
+ #: shape guesses wrong: measured at 0/24 correct while calling the right tool
483
+ #: 17/24 times, because `len()` on a rendered table counts characters. Naming
484
+ #: the shape beside the signature is the cheapest fix for that, and it is the
485
+ #: reason the envelope is a stable object rather than a bare array.
486
+ _AS_JSON_NOTE = (
487
+ "\n\nPass as_json=true for structured output: "
488
+ '{"rows": [...], "columns": [...], "omitted": <int>}. '
489
+ "`omitted` is how many rows the limit dropped — it is 0 when the result is "
490
+ "complete, so a truncated result can never be mistaken for a whole one."
491
+ )
492
+
493
+
494
+ def _limit_rows(rows, max_limit):
495
+ """Apply the presentation limit, returning (kept, omitted_count)."""
496
+ if max_limit and max_limit > 0 and len(rows) > max_limit:
497
+ return rows[:max_limit], len(rows) - max_limit
498
+ return rows, 0
499
+
500
+
501
+ def _project(cols, rows, presentation, verbose):
502
+ """Apply the compact_columns projection, if the tool declares one.
503
+
504
+ Shared so JSON and markdown callers see the same columns; a caller opts
505
+ out of both with verbose=True.
506
+ """
507
+ if not presentation.compact_columns or verbose:
508
+ return list(cols), list(rows)
509
+ keep_idx = [i for i, name in enumerate(cols)
510
+ if name in presentation.compact_columns]
511
+ order = {name: pos for pos, name in enumerate(presentation.compact_columns)}
512
+ keep_idx.sort(key=lambda i: order.get(cols[i], len(order)))
513
+ if not keep_idx:
514
+ return list(cols), list(rows)
515
+ return ([cols[i] for i in keep_idx],
516
+ [tuple(row[i] for i in keep_idx) for row in rows])
517
+
518
+
519
+ def _json_envelope(rows, columns=(), omitted: int = 0) -> str:
520
+ """The one shape every tool returns under `as_json=True`.
521
+
522
+ {"rows": [...], "columns": [...], "omitted": <int>}
523
+
524
+ One envelope for every tool, so a caller learns the shape once instead of
525
+ per tool. `omitted` exists so truncation stays REPRESENTABLE: a bare array
526
+ that had been cut is indistinguishable from a complete one, and shipping
527
+ silent truncation on the programmatic path would reproduce the exact defect
528
+ this output mode was added to fix.
529
+
530
+ The trade is that `len()` of the envelope is 3, not the row count — which
531
+ is the misread that motivated this ("0/24 correct while 17/24 called the
532
+ right tool"). That is why the shape is stated in the tool description
533
+ rather than left to be guessed.
534
+ """
535
+ return _json.dumps(
536
+ {"rows": list(rows), "columns": list(columns), "omitted": int(omitted)},
537
+ indent=2, default=str,
538
+ )
539
+
540
+
541
+ def _as_json_requested(kwargs) -> bool:
542
+ """Pop `as_json`, accepting the string forms MCP clients send."""
543
+ if "as_json" not in kwargs:
544
+ return False
545
+ v = kwargs.pop("as_json")
546
+ if isinstance(v, str):
547
+ return v.lower() in ("true", "1", "yes")
548
+ return bool(v) if v is not None else False
549
+
550
+
451
551
  def _register_tool(
452
552
  mcp, # FastMCP type annotation removed to avoid import at module level
453
553
  con,
@@ -510,6 +610,10 @@ def _register_tool(
510
610
  max_rows = 0
511
611
 
512
612
  # Remove None values; coerce known numeric params to int.
613
+ # Pop before building macro args: `as_json` is a presentation choice,
614
+ # not a macro parameter, and passing it through raises a binder error.
615
+ as_json = _as_json_requested(kwargs)
616
+
513
617
  # Only numeric_params are coerced — blanket isdigit() would
514
618
  # break git SHAs like "1234567".
515
619
  filtered = {}
@@ -587,10 +691,18 @@ def _register_tool(
587
691
  elapsed = (_time.time() - t0) * 1000
588
692
  access_log.record(tool_name, cache_args, 0,
589
693
  cached=False, elapsed_ms=elapsed)
590
- return _empty_msg()
694
+ # A program needs an empty list it can iterate, not a sentence.
695
+ return _json_envelope([], cols, 0) if as_json else _empty_msg()
591
696
 
592
697
  total_rows = len(rows)
593
698
 
699
+ if as_json:
700
+ # Structured callers get every row the limit allows plus an
701
+ # explicit count of what was dropped — never a quietly short list.
702
+ kept, omitted = _limit_rows(list(rows), max_rows if limit_param else 0)
703
+ return _json_envelope([dict(zip(cols, r)) for r in kept],
704
+ cols, omitted)
705
+
594
706
  # Apply truncation
595
707
  omission = None
596
708
  if limit_param and max_rows > 0:
@@ -669,7 +781,7 @@ def _register_tool(
669
781
  "DIFFERENT repository, pass `root=<repository directory>` (an absolute path) — the "
670
782
  "repo is indexed on first use and cached."
671
783
  )
672
- tool_fn.__doc__ = description
784
+ tool_fn.__doc__ = description + _AS_JSON_NOTE
673
785
 
674
786
  # Build parameter annotations for FastMCP schema generation.
675
787
  # All params get default=None so FastMCP validation passes even when
@@ -697,6 +809,13 @@ def _register_tool(
697
809
  "root", inspect.Parameter.KEYWORD_ONLY, default=None,
698
810
  annotation=Optional[str],
699
811
  ))
812
+ # Every one of these tools returns data, so the structured mode is offered
813
+ # unconditionally rather than per-tool.
814
+ annotations["as_json"] = Optional[bool]
815
+ sig_params.append(inspect.Parameter(
816
+ "as_json", inspect.Parameter.KEYWORD_ONLY,
817
+ default=None, annotation=Optional[bool],
818
+ ))
700
819
  tool_fn.__annotations__ = {**annotations, "return": str}
701
820
 
702
821
  tool_fn.__signature__ = inspect.Signature(
@@ -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.
@@ -288,7 +335,7 @@ def review(con, defaults, from_rev=None, to_rev=None, file_pattern=None):
288
335
  """Code review prep for a revision range."""
289
336
  from_rev = from_rev or defaults.from_rev
290
337
  to_rev = to_rev or defaults.to_rev
291
- file_pattern = file_pattern or defaults.code_glob
338
+ file_pattern = _anchored(defaults, file_pattern) or defaults.code_glob
292
339
 
293
340
  sections = []
294
341
 
@@ -368,7 +415,7 @@ def review(con, defaults, from_rev=None, to_rev=None, file_pattern=None):
368
415
 
369
416
  def search(con, defaults, query, file_pattern=None):
370
417
  """Multi-source search across code, docs, and git."""
371
- file_pattern = file_pattern or defaults.code_glob
418
+ file_pattern = _anchored(defaults, file_pattern) or defaults.code_glob
372
419
 
373
420
  sections = []
374
421
 
@@ -196,3 +196,58 @@ class TestMutationNamedMacrosSayTheyDoNotMutate:
196
196
  d = ToolPresentation(info=_Info("ast_replace", ["source"]),
197
197
  description_override="Overridden.")
198
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 |")
@@ -156,6 +156,19 @@ def repo(tmp_path_factory):
156
156
  subprocess.run(["git", "add", "-A"], cwd=root, check=True)
157
157
  subprocess.run(["git", "-c", "user.email=t@e", "-c", "user.name=t",
158
158
  "commit", "-qm", "initial"], cwd=root, check=True)
159
+ # A SECOND commit, so HEAD~1 resolves. `review` defaults to HEAD~1..HEAD,
160
+ # and on a one-commit repo its git sections fail for that reason alone —
161
+ # which renders as "(could not load)", exactly like the sandbox failure
162
+ # this file is about. Without this the review test passes or fails for the
163
+ # wrong reason.
164
+ (root / "src" / "mod.py").write_text(
165
+ "def parse_widget_config(path):\n"
166
+ " '''Load the widget config.'''\n"
167
+ " return {'version': 1}\n"
168
+ )
169
+ subprocess.run(["git", "add", "-A"], cwd=root, check=True)
170
+ subprocess.run(["git", "-c", "user.email=t@e", "-c", "user.name=t",
171
+ "commit", "-qm", "second"], cwd=root, check=True)
159
172
  return root
160
173
 
161
174
 
@@ -221,3 +234,85 @@ class TestServedFromForeignCwd:
221
234
  mcp = create_server(root=str(repo), init=False)
222
235
  result = _run_async(mcp.call_tool("doc_outline", {}))
223
236
  assert "Widget Guide" in result.content[0].text or "guide.md" in result.content[0].text
237
+
238
+
239
+ # ── Workflow tools anchor explicit patterns too (0.8.4) ─────────────
240
+
241
+
242
+ @requires_fastmcp
243
+ class TestWorkflowToolsAnchorExplicitPatterns:
244
+ """The compound tools never passed through `apply_defaults`.
245
+
246
+ 0.8.1 anchored caller-supplied relative patterns — but only on the macro
247
+ tool wrapper in server.py. `investigate`, `review` and `search` take
248
+ `file_pattern` as their own parameter and handed it to the macro unchanged,
249
+ so an explicit relative glob still resolved against the process cwd and,
250
+ under fledgling's sandbox, failed outright:
251
+
252
+ investigate(name="parse_config", file_pattern="src/**/*.py")
253
+ -> IOException: Failed to initialize file processing ... Permission
254
+
255
+ Same deployment shape 0.8.1 was written for, reached through a different
256
+ door. The CHANGELOG claim "caller-supplied relative patterns are anchored
257
+ too" was true of the macro path only.
258
+ """
259
+
260
+ def _server(self, repo):
261
+ from squackit.server import create_server
262
+ return create_server(root=str(repo), init=False)
263
+
264
+ def test_investigate_with_an_explicit_relative_pattern(self, repo, outside_cwd):
265
+ mcp = self._server(repo)
266
+ text = _run_async(mcp.call_tool(
267
+ "investigate", {"name": "parse_widget_config",
268
+ "file_pattern": "src/**/*.py"})).content[0].text
269
+ assert "Could not look up" not in text, (
270
+ f"explicit relative pattern was not anchored:\n{text[:300]}")
271
+ assert "parse_widget_config" in text
272
+
273
+ def test_search_with_an_explicit_relative_pattern(self, repo, outside_cwd):
274
+ mcp = self._server(repo)
275
+ text = _run_async(mcp.call_tool(
276
+ "search", {"query": "parse", "file_pattern": "src/**/*.py"})).content[0].text
277
+ assert "could not load" not in text.lower(), (
278
+ f"search section failed with an explicit relative pattern:\n{text[:400]}")
279
+
280
+ def test_review_with_an_explicit_relative_pattern(self, repo, outside_cwd):
281
+ mcp = self._server(repo)
282
+ text = _run_async(mcp.call_tool(
283
+ "review", {"file_pattern": "src/**/*.py"})).content[0].text
284
+ assert "could not load" not in text.lower(), (
285
+ f"review section failed with an explicit relative pattern:\n{text[:400]}")
286
+
287
+ def test_an_explicit_absolute_pattern_is_still_honoured(self, repo, outside_cwd):
288
+ """Anchoring must not re-root a path the caller gave in full."""
289
+ mcp = self._server(repo)
290
+ text = _run_async(mcp.call_tool(
291
+ "investigate", {"name": "parse_widget_config",
292
+ "file_pattern": f"{repo}/src/**/*.py"})).content[0].text
293
+ assert "parse_widget_config" in text
294
+
295
+
296
+ class TestAnchoringToleratesMissingDefaults:
297
+ """An explicit pattern needs no defaults object.
298
+
299
+ Regression: the 0.8.4 anchoring helper dereferenced `defaults` whenever a
300
+ pattern was supplied, which broke callers that pass `defaults=None` to
301
+ drive a workflow against a fake connection. The old code never touched
302
+ defaults on that path — the caller had already said where to look.
303
+ """
304
+
305
+ def test_explicit_pattern_survives_a_none_defaults(self):
306
+ from squackit.workflows import _anchored
307
+
308
+ assert _anchored(None, "src/**/*.py") == "src/**/*.py"
309
+
310
+ def test_none_pattern_with_none_defaults(self):
311
+ from squackit.workflows import _anchored
312
+
313
+ assert _anchored(None, None) is None
314
+
315
+ def test_a_rootless_defaults_leaves_the_pattern_alone(self):
316
+ from squackit.workflows import _anchored
317
+
318
+ assert _anchored(ProjectDefaults(), "src/**/*.py") == "src/**/*.py"