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