@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.
- package/CHANGELOG.md +30 -0
- package/README.md +51 -10
- package/README.zh-CN.md +21 -7
- package/bin/cstl.js +1 -1
- package/bin/smart-search.js +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +17 -15
- package/dist/cli/index.js.map +1 -1
- package/dist/commands/capability-smoke.js +2 -2
- package/dist/commands/capability-smoke.js.map +1 -1
- package/dist/commands/channel/agent-loader.d.ts +1 -1
- package/dist/commands/channel/agent-loader.js +4 -4
- package/dist/commands/channel/agent-loader.js.map +1 -1
- package/dist/commands/channel/guard.d.ts +2 -2
- package/dist/commands/channel/guard.js +3 -3
- package/dist/commands/channel/guard.js.map +1 -1
- package/dist/commands/channel/index.js +3 -3
- package/dist/commands/channel/index.js.map +1 -1
- package/dist/commands/channel/list.d.ts +1 -1
- package/dist/commands/channel/list.js +1 -1
- package/dist/commands/channel/rm.d.ts +1 -1
- package/dist/commands/channel/rm.js +1 -1
- package/dist/commands/channel/store/lock.d.ts +1 -1
- package/dist/commands/channel/store/lock.js +1 -1
- package/dist/commands/channel/store/paths.js +1 -1
- package/dist/commands/channel/store/paths.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +105 -59
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/uninstall.d.ts +3 -3
- package/dist/commands/uninstall.js +10 -10
- package/dist/commands/uninstall.js.map +1 -1
- package/dist/commands/update.d.ts +6 -1
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +83 -28
- package/dist/commands/update.js.map +1 -1
- package/dist/commands/workflow.d.ts +4 -4
- package/dist/commands/workflow.d.ts.map +1 -1
- package/dist/commands/workflow.js +10 -9
- package/dist/commands/workflow.js.map +1 -1
- package/dist/configurators/index.d.ts.map +1 -1
- package/dist/configurators/index.js +1 -1
- package/dist/configurators/index.js.map +1 -1
- package/dist/configurators/shared.js +6 -6
- package/dist/configurators/shared.js.map +1 -1
- package/dist/configurators/workflow.d.ts +4 -4
- package/dist/configurators/workflow.js +1 -1
- package/dist/constants/paths.d.ts +24 -24
- package/dist/constants/paths.d.ts.map +1 -1
- package/dist/constants/paths.js +16 -16
- package/dist/constants/paths.js.map +1 -1
- package/dist/migrations/manifests/0.3.1.json +32 -0
- package/dist/templates/common/bundled-skills/cstl-cursor2plus-setup/SKILL.md +11 -11
- package/dist/templates/common/bundled-skills/cstl-meta/SKILL.md +15 -15
- package/dist/templates/common/bundled-skills/cstl-meta/references/customize-local/add-project-local-conventions.md +12 -12
- package/dist/templates/common/bundled-skills/cstl-meta/references/customize-local/change-agents.md +2 -2
- package/dist/templates/common/bundled-skills/cstl-meta/references/customize-local/change-context-loading.md +21 -21
- package/dist/templates/common/bundled-skills/cstl-meta/references/customize-local/change-hooks.md +9 -9
- package/dist/templates/common/bundled-skills/cstl-meta/references/customize-local/change-skills-or-commands.md +7 -7
- package/dist/templates/common/bundled-skills/cstl-meta/references/customize-local/change-spec-structure.md +15 -15
- package/dist/templates/common/bundled-skills/cstl-meta/references/customize-local/change-task-lifecycle.md +27 -27
- package/dist/templates/common/bundled-skills/cstl-meta/references/customize-local/change-workflow.md +7 -7
- package/dist/templates/common/bundled-skills/cstl-meta/references/customize-local/overview.md +11 -11
- package/dist/templates/common/bundled-skills/cstl-meta/references/local-architecture/context-injection.md +12 -12
- package/dist/templates/common/bundled-skills/cstl-meta/references/local-architecture/generated-files.md +21 -21
- package/dist/templates/common/bundled-skills/cstl-meta/references/local-architecture/overview.md +14 -14
- package/dist/templates/common/bundled-skills/cstl-meta/references/local-architecture/spec-system.md +12 -12
- package/dist/templates/common/bundled-skills/cstl-meta/references/local-architecture/task-system.md +23 -23
- package/dist/templates/common/bundled-skills/cstl-meta/references/local-architecture/workflow.md +3 -3
- package/dist/templates/common/bundled-skills/cstl-meta/references/local-architecture/workspace-memory.md +16 -16
- package/dist/templates/common/bundled-skills/cstl-meta/references/platform-files/agents.md +2 -2
- package/dist/templates/common/bundled-skills/cstl-meta/references/platform-files/hooks-and-settings.md +6 -6
- package/dist/templates/common/bundled-skills/cstl-meta/references/platform-files/overview.md +5 -5
- package/dist/templates/common/bundled-skills/cstl-meta/references/platform-files/platform-map.md +3 -3
- package/dist/templates/common/bundled-skills/cstl-meta/references/platform-files/skills-and-commands.md +5 -5
- package/dist/templates/common/bundled-skills/cstl-skill-creator/SKILL.md +2 -2
- package/dist/templates/common/bundled-skills/cstl-skill-creator/references/review-checklist.md +1 -1
- package/dist/templates/common/bundled-skills/cstl-spec-bootstrap/SKILL.md +5 -5
- package/dist/templates/common/bundled-skills/cstl-spec-bootstrap/references/repository-analysis.md +1 -1
- package/dist/templates/common/bundled-skills/cstl-spec-bootstrap/references/spec-task-planning.md +3 -3
- package/dist/templates/common/bundled-skills/cstl-spec-bootstrap/references/spec-writing.md +1 -1
- package/dist/templates/common/commands/continue.md +6 -6
- package/dist/templates/common/commands/finish-work.md +5 -5
- package/dist/templates/common/commands/start.md +8 -8
- package/dist/templates/common/skills/before-dev.md +4 -4
- package/dist/templates/common/skills/brainstorm.md +5 -5
- package/dist/templates/common/skills/break-loop.md +3 -3
- package/dist/templates/common/skills/check.md +5 -5
- package/dist/templates/common/skills/update-spec.md +4 -4
- package/dist/templates/cursor/agents/cstl-check.md +6 -6
- package/dist/templates/cursor/agents/cstl-implement.md +7 -7
- package/dist/templates/cursor/agents/cstl-research.md +9 -9
- package/dist/templates/cursor/commands/cursor2plus-setup.md +10 -10
- package/dist/templates/cursor/rules/cstl-subagent-dispatch.mdc +2 -2
- package/dist/templates/cursor/rules/cstl-triage.mdc +2 -2
- package/dist/templates/cursor/rules/retrieval-routing.mdc +4 -4
- package/dist/templates/extract.d.ts +1 -1
- package/dist/templates/extract.js +1 -1
- package/dist/templates/markdown/agents.md +11 -11
- package/dist/templates/markdown/prompts/run-semantic-slice-12.md.txt +1 -1
- package/dist/templates/markdown/spec/guides/cross-platform-thinking-guide.md.txt +5 -5
- package/dist/templates/markdown/spec/guides/cursor-context-injection-guide.md.txt +3 -3
- package/dist/templates/markdown/spec/guides/cursor-semantic-compliance.md.txt +1 -1
- package/dist/templates/markdown/spec/guides/cursor-subagent-policy.md.txt +11 -11
- package/dist/templates/markdown/spec/guides/durable-learning-decision-guide.md.txt +8 -8
- package/dist/templates/markdown/spec/guides/execution-strategy.md.txt +3 -3
- package/dist/templates/markdown/spec/guides/retrieval-daily-guide.md.txt +10 -10
- package/dist/templates/markdown/workspace-index.md +3 -3
- package/dist/templates/markdown/worktree.yaml.txt +1 -1
- package/dist/templates/shared-hooks/index.d.ts +1 -1
- package/dist/templates/shared-hooks/index.js +1 -1
- package/dist/templates/shared-hooks/inject-retrieval-plan.py +3 -3
- package/dist/templates/shared-hooks/inject-shell-session-context.py +7 -7
- package/dist/templates/shared-hooks/inject-subagent-context.py +1 -1
- package/dist/templates/shared-hooks/inject-workflow-state.py +11 -11
- package/dist/templates/shared-hooks/research-end-retrieval-pack.py +1 -1
- package/dist/templates/shared-hooks/session-start.py +13 -13
- package/dist/templates/trellis/config.yaml +2 -22
- package/dist/templates/trellis/index.d.ts +2 -2
- package/dist/templates/trellis/index.js +2 -2
- package/dist/templates/trellis/local/README.md +3 -3
- package/dist/templates/trellis/local/patch_wpelc8.py +4 -4
- package/dist/templates/trellis/scripts/add_session.py +20 -20
- package/dist/templates/trellis/scripts/common/active_task.py +2 -2
- package/dist/templates/trellis/scripts/common/artifact_search.py +587 -587
- package/dist/templates/trellis/scripts/common/codebase_retrieval_router.py +6 -6
- package/dist/templates/trellis/scripts/common/config.py +3 -3
- package/dist/templates/trellis/scripts/common/developer.py +2 -2
- package/dist/templates/trellis/scripts/common/packages_context.py +23 -23
- package/dist/templates/trellis/scripts/common/parent_orchestration.py +16 -16
- package/dist/templates/trellis/scripts/common/paths.py +107 -107
- package/dist/templates/trellis/scripts/common/project_file_stats.py +1 -1
- package/dist/templates/trellis/scripts/common/retrieval_agent_instructions.py +1 -1
- package/dist/templates/trellis/scripts/common/safe_commit.py +31 -31
- package/dist/templates/trellis/scripts/common/session_memory.py +360 -360
- package/dist/templates/trellis/scripts/common/smart_search_evidence.py +514 -514
- package/dist/templates/trellis/scripts/common/smart_search_resolve.py +1 -1
- package/dist/templates/trellis/scripts/common/subagent_dispatch.py +2 -2
- package/dist/templates/trellis/scripts/common/task_context.py +1 -1
- package/dist/templates/trellis/scripts/common/task_dashboard.py +140 -140
- package/dist/templates/trellis/scripts/common/task_gates.py +23 -23
- package/dist/templates/trellis/scripts/common/task_store.py +5 -5
- package/dist/templates/trellis/scripts/common/task_utils.py +3 -3
- package/dist/templates/trellis/scripts/common/tasks.py +1 -1
- package/dist/templates/trellis/scripts/common/trellis_config.py +3 -3
- package/dist/templates/trellis/scripts/common/workflow_phase.py +1 -1
- package/dist/templates/trellis/scripts/cursor_retrieval_probe.py +3 -3
- package/dist/templates/trellis/scripts/cursor_retrieval_probe_prompt.md +11 -11
- package/dist/templates/trellis/scripts/hooks/linear_sync.py +8 -8
- package/dist/templates/trellis/scripts/init_developer.py +2 -2
- package/dist/templates/trellis/scripts/retrieval_probe_matrix_template.json +4 -4
- package/dist/templates/trellis/scripts/route_codebase_retrieval.py +1 -1
- package/dist/templates/trellis/scripts/task.py +918 -918
- package/dist/templates/trellis/tasks/templates/release-readiness/implement.md +2 -2
- package/dist/templates/trellis/workflow.md +64 -64
- package/dist/types/migration.d.ts +1 -1
- package/dist/types/migration.d.ts.map +1 -1
- package/dist/utils/agents-md.d.ts +37 -0
- package/dist/utils/agents-md.d.ts.map +1 -0
- package/dist/utils/agents-md.js +82 -0
- package/dist/utils/agents-md.js.map +1 -0
- package/dist/utils/codebase-retrieval-router.js +5 -5
- package/dist/utils/codebase-retrieval-router.js.map +1 -1
- package/dist/utils/manifest-prune.d.ts +2 -2
- package/dist/utils/manifest-prune.js +4 -4
- package/dist/utils/manifest-prune.js.map +1 -1
- package/dist/utils/mirror-check.d.ts +3 -1
- package/dist/utils/mirror-check.d.ts.map +1 -1
- package/dist/utils/mirror-check.js +22 -11
- package/dist/utils/mirror-check.js.map +1 -1
- package/dist/utils/project-capabilities.js +5 -5
- package/dist/utils/project-capabilities.js.map +1 -1
- package/dist/utils/retrieval-agent-instructions.js +1 -1
- package/dist/utils/retrieval-agent-instructions.js.map +1 -1
- package/dist/utils/retrieval-execution-telemetry.js +1 -1
- package/dist/utils/retrieval-execution-telemetry.js.map +1 -1
- package/dist/utils/template-fetcher.js +1 -1
- package/dist/utils/template-fetcher.js.map +1 -1
- package/dist/utils/template-hash.d.ts +2 -2
- package/dist/utils/template-hash.d.ts.map +1 -1
- package/dist/utils/template-hash.js +12 -10
- package/dist/utils/template-hash.js.map +1 -1
- package/dist/utils/workflow-dir.d.ts +13 -0
- package/dist/utils/workflow-dir.d.ts.map +1 -0
- package/dist/utils/workflow-dir.js +35 -0
- package/dist/utils/workflow-dir.js.map +1 -0
- package/dist/utils/workflow-ownership.d.ts +36 -0
- package/dist/utils/workflow-ownership.d.ts.map +1 -0
- package/dist/utils/workflow-ownership.js +137 -0
- package/dist/utils/workflow-ownership.js.map +1 -0
- package/dist/utils/workflow-resolver.d.ts +1 -1
- package/dist/utils/workflow-resolver.js +1 -1
- 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 .
|
|
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 .
|
|
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
|
+
|