@deftai/directive-content 0.58.0 → 0.60.0

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