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