@blxzer/cursor-trellis 0.2.9 → 0.3.0
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 +64 -0
- package/README.md +37 -12
- package/README.zh-CN.md +41 -16
- package/bin/{trellis.js → cstl.js} +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +30 -4
- 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/init.d.ts.map +1 -1
- package/dist/commands/init.js +94 -34
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/uninstall.d.ts +1 -1
- package/dist/commands/uninstall.js +2 -2
- package/dist/commands/uninstall.js.map +1 -1
- package/dist/commands/update.d.ts +1 -1
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +79 -34
- 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/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.js +3 -3
- 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 +26 -26
- package/dist/configurators/shared.js.map +1 -1
- package/dist/configurators/workflow.d.ts +2 -2
- package/dist/migrations/manifests/0.2.10.json +374 -0
- package/dist/migrations/manifests/0.3.0.json +89 -0
- package/dist/templates/common/bundled-skills/{trellis-cursor2plus-setup → cstl-cursor2plus-setup}/SKILL.md +5 -4
- 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 +8 -8
- package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/customize-local/change-agents.md +6 -6
- package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/customize-local/change-context-loading.md +7 -7
- package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/customize-local/change-skills-or-commands.md +1 -1
- package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/customize-local/change-spec-structure.md +2 -2
- package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/customize-local/change-task-lifecycle.md +31 -31
- package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/customize-local/change-workflow.md +12 -12
- 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 +13 -13
- package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/local-architecture/generated-files.md +83 -83
- package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/local-architecture/overview.md +2 -2
- package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/local-architecture/spec-system.md +1 -1
- package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/local-architecture/task-system.md +22 -22
- package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/local-architecture/workflow.md +5 -5
- package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/local-architecture/workspace-memory.md +3 -3
- package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/platform-files/agents.md +11 -11
- package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/platform-files/hooks-and-settings.md +1 -1
- package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/platform-files/overview.md +4 -4
- package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/platform-files/platform-map.md +5 -5
- package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/platform-files/skills-and-commands.md +15 -15
- 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 +1 -1
- package/dist/templates/common/commands/continue.md +20 -20
- package/dist/templates/common/commands/start.md +11 -11
- package/dist/templates/common/index.d.ts +1 -1
- package/dist/templates/common/index.d.ts.map +1 -1
- package/dist/templates/common/skills/brainstorm.md +188 -164
- package/dist/templates/common/skills/check.md +1 -1
- package/dist/templates/cursor/agents/{trellis-check.md → cstl-check.md} +8 -8
- package/dist/templates/cursor/agents/{trellis-implement.md → cstl-implement.md} +8 -8
- package/dist/templates/cursor/agents/{trellis-research.md → cstl-research.md} +2 -2
- package/dist/templates/cursor/commands/cursor2plus-setup.md +5 -5
- 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} +2 -2
- package/dist/templates/cursor/rules/{trellis-triage.mdc → cstl-triage.mdc} +1 -1
- package/dist/templates/markdown/agents.md +4 -4
- 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 +4 -4
- package/dist/templates/markdown/spec/guides/cursor-semantic-compliance.md.txt +6 -0
- 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 +6 -6
- package/dist/templates/markdown/spec/guides/execution-strategy.md.txt +2 -2
- package/dist/templates/markdown/spec/guides/index.md.txt +3 -2
- 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-subagent-context.py +3 -3
- package/dist/templates/shared-hooks/inject-workflow-state.py +27 -27
- package/dist/templates/shared-hooks/research-end-retrieval-pack.py +1 -1
- package/dist/templates/shared-hooks/session-start.py +3 -3
- package/dist/templates/trellis/config/execution-strategy-rules.json +1 -1
- package/dist/templates/trellis/config.yaml +4 -4
- package/dist/templates/trellis/local/README.md +12 -10
- package/dist/templates/trellis/local/patch_wpelc8.py +3 -3
- 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/common/cli_adapter.py +3 -3
- package/dist/templates/trellis/scripts/common/execution_strategy.py +1 -1
- package/dist/templates/trellis/scripts/common/parent_orchestration.py +702 -702
- package/dist/templates/trellis/scripts/common/session_context.py +1159 -1159
- package/dist/templates/trellis/scripts/common/subagent_dispatch.py +5 -5
- 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/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/utils/project-capabilities.d.ts +14 -3
- package/dist/utils/project-capabilities.d.ts.map +1 -1
- package/dist/utils/project-capabilities.js +122 -12
- 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/package.json +3 -4
- /package/dist/templates/common/bundled-skills/{trellis-meta → cstl-meta}/references/customize-local/change-hooks.md +0 -0
- /package/dist/templates/common/bundled-skills/{trellis-spec-bootstrap → cstl-spec-bootstrap}/references/mcp-setup.md +0 -0
- /package/dist/templates/common/bundled-skills/{trellis-spec-bootstrap → cstl-spec-bootstrap}/references/repository-analysis.md +0 -0
- /package/dist/templates/common/bundled-skills/{trellis-spec-bootstrap → cstl-spec-bootstrap}/references/spec-task-planning.md +0 -0
- /package/dist/templates/common/bundled-skills/{trellis-spec-bootstrap → cstl-spec-bootstrap}/references/spec-writing.md +0 -0
|
@@ -1,1159 +1,1159 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
Session context generation (default + record modes).
|
|
4
|
-
|
|
5
|
-
Provides:
|
|
6
|
-
get_context_json - JSON output for default mode
|
|
7
|
-
get_context_text - Text output for default mode
|
|
8
|
-
get_context_record_json - JSON for record mode
|
|
9
|
-
get_context_text_record - Text for record mode
|
|
10
|
-
output_json - Print JSON
|
|
11
|
-
output_text - Print text
|
|
12
|
-
"""
|
|
13
|
-
|
|
14
|
-
from __future__ import annotations
|
|
15
|
-
|
|
16
|
-
import json
|
|
17
|
-
import os
|
|
18
|
-
import re
|
|
19
|
-
import subprocess
|
|
20
|
-
from pathlib import Path
|
|
21
|
-
|
|
22
|
-
from .active_task import resolve_context_key
|
|
23
|
-
from .config import get_git_packages
|
|
24
|
-
from .git import run_git
|
|
25
|
-
from .packages_context import get_packages_section
|
|
26
|
-
from .tasks import iter_active_tasks, load_task, get_all_statuses, children_progress
|
|
27
|
-
from .task_dashboard import render_task_dashboard
|
|
28
|
-
from .paths import (
|
|
29
|
-
DIR_SCRIPTS,
|
|
30
|
-
DIR_SPEC,
|
|
31
|
-
DIR_TASKS,
|
|
32
|
-
DIR_WORKFLOW,
|
|
33
|
-
DIR_WORKSPACE,
|
|
34
|
-
count_lines,
|
|
35
|
-
get_active_journal_file,
|
|
36
|
-
get_selected_task,
|
|
37
|
-
get_selected_task_source,
|
|
38
|
-
get_developer,
|
|
39
|
-
get_repo_root,
|
|
40
|
-
get_tasks_dir,
|
|
41
|
-
)
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
# =============================================================================
|
|
45
|
-
# Helpers
|
|
46
|
-
# =============================================================================
|
|
47
|
-
|
|
48
|
-
_PACKAGE_NAME = "@blxzer/cursor-trellis"
|
|
49
|
-
_PYTHON_CMD = "python3"
|
|
50
|
-
_UPDATE_CHECK_TIMEOUT_SECONDS = 1.0
|
|
51
|
-
_VERSION_RE = re.compile(
|
|
52
|
-
r"^\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?\s*$"
|
|
53
|
-
)
|
|
54
|
-
_VERSION_TOKEN_RE = re.compile(r"\b\d+(?:\.\d+){1,2}(?:-[0-9A-Za-z.-]+)?\b")
|
|
55
|
-
_POLYREPO_IGNORED_DIRS = {
|
|
56
|
-
"node_modules",
|
|
57
|
-
"target",
|
|
58
|
-
"dist",
|
|
59
|
-
"build",
|
|
60
|
-
"out",
|
|
61
|
-
"bin",
|
|
62
|
-
"obj",
|
|
63
|
-
"vendor",
|
|
64
|
-
"coverage",
|
|
65
|
-
"tmp",
|
|
66
|
-
"__pycache__",
|
|
67
|
-
}
|
|
68
|
-
_POLYREPO_SCAN_MAX_DEPTH = 2
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def _is_git_worktree(path: Path) -> bool:
|
|
72
|
-
"""Return True when path is inside a Git worktree."""
|
|
73
|
-
rc, out, _ = run_git(["rev-parse", "--is-inside-work-tree"], cwd=path)
|
|
74
|
-
return rc == 0 and out.strip().lower() == "true"
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def _parse_recent_commits(log_output: str) -> list[dict]:
|
|
78
|
-
"""Parse `git log --oneline` output into structured commit entries."""
|
|
79
|
-
commits = []
|
|
80
|
-
for line in log_output.splitlines():
|
|
81
|
-
if not line.strip():
|
|
82
|
-
continue
|
|
83
|
-
parts = line.split(" ", 1)
|
|
84
|
-
if len(parts) >= 2:
|
|
85
|
-
commits.append({"hash": parts[0], "message": parts[1]})
|
|
86
|
-
elif len(parts) == 1:
|
|
87
|
-
commits.append({"hash": parts[0], "message": ""})
|
|
88
|
-
return commits
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
def _collect_git_repo_info(name: str, rel_path: str, repo_dir: Path) -> dict | None:
|
|
92
|
-
"""Collect Git status for one known repository directory."""
|
|
93
|
-
if not (repo_dir / ".git").exists():
|
|
94
|
-
return None
|
|
95
|
-
|
|
96
|
-
_, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_dir)
|
|
97
|
-
branch = branch_out.strip() or "unknown"
|
|
98
|
-
|
|
99
|
-
_, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_dir)
|
|
100
|
-
changes = len([l for l in status_out.splitlines() if l.strip()])
|
|
101
|
-
|
|
102
|
-
_, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_dir)
|
|
103
|
-
|
|
104
|
-
return {
|
|
105
|
-
"name": name,
|
|
106
|
-
"path": rel_path,
|
|
107
|
-
"branch": branch,
|
|
108
|
-
"isClean": changes == 0,
|
|
109
|
-
"uncommittedChanges": changes,
|
|
110
|
-
"recentCommits": _parse_recent_commits(log_out),
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
def _collect_root_git_info(repo_root: Path) -> dict:
|
|
115
|
-
"""Collect root Git info without pretending a non-Git root is clean."""
|
|
116
|
-
if not _is_git_worktree(repo_root):
|
|
117
|
-
return {
|
|
118
|
-
"isRepo": False,
|
|
119
|
-
"branch": "",
|
|
120
|
-
"isClean": False,
|
|
121
|
-
"uncommittedChanges": 0,
|
|
122
|
-
"recentCommits": [],
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
_, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root)
|
|
126
|
-
branch = branch_out.strip() or "unknown"
|
|
127
|
-
|
|
128
|
-
_, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_root)
|
|
129
|
-
status_lines = [line for line in status_out.splitlines() if line.strip()]
|
|
130
|
-
|
|
131
|
-
_, short_out, _ = run_git(["status", "--short"], cwd=repo_root)
|
|
132
|
-
|
|
133
|
-
_, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_root)
|
|
134
|
-
|
|
135
|
-
return {
|
|
136
|
-
"isRepo": True,
|
|
137
|
-
"branch": branch,
|
|
138
|
-
"isClean": len(status_lines) == 0,
|
|
139
|
-
"uncommittedChanges": len(status_lines),
|
|
140
|
-
"statusShort": short_out.splitlines(),
|
|
141
|
-
"recentCommits": _parse_recent_commits(log_out),
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
def _discover_child_git_repos(repo_root: Path) -> list[tuple[str, str]]:
|
|
146
|
-
"""Discover child Git repositories using the init-time polyrepo heuristic."""
|
|
147
|
-
found: list[str] = []
|
|
148
|
-
|
|
149
|
-
def is_candidate_dir(path: Path) -> bool:
|
|
150
|
-
name = path.name
|
|
151
|
-
return not name.startswith(".") and name not in _POLYREPO_IGNORED_DIRS
|
|
152
|
-
|
|
153
|
-
def scan(rel_dir: Path, depth: int) -> None:
|
|
154
|
-
if depth >= _POLYREPO_SCAN_MAX_DEPTH:
|
|
155
|
-
return
|
|
156
|
-
abs_dir = repo_root / rel_dir
|
|
157
|
-
try:
|
|
158
|
-
children = sorted(abs_dir.iterdir(), key=lambda p: p.name)
|
|
159
|
-
except OSError:
|
|
160
|
-
return
|
|
161
|
-
|
|
162
|
-
for child in children:
|
|
163
|
-
if not child.is_dir() or not is_candidate_dir(child):
|
|
164
|
-
continue
|
|
165
|
-
|
|
166
|
-
child_rel = (
|
|
167
|
-
rel_dir / child.name if rel_dir != Path(".") else Path(child.name)
|
|
168
|
-
)
|
|
169
|
-
if (child / ".git").exists():
|
|
170
|
-
found.append(child_rel.as_posix())
|
|
171
|
-
continue
|
|
172
|
-
scan(child_rel, depth + 1)
|
|
173
|
-
|
|
174
|
-
scan(Path("."), 0)
|
|
175
|
-
if len(found) < 2:
|
|
176
|
-
return []
|
|
177
|
-
return [(path.replace("/", "_"), path) for path in sorted(found)]
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
def _collect_package_git_info(
|
|
181
|
-
repo_root: Path,
|
|
182
|
-
discover_unconfigured: bool = False,
|
|
183
|
-
) -> list[dict]:
|
|
184
|
-
"""Collect Git status for independent package repositories.
|
|
185
|
-
|
|
186
|
-
Packages marked with ``git: true`` in config.yaml are authoritative.
|
|
187
|
-
When the Trellis root is not a Git repo and no configured package repos are
|
|
188
|
-
available, optionally fall back to the bounded polyrepo child scan.
|
|
189
|
-
|
|
190
|
-
Returns:
|
|
191
|
-
List of dicts with keys: name, path, branch, isClean,
|
|
192
|
-
uncommittedChanges, recentCommits.
|
|
193
|
-
Empty list if no git-repo packages are configured.
|
|
194
|
-
"""
|
|
195
|
-
git_pkgs = get_git_packages(repo_root)
|
|
196
|
-
result = []
|
|
197
|
-
for pkg_name, pkg_path in git_pkgs.items():
|
|
198
|
-
pkg_dir = repo_root / pkg_path
|
|
199
|
-
info = _collect_git_repo_info(pkg_name, pkg_path, pkg_dir)
|
|
200
|
-
if info is not None:
|
|
201
|
-
result.append(info)
|
|
202
|
-
|
|
203
|
-
if result or not discover_unconfigured:
|
|
204
|
-
return result
|
|
205
|
-
|
|
206
|
-
discovered = []
|
|
207
|
-
for pkg_name, pkg_path in _discover_child_git_repos(repo_root):
|
|
208
|
-
info = _collect_git_repo_info(pkg_name, pkg_path, repo_root / pkg_path)
|
|
209
|
-
if info is not None:
|
|
210
|
-
discovered.append(info)
|
|
211
|
-
return discovered
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
def _append_root_git_context(lines: list[str], root_git_info: dict) -> None:
|
|
215
|
-
"""Append root Git status without misleading non-Git roots."""
|
|
216
|
-
lines.append("## GIT STATUS")
|
|
217
|
-
if not root_git_info["isRepo"]:
|
|
218
|
-
lines.append("Root is not a Git repository.")
|
|
219
|
-
lines.append("Run Git commands from the package repository paths listed below.")
|
|
220
|
-
else:
|
|
221
|
-
lines.append(f"Branch: {root_git_info['branch']}")
|
|
222
|
-
if root_git_info["isClean"]:
|
|
223
|
-
lines.append("Working directory: Clean")
|
|
224
|
-
else:
|
|
225
|
-
lines.append(
|
|
226
|
-
f"Working directory: {root_git_info['uncommittedChanges']} "
|
|
227
|
-
"uncommitted change(s)"
|
|
228
|
-
)
|
|
229
|
-
lines.append("")
|
|
230
|
-
lines.append("Changes:")
|
|
231
|
-
for line in root_git_info.get("statusShort", [])[:10]:
|
|
232
|
-
lines.append(line)
|
|
233
|
-
lines.append("")
|
|
234
|
-
|
|
235
|
-
lines.append("## RECENT COMMITS")
|
|
236
|
-
if not root_git_info["isRepo"]:
|
|
237
|
-
lines.append(
|
|
238
|
-
"Root has no Git commit history because it is not a Git repository."
|
|
239
|
-
)
|
|
240
|
-
elif root_git_info["recentCommits"]:
|
|
241
|
-
for commit in root_git_info["recentCommits"]:
|
|
242
|
-
lines.append(f"{commit['hash']} {commit['message']}")
|
|
243
|
-
else:
|
|
244
|
-
lines.append("(no commits)")
|
|
245
|
-
lines.append("")
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
def _append_package_git_context(lines: list[str], package_git_info: list[dict]) -> None:
|
|
249
|
-
"""Append Git status and recent commits for package repositories."""
|
|
250
|
-
for pkg in package_git_info:
|
|
251
|
-
lines.append(f"## GIT STATUS ({pkg['name']}: {pkg['path']})")
|
|
252
|
-
lines.append(f"Branch: {pkg['branch']}")
|
|
253
|
-
if pkg["isClean"]:
|
|
254
|
-
lines.append("Working directory: Clean")
|
|
255
|
-
else:
|
|
256
|
-
lines.append(
|
|
257
|
-
f"Working directory: {pkg['uncommittedChanges']} uncommitted change(s)"
|
|
258
|
-
)
|
|
259
|
-
lines.append("")
|
|
260
|
-
lines.append(f"## RECENT COMMITS ({pkg['name']}: {pkg['path']})")
|
|
261
|
-
if pkg["recentCommits"]:
|
|
262
|
-
for commit in pkg["recentCommits"]:
|
|
263
|
-
lines.append(f"{commit['hash']} {commit['message']}")
|
|
264
|
-
else:
|
|
265
|
-
lines.append("(no commits)")
|
|
266
|
-
lines.append("")
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
def _read_project_version(repo_root: Path) -> str | None:
|
|
270
|
-
try:
|
|
271
|
-
version = (repo_root / DIR_WORKFLOW / ".version").read_text(
|
|
272
|
-
encoding="utf-8"
|
|
273
|
-
).strip()
|
|
274
|
-
except OSError:
|
|
275
|
-
return None
|
|
276
|
-
return version or None
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
def _fetch_trellis_version_output() -> str | None:
|
|
280
|
-
try:
|
|
281
|
-
result = subprocess.run(
|
|
282
|
-
["
|
|
283
|
-
capture_output=True,
|
|
284
|
-
text=True,
|
|
285
|
-
encoding="utf-8",
|
|
286
|
-
errors="replace",
|
|
287
|
-
timeout=_UPDATE_CHECK_TIMEOUT_SECONDS,
|
|
288
|
-
)
|
|
289
|
-
except (OSError, subprocess.SubprocessError, TimeoutError):
|
|
290
|
-
return None
|
|
291
|
-
|
|
292
|
-
if result.returncode != 0:
|
|
293
|
-
return None
|
|
294
|
-
output = f"{result.stdout}\n{result.stderr}".strip()
|
|
295
|
-
return output or None
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
def _extract_available_update_version(output: str) -> str | None:
|
|
299
|
-
update_match = re.search(
|
|
300
|
-
r"Trellis update available:\s*"
|
|
301
|
-
r"(?P<current>\S+)\s*(?:→|->)\s*(?P<latest>\S+)",
|
|
302
|
-
output,
|
|
303
|
-
)
|
|
304
|
-
if update_match:
|
|
305
|
-
return update_match.group("latest").strip()
|
|
306
|
-
candidates = _VERSION_TOKEN_RE.findall(output)
|
|
307
|
-
return candidates[-1] if candidates else None
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
def _resolve_available_update_version() -> str | None:
|
|
311
|
-
output = _fetch_trellis_version_output()
|
|
312
|
-
if not output:
|
|
313
|
-
return None
|
|
314
|
-
return _extract_available_update_version(output)
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
def _parse_version(version: str) -> tuple[tuple[int, int, int], tuple[str, ...] | None] | None:
|
|
318
|
-
match = _VERSION_RE.match(version)
|
|
319
|
-
if not match:
|
|
320
|
-
return None
|
|
321
|
-
major, minor, patch, prerelease = match.groups()
|
|
322
|
-
numbers = (int(major), int(minor or "0"), int(patch or "0"))
|
|
323
|
-
prerelease_parts = tuple(prerelease.split(".")) if prerelease else None
|
|
324
|
-
return numbers, prerelease_parts
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
def _compare_prerelease(
|
|
328
|
-
left: tuple[str, ...] | None,
|
|
329
|
-
right: tuple[str, ...] | None,
|
|
330
|
-
) -> int:
|
|
331
|
-
if left is None and right is None:
|
|
332
|
-
return 0
|
|
333
|
-
if left is None:
|
|
334
|
-
return 1
|
|
335
|
-
if right is None:
|
|
336
|
-
return -1
|
|
337
|
-
|
|
338
|
-
for left_part, right_part in zip(left, right):
|
|
339
|
-
if left_part == right_part:
|
|
340
|
-
continue
|
|
341
|
-
left_numeric = left_part.isdigit()
|
|
342
|
-
right_numeric = right_part.isdigit()
|
|
343
|
-
if left_numeric and right_numeric:
|
|
344
|
-
left_int = int(left_part)
|
|
345
|
-
right_int = int(right_part)
|
|
346
|
-
return (left_int > right_int) - (left_int < right_int)
|
|
347
|
-
if left_numeric:
|
|
348
|
-
return -1
|
|
349
|
-
if right_numeric:
|
|
350
|
-
return 1
|
|
351
|
-
return (left_part > right_part) - (left_part < right_part)
|
|
352
|
-
|
|
353
|
-
return (len(left) > len(right)) - (len(left) < len(right))
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
def _compare_versions(left: str, right: str) -> int | None:
|
|
357
|
-
parsed_left = _parse_version(left)
|
|
358
|
-
parsed_right = _parse_version(right)
|
|
359
|
-
if parsed_left is None or parsed_right is None:
|
|
360
|
-
return None
|
|
361
|
-
|
|
362
|
-
left_numbers, left_prerelease = parsed_left
|
|
363
|
-
right_numbers, right_prerelease = parsed_right
|
|
364
|
-
if left_numbers != right_numbers:
|
|
365
|
-
return (left_numbers > right_numbers) - (left_numbers < right_numbers)
|
|
366
|
-
return _compare_prerelease(left_prerelease, right_prerelease)
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
def _update_marker_path(repo_root: Path) -> Path:
|
|
370
|
-
context_key = resolve_context_key()
|
|
371
|
-
if not context_key:
|
|
372
|
-
terminal_key = os.environ.get("TERM_SESSION_ID", "").strip()
|
|
373
|
-
context_key = terminal_key or f"ppid-{os.getppid()}"
|
|
374
|
-
safe_key = re.sub(r"[^A-Za-z0-9._-]+", "_", context_key).strip("._-")
|
|
375
|
-
if not safe_key:
|
|
376
|
-
safe_key = "session"
|
|
377
|
-
return (
|
|
378
|
-
repo_root
|
|
379
|
-
/ DIR_WORKFLOW
|
|
380
|
-
/ ".runtime"
|
|
381
|
-
/ f"update-check-{safe_key[:160]}.marker"
|
|
382
|
-
)
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
def _mark_update_check_attempted(repo_root: Path) -> bool:
|
|
386
|
-
marker_path = _update_marker_path(repo_root)
|
|
387
|
-
if marker_path.exists():
|
|
388
|
-
return False
|
|
389
|
-
try:
|
|
390
|
-
marker_path.parent.mkdir(parents=True, exist_ok=True)
|
|
391
|
-
marker_path.write_text("checked\n", encoding="utf-8")
|
|
392
|
-
except OSError:
|
|
393
|
-
pass
|
|
394
|
-
return True
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
def _get_update_hint(repo_root: Path) -> str | None:
|
|
398
|
-
marker_path = _update_marker_path(repo_root)
|
|
399
|
-
if marker_path.exists():
|
|
400
|
-
return None
|
|
401
|
-
|
|
402
|
-
current_version = _read_project_version(repo_root)
|
|
403
|
-
if not current_version:
|
|
404
|
-
return None
|
|
405
|
-
|
|
406
|
-
latest_version = _resolve_available_update_version()
|
|
407
|
-
if not latest_version:
|
|
408
|
-
return None
|
|
409
|
-
|
|
410
|
-
_mark_update_check_attempted(repo_root)
|
|
411
|
-
comparison = _compare_versions(current_version, latest_version)
|
|
412
|
-
if comparison is None or comparison >= 0:
|
|
413
|
-
return None
|
|
414
|
-
|
|
415
|
-
return (
|
|
416
|
-
f"Trellis update available: {current_version} -> {latest_version}, "
|
|
417
|
-
"run
|
|
418
|
-
)
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
def _quote_command_value(value: str) -> str:
|
|
422
|
-
return value.replace("\\", "\\\\").replace('"', '\\"')
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
def _artifact_search_command(query: str = "<topic>") -> str:
|
|
426
|
-
return (
|
|
427
|
-
f"{_PYTHON_CMD} ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/search_artifacts.py "
|
|
428
|
-
f'--query "{_quote_command_value(query)}" --json'
|
|
429
|
-
)
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
def _session_memory_command(query: str = "<topic>") -> str:
|
|
433
|
-
return (
|
|
434
|
-
f"{_PYTHON_CMD} ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/search_memory.py "
|
|
435
|
-
f'--query "{_quote_command_value(query)}" --json'
|
|
436
|
-
)
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
def _smart_search_evidence_command(question: str = "<question>") -> str:
|
|
440
|
-
return (
|
|
441
|
-
f"{_PYTHON_CMD} ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/run_smart_search.py "
|
|
442
|
-
f'"{_quote_command_value(question)}" --intent deep-research --json'
|
|
443
|
-
)
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
def _query_tokens(*values: object) -> list[str]:
|
|
447
|
-
tokens: list[str] = []
|
|
448
|
-
seen: set[str] = set()
|
|
449
|
-
for value in values:
|
|
450
|
-
if not value:
|
|
451
|
-
continue
|
|
452
|
-
for raw_token in re.findall(r"[\w.-]+", str(value)):
|
|
453
|
-
token = raw_token.strip("._-")
|
|
454
|
-
if not token:
|
|
455
|
-
continue
|
|
456
|
-
key = token.lower()
|
|
457
|
-
if key in seen:
|
|
458
|
-
continue
|
|
459
|
-
seen.add(key)
|
|
460
|
-
tokens.append(token)
|
|
461
|
-
if len(tokens) >= 8:
|
|
462
|
-
return tokens
|
|
463
|
-
return tokens
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
def _selected_task_query(selected_task: str, task_info: object | None) -> str:
|
|
467
|
-
if task_info:
|
|
468
|
-
tokens = _query_tokens(
|
|
469
|
-
getattr(task_info, "title", ""),
|
|
470
|
-
getattr(task_info, "name", ""),
|
|
471
|
-
getattr(task_info, "package", ""),
|
|
472
|
-
getattr(task_info, "description", ""),
|
|
473
|
-
)
|
|
474
|
-
else:
|
|
475
|
-
tokens = _query_tokens(Path(selected_task).name)
|
|
476
|
-
return " ".join(tokens) if tokens else selected_task
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
def _recommendation(
|
|
480
|
-
source: str,
|
|
481
|
-
priority: int,
|
|
482
|
-
confidence: str,
|
|
483
|
-
reason: str,
|
|
484
|
-
action: str,
|
|
485
|
-
reference: str,
|
|
486
|
-
) -> dict[str, object]:
|
|
487
|
-
return {
|
|
488
|
-
"source": source,
|
|
489
|
-
"priority": priority,
|
|
490
|
-
"confidence": confidence,
|
|
491
|
-
"reason": reason,
|
|
492
|
-
"action": action,
|
|
493
|
-
"reference": reference,
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
def _resolve_selected_task_dir(repo_root: Path, selected_task: str) -> Path:
|
|
498
|
-
task_path = Path(selected_task)
|
|
499
|
-
if task_path.is_absolute():
|
|
500
|
-
return task_path
|
|
501
|
-
return repo_root / task_path
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
def _selected_task_artifacts(
|
|
505
|
-
repo_root: Path,
|
|
506
|
-
selected_task: str | None,
|
|
507
|
-
) -> dict[str, object] | None:
|
|
508
|
-
if not selected_task:
|
|
509
|
-
return None
|
|
510
|
-
|
|
511
|
-
task_dir = _resolve_selected_task_dir(repo_root, selected_task)
|
|
512
|
-
research_dir = task_dir / "research"
|
|
513
|
-
research_count = 0
|
|
514
|
-
if research_dir.is_dir():
|
|
515
|
-
research_count = len([p for p in research_dir.glob("*.md") if p.is_file()])
|
|
516
|
-
|
|
517
|
-
return {
|
|
518
|
-
"taskPath": selected_task,
|
|
519
|
-
"prd": (task_dir / "prd.md").is_file(),
|
|
520
|
-
"design": (task_dir / "design.md").is_file(),
|
|
521
|
-
"implement": (task_dir / "implement.md").is_file(),
|
|
522
|
-
"research": research_dir.is_dir(),
|
|
523
|
-
"researchCount": research_count,
|
|
524
|
-
"verify": (task_dir / "verify.md").is_file(),
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
def _get_retrieval_guide(
|
|
529
|
-
repo_root: Path,
|
|
530
|
-
selected_task: str | None,
|
|
531
|
-
) -> dict[str, object]:
|
|
532
|
-
guide: dict[str, object] = {
|
|
533
|
-
"artifactSearch": {
|
|
534
|
-
"command": _artifact_search_command(),
|
|
535
|
-
"purpose": (
|
|
536
|
-
"Find durable Trellis specs, tasks, research, verification "
|
|
537
|
-
"notes, and workspace journals."
|
|
538
|
-
),
|
|
539
|
-
},
|
|
540
|
-
"sessionMemory": {
|
|
541
|
-
"command": _session_memory_command(),
|
|
542
|
-
"purpose": (
|
|
543
|
-
"Find reusable session history, prior decisions, and recent "
|
|
544
|
-
"work context from local workspace journals."
|
|
545
|
-
),
|
|
546
|
-
},
|
|
547
|
-
"smartSearchEvidence": {
|
|
548
|
-
"command": _smart_search_evidence_command(),
|
|
549
|
-
"purpose": (
|
|
550
|
-
"Explicitly run Smart Search and save a task-local evidence "
|
|
551
|
-
"manifest for source-backed research."
|
|
552
|
-
),
|
|
553
|
-
},
|
|
554
|
-
"codebaseEvidence": (
|
|
555
|
-
"Treat adapter output as candidate evidence until current source, "
|
|
556
|
-
"Git, or validation confirms it."
|
|
557
|
-
),
|
|
558
|
-
"evidenceSinks": {
|
|
559
|
-
"exploratory": "selected task research/*.md",
|
|
560
|
-
"final": "selected task verify.md",
|
|
561
|
-
},
|
|
562
|
-
}
|
|
563
|
-
selected_artifacts = _selected_task_artifacts(repo_root, selected_task)
|
|
564
|
-
if selected_artifacts is not None:
|
|
565
|
-
guide["selectedTaskArtifacts"] = selected_artifacts
|
|
566
|
-
guide["recommendations"] = _get_retrieval_recommendations(
|
|
567
|
-
repo_root,
|
|
568
|
-
selected_task,
|
|
569
|
-
selected_artifacts,
|
|
570
|
-
)
|
|
571
|
-
return guide
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
def _get_retrieval_recommendations(
|
|
575
|
-
repo_root: Path,
|
|
576
|
-
selected_task: str | None,
|
|
577
|
-
selected_artifacts: dict[str, object] | None = None,
|
|
578
|
-
) -> list[dict[str, object]]:
|
|
579
|
-
if not selected_task:
|
|
580
|
-
return []
|
|
581
|
-
|
|
582
|
-
task_dir = _resolve_selected_task_dir(repo_root, selected_task)
|
|
583
|
-
task_info = load_task(task_dir)
|
|
584
|
-
artifacts = (
|
|
585
|
-
selected_artifacts
|
|
586
|
-
or _selected_task_artifacts(repo_root, selected_task)
|
|
587
|
-
or {}
|
|
588
|
-
)
|
|
589
|
-
query = _selected_task_query(selected_task, task_info)
|
|
590
|
-
recommendations: list[dict[str, object]] = []
|
|
591
|
-
|
|
592
|
-
has_local_artifacts = any(
|
|
593
|
-
bool(artifacts.get(key))
|
|
594
|
-
for key in ("prd", "design", "implement", "research", "verify")
|
|
595
|
-
)
|
|
596
|
-
if has_local_artifacts:
|
|
597
|
-
recommendations.append(
|
|
598
|
-
_recommendation(
|
|
599
|
-
"task-artifacts",
|
|
600
|
-
100,
|
|
601
|
-
"high",
|
|
602
|
-
"Selected task has local planning or evidence artifacts; read them first.",
|
|
603
|
-
"Read selected task prd.md, design.md, implement.md, research/*.md, and verify.md as present.",
|
|
604
|
-
selected_task,
|
|
605
|
-
)
|
|
606
|
-
)
|
|
607
|
-
|
|
608
|
-
recommendations.extend(
|
|
609
|
-
[
|
|
610
|
-
_recommendation(
|
|
611
|
-
"artifact-search",
|
|
612
|
-
90,
|
|
613
|
-
"high",
|
|
614
|
-
"Search durable Trellis specs, tasks, research, and verification notes for related context.",
|
|
615
|
-
_artifact_search_command(query),
|
|
616
|
-
selected_task,
|
|
617
|
-
),
|
|
618
|
-
_recommendation(
|
|
619
|
-
"session-memory",
|
|
620
|
-
80,
|
|
621
|
-
"medium",
|
|
622
|
-
"Search local session history for prior decisions and recent related work.",
|
|
623
|
-
_session_memory_command(query),
|
|
624
|
-
f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/",
|
|
625
|
-
),
|
|
626
|
-
]
|
|
627
|
-
)
|
|
628
|
-
|
|
629
|
-
if bool(artifacts.get("research")):
|
|
630
|
-
smart_priority = 55
|
|
631
|
-
smart_confidence = "low"
|
|
632
|
-
smart_reason = (
|
|
633
|
-
"Research artifacts already exist; run Smart Search only for current external source gaps."
|
|
634
|
-
)
|
|
635
|
-
else:
|
|
636
|
-
smart_priority = 70
|
|
637
|
-
smart_confidence = "medium"
|
|
638
|
-
smart_reason = (
|
|
639
|
-
"No selected-task research artifacts are present; capture explicit external evidence when needed."
|
|
640
|
-
)
|
|
641
|
-
|
|
642
|
-
recommendations.extend(
|
|
643
|
-
[
|
|
644
|
-
_recommendation(
|
|
645
|
-
"smart-search",
|
|
646
|
-
smart_priority,
|
|
647
|
-
smart_confidence,
|
|
648
|
-
smart_reason,
|
|
649
|
-
_smart_search_evidence_command(query),
|
|
650
|
-
f"{selected_task}/research/smart-search/",
|
|
651
|
-
),
|
|
652
|
-
_recommendation(
|
|
653
|
-
"codebase-evidence",
|
|
654
|
-
60,
|
|
655
|
-
"medium",
|
|
656
|
-
"Confirm candidate codebase evidence against current source, Git state, or validation.",
|
|
657
|
-
"Inspect current source and run focused validation before treating codebase retrieval as proof.",
|
|
658
|
-
"current source tree",
|
|
659
|
-
),
|
|
660
|
-
]
|
|
661
|
-
)
|
|
662
|
-
|
|
663
|
-
recommendations.sort(
|
|
664
|
-
key=lambda item: (-int(item["priority"]), str(item["source"]))
|
|
665
|
-
)
|
|
666
|
-
return recommendations
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
def _present_missing(value: object) -> str:
|
|
670
|
-
return "present" if bool(value) else "missing"
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
def _append_retrieval_recommendations(
|
|
674
|
-
lines: list[str],
|
|
675
|
-
recommendations: list[dict[str, object]],
|
|
676
|
-
) -> None:
|
|
677
|
-
if not recommendations:
|
|
678
|
-
return
|
|
679
|
-
lines.append("Retrieval recommendations:")
|
|
680
|
-
for index, recommendation in enumerate(recommendations, start=1):
|
|
681
|
-
source = recommendation.get("source", "")
|
|
682
|
-
confidence = recommendation.get("confidence", "")
|
|
683
|
-
priority = recommendation.get("priority", "")
|
|
684
|
-
reason = recommendation.get("reason", "")
|
|
685
|
-
lines.append(f"{index}. {source} [{confidence}] priority {priority}: {reason}")
|
|
686
|
-
lines.append(f" Action: {recommendation.get('action', '')}")
|
|
687
|
-
lines.append(f" Reference: {recommendation.get('reference', '')}")
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
def _append_retrieval_guide(
|
|
691
|
-
lines: list[str],
|
|
692
|
-
repo_root: Path,
|
|
693
|
-
selected_task: str | None,
|
|
694
|
-
) -> None:
|
|
695
|
-
lines.append("## RETRIEVAL GUIDE")
|
|
696
|
-
lines.append(f"Artifact search: {_artifact_search_command()}")
|
|
697
|
-
lines.append(f"Session memory: {_session_memory_command()}")
|
|
698
|
-
lines.append(f"Smart Search evidence: {_smart_search_evidence_command()}")
|
|
699
|
-
lines.append(
|
|
700
|
-
"Use artifact search for durable Trellis specs, tasks, research, "
|
|
701
|
-
"verification notes, and workspace journals."
|
|
702
|
-
)
|
|
703
|
-
lines.append(
|
|
704
|
-
"Use session memory for reusable prior decisions and recent work "
|
|
705
|
-
"recorded in local workspace journals."
|
|
706
|
-
)
|
|
707
|
-
lines.append(
|
|
708
|
-
"Run Smart Search evidence only when external/current source evidence is "
|
|
709
|
-
"needed; it writes a task-local manifest under research/smart-search/."
|
|
710
|
-
)
|
|
711
|
-
lines.append(
|
|
712
|
-
"Codebase evidence: adapter output is candidate evidence until current "
|
|
713
|
-
"source, Git, or validation confirms it."
|
|
714
|
-
)
|
|
715
|
-
|
|
716
|
-
if selected_task:
|
|
717
|
-
lines.append(
|
|
718
|
-
f"Evidence sinks: {selected_task}/research/*.md for exploratory "
|
|
719
|
-
f"chains; {selected_task}/verify.md for final proof."
|
|
720
|
-
)
|
|
721
|
-
artifacts = _selected_task_artifacts(repo_root, selected_task)
|
|
722
|
-
if artifacts:
|
|
723
|
-
research_count = int(artifacts.get("researchCount", 0))
|
|
724
|
-
research_state = (
|
|
725
|
-
f"{research_count} markdown file(s)"
|
|
726
|
-
if research_count
|
|
727
|
-
else _present_missing(artifacts.get("research"))
|
|
728
|
-
)
|
|
729
|
-
lines.append("Selected-task artifacts:")
|
|
730
|
-
lines.append(f"- prd.md: {_present_missing(artifacts.get('prd'))}")
|
|
731
|
-
lines.append(f"- design.md: {_present_missing(artifacts.get('design'))}")
|
|
732
|
-
lines.append(
|
|
733
|
-
f"- implement.md: {_present_missing(artifacts.get('implement'))}"
|
|
734
|
-
)
|
|
735
|
-
lines.append(f"- research/: {research_state}")
|
|
736
|
-
lines.append(f"- verify.md: {_present_missing(artifacts.get('verify'))}")
|
|
737
|
-
_append_retrieval_recommendations(
|
|
738
|
-
lines,
|
|
739
|
-
_get_retrieval_recommendations(repo_root, selected_task, artifacts),
|
|
740
|
-
)
|
|
741
|
-
else:
|
|
742
|
-
lines.append(
|
|
743
|
-
"Evidence sinks: task research/*.md for exploratory chains; task "
|
|
744
|
-
"verify.md for final proof."
|
|
745
|
-
)
|
|
746
|
-
lines.append("")
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
# =============================================================================
|
|
750
|
-
# JSON Output
|
|
751
|
-
# =============================================================================
|
|
752
|
-
|
|
753
|
-
def get_context_json(repo_root: Path | None = None) -> dict:
|
|
754
|
-
"""Get context as a dictionary.
|
|
755
|
-
|
|
756
|
-
Args:
|
|
757
|
-
repo_root: Repository root path. Defaults to auto-detected.
|
|
758
|
-
|
|
759
|
-
Returns:
|
|
760
|
-
Context dictionary.
|
|
761
|
-
"""
|
|
762
|
-
if repo_root is None:
|
|
763
|
-
repo_root = get_repo_root()
|
|
764
|
-
|
|
765
|
-
developer = get_developer(repo_root)
|
|
766
|
-
tasks_dir = get_tasks_dir(repo_root)
|
|
767
|
-
journal_file = get_active_journal_file(repo_root)
|
|
768
|
-
|
|
769
|
-
journal_lines = 0
|
|
770
|
-
journal_relative = ""
|
|
771
|
-
if journal_file and developer:
|
|
772
|
-
journal_lines = count_lines(journal_file)
|
|
773
|
-
journal_relative = (
|
|
774
|
-
f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{journal_file.name}"
|
|
775
|
-
)
|
|
776
|
-
|
|
777
|
-
root_git_info = _collect_root_git_info(repo_root)
|
|
778
|
-
|
|
779
|
-
# Tasks
|
|
780
|
-
tasks = [
|
|
781
|
-
{
|
|
782
|
-
"dir": t.dir_name,
|
|
783
|
-
"name": t.name,
|
|
784
|
-
"status": t.status,
|
|
785
|
-
"children": list(t.children),
|
|
786
|
-
"parent": t.parent,
|
|
787
|
-
}
|
|
788
|
-
for t in iter_active_tasks(tasks_dir)
|
|
789
|
-
]
|
|
790
|
-
|
|
791
|
-
# Package git repos (independent sub-repositories)
|
|
792
|
-
pkg_git_info = _collect_package_git_info(
|
|
793
|
-
repo_root,
|
|
794
|
-
discover_unconfigured=not root_git_info["isRepo"],
|
|
795
|
-
)
|
|
796
|
-
selected_task = get_selected_task(repo_root)
|
|
797
|
-
|
|
798
|
-
result = {
|
|
799
|
-
"developer": developer or "",
|
|
800
|
-
"git": {
|
|
801
|
-
"isRepo": root_git_info["isRepo"],
|
|
802
|
-
"branch": root_git_info["branch"],
|
|
803
|
-
"isClean": root_git_info["isClean"],
|
|
804
|
-
"uncommittedChanges": root_git_info["uncommittedChanges"],
|
|
805
|
-
"recentCommits": root_git_info["recentCommits"],
|
|
806
|
-
},
|
|
807
|
-
"tasks": {
|
|
808
|
-
"active": tasks,
|
|
809
|
-
"directory": f"{DIR_WORKFLOW}/{DIR_TASKS}",
|
|
810
|
-
},
|
|
811
|
-
"journal": {
|
|
812
|
-
"file": journal_relative,
|
|
813
|
-
"lines": journal_lines,
|
|
814
|
-
"nearLimit": journal_lines > 1800,
|
|
815
|
-
},
|
|
816
|
-
"retrievalGuide": _get_retrieval_guide(repo_root, selected_task),
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
if pkg_git_info:
|
|
820
|
-
result["packageGit"] = pkg_git_info
|
|
821
|
-
|
|
822
|
-
return result
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
def output_json(repo_root: Path | None = None) -> None:
|
|
826
|
-
"""Output context in JSON format.
|
|
827
|
-
|
|
828
|
-
Args:
|
|
829
|
-
repo_root: Repository root path. Defaults to auto-detected.
|
|
830
|
-
"""
|
|
831
|
-
context = get_context_json(repo_root)
|
|
832
|
-
print(json.dumps(context, indent=2, ensure_ascii=False))
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
# =============================================================================
|
|
836
|
-
# Text Output
|
|
837
|
-
# =============================================================================
|
|
838
|
-
|
|
839
|
-
def get_context_text(repo_root: Path | None = None) -> str:
|
|
840
|
-
"""Get context as formatted text.
|
|
841
|
-
|
|
842
|
-
Args:
|
|
843
|
-
repo_root: Repository root path. Defaults to auto-detected.
|
|
844
|
-
|
|
845
|
-
Returns:
|
|
846
|
-
Formatted text output.
|
|
847
|
-
"""
|
|
848
|
-
if repo_root is None:
|
|
849
|
-
repo_root = get_repo_root()
|
|
850
|
-
|
|
851
|
-
lines = []
|
|
852
|
-
lines.append("========================================")
|
|
853
|
-
lines.append("SESSION CONTEXT")
|
|
854
|
-
lines.append("========================================")
|
|
855
|
-
lines.append("")
|
|
856
|
-
|
|
857
|
-
developer = get_developer(repo_root)
|
|
858
|
-
|
|
859
|
-
# Developer section
|
|
860
|
-
lines.append("## DEVELOPER")
|
|
861
|
-
if not developer:
|
|
862
|
-
lines.append(
|
|
863
|
-
f"ERROR: Not initialized. Run: python3 ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <name>"
|
|
864
|
-
)
|
|
865
|
-
return "\n".join(lines)
|
|
866
|
-
|
|
867
|
-
lines.append(f"Name: {developer}")
|
|
868
|
-
lines.append("")
|
|
869
|
-
|
|
870
|
-
root_git_info = _collect_root_git_info(repo_root)
|
|
871
|
-
_append_root_git_context(lines, root_git_info)
|
|
872
|
-
|
|
873
|
-
# Package git repos — independent sub-repositories
|
|
874
|
-
_append_package_git_context(
|
|
875
|
-
lines,
|
|
876
|
-
_collect_package_git_info(
|
|
877
|
-
repo_root,
|
|
878
|
-
discover_unconfigured=not root_git_info["isRepo"],
|
|
879
|
-
),
|
|
880
|
-
)
|
|
881
|
-
|
|
882
|
-
# Selected task
|
|
883
|
-
lines.append("## SELECTED TASK")
|
|
884
|
-
selected_task = get_selected_task(repo_root)
|
|
885
|
-
if selected_task:
|
|
886
|
-
selected_task_dir = repo_root / selected_task
|
|
887
|
-
source_type, context_key, _ = get_selected_task_source(repo_root)
|
|
888
|
-
lines.append(f"Path: {selected_task}")
|
|
889
|
-
lines.append(
|
|
890
|
-
f"Source: {source_type}" + (f":{context_key}" if context_key else "")
|
|
891
|
-
)
|
|
892
|
-
|
|
893
|
-
ct = load_task(selected_task_dir)
|
|
894
|
-
if ct:
|
|
895
|
-
lines.append(f"Name: {ct.name}")
|
|
896
|
-
lines.append(f"Status: {ct.status}")
|
|
897
|
-
lines.append(f"Created: {ct.raw.get('createdAt', 'unknown')}")
|
|
898
|
-
if ct.description:
|
|
899
|
-
lines.append(f"Description: {ct.description}")
|
|
900
|
-
|
|
901
|
-
# Check for prd.md
|
|
902
|
-
prd_file = selected_task_dir / "prd.md"
|
|
903
|
-
if prd_file.is_file():
|
|
904
|
-
lines.append("")
|
|
905
|
-
lines.append("[!] This task has prd.md - read it for task details")
|
|
906
|
-
else:
|
|
907
|
-
lines.append("(none)")
|
|
908
|
-
lines.append("")
|
|
909
|
-
|
|
910
|
-
_append_retrieval_guide(lines, repo_root, selected_task)
|
|
911
|
-
|
|
912
|
-
lines.append("## TASK DASHBOARD")
|
|
913
|
-
lines.extend(render_task_dashboard(repo_root).splitlines())
|
|
914
|
-
lines.append("")
|
|
915
|
-
|
|
916
|
-
# Active tasks
|
|
917
|
-
lines.append("## ACTIVE TASKS")
|
|
918
|
-
tasks_dir = get_tasks_dir(repo_root)
|
|
919
|
-
task_count = 0
|
|
920
|
-
|
|
921
|
-
# Collect all task data for hierarchy display
|
|
922
|
-
all_tasks = {t.dir_name: t for t in iter_active_tasks(tasks_dir)}
|
|
923
|
-
all_statuses = {name: t.status for name, t in all_tasks.items()}
|
|
924
|
-
|
|
925
|
-
def _print_task_tree(name: str, indent: int = 0) -> None:
|
|
926
|
-
nonlocal task_count
|
|
927
|
-
t = all_tasks[name]
|
|
928
|
-
progress = children_progress(t.children, all_statuses)
|
|
929
|
-
prefix = " " * indent
|
|
930
|
-
lines.append(f"{prefix}- {name}/ ({t.status}){progress} @{t.assignee or '-'}")
|
|
931
|
-
task_count += 1
|
|
932
|
-
for child in t.children:
|
|
933
|
-
if child in all_tasks:
|
|
934
|
-
_print_task_tree(child, indent + 1)
|
|
935
|
-
|
|
936
|
-
for dir_name in sorted(all_tasks.keys()):
|
|
937
|
-
if not all_tasks[dir_name].parent:
|
|
938
|
-
_print_task_tree(dir_name)
|
|
939
|
-
|
|
940
|
-
if task_count == 0:
|
|
941
|
-
lines.append("(no active tasks)")
|
|
942
|
-
lines.append(f"Total: {task_count} active task(s)")
|
|
943
|
-
lines.append("")
|
|
944
|
-
|
|
945
|
-
# My tasks
|
|
946
|
-
lines.append("## MY TASKS (Assigned to me)")
|
|
947
|
-
my_task_count = 0
|
|
948
|
-
|
|
949
|
-
for t in all_tasks.values():
|
|
950
|
-
if t.assignee == developer and t.status != "done":
|
|
951
|
-
progress = children_progress(t.children, all_statuses)
|
|
952
|
-
lines.append(f"- [{t.priority}] {t.title} ({t.status}){progress}")
|
|
953
|
-
my_task_count += 1
|
|
954
|
-
|
|
955
|
-
if my_task_count == 0:
|
|
956
|
-
lines.append("(no tasks assigned to you)")
|
|
957
|
-
lines.append("")
|
|
958
|
-
|
|
959
|
-
# Journal file
|
|
960
|
-
lines.append("## JOURNAL FILE")
|
|
961
|
-
journal_file = get_active_journal_file(repo_root)
|
|
962
|
-
if journal_file:
|
|
963
|
-
journal_lines = count_lines(journal_file)
|
|
964
|
-
relative = f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{journal_file.name}"
|
|
965
|
-
lines.append(f"Active file: {relative}")
|
|
966
|
-
lines.append(f"Line count: {journal_lines} / 2000")
|
|
967
|
-
if journal_lines > 1800:
|
|
968
|
-
lines.append("[!] WARNING: Approaching 2000 line limit!")
|
|
969
|
-
else:
|
|
970
|
-
lines.append("No journal file found")
|
|
971
|
-
lines.append("")
|
|
972
|
-
|
|
973
|
-
# Packages
|
|
974
|
-
packages_text = get_packages_section(repo_root)
|
|
975
|
-
if packages_text:
|
|
976
|
-
lines.append(packages_text)
|
|
977
|
-
lines.append("")
|
|
978
|
-
|
|
979
|
-
# Paths
|
|
980
|
-
lines.append("## PATHS")
|
|
981
|
-
lines.append(f"Workspace: {DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/")
|
|
982
|
-
lines.append(f"Tasks: {DIR_WORKFLOW}/{DIR_TASKS}/")
|
|
983
|
-
lines.append(f"Spec: {DIR_WORKFLOW}/{DIR_SPEC}/")
|
|
984
|
-
lines.append("")
|
|
985
|
-
|
|
986
|
-
lines.append("========================================")
|
|
987
|
-
|
|
988
|
-
return "\n".join(lines)
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
# =============================================================================
|
|
992
|
-
# Record Mode
|
|
993
|
-
# =============================================================================
|
|
994
|
-
|
|
995
|
-
def get_context_record_json(repo_root: Path | None = None) -> dict:
|
|
996
|
-
"""Get record-mode context as a dictionary.
|
|
997
|
-
|
|
998
|
-
Focused on: my active tasks, git status, selected task.
|
|
999
|
-
"""
|
|
1000
|
-
if repo_root is None:
|
|
1001
|
-
repo_root = get_repo_root()
|
|
1002
|
-
|
|
1003
|
-
developer = get_developer(repo_root)
|
|
1004
|
-
tasks_dir = get_tasks_dir(repo_root)
|
|
1005
|
-
|
|
1006
|
-
root_git_info = _collect_root_git_info(repo_root)
|
|
1007
|
-
|
|
1008
|
-
# My tasks (single pass — collect statuses and filter by assignee)
|
|
1009
|
-
all_tasks_list = list(iter_active_tasks(tasks_dir))
|
|
1010
|
-
all_statuses = {t.dir_name: t.status for t in all_tasks_list}
|
|
1011
|
-
|
|
1012
|
-
my_tasks = []
|
|
1013
|
-
for t in all_tasks_list:
|
|
1014
|
-
if t.assignee == developer:
|
|
1015
|
-
done = sum(
|
|
1016
|
-
1 for c in t.children
|
|
1017
|
-
if all_statuses.get(c) in ("completed", "done")
|
|
1018
|
-
)
|
|
1019
|
-
my_tasks.append({
|
|
1020
|
-
"dir": t.dir_name,
|
|
1021
|
-
"title": t.title,
|
|
1022
|
-
"status": t.status,
|
|
1023
|
-
"priority": t.priority,
|
|
1024
|
-
"children": list(t.children),
|
|
1025
|
-
"childrenDone": done,
|
|
1026
|
-
"parent": t.parent,
|
|
1027
|
-
"meta": t.meta,
|
|
1028
|
-
})
|
|
1029
|
-
|
|
1030
|
-
# Selected task
|
|
1031
|
-
selected_task_info = None
|
|
1032
|
-
selected_task = get_selected_task(repo_root)
|
|
1033
|
-
if selected_task:
|
|
1034
|
-
source_type, context_key, _ = get_selected_task_source(repo_root)
|
|
1035
|
-
ct = load_task(repo_root / selected_task)
|
|
1036
|
-
if ct:
|
|
1037
|
-
selected_task_info = {
|
|
1038
|
-
"path": selected_task,
|
|
1039
|
-
"name": ct.name,
|
|
1040
|
-
"status": ct.status,
|
|
1041
|
-
"source": source_type,
|
|
1042
|
-
"contextKey": context_key,
|
|
1043
|
-
}
|
|
1044
|
-
|
|
1045
|
-
# Package git repos
|
|
1046
|
-
pkg_git_info = _collect_package_git_info(
|
|
1047
|
-
repo_root,
|
|
1048
|
-
discover_unconfigured=not root_git_info["isRepo"],
|
|
1049
|
-
)
|
|
1050
|
-
|
|
1051
|
-
result = {
|
|
1052
|
-
"developer": developer or "",
|
|
1053
|
-
"git": {
|
|
1054
|
-
"isRepo": root_git_info["isRepo"],
|
|
1055
|
-
"branch": root_git_info["branch"],
|
|
1056
|
-
"isClean": root_git_info["isClean"],
|
|
1057
|
-
"uncommittedChanges": root_git_info["uncommittedChanges"],
|
|
1058
|
-
"recentCommits": root_git_info["recentCommits"],
|
|
1059
|
-
},
|
|
1060
|
-
"myTasks": my_tasks,
|
|
1061
|
-
"selectedTask": selected_task_info,
|
|
1062
|
-
}
|
|
1063
|
-
|
|
1064
|
-
if pkg_git_info:
|
|
1065
|
-
result["packageGit"] = pkg_git_info
|
|
1066
|
-
|
|
1067
|
-
return result
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
def get_context_text_record(repo_root: Path | None = None) -> str:
|
|
1071
|
-
"""Get context as formatted text for record-session mode.
|
|
1072
|
-
|
|
1073
|
-
Focused output: MY ACTIVE TASKS first (with [!!!] emphasis),
|
|
1074
|
-
then GIT STATUS, RECENT COMMITS, SELECTED TASK.
|
|
1075
|
-
"""
|
|
1076
|
-
if repo_root is None:
|
|
1077
|
-
repo_root = get_repo_root()
|
|
1078
|
-
|
|
1079
|
-
lines: list[str] = []
|
|
1080
|
-
lines.append("========================================")
|
|
1081
|
-
lines.append("SESSION CONTEXT (RECORD MODE)")
|
|
1082
|
-
lines.append("========================================")
|
|
1083
|
-
lines.append("")
|
|
1084
|
-
|
|
1085
|
-
developer = get_developer(repo_root)
|
|
1086
|
-
if not developer:
|
|
1087
|
-
lines.append(
|
|
1088
|
-
f"ERROR: Not initialized. Run: python3 ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <name>"
|
|
1089
|
-
)
|
|
1090
|
-
return "\n".join(lines)
|
|
1091
|
-
|
|
1092
|
-
# MY ACTIVE TASKS — first and prominent
|
|
1093
|
-
lines.append(f"## [!!!] MY ACTIVE TASKS (Assigned to {developer})")
|
|
1094
|
-
lines.append("[!] Review whether any should be archived before recording this session.")
|
|
1095
|
-
lines.append("")
|
|
1096
|
-
|
|
1097
|
-
tasks_dir = get_tasks_dir(repo_root)
|
|
1098
|
-
my_task_count = 0
|
|
1099
|
-
|
|
1100
|
-
# Single pass — collect all tasks and filter by assignee
|
|
1101
|
-
all_statuses = get_all_statuses(tasks_dir)
|
|
1102
|
-
|
|
1103
|
-
for t in iter_active_tasks(tasks_dir):
|
|
1104
|
-
if t.assignee == developer:
|
|
1105
|
-
progress = children_progress(t.children, all_statuses)
|
|
1106
|
-
lines.append(f"- [{t.priority}] {t.title} ({t.status}){progress} — {t.dir_name}")
|
|
1107
|
-
my_task_count += 1
|
|
1108
|
-
|
|
1109
|
-
if my_task_count == 0:
|
|
1110
|
-
lines.append("(no active tasks assigned to you)")
|
|
1111
|
-
lines.append("")
|
|
1112
|
-
|
|
1113
|
-
root_git_info = _collect_root_git_info(repo_root)
|
|
1114
|
-
_append_root_git_context(lines, root_git_info)
|
|
1115
|
-
|
|
1116
|
-
# Package git repos — independent sub-repositories
|
|
1117
|
-
_append_package_git_context(
|
|
1118
|
-
lines,
|
|
1119
|
-
_collect_package_git_info(
|
|
1120
|
-
repo_root,
|
|
1121
|
-
discover_unconfigured=not root_git_info["isRepo"],
|
|
1122
|
-
),
|
|
1123
|
-
)
|
|
1124
|
-
|
|
1125
|
-
# SELECTED TASK
|
|
1126
|
-
lines.append("## SELECTED TASK")
|
|
1127
|
-
selected_task = get_selected_task(repo_root)
|
|
1128
|
-
if selected_task:
|
|
1129
|
-
source_type, context_key, _ = get_selected_task_source(repo_root)
|
|
1130
|
-
lines.append(f"Path: {selected_task}")
|
|
1131
|
-
lines.append(
|
|
1132
|
-
f"Source: {source_type}" + (f":{context_key}" if context_key else "")
|
|
1133
|
-
)
|
|
1134
|
-
ct = load_task(repo_root / selected_task)
|
|
1135
|
-
if ct:
|
|
1136
|
-
lines.append(f"Name: {ct.name}")
|
|
1137
|
-
lines.append(f"Status: {ct.status}")
|
|
1138
|
-
else:
|
|
1139
|
-
lines.append("(none)")
|
|
1140
|
-
lines.append("")
|
|
1141
|
-
|
|
1142
|
-
lines.append("========================================")
|
|
1143
|
-
|
|
1144
|
-
return "\n".join(lines)
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
def output_text(repo_root: Path | None = None) -> None:
|
|
1148
|
-
"""Output context in text format.
|
|
1149
|
-
|
|
1150
|
-
Args:
|
|
1151
|
-
repo_root: Repository root path. Defaults to auto-detected.
|
|
1152
|
-
"""
|
|
1153
|
-
if repo_root is None:
|
|
1154
|
-
repo_root = get_repo_root()
|
|
1155
|
-
update_hint = _get_update_hint(repo_root)
|
|
1156
|
-
if update_hint:
|
|
1157
|
-
print(update_hint)
|
|
1158
|
-
print("")
|
|
1159
|
-
print(get_context_text(repo_root))
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Session context generation (default + record modes).
|
|
4
|
+
|
|
5
|
+
Provides:
|
|
6
|
+
get_context_json - JSON output for default mode
|
|
7
|
+
get_context_text - Text output for default mode
|
|
8
|
+
get_context_record_json - JSON for record mode
|
|
9
|
+
get_context_text_record - Text for record mode
|
|
10
|
+
output_json - Print JSON
|
|
11
|
+
output_text - Print text
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import subprocess
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from .active_task import resolve_context_key
|
|
23
|
+
from .config import get_git_packages
|
|
24
|
+
from .git import run_git
|
|
25
|
+
from .packages_context import get_packages_section
|
|
26
|
+
from .tasks import iter_active_tasks, load_task, get_all_statuses, children_progress
|
|
27
|
+
from .task_dashboard import render_task_dashboard
|
|
28
|
+
from .paths import (
|
|
29
|
+
DIR_SCRIPTS,
|
|
30
|
+
DIR_SPEC,
|
|
31
|
+
DIR_TASKS,
|
|
32
|
+
DIR_WORKFLOW,
|
|
33
|
+
DIR_WORKSPACE,
|
|
34
|
+
count_lines,
|
|
35
|
+
get_active_journal_file,
|
|
36
|
+
get_selected_task,
|
|
37
|
+
get_selected_task_source,
|
|
38
|
+
get_developer,
|
|
39
|
+
get_repo_root,
|
|
40
|
+
get_tasks_dir,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# =============================================================================
|
|
45
|
+
# Helpers
|
|
46
|
+
# =============================================================================
|
|
47
|
+
|
|
48
|
+
_PACKAGE_NAME = "@blxzer/cursor-trellis"
|
|
49
|
+
_PYTHON_CMD = "python3"
|
|
50
|
+
_UPDATE_CHECK_TIMEOUT_SECONDS = 1.0
|
|
51
|
+
_VERSION_RE = re.compile(
|
|
52
|
+
r"^\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?\s*$"
|
|
53
|
+
)
|
|
54
|
+
_VERSION_TOKEN_RE = re.compile(r"\b\d+(?:\.\d+){1,2}(?:-[0-9A-Za-z.-]+)?\b")
|
|
55
|
+
_POLYREPO_IGNORED_DIRS = {
|
|
56
|
+
"node_modules",
|
|
57
|
+
"target",
|
|
58
|
+
"dist",
|
|
59
|
+
"build",
|
|
60
|
+
"out",
|
|
61
|
+
"bin",
|
|
62
|
+
"obj",
|
|
63
|
+
"vendor",
|
|
64
|
+
"coverage",
|
|
65
|
+
"tmp",
|
|
66
|
+
"__pycache__",
|
|
67
|
+
}
|
|
68
|
+
_POLYREPO_SCAN_MAX_DEPTH = 2
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _is_git_worktree(path: Path) -> bool:
|
|
72
|
+
"""Return True when path is inside a Git worktree."""
|
|
73
|
+
rc, out, _ = run_git(["rev-parse", "--is-inside-work-tree"], cwd=path)
|
|
74
|
+
return rc == 0 and out.strip().lower() == "true"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _parse_recent_commits(log_output: str) -> list[dict]:
|
|
78
|
+
"""Parse `git log --oneline` output into structured commit entries."""
|
|
79
|
+
commits = []
|
|
80
|
+
for line in log_output.splitlines():
|
|
81
|
+
if not line.strip():
|
|
82
|
+
continue
|
|
83
|
+
parts = line.split(" ", 1)
|
|
84
|
+
if len(parts) >= 2:
|
|
85
|
+
commits.append({"hash": parts[0], "message": parts[1]})
|
|
86
|
+
elif len(parts) == 1:
|
|
87
|
+
commits.append({"hash": parts[0], "message": ""})
|
|
88
|
+
return commits
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _collect_git_repo_info(name: str, rel_path: str, repo_dir: Path) -> dict | None:
|
|
92
|
+
"""Collect Git status for one known repository directory."""
|
|
93
|
+
if not (repo_dir / ".git").exists():
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
_, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_dir)
|
|
97
|
+
branch = branch_out.strip() or "unknown"
|
|
98
|
+
|
|
99
|
+
_, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_dir)
|
|
100
|
+
changes = len([l for l in status_out.splitlines() if l.strip()])
|
|
101
|
+
|
|
102
|
+
_, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_dir)
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
"name": name,
|
|
106
|
+
"path": rel_path,
|
|
107
|
+
"branch": branch,
|
|
108
|
+
"isClean": changes == 0,
|
|
109
|
+
"uncommittedChanges": changes,
|
|
110
|
+
"recentCommits": _parse_recent_commits(log_out),
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _collect_root_git_info(repo_root: Path) -> dict:
|
|
115
|
+
"""Collect root Git info without pretending a non-Git root is clean."""
|
|
116
|
+
if not _is_git_worktree(repo_root):
|
|
117
|
+
return {
|
|
118
|
+
"isRepo": False,
|
|
119
|
+
"branch": "",
|
|
120
|
+
"isClean": False,
|
|
121
|
+
"uncommittedChanges": 0,
|
|
122
|
+
"recentCommits": [],
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
_, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root)
|
|
126
|
+
branch = branch_out.strip() or "unknown"
|
|
127
|
+
|
|
128
|
+
_, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_root)
|
|
129
|
+
status_lines = [line for line in status_out.splitlines() if line.strip()]
|
|
130
|
+
|
|
131
|
+
_, short_out, _ = run_git(["status", "--short"], cwd=repo_root)
|
|
132
|
+
|
|
133
|
+
_, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_root)
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
"isRepo": True,
|
|
137
|
+
"branch": branch,
|
|
138
|
+
"isClean": len(status_lines) == 0,
|
|
139
|
+
"uncommittedChanges": len(status_lines),
|
|
140
|
+
"statusShort": short_out.splitlines(),
|
|
141
|
+
"recentCommits": _parse_recent_commits(log_out),
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _discover_child_git_repos(repo_root: Path) -> list[tuple[str, str]]:
|
|
146
|
+
"""Discover child Git repositories using the init-time polyrepo heuristic."""
|
|
147
|
+
found: list[str] = []
|
|
148
|
+
|
|
149
|
+
def is_candidate_dir(path: Path) -> bool:
|
|
150
|
+
name = path.name
|
|
151
|
+
return not name.startswith(".") and name not in _POLYREPO_IGNORED_DIRS
|
|
152
|
+
|
|
153
|
+
def scan(rel_dir: Path, depth: int) -> None:
|
|
154
|
+
if depth >= _POLYREPO_SCAN_MAX_DEPTH:
|
|
155
|
+
return
|
|
156
|
+
abs_dir = repo_root / rel_dir
|
|
157
|
+
try:
|
|
158
|
+
children = sorted(abs_dir.iterdir(), key=lambda p: p.name)
|
|
159
|
+
except OSError:
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
for child in children:
|
|
163
|
+
if not child.is_dir() or not is_candidate_dir(child):
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
child_rel = (
|
|
167
|
+
rel_dir / child.name if rel_dir != Path(".") else Path(child.name)
|
|
168
|
+
)
|
|
169
|
+
if (child / ".git").exists():
|
|
170
|
+
found.append(child_rel.as_posix())
|
|
171
|
+
continue
|
|
172
|
+
scan(child_rel, depth + 1)
|
|
173
|
+
|
|
174
|
+
scan(Path("."), 0)
|
|
175
|
+
if len(found) < 2:
|
|
176
|
+
return []
|
|
177
|
+
return [(path.replace("/", "_"), path) for path in sorted(found)]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _collect_package_git_info(
|
|
181
|
+
repo_root: Path,
|
|
182
|
+
discover_unconfigured: bool = False,
|
|
183
|
+
) -> list[dict]:
|
|
184
|
+
"""Collect Git status for independent package repositories.
|
|
185
|
+
|
|
186
|
+
Packages marked with ``git: true`` in config.yaml are authoritative.
|
|
187
|
+
When the Trellis root is not a Git repo and no configured package repos are
|
|
188
|
+
available, optionally fall back to the bounded polyrepo child scan.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
List of dicts with keys: name, path, branch, isClean,
|
|
192
|
+
uncommittedChanges, recentCommits.
|
|
193
|
+
Empty list if no git-repo packages are configured.
|
|
194
|
+
"""
|
|
195
|
+
git_pkgs = get_git_packages(repo_root)
|
|
196
|
+
result = []
|
|
197
|
+
for pkg_name, pkg_path in git_pkgs.items():
|
|
198
|
+
pkg_dir = repo_root / pkg_path
|
|
199
|
+
info = _collect_git_repo_info(pkg_name, pkg_path, pkg_dir)
|
|
200
|
+
if info is not None:
|
|
201
|
+
result.append(info)
|
|
202
|
+
|
|
203
|
+
if result or not discover_unconfigured:
|
|
204
|
+
return result
|
|
205
|
+
|
|
206
|
+
discovered = []
|
|
207
|
+
for pkg_name, pkg_path in _discover_child_git_repos(repo_root):
|
|
208
|
+
info = _collect_git_repo_info(pkg_name, pkg_path, repo_root / pkg_path)
|
|
209
|
+
if info is not None:
|
|
210
|
+
discovered.append(info)
|
|
211
|
+
return discovered
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _append_root_git_context(lines: list[str], root_git_info: dict) -> None:
|
|
215
|
+
"""Append root Git status without misleading non-Git roots."""
|
|
216
|
+
lines.append("## GIT STATUS")
|
|
217
|
+
if not root_git_info["isRepo"]:
|
|
218
|
+
lines.append("Root is not a Git repository.")
|
|
219
|
+
lines.append("Run Git commands from the package repository paths listed below.")
|
|
220
|
+
else:
|
|
221
|
+
lines.append(f"Branch: {root_git_info['branch']}")
|
|
222
|
+
if root_git_info["isClean"]:
|
|
223
|
+
lines.append("Working directory: Clean")
|
|
224
|
+
else:
|
|
225
|
+
lines.append(
|
|
226
|
+
f"Working directory: {root_git_info['uncommittedChanges']} "
|
|
227
|
+
"uncommitted change(s)"
|
|
228
|
+
)
|
|
229
|
+
lines.append("")
|
|
230
|
+
lines.append("Changes:")
|
|
231
|
+
for line in root_git_info.get("statusShort", [])[:10]:
|
|
232
|
+
lines.append(line)
|
|
233
|
+
lines.append("")
|
|
234
|
+
|
|
235
|
+
lines.append("## RECENT COMMITS")
|
|
236
|
+
if not root_git_info["isRepo"]:
|
|
237
|
+
lines.append(
|
|
238
|
+
"Root has no Git commit history because it is not a Git repository."
|
|
239
|
+
)
|
|
240
|
+
elif root_git_info["recentCommits"]:
|
|
241
|
+
for commit in root_git_info["recentCommits"]:
|
|
242
|
+
lines.append(f"{commit['hash']} {commit['message']}")
|
|
243
|
+
else:
|
|
244
|
+
lines.append("(no commits)")
|
|
245
|
+
lines.append("")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _append_package_git_context(lines: list[str], package_git_info: list[dict]) -> None:
|
|
249
|
+
"""Append Git status and recent commits for package repositories."""
|
|
250
|
+
for pkg in package_git_info:
|
|
251
|
+
lines.append(f"## GIT STATUS ({pkg['name']}: {pkg['path']})")
|
|
252
|
+
lines.append(f"Branch: {pkg['branch']}")
|
|
253
|
+
if pkg["isClean"]:
|
|
254
|
+
lines.append("Working directory: Clean")
|
|
255
|
+
else:
|
|
256
|
+
lines.append(
|
|
257
|
+
f"Working directory: {pkg['uncommittedChanges']} uncommitted change(s)"
|
|
258
|
+
)
|
|
259
|
+
lines.append("")
|
|
260
|
+
lines.append(f"## RECENT COMMITS ({pkg['name']}: {pkg['path']})")
|
|
261
|
+
if pkg["recentCommits"]:
|
|
262
|
+
for commit in pkg["recentCommits"]:
|
|
263
|
+
lines.append(f"{commit['hash']} {commit['message']}")
|
|
264
|
+
else:
|
|
265
|
+
lines.append("(no commits)")
|
|
266
|
+
lines.append("")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _read_project_version(repo_root: Path) -> str | None:
|
|
270
|
+
try:
|
|
271
|
+
version = (repo_root / DIR_WORKFLOW / ".version").read_text(
|
|
272
|
+
encoding="utf-8"
|
|
273
|
+
).strip()
|
|
274
|
+
except OSError:
|
|
275
|
+
return None
|
|
276
|
+
return version or None
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _fetch_trellis_version_output() -> str | None:
|
|
280
|
+
try:
|
|
281
|
+
result = subprocess.run(
|
|
282
|
+
["cstl", "--version"],
|
|
283
|
+
capture_output=True,
|
|
284
|
+
text=True,
|
|
285
|
+
encoding="utf-8",
|
|
286
|
+
errors="replace",
|
|
287
|
+
timeout=_UPDATE_CHECK_TIMEOUT_SECONDS,
|
|
288
|
+
)
|
|
289
|
+
except (OSError, subprocess.SubprocessError, TimeoutError):
|
|
290
|
+
return None
|
|
291
|
+
|
|
292
|
+
if result.returncode != 0:
|
|
293
|
+
return None
|
|
294
|
+
output = f"{result.stdout}\n{result.stderr}".strip()
|
|
295
|
+
return output or None
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _extract_available_update_version(output: str) -> str | None:
|
|
299
|
+
update_match = re.search(
|
|
300
|
+
r"Trellis update available:\s*"
|
|
301
|
+
r"(?P<current>\S+)\s*(?:→|->)\s*(?P<latest>\S+)",
|
|
302
|
+
output,
|
|
303
|
+
)
|
|
304
|
+
if update_match:
|
|
305
|
+
return update_match.group("latest").strip()
|
|
306
|
+
candidates = _VERSION_TOKEN_RE.findall(output)
|
|
307
|
+
return candidates[-1] if candidates else None
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _resolve_available_update_version() -> str | None:
|
|
311
|
+
output = _fetch_trellis_version_output()
|
|
312
|
+
if not output:
|
|
313
|
+
return None
|
|
314
|
+
return _extract_available_update_version(output)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _parse_version(version: str) -> tuple[tuple[int, int, int], tuple[str, ...] | None] | None:
|
|
318
|
+
match = _VERSION_RE.match(version)
|
|
319
|
+
if not match:
|
|
320
|
+
return None
|
|
321
|
+
major, minor, patch, prerelease = match.groups()
|
|
322
|
+
numbers = (int(major), int(minor or "0"), int(patch or "0"))
|
|
323
|
+
prerelease_parts = tuple(prerelease.split(".")) if prerelease else None
|
|
324
|
+
return numbers, prerelease_parts
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _compare_prerelease(
|
|
328
|
+
left: tuple[str, ...] | None,
|
|
329
|
+
right: tuple[str, ...] | None,
|
|
330
|
+
) -> int:
|
|
331
|
+
if left is None and right is None:
|
|
332
|
+
return 0
|
|
333
|
+
if left is None:
|
|
334
|
+
return 1
|
|
335
|
+
if right is None:
|
|
336
|
+
return -1
|
|
337
|
+
|
|
338
|
+
for left_part, right_part in zip(left, right):
|
|
339
|
+
if left_part == right_part:
|
|
340
|
+
continue
|
|
341
|
+
left_numeric = left_part.isdigit()
|
|
342
|
+
right_numeric = right_part.isdigit()
|
|
343
|
+
if left_numeric and right_numeric:
|
|
344
|
+
left_int = int(left_part)
|
|
345
|
+
right_int = int(right_part)
|
|
346
|
+
return (left_int > right_int) - (left_int < right_int)
|
|
347
|
+
if left_numeric:
|
|
348
|
+
return -1
|
|
349
|
+
if right_numeric:
|
|
350
|
+
return 1
|
|
351
|
+
return (left_part > right_part) - (left_part < right_part)
|
|
352
|
+
|
|
353
|
+
return (len(left) > len(right)) - (len(left) < len(right))
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _compare_versions(left: str, right: str) -> int | None:
|
|
357
|
+
parsed_left = _parse_version(left)
|
|
358
|
+
parsed_right = _parse_version(right)
|
|
359
|
+
if parsed_left is None or parsed_right is None:
|
|
360
|
+
return None
|
|
361
|
+
|
|
362
|
+
left_numbers, left_prerelease = parsed_left
|
|
363
|
+
right_numbers, right_prerelease = parsed_right
|
|
364
|
+
if left_numbers != right_numbers:
|
|
365
|
+
return (left_numbers > right_numbers) - (left_numbers < right_numbers)
|
|
366
|
+
return _compare_prerelease(left_prerelease, right_prerelease)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _update_marker_path(repo_root: Path) -> Path:
|
|
370
|
+
context_key = resolve_context_key()
|
|
371
|
+
if not context_key:
|
|
372
|
+
terminal_key = os.environ.get("TERM_SESSION_ID", "").strip()
|
|
373
|
+
context_key = terminal_key or f"ppid-{os.getppid()}"
|
|
374
|
+
safe_key = re.sub(r"[^A-Za-z0-9._-]+", "_", context_key).strip("._-")
|
|
375
|
+
if not safe_key:
|
|
376
|
+
safe_key = "session"
|
|
377
|
+
return (
|
|
378
|
+
repo_root
|
|
379
|
+
/ DIR_WORKFLOW
|
|
380
|
+
/ ".runtime"
|
|
381
|
+
/ f"update-check-{safe_key[:160]}.marker"
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _mark_update_check_attempted(repo_root: Path) -> bool:
|
|
386
|
+
marker_path = _update_marker_path(repo_root)
|
|
387
|
+
if marker_path.exists():
|
|
388
|
+
return False
|
|
389
|
+
try:
|
|
390
|
+
marker_path.parent.mkdir(parents=True, exist_ok=True)
|
|
391
|
+
marker_path.write_text("checked\n", encoding="utf-8")
|
|
392
|
+
except OSError:
|
|
393
|
+
pass
|
|
394
|
+
return True
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _get_update_hint(repo_root: Path) -> str | None:
|
|
398
|
+
marker_path = _update_marker_path(repo_root)
|
|
399
|
+
if marker_path.exists():
|
|
400
|
+
return None
|
|
401
|
+
|
|
402
|
+
current_version = _read_project_version(repo_root)
|
|
403
|
+
if not current_version:
|
|
404
|
+
return None
|
|
405
|
+
|
|
406
|
+
latest_version = _resolve_available_update_version()
|
|
407
|
+
if not latest_version:
|
|
408
|
+
return None
|
|
409
|
+
|
|
410
|
+
_mark_update_check_attempted(repo_root)
|
|
411
|
+
comparison = _compare_versions(current_version, latest_version)
|
|
412
|
+
if comparison is None or comparison >= 0:
|
|
413
|
+
return None
|
|
414
|
+
|
|
415
|
+
return (
|
|
416
|
+
f"Trellis update available: {current_version} -> {latest_version}, "
|
|
417
|
+
"run cstl upgrade"
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _quote_command_value(value: str) -> str:
|
|
422
|
+
return value.replace("\\", "\\\\").replace('"', '\\"')
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _artifact_search_command(query: str = "<topic>") -> str:
|
|
426
|
+
return (
|
|
427
|
+
f"{_PYTHON_CMD} ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/search_artifacts.py "
|
|
428
|
+
f'--query "{_quote_command_value(query)}" --json'
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _session_memory_command(query: str = "<topic>") -> str:
|
|
433
|
+
return (
|
|
434
|
+
f"{_PYTHON_CMD} ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/search_memory.py "
|
|
435
|
+
f'--query "{_quote_command_value(query)}" --json'
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _smart_search_evidence_command(question: str = "<question>") -> str:
|
|
440
|
+
return (
|
|
441
|
+
f"{_PYTHON_CMD} ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/run_smart_search.py "
|
|
442
|
+
f'"{_quote_command_value(question)}" --intent deep-research --json'
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _query_tokens(*values: object) -> list[str]:
|
|
447
|
+
tokens: list[str] = []
|
|
448
|
+
seen: set[str] = set()
|
|
449
|
+
for value in values:
|
|
450
|
+
if not value:
|
|
451
|
+
continue
|
|
452
|
+
for raw_token in re.findall(r"[\w.-]+", str(value)):
|
|
453
|
+
token = raw_token.strip("._-")
|
|
454
|
+
if not token:
|
|
455
|
+
continue
|
|
456
|
+
key = token.lower()
|
|
457
|
+
if key in seen:
|
|
458
|
+
continue
|
|
459
|
+
seen.add(key)
|
|
460
|
+
tokens.append(token)
|
|
461
|
+
if len(tokens) >= 8:
|
|
462
|
+
return tokens
|
|
463
|
+
return tokens
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _selected_task_query(selected_task: str, task_info: object | None) -> str:
|
|
467
|
+
if task_info:
|
|
468
|
+
tokens = _query_tokens(
|
|
469
|
+
getattr(task_info, "title", ""),
|
|
470
|
+
getattr(task_info, "name", ""),
|
|
471
|
+
getattr(task_info, "package", ""),
|
|
472
|
+
getattr(task_info, "description", ""),
|
|
473
|
+
)
|
|
474
|
+
else:
|
|
475
|
+
tokens = _query_tokens(Path(selected_task).name)
|
|
476
|
+
return " ".join(tokens) if tokens else selected_task
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _recommendation(
|
|
480
|
+
source: str,
|
|
481
|
+
priority: int,
|
|
482
|
+
confidence: str,
|
|
483
|
+
reason: str,
|
|
484
|
+
action: str,
|
|
485
|
+
reference: str,
|
|
486
|
+
) -> dict[str, object]:
|
|
487
|
+
return {
|
|
488
|
+
"source": source,
|
|
489
|
+
"priority": priority,
|
|
490
|
+
"confidence": confidence,
|
|
491
|
+
"reason": reason,
|
|
492
|
+
"action": action,
|
|
493
|
+
"reference": reference,
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _resolve_selected_task_dir(repo_root: Path, selected_task: str) -> Path:
|
|
498
|
+
task_path = Path(selected_task)
|
|
499
|
+
if task_path.is_absolute():
|
|
500
|
+
return task_path
|
|
501
|
+
return repo_root / task_path
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _selected_task_artifacts(
|
|
505
|
+
repo_root: Path,
|
|
506
|
+
selected_task: str | None,
|
|
507
|
+
) -> dict[str, object] | None:
|
|
508
|
+
if not selected_task:
|
|
509
|
+
return None
|
|
510
|
+
|
|
511
|
+
task_dir = _resolve_selected_task_dir(repo_root, selected_task)
|
|
512
|
+
research_dir = task_dir / "research"
|
|
513
|
+
research_count = 0
|
|
514
|
+
if research_dir.is_dir():
|
|
515
|
+
research_count = len([p for p in research_dir.glob("*.md") if p.is_file()])
|
|
516
|
+
|
|
517
|
+
return {
|
|
518
|
+
"taskPath": selected_task,
|
|
519
|
+
"prd": (task_dir / "prd.md").is_file(),
|
|
520
|
+
"design": (task_dir / "design.md").is_file(),
|
|
521
|
+
"implement": (task_dir / "implement.md").is_file(),
|
|
522
|
+
"research": research_dir.is_dir(),
|
|
523
|
+
"researchCount": research_count,
|
|
524
|
+
"verify": (task_dir / "verify.md").is_file(),
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _get_retrieval_guide(
|
|
529
|
+
repo_root: Path,
|
|
530
|
+
selected_task: str | None,
|
|
531
|
+
) -> dict[str, object]:
|
|
532
|
+
guide: dict[str, object] = {
|
|
533
|
+
"artifactSearch": {
|
|
534
|
+
"command": _artifact_search_command(),
|
|
535
|
+
"purpose": (
|
|
536
|
+
"Find durable Trellis specs, tasks, research, verification "
|
|
537
|
+
"notes, and workspace journals."
|
|
538
|
+
),
|
|
539
|
+
},
|
|
540
|
+
"sessionMemory": {
|
|
541
|
+
"command": _session_memory_command(),
|
|
542
|
+
"purpose": (
|
|
543
|
+
"Find reusable session history, prior decisions, and recent "
|
|
544
|
+
"work context from local workspace journals."
|
|
545
|
+
),
|
|
546
|
+
},
|
|
547
|
+
"smartSearchEvidence": {
|
|
548
|
+
"command": _smart_search_evidence_command(),
|
|
549
|
+
"purpose": (
|
|
550
|
+
"Explicitly run Smart Search and save a task-local evidence "
|
|
551
|
+
"manifest for source-backed research."
|
|
552
|
+
),
|
|
553
|
+
},
|
|
554
|
+
"codebaseEvidence": (
|
|
555
|
+
"Treat adapter output as candidate evidence until current source, "
|
|
556
|
+
"Git, or validation confirms it."
|
|
557
|
+
),
|
|
558
|
+
"evidenceSinks": {
|
|
559
|
+
"exploratory": "selected task research/*.md",
|
|
560
|
+
"final": "selected task verify.md",
|
|
561
|
+
},
|
|
562
|
+
}
|
|
563
|
+
selected_artifacts = _selected_task_artifacts(repo_root, selected_task)
|
|
564
|
+
if selected_artifacts is not None:
|
|
565
|
+
guide["selectedTaskArtifacts"] = selected_artifacts
|
|
566
|
+
guide["recommendations"] = _get_retrieval_recommendations(
|
|
567
|
+
repo_root,
|
|
568
|
+
selected_task,
|
|
569
|
+
selected_artifacts,
|
|
570
|
+
)
|
|
571
|
+
return guide
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _get_retrieval_recommendations(
|
|
575
|
+
repo_root: Path,
|
|
576
|
+
selected_task: str | None,
|
|
577
|
+
selected_artifacts: dict[str, object] | None = None,
|
|
578
|
+
) -> list[dict[str, object]]:
|
|
579
|
+
if not selected_task:
|
|
580
|
+
return []
|
|
581
|
+
|
|
582
|
+
task_dir = _resolve_selected_task_dir(repo_root, selected_task)
|
|
583
|
+
task_info = load_task(task_dir)
|
|
584
|
+
artifacts = (
|
|
585
|
+
selected_artifacts
|
|
586
|
+
or _selected_task_artifacts(repo_root, selected_task)
|
|
587
|
+
or {}
|
|
588
|
+
)
|
|
589
|
+
query = _selected_task_query(selected_task, task_info)
|
|
590
|
+
recommendations: list[dict[str, object]] = []
|
|
591
|
+
|
|
592
|
+
has_local_artifacts = any(
|
|
593
|
+
bool(artifacts.get(key))
|
|
594
|
+
for key in ("prd", "design", "implement", "research", "verify")
|
|
595
|
+
)
|
|
596
|
+
if has_local_artifacts:
|
|
597
|
+
recommendations.append(
|
|
598
|
+
_recommendation(
|
|
599
|
+
"task-artifacts",
|
|
600
|
+
100,
|
|
601
|
+
"high",
|
|
602
|
+
"Selected task has local planning or evidence artifacts; read them first.",
|
|
603
|
+
"Read selected task prd.md, design.md, implement.md, research/*.md, and verify.md as present.",
|
|
604
|
+
selected_task,
|
|
605
|
+
)
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
recommendations.extend(
|
|
609
|
+
[
|
|
610
|
+
_recommendation(
|
|
611
|
+
"artifact-search",
|
|
612
|
+
90,
|
|
613
|
+
"high",
|
|
614
|
+
"Search durable Trellis specs, tasks, research, and verification notes for related context.",
|
|
615
|
+
_artifact_search_command(query),
|
|
616
|
+
selected_task,
|
|
617
|
+
),
|
|
618
|
+
_recommendation(
|
|
619
|
+
"session-memory",
|
|
620
|
+
80,
|
|
621
|
+
"medium",
|
|
622
|
+
"Search local session history for prior decisions and recent related work.",
|
|
623
|
+
_session_memory_command(query),
|
|
624
|
+
f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/",
|
|
625
|
+
),
|
|
626
|
+
]
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
if bool(artifacts.get("research")):
|
|
630
|
+
smart_priority = 55
|
|
631
|
+
smart_confidence = "low"
|
|
632
|
+
smart_reason = (
|
|
633
|
+
"Research artifacts already exist; run Smart Search only for current external source gaps."
|
|
634
|
+
)
|
|
635
|
+
else:
|
|
636
|
+
smart_priority = 70
|
|
637
|
+
smart_confidence = "medium"
|
|
638
|
+
smart_reason = (
|
|
639
|
+
"No selected-task research artifacts are present; capture explicit external evidence when needed."
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
recommendations.extend(
|
|
643
|
+
[
|
|
644
|
+
_recommendation(
|
|
645
|
+
"smart-search",
|
|
646
|
+
smart_priority,
|
|
647
|
+
smart_confidence,
|
|
648
|
+
smart_reason,
|
|
649
|
+
_smart_search_evidence_command(query),
|
|
650
|
+
f"{selected_task}/research/smart-search/",
|
|
651
|
+
),
|
|
652
|
+
_recommendation(
|
|
653
|
+
"codebase-evidence",
|
|
654
|
+
60,
|
|
655
|
+
"medium",
|
|
656
|
+
"Confirm candidate codebase evidence against current source, Git state, or validation.",
|
|
657
|
+
"Inspect current source and run focused validation before treating codebase retrieval as proof.",
|
|
658
|
+
"current source tree",
|
|
659
|
+
),
|
|
660
|
+
]
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
recommendations.sort(
|
|
664
|
+
key=lambda item: (-int(item["priority"]), str(item["source"]))
|
|
665
|
+
)
|
|
666
|
+
return recommendations
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _present_missing(value: object) -> str:
|
|
670
|
+
return "present" if bool(value) else "missing"
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _append_retrieval_recommendations(
|
|
674
|
+
lines: list[str],
|
|
675
|
+
recommendations: list[dict[str, object]],
|
|
676
|
+
) -> None:
|
|
677
|
+
if not recommendations:
|
|
678
|
+
return
|
|
679
|
+
lines.append("Retrieval recommendations:")
|
|
680
|
+
for index, recommendation in enumerate(recommendations, start=1):
|
|
681
|
+
source = recommendation.get("source", "")
|
|
682
|
+
confidence = recommendation.get("confidence", "")
|
|
683
|
+
priority = recommendation.get("priority", "")
|
|
684
|
+
reason = recommendation.get("reason", "")
|
|
685
|
+
lines.append(f"{index}. {source} [{confidence}] priority {priority}: {reason}")
|
|
686
|
+
lines.append(f" Action: {recommendation.get('action', '')}")
|
|
687
|
+
lines.append(f" Reference: {recommendation.get('reference', '')}")
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _append_retrieval_guide(
|
|
691
|
+
lines: list[str],
|
|
692
|
+
repo_root: Path,
|
|
693
|
+
selected_task: str | None,
|
|
694
|
+
) -> None:
|
|
695
|
+
lines.append("## RETRIEVAL GUIDE")
|
|
696
|
+
lines.append(f"Artifact search: {_artifact_search_command()}")
|
|
697
|
+
lines.append(f"Session memory: {_session_memory_command()}")
|
|
698
|
+
lines.append(f"Smart Search evidence: {_smart_search_evidence_command()}")
|
|
699
|
+
lines.append(
|
|
700
|
+
"Use artifact search for durable Trellis specs, tasks, research, "
|
|
701
|
+
"verification notes, and workspace journals."
|
|
702
|
+
)
|
|
703
|
+
lines.append(
|
|
704
|
+
"Use session memory for reusable prior decisions and recent work "
|
|
705
|
+
"recorded in local workspace journals."
|
|
706
|
+
)
|
|
707
|
+
lines.append(
|
|
708
|
+
"Run Smart Search evidence only when external/current source evidence is "
|
|
709
|
+
"needed; it writes a task-local manifest under research/smart-search/."
|
|
710
|
+
)
|
|
711
|
+
lines.append(
|
|
712
|
+
"Codebase evidence: adapter output is candidate evidence until current "
|
|
713
|
+
"source, Git, or validation confirms it."
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
if selected_task:
|
|
717
|
+
lines.append(
|
|
718
|
+
f"Evidence sinks: {selected_task}/research/*.md for exploratory "
|
|
719
|
+
f"chains; {selected_task}/verify.md for final proof."
|
|
720
|
+
)
|
|
721
|
+
artifacts = _selected_task_artifacts(repo_root, selected_task)
|
|
722
|
+
if artifacts:
|
|
723
|
+
research_count = int(artifacts.get("researchCount", 0))
|
|
724
|
+
research_state = (
|
|
725
|
+
f"{research_count} markdown file(s)"
|
|
726
|
+
if research_count
|
|
727
|
+
else _present_missing(artifacts.get("research"))
|
|
728
|
+
)
|
|
729
|
+
lines.append("Selected-task artifacts:")
|
|
730
|
+
lines.append(f"- prd.md: {_present_missing(artifacts.get('prd'))}")
|
|
731
|
+
lines.append(f"- design.md: {_present_missing(artifacts.get('design'))}")
|
|
732
|
+
lines.append(
|
|
733
|
+
f"- implement.md: {_present_missing(artifacts.get('implement'))}"
|
|
734
|
+
)
|
|
735
|
+
lines.append(f"- research/: {research_state}")
|
|
736
|
+
lines.append(f"- verify.md: {_present_missing(artifacts.get('verify'))}")
|
|
737
|
+
_append_retrieval_recommendations(
|
|
738
|
+
lines,
|
|
739
|
+
_get_retrieval_recommendations(repo_root, selected_task, artifacts),
|
|
740
|
+
)
|
|
741
|
+
else:
|
|
742
|
+
lines.append(
|
|
743
|
+
"Evidence sinks: task research/*.md for exploratory chains; task "
|
|
744
|
+
"verify.md for final proof."
|
|
745
|
+
)
|
|
746
|
+
lines.append("")
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
# =============================================================================
|
|
750
|
+
# JSON Output
|
|
751
|
+
# =============================================================================
|
|
752
|
+
|
|
753
|
+
def get_context_json(repo_root: Path | None = None) -> dict:
|
|
754
|
+
"""Get context as a dictionary.
|
|
755
|
+
|
|
756
|
+
Args:
|
|
757
|
+
repo_root: Repository root path. Defaults to auto-detected.
|
|
758
|
+
|
|
759
|
+
Returns:
|
|
760
|
+
Context dictionary.
|
|
761
|
+
"""
|
|
762
|
+
if repo_root is None:
|
|
763
|
+
repo_root = get_repo_root()
|
|
764
|
+
|
|
765
|
+
developer = get_developer(repo_root)
|
|
766
|
+
tasks_dir = get_tasks_dir(repo_root)
|
|
767
|
+
journal_file = get_active_journal_file(repo_root)
|
|
768
|
+
|
|
769
|
+
journal_lines = 0
|
|
770
|
+
journal_relative = ""
|
|
771
|
+
if journal_file and developer:
|
|
772
|
+
journal_lines = count_lines(journal_file)
|
|
773
|
+
journal_relative = (
|
|
774
|
+
f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{journal_file.name}"
|
|
775
|
+
)
|
|
776
|
+
|
|
777
|
+
root_git_info = _collect_root_git_info(repo_root)
|
|
778
|
+
|
|
779
|
+
# Tasks
|
|
780
|
+
tasks = [
|
|
781
|
+
{
|
|
782
|
+
"dir": t.dir_name,
|
|
783
|
+
"name": t.name,
|
|
784
|
+
"status": t.status,
|
|
785
|
+
"children": list(t.children),
|
|
786
|
+
"parent": t.parent,
|
|
787
|
+
}
|
|
788
|
+
for t in iter_active_tasks(tasks_dir)
|
|
789
|
+
]
|
|
790
|
+
|
|
791
|
+
# Package git repos (independent sub-repositories)
|
|
792
|
+
pkg_git_info = _collect_package_git_info(
|
|
793
|
+
repo_root,
|
|
794
|
+
discover_unconfigured=not root_git_info["isRepo"],
|
|
795
|
+
)
|
|
796
|
+
selected_task = get_selected_task(repo_root)
|
|
797
|
+
|
|
798
|
+
result = {
|
|
799
|
+
"developer": developer or "",
|
|
800
|
+
"git": {
|
|
801
|
+
"isRepo": root_git_info["isRepo"],
|
|
802
|
+
"branch": root_git_info["branch"],
|
|
803
|
+
"isClean": root_git_info["isClean"],
|
|
804
|
+
"uncommittedChanges": root_git_info["uncommittedChanges"],
|
|
805
|
+
"recentCommits": root_git_info["recentCommits"],
|
|
806
|
+
},
|
|
807
|
+
"tasks": {
|
|
808
|
+
"active": tasks,
|
|
809
|
+
"directory": f"{DIR_WORKFLOW}/{DIR_TASKS}",
|
|
810
|
+
},
|
|
811
|
+
"journal": {
|
|
812
|
+
"file": journal_relative,
|
|
813
|
+
"lines": journal_lines,
|
|
814
|
+
"nearLimit": journal_lines > 1800,
|
|
815
|
+
},
|
|
816
|
+
"retrievalGuide": _get_retrieval_guide(repo_root, selected_task),
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
if pkg_git_info:
|
|
820
|
+
result["packageGit"] = pkg_git_info
|
|
821
|
+
|
|
822
|
+
return result
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def output_json(repo_root: Path | None = None) -> None:
|
|
826
|
+
"""Output context in JSON format.
|
|
827
|
+
|
|
828
|
+
Args:
|
|
829
|
+
repo_root: Repository root path. Defaults to auto-detected.
|
|
830
|
+
"""
|
|
831
|
+
context = get_context_json(repo_root)
|
|
832
|
+
print(json.dumps(context, indent=2, ensure_ascii=False))
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
# =============================================================================
|
|
836
|
+
# Text Output
|
|
837
|
+
# =============================================================================
|
|
838
|
+
|
|
839
|
+
def get_context_text(repo_root: Path | None = None) -> str:
|
|
840
|
+
"""Get context as formatted text.
|
|
841
|
+
|
|
842
|
+
Args:
|
|
843
|
+
repo_root: Repository root path. Defaults to auto-detected.
|
|
844
|
+
|
|
845
|
+
Returns:
|
|
846
|
+
Formatted text output.
|
|
847
|
+
"""
|
|
848
|
+
if repo_root is None:
|
|
849
|
+
repo_root = get_repo_root()
|
|
850
|
+
|
|
851
|
+
lines = []
|
|
852
|
+
lines.append("========================================")
|
|
853
|
+
lines.append("SESSION CONTEXT")
|
|
854
|
+
lines.append("========================================")
|
|
855
|
+
lines.append("")
|
|
856
|
+
|
|
857
|
+
developer = get_developer(repo_root)
|
|
858
|
+
|
|
859
|
+
# Developer section
|
|
860
|
+
lines.append("## DEVELOPER")
|
|
861
|
+
if not developer:
|
|
862
|
+
lines.append(
|
|
863
|
+
f"ERROR: Not initialized. Run: python3 ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <name>"
|
|
864
|
+
)
|
|
865
|
+
return "\n".join(lines)
|
|
866
|
+
|
|
867
|
+
lines.append(f"Name: {developer}")
|
|
868
|
+
lines.append("")
|
|
869
|
+
|
|
870
|
+
root_git_info = _collect_root_git_info(repo_root)
|
|
871
|
+
_append_root_git_context(lines, root_git_info)
|
|
872
|
+
|
|
873
|
+
# Package git repos — independent sub-repositories
|
|
874
|
+
_append_package_git_context(
|
|
875
|
+
lines,
|
|
876
|
+
_collect_package_git_info(
|
|
877
|
+
repo_root,
|
|
878
|
+
discover_unconfigured=not root_git_info["isRepo"],
|
|
879
|
+
),
|
|
880
|
+
)
|
|
881
|
+
|
|
882
|
+
# Selected task
|
|
883
|
+
lines.append("## SELECTED TASK")
|
|
884
|
+
selected_task = get_selected_task(repo_root)
|
|
885
|
+
if selected_task:
|
|
886
|
+
selected_task_dir = repo_root / selected_task
|
|
887
|
+
source_type, context_key, _ = get_selected_task_source(repo_root)
|
|
888
|
+
lines.append(f"Path: {selected_task}")
|
|
889
|
+
lines.append(
|
|
890
|
+
f"Source: {source_type}" + (f":{context_key}" if context_key else "")
|
|
891
|
+
)
|
|
892
|
+
|
|
893
|
+
ct = load_task(selected_task_dir)
|
|
894
|
+
if ct:
|
|
895
|
+
lines.append(f"Name: {ct.name}")
|
|
896
|
+
lines.append(f"Status: {ct.status}")
|
|
897
|
+
lines.append(f"Created: {ct.raw.get('createdAt', 'unknown')}")
|
|
898
|
+
if ct.description:
|
|
899
|
+
lines.append(f"Description: {ct.description}")
|
|
900
|
+
|
|
901
|
+
# Check for prd.md
|
|
902
|
+
prd_file = selected_task_dir / "prd.md"
|
|
903
|
+
if prd_file.is_file():
|
|
904
|
+
lines.append("")
|
|
905
|
+
lines.append("[!] This task has prd.md - read it for task details")
|
|
906
|
+
else:
|
|
907
|
+
lines.append("(none)")
|
|
908
|
+
lines.append("")
|
|
909
|
+
|
|
910
|
+
_append_retrieval_guide(lines, repo_root, selected_task)
|
|
911
|
+
|
|
912
|
+
lines.append("## TASK DASHBOARD")
|
|
913
|
+
lines.extend(render_task_dashboard(repo_root).splitlines())
|
|
914
|
+
lines.append("")
|
|
915
|
+
|
|
916
|
+
# Active tasks
|
|
917
|
+
lines.append("## ACTIVE TASKS")
|
|
918
|
+
tasks_dir = get_tasks_dir(repo_root)
|
|
919
|
+
task_count = 0
|
|
920
|
+
|
|
921
|
+
# Collect all task data for hierarchy display
|
|
922
|
+
all_tasks = {t.dir_name: t for t in iter_active_tasks(tasks_dir)}
|
|
923
|
+
all_statuses = {name: t.status for name, t in all_tasks.items()}
|
|
924
|
+
|
|
925
|
+
def _print_task_tree(name: str, indent: int = 0) -> None:
|
|
926
|
+
nonlocal task_count
|
|
927
|
+
t = all_tasks[name]
|
|
928
|
+
progress = children_progress(t.children, all_statuses)
|
|
929
|
+
prefix = " " * indent
|
|
930
|
+
lines.append(f"{prefix}- {name}/ ({t.status}){progress} @{t.assignee or '-'}")
|
|
931
|
+
task_count += 1
|
|
932
|
+
for child in t.children:
|
|
933
|
+
if child in all_tasks:
|
|
934
|
+
_print_task_tree(child, indent + 1)
|
|
935
|
+
|
|
936
|
+
for dir_name in sorted(all_tasks.keys()):
|
|
937
|
+
if not all_tasks[dir_name].parent:
|
|
938
|
+
_print_task_tree(dir_name)
|
|
939
|
+
|
|
940
|
+
if task_count == 0:
|
|
941
|
+
lines.append("(no active tasks)")
|
|
942
|
+
lines.append(f"Total: {task_count} active task(s)")
|
|
943
|
+
lines.append("")
|
|
944
|
+
|
|
945
|
+
# My tasks
|
|
946
|
+
lines.append("## MY TASKS (Assigned to me)")
|
|
947
|
+
my_task_count = 0
|
|
948
|
+
|
|
949
|
+
for t in all_tasks.values():
|
|
950
|
+
if t.assignee == developer and t.status != "done":
|
|
951
|
+
progress = children_progress(t.children, all_statuses)
|
|
952
|
+
lines.append(f"- [{t.priority}] {t.title} ({t.status}){progress}")
|
|
953
|
+
my_task_count += 1
|
|
954
|
+
|
|
955
|
+
if my_task_count == 0:
|
|
956
|
+
lines.append("(no tasks assigned to you)")
|
|
957
|
+
lines.append("")
|
|
958
|
+
|
|
959
|
+
# Journal file
|
|
960
|
+
lines.append("## JOURNAL FILE")
|
|
961
|
+
journal_file = get_active_journal_file(repo_root)
|
|
962
|
+
if journal_file:
|
|
963
|
+
journal_lines = count_lines(journal_file)
|
|
964
|
+
relative = f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{journal_file.name}"
|
|
965
|
+
lines.append(f"Active file: {relative}")
|
|
966
|
+
lines.append(f"Line count: {journal_lines} / 2000")
|
|
967
|
+
if journal_lines > 1800:
|
|
968
|
+
lines.append("[!] WARNING: Approaching 2000 line limit!")
|
|
969
|
+
else:
|
|
970
|
+
lines.append("No journal file found")
|
|
971
|
+
lines.append("")
|
|
972
|
+
|
|
973
|
+
# Packages
|
|
974
|
+
packages_text = get_packages_section(repo_root)
|
|
975
|
+
if packages_text:
|
|
976
|
+
lines.append(packages_text)
|
|
977
|
+
lines.append("")
|
|
978
|
+
|
|
979
|
+
# Paths
|
|
980
|
+
lines.append("## PATHS")
|
|
981
|
+
lines.append(f"Workspace: {DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/")
|
|
982
|
+
lines.append(f"Tasks: {DIR_WORKFLOW}/{DIR_TASKS}/")
|
|
983
|
+
lines.append(f"Spec: {DIR_WORKFLOW}/{DIR_SPEC}/")
|
|
984
|
+
lines.append("")
|
|
985
|
+
|
|
986
|
+
lines.append("========================================")
|
|
987
|
+
|
|
988
|
+
return "\n".join(lines)
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
# =============================================================================
|
|
992
|
+
# Record Mode
|
|
993
|
+
# =============================================================================
|
|
994
|
+
|
|
995
|
+
def get_context_record_json(repo_root: Path | None = None) -> dict:
|
|
996
|
+
"""Get record-mode context as a dictionary.
|
|
997
|
+
|
|
998
|
+
Focused on: my active tasks, git status, selected task.
|
|
999
|
+
"""
|
|
1000
|
+
if repo_root is None:
|
|
1001
|
+
repo_root = get_repo_root()
|
|
1002
|
+
|
|
1003
|
+
developer = get_developer(repo_root)
|
|
1004
|
+
tasks_dir = get_tasks_dir(repo_root)
|
|
1005
|
+
|
|
1006
|
+
root_git_info = _collect_root_git_info(repo_root)
|
|
1007
|
+
|
|
1008
|
+
# My tasks (single pass — collect statuses and filter by assignee)
|
|
1009
|
+
all_tasks_list = list(iter_active_tasks(tasks_dir))
|
|
1010
|
+
all_statuses = {t.dir_name: t.status for t in all_tasks_list}
|
|
1011
|
+
|
|
1012
|
+
my_tasks = []
|
|
1013
|
+
for t in all_tasks_list:
|
|
1014
|
+
if t.assignee == developer:
|
|
1015
|
+
done = sum(
|
|
1016
|
+
1 for c in t.children
|
|
1017
|
+
if all_statuses.get(c) in ("completed", "done")
|
|
1018
|
+
)
|
|
1019
|
+
my_tasks.append({
|
|
1020
|
+
"dir": t.dir_name,
|
|
1021
|
+
"title": t.title,
|
|
1022
|
+
"status": t.status,
|
|
1023
|
+
"priority": t.priority,
|
|
1024
|
+
"children": list(t.children),
|
|
1025
|
+
"childrenDone": done,
|
|
1026
|
+
"parent": t.parent,
|
|
1027
|
+
"meta": t.meta,
|
|
1028
|
+
})
|
|
1029
|
+
|
|
1030
|
+
# Selected task
|
|
1031
|
+
selected_task_info = None
|
|
1032
|
+
selected_task = get_selected_task(repo_root)
|
|
1033
|
+
if selected_task:
|
|
1034
|
+
source_type, context_key, _ = get_selected_task_source(repo_root)
|
|
1035
|
+
ct = load_task(repo_root / selected_task)
|
|
1036
|
+
if ct:
|
|
1037
|
+
selected_task_info = {
|
|
1038
|
+
"path": selected_task,
|
|
1039
|
+
"name": ct.name,
|
|
1040
|
+
"status": ct.status,
|
|
1041
|
+
"source": source_type,
|
|
1042
|
+
"contextKey": context_key,
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
# Package git repos
|
|
1046
|
+
pkg_git_info = _collect_package_git_info(
|
|
1047
|
+
repo_root,
|
|
1048
|
+
discover_unconfigured=not root_git_info["isRepo"],
|
|
1049
|
+
)
|
|
1050
|
+
|
|
1051
|
+
result = {
|
|
1052
|
+
"developer": developer or "",
|
|
1053
|
+
"git": {
|
|
1054
|
+
"isRepo": root_git_info["isRepo"],
|
|
1055
|
+
"branch": root_git_info["branch"],
|
|
1056
|
+
"isClean": root_git_info["isClean"],
|
|
1057
|
+
"uncommittedChanges": root_git_info["uncommittedChanges"],
|
|
1058
|
+
"recentCommits": root_git_info["recentCommits"],
|
|
1059
|
+
},
|
|
1060
|
+
"myTasks": my_tasks,
|
|
1061
|
+
"selectedTask": selected_task_info,
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
if pkg_git_info:
|
|
1065
|
+
result["packageGit"] = pkg_git_info
|
|
1066
|
+
|
|
1067
|
+
return result
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
def get_context_text_record(repo_root: Path | None = None) -> str:
|
|
1071
|
+
"""Get context as formatted text for record-session mode.
|
|
1072
|
+
|
|
1073
|
+
Focused output: MY ACTIVE TASKS first (with [!!!] emphasis),
|
|
1074
|
+
then GIT STATUS, RECENT COMMITS, SELECTED TASK.
|
|
1075
|
+
"""
|
|
1076
|
+
if repo_root is None:
|
|
1077
|
+
repo_root = get_repo_root()
|
|
1078
|
+
|
|
1079
|
+
lines: list[str] = []
|
|
1080
|
+
lines.append("========================================")
|
|
1081
|
+
lines.append("SESSION CONTEXT (RECORD MODE)")
|
|
1082
|
+
lines.append("========================================")
|
|
1083
|
+
lines.append("")
|
|
1084
|
+
|
|
1085
|
+
developer = get_developer(repo_root)
|
|
1086
|
+
if not developer:
|
|
1087
|
+
lines.append(
|
|
1088
|
+
f"ERROR: Not initialized. Run: python3 ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <name>"
|
|
1089
|
+
)
|
|
1090
|
+
return "\n".join(lines)
|
|
1091
|
+
|
|
1092
|
+
# MY ACTIVE TASKS — first and prominent
|
|
1093
|
+
lines.append(f"## [!!!] MY ACTIVE TASKS (Assigned to {developer})")
|
|
1094
|
+
lines.append("[!] Review whether any should be archived before recording this session.")
|
|
1095
|
+
lines.append("")
|
|
1096
|
+
|
|
1097
|
+
tasks_dir = get_tasks_dir(repo_root)
|
|
1098
|
+
my_task_count = 0
|
|
1099
|
+
|
|
1100
|
+
# Single pass — collect all tasks and filter by assignee
|
|
1101
|
+
all_statuses = get_all_statuses(tasks_dir)
|
|
1102
|
+
|
|
1103
|
+
for t in iter_active_tasks(tasks_dir):
|
|
1104
|
+
if t.assignee == developer:
|
|
1105
|
+
progress = children_progress(t.children, all_statuses)
|
|
1106
|
+
lines.append(f"- [{t.priority}] {t.title} ({t.status}){progress} — {t.dir_name}")
|
|
1107
|
+
my_task_count += 1
|
|
1108
|
+
|
|
1109
|
+
if my_task_count == 0:
|
|
1110
|
+
lines.append("(no active tasks assigned to you)")
|
|
1111
|
+
lines.append("")
|
|
1112
|
+
|
|
1113
|
+
root_git_info = _collect_root_git_info(repo_root)
|
|
1114
|
+
_append_root_git_context(lines, root_git_info)
|
|
1115
|
+
|
|
1116
|
+
# Package git repos — independent sub-repositories
|
|
1117
|
+
_append_package_git_context(
|
|
1118
|
+
lines,
|
|
1119
|
+
_collect_package_git_info(
|
|
1120
|
+
repo_root,
|
|
1121
|
+
discover_unconfigured=not root_git_info["isRepo"],
|
|
1122
|
+
),
|
|
1123
|
+
)
|
|
1124
|
+
|
|
1125
|
+
# SELECTED TASK
|
|
1126
|
+
lines.append("## SELECTED TASK")
|
|
1127
|
+
selected_task = get_selected_task(repo_root)
|
|
1128
|
+
if selected_task:
|
|
1129
|
+
source_type, context_key, _ = get_selected_task_source(repo_root)
|
|
1130
|
+
lines.append(f"Path: {selected_task}")
|
|
1131
|
+
lines.append(
|
|
1132
|
+
f"Source: {source_type}" + (f":{context_key}" if context_key else "")
|
|
1133
|
+
)
|
|
1134
|
+
ct = load_task(repo_root / selected_task)
|
|
1135
|
+
if ct:
|
|
1136
|
+
lines.append(f"Name: {ct.name}")
|
|
1137
|
+
lines.append(f"Status: {ct.status}")
|
|
1138
|
+
else:
|
|
1139
|
+
lines.append("(none)")
|
|
1140
|
+
lines.append("")
|
|
1141
|
+
|
|
1142
|
+
lines.append("========================================")
|
|
1143
|
+
|
|
1144
|
+
return "\n".join(lines)
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
def output_text(repo_root: Path | None = None) -> None:
|
|
1148
|
+
"""Output context in text format.
|
|
1149
|
+
|
|
1150
|
+
Args:
|
|
1151
|
+
repo_root: Repository root path. Defaults to auto-detected.
|
|
1152
|
+
"""
|
|
1153
|
+
if repo_root is None:
|
|
1154
|
+
repo_root = get_repo_root()
|
|
1155
|
+
update_hint = _get_update_hint(repo_root)
|
|
1156
|
+
if update_hint:
|
|
1157
|
+
print(update_hint)
|
|
1158
|
+
print("")
|
|
1159
|
+
print(get_context_text(repo_root))
|