@blxzer/cursor-trellis 0.3.5 → 0.4.0

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 (156) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +13 -0
  3. package/dist/cli/index.d.ts.map +1 -1
  4. package/dist/cli/index.js +3 -0
  5. package/dist/cli/index.js.map +1 -1
  6. package/dist/commands/goal/index.d.ts +3 -0
  7. package/dist/commands/goal/index.d.ts.map +1 -0
  8. package/dist/commands/goal/index.js +159 -0
  9. package/dist/commands/goal/index.js.map +1 -0
  10. package/dist/commands/goal/review.d.ts +6 -0
  11. package/dist/commands/goal/review.d.ts.map +1 -0
  12. package/dist/commands/goal/review.js +28 -0
  13. package/dist/commands/goal/review.js.map +1 -0
  14. package/dist/commands/sdk/run.d.ts.map +1 -1
  15. package/dist/commands/sdk/run.js +2 -27
  16. package/dist/commands/sdk/run.js.map +1 -1
  17. package/dist/commands/update.d.ts.map +1 -1
  18. package/dist/commands/update.js +5 -1
  19. package/dist/commands/update.js.map +1 -1
  20. package/dist/configurators/workflow.d.ts.map +1 -1
  21. package/dist/configurators/workflow.js +51 -2
  22. package/dist/configurators/workflow.js.map +1 -1
  23. package/dist/constants/paths.d.ts +4 -0
  24. package/dist/constants/paths.d.ts.map +1 -1
  25. package/dist/constants/paths.js +4 -0
  26. package/dist/constants/paths.js.map +1 -1
  27. package/dist/goal/action-packet.d.ts +89 -0
  28. package/dist/goal/action-packet.d.ts.map +1 -0
  29. package/dist/goal/action-packet.js +113 -0
  30. package/dist/goal/action-packet.js.map +1 -0
  31. package/dist/goal/audit.d.ts +14 -0
  32. package/dist/goal/audit.d.ts.map +1 -0
  33. package/dist/goal/audit.js +12 -0
  34. package/dist/goal/audit.js.map +1 -0
  35. package/dist/goal/constants.d.ts +26 -0
  36. package/dist/goal/constants.d.ts.map +1 -0
  37. package/dist/goal/constants.js +24 -0
  38. package/dist/goal/constants.js.map +1 -0
  39. package/dist/goal/paths.d.ts +8 -0
  40. package/dist/goal/paths.d.ts.map +1 -0
  41. package/dist/goal/paths.js +36 -0
  42. package/dist/goal/paths.js.map +1 -0
  43. package/dist/goal/preflight.d.ts +21 -0
  44. package/dist/goal/preflight.d.ts.map +1 -0
  45. package/dist/goal/preflight.js +97 -0
  46. package/dist/goal/preflight.js.map +1 -0
  47. package/dist/goal/review-seam.d.ts +44 -0
  48. package/dist/goal/review-seam.d.ts.map +1 -0
  49. package/dist/goal/review-seam.js +153 -0
  50. package/dist/goal/review-seam.js.map +1 -0
  51. package/dist/goal/reviewer-rules.d.ts +13 -0
  52. package/dist/goal/reviewer-rules.d.ts.map +1 -0
  53. package/dist/goal/reviewer-rules.js +153 -0
  54. package/dist/goal/reviewer-rules.js.map +1 -0
  55. package/dist/goal/reviewer.d.ts +9 -0
  56. package/dist/goal/reviewer.d.ts.map +1 -0
  57. package/dist/goal/reviewer.js +54 -0
  58. package/dist/goal/reviewer.js.map +1 -0
  59. package/dist/goal/runtime.d.ts +32 -0
  60. package/dist/goal/runtime.d.ts.map +1 -0
  61. package/dist/goal/runtime.js +242 -0
  62. package/dist/goal/runtime.js.map +1 -0
  63. package/dist/goal/sdk-client.d.ts +10 -0
  64. package/dist/goal/sdk-client.d.ts.map +1 -0
  65. package/dist/goal/sdk-client.js +34 -0
  66. package/dist/goal/sdk-client.js.map +1 -0
  67. package/dist/goal/state.d.ts +40 -0
  68. package/dist/goal/state.d.ts.map +1 -0
  69. package/dist/goal/state.js +37 -0
  70. package/dist/goal/state.js.map +1 -0
  71. package/dist/goal/task-triggers.d.ts +15 -0
  72. package/dist/goal/task-triggers.d.ts.map +1 -0
  73. package/dist/goal/task-triggers.js +23 -0
  74. package/dist/goal/task-triggers.js.map +1 -0
  75. package/dist/goal/walls.d.ts +12 -0
  76. package/dist/goal/walls.d.ts.map +1 -0
  77. package/dist/goal/walls.js +65 -0
  78. package/dist/goal/walls.js.map +1 -0
  79. package/dist/goal/worker-prompt.d.ts +13 -0
  80. package/dist/goal/worker-prompt.d.ts.map +1 -0
  81. package/dist/goal/worker-prompt.js +41 -0
  82. package/dist/goal/worker-prompt.js.map +1 -0
  83. package/dist/goal/worker.d.ts +42 -0
  84. package/dist/goal/worker.d.ts.map +1 -0
  85. package/dist/goal/worker.js +180 -0
  86. package/dist/goal/worker.js.map +1 -0
  87. package/dist/migrations/manifests/0.3.5.json +9 -0
  88. package/dist/migrations/manifests/0.3.6.json +9 -0
  89. package/dist/migrations/manifests/0.4.0.json +9 -0
  90. package/dist/templates/common/commands/continue.md +15 -3
  91. package/dist/templates/common/skills/check.md +7 -4
  92. package/dist/templates/cursor/agents/cstl-check.md +1 -0
  93. package/dist/templates/cursor/fixtures/expected-rules.d.ts.map +1 -1
  94. package/dist/templates/cursor/fixtures/expected-rules.js +6 -1
  95. package/dist/templates/cursor/fixtures/expected-rules.js.map +1 -1
  96. package/dist/templates/cursor/rules/cstl-cursor-modes.mdc +32 -0
  97. package/dist/templates/cursor/rules/cstl-triage.mdc +2 -4
  98. package/dist/templates/cursor/rules/retrieval-routing.mdc +26 -7
  99. package/dist/templates/markdown/index.d.ts +9 -0
  100. package/dist/templates/markdown/index.d.ts.map +1 -1
  101. package/dist/templates/markdown/index.js +9 -0
  102. package/dist/templates/markdown/index.js.map +1 -1
  103. package/dist/templates/markdown/spec/guides/artifact-locale-guide.md.txt +93 -0
  104. package/dist/templates/markdown/spec/guides/cross-platform-thinking-guide.md.txt +7 -7
  105. package/dist/templates/markdown/spec/guides/cursor-context-injection-guide.md.txt +38 -21
  106. package/dist/templates/markdown/spec/guides/cursor-native-modes-guide.md.txt +125 -0
  107. package/dist/templates/markdown/spec/guides/cursor-subagent-policy.md.txt +10 -8
  108. package/dist/templates/markdown/spec/guides/debug-loop-guide.md.txt +227 -0
  109. package/dist/templates/markdown/spec/guides/goal-release-regression-runbook.md.txt +132 -0
  110. package/dist/templates/markdown/spec/guides/index.md.txt +144 -104
  111. package/dist/templates/markdown/spec/guides/injection-budget-guide.md.txt +111 -0
  112. package/dist/templates/markdown/spec/guides/prototype-guide.md.txt +139 -0
  113. package/dist/templates/markdown/spec/guides/retrieval-daily-guide.md.txt +28 -6
  114. package/dist/templates/markdown/spec/guides/test-discipline-guide.md.txt +138 -0
  115. package/dist/templates/markdown/spec/guides/verification-strength-guide.md.txt +185 -0
  116. package/dist/templates/shared-hooks/session-start.py +10 -0
  117. package/dist/templates/trellis/config.yaml +18 -0
  118. package/dist/templates/trellis/index.d.ts +17 -0
  119. package/dist/templates/trellis/index.d.ts.map +1 -1
  120. package/dist/templates/trellis/index.js +40 -0
  121. package/dist/templates/trellis/index.js.map +1 -1
  122. package/dist/templates/trellis/pool/README.md +103 -0
  123. package/dist/templates/trellis/pool/items/.gitkeep +0 -0
  124. package/dist/templates/trellis/pool/plan.md +26 -0
  125. package/dist/templates/trellis/scripts/common/artifact_locale.py +278 -0
  126. package/dist/templates/trellis/scripts/common/codebase_retrieval_router.py +37 -2
  127. package/dist/templates/trellis/scripts/common/config.py +39 -0
  128. package/dist/templates/trellis/scripts/common/injection_budget.py +317 -0
  129. package/dist/templates/trellis/scripts/common/parent_orchestration.py +161 -5
  130. package/dist/templates/trellis/scripts/common/pool_store.py +702 -0
  131. package/dist/templates/trellis/scripts/common/retrieval_agent_instructions.py +9 -3
  132. package/dist/templates/trellis/scripts/common/session_context.py +32 -4
  133. package/dist/templates/trellis/scripts/common/session_memory.py +26 -0
  134. package/dist/templates/trellis/scripts/common/subagent_dispatch.py +74 -9
  135. package/dist/templates/trellis/scripts/common/task_context.py +18 -0
  136. package/dist/templates/trellis/scripts/common/task_dashboard.py +24 -1
  137. package/dist/templates/trellis/scripts/common/task_dependencies.py +673 -0
  138. package/dist/templates/trellis/scripts/common/task_gates.py +58 -5
  139. package/dist/templates/trellis/scripts/common/task_map.py +40 -8
  140. package/dist/templates/trellis/scripts/common/task_store.py +400 -39
  141. package/dist/templates/trellis/scripts/common/test_depends_mode_block.py +489 -0
  142. package/dist/templates/trellis/scripts/common/test_observable_defaults.py +135 -0
  143. package/dist/templates/trellis/scripts/common/test_pool_store.py +428 -0
  144. package/dist/templates/trellis/scripts/common/test_task_dependencies.py +345 -0
  145. package/dist/templates/trellis/scripts/cursor_retrieval_probe_prompt.md +14 -12
  146. package/dist/templates/trellis/scripts/generate_dispatch_prompt.py +182 -0
  147. package/dist/templates/trellis/scripts/injection_budget_probe.py +69 -0
  148. package/dist/templates/trellis/scripts/pool.py +192 -0
  149. package/dist/templates/trellis/scripts/retrieval_probe_matrix_template.json +3 -3
  150. package/dist/templates/trellis/scripts/spec_health_outcomes.py +160 -0
  151. package/dist/templates/trellis/scripts/task.py +85 -1
  152. package/dist/templates/trellis/scripts/verify_evidence_probe.py +138 -0
  153. package/dist/templates/trellis/tasks/locale/en/default-prd.md +19 -0
  154. package/dist/templates/trellis/tasks/locale/zh/default-prd.md +19 -0
  155. package/dist/templates/trellis/workflow.md +55 -5
  156. package/package.json +2 -2
@@ -0,0 +1,317 @@
1
+ #!/usr/bin/env python3
2
+ """Injection budget constants and measurement helpers (P0 channel matrix aligned)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import re
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import Literal
11
+
12
+ RULES_TOTAL_WARN_BYTES = 20_480
13
+ RULES_TOTAL_FAIL_BYTES = 32_768
14
+ AGENTS_CSTL_WARN_BYTES = 4_096
15
+ JSONL_MAX_ENTRIES = 8
16
+ JSONL_MAX_FILE_CHARS = 12_000
17
+ JSONL_MAX_TOTAL_CHARS = 48_000
18
+
19
+ DISPATCH_DEFAULT_MAX_CHARS: dict[str, int] = {
20
+ "implement": 96_000,
21
+ "check": 64_000,
22
+ "research": 48_000,
23
+ }
24
+
25
+ DispatchRole = Literal["implement", "check", "research"]
26
+
27
+
28
+ @dataclass
29
+ class RuleFileMeasure:
30
+ path: str
31
+ bytes: int
32
+ always_apply: bool
33
+
34
+
35
+ @dataclass
36
+ class RulesMeasure:
37
+ files: list[RuleFileMeasure] = field(default_factory=list)
38
+
39
+ @property
40
+ def always_apply_total_bytes(self) -> int:
41
+ return sum(f.bytes for f in self.files if f.always_apply)
42
+
43
+
44
+ @dataclass
45
+ class AgentsMeasure:
46
+ path: str
47
+ total_bytes: int
48
+ cstl_block_bytes: int
49
+
50
+
51
+ @dataclass
52
+ class JsonlMeasure:
53
+ jsonl_path: str
54
+ entry_count: int
55
+ expanded_chars: int
56
+ skipped: list[str] = field(default_factory=list)
57
+ loaded_files: list[str] = field(default_factory=list)
58
+
59
+
60
+ @dataclass
61
+ class DispatchMeasure:
62
+ role: str
63
+ task_rel: str
64
+ prompt_chars: int
65
+ warnings: list[str] = field(default_factory=list)
66
+
67
+
68
+ @dataclass
69
+ class BudgetReport:
70
+ rules: RulesMeasure
71
+ agents: AgentsMeasure | None
72
+ jsonl: list[JsonlMeasure] = field(default_factory=list)
73
+ dispatch: DispatchMeasure | None = None
74
+
75
+
76
+ def _read_text_bytes(path: Path) -> tuple[str, int]:
77
+ data = path.read_bytes()
78
+ try:
79
+ text = data.decode("utf-8")
80
+ except UnicodeDecodeError:
81
+ text = data.decode("utf-8", errors="replace")
82
+ return text, len(data)
83
+
84
+
85
+ def measure_always_apply_rules(repo_root: Path) -> RulesMeasure:
86
+ rules_dir = repo_root / ".cursor" / "rules"
87
+ result = RulesMeasure()
88
+ if not rules_dir.is_dir():
89
+ return result
90
+
91
+ for path in sorted(rules_dir.glob("*.mdc")):
92
+ text, nbytes = _read_text_bytes(path)
93
+ always = bool(re.search(r"^alwaysApply:\s*true\s*$", text, re.MULTILINE))
94
+ result.files.append(
95
+ RuleFileMeasure(path=str(path.relative_to(repo_root)), bytes=nbytes, always_apply=always)
96
+ )
97
+ return result
98
+
99
+
100
+ def measure_agents_block(repo_root: Path) -> AgentsMeasure | None:
101
+ agents_path = repo_root / "AGENTS.md"
102
+ if not agents_path.is_file():
103
+ return None
104
+ text, nbytes = _read_text_bytes(agents_path)
105
+ cstl_bytes = 0
106
+ start = text.find("<!-- CSTL:START -->")
107
+ end = text.find("<!-- CSTL:END -->")
108
+ if start != -1 and end != -1 and end > start:
109
+ cstl_bytes = len(text[start : end + len("<!-- CSTL:END -->")].encode("utf-8"))
110
+ return AgentsMeasure(
111
+ path="AGENTS.md",
112
+ total_bytes=nbytes,
113
+ cstl_block_bytes=cstl_bytes,
114
+ )
115
+
116
+
117
+ def _expand_jsonl_entry(repo_root: Path, file_path: str, entry_type: str) -> list[tuple[str, str]]:
118
+ if entry_type == "directory":
119
+ from .subagent_dispatch import read_directory_contents # lazy import
120
+
121
+ return read_directory_contents(str(repo_root), file_path)
122
+ from .subagent_dispatch import read_file_content
123
+
124
+ content = read_file_content(str(repo_root), file_path)
125
+ if content is None:
126
+ return []
127
+ return [(file_path, content)]
128
+
129
+
130
+ def measure_jsonl_manifest(
131
+ repo_root: Path,
132
+ task_dir: Path,
133
+ jsonl_name: str,
134
+ *,
135
+ apply_budget: bool = True,
136
+ ) -> JsonlMeasure:
137
+ jsonl_path = task_dir / jsonl_name
138
+ rel_jsonl = str(jsonl_path.relative_to(repo_root)) if jsonl_path.is_file() else jsonl_name
139
+ measure = JsonlMeasure(jsonl_path=rel_jsonl, entry_count=0, expanded_chars=0)
140
+ if not jsonl_path.is_file():
141
+ return measure
142
+
143
+ entries: list[tuple[str, str]] = []
144
+ for line in jsonl_path.read_text(encoding="utf-8").splitlines():
145
+ line = line.strip()
146
+ if not line:
147
+ continue
148
+ try:
149
+ item = json.loads(line)
150
+ except json.JSONDecodeError:
151
+ continue
152
+ file_path = item.get("file") or item.get("path")
153
+ if not file_path:
154
+ continue
155
+ entry_type = item.get("type", "file")
156
+ entries.append((file_path, entry_type))
157
+
158
+ measure.entry_count = len(entries)
159
+ if not apply_budget:
160
+ for file_path, entry_type in entries:
161
+ for rel, content in _expand_jsonl_entry(repo_root, file_path, entry_type):
162
+ measure.loaded_files.append(rel)
163
+ measure.expanded_chars += len(content)
164
+ return measure
165
+
166
+ total_chars = 0
167
+ loaded_count = 0
168
+ for file_path, entry_type in entries:
169
+ if loaded_count >= JSONL_MAX_ENTRIES:
170
+ measure.skipped.append(f"{file_path}: entry cap ({JSONL_MAX_ENTRIES})")
171
+ continue
172
+ expanded = _expand_jsonl_entry(repo_root, file_path, entry_type)
173
+ if not expanded:
174
+ measure.skipped.append(f"{file_path}: missing or empty")
175
+ continue
176
+ block_chars = sum(len(content) for _, content in expanded)
177
+ if block_chars > JSONL_MAX_FILE_CHARS:
178
+ measure.skipped.append(
179
+ f"{file_path}: file cap ({block_chars}>{JSONL_MAX_FILE_CHARS} chars)"
180
+ )
181
+ continue
182
+ if total_chars + block_chars > JSONL_MAX_TOTAL_CHARS:
183
+ measure.skipped.append(
184
+ f"{file_path}: total cap ({total_chars + block_chars}>{JSONL_MAX_TOTAL_CHARS})"
185
+ )
186
+ continue
187
+ for rel, content in expanded:
188
+ measure.loaded_files.append(rel)
189
+ total_chars += len(content)
190
+ loaded_count += 1
191
+
192
+ measure.expanded_chars = total_chars
193
+ return measure
194
+
195
+
196
+ def measure_dispatch_prompt(repo_root: Path, task_dir: Path, role: DispatchRole) -> DispatchMeasure | None:
197
+ from .subagent_dispatch import build_dispatch_prompt
198
+
199
+ prompt, warnings, errors = build_dispatch_prompt(
200
+ repo_root,
201
+ task_dir,
202
+ role,
203
+ max_chars=DISPATCH_DEFAULT_MAX_CHARS.get(role),
204
+ require_in_progress=False,
205
+ )
206
+ if not prompt:
207
+ return None
208
+ try:
209
+ task_rel = task_dir.relative_to(repo_root).as_posix()
210
+ except ValueError:
211
+ task_rel = task_dir.as_posix()
212
+ return DispatchMeasure(
213
+ role=role,
214
+ task_rel=task_rel,
215
+ prompt_chars=len(prompt),
216
+ warnings=list(warnings) + [f"error:{e}" for e in errors],
217
+ )
218
+
219
+
220
+ def collect_budget_report(
221
+ repo_root: Path,
222
+ *,
223
+ task_dir: Path | None = None,
224
+ dispatch_role: DispatchRole | None = None,
225
+ ) -> BudgetReport:
226
+ report = BudgetReport(
227
+ rules=measure_always_apply_rules(repo_root),
228
+ agents=measure_agents_block(repo_root),
229
+ )
230
+ if task_dir and task_dir.is_dir():
231
+ for name in ("implement.jsonl", "check.jsonl"):
232
+ report.jsonl.append(measure_jsonl_manifest(repo_root, task_dir, name))
233
+ if dispatch_role:
234
+ report.dispatch = measure_dispatch_prompt(repo_root, task_dir, dispatch_role)
235
+ return report
236
+
237
+
238
+ def budget_violations(report: BudgetReport, *, strict: bool = False) -> list[str]:
239
+ violations: list[str] = []
240
+ rules_total = report.rules.always_apply_total_bytes
241
+ if rules_total > RULES_TOTAL_WARN_BYTES:
242
+ level = "FAIL" if strict and rules_total > RULES_TOTAL_FAIL_BYTES else "WARN"
243
+ violations.append(
244
+ f"{level}: alwaysApply rules total {rules_total} B "
245
+ f"(warn>{RULES_TOTAL_WARN_BYTES}, fail>{RULES_TOTAL_FAIL_BYTES})"
246
+ )
247
+ elif strict and rules_total > RULES_TOTAL_WARN_BYTES:
248
+ violations.append(
249
+ f"WARN: alwaysApply rules total {rules_total} B (warn>{RULES_TOTAL_WARN_BYTES})"
250
+ )
251
+
252
+ if report.agents and report.agents.cstl_block_bytes > AGENTS_CSTL_WARN_BYTES:
253
+ violations.append(
254
+ f"WARN: AGENTS CSTL block {report.agents.cstl_block_bytes} B "
255
+ f"(warn>{AGENTS_CSTL_WARN_BYTES})"
256
+ )
257
+
258
+ for jm in report.jsonl:
259
+ if jm.entry_count > JSONL_MAX_ENTRIES:
260
+ violations.append(
261
+ f"WARN: {jm.jsonl_path} has {jm.entry_count} entries (max {JSONL_MAX_ENTRIES})"
262
+ )
263
+ if jm.expanded_chars > JSONL_MAX_TOTAL_CHARS:
264
+ violations.append(
265
+ f"WARN: {jm.jsonl_path} expanded {jm.expanded_chars} chars "
266
+ f"(max {JSONL_MAX_TOTAL_CHARS})"
267
+ )
268
+
269
+ if report.dispatch:
270
+ cap = DISPATCH_DEFAULT_MAX_CHARS.get(report.dispatch.role, 0)
271
+ if cap and report.dispatch.prompt_chars > cap:
272
+ violations.append(
273
+ f"WARN: dispatch {report.dispatch.role} prompt {report.dispatch.prompt_chars} chars "
274
+ f"(default cap {cap})"
275
+ )
276
+
277
+ return violations
278
+
279
+
280
+ def format_budget_report(report: BudgetReport) -> str:
281
+ lines: list[str] = ["# Injection budget report", ""]
282
+ lines.append("## alwaysApply rules (.cursor/rules/*.mdc)")
283
+ for rf in report.rules.files:
284
+ flag = "alwaysApply" if rf.always_apply else "scoped"
285
+ lines.append(f"- {rf.path}: {rf.bytes} B ({flag})")
286
+ lines.append(f"- **Total alwaysApply:** {report.rules.always_apply_total_bytes} B")
287
+ lines.append("")
288
+
289
+ if report.agents:
290
+ lines.append("## AGENTS.md")
291
+ lines.append(f"- Total: {report.agents.total_bytes} B")
292
+ lines.append(f"- CSTL block: {report.agents.cstl_block_bytes} B")
293
+ lines.append("")
294
+
295
+ for jm in report.jsonl:
296
+ lines.append(f"## {jm.jsonl_path}")
297
+ lines.append(f"- Entries: {jm.entry_count}")
298
+ lines.append(f"- Expanded (budgeted): {jm.expanded_chars} chars")
299
+ if jm.loaded_files:
300
+ lines.append(f"- Loaded: {', '.join(jm.loaded_files)}")
301
+ if jm.skipped:
302
+ lines.append("- Skipped:")
303
+ for item in jm.skipped:
304
+ lines.append(f" - {item}")
305
+ lines.append("")
306
+
307
+ if report.dispatch:
308
+ lines.append(f"## Layer 2 dispatch ({report.dispatch.role})")
309
+ lines.append(f"- Task: {report.dispatch.task_rel}")
310
+ lines.append(f"- Prompt chars: {report.dispatch.prompt_chars}")
311
+ if report.dispatch.warnings:
312
+ lines.append("- Warnings:")
313
+ for w in report.dispatch.warnings:
314
+ lines.append(f" - {w}")
315
+ lines.append("")
316
+
317
+ return "\n".join(lines)
@@ -12,6 +12,7 @@ import re
12
12
  from datetime import datetime, timezone
13
13
  from pathlib import Path
14
14
 
15
+ from .io import read_json
15
16
  from .paths import get_repo_root
16
17
  from .task_gates import (
17
18
  build_spec_update_scaffold,
@@ -22,6 +23,7 @@ from .task_map import (
22
23
  PARENT_TERMINAL_STATES,
23
24
  get_child_entry,
24
25
  load_task_map,
26
+ resolve_child_map_id,
25
27
  validate_parent_child_integration,
26
28
  )
27
29
 
@@ -100,7 +102,7 @@ def _child_prompt_section(
100
102
  *,
101
103
  include_artifacts: bool,
102
104
  ) -> str:
103
- child_name = child_dir.name
105
+ child_name = child_entry.get("id") or child_dir.name
104
106
  parent_rel = _repo_rel(parent_dir, repo_root)
105
107
  child_rel = _repo_rel(child_dir, repo_root)
106
108
  state = child_entry.get("state", "open")
@@ -164,12 +166,15 @@ def build_child_prompt(
164
166
  """Build an implementation prompt for a linked child task."""
165
167
  errors: list[str] = []
166
168
  repo_root = get_repo_root()
167
- child_name = child_dir.name
168
169
 
169
170
  parent_json = parent_dir / "task.json"
170
171
  if not parent_json.is_file():
171
172
  return None, [f"parent task.json missing: {parent_dir}"]
172
173
 
174
+ parent_data = read_json(parent_json) or {}
175
+ child_data = read_json(child_dir / "task.json") or {}
176
+ child_name = resolve_child_map_id(parent_data, child_dir, child_data)
177
+
173
178
  data, body = load_task_map(parent_dir)
174
179
  if data is None:
175
180
  return None, ["parent task-map.md missing or invalid"]
@@ -360,6 +365,141 @@ def _unit_readiness(
360
365
  return ("ready" if not blocked else "blocked", blocked)
361
366
 
362
367
 
368
+ _PACK_GENERATED_RE = re.compile(r"^- Generated:\s*(.+)$", re.MULTILINE)
369
+ _PACK_DASH = r"[\u2014\-]"
370
+ _PACK_UNIT_LINE_RE = re.compile(
371
+ rf"^- `([^`]+)` {_PACK_DASH} `([^`]+)` {_PACK_DASH} \*\*(ready|blocked)\*\*",
372
+ re.MULTILINE,
373
+ )
374
+
375
+
376
+ def _parse_iso_timestamp(raw: str) -> datetime | None:
377
+ text = raw.strip()
378
+ if not text:
379
+ return None
380
+ normalized = text[:-1] + "+00:00" if text.endswith("Z") else text
381
+ try:
382
+ parsed = datetime.fromisoformat(normalized)
383
+ except ValueError:
384
+ try:
385
+ parsed = datetime.strptime(text, "%Y-%m-%d")
386
+ except ValueError:
387
+ return None
388
+ if parsed.tzinfo is None:
389
+ return parsed.replace(tzinfo=timezone.utc)
390
+ return parsed
391
+
392
+
393
+ def _parse_pack_generated(pack_text: str) -> datetime | None:
394
+ match = _PACK_GENERATED_RE.search(pack_text)
395
+ if not match:
396
+ return None
397
+ return _parse_iso_timestamp(match.group(1))
398
+
399
+
400
+ def _last_integrate_event_time(body: str) -> datetime | None:
401
+ last: datetime | None = None
402
+ for line in body.splitlines():
403
+ stripped = line.strip()
404
+ if not stripped.startswith("- "):
405
+ continue
406
+ remainder = stripped[2:]
407
+ parts = remainder.split(" - ", 1)
408
+ if len(parts) != 2:
409
+ continue
410
+ ts_raw, message = parts[0].strip(), parts[1]
411
+ lowered = message.lower()
412
+ if "integration state" not in message and "integrate-through" not in lowered:
413
+ continue
414
+ parsed = _parse_iso_timestamp(ts_raw)
415
+ if parsed is not None and (last is None or parsed > last):
416
+ last = parsed
417
+ return last
418
+
419
+
420
+ def _pack_ready_units(pack_text: str) -> set[str]:
421
+ ready: set[str] = set()
422
+ for unit_id, _state, readiness in _PACK_UNIT_LINE_RE.findall(pack_text):
423
+ if readiness == "ready":
424
+ ready.add(unit_id)
425
+ return ready
426
+
427
+
428
+ def _expected_pack_ready_units(
429
+ data: dict,
430
+ children_by_id: dict[str, dict],
431
+ ) -> set[str]:
432
+ stages, _, _ = _normalize_stages(data)
433
+ ready: set[str] = set()
434
+ for stage in stages:
435
+ for unit in stage.get("units") or []:
436
+ if not isinstance(unit, str):
437
+ continue
438
+ readiness, _ = _unit_readiness(unit, children_by_id)
439
+ if readiness == "ready":
440
+ ready.add(unit)
441
+ return ready
442
+
443
+
444
+ def _pack_unit_states_mismatch(
445
+ pack_text: str,
446
+ children_by_id: dict[str, dict],
447
+ ) -> bool:
448
+ for unit_id, pack_state, _readiness in _PACK_UNIT_LINE_RE.findall(pack_text):
449
+ entry = children_by_id.get(unit_id) or {}
450
+ current_state = entry.get("state", "?")
451
+ if str(current_state) != pack_state:
452
+ return True
453
+ return False
454
+
455
+
456
+ def _compute_stale_pack(
457
+ parent_dir: Path,
458
+ data: dict,
459
+ body: str,
460
+ children_by_id: dict[str, dict],
461
+ ) -> bool | None:
462
+ pack_path = parent_dir / "child-prompts" / "PACK.md"
463
+ if not pack_path.is_file():
464
+ return None
465
+ try:
466
+ pack_text = pack_path.read_text(encoding="utf-8")
467
+ except OSError:
468
+ return None
469
+
470
+ generated = _parse_pack_generated(pack_text)
471
+ if generated is None:
472
+ return None
473
+
474
+ stale = False
475
+ last_integrate = _last_integrate_event_time(body)
476
+ if last_integrate is not None and generated < last_integrate:
477
+ stale = True
478
+ if _pack_unit_states_mismatch(pack_text, children_by_id):
479
+ stale = True
480
+ expected_ready = _expected_pack_ready_units(data, children_by_id)
481
+ if _pack_ready_units(pack_text) != expected_ready:
482
+ stale = True
483
+ return stale
484
+
485
+
486
+ def _compute_newly_ready(children_by_id: dict[str, dict]) -> list[str]:
487
+ ready_ids: list[str] = []
488
+ for unit_id in sorted(children_by_id):
489
+ entry = children_by_id[unit_id]
490
+ if entry.get("state") != "open":
491
+ continue
492
+ readiness, _ = _unit_readiness(unit_id, children_by_id)
493
+ if readiness == "ready":
494
+ ready_ids.append(unit_id)
495
+ return ready_ids
496
+
497
+
498
+ def _child_prompt_filename(unit_id: str) -> str:
499
+ """Flat filename for child-prompts (nested slash unit ids use `--`)."""
500
+ return f"{unit_id.replace('/', '--')}.md"
501
+
502
+
363
503
  def build_publish_pack(
364
504
  parent_dir: Path,
365
505
  *,
@@ -442,7 +582,7 @@ def build_publish_pack(
442
582
  if prompt_errors or not prompt:
443
583
  errors.extend(prompt_errors or [f"could not build prompt for `{unit}`"])
444
584
  continue
445
- out_name = f"{unit}.md"
585
+ out_name = _child_prompt_filename(unit)
446
586
  out_rel = f"{parent_rel}/child-prompts/{out_name}"
447
587
  if not dry_run:
448
588
  prompts_dir.mkdir(parents=True, exist_ok=True)
@@ -470,7 +610,7 @@ def build_publish_pack(
470
610
  "",
471
611
  ]
472
612
  )
473
- out_name = f"{unit}.md"
613
+ out_name = _child_prompt_filename(unit)
474
614
  out_rel = f"{parent_rel}/child-prompts/{out_name}"
475
615
  if not dry_run:
476
616
  prompts_dir.mkdir(parents=True, exist_ok=True)
@@ -539,6 +679,9 @@ def build_parent_status_dict(parent_dir: Path) -> dict | None:
539
679
 
540
680
  children_by_id = _children_by_id(data)
541
681
  stages, stage_errors, legacy = _normalize_stages(data)
682
+ _, body = load_task_map(parent_dir)
683
+ stale_pack = _compute_stale_pack(parent_dir, data, body, children_by_id)
684
+ newly_ready = _compute_newly_ready(children_by_id)
542
685
 
543
686
  stage_rows: list[dict] = []
544
687
  for stage in stages:
@@ -597,6 +740,8 @@ def build_parent_status_dict(parent_dir: Path) -> dict | None:
597
740
  "integrationQueue": data.get("integration_queue") or [],
598
741
  "stageErrors": stage_errors,
599
742
  "legacyStages": legacy,
743
+ "stalePack": stale_pack,
744
+ "newlyReady": newly_ready,
600
745
  }
601
746
 
602
747
 
@@ -620,6 +765,17 @@ def build_parent_status(parent_dir: Path) -> str:
620
765
 
621
766
  children_by_id = _children_by_id(data)
622
767
  stages, stage_errors, legacy = _normalize_stages(data)
768
+ _, body = load_task_map(parent_dir)
769
+ stale_pack = _compute_stale_pack(parent_dir, data, body, children_by_id)
770
+ newly_ready = _compute_newly_ready(children_by_id)
771
+ lines.append("## Pack freshness")
772
+ lines.append("")
773
+ lines.append(f"- stalePack: {stale_pack}")
774
+ if newly_ready:
775
+ lines.append(f"- newlyReady: {', '.join(newly_ready)}")
776
+ else:
777
+ lines.append("- newlyReady: (none)")
778
+ lines.append("")
623
779
  lines.append("## Stages")
624
780
  lines.append("")
625
781
  if stage_errors:
@@ -790,7 +946,7 @@ def build_review_report(
790
946
  notes: str | None,
791
947
  ) -> tuple[str, list[str], dict]:
792
948
  """Build review summary and validate integration transitions for a decision."""
793
- child_name = child_dir.name
949
+ child_name = resolve_child_map_id(parent_data, child_dir, child_data)
794
950
  summary = _summarize_handoff(child_dir)
795
951
  errors: list[str] = []
796
952
  actions: dict = {"integrate": None, "gates": []}