@deftai/directive-content 0.59.0 → 0.60.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 (184) hide show
  1. package/.githooks/pre-push +10 -9
  2. package/Taskfile.yml +48 -58
  3. package/UPGRADING.md +1 -1
  4. package/docs/assets/directive-lifecycle-diagram.png +0 -0
  5. package/docs/directive-lifecycle.md +73 -0
  6. package/docs/getting-started.md +5 -1
  7. package/package.json +3 -3
  8. package/packs/skills/skills-pack-0.1.json +22 -22
  9. package/scm/github.md +20 -2
  10. package/tasks/change.yml +16 -31
  11. package/tasks/ci.yml +8 -0
  12. package/tasks/commit.yml +12 -19
  13. package/tasks/core.yml +10 -0
  14. package/tasks/engine.yml +42 -0
  15. package/tasks/framework.yml +3 -0
  16. package/tasks/install.yml +20 -19
  17. package/tasks/migrate.yml +26 -15
  18. package/tasks/project.yml +16 -0
  19. package/tasks/toolchain.yml +15 -5
  20. package/tasks/vbrief.yml +4 -3
  21. package/tasks/verify.yml +12 -14
  22. package/scripts/_agents_md.py +0 -494
  23. package/scripts/_cache_fetch.py +0 -635
  24. package/scripts/_cache_quota.py +0 -529
  25. package/scripts/_cache_refresh.py +0 -163
  26. package/scripts/_cache_validate.py +0 -209
  27. package/scripts/_content_root.py +0 -42
  28. package/scripts/_doctor_state.py +0 -277
  29. package/scripts/_event_detect.py +0 -305
  30. package/scripts/_events.py +0 -514
  31. package/scripts/_lifecycle_hygiene.py +0 -568
  32. package/scripts/_pathspec.py +0 -91
  33. package/scripts/_policy_show_cli.py +0 -266
  34. package/scripts/_precutover.py +0 -92
  35. package/scripts/_project_context.py +0 -224
  36. package/scripts/_project_definition_io.py +0 -164
  37. package/scripts/_relocate_snapshot.py +0 -209
  38. package/scripts/_relocate_states.py +0 -343
  39. package/scripts/_resolve_preflight_path.py +0 -152
  40. package/scripts/_safe_subprocess.py +0 -167
  41. package/scripts/_session_start_hook.py +0 -205
  42. package/scripts/_sor_gate_diff.py +0 -365
  43. package/scripts/_stdio_utf8.py +0 -59
  44. package/scripts/_triage_bootstrap_gitignore.py +0 -904
  45. package/scripts/_triage_classify_cli.py +0 -122
  46. package/scripts/_triage_queue_cli.py +0 -625
  47. package/scripts/_triage_scope_cli.py +0 -343
  48. package/scripts/_triage_scope_drift_cli.py +0 -121
  49. package/scripts/_triage_scope_ignores.py +0 -286
  50. package/scripts/_triage_scope_milestone.py +0 -432
  51. package/scripts/_triage_scope_mutations.py +0 -337
  52. package/scripts/_triage_scope_renderers.py +0 -207
  53. package/scripts/_triage_smoketest_stages.py +0 -674
  54. package/scripts/_triage_subscribe_cli.py +0 -140
  55. package/scripts/_triage_welcome_cli.py +0 -421
  56. package/scripts/_vbrief_build.py +0 -239
  57. package/scripts/_vbrief_fidelity.py +0 -479
  58. package/scripts/_vbrief_legacy.py +0 -589
  59. package/scripts/_vbrief_reconciliation.py +0 -883
  60. package/scripts/_vbrief_routing.py +0 -277
  61. package/scripts/_vbrief_safety.py +0 -778
  62. package/scripts/_vbrief_sources.py +0 -312
  63. package/scripts/_vbrief_speckit.py +0 -262
  64. package/scripts/_vbrief_story_quality.py +0 -353
  65. package/scripts/_vbrief_validation.py +0 -299
  66. package/scripts/build_dist.py +0 -412
  67. package/scripts/cache.py +0 -1078
  68. package/scripts/cache_scanner.py +0 -745
  69. package/scripts/candidates_log.py +0 -432
  70. package/scripts/capacity_backfill.py +0 -680
  71. package/scripts/capacity_show.py +0 -653
  72. package/scripts/ci_local.py +0 -689
  73. package/scripts/code_structure_validate.py +0 -765
  74. package/scripts/codebase_default_extractor.py +0 -495
  75. package/scripts/codebase_map.py +0 -304
  76. package/scripts/codebase_map_fresh.py +0 -104
  77. package/scripts/codebase_projection_registry.py +0 -94
  78. package/scripts/codebase_provider.py +0 -582
  79. package/scripts/doctor.py +0 -2552
  80. package/scripts/framework_commands.py +0 -505
  81. package/scripts/gh_rest.py +0 -882
  82. package/scripts/github_auth_modes.py +0 -437
  83. package/scripts/github_body.py +0 -292
  84. package/scripts/ip_risk.py +0 -531
  85. package/scripts/issue_emit.py +0 -670
  86. package/scripts/issue_ingest.py +0 -1064
  87. package/scripts/migrate_preflight.py +0 -418
  88. package/scripts/migrate_vbrief.py +0 -2677
  89. package/scripts/monitor_pr.py +0 -401
  90. package/scripts/pack_migrate_lessons.py +0 -336
  91. package/scripts/pack_migrate_patterns.py +0 -254
  92. package/scripts/pack_migrate_rules.py +0 -350
  93. package/scripts/pack_migrate_skills.py +0 -423
  94. package/scripts/pack_migrate_strategies.py +0 -311
  95. package/scripts/pack_migrate_swarm_spec.py +0 -250
  96. package/scripts/pack_render.py +0 -434
  97. package/scripts/packs_slice.py +0 -712
  98. package/scripts/platform_capabilities.py +0 -336
  99. package/scripts/policy.py +0 -2826
  100. package/scripts/policy_set.py +0 -324
  101. package/scripts/pr_check_closing_keywords.py +0 -524
  102. package/scripts/pr_check_protected_issues.py +0 -267
  103. package/scripts/pr_merge_readiness.py +0 -1004
  104. package/scripts/pr_wait_mergeable.py +0 -669
  105. package/scripts/prd_render.py +0 -159
  106. package/scripts/preflight_architecture_sor.py +0 -974
  107. package/scripts/preflight_branch.py +0 -289
  108. package/scripts/preflight_cache.py +0 -974
  109. package/scripts/preflight_gh.py +0 -721
  110. package/scripts/preflight_implementation.py +0 -272
  111. package/scripts/preflight_story_start.py +0 -838
  112. package/scripts/preflight_wip_cap.py +0 -149
  113. package/scripts/probe_session.py +0 -545
  114. package/scripts/project_render.py +0 -293
  115. package/scripts/quarantine_ext.py +0 -237
  116. package/scripts/reconcile_issues.py +0 -1442
  117. package/scripts/refresh-path.ps1 +0 -107
  118. package/scripts/release.py +0 -2030
  119. package/scripts/release_e2e.py +0 -1011
  120. package/scripts/release_publish.py +0 -486
  121. package/scripts/release_rollback.py +0 -980
  122. package/scripts/relocate.py +0 -1034
  123. package/scripts/resolve_changelog_unreleased.py +0 -667
  124. package/scripts/resolve_version.py +0 -490
  125. package/scripts/resume_conditions.py +0 -706
  126. package/scripts/ritual_sentinel.py +0 -609
  127. package/scripts/roadmap_render.py +0 -635
  128. package/scripts/rule_ownership_lint.py +0 -325
  129. package/scripts/scm.py +0 -591
  130. package/scripts/scope_audit_log.py +0 -387
  131. package/scripts/scope_decompose.py +0 -654
  132. package/scripts/scope_demote.py +0 -509
  133. package/scripts/scope_lifecycle.py +0 -1126
  134. package/scripts/scope_undo.py +0 -772
  135. package/scripts/session_start.py +0 -406
  136. package/scripts/setup_ghx.py +0 -339
  137. package/scripts/setup_windows.ps1 +0 -220
  138. package/scripts/slice_audit.py +0 -585
  139. package/scripts/slice_record.py +0 -530
  140. package/scripts/slice_record_existing.py +0 -692
  141. package/scripts/slug_normalize.py +0 -178
  142. package/scripts/spec_render.py +0 -477
  143. package/scripts/spec_validate.py +0 -238
  144. package/scripts/subagent_monitor.py +0 -658
  145. package/scripts/swarm_complete_cohort.py +0 -644
  146. package/scripts/swarm_launch.py +0 -1206
  147. package/scripts/swarm_readiness.py +0 -554
  148. package/scripts/swarm_verify_review_clean.py +0 -438
  149. package/scripts/swarm_worktrees.py +0 -497
  150. package/scripts/toolchain-check.py +0 -52
  151. package/scripts/triage_actions.py +0 -871
  152. package/scripts/triage_bootstrap.py +0 -1153
  153. package/scripts/triage_bulk.py +0 -630
  154. package/scripts/triage_classify.py +0 -932
  155. package/scripts/triage_help.py +0 -1685
  156. package/scripts/triage_queue.py +0 -1944
  157. package/scripts/triage_reconcile.py +0 -581
  158. package/scripts/triage_refresh.py +0 -643
  159. package/scripts/triage_scope.py +0 -999
  160. package/scripts/triage_scope_drift.py +0 -575
  161. package/scripts/triage_smoketest.py +0 -396
  162. package/scripts/triage_subscribe.py +0 -399
  163. package/scripts/triage_summary.py +0 -1011
  164. package/scripts/triage_welcome.py +0 -1178
  165. package/scripts/ts_check_lane.py +0 -86
  166. package/scripts/validate-links.py +0 -64
  167. package/scripts/validate_strategy_output.py +0 -212
  168. package/scripts/vbrief_activate.py +0 -228
  169. package/scripts/vbrief_migrate_conformance.py +0 -368
  170. package/scripts/vbrief_reconcile_graph.py +0 -306
  171. package/scripts/vbrief_reconcile_labels.py +0 -460
  172. package/scripts/vbrief_reconcile_umbrellas.py +0 -741
  173. package/scripts/vbrief_validate.py +0 -1144
  174. package/scripts/verify-stubs.py +0 -61
  175. package/scripts/verify_capacity.py +0 -160
  176. package/scripts/verify_encoding.py +0 -699
  177. package/scripts/verify_hooks_installed.py +0 -206
  178. package/scripts/verify_investigation.py +0 -360
  179. package/scripts/verify_judgment_gates.py +0 -827
  180. package/scripts/verify_no_task_runtime.py +0 -171
  181. package/scripts/verify_scm_boundary.py +0 -509
  182. package/scripts/verify_session_ritual.py +0 -389
  183. package/scripts/verify_tools.py +0 -426
  184. package/scripts/verify_vbrief_conformance.py +0 -478
@@ -1,741 +0,0 @@
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())