@blxzer/cursor-trellis 0.2.9 → 0.3.2

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 (234) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +85 -19
  3. package/README.zh-CN.md +60 -21
  4. package/bin/smart-search.js +1 -1
  5. package/dist/cli/index.d.ts.map +1 -1
  6. package/dist/cli/index.js +46 -18
  7. package/dist/cli/index.js.map +1 -1
  8. package/dist/commands/capability-smoke.d.ts +24 -0
  9. package/dist/commands/capability-smoke.d.ts.map +1 -0
  10. package/dist/commands/capability-smoke.js +83 -0
  11. package/dist/commands/capability-smoke.js.map +1 -0
  12. package/dist/commands/channel/agent-loader.d.ts +1 -1
  13. package/dist/commands/channel/agent-loader.js +4 -4
  14. package/dist/commands/channel/agent-loader.js.map +1 -1
  15. package/dist/commands/channel/guard.d.ts +2 -2
  16. package/dist/commands/channel/guard.js +3 -3
  17. package/dist/commands/channel/guard.js.map +1 -1
  18. package/dist/commands/channel/index.js +3 -3
  19. package/dist/commands/channel/index.js.map +1 -1
  20. package/dist/commands/channel/list.d.ts +1 -1
  21. package/dist/commands/channel/list.js +1 -1
  22. package/dist/commands/channel/rm.d.ts +1 -1
  23. package/dist/commands/channel/rm.js +1 -1
  24. package/dist/commands/channel/store/lock.d.ts +1 -1
  25. package/dist/commands/channel/store/lock.js +1 -1
  26. package/dist/commands/channel/store/paths.js +1 -1
  27. package/dist/commands/channel/store/paths.js.map +1 -1
  28. package/dist/commands/init.d.ts.map +1 -1
  29. package/dist/commands/init.js +197 -91
  30. package/dist/commands/init.js.map +1 -1
  31. package/dist/commands/uninstall.d.ts +4 -4
  32. package/dist/commands/uninstall.js +12 -12
  33. package/dist/commands/uninstall.js.map +1 -1
  34. package/dist/commands/update.d.ts +7 -2
  35. package/dist/commands/update.d.ts.map +1 -1
  36. package/dist/commands/update.js +161 -61
  37. package/dist/commands/update.js.map +1 -1
  38. package/dist/commands/upgrade.js +2 -2
  39. package/dist/commands/upgrade.js.map +1 -1
  40. package/dist/commands/workflow.d.ts +4 -4
  41. package/dist/commands/workflow.d.ts.map +1 -1
  42. package/dist/commands/workflow.js +10 -9
  43. package/dist/commands/workflow.js.map +1 -1
  44. package/dist/configurators/cursor.d.ts +2 -2
  45. package/dist/configurators/cursor.js +4 -4
  46. package/dist/configurators/cursor.js.map +1 -1
  47. package/dist/configurators/index.d.ts.map +1 -1
  48. package/dist/configurators/index.js +4 -4
  49. package/dist/configurators/index.js.map +1 -1
  50. package/dist/configurators/shared.d.ts +10 -10
  51. package/dist/configurators/shared.d.ts.map +1 -1
  52. package/dist/configurators/shared.js +32 -32
  53. package/dist/configurators/shared.js.map +1 -1
  54. package/dist/configurators/workflow.d.ts +6 -6
  55. package/dist/configurators/workflow.js +1 -1
  56. package/dist/constants/paths.d.ts +24 -24
  57. package/dist/constants/paths.d.ts.map +1 -1
  58. package/dist/constants/paths.js +16 -16
  59. package/dist/constants/paths.js.map +1 -1
  60. package/dist/migrations/manifests/0.2.10.json +374 -0
  61. package/dist/migrations/manifests/0.3.0.json +89 -0
  62. package/dist/migrations/manifests/0.3.1.json +32 -0
  63. package/dist/templates/common/bundled-skills/{trellis-cursor2plus-setup → cstl-cursor2plus-setup}/SKILL.md +15 -14
  64. package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/SKILL.md +75 -75
  65. package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/customize-local/add-project-local-conventions.md +19 -19
  66. package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/customize-local/change-agents.md +8 -8
  67. package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/customize-local/change-context-loading.md +26 -26
  68. package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/customize-local/change-hooks.md +9 -9
  69. package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/customize-local/change-skills-or-commands.md +8 -8
  70. package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/customize-local/change-spec-structure.md +17 -17
  71. package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/customize-local/change-task-lifecycle.md +46 -46
  72. package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/customize-local/change-workflow.md +18 -18
  73. package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/customize-local/overview.md +55 -55
  74. package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/local-architecture/context-injection.md +20 -20
  75. package/dist/templates/common/bundled-skills/cstl-meta/references/local-architecture/generated-files.md +83 -0
  76. package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/local-architecture/overview.md +15 -15
  77. package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/local-architecture/spec-system.md +12 -12
  78. package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/local-architecture/task-system.md +34 -34
  79. package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/local-architecture/workflow.md +8 -8
  80. package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/local-architecture/workspace-memory.md +19 -19
  81. package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/platform-files/agents.md +12 -12
  82. package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/platform-files/hooks-and-settings.md +7 -7
  83. package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/platform-files/overview.md +9 -9
  84. package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/platform-files/platform-map.md +7 -7
  85. package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/platform-files/skills-and-commands.md +19 -19
  86. package/dist/templates/common/bundled-skills/{trellis-micro-grill → cstl-micro-grill}/SKILL.md +62 -47
  87. package/dist/templates/common/bundled-skills/{trellis-skill-creator → cstl-skill-creator}/SKILL.md +44 -44
  88. package/dist/templates/common/bundled-skills/{trellis-skill-creator → cstl-skill-creator}/references/authoring-rules.md +97 -97
  89. package/dist/templates/common/bundled-skills/{trellis-skill-creator/references/trellis-skill-locations.md → cstl-skill-creator/references/cstl-skill-locations.md} +52 -52
  90. package/dist/templates/common/bundled-skills/{trellis-skill-creator → cstl-skill-creator}/references/review-checklist.md +47 -47
  91. package/dist/templates/common/bundled-skills/{trellis-spec-bootstrap → cstl-spec-bootstrap}/SKILL.md +6 -6
  92. package/dist/templates/common/bundled-skills/{trellis-spec-bootstrap → cstl-spec-bootstrap}/references/repository-analysis.md +1 -1
  93. package/dist/templates/common/bundled-skills/{trellis-spec-bootstrap → cstl-spec-bootstrap}/references/spec-task-planning.md +3 -3
  94. package/dist/templates/common/bundled-skills/{trellis-spec-bootstrap → cstl-spec-bootstrap}/references/spec-writing.md +1 -1
  95. package/dist/templates/common/commands/continue.md +24 -24
  96. package/dist/templates/common/commands/finish-work.md +5 -5
  97. package/dist/templates/common/commands/start.md +20 -20
  98. package/dist/templates/common/index.d.ts +1 -1
  99. package/dist/templates/common/index.d.ts.map +1 -1
  100. package/dist/templates/common/skills/before-dev.md +4 -4
  101. package/dist/templates/common/skills/brainstorm.md +188 -164
  102. package/dist/templates/common/skills/break-loop.md +3 -3
  103. package/dist/templates/common/skills/check.md +5 -5
  104. package/dist/templates/common/skills/update-spec.md +4 -4
  105. package/dist/templates/cursor/agents/{trellis-check.md → cstl-check.md} +14 -14
  106. package/dist/templates/cursor/agents/{trellis-implement.md → cstl-implement.md} +14 -14
  107. package/dist/templates/cursor/agents/{trellis-research.md → cstl-research.md} +11 -11
  108. package/dist/templates/cursor/commands/cursor2plus-setup.md +15 -15
  109. package/dist/templates/cursor/fixtures/expected-rules.d.ts +1 -1
  110. package/dist/templates/cursor/fixtures/expected-rules.d.ts.map +1 -1
  111. package/dist/templates/cursor/fixtures/expected-rules.js +2 -2
  112. package/dist/templates/cursor/fixtures/expected-rules.js.map +1 -1
  113. package/dist/templates/cursor/rules/{trellis-subagent-dispatch.mdc → cstl-subagent-dispatch.mdc} +4 -4
  114. package/dist/templates/cursor/rules/{trellis-triage.mdc → cstl-triage.mdc} +3 -3
  115. package/dist/templates/cursor/rules/retrieval-routing.mdc +4 -4
  116. package/dist/templates/extract.d.ts +1 -1
  117. package/dist/templates/extract.js +1 -1
  118. package/dist/templates/markdown/agents.md +13 -13
  119. package/dist/templates/markdown/prompts/run-semantic-slice-12.md.txt +1 -1
  120. package/dist/templates/markdown/spec/guides/cross-platform-thinking-guide.md.txt +634 -634
  121. package/dist/templates/markdown/spec/guides/cursor-context-injection-guide.md.txt +5 -5
  122. package/dist/templates/markdown/spec/guides/cursor-semantic-compliance.md.txt +7 -1
  123. package/dist/templates/markdown/spec/guides/cursor-subagent-policy.md.txt +393 -391
  124. package/dist/templates/markdown/spec/guides/durable-learning-decision-guide.md.txt +14 -14
  125. package/dist/templates/markdown/spec/guides/execution-strategy.md.txt +5 -5
  126. package/dist/templates/markdown/spec/guides/index.md.txt +3 -2
  127. package/dist/templates/markdown/spec/guides/retrieval-daily-guide.md.txt +10 -10
  128. package/dist/templates/markdown/workspace-index.md +3 -3
  129. package/dist/templates/markdown/worktree.yaml.txt +1 -1
  130. package/dist/templates/shared-hooks/index.d.ts +2 -2
  131. package/dist/templates/shared-hooks/index.js +2 -2
  132. package/dist/templates/shared-hooks/inject-retrieval-plan.py +3 -3
  133. package/dist/templates/shared-hooks/inject-shell-session-context.py +7 -7
  134. package/dist/templates/shared-hooks/inject-subagent-context.py +4 -4
  135. package/dist/templates/shared-hooks/inject-workflow-state.py +37 -37
  136. package/dist/templates/shared-hooks/research-end-retrieval-pack.py +2 -2
  137. package/dist/templates/shared-hooks/session-start.py +16 -16
  138. package/dist/templates/trellis/config/execution-strategy-rules.json +1 -1
  139. package/dist/templates/trellis/config.yaml +5 -25
  140. package/dist/templates/trellis/index.d.ts +2 -2
  141. package/dist/templates/trellis/index.js +2 -2
  142. package/dist/templates/trellis/local/README.md +15 -13
  143. package/dist/templates/trellis/local/patch_wpelc8.py +7 -7
  144. package/dist/templates/trellis/local/subagent-models.json.example +1 -1
  145. package/dist/templates/trellis/local/trellis-task-models.json.example +3 -3
  146. package/dist/templates/trellis/local/trellis-task-models.json5.example +5 -5
  147. package/dist/templates/trellis/local/trellis_task_models_config.py +6 -6
  148. package/dist/templates/trellis/scripts/add_session.py +20 -20
  149. package/dist/templates/trellis/scripts/common/active_task.py +2 -2
  150. package/dist/templates/trellis/scripts/common/artifact_search.py +587 -587
  151. package/dist/templates/trellis/scripts/common/cli_adapter.py +3 -3
  152. package/dist/templates/trellis/scripts/common/codebase_retrieval_router.py +6 -6
  153. package/dist/templates/trellis/scripts/common/config.py +3 -3
  154. package/dist/templates/trellis/scripts/common/developer.py +2 -2
  155. package/dist/templates/trellis/scripts/common/execution_strategy.py +1 -1
  156. package/dist/templates/trellis/scripts/common/packages_context.py +23 -23
  157. package/dist/templates/trellis/scripts/common/parent_orchestration.py +702 -702
  158. package/dist/templates/trellis/scripts/common/paths.py +107 -107
  159. package/dist/templates/trellis/scripts/common/project_file_stats.py +1 -1
  160. package/dist/templates/trellis/scripts/common/retrieval_agent_instructions.py +1 -1
  161. package/dist/templates/trellis/scripts/common/safe_commit.py +31 -31
  162. package/dist/templates/trellis/scripts/common/session_context.py +1159 -1159
  163. package/dist/templates/trellis/scripts/common/session_memory.py +360 -360
  164. package/dist/templates/trellis/scripts/common/smart_search_evidence.py +514 -514
  165. package/dist/templates/trellis/scripts/common/smart_search_resolve.py +1 -1
  166. package/dist/templates/trellis/scripts/common/subagent_dispatch.py +7 -7
  167. package/dist/templates/trellis/scripts/common/task_context.py +1 -1
  168. package/dist/templates/trellis/scripts/common/task_dashboard.py +140 -140
  169. package/dist/templates/trellis/scripts/common/task_gates.py +2056 -2034
  170. package/dist/templates/trellis/scripts/common/task_store.py +1594 -1594
  171. package/dist/templates/trellis/scripts/common/task_utils.py +3 -3
  172. package/dist/templates/trellis/scripts/common/tasks.py +1 -1
  173. package/dist/templates/trellis/scripts/common/trellis_config.py +3 -3
  174. package/dist/templates/trellis/scripts/common/workflow_phase.py +1 -1
  175. package/dist/templates/trellis/scripts/cursor_retrieval_probe.py +3 -3
  176. package/dist/templates/trellis/scripts/cursor_retrieval_probe_prompt.md +11 -11
  177. package/dist/templates/trellis/scripts/hooks/linear_sync.py +8 -8
  178. package/dist/templates/trellis/scripts/init_developer.py +2 -2
  179. package/dist/templates/trellis/scripts/retrieval_probe_matrix_template.json +4 -4
  180. package/dist/templates/trellis/scripts/route_codebase_retrieval.py +1 -1
  181. package/dist/templates/trellis/scripts/task.py +918 -918
  182. package/dist/templates/trellis/tasks/templates/release-readiness/implement.md +2 -2
  183. package/dist/templates/trellis/workflow.md +805 -805
  184. package/dist/types/ai-tools.d.ts +1 -1
  185. package/dist/types/ai-tools.d.ts.map +1 -1
  186. package/dist/types/ai-tools.js +1 -1
  187. package/dist/types/ai-tools.js.map +1 -1
  188. package/dist/types/migration.d.ts +1 -1
  189. package/dist/types/migration.d.ts.map +1 -1
  190. package/dist/utils/agents-md.d.ts +37 -0
  191. package/dist/utils/agents-md.d.ts.map +1 -0
  192. package/dist/utils/agents-md.js +82 -0
  193. package/dist/utils/agents-md.js.map +1 -0
  194. package/dist/utils/codebase-retrieval-router.js +5 -5
  195. package/dist/utils/codebase-retrieval-router.js.map +1 -1
  196. package/dist/utils/manifest-prune.d.ts +2 -2
  197. package/dist/utils/manifest-prune.js +4 -4
  198. package/dist/utils/manifest-prune.js.map +1 -1
  199. package/dist/utils/mirror-check.d.ts +3 -1
  200. package/dist/utils/mirror-check.d.ts.map +1 -1
  201. package/dist/utils/mirror-check.js +22 -11
  202. package/dist/utils/mirror-check.js.map +1 -1
  203. package/dist/utils/project-capabilities.d.ts +14 -3
  204. package/dist/utils/project-capabilities.d.ts.map +1 -1
  205. package/dist/utils/project-capabilities.js +127 -17
  206. package/dist/utils/project-capabilities.js.map +1 -1
  207. package/dist/utils/readiness.d.ts +10 -0
  208. package/dist/utils/readiness.d.ts.map +1 -1
  209. package/dist/utils/readiness.js +6 -3
  210. package/dist/utils/readiness.js.map +1 -1
  211. package/dist/utils/retrieval-agent-instructions.js +1 -1
  212. package/dist/utils/retrieval-agent-instructions.js.map +1 -1
  213. package/dist/utils/retrieval-execution-telemetry.js +1 -1
  214. package/dist/utils/retrieval-execution-telemetry.js.map +1 -1
  215. package/dist/utils/template-fetcher.js +1 -1
  216. package/dist/utils/template-fetcher.js.map +1 -1
  217. package/dist/utils/template-hash.d.ts +2 -2
  218. package/dist/utils/template-hash.d.ts.map +1 -1
  219. package/dist/utils/template-hash.js +12 -10
  220. package/dist/utils/template-hash.js.map +1 -1
  221. package/dist/utils/workflow-dir.d.ts +13 -0
  222. package/dist/utils/workflow-dir.d.ts.map +1 -0
  223. package/dist/utils/workflow-dir.js +35 -0
  224. package/dist/utils/workflow-dir.js.map +1 -0
  225. package/dist/utils/workflow-ownership.d.ts +36 -0
  226. package/dist/utils/workflow-ownership.d.ts.map +1 -0
  227. package/dist/utils/workflow-ownership.js +137 -0
  228. package/dist/utils/workflow-ownership.js.map +1 -0
  229. package/dist/utils/workflow-resolver.d.ts +1 -1
  230. package/dist/utils/workflow-resolver.js +1 -1
  231. package/package.json +3 -4
  232. package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/generated-files.md +0 -83
  233. /package/bin/{trellis.js → cstl.js} +0 -0
  234. /package/dist/templates/common/bundled-skills/{trellis-spec-bootstrap → cstl-spec-bootstrap}/references/mcp-setup.md +0 -0
@@ -1,514 +1,514 @@
1
- #!/usr/bin/env python3
2
- """
3
- Capture Smart Search evidence into Trellis task artifacts.
4
-
5
- This module shells out to the local `smart-search` CLI and writes a compact
6
- manifest that downstream Trellis context/ranking code can consume. It stays
7
- dependency-free because it is generated into user projects.
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- import argparse
13
- import json
14
- import os
15
- import re
16
- import subprocess
17
- import sys
18
- from datetime import datetime, timezone
19
- from pathlib import Path
20
- from typing import Any
21
-
22
- from .paths import get_repo_root, get_selected_task_abs, resolve_task_ref
23
- from .smart_search_resolve import default_smart_search_argv, resolve_smart_search_argv
24
-
25
-
26
- INTENT_CHOICES = ("deep-research", "broad-search", "docs", "official-source", "fetch")
27
- STATUS_OK = "ok"
28
- STATUS_DEGRADED = "degraded"
29
- STATUS_FAILED = "failed"
30
- STATUS_NOT_CONFIGURED = "not_configured"
31
- MANIFEST_VERSION = 1
32
- MAX_SUMMARY_CHARS = 500
33
-
34
-
35
- def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
36
- parser = argparse.ArgumentParser(
37
- description="Run smart-search and save a Trellis evidence manifest.",
38
- )
39
- parser.add_argument("query", help="Search query, research question, or URL for --intent fetch.")
40
- parser.add_argument(
41
- "--intent",
42
- choices=INTENT_CHOICES,
43
- default="deep-research",
44
- help="Smart Search route to use.",
45
- )
46
- parser.add_argument(
47
- "--task",
48
- help="Task directory/ref. Defaults to the selected Trellis task when available.",
49
- )
50
- parser.add_argument(
51
- "--run-id",
52
- help="Stable run id for output paths. Defaults to timestamp plus query slug.",
53
- )
54
- parser.add_argument("--root", help="Repository root. Defaults to nearest parent with .trellis/.")
55
- parser.add_argument(
56
- "--smart-search-command",
57
- default=None,
58
- help="Override CLI executable (single path/name). Default: env, config, PATH, repo wrappers.",
59
- )
60
- parser.add_argument(
61
- "--skip-doctor",
62
- action="store_true",
63
- help="Skip smart-search doctor preflight and run the selected command directly.",
64
- )
65
- parser.add_argument("--budget", choices=("quick", "standard", "deep"), default="standard")
66
- parser.add_argument("--fallback", choices=("auto", "off"), default="auto")
67
- parser.add_argument("--validation", choices=("fast", "balanced", "strict"), default="balanced")
68
- parser.add_argument("--extra-sources", type=int, default=1)
69
- parser.add_argument("--timeout", type=int, default=90)
70
- parser.add_argument("--num-results", type=int, default=5)
71
- parser.add_argument(
72
- "--locale-scope",
73
- choices=("cn", "en", "both"),
74
- help="Bilingual discovery scope for --intent deep-research (smart-search research --locale-scope).",
75
- )
76
- parser.add_argument(
77
- "--dry-run",
78
- action="store_true",
79
- help="Preview research plan/routing without live providers (deep-research only).",
80
- )
81
- parser.add_argument(
82
- "--progress",
83
- action="store_true",
84
- help="Emit [research] stage logs to stderr during deep-research execution.",
85
- )
86
- parser.add_argument(
87
- "--include-domain",
88
- action="append",
89
- default=[],
90
- help="Domain filter for --intent official-source. May be repeated.",
91
- )
92
- parser.add_argument("--json", action="store_true", help="Emit manifest JSON.")
93
- return parser.parse_args(argv)
94
-
95
-
96
- def main(argv: list[str] | None = None) -> int:
97
- args = parse_args(argv)
98
- if args.extra_sources < 0:
99
- print("Error: --extra-sources must be >= 0", file=sys.stderr)
100
- return 2
101
- if args.timeout <= 0:
102
- print("Error: --timeout must be > 0", file=sys.stderr)
103
- return 2
104
- if args.num_results <= 0:
105
- print("Error: --num-results must be > 0", file=sys.stderr)
106
- return 2
107
-
108
- repo_root = Path(args.root).resolve() if args.root else get_repo_root()
109
- if args.smart_search_command:
110
- args.smart_search_argv = [args.smart_search_command]
111
- else:
112
- args.smart_search_argv = resolve_smart_search_argv(repo_root) or default_smart_search_argv(
113
- repo_root
114
- )
115
- run_id = args.run_id or default_run_id(args.intent, args.query)
116
- evidence_dir = resolve_evidence_dir(repo_root, args.task, run_id)
117
- evidence_dir.mkdir(parents=True, exist_ok=True)
118
-
119
- command = build_smart_search_command(args, evidence_dir)
120
- doctor_result = None
121
- if not args.skip_doctor:
122
- doctor_result = run_command(
123
- [*args.smart_search_argv, "doctor", "--format", "json"],
124
- repo_root,
125
- )
126
- if doctor_result.not_found:
127
- manifest = build_manifest(
128
- repo_root=repo_root,
129
- query=args.query,
130
- intent=args.intent,
131
- command=command,
132
- evidence_dir=evidence_dir,
133
- output_path=command_output_path(args.intent, evidence_dir),
134
- status=STATUS_NOT_CONFIGURED,
135
- result_data={},
136
- doctor_data={},
137
- error="smart-search CLI could not be resolved (PATH, config, or repo wrapper).",
138
- )
139
- write_manifest(manifest, evidence_dir)
140
- print_manifest(manifest, args.json)
141
- return 4
142
- doctor_data = parse_json_output(doctor_result.stdout)
143
- if doctor_result.returncode != 0 or not doctor_data.get("ok"):
144
- manifest = build_manifest(
145
- repo_root=repo_root,
146
- query=args.query,
147
- intent=args.intent,
148
- command=command,
149
- evidence_dir=evidence_dir,
150
- output_path=command_output_path(args.intent, evidence_dir),
151
- status=STATUS_NOT_CONFIGURED,
152
- result_data={},
153
- doctor_data=doctor_data,
154
- error=doctor_data.get("error") or doctor_result.stderr.strip() or "smart-search doctor failed.",
155
- )
156
- write_manifest(manifest, evidence_dir)
157
- print_manifest(manifest, args.json)
158
- return doctor_result.returncode or 3
159
-
160
- result = run_command(command, repo_root)
161
- output_path = command_output_path(args.intent, evidence_dir)
162
- result_data = read_result_data(output_path, result.stdout)
163
- doctor_data = parse_json_output(doctor_result.stdout) if doctor_result else {}
164
- status = status_from_result(result.returncode, result_data)
165
- error = ""
166
- if result.not_found:
167
- status = STATUS_NOT_CONFIGURED
168
- error = "smart-search CLI could not be resolved (PATH, config, or repo wrapper)."
169
- elif status == STATUS_FAILED:
170
- error = (
171
- string_value(result_data.get("error"))
172
- or result.stderr.strip()
173
- or f"smart-search exited with code {result.returncode}."
174
- )
175
- if "timed out" in error.lower() or "timeout" in error.lower():
176
- error = (
177
- f"{error} Retry with --timeout 120 or --intent docs; "
178
- "or use Cursor WebSearch/WebFetch and persist source: cursor-web-fallback."
179
- )
180
-
181
- manifest = build_manifest(
182
- repo_root=repo_root,
183
- query=args.query,
184
- intent=args.intent,
185
- command=command,
186
- evidence_dir=evidence_dir,
187
- output_path=output_path,
188
- status=status,
189
- result_data=result_data,
190
- doctor_data=doctor_data,
191
- error=error,
192
- dry_run=bool(args.dry_run),
193
- )
194
- write_manifest(manifest, evidence_dir)
195
- print_manifest(manifest, args.json)
196
- return 0 if status in {STATUS_OK, STATUS_DEGRADED} else (result.returncode or 4)
197
-
198
-
199
- class CommandResult:
200
- def __init__(self, returncode: int, stdout: str = "", stderr: str = "", not_found: bool = False):
201
- self.returncode = returncode
202
- self.stdout = stdout
203
- self.stderr = stderr
204
- self.not_found = not_found
205
-
206
-
207
- def run_command(command: list[str], cwd: Path, timeout: int = 120) -> CommandResult:
208
- try:
209
- completed = subprocess.run(
210
- command,
211
- cwd=str(cwd),
212
- text=True,
213
- encoding="utf-8",
214
- errors="replace",
215
- capture_output=True,
216
- check=False,
217
- timeout=timeout,
218
- )
219
- return CommandResult(completed.returncode, completed.stdout or "", completed.stderr or "")
220
- except subprocess.TimeoutExpired:
221
- return CommandResult(124, "", f"Command timed out after {timeout}s")
222
- except FileNotFoundError as error:
223
- return CommandResult(127, "", str(error), not_found=True)
224
-
225
-
226
- def build_smart_search_command(args: argparse.Namespace, evidence_dir: Path) -> list[str]:
227
- output_path = command_output_path(args.intent, evidence_dir)
228
- command = list(args.smart_search_argv)
229
- if args.intent == "deep-research":
230
- built = [
231
- *command,
232
- "research",
233
- args.query,
234
- "--budget",
235
- args.budget,
236
- "--fallback",
237
- args.fallback,
238
- "--format",
239
- "json",
240
- "--output",
241
- str(output_path),
242
- ]
243
- if args.locale_scope:
244
- built.extend(["--locale-scope", args.locale_scope])
245
- if args.dry_run:
246
- built.append("--dry-run")
247
- if args.progress:
248
- built.append("--progress")
249
- return built
250
- if args.intent == "broad-search":
251
- return [
252
- *command,
253
- "search",
254
- args.query,
255
- "--validation",
256
- args.validation,
257
- "--extra-sources",
258
- str(args.extra_sources),
259
- "--timeout",
260
- str(args.timeout),
261
- "--format",
262
- "json",
263
- "--output",
264
- str(output_path),
265
- ]
266
- if args.intent == "docs":
267
- return [
268
- *command,
269
- "context7-library",
270
- args.query,
271
- "--format",
272
- "json",
273
- "--output",
274
- str(output_path),
275
- ]
276
- if args.intent == "official-source":
277
- built = [
278
- *command,
279
- "exa-search",
280
- args.query,
281
- "--num-results",
282
- str(args.num_results),
283
- "--include-text",
284
- "--include-highlights",
285
- "--format",
286
- "json",
287
- "--output",
288
- str(output_path),
289
- ]
290
- for domain in args.include_domain:
291
- built.extend(["--include-domains", domain])
292
- return built
293
- if args.intent == "fetch":
294
- return [
295
- *command,
296
- "fetch",
297
- args.query,
298
- "--format",
299
- "markdown",
300
- "--output",
301
- str(output_path),
302
- ]
303
- raise ValueError(f"Unsupported Smart Search intent: {args.intent}")
304
-
305
-
306
- def command_output_path(intent: str, evidence_dir: Path) -> Path:
307
- suffix = "md" if intent == "fetch" else "json"
308
- name = intent.replace("-", "_")
309
- return evidence_dir / f"{name}.{suffix}"
310
-
311
-
312
- def resolve_evidence_dir(repo_root: Path, task_ref: str | None, run_id: str) -> Path:
313
- task_dir: Path | None = None
314
- if task_ref:
315
- task_dir = resolve_task_ref(task_ref, repo_root)
316
- else:
317
- task_dir = get_selected_task_abs(repo_root)
318
-
319
- if task_dir and task_dir.exists():
320
- return task_dir / "research" / "smart-search" / safe_filename(run_id)
321
- return repo_root / ".trellis" / "workspace" / "smart-search" / safe_filename(run_id)
322
-
323
-
324
- def default_run_id(intent: str, query: str) -> str:
325
- stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
326
- slug = safe_filename(query)[:48] or "query"
327
- return f"{stamp}-{intent}-{slug}"
328
-
329
-
330
- def safe_filename(value: str) -> str:
331
- cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip().lower())
332
- cleaned = cleaned.strip(".-_")
333
- return cleaned or "run"
334
-
335
-
336
- def parse_json_output(content: str) -> dict[str, Any]:
337
- if not content.strip():
338
- return {}
339
- try:
340
- parsed = json.loads(content)
341
- except json.JSONDecodeError:
342
- return {}
343
- return parsed if isinstance(parsed, dict) else {}
344
-
345
-
346
- def read_result_data(output_path: Path, stdout: str) -> dict[str, Any]:
347
- if output_path.is_file() and output_path.suffix.lower() == ".json":
348
- return parse_json_output(output_path.read_text(encoding="utf-8"))
349
- if output_path.is_file() and output_path.suffix.lower() != ".json":
350
- return {"ok": True, "content": output_path.read_text(encoding="utf-8")}
351
- return parse_json_output(stdout)
352
-
353
-
354
- def status_from_result(returncode: int, result_data: dict[str, Any]) -> str:
355
- if returncode != 0:
356
- return STATUS_FAILED
357
- if result_data.get("ok") is False:
358
- return STATUS_FAILED
359
- if result_data.get("degraded") is True:
360
- return STATUS_DEGRADED
361
- return STATUS_OK
362
-
363
-
364
- def build_manifest(
365
- repo_root: Path,
366
- query: str,
367
- intent: str,
368
- command: list[str],
369
- evidence_dir: Path,
370
- output_path: Path,
371
- status: str,
372
- result_data: dict[str, Any],
373
- doctor_data: dict[str, Any],
374
- error: str = "",
375
- dry_run: bool = False,
376
- ) -> dict[str, Any]:
377
- provider_attempts = list_value(result_data.get("provider_attempts"))
378
- citations = normalize_citations(result_data)
379
- output_schema_version = result_data.get("output_schema_version")
380
- manifest: dict[str, Any] = {
381
- "version": MANIFEST_VERSION,
382
- "source": "smart-search",
383
- "query": query,
384
- "intent": intent,
385
- "command": render_command(command),
386
- "outputPath": to_repo_path(output_path, repo_root),
387
- "evidenceDir": to_repo_path(evidence_dir, repo_root),
388
- "manifestPath": to_repo_path(evidence_dir / "manifest.json", repo_root),
389
- "status": status,
390
- "createdAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
391
- "summary": summarize_result(result_data, error),
392
- "citations": citations,
393
- "gapCheck": dict_value(result_data.get("gap_check")),
394
- "providerAttempts": provider_attempts,
395
- "degraded": bool(result_data.get("degraded", status == STATUS_DEGRADED)),
396
- "routePolicyVersion": string_value(result_data.get("route_policy_version")),
397
- "doctor": normalize_doctor(doctor_data),
398
- }
399
- if isinstance(output_schema_version, int):
400
- manifest["outputSchemaVersion"] = output_schema_version
401
- if dry_run:
402
- manifest["dryRun"] = True
403
- if error:
404
- manifest["error"] = error
405
- return manifest
406
-
407
-
408
- def write_manifest(manifest: dict[str, Any], evidence_dir: Path) -> None:
409
- path = evidence_dir / "manifest.json"
410
- path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
411
-
412
-
413
- def print_manifest(manifest: dict[str, Any], as_json: bool) -> None:
414
- if as_json:
415
- print(json.dumps(manifest, ensure_ascii=False, indent=2))
416
- return
417
- print(f"Smart Search evidence: {manifest['status']}")
418
- print(f"Manifest: {manifest['manifestPath']}")
419
- print(f"Output: {manifest['outputPath']}")
420
- if manifest.get("summary"):
421
- print(f"Summary: {manifest['summary']}")
422
- if manifest.get("error"):
423
- print(f"Error: {manifest['error']}")
424
-
425
-
426
- def normalize_citations(result_data: dict[str, Any]) -> list[dict[str, Any]]:
427
- raw_items = list_value(result_data.get("citations"))
428
- if not raw_items:
429
- raw_items = list_value(result_data.get("evidence_items"))
430
- if not raw_items:
431
- raw_items = list_value(result_data.get("primary_sources")) or list_value(result_data.get("sources"))
432
-
433
- citations: list[dict[str, Any]] = []
434
- for item in raw_items:
435
- if not isinstance(item, dict):
436
- continue
437
- url = string_value(item.get("url"))
438
- title = string_value(item.get("title"))
439
- if not url and not title:
440
- continue
441
- citation: dict[str, Any] = {}
442
- if title:
443
- citation["title"] = title
444
- if url:
445
- citation["url"] = url
446
- provider = string_value(item.get("provider"))
447
- if provider:
448
- citation["provider"] = provider
449
- for key in ("id", "source_type", "subquestion_id", "verified", "content_len"):
450
- if key in item:
451
- citation[key] = item[key]
452
- citations.append(citation)
453
- return citations
454
-
455
-
456
- def summarize_result(result_data: dict[str, Any], error: str = "") -> str:
457
- if error:
458
- return error[:MAX_SUMMARY_CHARS]
459
- content = (
460
- string_value(result_data.get("final_answer"))
461
- or string_value(result_data.get("content"))
462
- or string_value(result_data.get("summary"))
463
- )
464
- if not content:
465
- total = result_data.get("total") or result_data.get("sources_count")
466
- if isinstance(total, int):
467
- return f"Smart Search returned {total} result(s)."
468
- return ""
469
- normalized = re.sub(r"\s+", " ", content).strip()
470
- return normalized[:MAX_SUMMARY_CHARS]
471
-
472
-
473
- def normalize_doctor(doctor_data: dict[str, Any]) -> dict[str, Any]:
474
- allowed_keys = (
475
- "ok",
476
- "config_status",
477
- "minimum_profile_ok",
478
- "capability_status",
479
- "resolved_evidence_dir",
480
- "config_dir_source",
481
- )
482
- return {key: doctor_data[key] for key in allowed_keys if key in doctor_data}
483
-
484
-
485
- def dict_value(value: Any) -> dict[str, Any]:
486
- return value if isinstance(value, dict) else {}
487
-
488
-
489
- def list_value(value: Any) -> list[Any]:
490
- return value if isinstance(value, list) else []
491
-
492
-
493
- def string_value(value: Any) -> str | None:
494
- if isinstance(value, str):
495
- stripped = value.strip()
496
- return stripped or None
497
- return None
498
-
499
-
500
- def to_repo_path(path: Path, repo_root: Path) -> str:
501
- try:
502
- return path.resolve().relative_to(repo_root.resolve()).as_posix()
503
- except ValueError:
504
- return str(path)
505
-
506
-
507
- def render_command(parts: list[str]) -> str:
508
- return " ".join(quote_arg(part) for part in parts)
509
-
510
-
511
- def quote_arg(value: str) -> str:
512
- if re.fullmatch(r"[A-Za-z0-9_./:=@+-]+", value):
513
- return value
514
- return json.dumps(value, ensure_ascii=False)
1
+ #!/usr/bin/env python3
2
+ """
3
+ Capture Smart Search evidence into Trellis task artifacts.
4
+
5
+ This module shells out to the local `smart-search` CLI and writes a compact
6
+ manifest that downstream Trellis context/ranking code can consume. It stays
7
+ dependency-free because it is generated into user projects.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import os
15
+ import re
16
+ import subprocess
17
+ import sys
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from .paths import get_repo_root, get_selected_task_abs, resolve_task_ref
23
+ from .smart_search_resolve import default_smart_search_argv, resolve_smart_search_argv
24
+
25
+
26
+ INTENT_CHOICES = ("deep-research", "broad-search", "docs", "official-source", "fetch")
27
+ STATUS_OK = "ok"
28
+ STATUS_DEGRADED = "degraded"
29
+ STATUS_FAILED = "failed"
30
+ STATUS_NOT_CONFIGURED = "not_configured"
31
+ MANIFEST_VERSION = 1
32
+ MAX_SUMMARY_CHARS = 500
33
+
34
+
35
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
36
+ parser = argparse.ArgumentParser(
37
+ description="Run smart-search and save a Trellis evidence manifest.",
38
+ )
39
+ parser.add_argument("query", help="Search query, research question, or URL for --intent fetch.")
40
+ parser.add_argument(
41
+ "--intent",
42
+ choices=INTENT_CHOICES,
43
+ default="deep-research",
44
+ help="Smart Search route to use.",
45
+ )
46
+ parser.add_argument(
47
+ "--task",
48
+ help="Task directory/ref. Defaults to the selected Trellis task when available.",
49
+ )
50
+ parser.add_argument(
51
+ "--run-id",
52
+ help="Stable run id for output paths. Defaults to timestamp plus query slug.",
53
+ )
54
+ parser.add_argument("--root", help="Repository root. Defaults to nearest parent with .cstl/.")
55
+ parser.add_argument(
56
+ "--smart-search-command",
57
+ default=None,
58
+ help="Override CLI executable (single path/name). Default: env, config, PATH, repo wrappers.",
59
+ )
60
+ parser.add_argument(
61
+ "--skip-doctor",
62
+ action="store_true",
63
+ help="Skip smart-search doctor preflight and run the selected command directly.",
64
+ )
65
+ parser.add_argument("--budget", choices=("quick", "standard", "deep"), default="standard")
66
+ parser.add_argument("--fallback", choices=("auto", "off"), default="auto")
67
+ parser.add_argument("--validation", choices=("fast", "balanced", "strict"), default="balanced")
68
+ parser.add_argument("--extra-sources", type=int, default=1)
69
+ parser.add_argument("--timeout", type=int, default=90)
70
+ parser.add_argument("--num-results", type=int, default=5)
71
+ parser.add_argument(
72
+ "--locale-scope",
73
+ choices=("cn", "en", "both"),
74
+ help="Bilingual discovery scope for --intent deep-research (smart-search research --locale-scope).",
75
+ )
76
+ parser.add_argument(
77
+ "--dry-run",
78
+ action="store_true",
79
+ help="Preview research plan/routing without live providers (deep-research only).",
80
+ )
81
+ parser.add_argument(
82
+ "--progress",
83
+ action="store_true",
84
+ help="Emit [research] stage logs to stderr during deep-research execution.",
85
+ )
86
+ parser.add_argument(
87
+ "--include-domain",
88
+ action="append",
89
+ default=[],
90
+ help="Domain filter for --intent official-source. May be repeated.",
91
+ )
92
+ parser.add_argument("--json", action="store_true", help="Emit manifest JSON.")
93
+ return parser.parse_args(argv)
94
+
95
+
96
+ def main(argv: list[str] | None = None) -> int:
97
+ args = parse_args(argv)
98
+ if args.extra_sources < 0:
99
+ print("Error: --extra-sources must be >= 0", file=sys.stderr)
100
+ return 2
101
+ if args.timeout <= 0:
102
+ print("Error: --timeout must be > 0", file=sys.stderr)
103
+ return 2
104
+ if args.num_results <= 0:
105
+ print("Error: --num-results must be > 0", file=sys.stderr)
106
+ return 2
107
+
108
+ repo_root = Path(args.root).resolve() if args.root else get_repo_root()
109
+ if args.smart_search_command:
110
+ args.smart_search_argv = [args.smart_search_command]
111
+ else:
112
+ args.smart_search_argv = resolve_smart_search_argv(repo_root) or default_smart_search_argv(
113
+ repo_root
114
+ )
115
+ run_id = args.run_id or default_run_id(args.intent, args.query)
116
+ evidence_dir = resolve_evidence_dir(repo_root, args.task, run_id)
117
+ evidence_dir.mkdir(parents=True, exist_ok=True)
118
+
119
+ command = build_smart_search_command(args, evidence_dir)
120
+ doctor_result = None
121
+ if not args.skip_doctor:
122
+ doctor_result = run_command(
123
+ [*args.smart_search_argv, "doctor", "--format", "json"],
124
+ repo_root,
125
+ )
126
+ if doctor_result.not_found:
127
+ manifest = build_manifest(
128
+ repo_root=repo_root,
129
+ query=args.query,
130
+ intent=args.intent,
131
+ command=command,
132
+ evidence_dir=evidence_dir,
133
+ output_path=command_output_path(args.intent, evidence_dir),
134
+ status=STATUS_NOT_CONFIGURED,
135
+ result_data={},
136
+ doctor_data={},
137
+ error="smart-search CLI could not be resolved (PATH, config, or repo wrapper).",
138
+ )
139
+ write_manifest(manifest, evidence_dir)
140
+ print_manifest(manifest, args.json)
141
+ return 4
142
+ doctor_data = parse_json_output(doctor_result.stdout)
143
+ if doctor_result.returncode != 0 or not doctor_data.get("ok"):
144
+ manifest = build_manifest(
145
+ repo_root=repo_root,
146
+ query=args.query,
147
+ intent=args.intent,
148
+ command=command,
149
+ evidence_dir=evidence_dir,
150
+ output_path=command_output_path(args.intent, evidence_dir),
151
+ status=STATUS_NOT_CONFIGURED,
152
+ result_data={},
153
+ doctor_data=doctor_data,
154
+ error=doctor_data.get("error") or doctor_result.stderr.strip() or "smart-search doctor failed.",
155
+ )
156
+ write_manifest(manifest, evidence_dir)
157
+ print_manifest(manifest, args.json)
158
+ return doctor_result.returncode or 3
159
+
160
+ result = run_command(command, repo_root)
161
+ output_path = command_output_path(args.intent, evidence_dir)
162
+ result_data = read_result_data(output_path, result.stdout)
163
+ doctor_data = parse_json_output(doctor_result.stdout) if doctor_result else {}
164
+ status = status_from_result(result.returncode, result_data)
165
+ error = ""
166
+ if result.not_found:
167
+ status = STATUS_NOT_CONFIGURED
168
+ error = "smart-search CLI could not be resolved (PATH, config, or repo wrapper)."
169
+ elif status == STATUS_FAILED:
170
+ error = (
171
+ string_value(result_data.get("error"))
172
+ or result.stderr.strip()
173
+ or f"smart-search exited with code {result.returncode}."
174
+ )
175
+ if "timed out" in error.lower() or "timeout" in error.lower():
176
+ error = (
177
+ f"{error} Retry with --timeout 120 or --intent docs; "
178
+ "or use Cursor WebSearch/WebFetch and persist source: cursor-web-fallback."
179
+ )
180
+
181
+ manifest = build_manifest(
182
+ repo_root=repo_root,
183
+ query=args.query,
184
+ intent=args.intent,
185
+ command=command,
186
+ evidence_dir=evidence_dir,
187
+ output_path=output_path,
188
+ status=status,
189
+ result_data=result_data,
190
+ doctor_data=doctor_data,
191
+ error=error,
192
+ dry_run=bool(args.dry_run),
193
+ )
194
+ write_manifest(manifest, evidence_dir)
195
+ print_manifest(manifest, args.json)
196
+ return 0 if status in {STATUS_OK, STATUS_DEGRADED} else (result.returncode or 4)
197
+
198
+
199
+ class CommandResult:
200
+ def __init__(self, returncode: int, stdout: str = "", stderr: str = "", not_found: bool = False):
201
+ self.returncode = returncode
202
+ self.stdout = stdout
203
+ self.stderr = stderr
204
+ self.not_found = not_found
205
+
206
+
207
+ def run_command(command: list[str], cwd: Path, timeout: int = 120) -> CommandResult:
208
+ try:
209
+ completed = subprocess.run(
210
+ command,
211
+ cwd=str(cwd),
212
+ text=True,
213
+ encoding="utf-8",
214
+ errors="replace",
215
+ capture_output=True,
216
+ check=False,
217
+ timeout=timeout,
218
+ )
219
+ return CommandResult(completed.returncode, completed.stdout or "", completed.stderr or "")
220
+ except subprocess.TimeoutExpired:
221
+ return CommandResult(124, "", f"Command timed out after {timeout}s")
222
+ except FileNotFoundError as error:
223
+ return CommandResult(127, "", str(error), not_found=True)
224
+
225
+
226
+ def build_smart_search_command(args: argparse.Namespace, evidence_dir: Path) -> list[str]:
227
+ output_path = command_output_path(args.intent, evidence_dir)
228
+ command = list(args.smart_search_argv)
229
+ if args.intent == "deep-research":
230
+ built = [
231
+ *command,
232
+ "research",
233
+ args.query,
234
+ "--budget",
235
+ args.budget,
236
+ "--fallback",
237
+ args.fallback,
238
+ "--format",
239
+ "json",
240
+ "--output",
241
+ str(output_path),
242
+ ]
243
+ if args.locale_scope:
244
+ built.extend(["--locale-scope", args.locale_scope])
245
+ if args.dry_run:
246
+ built.append("--dry-run")
247
+ if args.progress:
248
+ built.append("--progress")
249
+ return built
250
+ if args.intent == "broad-search":
251
+ return [
252
+ *command,
253
+ "search",
254
+ args.query,
255
+ "--validation",
256
+ args.validation,
257
+ "--extra-sources",
258
+ str(args.extra_sources),
259
+ "--timeout",
260
+ str(args.timeout),
261
+ "--format",
262
+ "json",
263
+ "--output",
264
+ str(output_path),
265
+ ]
266
+ if args.intent == "docs":
267
+ return [
268
+ *command,
269
+ "context7-library",
270
+ args.query,
271
+ "--format",
272
+ "json",
273
+ "--output",
274
+ str(output_path),
275
+ ]
276
+ if args.intent == "official-source":
277
+ built = [
278
+ *command,
279
+ "exa-search",
280
+ args.query,
281
+ "--num-results",
282
+ str(args.num_results),
283
+ "--include-text",
284
+ "--include-highlights",
285
+ "--format",
286
+ "json",
287
+ "--output",
288
+ str(output_path),
289
+ ]
290
+ for domain in args.include_domain:
291
+ built.extend(["--include-domains", domain])
292
+ return built
293
+ if args.intent == "fetch":
294
+ return [
295
+ *command,
296
+ "fetch",
297
+ args.query,
298
+ "--format",
299
+ "markdown",
300
+ "--output",
301
+ str(output_path),
302
+ ]
303
+ raise ValueError(f"Unsupported Smart Search intent: {args.intent}")
304
+
305
+
306
+ def command_output_path(intent: str, evidence_dir: Path) -> Path:
307
+ suffix = "md" if intent == "fetch" else "json"
308
+ name = intent.replace("-", "_")
309
+ return evidence_dir / f"{name}.{suffix}"
310
+
311
+
312
+ def resolve_evidence_dir(repo_root: Path, task_ref: str | None, run_id: str) -> Path:
313
+ task_dir: Path | None = None
314
+ if task_ref:
315
+ task_dir = resolve_task_ref(task_ref, repo_root)
316
+ else:
317
+ task_dir = get_selected_task_abs(repo_root)
318
+
319
+ if task_dir and task_dir.exists():
320
+ return task_dir / "research" / "smart-search" / safe_filename(run_id)
321
+ return repo_root / ".cstl" / "workspace" / "smart-search" / safe_filename(run_id)
322
+
323
+
324
+ def default_run_id(intent: str, query: str) -> str:
325
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
326
+ slug = safe_filename(query)[:48] or "query"
327
+ return f"{stamp}-{intent}-{slug}"
328
+
329
+
330
+ def safe_filename(value: str) -> str:
331
+ cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip().lower())
332
+ cleaned = cleaned.strip(".-_")
333
+ return cleaned or "run"
334
+
335
+
336
+ def parse_json_output(content: str) -> dict[str, Any]:
337
+ if not content.strip():
338
+ return {}
339
+ try:
340
+ parsed = json.loads(content)
341
+ except json.JSONDecodeError:
342
+ return {}
343
+ return parsed if isinstance(parsed, dict) else {}
344
+
345
+
346
+ def read_result_data(output_path: Path, stdout: str) -> dict[str, Any]:
347
+ if output_path.is_file() and output_path.suffix.lower() == ".json":
348
+ return parse_json_output(output_path.read_text(encoding="utf-8"))
349
+ if output_path.is_file() and output_path.suffix.lower() != ".json":
350
+ return {"ok": True, "content": output_path.read_text(encoding="utf-8")}
351
+ return parse_json_output(stdout)
352
+
353
+
354
+ def status_from_result(returncode: int, result_data: dict[str, Any]) -> str:
355
+ if returncode != 0:
356
+ return STATUS_FAILED
357
+ if result_data.get("ok") is False:
358
+ return STATUS_FAILED
359
+ if result_data.get("degraded") is True:
360
+ return STATUS_DEGRADED
361
+ return STATUS_OK
362
+
363
+
364
+ def build_manifest(
365
+ repo_root: Path,
366
+ query: str,
367
+ intent: str,
368
+ command: list[str],
369
+ evidence_dir: Path,
370
+ output_path: Path,
371
+ status: str,
372
+ result_data: dict[str, Any],
373
+ doctor_data: dict[str, Any],
374
+ error: str = "",
375
+ dry_run: bool = False,
376
+ ) -> dict[str, Any]:
377
+ provider_attempts = list_value(result_data.get("provider_attempts"))
378
+ citations = normalize_citations(result_data)
379
+ output_schema_version = result_data.get("output_schema_version")
380
+ manifest: dict[str, Any] = {
381
+ "version": MANIFEST_VERSION,
382
+ "source": "smart-search",
383
+ "query": query,
384
+ "intent": intent,
385
+ "command": render_command(command),
386
+ "outputPath": to_repo_path(output_path, repo_root),
387
+ "evidenceDir": to_repo_path(evidence_dir, repo_root),
388
+ "manifestPath": to_repo_path(evidence_dir / "manifest.json", repo_root),
389
+ "status": status,
390
+ "createdAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
391
+ "summary": summarize_result(result_data, error),
392
+ "citations": citations,
393
+ "gapCheck": dict_value(result_data.get("gap_check")),
394
+ "providerAttempts": provider_attempts,
395
+ "degraded": bool(result_data.get("degraded", status == STATUS_DEGRADED)),
396
+ "routePolicyVersion": string_value(result_data.get("route_policy_version")),
397
+ "doctor": normalize_doctor(doctor_data),
398
+ }
399
+ if isinstance(output_schema_version, int):
400
+ manifest["outputSchemaVersion"] = output_schema_version
401
+ if dry_run:
402
+ manifest["dryRun"] = True
403
+ if error:
404
+ manifest["error"] = error
405
+ return manifest
406
+
407
+
408
+ def write_manifest(manifest: dict[str, Any], evidence_dir: Path) -> None:
409
+ path = evidence_dir / "manifest.json"
410
+ path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
411
+
412
+
413
+ def print_manifest(manifest: dict[str, Any], as_json: bool) -> None:
414
+ if as_json:
415
+ print(json.dumps(manifest, ensure_ascii=False, indent=2))
416
+ return
417
+ print(f"Smart Search evidence: {manifest['status']}")
418
+ print(f"Manifest: {manifest['manifestPath']}")
419
+ print(f"Output: {manifest['outputPath']}")
420
+ if manifest.get("summary"):
421
+ print(f"Summary: {manifest['summary']}")
422
+ if manifest.get("error"):
423
+ print(f"Error: {manifest['error']}")
424
+
425
+
426
+ def normalize_citations(result_data: dict[str, Any]) -> list[dict[str, Any]]:
427
+ raw_items = list_value(result_data.get("citations"))
428
+ if not raw_items:
429
+ raw_items = list_value(result_data.get("evidence_items"))
430
+ if not raw_items:
431
+ raw_items = list_value(result_data.get("primary_sources")) or list_value(result_data.get("sources"))
432
+
433
+ citations: list[dict[str, Any]] = []
434
+ for item in raw_items:
435
+ if not isinstance(item, dict):
436
+ continue
437
+ url = string_value(item.get("url"))
438
+ title = string_value(item.get("title"))
439
+ if not url and not title:
440
+ continue
441
+ citation: dict[str, Any] = {}
442
+ if title:
443
+ citation["title"] = title
444
+ if url:
445
+ citation["url"] = url
446
+ provider = string_value(item.get("provider"))
447
+ if provider:
448
+ citation["provider"] = provider
449
+ for key in ("id", "source_type", "subquestion_id", "verified", "content_len"):
450
+ if key in item:
451
+ citation[key] = item[key]
452
+ citations.append(citation)
453
+ return citations
454
+
455
+
456
+ def summarize_result(result_data: dict[str, Any], error: str = "") -> str:
457
+ if error:
458
+ return error[:MAX_SUMMARY_CHARS]
459
+ content = (
460
+ string_value(result_data.get("final_answer"))
461
+ or string_value(result_data.get("content"))
462
+ or string_value(result_data.get("summary"))
463
+ )
464
+ if not content:
465
+ total = result_data.get("total") or result_data.get("sources_count")
466
+ if isinstance(total, int):
467
+ return f"Smart Search returned {total} result(s)."
468
+ return ""
469
+ normalized = re.sub(r"\s+", " ", content).strip()
470
+ return normalized[:MAX_SUMMARY_CHARS]
471
+
472
+
473
+ def normalize_doctor(doctor_data: dict[str, Any]) -> dict[str, Any]:
474
+ allowed_keys = (
475
+ "ok",
476
+ "config_status",
477
+ "minimum_profile_ok",
478
+ "capability_status",
479
+ "resolved_evidence_dir",
480
+ "config_dir_source",
481
+ )
482
+ return {key: doctor_data[key] for key in allowed_keys if key in doctor_data}
483
+
484
+
485
+ def dict_value(value: Any) -> dict[str, Any]:
486
+ return value if isinstance(value, dict) else {}
487
+
488
+
489
+ def list_value(value: Any) -> list[Any]:
490
+ return value if isinstance(value, list) else []
491
+
492
+
493
+ def string_value(value: Any) -> str | None:
494
+ if isinstance(value, str):
495
+ stripped = value.strip()
496
+ return stripped or None
497
+ return None
498
+
499
+
500
+ def to_repo_path(path: Path, repo_root: Path) -> str:
501
+ try:
502
+ return path.resolve().relative_to(repo_root.resolve()).as_posix()
503
+ except ValueError:
504
+ return str(path)
505
+
506
+
507
+ def render_command(parts: list[str]) -> str:
508
+ return " ".join(quote_arg(part) for part in parts)
509
+
510
+
511
+ def quote_arg(value: str) -> str:
512
+ if re.fullmatch(r"[A-Za-z0-9_./:=@+-]+", value):
513
+ return value
514
+ return json.dumps(value, ensure_ascii=False)