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