@blxzer/cursor-trellis 0.3.0 → 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 (193) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +51 -10
  3. package/README.zh-CN.md +21 -7
  4. package/bin/cstl.js +1 -1
  5. package/bin/smart-search.js +1 -1
  6. package/dist/cli/index.d.ts.map +1 -1
  7. package/dist/cli/index.js +17 -15
  8. package/dist/cli/index.js.map +1 -1
  9. package/dist/commands/capability-smoke.js +2 -2
  10. package/dist/commands/capability-smoke.js.map +1 -1
  11. package/dist/commands/channel/agent-loader.d.ts +1 -1
  12. package/dist/commands/channel/agent-loader.js +4 -4
  13. package/dist/commands/channel/agent-loader.js.map +1 -1
  14. package/dist/commands/channel/guard.d.ts +2 -2
  15. package/dist/commands/channel/guard.js +3 -3
  16. package/dist/commands/channel/guard.js.map +1 -1
  17. package/dist/commands/channel/index.js +3 -3
  18. package/dist/commands/channel/index.js.map +1 -1
  19. package/dist/commands/channel/list.d.ts +1 -1
  20. package/dist/commands/channel/list.js +1 -1
  21. package/dist/commands/channel/rm.d.ts +1 -1
  22. package/dist/commands/channel/rm.js +1 -1
  23. package/dist/commands/channel/store/lock.d.ts +1 -1
  24. package/dist/commands/channel/store/lock.js +1 -1
  25. package/dist/commands/channel/store/paths.js +1 -1
  26. package/dist/commands/channel/store/paths.js.map +1 -1
  27. package/dist/commands/init.d.ts.map +1 -1
  28. package/dist/commands/init.js +105 -59
  29. package/dist/commands/init.js.map +1 -1
  30. package/dist/commands/uninstall.d.ts +3 -3
  31. package/dist/commands/uninstall.js +10 -10
  32. package/dist/commands/uninstall.js.map +1 -1
  33. package/dist/commands/update.d.ts +6 -1
  34. package/dist/commands/update.d.ts.map +1 -1
  35. package/dist/commands/update.js +83 -28
  36. package/dist/commands/update.js.map +1 -1
  37. package/dist/commands/workflow.d.ts +4 -4
  38. package/dist/commands/workflow.d.ts.map +1 -1
  39. package/dist/commands/workflow.js +10 -9
  40. package/dist/commands/workflow.js.map +1 -1
  41. package/dist/configurators/index.d.ts.map +1 -1
  42. package/dist/configurators/index.js +1 -1
  43. package/dist/configurators/index.js.map +1 -1
  44. package/dist/configurators/shared.js +6 -6
  45. package/dist/configurators/shared.js.map +1 -1
  46. package/dist/configurators/workflow.d.ts +4 -4
  47. package/dist/configurators/workflow.js +1 -1
  48. package/dist/constants/paths.d.ts +24 -24
  49. package/dist/constants/paths.d.ts.map +1 -1
  50. package/dist/constants/paths.js +16 -16
  51. package/dist/constants/paths.js.map +1 -1
  52. package/dist/migrations/manifests/0.3.1.json +32 -0
  53. package/dist/templates/common/bundled-skills/cstl-cursor2plus-setup/SKILL.md +11 -11
  54. package/dist/templates/common/bundled-skills/cstl-meta/SKILL.md +15 -15
  55. package/dist/templates/common/bundled-skills/cstl-meta/references/customize-local/add-project-local-conventions.md +12 -12
  56. package/dist/templates/common/bundled-skills/cstl-meta/references/customize-local/change-agents.md +2 -2
  57. package/dist/templates/common/bundled-skills/cstl-meta/references/customize-local/change-context-loading.md +21 -21
  58. package/dist/templates/common/bundled-skills/cstl-meta/references/customize-local/change-hooks.md +9 -9
  59. package/dist/templates/common/bundled-skills/cstl-meta/references/customize-local/change-skills-or-commands.md +7 -7
  60. package/dist/templates/common/bundled-skills/cstl-meta/references/customize-local/change-spec-structure.md +15 -15
  61. package/dist/templates/common/bundled-skills/cstl-meta/references/customize-local/change-task-lifecycle.md +27 -27
  62. package/dist/templates/common/bundled-skills/cstl-meta/references/customize-local/change-workflow.md +7 -7
  63. package/dist/templates/common/bundled-skills/cstl-meta/references/customize-local/overview.md +11 -11
  64. package/dist/templates/common/bundled-skills/cstl-meta/references/local-architecture/context-injection.md +12 -12
  65. package/dist/templates/common/bundled-skills/cstl-meta/references/local-architecture/generated-files.md +21 -21
  66. package/dist/templates/common/bundled-skills/cstl-meta/references/local-architecture/overview.md +14 -14
  67. package/dist/templates/common/bundled-skills/cstl-meta/references/local-architecture/spec-system.md +12 -12
  68. package/dist/templates/common/bundled-skills/cstl-meta/references/local-architecture/task-system.md +23 -23
  69. package/dist/templates/common/bundled-skills/cstl-meta/references/local-architecture/workflow.md +3 -3
  70. package/dist/templates/common/bundled-skills/cstl-meta/references/local-architecture/workspace-memory.md +16 -16
  71. package/dist/templates/common/bundled-skills/cstl-meta/references/platform-files/agents.md +2 -2
  72. package/dist/templates/common/bundled-skills/cstl-meta/references/platform-files/hooks-and-settings.md +6 -6
  73. package/dist/templates/common/bundled-skills/cstl-meta/references/platform-files/overview.md +5 -5
  74. package/dist/templates/common/bundled-skills/cstl-meta/references/platform-files/platform-map.md +3 -3
  75. package/dist/templates/common/bundled-skills/cstl-meta/references/platform-files/skills-and-commands.md +5 -5
  76. package/dist/templates/common/bundled-skills/cstl-skill-creator/SKILL.md +2 -2
  77. package/dist/templates/common/bundled-skills/cstl-skill-creator/references/review-checklist.md +1 -1
  78. package/dist/templates/common/bundled-skills/cstl-spec-bootstrap/SKILL.md +5 -5
  79. package/dist/templates/common/bundled-skills/cstl-spec-bootstrap/references/repository-analysis.md +1 -1
  80. package/dist/templates/common/bundled-skills/cstl-spec-bootstrap/references/spec-task-planning.md +3 -3
  81. package/dist/templates/common/bundled-skills/cstl-spec-bootstrap/references/spec-writing.md +1 -1
  82. package/dist/templates/common/commands/continue.md +6 -6
  83. package/dist/templates/common/commands/finish-work.md +5 -5
  84. package/dist/templates/common/commands/start.md +8 -8
  85. package/dist/templates/common/skills/before-dev.md +4 -4
  86. package/dist/templates/common/skills/brainstorm.md +5 -5
  87. package/dist/templates/common/skills/break-loop.md +3 -3
  88. package/dist/templates/common/skills/check.md +5 -5
  89. package/dist/templates/common/skills/update-spec.md +4 -4
  90. package/dist/templates/cursor/agents/cstl-check.md +6 -6
  91. package/dist/templates/cursor/agents/cstl-implement.md +7 -7
  92. package/dist/templates/cursor/agents/cstl-research.md +9 -9
  93. package/dist/templates/cursor/commands/cursor2plus-setup.md +10 -10
  94. package/dist/templates/cursor/rules/cstl-subagent-dispatch.mdc +2 -2
  95. package/dist/templates/cursor/rules/cstl-triage.mdc +2 -2
  96. package/dist/templates/cursor/rules/retrieval-routing.mdc +4 -4
  97. package/dist/templates/extract.d.ts +1 -1
  98. package/dist/templates/extract.js +1 -1
  99. package/dist/templates/markdown/agents.md +11 -11
  100. package/dist/templates/markdown/prompts/run-semantic-slice-12.md.txt +1 -1
  101. package/dist/templates/markdown/spec/guides/cross-platform-thinking-guide.md.txt +5 -5
  102. package/dist/templates/markdown/spec/guides/cursor-context-injection-guide.md.txt +3 -3
  103. package/dist/templates/markdown/spec/guides/cursor-semantic-compliance.md.txt +1 -1
  104. package/dist/templates/markdown/spec/guides/cursor-subagent-policy.md.txt +11 -11
  105. package/dist/templates/markdown/spec/guides/durable-learning-decision-guide.md.txt +8 -8
  106. package/dist/templates/markdown/spec/guides/execution-strategy.md.txt +3 -3
  107. package/dist/templates/markdown/spec/guides/retrieval-daily-guide.md.txt +10 -10
  108. package/dist/templates/markdown/workspace-index.md +3 -3
  109. package/dist/templates/markdown/worktree.yaml.txt +1 -1
  110. package/dist/templates/shared-hooks/index.d.ts +1 -1
  111. package/dist/templates/shared-hooks/index.js +1 -1
  112. package/dist/templates/shared-hooks/inject-retrieval-plan.py +3 -3
  113. package/dist/templates/shared-hooks/inject-shell-session-context.py +7 -7
  114. package/dist/templates/shared-hooks/inject-subagent-context.py +1 -1
  115. package/dist/templates/shared-hooks/inject-workflow-state.py +11 -11
  116. package/dist/templates/shared-hooks/research-end-retrieval-pack.py +1 -1
  117. package/dist/templates/shared-hooks/session-start.py +13 -13
  118. package/dist/templates/trellis/config.yaml +2 -22
  119. package/dist/templates/trellis/index.d.ts +2 -2
  120. package/dist/templates/trellis/index.js +2 -2
  121. package/dist/templates/trellis/local/README.md +3 -3
  122. package/dist/templates/trellis/local/patch_wpelc8.py +4 -4
  123. package/dist/templates/trellis/scripts/add_session.py +20 -20
  124. package/dist/templates/trellis/scripts/common/active_task.py +2 -2
  125. package/dist/templates/trellis/scripts/common/artifact_search.py +587 -587
  126. package/dist/templates/trellis/scripts/common/codebase_retrieval_router.py +6 -6
  127. package/dist/templates/trellis/scripts/common/config.py +3 -3
  128. package/dist/templates/trellis/scripts/common/developer.py +2 -2
  129. package/dist/templates/trellis/scripts/common/packages_context.py +23 -23
  130. package/dist/templates/trellis/scripts/common/parent_orchestration.py +16 -16
  131. package/dist/templates/trellis/scripts/common/paths.py +107 -107
  132. package/dist/templates/trellis/scripts/common/project_file_stats.py +1 -1
  133. package/dist/templates/trellis/scripts/common/retrieval_agent_instructions.py +1 -1
  134. package/dist/templates/trellis/scripts/common/safe_commit.py +31 -31
  135. package/dist/templates/trellis/scripts/common/session_memory.py +360 -360
  136. package/dist/templates/trellis/scripts/common/smart_search_evidence.py +514 -514
  137. package/dist/templates/trellis/scripts/common/smart_search_resolve.py +1 -1
  138. package/dist/templates/trellis/scripts/common/subagent_dispatch.py +2 -2
  139. package/dist/templates/trellis/scripts/common/task_context.py +1 -1
  140. package/dist/templates/trellis/scripts/common/task_dashboard.py +140 -140
  141. package/dist/templates/trellis/scripts/common/task_gates.py +23 -23
  142. package/dist/templates/trellis/scripts/common/task_store.py +5 -5
  143. package/dist/templates/trellis/scripts/common/task_utils.py +3 -3
  144. package/dist/templates/trellis/scripts/common/tasks.py +1 -1
  145. package/dist/templates/trellis/scripts/common/trellis_config.py +3 -3
  146. package/dist/templates/trellis/scripts/common/workflow_phase.py +1 -1
  147. package/dist/templates/trellis/scripts/cursor_retrieval_probe.py +3 -3
  148. package/dist/templates/trellis/scripts/cursor_retrieval_probe_prompt.md +11 -11
  149. package/dist/templates/trellis/scripts/hooks/linear_sync.py +8 -8
  150. package/dist/templates/trellis/scripts/init_developer.py +2 -2
  151. package/dist/templates/trellis/scripts/retrieval_probe_matrix_template.json +4 -4
  152. package/dist/templates/trellis/scripts/route_codebase_retrieval.py +1 -1
  153. package/dist/templates/trellis/scripts/task.py +918 -918
  154. package/dist/templates/trellis/tasks/templates/release-readiness/implement.md +2 -2
  155. package/dist/templates/trellis/workflow.md +64 -64
  156. package/dist/types/migration.d.ts +1 -1
  157. package/dist/types/migration.d.ts.map +1 -1
  158. package/dist/utils/agents-md.d.ts +37 -0
  159. package/dist/utils/agents-md.d.ts.map +1 -0
  160. package/dist/utils/agents-md.js +82 -0
  161. package/dist/utils/agents-md.js.map +1 -0
  162. package/dist/utils/codebase-retrieval-router.js +5 -5
  163. package/dist/utils/codebase-retrieval-router.js.map +1 -1
  164. package/dist/utils/manifest-prune.d.ts +2 -2
  165. package/dist/utils/manifest-prune.js +4 -4
  166. package/dist/utils/manifest-prune.js.map +1 -1
  167. package/dist/utils/mirror-check.d.ts +3 -1
  168. package/dist/utils/mirror-check.d.ts.map +1 -1
  169. package/dist/utils/mirror-check.js +22 -11
  170. package/dist/utils/mirror-check.js.map +1 -1
  171. package/dist/utils/project-capabilities.js +5 -5
  172. package/dist/utils/project-capabilities.js.map +1 -1
  173. package/dist/utils/retrieval-agent-instructions.js +1 -1
  174. package/dist/utils/retrieval-agent-instructions.js.map +1 -1
  175. package/dist/utils/retrieval-execution-telemetry.js +1 -1
  176. package/dist/utils/retrieval-execution-telemetry.js.map +1 -1
  177. package/dist/utils/template-fetcher.js +1 -1
  178. package/dist/utils/template-fetcher.js.map +1 -1
  179. package/dist/utils/template-hash.d.ts +2 -2
  180. package/dist/utils/template-hash.d.ts.map +1 -1
  181. package/dist/utils/template-hash.js +12 -10
  182. package/dist/utils/template-hash.js.map +1 -1
  183. package/dist/utils/workflow-dir.d.ts +13 -0
  184. package/dist/utils/workflow-dir.d.ts.map +1 -0
  185. package/dist/utils/workflow-dir.js +35 -0
  186. package/dist/utils/workflow-dir.js.map +1 -0
  187. package/dist/utils/workflow-ownership.d.ts +36 -0
  188. package/dist/utils/workflow-ownership.d.ts.map +1 -0
  189. package/dist/utils/workflow-ownership.js +137 -0
  190. package/dist/utils/workflow-ownership.js.map +1 -0
  191. package/dist/utils/workflow-resolver.d.ts +1 -1
  192. package/dist/utils/workflow-resolver.js +1 -1
  193. package/package.json +2 -2
@@ -1,587 +1,587 @@
1
- #!/usr/bin/env python3
2
- """
3
- Search durable Trellis markdown artifacts.
4
-
5
- This module is intentionally dependency-free because it is generated into user
6
- projects as part of the Trellis Python runtime.
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- import argparse
12
- import json
13
- import re
14
- import sys
15
- from dataclasses import dataclass, field
16
- from pathlib import Path
17
- from typing import Iterable
18
-
19
- from .paths import DIR_TASKS, DIR_WORKFLOW, DIR_WORKSPACE, get_repo_root
20
-
21
-
22
- MAX_SNIPPETS = 3
23
- VALID_CATEGORIES = {"spec", "task", "workspace"}
24
- VALID_KINDS = {
25
- "spec",
26
- "task_prd",
27
- "task_design",
28
- "task_implement",
29
- "task_research",
30
- "task_verify",
31
- "task_handoff",
32
- "task_artifact",
33
- "workspace",
34
- }
35
-
36
-
37
- @dataclass
38
- class Artifact:
39
- path: Path
40
- rel_path: str
41
- kind: str
42
- category: str
43
- frontmatter: dict[str, object]
44
- body: str
45
- body_start_line: int
46
- title: str | None
47
- headings: list[tuple[int, str, str]]
48
-
49
-
50
- @dataclass
51
- class FilterSpec:
52
- raw: str
53
- key: str
54
- op: str
55
- values: list[str]
56
-
57
-
58
- @dataclass
59
- class Snippet:
60
- line: int
61
- text: str
62
- anchor: str | None = None
63
-
64
- def to_json(self) -> dict[str, object]:
65
- data: dict[str, object] = {"line": self.line, "text": self.text}
66
- if self.anchor:
67
- data["anchor"] = self.anchor
68
- return data
69
-
70
-
71
- @dataclass
72
- class SearchResult:
73
- artifact: Artifact
74
- score: int
75
- matched_fields: set[str] = field(default_factory=set)
76
- snippets: list[Snippet] = field(default_factory=list)
77
-
78
- def to_json(self) -> dict[str, object]:
79
- return {
80
- "path": self.artifact.rel_path,
81
- "kind": self.artifact.kind,
82
- "category": self.artifact.category,
83
- "title": self.artifact.title,
84
- "frontmatter": self.artifact.frontmatter,
85
- "matched_fields": sorted(self.matched_fields),
86
- "snippets": [snippet.to_json() for snippet in self.snippets],
87
- "score": self.score,
88
- }
89
-
90
-
91
- def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
92
- parser = argparse.ArgumentParser(
93
- description="Search local .trellis markdown artifacts.",
94
- )
95
- parser.add_argument(
96
- "--query",
97
- "-q",
98
- default="",
99
- help="Whitespace-token AND query over path, title, headings, frontmatter, and body.",
100
- )
101
- parser.add_argument(
102
- "--filter",
103
- action="append",
104
- default=[],
105
- help="Frontmatter filter: key=value or key~=substring. Use a|b for OR values.",
106
- )
107
- parser.add_argument(
108
- "--kind",
109
- action="append",
110
- choices=sorted(VALID_KINDS),
111
- default=[],
112
- help="Restrict to an artifact kind. May be repeated.",
113
- )
114
- parser.add_argument(
115
- "--category",
116
- action="append",
117
- choices=sorted(VALID_CATEGORIES),
118
- default=[],
119
- help="Restrict to an artifact category. May be repeated.",
120
- )
121
- parser.add_argument(
122
- "--limit",
123
- type=int,
124
- default=20,
125
- help="Maximum results to return. Use 0 for no limit.",
126
- )
127
- parser.add_argument(
128
- "--json",
129
- action="store_true",
130
- help="Emit stable JSON for agents and tests.",
131
- )
132
- parser.add_argument(
133
- "--root",
134
- help="Repository root. Defaults to nearest parent containing .trellis/.",
135
- )
136
- return parser.parse_args(argv)
137
-
138
-
139
- def main(argv: list[str] | None = None) -> int:
140
- args = parse_args(argv)
141
- try:
142
- filters = [parse_filter(raw) for raw in args.filter]
143
- except ValueError as error:
144
- print(f"Error: {error}", file=sys.stderr)
145
- return 2
146
-
147
- if args.limit < 0:
148
- print("Error: --limit must be >= 0", file=sys.stderr)
149
- return 2
150
-
151
- repo_root = Path(args.root).resolve() if args.root else get_repo_root()
152
- results = search_artifacts(
153
- repo_root=repo_root,
154
- query=args.query,
155
- filters=filters,
156
- kinds=set(args.kind),
157
- categories=set(args.category),
158
- limit=args.limit,
159
- )
160
-
161
- payload = {
162
- "query": args.query,
163
- "filters": args.filter,
164
- "kinds": args.kind,
165
- "categories": args.category,
166
- "total": len(results),
167
- "results": [result.to_json() for result in results],
168
- }
169
-
170
- if args.json:
171
- print(json.dumps(payload, ensure_ascii=False, indent=2))
172
- else:
173
- print_human(payload)
174
-
175
- return 0
176
-
177
-
178
- def search_artifacts(
179
- repo_root: Path,
180
- query: str,
181
- filters: list[FilterSpec],
182
- kinds: set[str],
183
- categories: set[str],
184
- limit: int,
185
- ) -> list[SearchResult]:
186
- tokens = tokenize(query)
187
- results: list[SearchResult] = []
188
-
189
- for path in iter_artifact_paths(repo_root):
190
- artifact = load_artifact(path, repo_root)
191
- if artifact is None:
192
- continue
193
- if kinds and artifact.kind not in kinds:
194
- continue
195
- if categories and artifact.category not in categories:
196
- continue
197
-
198
- filter_fields = matched_filter_fields(artifact, filters)
199
- if filter_fields is None:
200
- continue
201
-
202
- result = score_artifact(artifact, tokens)
203
- if result is None:
204
- continue
205
-
206
- result.matched_fields.update(filter_fields)
207
- if filter_fields and not tokens:
208
- result.score += len(filter_fields)
209
- if not result.snippets:
210
- result.snippets = fallback_snippets(artifact)
211
- results.append(result)
212
-
213
- results.sort(key=lambda item: (-item.score, item.artifact.rel_path))
214
- if limit:
215
- return results[:limit]
216
- return results
217
-
218
-
219
- def iter_artifact_paths(repo_root: Path) -> Iterable[Path]:
220
- roots = [
221
- repo_root / DIR_WORKFLOW / "spec",
222
- repo_root / DIR_WORKFLOW / DIR_TASKS,
223
- repo_root / DIR_WORKFLOW / DIR_WORKSPACE,
224
- ]
225
- seen: set[Path] = set()
226
- for root in roots:
227
- if not root.is_dir():
228
- continue
229
- for path in sorted(root.rglob("*.md"), key=lambda item: item.as_posix()):
230
- if not path.is_file():
231
- continue
232
- resolved = path.resolve()
233
- if resolved in seen:
234
- continue
235
- seen.add(resolved)
236
- yield path
237
-
238
-
239
- def load_artifact(path: Path, repo_root: Path) -> Artifact | None:
240
- try:
241
- content = path.read_text(encoding="utf-8", errors="replace")
242
- except OSError:
243
- return None
244
-
245
- rel_path = to_repo_path(path, repo_root)
246
- kind = classify_kind(rel_path)
247
- if kind is None:
248
- return None
249
-
250
- frontmatter, body, body_start_line = split_frontmatter(content)
251
- headings = extract_headings(body, body_start_line)
252
- title = discover_title(frontmatter, headings)
253
- category = category_for_kind(kind)
254
- return Artifact(
255
- path=path,
256
- rel_path=rel_path,
257
- kind=kind,
258
- category=category,
259
- frontmatter=frontmatter,
260
- body=body,
261
- body_start_line=body_start_line,
262
- title=title,
263
- headings=headings,
264
- )
265
-
266
-
267
- def to_repo_path(path: Path, repo_root: Path) -> str:
268
- try:
269
- return path.resolve().relative_to(repo_root.resolve()).as_posix()
270
- except ValueError:
271
- return path.as_posix()
272
-
273
-
274
- def classify_kind(rel_path: str) -> str | None:
275
- normalized = rel_path.replace("\\", "/")
276
- if normalized.startswith(f"{DIR_WORKFLOW}/spec/"):
277
- return "spec"
278
- if normalized.startswith(f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/"):
279
- return "workspace"
280
- if not normalized.startswith(f"{DIR_WORKFLOW}/{DIR_TASKS}/"):
281
- return None
282
-
283
- name = normalized.rsplit("/", 1)[-1]
284
- if "/research/" in normalized:
285
- return "task_research"
286
- if name == "prd.md":
287
- return "task_prd"
288
- if name == "design.md":
289
- return "task_design"
290
- if name == "implement.md":
291
- return "task_implement"
292
- if name == "verify.md":
293
- return "task_verify"
294
- if name == "handoff.md":
295
- return "task_handoff"
296
- return "task_artifact"
297
-
298
-
299
- def category_for_kind(kind: str) -> str:
300
- if kind == "spec":
301
- return "spec"
302
- if kind == "workspace":
303
- return "workspace"
304
- return "task"
305
-
306
-
307
- def split_frontmatter(content: str) -> tuple[dict[str, object], str, int]:
308
- lines = content.splitlines()
309
- if not lines or lines[0].strip() != "---":
310
- return {}, content, 1
311
-
312
- for idx in range(1, len(lines)):
313
- if lines[idx].strip() == "---":
314
- frontmatter_lines = lines[1:idx]
315
- body_lines = lines[idx + 1 :]
316
- frontmatter = parse_frontmatter(frontmatter_lines)
317
- return frontmatter, "\n".join(body_lines), idx + 2
318
-
319
- return {}, content, 1
320
-
321
-
322
- def parse_frontmatter(lines: list[str]) -> dict[str, object]:
323
- data: dict[str, object] = {}
324
- current_list_key: str | None = None
325
-
326
- for raw_line in lines:
327
- if not raw_line.strip() or raw_line.lstrip().startswith("#"):
328
- continue
329
- stripped = raw_line.strip()
330
- if stripped.startswith("- "):
331
- if current_list_key is None:
332
- continue
333
- value = unquote(stripped[2:].strip())
334
- items = data.setdefault(current_list_key, [])
335
- if isinstance(items, list):
336
- items.append(value)
337
- continue
338
-
339
- if ":" not in stripped:
340
- current_list_key = None
341
- continue
342
-
343
- key, value = stripped.split(":", 1)
344
- key = key.strip()
345
- value = strip_inline_comment(value).strip()
346
- if not key:
347
- current_list_key = None
348
- continue
349
- if value:
350
- data[key] = unquote(value)
351
- current_list_key = None
352
- else:
353
- data[key] = []
354
- current_list_key = key
355
-
356
- return data
357
-
358
-
359
- def strip_inline_comment(value: str) -> str:
360
- in_quote: str | None = None
361
- for idx, char in enumerate(value):
362
- if in_quote:
363
- if char == in_quote:
364
- in_quote = None
365
- continue
366
- if char in ("'", '"'):
367
- in_quote = char
368
- continue
369
- if char == "#" and (idx == 0 or value[idx - 1].isspace()):
370
- return value[:idx]
371
- return value
372
-
373
-
374
- def unquote(value: str) -> str:
375
- if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
376
- return value[1:-1]
377
- return value
378
-
379
-
380
- def extract_headings(body: str, body_start_line: int) -> list[tuple[int, str, str]]:
381
- headings: list[tuple[int, str, str]] = []
382
- for offset, line in enumerate(body.splitlines()):
383
- match = re.match(r"^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$", line)
384
- if not match:
385
- continue
386
- text = normalize_space(match.group(1))
387
- headings.append((body_start_line + offset, text, slugify_heading(text)))
388
- return headings
389
-
390
-
391
- def discover_title(
392
- frontmatter: dict[str, object],
393
- headings: list[tuple[int, str, str]],
394
- ) -> str | None:
395
- raw_title = frontmatter.get("title")
396
- if isinstance(raw_title, str) and raw_title.strip():
397
- return raw_title.strip()
398
- for _, heading, _ in headings:
399
- return heading
400
- return None
401
-
402
-
403
- def parse_filter(raw: str) -> FilterSpec:
404
- op = "~=" if "~=" in raw else "="
405
- if op not in raw:
406
- raise ValueError(f"invalid filter {raw!r}; expected key=value or key~=value")
407
- key, value = raw.split(op, 1)
408
- key = key.strip()
409
- values = [item.strip() for item in value.split("|") if item.strip()]
410
- if not key or not values:
411
- raise ValueError(f"invalid filter {raw!r}; expected key=value or key~=value")
412
- return FilterSpec(raw=raw, key=key, op=op, values=values)
413
-
414
-
415
- def matched_filter_fields(
416
- artifact: Artifact,
417
- filters: list[FilterSpec],
418
- ) -> set[str] | None:
419
- matched: set[str] = set()
420
- for item in filters:
421
- raw_value = artifact.frontmatter.get(item.key)
422
- values = normalized_values(raw_value)
423
- if not values:
424
- return None
425
- if item.op == "=":
426
- ok = any(value == candidate for value in values for candidate in lower_all(item.values))
427
- else:
428
- ok = any(candidate in value for value in values for candidate in lower_all(item.values))
429
- if not ok:
430
- return None
431
- matched.add(f"frontmatter.{item.key}")
432
- return matched
433
-
434
-
435
- def score_artifact(artifact: Artifact, tokens: list[str]) -> SearchResult | None:
436
- if not tokens:
437
- return SearchResult(artifact=artifact, score=0)
438
-
439
- field_values = searchable_fields(artifact)
440
- combined = "\n".join(value for _, value in field_values).lower()
441
- if any(token not in combined for token in tokens):
442
- return None
443
-
444
- result = SearchResult(artifact=artifact, score=0)
445
- for token in tokens:
446
- if token in artifact.rel_path.lower():
447
- result.score += 8
448
- result.matched_fields.add("path")
449
- if artifact.title and token in artifact.title.lower():
450
- result.score += 7
451
- result.matched_fields.add("title")
452
- for key, value in artifact.frontmatter.items():
453
- if any(token in item for item in normalized_values(value)):
454
- result.score += 6
455
- result.matched_fields.add(f"frontmatter.{key}")
456
- if any(token in heading.lower() for _, heading, _ in artifact.headings):
457
- result.score += 4
458
- result.matched_fields.add("headings")
459
- if token in artifact.body.lower():
460
- result.score += 2
461
- result.matched_fields.add("body")
462
-
463
- result.snippets = query_snippets(artifact, tokens)
464
- return result
465
-
466
-
467
- def searchable_fields(artifact: Artifact) -> list[tuple[str, str]]:
468
- frontmatter = " ".join(flatten_frontmatter_values(artifact.frontmatter))
469
- headings = " ".join(heading for _, heading, _ in artifact.headings)
470
- return [
471
- ("path", artifact.rel_path),
472
- ("title", artifact.title or ""),
473
- ("frontmatter", frontmatter),
474
- ("headings", headings),
475
- ("body", artifact.body),
476
- ]
477
-
478
-
479
- def query_snippets(artifact: Artifact, tokens: list[str]) -> list[Snippet]:
480
- snippets: list[Snippet] = []
481
- seen_lines: set[int] = set()
482
- current_anchor: str | None = None
483
- heading_by_line = {line: anchor for line, _, anchor in artifact.headings}
484
-
485
- for offset, raw_line in enumerate(artifact.body.splitlines()):
486
- line_number = artifact.body_start_line + offset
487
- if line_number in heading_by_line:
488
- current_anchor = heading_by_line[line_number]
489
- line = normalize_space(raw_line)
490
- if not line:
491
- continue
492
- lower = line.lower()
493
- if not any(token in lower for token in tokens):
494
- continue
495
- if line_number in seen_lines:
496
- continue
497
- snippets.append(Snippet(line=line_number, text=line, anchor=current_anchor))
498
- seen_lines.add(line_number)
499
- if len(snippets) >= MAX_SNIPPETS:
500
- break
501
-
502
- return snippets
503
-
504
-
505
- def fallback_snippets(artifact: Artifact) -> list[Snippet]:
506
- current_anchor: str | None = None
507
- heading_by_line = {line: anchor for line, _, anchor in artifact.headings}
508
- for offset, raw_line in enumerate(artifact.body.splitlines()):
509
- line_number = artifact.body_start_line + offset
510
- if line_number in heading_by_line:
511
- current_anchor = heading_by_line[line_number]
512
- text = normalize_space(raw_line)
513
- if text:
514
- return [Snippet(line=line_number, text=text, anchor=current_anchor)]
515
- return []
516
-
517
-
518
- def tokenize(query: str) -> list[str]:
519
- return [token.lower() for token in re.findall(r"\S+", query) if token.strip()]
520
-
521
-
522
- def lower_all(values: Iterable[str]) -> list[str]:
523
- return [value.lower() for value in values]
524
-
525
-
526
- def normalized_values(value: object) -> list[str]:
527
- if isinstance(value, list):
528
- return [str(item).lower() for item in value]
529
- if value is None:
530
- return []
531
- return [str(value).lower()]
532
-
533
-
534
- def flatten_frontmatter_values(frontmatter: dict[str, object]) -> list[str]:
535
- values: list[str] = []
536
- for key, value in frontmatter.items():
537
- values.append(key)
538
- if isinstance(value, list):
539
- values.extend(str(item) for item in value)
540
- elif value is not None:
541
- values.append(str(value))
542
- return values
543
-
544
-
545
- def normalize_space(value: str) -> str:
546
- return re.sub(r"\s+", " ", value).strip()
547
-
548
-
549
- def slugify_heading(value: str) -> str:
550
- slug = re.sub(r"[^a-z0-9\s-]", "", value.lower())
551
- return re.sub(r"[\s-]+", "-", slug).strip("-")
552
-
553
-
554
- def print_human(payload: dict[str, object]) -> None:
555
- total = int(payload["total"])
556
- noun = "artifact" if total == 1 else "artifacts"
557
- print(f"Found {total} {noun}")
558
- print()
559
-
560
- results = payload["results"]
561
- if not isinstance(results, list):
562
- return
563
-
564
- for idx, result in enumerate(results, start=1):
565
- if not isinstance(result, dict):
566
- continue
567
- print(
568
- f"{idx}. {result['path']} [{result['kind']}] "
569
- f"score={result['score']}"
570
- )
571
- title = result.get("title")
572
- if title:
573
- print(f" title: {title}")
574
- matched = result.get("matched_fields")
575
- if isinstance(matched, list) and matched:
576
- print(f" matched: {', '.join(str(item) for item in matched)}")
577
- snippets = result.get("snippets")
578
- if isinstance(snippets, list):
579
- for snippet in snippets:
580
- if not isinstance(snippet, dict):
581
- continue
582
- print(f" L{snippet.get('line')}: {snippet.get('text')}")
583
-
584
-
585
- if __name__ == "__main__":
586
- sys.exit(main())
587
-
1
+ #!/usr/bin/env python3
2
+ """
3
+ Search durable Trellis markdown artifacts.
4
+
5
+ This module is intentionally dependency-free because it is generated into user
6
+ projects as part of the Trellis Python runtime.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import re
14
+ import sys
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+ from typing import Iterable
18
+
19
+ from .paths import DIR_TASKS, DIR_WORKFLOW, DIR_WORKSPACE, get_repo_root
20
+
21
+
22
+ MAX_SNIPPETS = 3
23
+ VALID_CATEGORIES = {"spec", "task", "workspace"}
24
+ VALID_KINDS = {
25
+ "spec",
26
+ "task_prd",
27
+ "task_design",
28
+ "task_implement",
29
+ "task_research",
30
+ "task_verify",
31
+ "task_handoff",
32
+ "task_artifact",
33
+ "workspace",
34
+ }
35
+
36
+
37
+ @dataclass
38
+ class Artifact:
39
+ path: Path
40
+ rel_path: str
41
+ kind: str
42
+ category: str
43
+ frontmatter: dict[str, object]
44
+ body: str
45
+ body_start_line: int
46
+ title: str | None
47
+ headings: list[tuple[int, str, str]]
48
+
49
+
50
+ @dataclass
51
+ class FilterSpec:
52
+ raw: str
53
+ key: str
54
+ op: str
55
+ values: list[str]
56
+
57
+
58
+ @dataclass
59
+ class Snippet:
60
+ line: int
61
+ text: str
62
+ anchor: str | None = None
63
+
64
+ def to_json(self) -> dict[str, object]:
65
+ data: dict[str, object] = {"line": self.line, "text": self.text}
66
+ if self.anchor:
67
+ data["anchor"] = self.anchor
68
+ return data
69
+
70
+
71
+ @dataclass
72
+ class SearchResult:
73
+ artifact: Artifact
74
+ score: int
75
+ matched_fields: set[str] = field(default_factory=set)
76
+ snippets: list[Snippet] = field(default_factory=list)
77
+
78
+ def to_json(self) -> dict[str, object]:
79
+ return {
80
+ "path": self.artifact.rel_path,
81
+ "kind": self.artifact.kind,
82
+ "category": self.artifact.category,
83
+ "title": self.artifact.title,
84
+ "frontmatter": self.artifact.frontmatter,
85
+ "matched_fields": sorted(self.matched_fields),
86
+ "snippets": [snippet.to_json() for snippet in self.snippets],
87
+ "score": self.score,
88
+ }
89
+
90
+
91
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
92
+ parser = argparse.ArgumentParser(
93
+ description="Search local .cstl markdown artifacts.",
94
+ )
95
+ parser.add_argument(
96
+ "--query",
97
+ "-q",
98
+ default="",
99
+ help="Whitespace-token AND query over path, title, headings, frontmatter, and body.",
100
+ )
101
+ parser.add_argument(
102
+ "--filter",
103
+ action="append",
104
+ default=[],
105
+ help="Frontmatter filter: key=value or key~=substring. Use a|b for OR values.",
106
+ )
107
+ parser.add_argument(
108
+ "--kind",
109
+ action="append",
110
+ choices=sorted(VALID_KINDS),
111
+ default=[],
112
+ help="Restrict to an artifact kind. May be repeated.",
113
+ )
114
+ parser.add_argument(
115
+ "--category",
116
+ action="append",
117
+ choices=sorted(VALID_CATEGORIES),
118
+ default=[],
119
+ help="Restrict to an artifact category. May be repeated.",
120
+ )
121
+ parser.add_argument(
122
+ "--limit",
123
+ type=int,
124
+ default=20,
125
+ help="Maximum results to return. Use 0 for no limit.",
126
+ )
127
+ parser.add_argument(
128
+ "--json",
129
+ action="store_true",
130
+ help="Emit stable JSON for agents and tests.",
131
+ )
132
+ parser.add_argument(
133
+ "--root",
134
+ help="Repository root. Defaults to nearest parent containing .cstl/.",
135
+ )
136
+ return parser.parse_args(argv)
137
+
138
+
139
+ def main(argv: list[str] | None = None) -> int:
140
+ args = parse_args(argv)
141
+ try:
142
+ filters = [parse_filter(raw) for raw in args.filter]
143
+ except ValueError as error:
144
+ print(f"Error: {error}", file=sys.stderr)
145
+ return 2
146
+
147
+ if args.limit < 0:
148
+ print("Error: --limit must be >= 0", file=sys.stderr)
149
+ return 2
150
+
151
+ repo_root = Path(args.root).resolve() if args.root else get_repo_root()
152
+ results = search_artifacts(
153
+ repo_root=repo_root,
154
+ query=args.query,
155
+ filters=filters,
156
+ kinds=set(args.kind),
157
+ categories=set(args.category),
158
+ limit=args.limit,
159
+ )
160
+
161
+ payload = {
162
+ "query": args.query,
163
+ "filters": args.filter,
164
+ "kinds": args.kind,
165
+ "categories": args.category,
166
+ "total": len(results),
167
+ "results": [result.to_json() for result in results],
168
+ }
169
+
170
+ if args.json:
171
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
172
+ else:
173
+ print_human(payload)
174
+
175
+ return 0
176
+
177
+
178
+ def search_artifacts(
179
+ repo_root: Path,
180
+ query: str,
181
+ filters: list[FilterSpec],
182
+ kinds: set[str],
183
+ categories: set[str],
184
+ limit: int,
185
+ ) -> list[SearchResult]:
186
+ tokens = tokenize(query)
187
+ results: list[SearchResult] = []
188
+
189
+ for path in iter_artifact_paths(repo_root):
190
+ artifact = load_artifact(path, repo_root)
191
+ if artifact is None:
192
+ continue
193
+ if kinds and artifact.kind not in kinds:
194
+ continue
195
+ if categories and artifact.category not in categories:
196
+ continue
197
+
198
+ filter_fields = matched_filter_fields(artifact, filters)
199
+ if filter_fields is None:
200
+ continue
201
+
202
+ result = score_artifact(artifact, tokens)
203
+ if result is None:
204
+ continue
205
+
206
+ result.matched_fields.update(filter_fields)
207
+ if filter_fields and not tokens:
208
+ result.score += len(filter_fields)
209
+ if not result.snippets:
210
+ result.snippets = fallback_snippets(artifact)
211
+ results.append(result)
212
+
213
+ results.sort(key=lambda item: (-item.score, item.artifact.rel_path))
214
+ if limit:
215
+ return results[:limit]
216
+ return results
217
+
218
+
219
+ def iter_artifact_paths(repo_root: Path) -> Iterable[Path]:
220
+ roots = [
221
+ repo_root / DIR_WORKFLOW / "spec",
222
+ repo_root / DIR_WORKFLOW / DIR_TASKS,
223
+ repo_root / DIR_WORKFLOW / DIR_WORKSPACE,
224
+ ]
225
+ seen: set[Path] = set()
226
+ for root in roots:
227
+ if not root.is_dir():
228
+ continue
229
+ for path in sorted(root.rglob("*.md"), key=lambda item: item.as_posix()):
230
+ if not path.is_file():
231
+ continue
232
+ resolved = path.resolve()
233
+ if resolved in seen:
234
+ continue
235
+ seen.add(resolved)
236
+ yield path
237
+
238
+
239
+ def load_artifact(path: Path, repo_root: Path) -> Artifact | None:
240
+ try:
241
+ content = path.read_text(encoding="utf-8", errors="replace")
242
+ except OSError:
243
+ return None
244
+
245
+ rel_path = to_repo_path(path, repo_root)
246
+ kind = classify_kind(rel_path)
247
+ if kind is None:
248
+ return None
249
+
250
+ frontmatter, body, body_start_line = split_frontmatter(content)
251
+ headings = extract_headings(body, body_start_line)
252
+ title = discover_title(frontmatter, headings)
253
+ category = category_for_kind(kind)
254
+ return Artifact(
255
+ path=path,
256
+ rel_path=rel_path,
257
+ kind=kind,
258
+ category=category,
259
+ frontmatter=frontmatter,
260
+ body=body,
261
+ body_start_line=body_start_line,
262
+ title=title,
263
+ headings=headings,
264
+ )
265
+
266
+
267
+ def to_repo_path(path: Path, repo_root: Path) -> str:
268
+ try:
269
+ return path.resolve().relative_to(repo_root.resolve()).as_posix()
270
+ except ValueError:
271
+ return path.as_posix()
272
+
273
+
274
+ def classify_kind(rel_path: str) -> str | None:
275
+ normalized = rel_path.replace("\\", "/")
276
+ if normalized.startswith(f"{DIR_WORKFLOW}/spec/"):
277
+ return "spec"
278
+ if normalized.startswith(f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/"):
279
+ return "workspace"
280
+ if not normalized.startswith(f"{DIR_WORKFLOW}/{DIR_TASKS}/"):
281
+ return None
282
+
283
+ name = normalized.rsplit("/", 1)[-1]
284
+ if "/research/" in normalized:
285
+ return "task_research"
286
+ if name == "prd.md":
287
+ return "task_prd"
288
+ if name == "design.md":
289
+ return "task_design"
290
+ if name == "implement.md":
291
+ return "task_implement"
292
+ if name == "verify.md":
293
+ return "task_verify"
294
+ if name == "handoff.md":
295
+ return "task_handoff"
296
+ return "task_artifact"
297
+
298
+
299
+ def category_for_kind(kind: str) -> str:
300
+ if kind == "spec":
301
+ return "spec"
302
+ if kind == "workspace":
303
+ return "workspace"
304
+ return "task"
305
+
306
+
307
+ def split_frontmatter(content: str) -> tuple[dict[str, object], str, int]:
308
+ lines = content.splitlines()
309
+ if not lines or lines[0].strip() != "---":
310
+ return {}, content, 1
311
+
312
+ for idx in range(1, len(lines)):
313
+ if lines[idx].strip() == "---":
314
+ frontmatter_lines = lines[1:idx]
315
+ body_lines = lines[idx + 1 :]
316
+ frontmatter = parse_frontmatter(frontmatter_lines)
317
+ return frontmatter, "\n".join(body_lines), idx + 2
318
+
319
+ return {}, content, 1
320
+
321
+
322
+ def parse_frontmatter(lines: list[str]) -> dict[str, object]:
323
+ data: dict[str, object] = {}
324
+ current_list_key: str | None = None
325
+
326
+ for raw_line in lines:
327
+ if not raw_line.strip() or raw_line.lstrip().startswith("#"):
328
+ continue
329
+ stripped = raw_line.strip()
330
+ if stripped.startswith("- "):
331
+ if current_list_key is None:
332
+ continue
333
+ value = unquote(stripped[2:].strip())
334
+ items = data.setdefault(current_list_key, [])
335
+ if isinstance(items, list):
336
+ items.append(value)
337
+ continue
338
+
339
+ if ":" not in stripped:
340
+ current_list_key = None
341
+ continue
342
+
343
+ key, value = stripped.split(":", 1)
344
+ key = key.strip()
345
+ value = strip_inline_comment(value).strip()
346
+ if not key:
347
+ current_list_key = None
348
+ continue
349
+ if value:
350
+ data[key] = unquote(value)
351
+ current_list_key = None
352
+ else:
353
+ data[key] = []
354
+ current_list_key = key
355
+
356
+ return data
357
+
358
+
359
+ def strip_inline_comment(value: str) -> str:
360
+ in_quote: str | None = None
361
+ for idx, char in enumerate(value):
362
+ if in_quote:
363
+ if char == in_quote:
364
+ in_quote = None
365
+ continue
366
+ if char in ("'", '"'):
367
+ in_quote = char
368
+ continue
369
+ if char == "#" and (idx == 0 or value[idx - 1].isspace()):
370
+ return value[:idx]
371
+ return value
372
+
373
+
374
+ def unquote(value: str) -> str:
375
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
376
+ return value[1:-1]
377
+ return value
378
+
379
+
380
+ def extract_headings(body: str, body_start_line: int) -> list[tuple[int, str, str]]:
381
+ headings: list[tuple[int, str, str]] = []
382
+ for offset, line in enumerate(body.splitlines()):
383
+ match = re.match(r"^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$", line)
384
+ if not match:
385
+ continue
386
+ text = normalize_space(match.group(1))
387
+ headings.append((body_start_line + offset, text, slugify_heading(text)))
388
+ return headings
389
+
390
+
391
+ def discover_title(
392
+ frontmatter: dict[str, object],
393
+ headings: list[tuple[int, str, str]],
394
+ ) -> str | None:
395
+ raw_title = frontmatter.get("title")
396
+ if isinstance(raw_title, str) and raw_title.strip():
397
+ return raw_title.strip()
398
+ for _, heading, _ in headings:
399
+ return heading
400
+ return None
401
+
402
+
403
+ def parse_filter(raw: str) -> FilterSpec:
404
+ op = "~=" if "~=" in raw else "="
405
+ if op not in raw:
406
+ raise ValueError(f"invalid filter {raw!r}; expected key=value or key~=value")
407
+ key, value = raw.split(op, 1)
408
+ key = key.strip()
409
+ values = [item.strip() for item in value.split("|") if item.strip()]
410
+ if not key or not values:
411
+ raise ValueError(f"invalid filter {raw!r}; expected key=value or key~=value")
412
+ return FilterSpec(raw=raw, key=key, op=op, values=values)
413
+
414
+
415
+ def matched_filter_fields(
416
+ artifact: Artifact,
417
+ filters: list[FilterSpec],
418
+ ) -> set[str] | None:
419
+ matched: set[str] = set()
420
+ for item in filters:
421
+ raw_value = artifact.frontmatter.get(item.key)
422
+ values = normalized_values(raw_value)
423
+ if not values:
424
+ return None
425
+ if item.op == "=":
426
+ ok = any(value == candidate for value in values for candidate in lower_all(item.values))
427
+ else:
428
+ ok = any(candidate in value for value in values for candidate in lower_all(item.values))
429
+ if not ok:
430
+ return None
431
+ matched.add(f"frontmatter.{item.key}")
432
+ return matched
433
+
434
+
435
+ def score_artifact(artifact: Artifact, tokens: list[str]) -> SearchResult | None:
436
+ if not tokens:
437
+ return SearchResult(artifact=artifact, score=0)
438
+
439
+ field_values = searchable_fields(artifact)
440
+ combined = "\n".join(value for _, value in field_values).lower()
441
+ if any(token not in combined for token in tokens):
442
+ return None
443
+
444
+ result = SearchResult(artifact=artifact, score=0)
445
+ for token in tokens:
446
+ if token in artifact.rel_path.lower():
447
+ result.score += 8
448
+ result.matched_fields.add("path")
449
+ if artifact.title and token in artifact.title.lower():
450
+ result.score += 7
451
+ result.matched_fields.add("title")
452
+ for key, value in artifact.frontmatter.items():
453
+ if any(token in item for item in normalized_values(value)):
454
+ result.score += 6
455
+ result.matched_fields.add(f"frontmatter.{key}")
456
+ if any(token in heading.lower() for _, heading, _ in artifact.headings):
457
+ result.score += 4
458
+ result.matched_fields.add("headings")
459
+ if token in artifact.body.lower():
460
+ result.score += 2
461
+ result.matched_fields.add("body")
462
+
463
+ result.snippets = query_snippets(artifact, tokens)
464
+ return result
465
+
466
+
467
+ def searchable_fields(artifact: Artifact) -> list[tuple[str, str]]:
468
+ frontmatter = " ".join(flatten_frontmatter_values(artifact.frontmatter))
469
+ headings = " ".join(heading for _, heading, _ in artifact.headings)
470
+ return [
471
+ ("path", artifact.rel_path),
472
+ ("title", artifact.title or ""),
473
+ ("frontmatter", frontmatter),
474
+ ("headings", headings),
475
+ ("body", artifact.body),
476
+ ]
477
+
478
+
479
+ def query_snippets(artifact: Artifact, tokens: list[str]) -> list[Snippet]:
480
+ snippets: list[Snippet] = []
481
+ seen_lines: set[int] = set()
482
+ current_anchor: str | None = None
483
+ heading_by_line = {line: anchor for line, _, anchor in artifact.headings}
484
+
485
+ for offset, raw_line in enumerate(artifact.body.splitlines()):
486
+ line_number = artifact.body_start_line + offset
487
+ if line_number in heading_by_line:
488
+ current_anchor = heading_by_line[line_number]
489
+ line = normalize_space(raw_line)
490
+ if not line:
491
+ continue
492
+ lower = line.lower()
493
+ if not any(token in lower for token in tokens):
494
+ continue
495
+ if line_number in seen_lines:
496
+ continue
497
+ snippets.append(Snippet(line=line_number, text=line, anchor=current_anchor))
498
+ seen_lines.add(line_number)
499
+ if len(snippets) >= MAX_SNIPPETS:
500
+ break
501
+
502
+ return snippets
503
+
504
+
505
+ def fallback_snippets(artifact: Artifact) -> list[Snippet]:
506
+ current_anchor: str | None = None
507
+ heading_by_line = {line: anchor for line, _, anchor in artifact.headings}
508
+ for offset, raw_line in enumerate(artifact.body.splitlines()):
509
+ line_number = artifact.body_start_line + offset
510
+ if line_number in heading_by_line:
511
+ current_anchor = heading_by_line[line_number]
512
+ text = normalize_space(raw_line)
513
+ if text:
514
+ return [Snippet(line=line_number, text=text, anchor=current_anchor)]
515
+ return []
516
+
517
+
518
+ def tokenize(query: str) -> list[str]:
519
+ return [token.lower() for token in re.findall(r"\S+", query) if token.strip()]
520
+
521
+
522
+ def lower_all(values: Iterable[str]) -> list[str]:
523
+ return [value.lower() for value in values]
524
+
525
+
526
+ def normalized_values(value: object) -> list[str]:
527
+ if isinstance(value, list):
528
+ return [str(item).lower() for item in value]
529
+ if value is None:
530
+ return []
531
+ return [str(value).lower()]
532
+
533
+
534
+ def flatten_frontmatter_values(frontmatter: dict[str, object]) -> list[str]:
535
+ values: list[str] = []
536
+ for key, value in frontmatter.items():
537
+ values.append(key)
538
+ if isinstance(value, list):
539
+ values.extend(str(item) for item in value)
540
+ elif value is not None:
541
+ values.append(str(value))
542
+ return values
543
+
544
+
545
+ def normalize_space(value: str) -> str:
546
+ return re.sub(r"\s+", " ", value).strip()
547
+
548
+
549
+ def slugify_heading(value: str) -> str:
550
+ slug = re.sub(r"[^a-z0-9\s-]", "", value.lower())
551
+ return re.sub(r"[\s-]+", "-", slug).strip("-")
552
+
553
+
554
+ def print_human(payload: dict[str, object]) -> None:
555
+ total = int(payload["total"])
556
+ noun = "artifact" if total == 1 else "artifacts"
557
+ print(f"Found {total} {noun}")
558
+ print()
559
+
560
+ results = payload["results"]
561
+ if not isinstance(results, list):
562
+ return
563
+
564
+ for idx, result in enumerate(results, start=1):
565
+ if not isinstance(result, dict):
566
+ continue
567
+ print(
568
+ f"{idx}. {result['path']} [{result['kind']}] "
569
+ f"score={result['score']}"
570
+ )
571
+ title = result.get("title")
572
+ if title:
573
+ print(f" title: {title}")
574
+ matched = result.get("matched_fields")
575
+ if isinstance(matched, list) and matched:
576
+ print(f" matched: {', '.join(str(item) for item in matched)}")
577
+ snippets = result.get("snippets")
578
+ if isinstance(snippets, list):
579
+ for snippet in snippets:
580
+ if not isinstance(snippet, dict):
581
+ continue
582
+ print(f" L{snippet.get('line')}: {snippet.get('text')}")
583
+
584
+
585
+ if __name__ == "__main__":
586
+ sys.exit(main())
587
+