@deftai/directive-content 0.55.1 → 0.56.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 (220) hide show
  1. package/.githooks/pre-commit +143 -0
  2. package/.githooks/pre-push +121 -0
  3. package/QUICK-START.md +13 -3
  4. package/Taskfile.yml +934 -0
  5. package/UPGRADING.md +82 -11
  6. package/events/README.md +3 -3
  7. package/package.json +5 -4
  8. package/packs/skills/skills-pack-0.1.json +22 -22
  9. package/scripts/_agents_md.py +494 -0
  10. package/scripts/_cache_fetch.py +635 -0
  11. package/scripts/_cache_quota.py +529 -0
  12. package/scripts/_cache_refresh.py +163 -0
  13. package/scripts/_cache_validate.py +209 -0
  14. package/scripts/_content_root.py +42 -0
  15. package/scripts/_doctor_state.py +277 -0
  16. package/scripts/_event_detect.py +305 -0
  17. package/scripts/_events.py +514 -0
  18. package/scripts/_lifecycle_hygiene.py +568 -0
  19. package/scripts/_pathspec.py +91 -0
  20. package/scripts/_policy_show_cli.py +266 -0
  21. package/scripts/_precutover.py +92 -0
  22. package/scripts/_project_context.py +224 -0
  23. package/scripts/_project_definition_io.py +164 -0
  24. package/scripts/_relocate_snapshot.py +209 -0
  25. package/scripts/_relocate_states.py +343 -0
  26. package/scripts/_resolve_preflight_path.py +152 -0
  27. package/scripts/_safe_subprocess.py +167 -0
  28. package/scripts/_session_start_hook.py +205 -0
  29. package/scripts/_sor_gate_diff.py +365 -0
  30. package/scripts/_stdio_utf8.py +59 -0
  31. package/scripts/_triage_bootstrap_gitignore.py +904 -0
  32. package/scripts/_triage_classify_cli.py +122 -0
  33. package/scripts/_triage_queue_cli.py +625 -0
  34. package/scripts/_triage_scope_cli.py +343 -0
  35. package/scripts/_triage_scope_drift_cli.py +121 -0
  36. package/scripts/_triage_scope_ignores.py +286 -0
  37. package/scripts/_triage_scope_milestone.py +432 -0
  38. package/scripts/_triage_scope_mutations.py +337 -0
  39. package/scripts/_triage_scope_renderers.py +207 -0
  40. package/scripts/_triage_smoketest_stages.py +674 -0
  41. package/scripts/_triage_subscribe_cli.py +140 -0
  42. package/scripts/_triage_welcome_cli.py +421 -0
  43. package/scripts/_vbrief_build.py +239 -0
  44. package/scripts/_vbrief_fidelity.py +479 -0
  45. package/scripts/_vbrief_legacy.py +589 -0
  46. package/scripts/_vbrief_reconciliation.py +883 -0
  47. package/scripts/_vbrief_routing.py +277 -0
  48. package/scripts/_vbrief_safety.py +778 -0
  49. package/scripts/_vbrief_sources.py +312 -0
  50. package/scripts/_vbrief_speckit.py +262 -0
  51. package/scripts/_vbrief_story_quality.py +353 -0
  52. package/scripts/_vbrief_validation.py +299 -0
  53. package/scripts/build_dist.py +412 -0
  54. package/scripts/cache.py +1078 -0
  55. package/scripts/cache_scanner.py +745 -0
  56. package/scripts/candidates_log.py +432 -0
  57. package/scripts/capacity_backfill.py +680 -0
  58. package/scripts/capacity_show.py +653 -0
  59. package/scripts/ci_local.py +689 -0
  60. package/scripts/code_structure_validate.py +765 -0
  61. package/scripts/codebase_default_extractor.py +495 -0
  62. package/scripts/codebase_map.py +304 -0
  63. package/scripts/codebase_map_fresh.py +104 -0
  64. package/scripts/codebase_projection_registry.py +94 -0
  65. package/scripts/codebase_provider.py +582 -0
  66. package/scripts/doctor.py +2257 -0
  67. package/scripts/framework_commands.py +505 -0
  68. package/scripts/gh_rest.py +882 -0
  69. package/scripts/github_auth_modes.py +437 -0
  70. package/scripts/github_body.py +292 -0
  71. package/scripts/ip_risk.py +531 -0
  72. package/scripts/issue_emit.py +670 -0
  73. package/scripts/issue_ingest.py +1064 -0
  74. package/scripts/migrate_preflight.py +418 -0
  75. package/scripts/migrate_vbrief.py +2677 -0
  76. package/scripts/monitor_pr.py +401 -0
  77. package/scripts/pack_migrate_lessons.py +336 -0
  78. package/scripts/pack_migrate_patterns.py +254 -0
  79. package/scripts/pack_migrate_rules.py +350 -0
  80. package/scripts/pack_migrate_skills.py +423 -0
  81. package/scripts/pack_migrate_strategies.py +311 -0
  82. package/scripts/pack_migrate_swarm_spec.py +250 -0
  83. package/scripts/pack_render.py +434 -0
  84. package/scripts/packs_slice.py +712 -0
  85. package/scripts/platform_capabilities.py +336 -0
  86. package/scripts/policy.py +2826 -0
  87. package/scripts/policy_set.py +324 -0
  88. package/scripts/pr_check_closing_keywords.py +524 -0
  89. package/scripts/pr_check_protected_issues.py +267 -0
  90. package/scripts/pr_merge_readiness.py +1004 -0
  91. package/scripts/pr_wait_mergeable.py +669 -0
  92. package/scripts/prd_render.py +159 -0
  93. package/scripts/preflight_architecture_sor.py +974 -0
  94. package/scripts/preflight_branch.py +289 -0
  95. package/scripts/preflight_cache.py +974 -0
  96. package/scripts/preflight_gh.py +721 -0
  97. package/scripts/preflight_implementation.py +272 -0
  98. package/scripts/preflight_story_start.py +838 -0
  99. package/scripts/preflight_wip_cap.py +149 -0
  100. package/scripts/probe_session.py +545 -0
  101. package/scripts/project_render.py +293 -0
  102. package/scripts/quarantine_ext.py +237 -0
  103. package/scripts/reconcile_issues.py +1442 -0
  104. package/scripts/refresh-path.ps1 +107 -0
  105. package/scripts/release.py +2030 -0
  106. package/scripts/release_e2e.py +1011 -0
  107. package/scripts/release_publish.py +486 -0
  108. package/scripts/release_rollback.py +980 -0
  109. package/scripts/relocate.py +1034 -0
  110. package/scripts/resolve_changelog_unreleased.py +667 -0
  111. package/scripts/resolve_version.py +490 -0
  112. package/scripts/resume_conditions.py +706 -0
  113. package/scripts/ritual_sentinel.py +609 -0
  114. package/scripts/roadmap_render.py +635 -0
  115. package/scripts/rule_ownership_lint.py +325 -0
  116. package/scripts/scm.py +591 -0
  117. package/scripts/scope_audit_log.py +387 -0
  118. package/scripts/scope_decompose.py +654 -0
  119. package/scripts/scope_demote.py +509 -0
  120. package/scripts/scope_lifecycle.py +1126 -0
  121. package/scripts/scope_undo.py +772 -0
  122. package/scripts/session_start.py +406 -0
  123. package/scripts/setup_ghx.py +339 -0
  124. package/scripts/setup_windows.ps1 +220 -0
  125. package/scripts/slice_audit.py +585 -0
  126. package/scripts/slice_record.py +530 -0
  127. package/scripts/slice_record_existing.py +692 -0
  128. package/scripts/slug_normalize.py +178 -0
  129. package/scripts/spec_render.py +477 -0
  130. package/scripts/spec_validate.py +238 -0
  131. package/scripts/subagent_monitor.py +658 -0
  132. package/scripts/swarm_complete_cohort.py +644 -0
  133. package/scripts/swarm_launch.py +1206 -0
  134. package/scripts/swarm_readiness.py +554 -0
  135. package/scripts/swarm_verify_review_clean.py +438 -0
  136. package/scripts/swarm_worktrees.py +497 -0
  137. package/scripts/toolchain-check.py +52 -0
  138. package/scripts/triage_actions.py +871 -0
  139. package/scripts/triage_bootstrap.py +1153 -0
  140. package/scripts/triage_bulk.py +630 -0
  141. package/scripts/triage_classify.py +932 -0
  142. package/scripts/triage_help.py +1685 -0
  143. package/scripts/triage_queue.py +1944 -0
  144. package/scripts/triage_reconcile.py +581 -0
  145. package/scripts/triage_refresh.py +643 -0
  146. package/scripts/triage_scope.py +999 -0
  147. package/scripts/triage_scope_drift.py +575 -0
  148. package/scripts/triage_smoketest.py +396 -0
  149. package/scripts/triage_subscribe.py +399 -0
  150. package/scripts/triage_summary.py +1011 -0
  151. package/scripts/triage_welcome.py +1178 -0
  152. package/scripts/ts_check_lane.py +86 -0
  153. package/scripts/validate-links.py +64 -0
  154. package/scripts/validate_strategy_output.py +212 -0
  155. package/scripts/vbrief_activate.py +228 -0
  156. package/scripts/vbrief_migrate_conformance.py +368 -0
  157. package/scripts/vbrief_reconcile_graph.py +306 -0
  158. package/scripts/vbrief_reconcile_labels.py +460 -0
  159. package/scripts/vbrief_reconcile_umbrellas.py +741 -0
  160. package/scripts/vbrief_validate.py +1195 -0
  161. package/scripts/verify-stubs.py +61 -0
  162. package/scripts/verify_capacity.py +160 -0
  163. package/scripts/verify_encoding.py +699 -0
  164. package/scripts/verify_hooks_installed.py +206 -0
  165. package/scripts/verify_investigation.py +360 -0
  166. package/scripts/verify_judgment_gates.py +827 -0
  167. package/scripts/verify_no_task_runtime.py +171 -0
  168. package/scripts/verify_scm_boundary.py +509 -0
  169. package/scripts/verify_session_ritual.py +389 -0
  170. package/scripts/verify_tools.py +426 -0
  171. package/scripts/verify_vbrief_conformance.py +478 -0
  172. package/skills/deft-directive-swarm/SKILL.md +7 -26
  173. package/skills/deft-directive-sync/SKILL.md +1 -1
  174. package/tasks/architecture.yml +13 -0
  175. package/tasks/cache.yml +69 -0
  176. package/tasks/capacity.yml +38 -0
  177. package/tasks/change.yml +46 -0
  178. package/tasks/changelog.yml +24 -0
  179. package/tasks/ci.yml +49 -0
  180. package/tasks/codebase.yml +47 -0
  181. package/tasks/commit.yml +30 -0
  182. package/tasks/core.yml +126 -0
  183. package/tasks/deployments.yml +54 -0
  184. package/tasks/framework.yml +74 -0
  185. package/tasks/install.yml +60 -0
  186. package/tasks/issue.yml +50 -0
  187. package/tasks/migrate.yml +73 -0
  188. package/tasks/packs.yml +92 -0
  189. package/tasks/policy.yml +75 -0
  190. package/tasks/pr.yml +89 -0
  191. package/tasks/prd.yml +39 -0
  192. package/tasks/project.yml +27 -0
  193. package/tasks/reconcile.yml +32 -0
  194. package/tasks/relocate.yml +56 -0
  195. package/tasks/roadmap.yml +28 -0
  196. package/tasks/scm.yml +126 -0
  197. package/tasks/scope-undo.yml +36 -0
  198. package/tasks/scope.yml +141 -0
  199. package/tasks/session.yml +19 -0
  200. package/tasks/setup.yml +37 -0
  201. package/tasks/slice.yml +69 -0
  202. package/tasks/spec.yml +41 -0
  203. package/tasks/swarm.yml +85 -0
  204. package/tasks/toolchain.yml +13 -0
  205. package/tasks/triage-actions.yml +94 -0
  206. package/tasks/triage-bootstrap.yml +43 -0
  207. package/tasks/triage-bulk.yml +75 -0
  208. package/tasks/triage-classify.yml +30 -0
  209. package/tasks/triage-queue.yml +50 -0
  210. package/tasks/triage-reconcile.yml +29 -0
  211. package/tasks/triage-scope-drift.yml +29 -0
  212. package/tasks/triage-scope.yml +31 -0
  213. package/tasks/triage-smoketest.yml +33 -0
  214. package/tasks/triage-subscribe.yml +36 -0
  215. package/tasks/triage-summary.yml +29 -0
  216. package/tasks/triage-welcome.yml +32 -0
  217. package/tasks/ts.yml +328 -0
  218. package/tasks/vbrief.yml +206 -0
  219. package/tasks/verify.yml +292 -0
  220. package/templates/agents-entry.md +2 -2
@@ -0,0 +1,741 @@
1
+ #!/usr/bin/env python3
2
+ """vbrief_reconcile_umbrellas.py -- umbrella current-shape auto-update (#1289).
3
+
4
+ The final reconcile-suite verb (``task vbrief:reconcile:umbrellas``), the
5
+ companion to ``task vbrief:reconcile:graph`` (#1287) and
6
+ ``task vbrief:reconcile:labels`` (#1288). Where the graph walker promotes
7
+ proposed/ vBRIEFs as their dependencies clear and the label reconciler
8
+ keeps the forge label surface in sync, this verb keeps every
9
+ ``kind == "epic"`` umbrella's canonical *current-shape* comment in sync
10
+ with canonical vBRIEF state per the AGENTS.md "Umbrella current-shape
11
+ convention (#1152)".
12
+
13
+ For each epic vBRIEF the verb:
14
+
15
+ * resolves the epic's children from its ``plan.references[]`` entries of
16
+ type ``x-vbrief/plan`` (the linkage ``scripts/scope_decompose.py``
17
+ writes), looking each child up by filename across the lifecycle folders
18
+ so a child that has since moved folder is still resolved to its current
19
+ lifecycle state;
20
+ * computes the wave structure from the children's
21
+ ``plan.metadata.swarm.depends_on[]`` edges (restricted to the child
22
+ set; a dependency cycle degrades gracefully to a single trailing wave);
23
+ * builds the canonical AGENTS.md section-1152 body (Last updated /
24
+ Last pass type / Child count / Child-count history / Open children /
25
+ Closed children / Wave order / Open questions / Reading order); and
26
+ * edits the linked SCM umbrella's current-shape comment **in place** via
27
+ the ``scripts/scm.py`` shim so the comment permalink is preserved and
28
+ the amendment trail is never touched. When no current-shape comment
29
+ exists yet, one is created at pass-1.
30
+
31
+ Design contract:
32
+
33
+ * **Edit in place, preserve the permalink.** The verb finds the single
34
+ comment whose body carries the ``## Current shape (as of pass-N)``
35
+ header and PATCHes that comment; it never deletes amendment comments
36
+ and never posts a replacement.
37
+ * **Forge-agnostic.** Every forge call routes through ``scripts/scm.py``
38
+ (#1145) via :func:`scm.call`; ``task verify:scm-boundary`` enforces no
39
+ direct ``gh`` invocation remains. The default :class:`ScmUmbrellaClient`
40
+ is the only thing that talks to the forge, and it is injectable so the
41
+ test suite never makes a live ``gh`` call.
42
+ * **Idempotent.** A second run with unchanged epic state is a no-op: the
43
+ pass number is only bumped (and ``Last updated`` only re-stamped) when
44
+ the rendered substantive body differs from the comment already posted.
45
+
46
+ Exit codes (three-state, mirrors ``scripts/vbrief_reconcile_labels.py``):
47
+
48
+ 0 -- ran successfully (zero or more umbrellas reconciled).
49
+ 1 -- one or more per-umbrella forge calls failed.
50
+ 2 -- usage / config error (no ``vbrief/`` directory under
51
+ ``--project-root``).
52
+ """
53
+
54
+ from __future__ import annotations
55
+
56
+ import argparse
57
+ import json
58
+ import re
59
+ import sys
60
+ from collections.abc import Sequence
61
+ from dataclasses import dataclass, field
62
+ from datetime import UTC, datetime
63
+ from pathlib import Path
64
+ from typing import Protocol
65
+
66
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
67
+
68
+ import scm # noqa: E402
69
+ from _stdio_utf8 import reconfigure_stdio # noqa: E402
70
+ from triage_reconcile import _extract_issue_ref # noqa: E402
71
+
72
+ reconfigure_stdio()
73
+
74
+ #: Lifecycle folders, partitioned into open (in-flight) vs closed
75
+ #: (terminal). A child's *closure reason* is just its terminal folder.
76
+ OPEN_FOLDERS = ("proposed", "pending", "active")
77
+ CLOSED_FOLDERS = ("completed", "cancelled")
78
+ LIFECYCLE_FOLDERS = OPEN_FOLDERS + CLOSED_FOLDERS
79
+
80
+ #: The reference type ``scripts/scope_decompose.py`` writes onto a parent
81
+ #: epic for each decomposed child story (uri is a vbrief-relative path).
82
+ CHILD_REF_TYPE = "x-vbrief/plan"
83
+
84
+ #: scm.call source identity (#1145). v1 supports only github-issue.
85
+ SCM_SOURCE = "github-issue"
86
+
87
+ #: The canonical current-shape comment header. The pass number is the
88
+ #: single source of truth for "which design pass produced this shape".
89
+ _HEADER_RE = re.compile(r"^## Current shape \(as of pass-(\d+)\)", re.MULTILINE)
90
+ _HISTORY_RE = re.compile(r"^Child-count history:\s*(.*)$", re.MULTILINE)
91
+ _LAST_UPDATED_RE = re.compile(r"^Last updated:\s*(.*)$", re.MULTILINE)
92
+ _LAST_PASS_TYPE_RE = re.compile(r"^Last pass type:\s*(.*)$", re.MULTILINE)
93
+
94
+ _VALID_PASS_TYPES = ("additive", "subtractive", "refactor", "verify")
95
+
96
+ _READING_ORDER = (
97
+ "1. Read the umbrella issue body.\n"
98
+ "2. Read this current-shape comment.\n"
99
+ "3. Read the amendment comments in chronological order for the full audit trail."
100
+ )
101
+
102
+
103
+ class UmbrellaScmError(RuntimeError):
104
+ """Raised when a forge comment read / mutation fails."""
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # Child model + lifecycle index
109
+ # ---------------------------------------------------------------------------
110
+
111
+
112
+ @dataclass
113
+ class Child:
114
+ """A single resolved child of an epic, with its current lifecycle state."""
115
+
116
+ story_id: str
117
+ title: str
118
+ kind: str
119
+ folder: str
120
+ depends_on: list[str] = field(default_factory=list)
121
+
122
+ @property
123
+ def is_open(self) -> bool:
124
+ return self.folder in OPEN_FOLDERS
125
+
126
+
127
+ def _read_json(path: Path) -> dict | None:
128
+ try:
129
+ data = json.loads(path.read_text(encoding="utf-8"))
130
+ except (json.JSONDecodeError, OSError, UnicodeDecodeError):
131
+ return None
132
+ return data if isinstance(data, dict) else None
133
+
134
+
135
+ def _child_from_data(data: dict, folder: str, fallback_id: str) -> Child:
136
+ plan = data.get("plan") if isinstance(data.get("plan"), dict) else {}
137
+ metadata = plan.get("metadata") if isinstance(plan.get("metadata"), dict) else {}
138
+ swarm = metadata.get("swarm") if isinstance(metadata.get("swarm"), dict) else {}
139
+ raw_deps = swarm.get("depends_on")
140
+ depends_on = [str(d) for d in raw_deps] if isinstance(raw_deps, list) else []
141
+ return Child(
142
+ story_id=str(plan.get("id") or fallback_id),
143
+ title=str(plan.get("title") or plan.get("id") or fallback_id),
144
+ kind=str(metadata.get("kind") or "story"),
145
+ folder=folder,
146
+ depends_on=depends_on,
147
+ )
148
+
149
+
150
+ def build_child_index(vbrief_dir: Path) -> dict[str, Child]:
151
+ """Index every lifecycle vBRIEF by filename -> :class:`Child`.
152
+
153
+ Keying by filename (not story id) lets :func:`compute_children`
154
+ resolve an epic's ``x-vbrief/plan`` references -- whose URIs carry the
155
+ file *path* the decomposition wrote -- even after a child has moved
156
+ lifecycle folder (the URI's folder segment goes stale, but the
157
+ basename does not).
158
+ """
159
+ index: dict[str, Child] = {}
160
+ for folder in LIFECYCLE_FOLDERS:
161
+ folder_path = vbrief_dir / folder
162
+ if not folder_path.is_dir():
163
+ continue
164
+ for path in sorted(folder_path.glob("*.vbrief.json")):
165
+ data = _read_json(path)
166
+ if data is None:
167
+ continue
168
+ fallback_id = path.name[: -len(".vbrief.json")]
169
+ index[path.name] = _child_from_data(data, folder, fallback_id)
170
+ return index
171
+
172
+
173
+ def compute_children(epic_data: dict, index: dict[str, Child]) -> list[Child]:
174
+ """Resolve an epic's children from its ``x-vbrief/plan`` references."""
175
+ plan = epic_data.get("plan") if isinstance(epic_data.get("plan"), dict) else {}
176
+ refs = plan.get("references")
177
+ children: list[Child] = []
178
+ seen: set[str] = set()
179
+ if not isinstance(refs, list):
180
+ return children
181
+ for ref in refs:
182
+ if not isinstance(ref, dict) or ref.get("type") != CHILD_REF_TYPE:
183
+ continue
184
+ name = Path(str(ref.get("uri") or "")).name
185
+ child = index.get(name)
186
+ if child is None or child.story_id in seen:
187
+ continue
188
+ seen.add(child.story_id)
189
+ children.append(child)
190
+ return children
191
+
192
+
193
+ def compute_waves(children: Sequence[Child]) -> list[list[str]]:
194
+ """Layer the children into dependency waves (deterministic ordering).
195
+
196
+ A child enters a wave once every one of its in-set ``depends_on``
197
+ edges has been placed in an earlier wave. Dependencies pointing
198
+ outside the child set are ignored (they cannot gate a wave). A
199
+ dependency cycle is non-fatal: the unresolvable remainder is emitted
200
+ as a single trailing wave so the verb never hangs or raises.
201
+ """
202
+ ids = {c.story_id for c in children}
203
+ deps = {c.story_id: [d for d in c.depends_on if d in ids] for c in children}
204
+ resolved: set[str] = set()
205
+ remaining = set(ids)
206
+ waves: list[list[str]] = []
207
+ while remaining:
208
+ layer = sorted(r for r in remaining if all(d in resolved for d in deps[r]))
209
+ if not layer:
210
+ waves.append(sorted(remaining))
211
+ break
212
+ waves.append(layer)
213
+ resolved.update(layer)
214
+ remaining.difference_update(layer)
215
+ return waves
216
+
217
+
218
+ # ---------------------------------------------------------------------------
219
+ # Body render + parse
220
+ # ---------------------------------------------------------------------------
221
+
222
+
223
+ def _bullet_block(lines: Sequence[str]) -> str:
224
+ return "\n".join(lines) if lines else "- none"
225
+
226
+
227
+ def render_body(
228
+ *,
229
+ pass_n: int,
230
+ last_pass_type: str,
231
+ last_updated: str,
232
+ open_children: Sequence[Child],
233
+ closed_children: Sequence[Child],
234
+ waves: Sequence[Sequence[str]],
235
+ history: Sequence[tuple[int, int]],
236
+ ) -> str:
237
+ """Render the canonical AGENTS.md section-1152 current-shape body."""
238
+ total = len(open_children) + len(closed_children)
239
+ history_str = ", ".join(f"pass-{n}: {count}" for n, count in history)
240
+ open_lines = [f"- {c.story_id}: {c.title} ({c.kind})" for c in open_children]
241
+ closed_lines = [f"- {c.story_id}: {c.title} ({c.folder})" for c in closed_children]
242
+ wave_lines = [f"- Wave {i}: {', '.join(layer)}" for i, layer in enumerate(waves, 1)]
243
+ return (
244
+ f"## Current shape (as of pass-{pass_n})\n"
245
+ "\n"
246
+ f"Last updated: {last_updated}\n"
247
+ f"Last pass type: {last_pass_type}\n"
248
+ f"Child count: {total} ({len(open_children)}/{len(closed_children)})\n"
249
+ f"Child-count history: {history_str}\n"
250
+ "\n"
251
+ "### Open children\n"
252
+ "\n"
253
+ f"{_bullet_block(open_lines)}\n"
254
+ "\n"
255
+ "### Closed children\n"
256
+ "\n"
257
+ f"{_bullet_block(closed_lines)}\n"
258
+ "\n"
259
+ "### Wave order\n"
260
+ "\n"
261
+ f"{_bullet_block(wave_lines)}\n"
262
+ "\n"
263
+ "### Open questions\n"
264
+ "\n"
265
+ "- none\n"
266
+ "\n"
267
+ "### Reading order for fresh contributors\n"
268
+ "\n"
269
+ f"{_READING_ORDER}"
270
+ )
271
+
272
+
273
+ @dataclass
274
+ class ParsedShape:
275
+ """The fields parsed back out of an existing current-shape comment."""
276
+
277
+ pass_n: int | None = None
278
+ history: list[tuple[int, int]] = field(default_factory=list)
279
+ last_updated: str | None = None
280
+ last_pass_type: str | None = None
281
+
282
+
283
+ def _parse_history(raw: str) -> list[tuple[int, int]]:
284
+ history: list[tuple[int, int]] = []
285
+ for token in raw.split(","):
286
+ match = re.match(r"\s*pass-(\d+):\s*(\d+)\s*$", token)
287
+ if match:
288
+ history.append((int(match.group(1)), int(match.group(2))))
289
+ return history
290
+
291
+
292
+ def parse_current_shape(body: str) -> ParsedShape:
293
+ """Parse pass number, history, timestamp, and pass type from *body*.
294
+
295
+ Tolerant of a hand-authored / pre-convention comment: any field that
296
+ does not match returns its empty default, so a first reconcile of a
297
+ legacy comment is treated as a substantive change (pass bump) rather
298
+ than crashing.
299
+ """
300
+ header = _HEADER_RE.search(body)
301
+ if header is None:
302
+ return ParsedShape()
303
+ history_match = _HISTORY_RE.search(body)
304
+ updated_match = _LAST_UPDATED_RE.search(body)
305
+ pass_type_match = _LAST_PASS_TYPE_RE.search(body)
306
+ return ParsedShape(
307
+ pass_n=int(header.group(1)),
308
+ history=_parse_history(history_match.group(1)) if history_match else [],
309
+ last_updated=updated_match.group(1).strip() if updated_match else None,
310
+ last_pass_type=pass_type_match.group(1).strip() if pass_type_match else None,
311
+ )
312
+
313
+
314
+ def _classify_pass_type(prev_total: int | None, total: int) -> str:
315
+ if prev_total is None:
316
+ return "refactor"
317
+ if total > prev_total:
318
+ return "additive"
319
+ if total < prev_total:
320
+ return "subtractive"
321
+ return "refactor"
322
+
323
+
324
+ def _has_current_shape(body: str) -> bool:
325
+ return _HEADER_RE.search(body) is not None
326
+
327
+
328
+ # ---------------------------------------------------------------------------
329
+ # Forge client (injectable)
330
+ # ---------------------------------------------------------------------------
331
+
332
+
333
+ class UmbrellaClient(Protocol):
334
+ """The seam the reconciler talks to. Tests inject an in-memory fake."""
335
+
336
+ def fetch_comments(self, repo: str, issue_number: int) -> list[dict]:
337
+ ...
338
+
339
+ def edit_comment(self, repo: str, comment_id: int, body: str) -> None:
340
+ ...
341
+
342
+ def create_comment(self, repo: str, issue_number: int, body: str) -> int | None:
343
+ ...
344
+
345
+
346
+ class ScmUmbrellaClient:
347
+ """Forge-backed comment client routing every call through ``scripts/scm.py``.
348
+
349
+ The comment list (``api repos/.../issues/<N>/comments``) and the
350
+ in-place edit / create (``api -X PATCH|POST ... --input -``) all go
351
+ through :func:`scm.call` with ``source="github-issue"`` so the #1145
352
+ scm boundary is honoured. Markdown bodies are sent as a JSON payload
353
+ over stdin (``--input -``) so backticks in the rendered body are never
354
+ interpreted by a shell (preamble section 5.5).
355
+ """
356
+
357
+ def fetch_comments(self, repo: str, issue_number: int) -> list[dict]:
358
+ proc = scm.call(
359
+ SCM_SOURCE,
360
+ "api",
361
+ [f"repos/{repo}/issues/{issue_number}/comments?per_page=100"],
362
+ )
363
+ if proc.returncode != 0:
364
+ raise UmbrellaScmError(
365
+ f"list comments #{issue_number} ({repo}) failed: "
366
+ f"{(proc.stderr or '').strip()}"
367
+ )
368
+ try:
369
+ data = json.loads(proc.stdout or "[]")
370
+ except json.JSONDecodeError as exc:
371
+ raise UmbrellaScmError(
372
+ f"list comments #{issue_number} ({repo}) returned non-JSON: {exc}"
373
+ ) from exc
374
+ if not isinstance(data, list):
375
+ return []
376
+ comments: list[dict] = []
377
+ for entry in data:
378
+ if (
379
+ isinstance(entry, dict)
380
+ and isinstance(entry.get("id"), int)
381
+ and isinstance(entry.get("body"), str)
382
+ ):
383
+ comments.append({"id": entry["id"], "body": entry["body"]})
384
+ return comments
385
+
386
+ def edit_comment(self, repo: str, comment_id: int, body: str) -> None:
387
+ proc = scm.call(
388
+ SCM_SOURCE,
389
+ "api",
390
+ ["-X", "PATCH", f"repos/{repo}/issues/comments/{comment_id}", "--input", "-"],
391
+ input=json.dumps({"body": body}),
392
+ )
393
+ if proc.returncode != 0:
394
+ raise UmbrellaScmError(
395
+ f"edit comment {comment_id} ({repo}) failed: "
396
+ f"{(proc.stderr or '').strip()}"
397
+ )
398
+
399
+ def create_comment(self, repo: str, issue_number: int, body: str) -> int | None:
400
+ proc = scm.call(
401
+ SCM_SOURCE,
402
+ "api",
403
+ ["-X", "POST", f"repos/{repo}/issues/{issue_number}/comments", "--input", "-"],
404
+ input=json.dumps({"body": body}),
405
+ )
406
+ if proc.returncode != 0:
407
+ raise UmbrellaScmError(
408
+ f"create comment #{issue_number} ({repo}) failed: "
409
+ f"{(proc.stderr or '').strip()}"
410
+ )
411
+ try:
412
+ data = json.loads(proc.stdout or "{}")
413
+ except json.JSONDecodeError:
414
+ return None
415
+ if isinstance(data, dict) and isinstance(data.get("id"), int):
416
+ return data["id"]
417
+ return None
418
+
419
+
420
+ # ---------------------------------------------------------------------------
421
+ # Outcome types
422
+ # ---------------------------------------------------------------------------
423
+
424
+
425
+ @dataclass
426
+ class UmbrellaChange:
427
+ """A single epic's computed (and, unless dry-run, applied) shape update."""
428
+
429
+ story_id: str
430
+ repo: str
431
+ issue_number: int
432
+ action: str # "created" | "edited" | "unchanged"
433
+ pass_n: int
434
+ body: str
435
+
436
+ def to_json(self) -> dict[str, object]:
437
+ return {
438
+ "story_id": self.story_id,
439
+ "repo": self.repo,
440
+ "issue_number": self.issue_number,
441
+ "action": self.action,
442
+ "pass_n": self.pass_n,
443
+ }
444
+
445
+
446
+ @dataclass
447
+ class ReconcileUmbrellasOutcome:
448
+ """Aggregate result of a single umbrella-reconcile run."""
449
+
450
+ changed: list[UmbrellaChange] = field(default_factory=list)
451
+ unchanged: list[UmbrellaChange] = field(default_factory=list)
452
+ skipped_no_ref: list[str] = field(default_factory=list)
453
+ errors: list[tuple[str, str]] = field(default_factory=list)
454
+ dry_run: bool = False
455
+
456
+ def to_json(self) -> dict[str, object]:
457
+ return {
458
+ "changed": [c.to_json() for c in self.changed],
459
+ "unchanged": [c.to_json() for c in self.unchanged],
460
+ "skipped_no_ref": list(self.skipped_no_ref),
461
+ "errors": [{"story_id": sid, "message": msg} for sid, msg in self.errors],
462
+ "dry_run": self.dry_run,
463
+ }
464
+
465
+
466
+ # ---------------------------------------------------------------------------
467
+ # Core reconcile logic
468
+ # ---------------------------------------------------------------------------
469
+
470
+
471
+ def _now_iso() -> str:
472
+ return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
473
+
474
+
475
+ def _plan_of(data: dict) -> dict:
476
+ plan = data.get("plan")
477
+ return plan if isinstance(plan, dict) else {}
478
+
479
+
480
+ def _is_epic(plan: dict) -> bool:
481
+ metadata = plan.get("metadata") if isinstance(plan.get("metadata"), dict) else {}
482
+ return metadata.get("kind") == "epic"
483
+
484
+
485
+ def _plan_shape(
486
+ epic_data: dict, index: dict[str, Child]
487
+ ) -> tuple[list[Child], list[Child], list[list[str]]]:
488
+ children = compute_children(epic_data, index)
489
+ open_children = sorted(
490
+ (c for c in children if c.is_open), key=lambda c: c.story_id
491
+ )
492
+ closed_children = sorted(
493
+ (c for c in children if not c.is_open), key=lambda c: c.story_id
494
+ )
495
+ waves = compute_waves(children)
496
+ return open_children, closed_children, waves
497
+
498
+
499
+ def _reconcile_one_epic(
500
+ epic_data: dict,
501
+ index: dict[str, Child],
502
+ *,
503
+ story_id: str,
504
+ repo: str,
505
+ number: int,
506
+ client: UmbrellaClient,
507
+ dry_run: bool,
508
+ now: str,
509
+ ) -> UmbrellaChange:
510
+ """Compute (and, unless dry-run, apply) one epic's current-shape update."""
511
+ open_children, closed_children, waves = _plan_shape(epic_data, index)
512
+ total = len(open_children) + len(closed_children)
513
+
514
+ comments = client.fetch_comments(repo, number)
515
+ existing = next(
516
+ (c for c in comments if _has_current_shape(str(c.get("body", "")))), None
517
+ )
518
+
519
+ if existing is None:
520
+ body = render_body(
521
+ pass_n=1,
522
+ last_pass_type="additive",
523
+ last_updated=now,
524
+ open_children=open_children,
525
+ closed_children=closed_children,
526
+ waves=waves,
527
+ history=[(1, total)],
528
+ )
529
+ if not dry_run:
530
+ client.create_comment(repo, number, body)
531
+ return UmbrellaChange(story_id, repo, number, "created", 1, body)
532
+
533
+ parsed = parse_current_shape(str(existing.get("body", "")))
534
+ prev_pass = parsed.pass_n or 1
535
+ prev_total = parsed.history[-1][1] if parsed.history else None
536
+
537
+ # Re-render the body with the PREVIOUS pass/history/timestamp/type. If
538
+ # it reproduces the posted comment byte-for-byte, nothing substantive
539
+ # changed -> idempotent no-op (no edit, no Last-updated re-stamp).
540
+ candidate = render_body(
541
+ pass_n=prev_pass,
542
+ last_pass_type=parsed.last_pass_type or "refactor",
543
+ last_updated=parsed.last_updated or now,
544
+ open_children=open_children,
545
+ closed_children=closed_children,
546
+ waves=waves,
547
+ history=parsed.history or [(prev_pass, total)],
548
+ )
549
+ if candidate == str(existing.get("body", "")):
550
+ return UmbrellaChange(story_id, repo, number, "unchanged", prev_pass, candidate)
551
+
552
+ pass_n = prev_pass + 1
553
+ body = render_body(
554
+ pass_n=pass_n,
555
+ last_pass_type=_classify_pass_type(prev_total, total),
556
+ last_updated=now,
557
+ open_children=open_children,
558
+ closed_children=closed_children,
559
+ waves=waves,
560
+ history=[*parsed.history, (pass_n, total)],
561
+ )
562
+ if not dry_run:
563
+ client.edit_comment(repo, int(existing["id"]), body)
564
+ return UmbrellaChange(story_id, repo, number, "edited", pass_n, body)
565
+
566
+
567
+ def reconcile_umbrellas(
568
+ project_root: Path,
569
+ *,
570
+ repo: str | None = None,
571
+ dry_run: bool = False,
572
+ client: UmbrellaClient | None = None,
573
+ now: str | None = None,
574
+ ) -> tuple[int, ReconcileUmbrellasOutcome]:
575
+ """Reconcile every epic umbrella's current-shape comment to vBRIEF state.
576
+
577
+ Scans all lifecycle folders for ``kind == "epic"`` vBRIEFs, resolves
578
+ each one's linked SCM issue, and creates / edits-in-place its
579
+ current-shape comment. Returns ``(exit_code, outcome)``.
580
+ """
581
+ vbrief_dir = project_root / "vbrief"
582
+ if not vbrief_dir.is_dir():
583
+ return 2, ReconcileUmbrellasOutcome(dry_run=dry_run)
584
+
585
+ if client is None:
586
+ client = ScmUmbrellaClient()
587
+ if now is None:
588
+ now = _now_iso()
589
+
590
+ index = build_child_index(vbrief_dir)
591
+ outcome = ReconcileUmbrellasOutcome(dry_run=dry_run)
592
+ seen_issues: set[tuple[str, int]] = set()
593
+
594
+ for folder in LIFECYCLE_FOLDERS:
595
+ folder_path = vbrief_dir / folder
596
+ if not folder_path.is_dir():
597
+ continue
598
+ for path in sorted(folder_path.glob("*.vbrief.json")):
599
+ data = _read_json(path)
600
+ if data is None:
601
+ continue
602
+ plan = _plan_of(data)
603
+ if not _is_epic(plan):
604
+ continue
605
+ story_id = str(plan.get("id") or path.name[: -len(".vbrief.json")])
606
+
607
+ ref_repo, number = _extract_issue_ref(data)
608
+ effective_repo = ref_repo or repo
609
+ if number is None or effective_repo is None:
610
+ outcome.skipped_no_ref.append(story_id)
611
+ continue
612
+ key = (effective_repo, number)
613
+ if key in seen_issues:
614
+ continue
615
+ seen_issues.add(key)
616
+
617
+ try:
618
+ change = _reconcile_one_epic(
619
+ data,
620
+ index,
621
+ story_id=story_id,
622
+ repo=effective_repo,
623
+ number=number,
624
+ client=client,
625
+ dry_run=dry_run,
626
+ now=now,
627
+ )
628
+ except UmbrellaScmError as exc:
629
+ outcome.errors.append((story_id, str(exc)))
630
+ continue
631
+ if change.action == "unchanged":
632
+ outcome.unchanged.append(change)
633
+ else:
634
+ outcome.changed.append(change)
635
+
636
+ exit_code = 1 if outcome.errors else 0
637
+ return exit_code, outcome
638
+
639
+
640
+ # ---------------------------------------------------------------------------
641
+ # Rendering + CLI
642
+ # ---------------------------------------------------------------------------
643
+
644
+
645
+ def _render_report(outcome: ReconcileUmbrellasOutcome) -> str:
646
+ lines: list[str] = ["vBRIEF reconcile umbrellas", ""]
647
+ suffix = " (dry-run)" if outcome.dry_run else ""
648
+
649
+ lines.append(f"Changed{suffix}:")
650
+ if outcome.changed:
651
+ lines.extend(
652
+ f"- #{c.issue_number} ({c.repo}) [{c.story_id}]: {c.action} -> pass-{c.pass_n}"
653
+ for c in outcome.changed
654
+ )
655
+ else:
656
+ lines.append("- none")
657
+ lines.append("")
658
+
659
+ lines.append("Unchanged:")
660
+ if outcome.unchanged:
661
+ lines.extend(
662
+ f"- #{c.issue_number} ({c.repo}) [{c.story_id}]: pass-{c.pass_n}"
663
+ for c in outcome.unchanged
664
+ )
665
+ else:
666
+ lines.append("- none")
667
+
668
+ if outcome.skipped_no_ref:
669
+ lines.append("")
670
+ lines.append("Skipped (no github-issue reference / repo):")
671
+ lines.extend(f"- {story_id}" for story_id in outcome.skipped_no_ref)
672
+
673
+ if outcome.errors:
674
+ lines.append("")
675
+ lines.append("Errors:")
676
+ lines.extend(f"- {story_id}: {message}" for story_id, message in outcome.errors)
677
+
678
+ return "\n".join(lines)
679
+
680
+
681
+ def _parse_args(argv: list[str]) -> argparse.Namespace:
682
+ parser = argparse.ArgumentParser(
683
+ description=(
684
+ "Reconcile each kind=epic umbrella's current-shape comment to "
685
+ "canonical vBRIEF state per AGENTS.md #1152: edit the comment in "
686
+ "place (preserve permalink), never delete amendment comments. "
687
+ "Routes through scripts/scm.py (#1145). Idempotent."
688
+ )
689
+ )
690
+ parser.add_argument(
691
+ "--project-root",
692
+ default=".",
693
+ help="Project root containing vbrief/ (default: current directory).",
694
+ )
695
+ parser.add_argument(
696
+ "--repo",
697
+ default=None,
698
+ help=(
699
+ "Fallback repo slug 'owner/name' used ONLY when an epic's "
700
+ "github-issue reference URI lacks an owner/repo segment."
701
+ ),
702
+ )
703
+ parser.add_argument(
704
+ "--dry-run",
705
+ action="store_true",
706
+ help="Report which umbrellas WOULD change without mutating any comment.",
707
+ )
708
+ parser.add_argument(
709
+ "--json",
710
+ action="store_true",
711
+ help="Emit a machine-readable JSON summary instead of the text report.",
712
+ )
713
+ return parser.parse_args(argv)
714
+
715
+
716
+ def main(argv: list[str] | None = None) -> int:
717
+ args = _parse_args(sys.argv[1:] if argv is None else argv)
718
+ project_root = Path(args.project_root).resolve()
719
+ exit_code, outcome = reconcile_umbrellas(
720
+ project_root,
721
+ repo=args.repo,
722
+ dry_run=args.dry_run,
723
+ )
724
+ if exit_code == 2:
725
+ if args.json:
726
+ print(json.dumps({"error": "no vbrief/ directory found"}))
727
+ else:
728
+ print(
729
+ f"Error: no vbrief/ directory found under {project_root}",
730
+ file=sys.stderr,
731
+ )
732
+ return 2
733
+ if args.json:
734
+ print(json.dumps(outcome.to_json(), indent=2))
735
+ else:
736
+ print(_render_report(outcome))
737
+ return exit_code
738
+
739
+
740
+ if __name__ == "__main__":
741
+ raise SystemExit(main())