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