@deftai/directive-content 0.59.0 → 0.61.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.
- package/.githooks/pre-commit +10 -128
- package/.githooks/pre-push +8 -108
- package/Taskfile.yml +48 -58
- package/UPGRADING.md +19 -3
- package/docs/assets/directive-lifecycle-diagram.png +0 -0
- package/docs/directive-lifecycle.md +73 -0
- package/docs/getting-started.md +5 -1
- package/package.json +3 -3
- package/packs/skills/skills-pack-0.1.json +1 -1
- package/packs/strategies/strategies-pack-0.1.json +19 -19
- package/scm/github.md +37 -6
- package/skills/deft-directive-setup/SKILL.md +24 -15
- package/strategies/speckit.md +14 -14
- package/strategies/v0-20-contract.md +12 -1
- package/tasks/change.yml +16 -31
- package/tasks/ci.yml +8 -0
- package/tasks/commit.yml +12 -19
- package/tasks/core.yml +10 -0
- package/tasks/engine.yml +42 -0
- package/tasks/framework.yml +3 -0
- package/tasks/install.yml +20 -19
- package/tasks/migrate.yml +26 -15
- package/tasks/project.yml +26 -0
- package/tasks/toolchain.yml +15 -5
- package/tasks/vbrief.yml +4 -3
- package/tasks/verify.yml +12 -14
- package/templates/agents-entry.md +1 -1
- package/scripts/_agents_md.py +0 -494
- package/scripts/_cache_fetch.py +0 -635
- package/scripts/_cache_quota.py +0 -529
- package/scripts/_cache_refresh.py +0 -163
- package/scripts/_cache_validate.py +0 -209
- package/scripts/_content_root.py +0 -42
- package/scripts/_doctor_state.py +0 -277
- package/scripts/_event_detect.py +0 -305
- package/scripts/_events.py +0 -514
- package/scripts/_lifecycle_hygiene.py +0 -568
- package/scripts/_pathspec.py +0 -91
- package/scripts/_policy_show_cli.py +0 -266
- package/scripts/_precutover.py +0 -92
- package/scripts/_project_context.py +0 -224
- package/scripts/_project_definition_io.py +0 -164
- package/scripts/_relocate_snapshot.py +0 -209
- package/scripts/_relocate_states.py +0 -343
- package/scripts/_resolve_preflight_path.py +0 -152
- package/scripts/_safe_subprocess.py +0 -167
- package/scripts/_session_start_hook.py +0 -205
- package/scripts/_sor_gate_diff.py +0 -365
- package/scripts/_stdio_utf8.py +0 -59
- package/scripts/_triage_bootstrap_gitignore.py +0 -904
- package/scripts/_triage_classify_cli.py +0 -122
- package/scripts/_triage_queue_cli.py +0 -625
- package/scripts/_triage_scope_cli.py +0 -343
- package/scripts/_triage_scope_drift_cli.py +0 -121
- package/scripts/_triage_scope_ignores.py +0 -286
- package/scripts/_triage_scope_milestone.py +0 -432
- package/scripts/_triage_scope_mutations.py +0 -337
- package/scripts/_triage_scope_renderers.py +0 -207
- package/scripts/_triage_smoketest_stages.py +0 -674
- package/scripts/_triage_subscribe_cli.py +0 -140
- package/scripts/_triage_welcome_cli.py +0 -421
- package/scripts/_vbrief_build.py +0 -239
- package/scripts/_vbrief_fidelity.py +0 -479
- package/scripts/_vbrief_legacy.py +0 -589
- package/scripts/_vbrief_reconciliation.py +0 -883
- package/scripts/_vbrief_routing.py +0 -277
- package/scripts/_vbrief_safety.py +0 -778
- package/scripts/_vbrief_sources.py +0 -312
- package/scripts/_vbrief_speckit.py +0 -262
- package/scripts/_vbrief_story_quality.py +0 -353
- package/scripts/_vbrief_validation.py +0 -299
- package/scripts/build_dist.py +0 -412
- package/scripts/cache.py +0 -1078
- package/scripts/cache_scanner.py +0 -745
- package/scripts/candidates_log.py +0 -432
- package/scripts/capacity_backfill.py +0 -680
- package/scripts/capacity_show.py +0 -653
- package/scripts/ci_local.py +0 -689
- package/scripts/code_structure_validate.py +0 -765
- package/scripts/codebase_default_extractor.py +0 -495
- package/scripts/codebase_map.py +0 -304
- package/scripts/codebase_map_fresh.py +0 -104
- package/scripts/codebase_projection_registry.py +0 -94
- package/scripts/codebase_provider.py +0 -582
- package/scripts/doctor.py +0 -2552
- package/scripts/framework_commands.py +0 -505
- package/scripts/gh_rest.py +0 -882
- package/scripts/github_auth_modes.py +0 -437
- package/scripts/github_body.py +0 -292
- package/scripts/ip_risk.py +0 -531
- package/scripts/issue_emit.py +0 -670
- package/scripts/issue_ingest.py +0 -1064
- package/scripts/migrate_preflight.py +0 -418
- package/scripts/migrate_vbrief.py +0 -2677
- package/scripts/monitor_pr.py +0 -401
- package/scripts/pack_migrate_lessons.py +0 -336
- package/scripts/pack_migrate_patterns.py +0 -254
- package/scripts/pack_migrate_rules.py +0 -350
- package/scripts/pack_migrate_skills.py +0 -423
- package/scripts/pack_migrate_strategies.py +0 -311
- package/scripts/pack_migrate_swarm_spec.py +0 -250
- package/scripts/pack_render.py +0 -434
- package/scripts/packs_slice.py +0 -712
- package/scripts/platform_capabilities.py +0 -336
- package/scripts/policy.py +0 -2826
- package/scripts/policy_set.py +0 -324
- package/scripts/pr_check_closing_keywords.py +0 -524
- package/scripts/pr_check_protected_issues.py +0 -267
- package/scripts/pr_merge_readiness.py +0 -1004
- package/scripts/pr_wait_mergeable.py +0 -669
- package/scripts/prd_render.py +0 -159
- package/scripts/preflight_architecture_sor.py +0 -974
- package/scripts/preflight_branch.py +0 -289
- package/scripts/preflight_cache.py +0 -974
- package/scripts/preflight_gh.py +0 -721
- package/scripts/preflight_implementation.py +0 -272
- package/scripts/preflight_story_start.py +0 -838
- package/scripts/preflight_wip_cap.py +0 -149
- package/scripts/probe_session.py +0 -545
- package/scripts/project_render.py +0 -293
- package/scripts/quarantine_ext.py +0 -237
- package/scripts/reconcile_issues.py +0 -1442
- package/scripts/refresh-path.ps1 +0 -107
- package/scripts/release.py +0 -2030
- package/scripts/release_e2e.py +0 -1011
- package/scripts/release_publish.py +0 -486
- package/scripts/release_rollback.py +0 -980
- package/scripts/relocate.py +0 -1034
- package/scripts/resolve_changelog_unreleased.py +0 -667
- package/scripts/resolve_version.py +0 -490
- package/scripts/resume_conditions.py +0 -706
- package/scripts/ritual_sentinel.py +0 -609
- package/scripts/roadmap_render.py +0 -635
- package/scripts/rule_ownership_lint.py +0 -325
- package/scripts/scm.py +0 -591
- package/scripts/scope_audit_log.py +0 -387
- package/scripts/scope_decompose.py +0 -654
- package/scripts/scope_demote.py +0 -509
- package/scripts/scope_lifecycle.py +0 -1126
- package/scripts/scope_undo.py +0 -772
- package/scripts/session_start.py +0 -406
- package/scripts/setup_ghx.py +0 -339
- package/scripts/setup_windows.ps1 +0 -220
- package/scripts/slice_audit.py +0 -585
- package/scripts/slice_record.py +0 -530
- package/scripts/slice_record_existing.py +0 -692
- package/scripts/slug_normalize.py +0 -178
- package/scripts/spec_render.py +0 -477
- package/scripts/spec_validate.py +0 -238
- package/scripts/subagent_monitor.py +0 -658
- package/scripts/swarm_complete_cohort.py +0 -644
- package/scripts/swarm_launch.py +0 -1206
- package/scripts/swarm_readiness.py +0 -554
- package/scripts/swarm_verify_review_clean.py +0 -438
- package/scripts/swarm_worktrees.py +0 -497
- package/scripts/toolchain-check.py +0 -52
- package/scripts/triage_actions.py +0 -871
- package/scripts/triage_bootstrap.py +0 -1153
- package/scripts/triage_bulk.py +0 -630
- package/scripts/triage_classify.py +0 -932
- package/scripts/triage_help.py +0 -1685
- package/scripts/triage_queue.py +0 -1944
- package/scripts/triage_reconcile.py +0 -581
- package/scripts/triage_refresh.py +0 -643
- package/scripts/triage_scope.py +0 -999
- package/scripts/triage_scope_drift.py +0 -575
- package/scripts/triage_smoketest.py +0 -396
- package/scripts/triage_subscribe.py +0 -399
- package/scripts/triage_summary.py +0 -1011
- package/scripts/triage_welcome.py +0 -1178
- package/scripts/ts_check_lane.py +0 -86
- package/scripts/validate-links.py +0 -64
- package/scripts/validate_strategy_output.py +0 -212
- package/scripts/vbrief_activate.py +0 -228
- package/scripts/vbrief_migrate_conformance.py +0 -368
- package/scripts/vbrief_reconcile_graph.py +0 -306
- package/scripts/vbrief_reconcile_labels.py +0 -460
- package/scripts/vbrief_reconcile_umbrellas.py +0 -741
- package/scripts/vbrief_validate.py +0 -1144
- package/scripts/verify-stubs.py +0 -61
- package/scripts/verify_capacity.py +0 -160
- package/scripts/verify_encoding.py +0 -699
- package/scripts/verify_hooks_installed.py +0 -206
- package/scripts/verify_investigation.py +0 -360
- package/scripts/verify_judgment_gates.py +0 -827
- package/scripts/verify_no_task_runtime.py +0 -171
- package/scripts/verify_scm_boundary.py +0 -509
- package/scripts/verify_session_ritual.py +0 -389
- package/scripts/verify_tools.py +0 -426
- 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())
|