@mindfoldhq/trellis 0.3.5 → 0.3.6

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.
Files changed (55) hide show
  1. package/README.md +6 -4
  2. package/dist/cli/index.js +1 -0
  3. package/dist/cli/index.js.map +1 -1
  4. package/dist/commands/init.d.ts +1 -0
  5. package/dist/commands/init.d.ts.map +1 -1
  6. package/dist/commands/init.js +243 -49
  7. package/dist/commands/init.js.map +1 -1
  8. package/dist/commands/update.d.ts.map +1 -1
  9. package/dist/commands/update.js +3 -0
  10. package/dist/commands/update.js.map +1 -1
  11. package/dist/migrations/manifests/0.3.6.json +9 -0
  12. package/dist/templates/claude/commands/trellis/brainstorm.md +13 -0
  13. package/dist/templates/claude/commands/trellis/record-session.md +4 -1
  14. package/dist/templates/claude/commands/trellis/start.md +4 -0
  15. package/dist/templates/claude/hooks/inject-subagent-context.py +1 -1
  16. package/dist/templates/claude/settings.json +10 -0
  17. package/dist/templates/codex/skills/brainstorm/SKILL.md +13 -0
  18. package/dist/templates/codex/skills/record-session/SKILL.md +4 -1
  19. package/dist/templates/codex/skills/start/SKILL.md +4 -0
  20. package/dist/templates/cursor/commands/trellis-brainstorm.md +13 -0
  21. package/dist/templates/cursor/commands/trellis-record-session.md +4 -1
  22. package/dist/templates/cursor/commands/trellis-start.md +4 -0
  23. package/dist/templates/gemini/commands/trellis/brainstorm.toml +15 -0
  24. package/dist/templates/gemini/commands/trellis/record-session.toml +4 -1
  25. package/dist/templates/gemini/commands/trellis/start.toml +4 -0
  26. package/dist/templates/iflow/commands/trellis/brainstorm.md +13 -0
  27. package/dist/templates/iflow/commands/trellis/record-session.md +4 -1
  28. package/dist/templates/iflow/commands/trellis/start.md +4 -0
  29. package/dist/templates/iflow/hooks/inject-subagent-context.py +1 -1
  30. package/dist/templates/kilo/workflows/brainstorm.md +13 -0
  31. package/dist/templates/kilo/workflows/record-session.md +4 -1
  32. package/dist/templates/kilo/workflows/start.md +4 -0
  33. package/dist/templates/kiro/skills/brainstorm/SKILL.md +13 -0
  34. package/dist/templates/kiro/skills/record-session/SKILL.md +4 -1
  35. package/dist/templates/kiro/skills/start/SKILL.md +4 -0
  36. package/dist/templates/markdown/spec/backend/directory-structure.md +292 -0
  37. package/dist/templates/markdown/spec/backend/script-conventions.md +220 -38
  38. package/dist/templates/opencode/commands/trellis/brainstorm.md +13 -0
  39. package/dist/templates/opencode/commands/trellis/record-session.md +4 -1
  40. package/dist/templates/opencode/commands/trellis/start.md +4 -0
  41. package/dist/templates/qoder/skills/brainstorm/SKILL.md +13 -0
  42. package/dist/templates/qoder/skills/record-session/SKILL.md +4 -1
  43. package/dist/templates/qoder/skills/start/SKILL.md +4 -0
  44. package/dist/templates/trellis/config.yaml +18 -0
  45. package/dist/templates/trellis/scripts/common/config.py +20 -0
  46. package/dist/templates/trellis/scripts/common/git_context.py +160 -12
  47. package/dist/templates/trellis/scripts/common/task_queue.py +4 -0
  48. package/dist/templates/trellis/scripts/common/worktree.py +78 -11
  49. package/dist/templates/trellis/scripts/create_bootstrap.py +3 -0
  50. package/dist/templates/trellis/scripts/task.py +312 -17
  51. package/dist/utils/template-fetcher.d.ts +57 -4
  52. package/dist/utils/template-fetcher.d.ts.map +1 -1
  53. package/dist/utils/template-fetcher.js +179 -10
  54. package/dist/utils/template-fetcher.js.map +1 -1
  55. package/package.json +7 -7
@@ -17,7 +17,10 @@ description: "Record work progress after human has tested and committed code"
17
17
  python3 ./.trellis/scripts/get_context.py --mode record
18
18
  ```
19
19
 
20
- [!] If MY ACTIVE TASKS shows any completed tasks, archive them FIRST:
20
+ [!] Archive tasks whose work is **actually done** — judge by work status, not the `status` field in task.json:
21
+ - Code committed? → Archive it (don't wait for PR)
22
+ - All acceptance criteria met? → Archive it
23
+ - Don't skip archiving just because `status` still says `planning` or `in_progress`
21
24
 
22
25
  ```bash
23
26
  python3 ./.trellis/scripts/task.py archive <task-name>
@@ -73,6 +73,10 @@ When user describes a task, classify it:
73
73
  > Task Workflow ensures specs are injected to agents, resulting in higher quality code.
74
74
  > The overhead is minimal, but the benefit is significant.
75
75
 
76
+ > **Subtask Decomposition**: If a task has multiple independent work items,
77
+ > consider creating subtasks using `--parent` flag or `add-subtask` command.
78
+ > See the brainstorm skill's Step 8 for details.
79
+
76
80
  ---
77
81
 
78
82
  ## Question / Trivial Fix
@@ -13,3 +13,21 @@ session_commit_message: "chore: record journal"
13
13
 
14
14
  # Maximum lines per journal file before rotating to a new one
15
15
  max_journal_lines: 2000
16
+
17
+ #-------------------------------------------------------------------------------
18
+ # Task Lifecycle Hooks
19
+ #-------------------------------------------------------------------------------
20
+
21
+ # Shell commands to run after task lifecycle events.
22
+ # Each hook receives TASK_JSON_PATH environment variable pointing to task.json.
23
+ # Hook failures print a warning but do not block the main operation.
24
+ #
25
+ # hooks:
26
+ # after_create:
27
+ # - "echo 'Task created'"
28
+ # after_start:
29
+ # - "echo 'Task started'"
30
+ # after_finish:
31
+ # - "echo 'Task finished'"
32
+ # after_archive:
33
+ # - "echo 'Task archived'"
@@ -50,3 +50,23 @@ def get_max_journal_lines(repo_root: Path | None = None) -> int:
50
50
  return int(value)
51
51
  except (ValueError, TypeError):
52
52
  return DEFAULT_MAX_JOURNAL_LINES
53
+
54
+
55
+ def get_hooks(event: str, repo_root: Path | None = None) -> list[str]:
56
+ """Get hook commands for a lifecycle event.
57
+
58
+ Args:
59
+ event: Event name (e.g. "after_create", "after_archive").
60
+ repo_root: Repository root path.
61
+
62
+ Returns:
63
+ List of shell commands to execute, empty if none configured.
64
+ """
65
+ config = _load_config(repo_root)
66
+ hooks = config.get("hooks")
67
+ if not isinstance(hooks, dict):
68
+ return []
69
+ commands = hooks.get(event)
70
+ if isinstance(commands, list):
71
+ return [str(c) for c in commands]
72
+ return []
@@ -11,7 +11,6 @@ Provides:
11
11
  from __future__ import annotations
12
12
 
13
13
  import json
14
- import sys
15
14
  import subprocess
16
15
  from pathlib import Path
17
16
 
@@ -127,6 +126,8 @@ def get_context_json(repo_root: Path | None = None) -> dict:
127
126
  "dir": d.name,
128
127
  "name": data.get("name") or data.get("id") or "unknown",
129
128
  "status": data.get("status", "unknown"),
129
+ "children": data.get("children", []),
130
+ "parent": data.get("parent"),
130
131
  }
131
132
  )
132
133
 
@@ -263,6 +264,8 @@ def get_context_text(repo_root: Path | None = None) -> str:
263
264
  tasks_dir = get_tasks_dir(repo_root)
264
265
  task_count = 0
265
266
 
267
+ # Collect all task data for hierarchy display
268
+ all_task_data: dict[str, dict] = {}
266
269
  if tasks_dir.is_dir():
267
270
  for d in sorted(tasks_dir.iterdir()):
268
271
  if d.is_dir() and d.name != "archive":
@@ -270,15 +273,47 @@ def get_context_text(repo_root: Path | None = None) -> str:
270
273
  t_json = d / FILE_TASK_JSON
271
274
  status = "unknown"
272
275
  assignee = "-"
276
+ children: list[str] = []
277
+ parent: str | None = None
273
278
 
274
279
  if t_json.is_file():
275
280
  data = _read_json_file(t_json)
276
281
  if data:
277
282
  status = data.get("status", "unknown")
278
283
  assignee = data.get("assignee", "-")
279
-
280
- lines.append(f"- {dir_name}/ ({status}) @{assignee}")
281
- task_count += 1
284
+ children = data.get("children", [])
285
+ parent = data.get("parent")
286
+
287
+ all_task_data[dir_name] = {
288
+ "status": status,
289
+ "assignee": assignee,
290
+ "children": children,
291
+ "parent": parent,
292
+ }
293
+
294
+ def _children_progress(children_list: list[str]) -> str:
295
+ if not children_list:
296
+ return ""
297
+ done = 0
298
+ for c in children_list:
299
+ if c in all_task_data and all_task_data[c]["status"] in ("completed", "done"):
300
+ done += 1
301
+ return f" [{done}/{len(children_list)} done]"
302
+
303
+ def _print_task_tree(name: str, indent: int = 0) -> None:
304
+ nonlocal task_count
305
+ info = all_task_data[name]
306
+ progress = _children_progress(info["children"]) if info["children"] else ""
307
+ prefix = " " * indent
308
+ lines.append(f"{prefix}- {name}/ ({info['status']}){progress} @{info['assignee']}")
309
+ task_count += 1
310
+ for child in info["children"]:
311
+ if child in all_task_data:
312
+ _print_task_tree(child, indent + 1)
313
+
314
+ for dir_name in sorted(all_task_data.keys()):
315
+ if not all_task_data[dir_name]["parent"]:
316
+ _print_task_tree(dir_name)
282
317
 
283
318
  if task_count == 0:
284
319
  lines.append("(no active tasks)")
@@ -302,7 +337,9 @@ def get_context_text(repo_root: Path | None = None) -> str:
302
337
  if assignee == developer and status != "done":
303
338
  title = data.get("title") or data.get("name") or "unknown"
304
339
  priority = data.get("priority", "P2")
305
- lines.append(f"- [{priority}] {title} ({status})")
340
+ children_list = data.get("children", [])
341
+ progress = _children_progress(children_list) if children_list else ""
342
+ lines.append(f"- [{priority}] {title} ({status}){progress}")
306
343
  my_task_count += 1
307
344
 
308
345
  if my_task_count == 0:
@@ -335,6 +372,91 @@ def get_context_text(repo_root: Path | None = None) -> str:
335
372
  return "\n".join(lines)
336
373
 
337
374
 
375
+ def get_context_record_json(repo_root: Path | None = None) -> dict:
376
+ """Get record-mode context as a dictionary.
377
+
378
+ Focused on: my active tasks, git status, current task.
379
+ """
380
+ if repo_root is None:
381
+ repo_root = get_repo_root()
382
+
383
+ developer = get_developer(repo_root)
384
+ tasks_dir = get_tasks_dir(repo_root)
385
+
386
+ # Git info
387
+ _, branch_out, _ = _run_git_command(["branch", "--show-current"], cwd=repo_root)
388
+ branch = branch_out.strip() or "unknown"
389
+
390
+ _, status_out, _ = _run_git_command(["status", "--porcelain"], cwd=repo_root)
391
+ git_status_count = len([line for line in status_out.splitlines() if line.strip()])
392
+
393
+ _, log_out, _ = _run_git_command(["log", "--oneline", "-5"], cwd=repo_root)
394
+ commits = []
395
+ for line in log_out.splitlines():
396
+ if line.strip():
397
+ parts = line.split(" ", 1)
398
+ if len(parts) >= 2:
399
+ commits.append({"hash": parts[0], "message": parts[1]})
400
+
401
+ # My tasks
402
+ my_tasks = []
403
+ all_task_statuses: dict[str, str] = {}
404
+ if tasks_dir.is_dir():
405
+ for d in sorted(tasks_dir.iterdir()):
406
+ if d.is_dir() and d.name != "archive":
407
+ t_json = d / FILE_TASK_JSON
408
+ if t_json.is_file():
409
+ data = _read_json_file(t_json)
410
+ if data:
411
+ all_task_statuses[d.name] = data.get("status", "unknown")
412
+
413
+ if tasks_dir.is_dir():
414
+ for d in sorted(tasks_dir.iterdir()):
415
+ if d.is_dir() and d.name != "archive":
416
+ t_json = d / FILE_TASK_JSON
417
+ if t_json.is_file():
418
+ data = _read_json_file(t_json)
419
+ if data and data.get("assignee") == developer:
420
+ children_list = data.get("children", [])
421
+ done = sum(1 for c in children_list if all_task_statuses.get(c) in ("completed", "done"))
422
+ my_tasks.append({
423
+ "dir": d.name,
424
+ "title": data.get("title") or data.get("name") or "unknown",
425
+ "status": data.get("status", "unknown"),
426
+ "priority": data.get("priority", "P2"),
427
+ "children": children_list,
428
+ "childrenDone": done,
429
+ "parent": data.get("parent"),
430
+ "meta": data.get("meta", {}),
431
+ })
432
+
433
+ # Current task
434
+ current_task_info = None
435
+ current_task = get_current_task(repo_root)
436
+ if current_task:
437
+ task_json_path = (repo_root / current_task) / FILE_TASK_JSON
438
+ if task_json_path.is_file():
439
+ data = _read_json_file(task_json_path)
440
+ if data:
441
+ current_task_info = {
442
+ "path": current_task,
443
+ "name": data.get("name") or data.get("id") or "unknown",
444
+ "status": data.get("status", "unknown"),
445
+ }
446
+
447
+ return {
448
+ "developer": developer or "",
449
+ "git": {
450
+ "branch": branch,
451
+ "isClean": git_status_count == 0,
452
+ "uncommittedChanges": git_status_count,
453
+ "recentCommits": commits,
454
+ },
455
+ "myTasks": my_tasks,
456
+ "currentTask": current_task_info,
457
+ }
458
+
459
+
338
460
  def get_context_text_record(repo_root: Path | None = None) -> str:
339
461
  """Get context as formatted text for record-session mode.
340
462
 
@@ -371,6 +493,26 @@ def get_context_text_record(repo_root: Path | None = None) -> str:
371
493
  tasks_dir = get_tasks_dir(repo_root)
372
494
  my_task_count = 0
373
495
 
496
+ # Collect task data for children progress
497
+ all_task_statuses: dict[str, str] = {}
498
+ if tasks_dir.is_dir():
499
+ for d in sorted(tasks_dir.iterdir()):
500
+ if d.is_dir() and d.name != "archive":
501
+ t_json = d / FILE_TASK_JSON
502
+ if t_json.is_file():
503
+ data = _read_json_file(t_json)
504
+ if data:
505
+ all_task_statuses[d.name] = data.get("status", "unknown")
506
+
507
+ def _record_children_progress(children_list: list[str]) -> str:
508
+ if not children_list:
509
+ return ""
510
+ done = 0
511
+ for c in children_list:
512
+ if all_task_statuses.get(c) in ("completed", "done"):
513
+ done += 1
514
+ return f" [{done}/{len(children_list)} done]"
515
+
374
516
  if tasks_dir.is_dir():
375
517
  for d in sorted(tasks_dir.iterdir()):
376
518
  if d.is_dir() and d.name != "archive":
@@ -384,7 +526,9 @@ def get_context_text_record(repo_root: Path | None = None) -> str:
384
526
  if assignee == developer:
385
527
  title = data.get("title") or data.get("name") or "unknown"
386
528
  priority = data.get("priority", "P2")
387
- lines.append(f"- [{priority}] {title} ({status}) — {d.name}")
529
+ children_list = data.get("children", [])
530
+ progress = _record_children_progress(children_list) if children_list else ""
531
+ lines.append(f"- [{priority}] {title} ({status}){progress} — {d.name}")
388
532
  my_task_count += 1
389
533
 
390
534
  if my_task_count == 0:
@@ -469,7 +613,7 @@ def main() -> None:
469
613
  "--json",
470
614
  "-j",
471
615
  action="store_true",
472
- help="Output context in JSON format",
616
+ help="Output in JSON format (works with any --mode)",
473
617
  )
474
618
  parser.add_argument(
475
619
  "--mode",
@@ -481,12 +625,16 @@ def main() -> None:
481
625
 
482
626
  args = parser.parse_args()
483
627
 
484
- if args.json:
485
- output_json()
486
- elif args.mode == "record":
487
- print(get_context_text_record())
628
+ if args.mode == "record":
629
+ if args.json:
630
+ print(json.dumps(get_context_record_json(), indent=2, ensure_ascii=False))
631
+ else:
632
+ print(get_context_text_record())
488
633
  else:
489
- output_text()
634
+ if args.json:
635
+ output_json()
636
+ else:
637
+ output_text()
490
638
 
491
639
 
492
640
  if __name__ == "__main__":
@@ -86,6 +86,8 @@ def list_tasks_by_status(
86
86
  "status": status,
87
87
  "assignee": assignee,
88
88
  "dir": d.name,
89
+ "children": data.get("children", []),
90
+ "parent": data.get("parent"),
89
91
  })
90
92
 
91
93
  return results
@@ -161,6 +163,8 @@ def list_tasks_by_assignee(
161
163
  "status": status,
162
164
  "assignee": task_assignee,
163
165
  "dir": d.name,
166
+ "children": data.get("children", []),
167
+ "parent": data.get("parent"),
164
168
  })
165
169
 
166
170
  return results
@@ -26,39 +26,106 @@ from .paths import (
26
26
  # =============================================================================
27
27
 
28
28
  def parse_simple_yaml(content: str) -> dict:
29
- """Parse simple YAML (only supports key: value and lists).
29
+ """Parse simple YAML with nested dict support (no dependencies).
30
+
31
+ Supports:
32
+ - key: value (string)
33
+ - key: (followed by list items)
34
+ - item1
35
+ - item2
36
+ - key: (followed by nested dict)
37
+ nested_key: value
38
+ nested_key2:
39
+ - item
40
+
41
+ Uses indentation to detect nesting (2+ spaces deeper = child).
30
42
 
31
43
  Args:
32
44
  content: YAML content string.
33
45
 
34
46
  Returns:
35
- Parsed dict.
47
+ Parsed dict (values can be str, list[str], or dict).
36
48
  """
49
+ lines = content.splitlines()
37
50
  result: dict = {}
51
+ _parse_yaml_block(lines, 0, 0, result)
52
+ return result
53
+
54
+
55
+ def _parse_yaml_block(
56
+ lines: list[str], start: int, min_indent: int, target: dict
57
+ ) -> int:
58
+ """Parse a YAML block into target dict, returning next line index."""
59
+ i = start
38
60
  current_list: list | None = None
39
61
 
40
- for line in content.splitlines():
62
+ while i < len(lines):
63
+ line = lines[i]
41
64
  stripped = line.strip()
65
+
66
+ # Skip empty lines and comments
42
67
  if not stripped or stripped.startswith("#"):
68
+ i += 1
43
69
  continue
44
70
 
71
+ # Calculate indentation
72
+ indent = len(line) - len(line.lstrip())
73
+
74
+ # If dedented past our block, we're done
75
+ if indent < min_indent:
76
+ break
77
+
45
78
  if stripped.startswith("- "):
46
79
  if current_list is not None:
47
80
  current_list.append(stripped[2:].strip().strip('"').strip("'"))
81
+ i += 1
48
82
  elif ":" in stripped:
49
83
  key, _, value = stripped.partition(":")
50
84
  key = key.strip()
51
85
  value = value.strip().strip('"').strip("'")
86
+ current_list = None
87
+
52
88
  if value:
53
- result[key] = value
54
- _ = None
55
- current_list = None
89
+ # key: value
90
+ target[key] = value
91
+ i += 1
56
92
  else:
57
- _ = key
58
- current_list = []
59
- result[key] = current_list
60
-
61
- return result
93
+ # key: (no value) — peek ahead to determine list vs nested dict
94
+ next_i, next_line = _next_content_line(lines, i + 1)
95
+ if next_i >= len(lines):
96
+ target[key] = {}
97
+ i = next_i
98
+ elif next_line.strip().startswith("- "):
99
+ # It's a list
100
+ current_list = []
101
+ target[key] = current_list
102
+ i += 1
103
+ else:
104
+ next_indent = len(next_line) - len(next_line.lstrip())
105
+ if next_indent > indent:
106
+ # It's a nested dict
107
+ nested: dict = {}
108
+ target[key] = nested
109
+ i = _parse_yaml_block(lines, i + 1, next_indent, nested)
110
+ else:
111
+ # Empty value, same or less indent follows
112
+ target[key] = {}
113
+ i += 1
114
+ else:
115
+ i += 1
116
+
117
+ return i
118
+
119
+
120
+ def _next_content_line(lines: list[str], start: int) -> tuple[int, str]:
121
+ """Find the next non-empty, non-comment line."""
122
+ i = start
123
+ while i < len(lines):
124
+ stripped = lines[i].strip()
125
+ if stripped and not stripped.startswith("#"):
126
+ return i, lines[i]
127
+ i += 1
128
+ return i, ""
62
129
 
63
130
 
64
131
  def _yaml_get_value(config_file: Path, key: str) -> str | None:
@@ -228,8 +228,11 @@ def write_task_json(task_dir: Path, developer: str, project_type: str) -> None:
228
228
  "completedAt": None,
229
229
  "commit": None,
230
230
  "subtasks": subtasks,
231
+ "children": [],
232
+ "parent": None,
231
233
  "relatedFiles": related_files,
232
234
  "notes": f"First-time setup task created by trellis init ({project_type} project)",
235
+ "meta": {},
233
236
  }
234
237
 
235
238
  task_json = task_dir / "task.json"