@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.
- package/README.md +6 -4
- package/dist/cli/index.js +1 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +243 -49
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +3 -0
- package/dist/commands/update.js.map +1 -1
- package/dist/migrations/manifests/0.3.6.json +9 -0
- package/dist/templates/claude/commands/trellis/brainstorm.md +13 -0
- package/dist/templates/claude/commands/trellis/record-session.md +4 -1
- package/dist/templates/claude/commands/trellis/start.md +4 -0
- package/dist/templates/claude/hooks/inject-subagent-context.py +1 -1
- package/dist/templates/claude/settings.json +10 -0
- package/dist/templates/codex/skills/brainstorm/SKILL.md +13 -0
- package/dist/templates/codex/skills/record-session/SKILL.md +4 -1
- package/dist/templates/codex/skills/start/SKILL.md +4 -0
- package/dist/templates/cursor/commands/trellis-brainstorm.md +13 -0
- package/dist/templates/cursor/commands/trellis-record-session.md +4 -1
- package/dist/templates/cursor/commands/trellis-start.md +4 -0
- package/dist/templates/gemini/commands/trellis/brainstorm.toml +15 -0
- package/dist/templates/gemini/commands/trellis/record-session.toml +4 -1
- package/dist/templates/gemini/commands/trellis/start.toml +4 -0
- package/dist/templates/iflow/commands/trellis/brainstorm.md +13 -0
- package/dist/templates/iflow/commands/trellis/record-session.md +4 -1
- package/dist/templates/iflow/commands/trellis/start.md +4 -0
- package/dist/templates/iflow/hooks/inject-subagent-context.py +1 -1
- package/dist/templates/kilo/workflows/brainstorm.md +13 -0
- package/dist/templates/kilo/workflows/record-session.md +4 -1
- package/dist/templates/kilo/workflows/start.md +4 -0
- package/dist/templates/kiro/skills/brainstorm/SKILL.md +13 -0
- package/dist/templates/kiro/skills/record-session/SKILL.md +4 -1
- package/dist/templates/kiro/skills/start/SKILL.md +4 -0
- package/dist/templates/markdown/spec/backend/directory-structure.md +292 -0
- package/dist/templates/markdown/spec/backend/script-conventions.md +220 -38
- package/dist/templates/opencode/commands/trellis/brainstorm.md +13 -0
- package/dist/templates/opencode/commands/trellis/record-session.md +4 -1
- package/dist/templates/opencode/commands/trellis/start.md +4 -0
- package/dist/templates/qoder/skills/brainstorm/SKILL.md +13 -0
- package/dist/templates/qoder/skills/record-session/SKILL.md +4 -1
- package/dist/templates/qoder/skills/start/SKILL.md +4 -0
- package/dist/templates/trellis/config.yaml +18 -0
- package/dist/templates/trellis/scripts/common/config.py +20 -0
- package/dist/templates/trellis/scripts/common/git_context.py +160 -12
- package/dist/templates/trellis/scripts/common/task_queue.py +4 -0
- package/dist/templates/trellis/scripts/common/worktree.py +78 -11
- package/dist/templates/trellis/scripts/create_bootstrap.py +3 -0
- package/dist/templates/trellis/scripts/task.py +312 -17
- package/dist/utils/template-fetcher.d.ts +57 -4
- package/dist/utils/template-fetcher.d.ts.map +1 -1
- package/dist/utils/template-fetcher.js +179 -10
- package/dist/utils/template-fetcher.js.map +1 -1
- 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
|
-
[!]
|
|
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
|
-
|
|
281
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
89
|
+
# key: value
|
|
90
|
+
target[key] = value
|
|
91
|
+
i += 1
|
|
56
92
|
else:
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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"
|