@blxzer/cursor-trellis 0.3.4 → 0.3.5

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 (95) hide show
  1. package/README.md +0 -13
  2. package/README.zh-CN.md +1 -1
  3. package/bin/cstl.js +1 -1
  4. package/bin/smart-search.js +1 -1
  5. package/dist/cli/index.d.ts.map +1 -1
  6. package/dist/cli/index.js +14 -2
  7. package/dist/cli/index.js.map +1 -1
  8. package/dist/commands/campaign/canvas-render.d.ts +68 -0
  9. package/dist/commands/campaign/canvas-render.d.ts.map +1 -0
  10. package/dist/commands/campaign/canvas-render.js +414 -0
  11. package/dist/commands/campaign/canvas-render.js.map +1 -0
  12. package/dist/commands/campaign/compose.d.ts +15 -0
  13. package/dist/commands/campaign/compose.d.ts.map +1 -0
  14. package/dist/commands/campaign/compose.js +110 -0
  15. package/dist/commands/campaign/compose.js.map +1 -0
  16. package/dist/commands/campaign/index.d.ts +13 -0
  17. package/dist/commands/campaign/index.d.ts.map +1 -0
  18. package/dist/commands/campaign/index.js +134 -0
  19. package/dist/commands/campaign/index.js.map +1 -0
  20. package/dist/commands/campaign/kind-map.d.ts +6 -0
  21. package/dist/commands/campaign/kind-map.d.ts.map +1 -0
  22. package/dist/commands/campaign/kind-map.js +21 -0
  23. package/dist/commands/campaign/kind-map.js.map +1 -0
  24. package/dist/commands/campaign/mcp-server.d.ts +6 -0
  25. package/dist/commands/campaign/mcp-server.d.ts.map +1 -0
  26. package/dist/commands/campaign/mcp-server.js +167 -0
  27. package/dist/commands/campaign/mcp-server.js.map +1 -0
  28. package/dist/commands/campaign/render.d.ts +5 -0
  29. package/dist/commands/campaign/render.d.ts.map +1 -0
  30. package/dist/commands/campaign/render.js +103 -0
  31. package/dist/commands/campaign/render.js.map +1 -0
  32. package/dist/commands/campaign/trellis-load.d.ts +13 -0
  33. package/dist/commands/campaign/trellis-load.d.ts.map +1 -0
  34. package/dist/commands/campaign/trellis-load.js +122 -0
  35. package/dist/commands/campaign/trellis-load.js.map +1 -0
  36. package/dist/commands/campaign/types.d.ts +61 -0
  37. package/dist/commands/campaign/types.d.ts.map +1 -0
  38. package/dist/commands/campaign/types.js +3 -0
  39. package/dist/commands/campaign/types.js.map +1 -0
  40. package/dist/commands/init.d.ts.map +1 -1
  41. package/dist/commands/init.js +11 -1
  42. package/dist/commands/init.js.map +1 -1
  43. package/dist/commands/rpc/client.d.ts +51 -0
  44. package/dist/commands/rpc/client.d.ts.map +1 -0
  45. package/dist/commands/rpc/client.js +147 -0
  46. package/dist/commands/rpc/client.js.map +1 -0
  47. package/dist/commands/rpc/index.d.ts +10 -0
  48. package/dist/commands/rpc/index.d.ts.map +1 -0
  49. package/dist/commands/rpc/index.js +157 -0
  50. package/dist/commands/rpc/index.js.map +1 -0
  51. package/dist/commands/rpc/serve.d.ts +23 -0
  52. package/dist/commands/rpc/serve.d.ts.map +1 -0
  53. package/dist/commands/rpc/serve.js +60 -0
  54. package/dist/commands/rpc/serve.js.map +1 -0
  55. package/dist/commands/rpc/smoke.d.ts +16 -0
  56. package/dist/commands/rpc/smoke.d.ts.map +1 -0
  57. package/dist/commands/rpc/smoke.js +92 -0
  58. package/dist/commands/rpc/smoke.js.map +1 -0
  59. package/dist/commands/sdk/index.d.ts +9 -0
  60. package/dist/commands/sdk/index.d.ts.map +1 -0
  61. package/dist/commands/sdk/index.js +58 -0
  62. package/dist/commands/sdk/index.js.map +1 -0
  63. package/dist/commands/sdk/run.d.ts +42 -0
  64. package/dist/commands/sdk/run.d.ts.map +1 -0
  65. package/dist/commands/sdk/run.js +245 -0
  66. package/dist/commands/sdk/run.js.map +1 -0
  67. package/dist/commands/sdk/status.d.ts +14 -0
  68. package/dist/commands/sdk/status.d.ts.map +1 -0
  69. package/dist/commands/sdk/status.js +69 -0
  70. package/dist/commands/sdk/status.js.map +1 -0
  71. package/dist/templates/common/bundled-skills/smart-search-cli/SKILL.md +35 -29
  72. package/dist/templates/common/bundled-skills/smart-search-cli/references/cli-contract.md +11 -25
  73. package/dist/templates/cursor/rules/retrieval-routing.mdc +1 -1
  74. package/dist/templates/markdown/agents.md +2 -0
  75. package/dist/templates/markdown/spec/guides/retrieval-daily-guide.md.txt +14 -0
  76. package/dist/templates/trellis/scripts/common/codebase_retrieval_router.py +14 -0
  77. package/dist/templates/trellis/scripts/common/parent_orchestration.py +341 -2
  78. package/dist/templates/trellis/scripts/common/task_map.py +638 -614
  79. package/dist/templates/trellis/scripts/common/task_store.py +47 -1
  80. package/dist/templates/trellis/scripts/task.py +32 -0
  81. package/dist/utils/codebase-retrieval-router.d.ts.map +1 -1
  82. package/dist/utils/codebase-retrieval-router.js +14 -0
  83. package/dist/utils/codebase-retrieval-router.js.map +1 -1
  84. package/dist/utils/cursor-sdk-gate.d.ts +10 -0
  85. package/dist/utils/cursor-sdk-gate.d.ts.map +1 -0
  86. package/dist/utils/cursor-sdk-gate.js +32 -0
  87. package/dist/utils/cursor-sdk-gate.js.map +1 -0
  88. package/dist/utils/project-capabilities.d.ts +30 -3
  89. package/dist/utils/project-capabilities.d.ts.map +1 -1
  90. package/dist/utils/project-capabilities.js +182 -11
  91. package/dist/utils/project-capabilities.js.map +1 -1
  92. package/dist/utils/readiness.d.ts.map +1 -1
  93. package/dist/utils/readiness.js +26 -0
  94. package/dist/utils/readiness.js.map +1 -1
  95. package/package.json +4 -3
@@ -1,614 +1,638 @@
1
- #!/usr/bin/env python3
2
- """
3
- Parent/Child task-map helpers.
4
-
5
- task-map.md is the Parent Supervisor's orchestration authority. This module
6
- parses and writes only the small YAML frontmatter subset generated here.
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- from datetime import datetime, timezone
12
- from pathlib import Path
13
-
14
-
15
- TASK_MAP = "task-map.md"
16
-
17
- CHILD_STATES = {
18
- "open",
19
- "working",
20
- "blocked",
21
- "review",
22
- "changes",
23
- "accepted",
24
- "integrating",
25
- "integrated",
26
- "cancelled",
27
- }
28
-
29
- PARENT_TERMINAL_STATES = {"integrated", "cancelled"}
30
- PARENT_CONTROLLED_STATES = {
31
- "changes",
32
- "accepted",
33
- "integrating",
34
- "integrated",
35
- "cancelled",
36
- }
37
- CHILD_REPORT_STATES = CHILD_STATES - PARENT_CONTROLLED_STATES
38
- REF_REQUIRED_STATES = {"accepted", "integrating", "integrated"}
39
- HANDOFF_REQUIRED_STATES = {"accepted", "integrating", "integrated"}
40
-
41
-
42
- def utc_now() -> str:
43
- """Return a compact UTC timestamp for task-map event log entries."""
44
- return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace(
45
- "+00:00", "Z"
46
- )
47
-
48
-
49
- def load_task_map(parent_dir: Path) -> tuple[dict | None, str]:
50
- """Load task-map.md frontmatter and body."""
51
- path = parent_dir / TASK_MAP
52
- if not path.is_file():
53
- return None, ""
54
-
55
- try:
56
- content = path.read_text(encoding="utf-8")
57
- except OSError:
58
- return None, ""
59
-
60
- frontmatter, body = _split_frontmatter(content)
61
- if frontmatter is None:
62
- return None, content
63
- return _parse_frontmatter(frontmatter), body
64
-
65
-
66
- def ensure_task_map(
67
- parent_dir: Path,
68
- parent_data: dict,
69
- child_names: list[str],
70
- event: str | None = None,
71
- ) -> dict:
72
- """Ensure task-map.md exists and contains entries for all child_names."""
73
- data, body = load_task_map(parent_dir)
74
- if data is None:
75
- data = {
76
- "parent_id": parent_data.get("id") or parent_dir.name,
77
- "contract_epoch": 1,
78
- "execution_topology": "serial",
79
- "merge_limit": 1,
80
- "children": [],
81
- "integration_queue": [],
82
- }
83
- body = _default_body()
84
-
85
- data.setdefault("parent_id", parent_data.get("id") or parent_dir.name)
86
- data.setdefault("contract_epoch", 1)
87
- data.setdefault("execution_topology", "serial")
88
- data.setdefault("merge_limit", 1)
89
- data.setdefault("children", [])
90
- data.setdefault("integration_queue", [])
91
-
92
- existing = {
93
- child.get("id"): child
94
- for child in data.get("children", [])
95
- if isinstance(child, dict)
96
- }
97
- for child_name in child_names:
98
- if child_name not in existing:
99
- child = _default_child(child_name)
100
- data["children"].append(child)
101
- existing[child_name] = child
102
-
103
- write_task_map(parent_dir, data, body, event)
104
- return data
105
-
106
-
107
- def remove_child_from_task_map(
108
- parent_dir: Path,
109
- parent_data: dict,
110
- child_name: str,
111
- ) -> None:
112
- """Remove a child entry from task-map.md when structural links are removed."""
113
- data, body = load_task_map(parent_dir)
114
- if data is None:
115
- return
116
-
117
- children = data.get("children")
118
- if isinstance(children, list):
119
- data["children"] = [
120
- child
121
- for child in children
122
- if not isinstance(child, dict) or child.get("id") != child_name
123
- ]
124
- data.setdefault("parent_id", parent_data.get("id") or parent_dir.name)
125
- write_task_map(parent_dir, data, body, f"Unlinked Child `{child_name}`.")
126
-
127
-
128
- def set_child_state(
129
- parent_dir: Path,
130
- parent_data: dict,
131
- child_name: str,
132
- state: str,
133
- evidence: str,
134
- reason: str | None = None,
135
- ) -> tuple[bool, list[str]]:
136
- """Set a child state inside Parent task-map.md."""
137
- errors: list[str] = []
138
- if state not in CHILD_STATES:
139
- errors.append(f"unknown child state: {state}")
140
- if not _short_text(evidence):
141
- errors.append("evidence must be a short reference")
142
- if reason and not _short_text(reason, 500):
143
- errors.append("reason must be short and single-line")
144
- if errors:
145
- return False, errors
146
-
147
- structural_children = parent_data.get("children", [])
148
- if child_name not in structural_children:
149
- errors.append(f"child is not linked to parent: {child_name}")
150
- return False, errors
151
-
152
- data = ensure_task_map(parent_dir, parent_data, list(structural_children))
153
- child = get_child_entry(data, child_name)
154
- if child is None:
155
- errors.append(f"child missing from task-map.md: {child_name}")
156
- return False, errors
157
-
158
- child["state"] = state
159
- child["evidence"] = evidence
160
- if reason:
161
- child["reason"] = reason
162
-
163
- event = f"Set Child `{child_name}` state to `{state}`. Evidence: {evidence}."
164
- if reason:
165
- event = f"{event} Reason: {reason}."
166
- _, body = load_task_map(parent_dir)
167
- write_task_map(parent_dir, data, body, event)
168
- return True, []
169
-
170
-
171
- def validate_parent_child_integration(
172
- parent_dir: Path,
173
- parent_data: dict,
174
- child_dir: Path,
175
- child_data: dict,
176
- state: str,
177
- evidence: str,
178
- ref: str | None = None,
179
- reason: str | None = None,
180
- current_state_override: str | None = None,
181
- ) -> list[str]:
182
- """Validate a Parent-controlled Child integration state transition."""
183
- errors: list[str] = []
184
- child_name = child_dir.name
185
- if state not in PARENT_CONTROLLED_STATES:
186
- errors.append(f"state is not Parent-controlled: {state}")
187
- if not _short_text(evidence):
188
- errors.append("evidence must be a short reference")
189
- if reason and not _short_text(reason, 500):
190
- errors.append("reason must be short and single-line")
191
- if state in {"changes", "cancelled"} and not reason:
192
- errors.append(f"{state} requires --reason")
193
- if state in REF_REQUIRED_STATES and not _short_text(ref):
194
- errors.append(f"{state} requires --ref")
195
-
196
- if child_data.get("parent") != parent_dir.name:
197
- errors.append(f"child is not linked to parent: {child_name}")
198
-
199
- structural_children = parent_data.get("children", [])
200
- if child_name not in structural_children:
201
- errors.append(f"child is not linked to parent: {child_name}")
202
-
203
- data, _ = load_task_map(parent_dir)
204
- if data is None:
205
- errors.append(f"{TASK_MAP}")
206
- return errors
207
-
208
- child = get_child_entry(data, child_name)
209
- if child is None:
210
- errors.append(f"child missing from task-map.md: {child_name}")
211
- return errors
212
-
213
- current_state = (
214
- current_state_override
215
- if current_state_override is not None
216
- else child.get("state")
217
- )
218
- if state in {"accepted", "changes"} and current_state != "review":
219
- errors.append(f"{state} requires current Child state 'review', got {current_state!r}")
220
- if state == "integrating" and current_state != "accepted":
221
- errors.append(f"integrating requires current Child state 'accepted', got {current_state!r}")
222
- if state == "integrated" and current_state != "integrating":
223
- errors.append(f"integrated requires current Child state 'integrating', got {current_state!r}")
224
-
225
- if state in HANDOFF_REQUIRED_STATES:
226
- errors.extend(_required_child_evidence(child_dir, ["verify.md", "handoff.md"]))
227
-
228
- if state == "integrating":
229
- merge_limit = data.get("merge_limit", 1)
230
- if not isinstance(merge_limit, int) or merge_limit < 1:
231
- errors.append("merge_limit must be a positive integer")
232
- else:
233
- integrating = [
234
- item.get("id")
235
- for item in data.get("children", [])
236
- if isinstance(item, dict)
237
- and item.get("id") != child_name
238
- and item.get("state") == "integrating"
239
- ]
240
- if len(integrating) >= merge_limit:
241
- errors.append(
242
- f"merge_limit {merge_limit} blocks integrating {child_name}; already integrating: {', '.join(str(item) for item in integrating)}"
243
- )
244
-
245
- if state == "accepted":
246
- from .task_gates import task_closeout_profile, validate_transition_readiness
247
-
248
- profile = task_closeout_profile(child_dir, child_data)
249
- if profile == "full":
250
- errors.extend(
251
- validate_transition_readiness(
252
- child_dir,
253
- child_data,
254
- "child-review",
255
- mode="complete",
256
- )
257
- )
258
- elif profile == "parent":
259
- errors.extend(
260
- validate_transition_readiness(
261
- child_dir,
262
- child_data,
263
- "parent-integrated",
264
- mode="complete",
265
- )
266
- )
267
-
268
- return errors
269
-
270
-
271
- def set_parent_child_integration_state(
272
- parent_dir: Path,
273
- parent_data: dict,
274
- child_dir: Path,
275
- child_data: dict,
276
- state: str,
277
- evidence: str,
278
- ref: str | None = None,
279
- reason: str | None = None,
280
- merge_ref: str | None = None,
281
- ) -> tuple[bool, list[str]]:
282
- """Set a Parent-controlled Child integration state."""
283
- errors = validate_parent_child_integration(
284
- parent_dir,
285
- parent_data,
286
- child_dir,
287
- child_data,
288
- state,
289
- evidence,
290
- ref,
291
- reason,
292
- )
293
- if errors:
294
- return False, errors
295
-
296
- data = ensure_task_map(parent_dir, parent_data, list(parent_data.get("children", [])))
297
- child = get_child_entry(data, child_dir.name)
298
- if child is None:
299
- return False, [f"child missing from task-map.md: {child_dir.name}"]
300
-
301
- child["state"] = state
302
- child["evidence"] = evidence
303
- if ref:
304
- child["ref"] = ref
305
- if merge_ref:
306
- child["merged_ref"] = merge_ref
307
- if reason:
308
- child["reason"] = reason
309
- elif "reason" in child:
310
- del child["reason"]
311
-
312
- queue = data.get("integration_queue")
313
- if not isinstance(queue, list):
314
- queue = []
315
- data["integration_queue"] = queue
316
- if state == "integrating":
317
- if child_dir.name not in queue:
318
- queue.append(child_dir.name)
319
- elif child_dir.name in queue:
320
- data["integration_queue"] = [item for item in queue if item != child_dir.name]
321
-
322
- event = f"Parent set Child `{child_dir.name}` integration state to `{state}`. Evidence: {evidence}."
323
- if ref:
324
- event = f"{event} Ref: {ref}."
325
- if merge_ref:
326
- event = f"{event} Merge executed: git merge --no-ff --no-commit {merge_ref}."
327
- if reason:
328
- event = f"{event} Reason: {reason}."
329
- _, body = load_task_map(parent_dir)
330
- write_task_map(parent_dir, data, body, event)
331
- return True, []
332
-
333
-
334
- def record_child_worktree(
335
- parent_dir: Path,
336
- parent_data: dict,
337
- child_name: str,
338
- branch: str,
339
- worktree_path: str,
340
- base_ref: str,
341
- ) -> tuple[bool, list[str]]:
342
- """Record a Child git worktree checkout in Parent task-map.md."""
343
- errors: list[str] = []
344
- if not _short_text(branch):
345
- errors.append("branch must be a short reference")
346
- if not _short_text(worktree_path):
347
- errors.append("worktree_path must be a short path reference")
348
- if not _short_text(base_ref):
349
- errors.append("base_ref must be a short reference")
350
-
351
- structural_children = parent_data.get("children", [])
352
- if child_name not in structural_children:
353
- errors.append(f"child is not linked to parent: {child_name}")
354
- if errors:
355
- return False, errors
356
-
357
- data = ensure_task_map(parent_dir, parent_data, list(structural_children))
358
- child = get_child_entry(data, child_name)
359
- if child is None:
360
- return False, [f"child missing from task-map.md: {child_name}"]
361
-
362
- child["isolation"] = "git-worktree"
363
- child["branch"] = branch
364
- child["worktree_path"] = worktree_path
365
- child["base_ref"] = base_ref
366
- child["ref"] = f"refs/heads/{branch}"
367
-
368
- event = (
369
- f"Prepared Child `{child_name}` git worktree. "
370
- f"Branch: {branch}. Path: {worktree_path}. Base: {base_ref}."
371
- )
372
- _, body = load_task_map(parent_dir)
373
- write_task_map(parent_dir, data, body, event)
374
- return True, []
375
-
376
-
377
- def get_child_entry(data: dict | None, child_name: str) -> dict | None:
378
- """Return a child map entry by id."""
379
- if not data:
380
- return None
381
- children = data.get("children")
382
- if not isinstance(children, list):
383
- return None
384
- for child in children:
385
- if isinstance(child, dict) and child.get("id") == child_name:
386
- return child
387
- return None
388
-
389
-
390
- def get_child_state(parent_dir: Path, child_name: str) -> str | None:
391
- """Return child state from a Parent task-map.md."""
392
- data, _ = load_task_map(parent_dir)
393
- child = get_child_entry(data, child_name)
394
- if not child:
395
- return None
396
- state = child.get("state")
397
- return state if isinstance(state, str) else None
398
-
399
-
400
- def validate_parent_children_complete(parent_dir: Path, child_names: list[str]) -> list[str]:
401
- """Validate every structural child is integrated or cancelled."""
402
- errors: list[str] = []
403
- data, _ = load_task_map(parent_dir)
404
- if data is None:
405
- return [f"{TASK_MAP}"]
406
-
407
- for child_name in child_names:
408
- child = get_child_entry(data, child_name)
409
- if child is None:
410
- errors.append(f"{TASK_MAP} missing child entry: {child_name}")
411
- continue
412
- state = child.get("state")
413
- if state not in PARENT_TERMINAL_STATES:
414
- errors.append(
415
- f"child {child_name} must be integrated or cancelled before parent archive, got {state!r}"
416
- )
417
- return errors
418
-
419
-
420
- def validate_child_archive_state(parent_dir: Path, child_name: str) -> list[str]:
421
- """Validate the Parent has marked a Child terminal before child archive."""
422
- state = get_child_state(parent_dir, child_name)
423
- if state is None:
424
- return [f"{TASK_MAP} missing child entry: {child_name}"]
425
- if state not in PARENT_TERMINAL_STATES:
426
- return [
427
- f"Parent task-map.md must mark child {child_name} integrated or cancelled before archive, got {state!r}"
428
- ]
429
- return []
430
-
431
-
432
- def write_task_map(
433
- parent_dir: Path,
434
- data: dict,
435
- body: str,
436
- event: str | None = None,
437
- ) -> None:
438
- """Write task-map.md from structured snapshot and Markdown body."""
439
- normalized_body = body if body.strip() else _default_body()
440
- if event:
441
- normalized_body = _append_event(normalized_body, event)
442
- content = f"---\n{_format_frontmatter(data)}---\n{normalized_body.lstrip()}"
443
- (parent_dir / TASK_MAP).write_text(content, encoding="utf-8")
444
-
445
-
446
- def _default_child(child_name: str) -> dict:
447
- return {
448
- "id": child_name,
449
- "state": "open",
450
- "depends_on": [],
451
- "touches": [],
452
- "isolation": "git-worktree",
453
- "ref": None,
454
- }
455
-
456
-
457
- def _default_body() -> str:
458
- return (
459
- "# Task Map\n\n"
460
- "## Orchestration notes\n\n"
461
- "- `execution_topology: parallel` — children with empty `depends_on` may run concurrently; "
462
- "Parent integrates serially up to `merge_limit`.\n"
463
- "- Child-reported states: `open` `working` `blocked` | `review`.\n"
464
- "- Parent-controlled states: `review` `changes` | `accepted` → `integrating` → "
465
- "`integrated` | `cancelled`.\n"
466
- "- Declare `touches` per child before dispatch to reduce merge conflicts.\n"
467
- "- `isolation: git-worktree` run `prepare-child-worktree` from the **git package root** "
468
- "(not a non-git harness root).\n\n"
469
- "## Event Log\n\n"
470
- )
471
-
472
-
473
- def _append_event(body: str, event: str) -> str:
474
- stripped = body.rstrip()
475
- if "## Event Log" not in stripped:
476
- stripped = f"{stripped}\n\n## Event Log"
477
- return f"{stripped}\n\n- {utc_now()} - {event}\n"
478
-
479
-
480
- def _split_frontmatter(content: str) -> tuple[str | None, str]:
481
- lines = content.splitlines(keepends=True)
482
- if not lines or lines[0].strip() != "---":
483
- return None, content
484
-
485
- for idx in range(1, len(lines)):
486
- if lines[idx].strip() == "---":
487
- frontmatter = "".join(lines[1:idx])
488
- body = "".join(lines[idx + 1 :])
489
- return frontmatter, body
490
- return None, content
491
-
492
-
493
- def _parse_frontmatter(frontmatter: str) -> dict:
494
- data: dict = {"children": []}
495
- current_child: dict | None = None
496
- in_children = False
497
-
498
- for raw_line in frontmatter.splitlines():
499
- if not raw_line.strip():
500
- continue
501
-
502
- if raw_line.startswith("children:"):
503
- in_children = True
504
- data["children"] = []
505
- current_child = None
506
- continue
507
-
508
- if in_children and raw_line.startswith(" - "):
509
- current_child = {}
510
- data["children"].append(current_child)
511
- item = raw_line[4:].strip()
512
- if ":" in item:
513
- key, value = item.split(":", 1)
514
- current_child[key.strip()] = _parse_value(value.strip())
515
- continue
516
-
517
- if in_children and raw_line.startswith(" ") and current_child is not None:
518
- item = raw_line.strip()
519
- if ":" in item:
520
- key, value = item.split(":", 1)
521
- current_child[key.strip()] = _parse_value(value.strip())
522
- continue
523
-
524
- in_children = False
525
- if ":" in raw_line:
526
- key, value = raw_line.split(":", 1)
527
- data[key.strip()] = _parse_value(value.strip())
528
-
529
- return data
530
-
531
-
532
- def _parse_value(value: str):
533
- if value == "null":
534
- return None
535
- if value == "[]":
536
- return []
537
- if value.startswith("[") and value.endswith("]"):
538
- inner = value[1:-1].strip()
539
- if not inner:
540
- return []
541
- return [item.strip().strip("'\"") for item in inner.split(",") if item.strip()]
542
- if value.isdigit():
543
- return int(value)
544
- return value.strip("'\"")
545
-
546
-
547
- def _format_frontmatter(data: dict) -> str:
548
- lines = [
549
- f"parent_id: {_format_value(data.get('parent_id'))}",
550
- f"contract_epoch: {_format_value(data.get('contract_epoch', 1))}",
551
- f"execution_topology: {_format_value(data.get('execution_topology', 'serial'))}",
552
- f"merge_limit: {_format_value(data.get('merge_limit', 1))}",
553
- "children:",
554
- ]
555
- children = data.get("children", [])
556
- if isinstance(children, list):
557
- for child in children:
558
- if not isinstance(child, dict):
559
- continue
560
- lines.append(f" - id: {_format_value(child.get('id'))}")
561
- lines.append(f" state: {_format_value(child.get('state', 'open'))}")
562
- lines.append(f" depends_on: {_format_value(child.get('depends_on', []))}")
563
- lines.append(f" touches: {_format_value(child.get('touches', []))}")
564
- lines.append(f" isolation: {_format_value(child.get('isolation', 'git-worktree'))}")
565
- lines.append(f" ref: {_format_value(child.get('ref'))}")
566
- if child.get("branch"):
567
- lines.append(f" branch: {_format_value(child.get('branch'))}")
568
- if child.get("worktree_path"):
569
- lines.append(f" worktree_path: {_format_value(child.get('worktree_path'))}")
570
- if child.get("base_ref"):
571
- lines.append(f" base_ref: {_format_value(child.get('base_ref'))}")
572
- if child.get("merged_ref"):
573
- lines.append(f" merged_ref: {_format_value(child.get('merged_ref'))}")
574
- if child.get("evidence"):
575
- lines.append(f" evidence: {_format_value(child.get('evidence'))}")
576
- if child.get("reason"):
577
- lines.append(f" reason: {_format_value(child.get('reason'))}")
578
- lines.append(f"integration_queue: {_format_value(data.get('integration_queue', []))}")
579
- return "\n".join(lines) + "\n"
580
-
581
-
582
- def _format_value(value) -> str:
583
- if value is None:
584
- return "null"
585
- if isinstance(value, list):
586
- if not value:
587
- return "[]"
588
- return "[" + ", ".join(str(item) for item in value) + "]"
589
- return str(value)
590
-
591
-
592
- def _short_text(value: str | None, max_len: int = 240) -> bool:
593
- if not value or not isinstance(value, str):
594
- return False
595
- if len(value) > max_len:
596
- return False
597
- if "\n" in value or "\r" in value:
598
- return False
599
- return True
600
-
601
-
602
- def _required_child_evidence(child_dir: Path, names: list[str]) -> list[str]:
603
- errors: list[str] = []
604
- for name in names:
605
- path = child_dir / name
606
- if not path.is_file():
607
- errors.append(name)
608
- continue
609
- try:
610
- if not path.read_text(encoding="utf-8").strip():
611
- errors.append(f"{name} is empty")
612
- except OSError:
613
- errors.append(f"{name} could not be read")
614
- return errors
1
+ #!/usr/bin/env python3
2
+ """
3
+ Parent/Child task-map helpers.
4
+
5
+ task-map.md is the Parent Supervisor's orchestration authority. This module
6
+ parses and writes only the small YAML frontmatter subset generated here.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+
14
+
15
+ TASK_MAP = "task-map.md"
16
+
17
+ CHILD_STATES = {
18
+ "open",
19
+ "working",
20
+ "blocked",
21
+ "review",
22
+ "changes",
23
+ "accepted",
24
+ "integrating",
25
+ "integrated",
26
+ "cancelled",
27
+ }
28
+
29
+ PARENT_TERMINAL_STATES = {"integrated", "cancelled"}
30
+ PARENT_CONTROLLED_STATES = {
31
+ "changes",
32
+ "accepted",
33
+ "integrating",
34
+ "integrated",
35
+ "cancelled",
36
+ }
37
+ CHILD_REPORT_STATES = CHILD_STATES - PARENT_CONTROLLED_STATES
38
+ REF_REQUIRED_STATES = {"accepted", "integrating", "integrated"}
39
+ HANDOFF_REQUIRED_STATES = {"accepted", "integrating", "integrated"}
40
+
41
+
42
+ def utc_now() -> str:
43
+ """Return a compact UTC timestamp for task-map event log entries."""
44
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace(
45
+ "+00:00", "Z"
46
+ )
47
+
48
+
49
+ def load_task_map(parent_dir: Path) -> tuple[dict | None, str]:
50
+ """Load task-map.md frontmatter and body."""
51
+ path = parent_dir / TASK_MAP
52
+ if not path.is_file():
53
+ return None, ""
54
+
55
+ try:
56
+ content = path.read_text(encoding="utf-8")
57
+ except OSError:
58
+ return None, ""
59
+
60
+ frontmatter, body = _split_frontmatter(content)
61
+ if frontmatter is None:
62
+ return None, content
63
+ return _parse_frontmatter(frontmatter), body
64
+
65
+
66
+ def ensure_task_map(
67
+ parent_dir: Path,
68
+ parent_data: dict,
69
+ child_names: list[str],
70
+ event: str | None = None,
71
+ ) -> dict:
72
+ """Ensure task-map.md exists and contains entries for all child_names."""
73
+ data, body = load_task_map(parent_dir)
74
+ if data is None:
75
+ data = {
76
+ "parent_id": parent_data.get("id") or parent_dir.name,
77
+ "contract_epoch": 1,
78
+ "execution_topology": "serial",
79
+ "merge_limit": 1,
80
+ "children": [],
81
+ "stages": [],
82
+ "integration_queue": [],
83
+ }
84
+ body = _default_body()
85
+
86
+ data.setdefault("parent_id", parent_data.get("id") or parent_dir.name)
87
+ data.setdefault("contract_epoch", 1)
88
+ data.setdefault("execution_topology", "serial")
89
+ data.setdefault("merge_limit", 1)
90
+ data.setdefault("children", [])
91
+ data.setdefault("stages", [])
92
+ data.setdefault("integration_queue", [])
93
+
94
+ existing = {
95
+ child.get("id"): child
96
+ for child in data.get("children", [])
97
+ if isinstance(child, dict)
98
+ }
99
+ for child_name in child_names:
100
+ if child_name not in existing:
101
+ child = _default_child(child_name)
102
+ data["children"].append(child)
103
+ existing[child_name] = child
104
+
105
+ write_task_map(parent_dir, data, body, event)
106
+ return data
107
+
108
+
109
+ def remove_child_from_task_map(
110
+ parent_dir: Path,
111
+ parent_data: dict,
112
+ child_name: str,
113
+ ) -> None:
114
+ """Remove a child entry from task-map.md when structural links are removed."""
115
+ data, body = load_task_map(parent_dir)
116
+ if data is None:
117
+ return
118
+
119
+ children = data.get("children")
120
+ if isinstance(children, list):
121
+ data["children"] = [
122
+ child
123
+ for child in children
124
+ if not isinstance(child, dict) or child.get("id") != child_name
125
+ ]
126
+ data.setdefault("parent_id", parent_data.get("id") or parent_dir.name)
127
+ write_task_map(parent_dir, data, body, f"Unlinked Child `{child_name}`.")
128
+
129
+
130
+ def set_child_state(
131
+ parent_dir: Path,
132
+ parent_data: dict,
133
+ child_name: str,
134
+ state: str,
135
+ evidence: str,
136
+ reason: str | None = None,
137
+ ) -> tuple[bool, list[str]]:
138
+ """Set a child state inside Parent task-map.md."""
139
+ errors: list[str] = []
140
+ if state not in CHILD_STATES:
141
+ errors.append(f"unknown child state: {state}")
142
+ if not _short_text(evidence):
143
+ errors.append("evidence must be a short reference")
144
+ if reason and not _short_text(reason, 500):
145
+ errors.append("reason must be short and single-line")
146
+ if errors:
147
+ return False, errors
148
+
149
+ structural_children = parent_data.get("children", [])
150
+ if child_name not in structural_children:
151
+ errors.append(f"child is not linked to parent: {child_name}")
152
+ return False, errors
153
+
154
+ data = ensure_task_map(parent_dir, parent_data, list(structural_children))
155
+ child = get_child_entry(data, child_name)
156
+ if child is None:
157
+ errors.append(f"child missing from task-map.md: {child_name}")
158
+ return False, errors
159
+
160
+ child["state"] = state
161
+ child["evidence"] = evidence
162
+ if reason:
163
+ child["reason"] = reason
164
+
165
+ event = f"Set Child `{child_name}` state to `{state}`. Evidence: {evidence}."
166
+ if reason:
167
+ event = f"{event} Reason: {reason}."
168
+ _, body = load_task_map(parent_dir)
169
+ write_task_map(parent_dir, data, body, event)
170
+ return True, []
171
+
172
+
173
+ def validate_parent_child_integration(
174
+ parent_dir: Path,
175
+ parent_data: dict,
176
+ child_dir: Path,
177
+ child_data: dict,
178
+ state: str,
179
+ evidence: str,
180
+ ref: str | None = None,
181
+ reason: str | None = None,
182
+ current_state_override: str | None = None,
183
+ ) -> list[str]:
184
+ """Validate a Parent-controlled Child integration state transition."""
185
+ errors: list[str] = []
186
+ child_name = child_dir.name
187
+ if state not in PARENT_CONTROLLED_STATES:
188
+ errors.append(f"state is not Parent-controlled: {state}")
189
+ if not _short_text(evidence):
190
+ errors.append("evidence must be a short reference")
191
+ if reason and not _short_text(reason, 500):
192
+ errors.append("reason must be short and single-line")
193
+ if state in {"changes", "cancelled"} and not reason:
194
+ errors.append(f"{state} requires --reason")
195
+ if state in REF_REQUIRED_STATES and not _short_text(ref):
196
+ errors.append(f"{state} requires --ref")
197
+
198
+ if child_data.get("parent") != parent_dir.name:
199
+ errors.append(f"child is not linked to parent: {child_name}")
200
+
201
+ structural_children = parent_data.get("children", [])
202
+ if child_name not in structural_children:
203
+ errors.append(f"child is not linked to parent: {child_name}")
204
+
205
+ data, _ = load_task_map(parent_dir)
206
+ if data is None:
207
+ errors.append(f"{TASK_MAP}")
208
+ return errors
209
+
210
+ child = get_child_entry(data, child_name)
211
+ if child is None:
212
+ errors.append(f"child missing from task-map.md: {child_name}")
213
+ return errors
214
+
215
+ current_state = (
216
+ current_state_override
217
+ if current_state_override is not None
218
+ else child.get("state")
219
+ )
220
+ if state in {"accepted", "changes"} and current_state != "review":
221
+ errors.append(f"{state} requires current Child state 'review', got {current_state!r}")
222
+ if state == "integrating" and current_state != "accepted":
223
+ errors.append(f"integrating requires current Child state 'accepted', got {current_state!r}")
224
+ if state == "integrated" and current_state != "integrating":
225
+ errors.append(f"integrated requires current Child state 'integrating', got {current_state!r}")
226
+
227
+ if state in HANDOFF_REQUIRED_STATES:
228
+ errors.extend(_required_child_evidence(child_dir, ["verify.md", "handoff.md"]))
229
+
230
+ if state == "integrating":
231
+ merge_limit = data.get("merge_limit", 1)
232
+ if not isinstance(merge_limit, int) or merge_limit < 1:
233
+ errors.append("merge_limit must be a positive integer")
234
+ else:
235
+ integrating = [
236
+ item.get("id")
237
+ for item in data.get("children", [])
238
+ if isinstance(item, dict)
239
+ and item.get("id") != child_name
240
+ and item.get("state") == "integrating"
241
+ ]
242
+ if len(integrating) >= merge_limit:
243
+ errors.append(
244
+ f"merge_limit {merge_limit} blocks integrating {child_name}; already integrating: {', '.join(str(item) for item in integrating)}"
245
+ )
246
+
247
+ if state == "accepted":
248
+ from .task_gates import task_closeout_profile, validate_transition_readiness
249
+
250
+ profile = task_closeout_profile(child_dir, child_data)
251
+ if profile == "full":
252
+ errors.extend(
253
+ validate_transition_readiness(
254
+ child_dir,
255
+ child_data,
256
+ "child-review",
257
+ mode="complete",
258
+ )
259
+ )
260
+ elif profile == "parent":
261
+ errors.extend(
262
+ validate_transition_readiness(
263
+ child_dir,
264
+ child_data,
265
+ "parent-integrated",
266
+ mode="complete",
267
+ )
268
+ )
269
+
270
+ return errors
271
+
272
+
273
+ def set_parent_child_integration_state(
274
+ parent_dir: Path,
275
+ parent_data: dict,
276
+ child_dir: Path,
277
+ child_data: dict,
278
+ state: str,
279
+ evidence: str,
280
+ ref: str | None = None,
281
+ reason: str | None = None,
282
+ merge_ref: str | None = None,
283
+ ) -> tuple[bool, list[str]]:
284
+ """Set a Parent-controlled Child integration state."""
285
+ errors = validate_parent_child_integration(
286
+ parent_dir,
287
+ parent_data,
288
+ child_dir,
289
+ child_data,
290
+ state,
291
+ evidence,
292
+ ref,
293
+ reason,
294
+ )
295
+ if errors:
296
+ return False, errors
297
+
298
+ data = ensure_task_map(parent_dir, parent_data, list(parent_data.get("children", [])))
299
+ child = get_child_entry(data, child_dir.name)
300
+ if child is None:
301
+ return False, [f"child missing from task-map.md: {child_dir.name}"]
302
+
303
+ child["state"] = state
304
+ child["evidence"] = evidence
305
+ if ref:
306
+ child["ref"] = ref
307
+ if merge_ref:
308
+ child["merged_ref"] = merge_ref
309
+ if reason:
310
+ child["reason"] = reason
311
+ elif "reason" in child:
312
+ del child["reason"]
313
+
314
+ queue = data.get("integration_queue")
315
+ if not isinstance(queue, list):
316
+ queue = []
317
+ data["integration_queue"] = queue
318
+ if state == "integrating":
319
+ if child_dir.name not in queue:
320
+ queue.append(child_dir.name)
321
+ elif child_dir.name in queue:
322
+ data["integration_queue"] = [item for item in queue if item != child_dir.name]
323
+
324
+ event = f"Parent set Child `{child_dir.name}` integration state to `{state}`. Evidence: {evidence}."
325
+ if ref:
326
+ event = f"{event} Ref: {ref}."
327
+ if merge_ref:
328
+ event = f"{event} Merge executed: git merge --no-ff --no-commit {merge_ref}."
329
+ if reason:
330
+ event = f"{event} Reason: {reason}."
331
+ _, body = load_task_map(parent_dir)
332
+ write_task_map(parent_dir, data, body, event)
333
+ return True, []
334
+
335
+
336
+ def record_child_worktree(
337
+ parent_dir: Path,
338
+ parent_data: dict,
339
+ child_name: str,
340
+ branch: str,
341
+ worktree_path: str,
342
+ base_ref: str,
343
+ ) -> tuple[bool, list[str]]:
344
+ """Record a Child git worktree checkout in Parent task-map.md."""
345
+ errors: list[str] = []
346
+ if not _short_text(branch):
347
+ errors.append("branch must be a short reference")
348
+ if not _short_text(worktree_path):
349
+ errors.append("worktree_path must be a short path reference")
350
+ if not _short_text(base_ref):
351
+ errors.append("base_ref must be a short reference")
352
+
353
+ structural_children = parent_data.get("children", [])
354
+ if child_name not in structural_children:
355
+ errors.append(f"child is not linked to parent: {child_name}")
356
+ if errors:
357
+ return False, errors
358
+
359
+ data = ensure_task_map(parent_dir, parent_data, list(structural_children))
360
+ child = get_child_entry(data, child_name)
361
+ if child is None:
362
+ return False, [f"child missing from task-map.md: {child_name}"]
363
+
364
+ child["isolation"] = "git-worktree"
365
+ child["branch"] = branch
366
+ child["worktree_path"] = worktree_path
367
+ child["base_ref"] = base_ref
368
+ child["ref"] = f"refs/heads/{branch}"
369
+
370
+ event = (
371
+ f"Prepared Child `{child_name}` git worktree. "
372
+ f"Branch: {branch}. Path: {worktree_path}. Base: {base_ref}."
373
+ )
374
+ _, body = load_task_map(parent_dir)
375
+ write_task_map(parent_dir, data, body, event)
376
+ return True, []
377
+
378
+
379
+ def get_child_entry(data: dict | None, child_name: str) -> dict | None:
380
+ """Return a child map entry by id."""
381
+ if not data:
382
+ return None
383
+ children = data.get("children")
384
+ if not isinstance(children, list):
385
+ return None
386
+ for child in children:
387
+ if isinstance(child, dict) and child.get("id") == child_name:
388
+ return child
389
+ return None
390
+
391
+
392
+ def get_child_state(parent_dir: Path, child_name: str) -> str | None:
393
+ """Return child state from a Parent task-map.md."""
394
+ data, _ = load_task_map(parent_dir)
395
+ child = get_child_entry(data, child_name)
396
+ if not child:
397
+ return None
398
+ state = child.get("state")
399
+ return state if isinstance(state, str) else None
400
+
401
+
402
+ def validate_parent_children_complete(parent_dir: Path, child_names: list[str]) -> list[str]:
403
+ """Validate every structural child is integrated or cancelled."""
404
+ errors: list[str] = []
405
+ data, _ = load_task_map(parent_dir)
406
+ if data is None:
407
+ return [f"{TASK_MAP}"]
408
+
409
+ for child_name in child_names:
410
+ child = get_child_entry(data, child_name)
411
+ if child is None:
412
+ errors.append(f"{TASK_MAP} missing child entry: {child_name}")
413
+ continue
414
+ state = child.get("state")
415
+ if state not in PARENT_TERMINAL_STATES:
416
+ errors.append(
417
+ f"child {child_name} must be integrated or cancelled before parent archive, got {state!r}"
418
+ )
419
+ return errors
420
+
421
+
422
+ def validate_child_archive_state(parent_dir: Path, child_name: str) -> list[str]:
423
+ """Validate the Parent has marked a Child terminal before child archive."""
424
+ state = get_child_state(parent_dir, child_name)
425
+ if state is None:
426
+ return [f"{TASK_MAP} missing child entry: {child_name}"]
427
+ if state not in PARENT_TERMINAL_STATES:
428
+ return [
429
+ f"Parent task-map.md must mark child {child_name} integrated or cancelled before archive, got {state!r}"
430
+ ]
431
+ return []
432
+
433
+
434
+ def write_task_map(
435
+ parent_dir: Path,
436
+ data: dict,
437
+ body: str,
438
+ event: str | None = None,
439
+ ) -> None:
440
+ """Write task-map.md from structured snapshot and Markdown body."""
441
+ normalized_body = body if body.strip() else _default_body()
442
+ if event:
443
+ normalized_body = _append_event(normalized_body, event)
444
+ content = f"---\n{_format_frontmatter(data)}---\n{normalized_body.lstrip()}"
445
+ (parent_dir / TASK_MAP).write_text(content, encoding="utf-8")
446
+
447
+
448
+ def _default_child(child_name: str) -> dict:
449
+ return {
450
+ "id": child_name,
451
+ "state": "open",
452
+ "depends_on": [],
453
+ "touches": [],
454
+ "isolation": "git-worktree",
455
+ "ref": None,
456
+ }
457
+
458
+
459
+ def _default_body() -> str:
460
+ return (
461
+ "# Task Map\n\n"
462
+ "## Orchestration notes\n\n"
463
+ "- `execution_topology: parallel` children with empty `depends_on` may run concurrently; "
464
+ "Parent integrates serially up to `merge_limit`.\n"
465
+ "- Child-reported states: `open` → `working` → `blocked` | `review`.\n"
466
+ "- Parent-controlled states: `review` `changes` | `accepted` `integrating` "
467
+ "`integrated` | `cancelled`.\n"
468
+ "- Declare `touches` per child before dispatch to reduce merge conflicts.\n"
469
+ "- `isolation: git-worktree` — run `prepare-child-worktree` from the **git package root** "
470
+ "(not a non-git harness root).\n\n"
471
+ "## Event Log\n\n"
472
+ )
473
+
474
+
475
+ def _append_event(body: str, event: str) -> str:
476
+ stripped = body.rstrip()
477
+ if "## Event Log" not in stripped:
478
+ stripped = f"{stripped}\n\n## Event Log"
479
+ return f"{stripped}\n\n- {utc_now()} - {event}\n"
480
+
481
+
482
+ def _split_frontmatter(content: str) -> tuple[str | None, str]:
483
+ lines = content.splitlines(keepends=True)
484
+ if not lines or lines[0].strip() != "---":
485
+ return None, content
486
+
487
+ for idx in range(1, len(lines)):
488
+ if lines[idx].strip() == "---":
489
+ frontmatter = "".join(lines[1:idx])
490
+ body = "".join(lines[idx + 1 :])
491
+ return frontmatter, body
492
+ return None, content
493
+
494
+
495
+ def _parse_frontmatter(frontmatter: str) -> dict:
496
+ """Parse the small YAML subset written by this module.
497
+
498
+ Supports top-level scalars plus list-of-maps for ``children:`` and optional
499
+ ``stages:`` (same indentation style).
500
+ """
501
+ data: dict = {"children": [], "stages": []}
502
+ current_item: dict | None = None
503
+ list_key: str | None = None
504
+
505
+ for raw_line in frontmatter.splitlines():
506
+ if not raw_line.strip():
507
+ continue
508
+
509
+ if raw_line.startswith("children:"):
510
+ list_key = "children"
511
+ data["children"] = []
512
+ current_item = None
513
+ continue
514
+
515
+ if raw_line.startswith("stages:"):
516
+ list_key = "stages"
517
+ data["stages"] = []
518
+ current_item = None
519
+ continue
520
+
521
+ if list_key and raw_line.startswith(" - "):
522
+ current_item = {}
523
+ data[list_key].append(current_item)
524
+ item = raw_line[4:].strip()
525
+ if ":" in item:
526
+ key, value = item.split(":", 1)
527
+ current_item[key.strip()] = _parse_value(value.strip())
528
+ continue
529
+
530
+ if list_key and raw_line.startswith(" ") and current_item is not None:
531
+ item = raw_line.strip()
532
+ if ":" in item:
533
+ key, value = item.split(":", 1)
534
+ current_item[key.strip()] = _parse_value(value.strip())
535
+ continue
536
+
537
+ list_key = None
538
+ current_item = None
539
+ if ":" in raw_line:
540
+ key, value = raw_line.split(":", 1)
541
+ data[key.strip()] = _parse_value(value.strip())
542
+
543
+ return data
544
+
545
+
546
+ def _parse_value(value: str):
547
+ if value == "null":
548
+ return None
549
+ if value == "[]":
550
+ return []
551
+ if value.startswith("[") and value.endswith("]"):
552
+ inner = value[1:-1].strip()
553
+ if not inner:
554
+ return []
555
+ return [item.strip().strip("'\"") for item in inner.split(",") if item.strip()]
556
+ if value.isdigit():
557
+ return int(value)
558
+ return value.strip("'\"")
559
+
560
+
561
+ def _format_frontmatter(data: dict) -> str:
562
+ lines = [
563
+ f"parent_id: {_format_value(data.get('parent_id'))}",
564
+ f"contract_epoch: {_format_value(data.get('contract_epoch', 1))}",
565
+ f"execution_topology: {_format_value(data.get('execution_topology', 'serial'))}",
566
+ f"merge_limit: {_format_value(data.get('merge_limit', 1))}",
567
+ "children:",
568
+ ]
569
+ children = data.get("children", [])
570
+ if isinstance(children, list):
571
+ for child in children:
572
+ if not isinstance(child, dict):
573
+ continue
574
+ lines.append(f" - id: {_format_value(child.get('id'))}")
575
+ lines.append(f" state: {_format_value(child.get('state', 'open'))}")
576
+ lines.append(f" depends_on: {_format_value(child.get('depends_on', []))}")
577
+ lines.append(f" touches: {_format_value(child.get('touches', []))}")
578
+ lines.append(f" isolation: {_format_value(child.get('isolation', 'git-worktree'))}")
579
+ lines.append(f" ref: {_format_value(child.get('ref'))}")
580
+ if child.get("branch"):
581
+ lines.append(f" branch: {_format_value(child.get('branch'))}")
582
+ if child.get("worktree_path"):
583
+ lines.append(f" worktree_path: {_format_value(child.get('worktree_path'))}")
584
+ if child.get("base_ref"):
585
+ lines.append(f" base_ref: {_format_value(child.get('base_ref'))}")
586
+ if child.get("merged_ref"):
587
+ lines.append(f" merged_ref: {_format_value(child.get('merged_ref'))}")
588
+ if child.get("evidence"):
589
+ lines.append(f" evidence: {_format_value(child.get('evidence'))}")
590
+ if child.get("reason"):
591
+ lines.append(f" reason: {_format_value(child.get('reason'))}")
592
+ stages = data.get("stages") or []
593
+ if isinstance(stages, list) and stages:
594
+ lines.append("stages:")
595
+ for stage in stages:
596
+ if not isinstance(stage, dict):
597
+ continue
598
+ lines.append(f" - id: {_format_value(stage.get('id'))}")
599
+ if stage.get("title"):
600
+ lines.append(f" title: {_format_value(stage.get('title'))}")
601
+ lines.append(f" units: {_format_value(stage.get('units', []))}")
602
+ lines.append(f"integration_queue: {_format_value(data.get('integration_queue', []))}")
603
+ return "\n".join(lines) + "\n"
604
+
605
+
606
+ def _format_value(value) -> str:
607
+ if value is None:
608
+ return "null"
609
+ if isinstance(value, list):
610
+ if not value:
611
+ return "[]"
612
+ return "[" + ", ".join(str(item) for item in value) + "]"
613
+ return str(value)
614
+
615
+
616
+ def _short_text(value: str | None, max_len: int = 240) -> bool:
617
+ if not value or not isinstance(value, str):
618
+ return False
619
+ if len(value) > max_len:
620
+ return False
621
+ if "\n" in value or "\r" in value:
622
+ return False
623
+ return True
624
+
625
+
626
+ def _required_child_evidence(child_dir: Path, names: list[str]) -> list[str]:
627
+ errors: list[str] = []
628
+ for name in names:
629
+ path = child_dir / name
630
+ if not path.is_file():
631
+ errors.append(name)
632
+ continue
633
+ try:
634
+ if not path.read_text(encoding="utf-8").strip():
635
+ errors.append(f"{name} is empty")
636
+ except OSError:
637
+ errors.append(f"{name} could not be read")
638
+ return errors