@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,368 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""vbrief_migrate_conformance.py -- idempotent Category A vBRIEF 0.6 conformance
|
|
3
|
-
migration (#1620).
|
|
4
|
-
|
|
5
|
-
directive is the vBRIEF reference implementation, yet its own corpus emits a
|
|
6
|
-
handful of bare, non-namespaced keys that are misused / misspelled CORE fields.
|
|
7
|
-
0.6 *permits* unknown fields (they MUST be preserved), so these are not hard
|
|
8
|
-
spec breaks -- but they violate directive's own discipline (consumer usage must
|
|
9
|
-
be core-correct or ``x-directive/`` / ``x-vbrief/`` namespaced, never bare) and
|
|
10
|
-
produced the statusreport #34 false-RED.
|
|
11
|
-
|
|
12
|
-
This script migrates the **Category A** correctness bugs to their correct core
|
|
13
|
-
home. Category B namespacing (``plan.policy`` -> ``plan["x-directive/policy"]``)
|
|
14
|
-
is DEFERRED to a follow-up because it depends on upstream vBRIEF #12; it is NOT
|
|
15
|
-
touched here.
|
|
16
|
-
|
|
17
|
-
Migrations (Category A only)
|
|
18
|
-
----------------------------
|
|
19
|
-
|
|
20
|
-
1. ``plan.planRef`` (plan-LEVEL) that is a bare GitHub ISSUE ref ``"#1348"`` ->
|
|
21
|
-
append a deduped ``plan.references[]`` entry ``{ "uri":
|
|
22
|
-
"https://github.com/deftai/directive/issues/1348", "type":
|
|
23
|
-
"x-vbrief/github-issue", "title": "Issue #1348" }``, then delete ``planRef``.
|
|
24
|
-
This is the misused-as-issue-pointer pattern that produced the statusreport
|
|
25
|
-
#34 false-RED.
|
|
26
|
-
|
|
27
|
-
IMPORTANT -- what is LEFT UNTOUCHED:
|
|
28
|
-
|
|
29
|
-
- A PATH-style plan-level ``planRef`` (e.g. ``"completed/...vbrief.json"``)
|
|
30
|
-
is the directive epic<->story child->parent linkage that
|
|
31
|
-
``scripts/vbrief_validate.py`` D4 validates bidirectionally. Migrating it
|
|
32
|
-
to ``references[]`` breaks D4 (which reads the back-pointer from
|
|
33
|
-
``planRef``), so it is deliberately NOT migrated here. Reconciling that
|
|
34
|
-
linkage onto ``references[]`` (and reworking D4) is deferred to the
|
|
35
|
-
Category B follow-up (#1650).
|
|
36
|
-
- item-LEVEL ``planRef`` is a legitimate 0.6 core field
|
|
37
|
-
(``PlanItem.planRef``) and is LEFT UNTOUCHED.
|
|
38
|
-
|
|
39
|
-
A ``#``-prefixed plan-level ``planRef`` that is NOT a numeric issue ref
|
|
40
|
-
(e.g. a stale symbolic slug) carries no valid issue/path target and is
|
|
41
|
-
simply deleted -- the real references already live in ``references[]``.
|
|
42
|
-
|
|
43
|
-
2. item-level ``description`` (prose) -> item-level ``narrative`` (the core
|
|
44
|
-
field). ``narrative`` is an object in 0.6, so a string ``description`` is
|
|
45
|
-
wrapped as ``{ "Description": <prose> }``. When the item already has a
|
|
46
|
-
``narrative`` the existing keys win and ``description`` is folded in
|
|
47
|
-
non-destructively.
|
|
48
|
-
|
|
49
|
-
3. item-level ``narratives`` (PLURAL -- a copy-paste typo at item level) ->
|
|
50
|
-
item-level ``narrative`` (SINGULAR). The plan-LEVEL ``narratives`` (plural)
|
|
51
|
-
is the correct/expected key and is LEFT UNTOUCHED -- only the item-level
|
|
52
|
-
typo is migrated.
|
|
53
|
-
|
|
54
|
-
Formatting
|
|
55
|
-
----------
|
|
56
|
-
Files are read / written via ``pathlib.Path.read_text(encoding="utf-8")`` /
|
|
57
|
-
``write_text(..., encoding="utf-8")`` per the #798 PowerShell/encoding rule.
|
|
58
|
-
The output preserves key order (``json.load`` / ``json.dump`` are
|
|
59
|
-
order-preserving) and matches each file's existing formatting -- 2-space
|
|
60
|
-
indent, a trailing newline, and the file's original ``ensure_ascii`` style
|
|
61
|
-
(detected per file) -- so an unchanged file is never rewritten and a changed
|
|
62
|
-
file produces a minimal diff.
|
|
63
|
-
|
|
64
|
-
Modes
|
|
65
|
-
-----
|
|
66
|
-
- default (write): apply every needed change, print a per-file summary, exit 0.
|
|
67
|
-
- ``--check`` (dry-run): mutate nothing; exit 0 when the corpus is already
|
|
68
|
-
conformant (a no-op second run), else print the per-file summary and exit 1.
|
|
69
|
-
|
|
70
|
-
Exit codes
|
|
71
|
-
----------
|
|
72
|
-
- ``0`` -- write mode succeeded, OR ``--check`` found no needed changes.
|
|
73
|
-
- ``1`` -- ``--check`` found needed changes (drift).
|
|
74
|
-
- ``2`` -- config error (``--project-root`` has no ``vbrief/`` directory).
|
|
75
|
-
"""
|
|
76
|
-
|
|
77
|
-
from __future__ import annotations
|
|
78
|
-
|
|
79
|
-
import argparse
|
|
80
|
-
import json
|
|
81
|
-
import re
|
|
82
|
-
import sys
|
|
83
|
-
from pathlib import Path
|
|
84
|
-
|
|
85
|
-
#: A plan-level ``planRef`` that is a bare GitHub issue ref, e.g. ``"#1348"``.
|
|
86
|
-
_ISSUE_REF = re.compile(r"^#(\d+)$")
|
|
87
|
-
|
|
88
|
-
#: Canonical issue-URL base for directive. The corpus is single-repo, so a
|
|
89
|
-
#: bare ``#N`` always resolves to deftai/directive (mirrors the existing
|
|
90
|
-
#: ``x-vbrief/github-issue`` references already present across the corpus).
|
|
91
|
-
_ISSUE_URL_BASE = "https://github.com/deftai/directive/issues"
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
def _rename_key_inplace(d: dict, old: str, new: str, new_value: object) -> None:
|
|
95
|
-
"""Replace ``old`` key with ``new`` (value ``new_value``) preserving order.
|
|
96
|
-
|
|
97
|
-
Rebuilds the dict so ``new`` occupies ``old``'s position. Caller guarantees
|
|
98
|
-
``new`` is not already present (else the existing ``new`` would be dropped).
|
|
99
|
-
"""
|
|
100
|
-
rebuilt = {}
|
|
101
|
-
for key, value in d.items():
|
|
102
|
-
if key == old:
|
|
103
|
-
rebuilt[new] = new_value
|
|
104
|
-
else:
|
|
105
|
-
rebuilt[key] = value
|
|
106
|
-
d.clear()
|
|
107
|
-
d.update(rebuilt)
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
def _issue_reference(number: str) -> tuple[dict, str]:
|
|
111
|
-
"""Build the ``x-vbrief/github-issue`` ``references[]`` entry + dedupe-uri."""
|
|
112
|
-
uri = f"{_ISSUE_URL_BASE}/{number}"
|
|
113
|
-
return (
|
|
114
|
-
{"uri": uri, "type": "x-vbrief/github-issue", "title": f"Issue #{number}"},
|
|
115
|
-
uri,
|
|
116
|
-
)
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
def _migrate_plan_ref(plan: dict, changes: list[str]) -> None:
|
|
120
|
-
"""Migrate / clean a plan-level ``planRef`` per its value shape.
|
|
121
|
-
|
|
122
|
-
- ``"#1348"`` (numeric issue ref) -> deduped ``references[]`` entry, delete.
|
|
123
|
-
- ``"#some-slug"`` (``#``-prefixed, non-numeric junk) -> delete (the real
|
|
124
|
-
references already live in ``references[]``).
|
|
125
|
-
- anything else (a PATH-style child->parent link) -> LEFT UNTOUCHED: it is
|
|
126
|
-
the D4 epic<->story linkage read by ``scripts/vbrief_validate.py``;
|
|
127
|
-
migrating it would break that bidirectional check (deferred to #1650).
|
|
128
|
-
"""
|
|
129
|
-
if "planRef" not in plan:
|
|
130
|
-
return
|
|
131
|
-
value = plan["planRef"]
|
|
132
|
-
sval = value.strip() if isinstance(value, str) else ""
|
|
133
|
-
match = _ISSUE_REF.match(sval)
|
|
134
|
-
|
|
135
|
-
if match is not None:
|
|
136
|
-
entry, dedupe_uri = _issue_reference(match.group(1))
|
|
137
|
-
refs = plan.get("references")
|
|
138
|
-
if isinstance(refs, list):
|
|
139
|
-
existing = {
|
|
140
|
-
r.get("uri") for r in refs if isinstance(r, dict) and "uri" in r
|
|
141
|
-
}
|
|
142
|
-
if dedupe_uri in existing:
|
|
143
|
-
del plan["planRef"]
|
|
144
|
-
changes.append(
|
|
145
|
-
f"planRef {value!r} dropped (references[] already has {dedupe_uri})"
|
|
146
|
-
)
|
|
147
|
-
else:
|
|
148
|
-
refs.append(entry)
|
|
149
|
-
del plan["planRef"]
|
|
150
|
-
changes.append(f"planRef {value!r} -> references[] (x-vbrief/github-issue)")
|
|
151
|
-
else:
|
|
152
|
-
# No references[] yet: put it where planRef was for a minimal diff.
|
|
153
|
-
_rename_key_inplace(plan, "planRef", "references", [entry])
|
|
154
|
-
changes.append(
|
|
155
|
-
f"planRef {value!r} -> new references[] (x-vbrief/github-issue)"
|
|
156
|
-
)
|
|
157
|
-
return
|
|
158
|
-
|
|
159
|
-
if sval.startswith("#"):
|
|
160
|
-
del plan["planRef"]
|
|
161
|
-
changes.append(f"planRef {value!r} removed (non-issue, non-path bare ref)")
|
|
162
|
-
return
|
|
163
|
-
|
|
164
|
-
# Path-style child->parent link: leave it for the D4 validator (see #1650).
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
def _fold_into_narrative(item: dict, source_key: str, changes: list[str]) -> None:
|
|
168
|
-
"""Migrate item-level ``description`` / ``narratives`` -> ``narrative``.
|
|
169
|
-
|
|
170
|
-
``narrative`` is a 0.6 object field. A string source is wrapped under a
|
|
171
|
-
sensible key; an object source is renamed / merged. An existing
|
|
172
|
-
``narrative`` wins on key conflicts (the source is folded in
|
|
173
|
-
non-destructively).
|
|
174
|
-
"""
|
|
175
|
-
if source_key not in item:
|
|
176
|
-
return
|
|
177
|
-
source = item[source_key]
|
|
178
|
-
# The wrapper key used when the source is a bare string.
|
|
179
|
-
wrap_key = "Description" if source_key == "description" else "Narrative"
|
|
180
|
-
|
|
181
|
-
if "narrative" not in item:
|
|
182
|
-
if isinstance(source, dict):
|
|
183
|
-
new_value: object = dict(source)
|
|
184
|
-
else:
|
|
185
|
-
new_value = {wrap_key: source}
|
|
186
|
-
_rename_key_inplace(item, source_key, "narrative", new_value)
|
|
187
|
-
changes.append(f"item {source_key} -> narrative")
|
|
188
|
-
return
|
|
189
|
-
|
|
190
|
-
# narrative already present: fold the source in, prefer existing keys.
|
|
191
|
-
narrative = item["narrative"]
|
|
192
|
-
if isinstance(narrative, dict):
|
|
193
|
-
if isinstance(source, dict):
|
|
194
|
-
for key, value in source.items():
|
|
195
|
-
narrative.setdefault(key, value)
|
|
196
|
-
else:
|
|
197
|
-
narrative.setdefault(wrap_key, source)
|
|
198
|
-
del item[source_key]
|
|
199
|
-
changes.append(f"item {source_key} folded into existing narrative")
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
def _walk_items(items: list, changes: list[str]) -> None:
|
|
203
|
-
"""Recurse item / subItem trees applying the item-level migrations."""
|
|
204
|
-
for item in items:
|
|
205
|
-
if not isinstance(item, dict):
|
|
206
|
-
continue
|
|
207
|
-
_fold_into_narrative(item, "description", changes)
|
|
208
|
-
_fold_into_narrative(item, "narratives", changes)
|
|
209
|
-
for nested_key in ("items", "subItems"):
|
|
210
|
-
nested = item.get(nested_key)
|
|
211
|
-
if isinstance(nested, list):
|
|
212
|
-
_walk_items(nested, changes)
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
def migrate_data(data: dict) -> list[str]:
|
|
216
|
-
"""Apply all Category A migrations to ``data`` in place; return change log."""
|
|
217
|
-
changes: list[str] = []
|
|
218
|
-
plan = data.get("plan")
|
|
219
|
-
if not isinstance(plan, dict):
|
|
220
|
-
return changes
|
|
221
|
-
_migrate_plan_ref(plan, changes)
|
|
222
|
-
items = plan.get("items")
|
|
223
|
-
if isinstance(items, list):
|
|
224
|
-
_walk_items(items, changes)
|
|
225
|
-
return changes
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
def _detect_indent_ensure_ascii(original: str, data: dict) -> bool:
|
|
229
|
-
"""Return the ``ensure_ascii`` value that reproduces ``original``.
|
|
230
|
-
|
|
231
|
-
617/618 corpus files use ``ensure_ascii=False``; one historical file uses
|
|
232
|
-
ASCII-escaped unicode. Detecting per file keeps a changed file's diff
|
|
233
|
-
minimal (it is never re-escaped / un-escaped as a side effect).
|
|
234
|
-
"""
|
|
235
|
-
if json.dumps(data, indent=2, ensure_ascii=False) + "\n" == original:
|
|
236
|
-
return False
|
|
237
|
-
# Default to ensure_ascii=False (canonical) unless the ASCII-escaped form
|
|
238
|
-
# is the exact reproduction of the original.
|
|
239
|
-
return json.dumps(data, indent=2, ensure_ascii=True) + "\n" == original
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
def _serialize(data: dict, ensure_ascii: bool) -> str:
|
|
243
|
-
return json.dumps(data, indent=2, ensure_ascii=ensure_ascii) + "\n"
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
def iter_vbrief_files(project_root: Path) -> list[Path]:
|
|
247
|
-
"""Return sorted ``vbrief/**/*.vbrief.json`` paths under ``project_root``."""
|
|
248
|
-
vbrief_dir = project_root / "vbrief"
|
|
249
|
-
if not vbrief_dir.is_dir():
|
|
250
|
-
return []
|
|
251
|
-
return sorted(vbrief_dir.rglob("*.vbrief.json"))
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
def evaluate(
|
|
255
|
-
project_root: Path,
|
|
256
|
-
*,
|
|
257
|
-
check: bool = False,
|
|
258
|
-
) -> tuple[int, list[tuple[str, list[str]]], str]:
|
|
259
|
-
"""Pure driver returning ``(exit_code, per_file_changes, human_message)``.
|
|
260
|
-
|
|
261
|
-
Separated from :func:`main` so tests can drive every state without CLI
|
|
262
|
-
plumbing. In write mode (``check=False``) changed files are persisted.
|
|
263
|
-
"""
|
|
264
|
-
vbrief_dir = project_root / "vbrief"
|
|
265
|
-
if not vbrief_dir.is_dir():
|
|
266
|
-
return (
|
|
267
|
-
2,
|
|
268
|
-
[],
|
|
269
|
-
(
|
|
270
|
-
f"\u274c vbrief_migrate_conformance: no vbrief/ directory under "
|
|
271
|
-
f"{project_root}.\n"
|
|
272
|
-
" Recovery: run from a project root that contains vbrief/."
|
|
273
|
-
),
|
|
274
|
-
)
|
|
275
|
-
|
|
276
|
-
per_file: list[tuple[str, list[str]]] = []
|
|
277
|
-
for path in iter_vbrief_files(project_root):
|
|
278
|
-
original = path.read_text(encoding="utf-8")
|
|
279
|
-
try:
|
|
280
|
-
data = json.loads(original)
|
|
281
|
-
except json.JSONDecodeError:
|
|
282
|
-
# Leave unparseable files alone; the conformance gate / encoding
|
|
283
|
-
# gate own malformed-file reporting.
|
|
284
|
-
continue
|
|
285
|
-
changes = migrate_data(data)
|
|
286
|
-
if not changes:
|
|
287
|
-
continue
|
|
288
|
-
rel = path.relative_to(project_root).as_posix()
|
|
289
|
-
per_file.append((rel, changes))
|
|
290
|
-
if not check:
|
|
291
|
-
ensure_ascii = _detect_indent_ensure_ascii(original, json.loads(original))
|
|
292
|
-
path.write_text(_serialize(data, ensure_ascii), encoding="utf-8")
|
|
293
|
-
|
|
294
|
-
if not per_file:
|
|
295
|
-
msg = (
|
|
296
|
-
"\u2713 vbrief_migrate_conformance: corpus already conformant "
|
|
297
|
-
"(no Category A migrations needed) (#1620)."
|
|
298
|
-
)
|
|
299
|
-
return 0, per_file, msg
|
|
300
|
-
|
|
301
|
-
verb = "would change" if check else "changed"
|
|
302
|
-
marker = "\u26a0" if check else "\u2713"
|
|
303
|
-
lines = [
|
|
304
|
-
f"{marker} vbrief_migrate_conformance: "
|
|
305
|
-
f"{verb} {len(per_file)} file(s) (Category A, #1620)."
|
|
306
|
-
]
|
|
307
|
-
for rel, changes in per_file:
|
|
308
|
-
lines.append(f" {rel}")
|
|
309
|
-
for change in changes:
|
|
310
|
-
lines.append(f" - {change}")
|
|
311
|
-
message = "\n".join(lines)
|
|
312
|
-
# --check signals drift with exit 1; write mode reports success with exit 0.
|
|
313
|
-
return (1 if check else 0), per_file, message
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
def _build_parser() -> argparse.ArgumentParser:
|
|
317
|
-
parser = argparse.ArgumentParser(
|
|
318
|
-
prog="vbrief_migrate_conformance.py",
|
|
319
|
-
description=(
|
|
320
|
-
"Idempotent Category A vBRIEF 0.6 conformance migration (#1620): "
|
|
321
|
-
"plan.planRef -> plan.references[], item description/narratives -> "
|
|
322
|
-
"item narrative."
|
|
323
|
-
),
|
|
324
|
-
)
|
|
325
|
-
parser.add_argument(
|
|
326
|
-
"--check",
|
|
327
|
-
action="store_true",
|
|
328
|
-
help=(
|
|
329
|
-
"Dry-run: mutate nothing. Exit 0 when no changes are needed, "
|
|
330
|
-
"else print a per-file summary and exit 1."
|
|
331
|
-
),
|
|
332
|
-
)
|
|
333
|
-
parser.add_argument(
|
|
334
|
-
"--project-root",
|
|
335
|
-
default=".",
|
|
336
|
-
help="Project root containing vbrief/ (default: current directory).",
|
|
337
|
-
)
|
|
338
|
-
parser.add_argument(
|
|
339
|
-
"--quiet",
|
|
340
|
-
action="store_true",
|
|
341
|
-
help="Suppress the success message (drift/errors still print).",
|
|
342
|
-
)
|
|
343
|
-
return parser
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
def main(argv: list[str] | None = None) -> int:
|
|
347
|
-
# Force UTF-8 stdout/stderr so the non-ASCII status glyphs survive a
|
|
348
|
-
# Windows cp1252/cp437 console (mirrors scripts/verify_encoding.py #814).
|
|
349
|
-
if hasattr(sys.stdout, "reconfigure"):
|
|
350
|
-
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
351
|
-
if hasattr(sys.stderr, "reconfigure"):
|
|
352
|
-
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
353
|
-
|
|
354
|
-
parser = _build_parser()
|
|
355
|
-
args = parser.parse_args(argv)
|
|
356
|
-
project_root = Path(args.project_root).resolve()
|
|
357
|
-
|
|
358
|
-
code, _per_file, message = evaluate(project_root, check=args.check)
|
|
359
|
-
if code == 0:
|
|
360
|
-
if not args.quiet:
|
|
361
|
-
print(message)
|
|
362
|
-
else:
|
|
363
|
-
print(message, file=sys.stderr)
|
|
364
|
-
return code
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
if __name__ == "__main__":
|
|
368
|
-
sys.exit(main())
|
|
@@ -1,306 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""vbrief_reconcile_graph.py -- cascade-unblock walker (#1287).
|
|
3
|
-
|
|
4
|
-
A pure-vBRIEF, forge-agnostic verb (``task vbrief:reconcile:graph``) that
|
|
5
|
-
walks ``vbrief/proposed/``, resolves each candidate's
|
|
6
|
-
``plan.metadata.swarm.depends_on[]`` against current lifecycle state, and
|
|
7
|
-
promotes (``proposed/ -> pending/`` via the existing
|
|
8
|
-
``scope_lifecycle.run_transition`` path) every candidate whose
|
|
9
|
-
dependencies ALL resolve to a brief living in ``vbrief/completed/`` or
|
|
10
|
-
``vbrief/cancelled/``.
|
|
11
|
-
|
|
12
|
-
Design contract:
|
|
13
|
-
|
|
14
|
-
* **Cascade-unblock only.** A candidate with an empty ``depends_on`` is
|
|
15
|
-
left in ``proposed/`` -- the backlog is the operator's, not the
|
|
16
|
-
walker's. Only candidates that WERE blocked on dependencies and are now
|
|
17
|
-
unblocked get promoted.
|
|
18
|
-
* **WIP-cap aware.** Promotions stop once ``pending/ + active/`` reaches
|
|
19
|
-
the configured cap (``plan.policy.wipCap``, default 10). ``--force``
|
|
20
|
-
overrides the cap (each forced promote is logged by the underlying
|
|
21
|
-
scope-lifecycle audit path).
|
|
22
|
-
* **Cycle-safe.** Dependency cycles among proposed candidates are detected
|
|
23
|
-
via the ``swarm_readiness`` dep-graph machinery and never promoted; a
|
|
24
|
-
cycle is reported and yields exit 1.
|
|
25
|
-
* **Idempotent.** A second run is a no-op: promoted candidates have left
|
|
26
|
-
``proposed/``, so nothing further resolves.
|
|
27
|
-
|
|
28
|
-
Reuses the dependency-graph resolution machinery in
|
|
29
|
-
``scripts/swarm_readiness.py`` (``_candidate``, ``_all_scope_ids``,
|
|
30
|
-
``_candidate_dep_graph``, ``_mark_cycles``) and the promote surface in
|
|
31
|
-
``scripts/scope_lifecycle.py`` (``run_transition``) rather than
|
|
32
|
-
reinventing either.
|
|
33
|
-
|
|
34
|
-
Exit codes (three-state):
|
|
35
|
-
0 -- ran successfully (promoted >= 0 candidates; WIP-cap deferral and
|
|
36
|
-
not-yet-resolved candidates are normal, idempotent outcomes).
|
|
37
|
-
1 -- a dependency cycle was detected among proposed candidates.
|
|
38
|
-
2 -- usage / config error (no ``vbrief/proposed/`` directory, etc.).
|
|
39
|
-
"""
|
|
40
|
-
|
|
41
|
-
from __future__ import annotations
|
|
42
|
-
|
|
43
|
-
import argparse
|
|
44
|
-
import json
|
|
45
|
-
import sys
|
|
46
|
-
from dataclasses import dataclass, field
|
|
47
|
-
from pathlib import Path
|
|
48
|
-
|
|
49
|
-
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
50
|
-
|
|
51
|
-
from _stdio_utf8 import reconfigure_stdio # noqa: E402
|
|
52
|
-
from scope_lifecycle import run_transition # noqa: E402
|
|
53
|
-
from swarm_readiness import ( # noqa: E402
|
|
54
|
-
Candidate,
|
|
55
|
-
_all_scope_ids,
|
|
56
|
-
_as_str_list,
|
|
57
|
-
_candidate,
|
|
58
|
-
_candidate_dep_graph,
|
|
59
|
-
_mark_cycles,
|
|
60
|
-
)
|
|
61
|
-
|
|
62
|
-
reconfigure_stdio()
|
|
63
|
-
|
|
64
|
-
# A dependency "resolves" (no longer blocks its dependent) when the brief
|
|
65
|
-
# it names lives in one of these terminal lifecycle folders.
|
|
66
|
-
RESOLVED_FOLDERS = ("completed", "cancelled")
|
|
67
|
-
_CYCLE_MARKER = "dependency cycle:"
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
@dataclass
|
|
71
|
-
class ReconcileOutcome:
|
|
72
|
-
"""Result of a single reconcile walk."""
|
|
73
|
-
|
|
74
|
-
promoted: list[str] = field(default_factory=list)
|
|
75
|
-
deferred_wip: list[str] = field(default_factory=list)
|
|
76
|
-
waiting: list[tuple[str, list[str]]] = field(default_factory=list)
|
|
77
|
-
cycles: list[str] = field(default_factory=list)
|
|
78
|
-
errors: list[tuple[str, str]] = field(default_factory=list)
|
|
79
|
-
cap: int = 0
|
|
80
|
-
count: int = 0
|
|
81
|
-
dry_run: bool = False
|
|
82
|
-
forced: bool = False
|
|
83
|
-
|
|
84
|
-
def to_json(self) -> dict[str, object]:
|
|
85
|
-
return {
|
|
86
|
-
"promoted": list(self.promoted),
|
|
87
|
-
"deferred_wip": list(self.deferred_wip),
|
|
88
|
-
"waiting": [{"story_id": sid, "unresolved": deps} for sid, deps in self.waiting],
|
|
89
|
-
"cycles": list(self.cycles),
|
|
90
|
-
"errors": [{"story_id": sid, "message": msg} for sid, msg in self.errors],
|
|
91
|
-
"cap": self.cap,
|
|
92
|
-
"count": self.count,
|
|
93
|
-
"dry_run": self.dry_run,
|
|
94
|
-
"forced": self.forced,
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def _resolve_wip_state(project_root: Path) -> tuple[int, int]:
|
|
99
|
-
"""Return ``(cap, current_count)`` for the WIP cap.
|
|
100
|
-
|
|
101
|
-
Deferred-import of ``scripts.policy`` so a tree that pre-dates the D4
|
|
102
|
-
WIP-cap schema (#1124) degrades to "no cap" (a very large cap) rather
|
|
103
|
-
than raising.
|
|
104
|
-
"""
|
|
105
|
-
try:
|
|
106
|
-
from policy import count_vbrief_wip, resolve_wip_cap
|
|
107
|
-
except ImportError: # pragma: no cover -- D4 not present
|
|
108
|
-
return sys.maxsize, 0
|
|
109
|
-
cap = resolve_wip_cap(project_root).cap
|
|
110
|
-
count = count_vbrief_wip(project_root)
|
|
111
|
-
return cap, count
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
def _dep_resolved(dep: str, known_ids: dict[str, tuple[Path, str]]) -> bool:
|
|
115
|
-
"""True when *dep* names a brief in a terminal (completed/cancelled) folder."""
|
|
116
|
-
known = known_ids.get(dep)
|
|
117
|
-
if known is None:
|
|
118
|
-
return False
|
|
119
|
-
path, _status = known
|
|
120
|
-
return path.parent.name in RESOLVED_FOLDERS
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
def _unresolved_deps(
|
|
124
|
-
candidate: Candidate,
|
|
125
|
-
known_ids: dict[str, tuple[Path, str]],
|
|
126
|
-
) -> list[str]:
|
|
127
|
-
"""Return the dependency ids that have NOT resolved to a terminal folder."""
|
|
128
|
-
return [
|
|
129
|
-
dep
|
|
130
|
-
for dep in _as_str_list(candidate.swarm.get("depends_on"))
|
|
131
|
-
if not _dep_resolved(dep, known_ids)
|
|
132
|
-
]
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
def _candidate_in_cycle(candidate: Candidate) -> bool:
|
|
136
|
-
return any(reason.startswith(_CYCLE_MARKER) for reason in candidate.blocked)
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
def reconcile_graph(
|
|
140
|
-
project_root: Path,
|
|
141
|
-
*,
|
|
142
|
-
force: bool = False,
|
|
143
|
-
dry_run: bool = False,
|
|
144
|
-
) -> tuple[int, ReconcileOutcome]:
|
|
145
|
-
"""Walk proposed/, promote cascade-unblocked candidates, return (exit, outcome).
|
|
146
|
-
|
|
147
|
-
The promote order is deterministic (sorted by story id) so the WIP-cap
|
|
148
|
-
cut-off is stable across runs.
|
|
149
|
-
"""
|
|
150
|
-
proposed_dir = project_root / "vbrief" / "proposed"
|
|
151
|
-
if not proposed_dir.is_dir():
|
|
152
|
-
return 2, ReconcileOutcome()
|
|
153
|
-
|
|
154
|
-
candidate_paths = sorted(proposed_dir.glob("*.vbrief.json"))
|
|
155
|
-
candidates = [c for path in candidate_paths if (c := _candidate(path, project_root))]
|
|
156
|
-
|
|
157
|
-
known_ids = _all_scope_ids(project_root)
|
|
158
|
-
for candidate in candidates:
|
|
159
|
-
known_ids.setdefault(candidate.story_id, (candidate.path, candidate.status))
|
|
160
|
-
|
|
161
|
-
# Reuse the swarm_readiness dep-graph + cycle machinery. ``_candidate_dep_graph``
|
|
162
|
-
# builds edges only between proposed candidates; ``_mark_cycles`` then appends a
|
|
163
|
-
# ``dependency cycle: ...`` marker to every candidate that participates in one.
|
|
164
|
-
graph = _candidate_dep_graph(candidates, known_ids)
|
|
165
|
-
_mark_cycles(candidates, graph)
|
|
166
|
-
|
|
167
|
-
cap, count = _resolve_wip_state(project_root)
|
|
168
|
-
outcome = ReconcileOutcome(cap=cap, count=count, dry_run=dry_run, forced=force)
|
|
169
|
-
|
|
170
|
-
eligible: list[Candidate] = []
|
|
171
|
-
for candidate in sorted(candidates, key=lambda c: c.story_id):
|
|
172
|
-
if _candidate_in_cycle(candidate):
|
|
173
|
-
cycle_reason = next(
|
|
174
|
-
reason for reason in candidate.blocked if reason.startswith(_CYCLE_MARKER)
|
|
175
|
-
)
|
|
176
|
-
outcome.cycles.append(f"{candidate.story_id}: {cycle_reason}")
|
|
177
|
-
continue
|
|
178
|
-
deps = _as_str_list(candidate.swarm.get("depends_on"))
|
|
179
|
-
if not deps:
|
|
180
|
-
# Cascade-unblock only: a dependency-free brief is operator backlog,
|
|
181
|
-
# not the walker's to promote.
|
|
182
|
-
continue
|
|
183
|
-
unresolved = _unresolved_deps(candidate, known_ids)
|
|
184
|
-
if unresolved:
|
|
185
|
-
outcome.waiting.append((candidate.story_id, unresolved))
|
|
186
|
-
continue
|
|
187
|
-
eligible.append(candidate)
|
|
188
|
-
|
|
189
|
-
running_count = count
|
|
190
|
-
for candidate in eligible:
|
|
191
|
-
if running_count >= cap and not force:
|
|
192
|
-
outcome.deferred_wip.append(candidate.story_id)
|
|
193
|
-
continue
|
|
194
|
-
if dry_run:
|
|
195
|
-
outcome.promoted.append(candidate.story_id)
|
|
196
|
-
running_count += 1
|
|
197
|
-
continue
|
|
198
|
-
ok, message = run_transition("promote", candidate.path)
|
|
199
|
-
if not ok:
|
|
200
|
-
outcome.errors.append((candidate.story_id, message))
|
|
201
|
-
continue
|
|
202
|
-
outcome.promoted.append(candidate.story_id)
|
|
203
|
-
running_count += 1
|
|
204
|
-
|
|
205
|
-
outcome.count = running_count
|
|
206
|
-
exit_code = 1 if outcome.cycles else 0
|
|
207
|
-
return exit_code, outcome
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
def _render_report(outcome: ReconcileOutcome) -> str:
|
|
211
|
-
lines: list[str] = ["vBRIEF reconcile graph", ""]
|
|
212
|
-
suffix = " (dry-run)" if outcome.dry_run else ""
|
|
213
|
-
|
|
214
|
-
lines.append(f"Promoted{suffix}:")
|
|
215
|
-
if outcome.promoted:
|
|
216
|
-
lines.extend(f"- {story_id}" for story_id in outcome.promoted)
|
|
217
|
-
else:
|
|
218
|
-
lines.append("- none")
|
|
219
|
-
lines.append("")
|
|
220
|
-
|
|
221
|
-
lines.append(f"Deferred (WIP cap {outcome.count}/{outcome.cap}):")
|
|
222
|
-
if outcome.deferred_wip:
|
|
223
|
-
lines.extend(f"- {story_id}" for story_id in outcome.deferred_wip)
|
|
224
|
-
else:
|
|
225
|
-
lines.append("- none")
|
|
226
|
-
lines.append("")
|
|
227
|
-
|
|
228
|
-
lines.append("Waiting (deps unresolved):")
|
|
229
|
-
if outcome.waiting:
|
|
230
|
-
lines.extend(
|
|
231
|
-
f"- {story_id}: needs {', '.join(deps)}" for story_id, deps in outcome.waiting
|
|
232
|
-
)
|
|
233
|
-
else:
|
|
234
|
-
lines.append("- none")
|
|
235
|
-
lines.append("")
|
|
236
|
-
|
|
237
|
-
lines.append("Cycles:")
|
|
238
|
-
if outcome.cycles:
|
|
239
|
-
lines.extend(f"- {entry}" for entry in outcome.cycles)
|
|
240
|
-
else:
|
|
241
|
-
lines.append("- none")
|
|
242
|
-
|
|
243
|
-
if outcome.errors:
|
|
244
|
-
lines.append("")
|
|
245
|
-
lines.append("Errors:")
|
|
246
|
-
lines.extend(f"- {story_id}: {message}" for story_id, message in outcome.errors)
|
|
247
|
-
|
|
248
|
-
return "\n".join(lines)
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
def _parse_args(argv: list[str]) -> argparse.Namespace:
|
|
252
|
-
parser = argparse.ArgumentParser(
|
|
253
|
-
description=(
|
|
254
|
-
"Cascade-unblock walker: promote proposed/ vBRIEFs whose "
|
|
255
|
-
"swarm.depends_on[] all resolve to completed/ or cancelled/."
|
|
256
|
-
)
|
|
257
|
-
)
|
|
258
|
-
parser.add_argument(
|
|
259
|
-
"--project-root",
|
|
260
|
-
default=".",
|
|
261
|
-
help="Project root containing vbrief/ (default: current directory).",
|
|
262
|
-
)
|
|
263
|
-
parser.add_argument(
|
|
264
|
-
"--force",
|
|
265
|
-
action="store_true",
|
|
266
|
-
help="Override the WIP cap when promoting (each forced promote is audited).",
|
|
267
|
-
)
|
|
268
|
-
parser.add_argument(
|
|
269
|
-
"--dry-run",
|
|
270
|
-
action="store_true",
|
|
271
|
-
help="Report which candidates WOULD be promoted without moving any files.",
|
|
272
|
-
)
|
|
273
|
-
parser.add_argument(
|
|
274
|
-
"--json",
|
|
275
|
-
action="store_true",
|
|
276
|
-
help="Emit a machine-readable JSON summary instead of the text report.",
|
|
277
|
-
)
|
|
278
|
-
return parser.parse_args(argv)
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
def main(argv: list[str] | None = None) -> int:
|
|
282
|
-
args = _parse_args(sys.argv[1:] if argv is None else argv)
|
|
283
|
-
project_root = Path(args.project_root).resolve()
|
|
284
|
-
exit_code, outcome = reconcile_graph(
|
|
285
|
-
project_root,
|
|
286
|
-
force=args.force,
|
|
287
|
-
dry_run=args.dry_run,
|
|
288
|
-
)
|
|
289
|
-
if exit_code == 2:
|
|
290
|
-
if args.json:
|
|
291
|
-
print(json.dumps({"error": "no vbrief/proposed/ directory found"}))
|
|
292
|
-
else:
|
|
293
|
-
print(
|
|
294
|
-
f"Error: no vbrief/proposed/ directory found under {project_root}",
|
|
295
|
-
file=sys.stderr,
|
|
296
|
-
)
|
|
297
|
-
return 2
|
|
298
|
-
if args.json:
|
|
299
|
-
print(json.dumps(outcome.to_json(), indent=2))
|
|
300
|
-
else:
|
|
301
|
-
print(_render_report(outcome))
|
|
302
|
-
return exit_code
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
if __name__ == "__main__":
|
|
306
|
-
raise SystemExit(main())
|