@mindfoldhq/trellis 0.4.0-beta.3 → 0.4.0-beta.4
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/dist/migrations/manifests/0.4.0-beta.4.json +9 -0
- package/dist/templates/trellis/scripts/common/config.py +40 -0
- package/dist/templates/trellis/scripts/common/packages_context.py +9 -4
- package/dist/templates/trellis/scripts/common/session_context.py +98 -2
- package/dist/templates/trellis/scripts/common/task_context.py +27 -1
- package/package.json +1 -1
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "0.4.0-beta.4",
|
|
3
|
+
"description": "Git repo context for monorepo packages + improved init-context hints",
|
|
4
|
+
"breaking": false,
|
|
5
|
+
"recommendMigrate": false,
|
|
6
|
+
"changelog": "**Enhancements:**\n- feat(context): show git status and recent commits for independent sub-repo packages (config `git: true`)\n- feat(task): init-context now lists auto-injected defaults and available spec files to guide AI agents\n- feat(commands): add /trellis:publish-skill slash command\n- feat(marketplace): add cc-codex-spec-bootstrap skill\n\n**Bug Fixes:**\n- fix(context): use _is_true_config_value for isGitRepo consistency (case-insensitive)",
|
|
7
|
+
"migrations": [],
|
|
8
|
+
"notes": "Run `trellis update` to sync. Projects with independent git sub-repos can now add `git: true` to package config to see their status in session context."
|
|
9
|
+
}
|
|
@@ -21,6 +21,15 @@ DEFAULT_MAX_JOURNAL_LINES = 2000
|
|
|
21
21
|
CONFIG_FILE = "config.yaml"
|
|
22
22
|
|
|
23
23
|
|
|
24
|
+
def _is_true_config_value(value: object) -> bool:
|
|
25
|
+
"""Return True when a config value represents an enabled flag."""
|
|
26
|
+
if isinstance(value, bool):
|
|
27
|
+
return value
|
|
28
|
+
if isinstance(value, str):
|
|
29
|
+
return value.strip().lower() == "true"
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
|
|
24
33
|
def _get_config_path(repo_root: Path | None = None) -> Path:
|
|
25
34
|
"""Get path to config.yaml."""
|
|
26
35
|
root = repo_root or get_repo_root()
|
|
@@ -130,6 +139,37 @@ def get_submodule_packages(repo_root: Path | None = None) -> dict[str, str]:
|
|
|
130
139
|
}
|
|
131
140
|
|
|
132
141
|
|
|
142
|
+
def get_git_packages(repo_root: Path | None = None) -> dict[str, str]:
|
|
143
|
+
"""Get packages that have their own independent git repository.
|
|
144
|
+
|
|
145
|
+
These are sub-directories with their own .git (not submodules),
|
|
146
|
+
marked with ``git: true`` in config.yaml.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Dict mapping package name to its path for git-repo packages.
|
|
150
|
+
Empty dict if none configured.
|
|
151
|
+
|
|
152
|
+
Example config::
|
|
153
|
+
|
|
154
|
+
packages:
|
|
155
|
+
backend:
|
|
156
|
+
path: iqs
|
|
157
|
+
git: true
|
|
158
|
+
|
|
159
|
+
Example return::
|
|
160
|
+
|
|
161
|
+
{"backend": "iqs"}
|
|
162
|
+
"""
|
|
163
|
+
packages = get_packages(repo_root)
|
|
164
|
+
if packages is None:
|
|
165
|
+
return {}
|
|
166
|
+
return {
|
|
167
|
+
name: cfg.get("path", name)
|
|
168
|
+
for name, cfg in packages.items()
|
|
169
|
+
if _is_true_config_value(cfg.get("git"))
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
133
173
|
def is_monorepo(repo_root: Path | None = None) -> bool:
|
|
134
174
|
"""Check if the project is configured as a monorepo (has packages in config)."""
|
|
135
175
|
return get_packages(repo_root) is not None
|
|
@@ -13,7 +13,7 @@ from __future__ import annotations
|
|
|
13
13
|
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
|
|
16
|
-
from .config import get_default_package, get_packages, get_spec_scope
|
|
16
|
+
from .config import _is_true_config_value, get_default_package, get_packages, get_spec_scope
|
|
17
17
|
from .paths import (
|
|
18
18
|
DIR_SPEC,
|
|
19
19
|
DIR_WORKFLOW,
|
|
@@ -91,7 +91,8 @@ def _resolve_scope_set(
|
|
|
91
91
|
def get_packages_info(repo_root: Path) -> list[dict]:
|
|
92
92
|
"""Get structured package info for monorepo projects.
|
|
93
93
|
|
|
94
|
-
Returns list of dicts with keys: name, path, type, default, specLayers,
|
|
94
|
+
Returns list of dicts with keys: name, path, type, default, specLayers,
|
|
95
|
+
isSubmodule, isGitRepo.
|
|
95
96
|
Returns empty list for single-repo projects.
|
|
96
97
|
"""
|
|
97
98
|
packages = get_packages(repo_root)
|
|
@@ -105,6 +106,7 @@ def get_packages_info(repo_root: Path) -> list[dict]:
|
|
|
105
106
|
for pkg_name, pkg_config in packages.items():
|
|
106
107
|
pkg_path = pkg_config.get("path", pkg_name) if isinstance(pkg_config, dict) else str(pkg_config)
|
|
107
108
|
pkg_type = pkg_config.get("type", "local") if isinstance(pkg_config, dict) else "local"
|
|
109
|
+
pkg_git = pkg_config.get("git", False) if isinstance(pkg_config, dict) else False
|
|
108
110
|
layers = _scan_spec_layers(spec_dir, pkg_name)
|
|
109
111
|
|
|
110
112
|
result.append({
|
|
@@ -114,6 +116,7 @@ def get_packages_info(repo_root: Path) -> list[dict]:
|
|
|
114
116
|
"default": pkg_name == default_pkg,
|
|
115
117
|
"specLayers": layers,
|
|
116
118
|
"isSubmodule": pkg_type == "submodule",
|
|
119
|
+
"isGitRepo": _is_true_config_value(pkg_git),
|
|
117
120
|
})
|
|
118
121
|
|
|
119
122
|
return result
|
|
@@ -139,9 +142,10 @@ def get_packages_section(repo_root: Path) -> str:
|
|
|
139
142
|
for pkg in pkg_info:
|
|
140
143
|
layers_str = f" [{', '.join(pkg['specLayers'])}]" if pkg["specLayers"] else ""
|
|
141
144
|
submodule_tag = " (submodule)" if pkg["isSubmodule"] else ""
|
|
145
|
+
git_repo_tag = " (git repo)" if pkg["isGitRepo"] else ""
|
|
142
146
|
default_tag = " *" if pkg["default"] else ""
|
|
143
147
|
lines.append(
|
|
144
|
-
f"- {pkg['name']:<16} {pkg['path']:<20}{layers_str}{submodule_tag}{default_tag}"
|
|
148
|
+
f"- {pkg['name']:<16} {pkg['path']:<20}{layers_str}{submodule_tag}{git_repo_tag}{default_tag}"
|
|
145
149
|
)
|
|
146
150
|
|
|
147
151
|
if default_pkg:
|
|
@@ -179,13 +183,14 @@ def get_context_packages_text(repo_root: Path | None = None) -> str:
|
|
|
179
183
|
for pkg in pkg_info:
|
|
180
184
|
default_tag = " (default)" if pkg["default"] else ""
|
|
181
185
|
type_tag = f" [{pkg['type']}]" if pkg["type"] != "local" else ""
|
|
186
|
+
git_tag = " [git repo]" if pkg["isGitRepo"] else ""
|
|
182
187
|
|
|
183
188
|
# Scope annotation
|
|
184
189
|
scope_tag = ""
|
|
185
190
|
if scope_set is not None and pkg["name"] not in scope_set:
|
|
186
191
|
scope_tag = " (out of scope)"
|
|
187
192
|
|
|
188
|
-
lines.append(f"### {pkg['name']}{default_tag}{type_tag}{scope_tag}")
|
|
193
|
+
lines.append(f"### {pkg['name']}{default_tag}{type_tag}{git_tag}{scope_tag}")
|
|
189
194
|
lines.append(f"Path: {pkg['path']}")
|
|
190
195
|
if pkg["specLayers"]:
|
|
191
196
|
lines.append(f"Spec layers: {', '.join(pkg['specLayers'])}")
|
|
@@ -16,6 +16,7 @@ from __future__ import annotations
|
|
|
16
16
|
import json
|
|
17
17
|
from pathlib import Path
|
|
18
18
|
|
|
19
|
+
from .config import get_git_packages
|
|
19
20
|
from .git import run_git
|
|
20
21
|
from .packages_context import get_packages_section
|
|
21
22
|
from .tasks import iter_active_tasks, load_task, get_all_statuses, children_progress
|
|
@@ -34,6 +35,79 @@ from .paths import (
|
|
|
34
35
|
)
|
|
35
36
|
|
|
36
37
|
|
|
38
|
+
# =============================================================================
|
|
39
|
+
# Helpers
|
|
40
|
+
# =============================================================================
|
|
41
|
+
|
|
42
|
+
def _collect_package_git_info(repo_root: Path) -> list[dict]:
|
|
43
|
+
"""Collect git status and recent commits for packages with independent git repos.
|
|
44
|
+
|
|
45
|
+
Only packages marked with ``git: true`` in config.yaml are included.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
List of dicts with keys: name, path, branch, isClean,
|
|
49
|
+
uncommittedChanges, recentCommits.
|
|
50
|
+
Empty list if no git-repo packages are configured.
|
|
51
|
+
"""
|
|
52
|
+
git_pkgs = get_git_packages(repo_root)
|
|
53
|
+
if not git_pkgs:
|
|
54
|
+
return []
|
|
55
|
+
|
|
56
|
+
result = []
|
|
57
|
+
for pkg_name, pkg_path in git_pkgs.items():
|
|
58
|
+
pkg_dir = repo_root / pkg_path
|
|
59
|
+
if not (pkg_dir / ".git").exists():
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
_, branch_out, _ = run_git(["branch", "--show-current"], cwd=pkg_dir)
|
|
63
|
+
branch = branch_out.strip() or "unknown"
|
|
64
|
+
|
|
65
|
+
_, status_out, _ = run_git(["status", "--porcelain"], cwd=pkg_dir)
|
|
66
|
+
changes = len([l for l in status_out.splitlines() if l.strip()])
|
|
67
|
+
|
|
68
|
+
_, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=pkg_dir)
|
|
69
|
+
commits = []
|
|
70
|
+
for line in log_out.splitlines():
|
|
71
|
+
if line.strip():
|
|
72
|
+
parts = line.split(" ", 1)
|
|
73
|
+
if len(parts) >= 2:
|
|
74
|
+
commits.append({"hash": parts[0], "message": parts[1]})
|
|
75
|
+
elif len(parts) == 1:
|
|
76
|
+
commits.append({"hash": parts[0], "message": ""})
|
|
77
|
+
|
|
78
|
+
result.append({
|
|
79
|
+
"name": pkg_name,
|
|
80
|
+
"path": pkg_path,
|
|
81
|
+
"branch": branch,
|
|
82
|
+
"isClean": changes == 0,
|
|
83
|
+
"uncommittedChanges": changes,
|
|
84
|
+
"recentCommits": commits,
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
return result
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _append_package_git_context(lines: list[str], package_git_info: list[dict]) -> None:
|
|
91
|
+
"""Append Git status and recent commits for package repositories."""
|
|
92
|
+
for pkg in package_git_info:
|
|
93
|
+
lines.append(f"## GIT STATUS ({pkg['name']}: {pkg['path']})")
|
|
94
|
+
lines.append(f"Branch: {pkg['branch']}")
|
|
95
|
+
if pkg["isClean"]:
|
|
96
|
+
lines.append("Working directory: Clean")
|
|
97
|
+
else:
|
|
98
|
+
lines.append(
|
|
99
|
+
f"Working directory: {pkg['uncommittedChanges']} uncommitted change(s)"
|
|
100
|
+
)
|
|
101
|
+
lines.append("")
|
|
102
|
+
lines.append(f"## RECENT COMMITS ({pkg['name']}: {pkg['path']})")
|
|
103
|
+
if pkg["recentCommits"]:
|
|
104
|
+
for commit in pkg["recentCommits"]:
|
|
105
|
+
lines.append(f"{commit['hash']} {commit['message']}")
|
|
106
|
+
else:
|
|
107
|
+
lines.append("(no commits)")
|
|
108
|
+
lines.append("")
|
|
109
|
+
|
|
110
|
+
|
|
37
111
|
# =============================================================================
|
|
38
112
|
# JSON Output
|
|
39
113
|
# =============================================================================
|
|
@@ -93,7 +167,10 @@ def get_context_json(repo_root: Path | None = None) -> dict:
|
|
|
93
167
|
for t in iter_active_tasks(tasks_dir)
|
|
94
168
|
]
|
|
95
169
|
|
|
96
|
-
|
|
170
|
+
# Package git repos (independent sub-repositories)
|
|
171
|
+
pkg_git_info = _collect_package_git_info(repo_root)
|
|
172
|
+
|
|
173
|
+
result = {
|
|
97
174
|
"developer": developer or "",
|
|
98
175
|
"git": {
|
|
99
176
|
"branch": branch,
|
|
@@ -112,6 +189,11 @@ def get_context_json(repo_root: Path | None = None) -> dict:
|
|
|
112
189
|
},
|
|
113
190
|
}
|
|
114
191
|
|
|
192
|
+
if pkg_git_info:
|
|
193
|
+
result["packageGit"] = pkg_git_info
|
|
194
|
+
|
|
195
|
+
return result
|
|
196
|
+
|
|
115
197
|
|
|
116
198
|
def output_json(repo_root: Path | None = None) -> None:
|
|
117
199
|
"""Output context in JSON format.
|
|
@@ -189,6 +271,9 @@ def get_context_text(repo_root: Path | None = None) -> str:
|
|
|
189
271
|
lines.append("(no commits)")
|
|
190
272
|
lines.append("")
|
|
191
273
|
|
|
274
|
+
# Package git repos — independent sub-repositories
|
|
275
|
+
_append_package_git_context(lines, _collect_package_git_info(repo_root))
|
|
276
|
+
|
|
192
277
|
# Current task
|
|
193
278
|
lines.append("## CURRENT TASK")
|
|
194
279
|
current_task = get_current_task(repo_root)
|
|
@@ -352,7 +437,10 @@ def get_context_record_json(repo_root: Path | None = None) -> dict:
|
|
|
352
437
|
"status": ct.status,
|
|
353
438
|
}
|
|
354
439
|
|
|
355
|
-
|
|
440
|
+
# Package git repos
|
|
441
|
+
pkg_git_info = _collect_package_git_info(repo_root)
|
|
442
|
+
|
|
443
|
+
result = {
|
|
356
444
|
"developer": developer or "",
|
|
357
445
|
"git": {
|
|
358
446
|
"branch": branch,
|
|
@@ -364,6 +452,11 @@ def get_context_record_json(repo_root: Path | None = None) -> dict:
|
|
|
364
452
|
"currentTask": current_task_info,
|
|
365
453
|
}
|
|
366
454
|
|
|
455
|
+
if pkg_git_info:
|
|
456
|
+
result["packageGit"] = pkg_git_info
|
|
457
|
+
|
|
458
|
+
return result
|
|
459
|
+
|
|
367
460
|
|
|
368
461
|
def get_context_text_record(repo_root: Path | None = None) -> str:
|
|
369
462
|
"""Get context as formatted text for record-session mode.
|
|
@@ -439,6 +532,9 @@ def get_context_text_record(repo_root: Path | None = None) -> str:
|
|
|
439
532
|
lines.append("(no commits)")
|
|
440
533
|
lines.append("")
|
|
441
534
|
|
|
535
|
+
# Package git repos — independent sub-repositories
|
|
536
|
+
_append_package_git_context(lines, _collect_package_git_info(repo_root))
|
|
537
|
+
|
|
442
538
|
# CURRENT TASK
|
|
443
539
|
lines.append("## CURRENT TASK")
|
|
444
540
|
current_task = get_current_task(repo_root)
|
|
@@ -194,8 +194,34 @@ def cmd_init_context(args: argparse.Namespace) -> int:
|
|
|
194
194
|
print()
|
|
195
195
|
print(colored("✓ All context files created", Colors.GREEN))
|
|
196
196
|
print()
|
|
197
|
+
|
|
198
|
+
# Show what was auto-injected
|
|
199
|
+
all_injected = [e["file"] for e in implement_entries]
|
|
200
|
+
print(colored("Auto-injected (defaults only):", Colors.YELLOW))
|
|
201
|
+
for f in all_injected:
|
|
202
|
+
print(f" - {f}")
|
|
203
|
+
print()
|
|
204
|
+
|
|
205
|
+
# Scan spec directory for available spec files the AI should consider
|
|
206
|
+
spec_base = repo_root / DIR_WORKFLOW / DIR_SPEC
|
|
207
|
+
if package:
|
|
208
|
+
spec_base = spec_base / package
|
|
209
|
+
available_specs: list[str] = []
|
|
210
|
+
if spec_base.is_dir():
|
|
211
|
+
for md_file in sorted(spec_base.rglob("*.md")):
|
|
212
|
+
rel = str(md_file.relative_to(repo_root))
|
|
213
|
+
if rel not in all_injected:
|
|
214
|
+
available_specs.append(rel)
|
|
215
|
+
|
|
216
|
+
if available_specs:
|
|
217
|
+
print(colored("Available spec files (not yet injected):", Colors.BLUE))
|
|
218
|
+
for spec in available_specs:
|
|
219
|
+
print(f" - {spec}")
|
|
220
|
+
print()
|
|
221
|
+
|
|
197
222
|
print(colored("Next steps:", Colors.BLUE))
|
|
198
|
-
print(" 1.
|
|
223
|
+
print(" 1. Review the spec files above and add relevant ones for your task:")
|
|
224
|
+
print(f" python3 task.py add-context <dir> implement <spec-path> \"<reason>\"")
|
|
199
225
|
print(" 2. Set as current: python3 task.py start <dir>")
|
|
200
226
|
|
|
201
227
|
return 0
|
package/package.json
CHANGED