@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.
@@ -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, isSubmodule.
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
- return {
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
- return {
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. Add task-specific specs: python3 task.py add-context <dir> <jsonl> <path>")
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mindfoldhq/trellis",
3
- "version": "0.4.0-beta.3",
3
+ "version": "0.4.0-beta.4",
4
4
  "description": "AI capabilities grow like ivy — Trellis provides the structure to guide them along a disciplined path",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",