@deftai/directive-content 0.55.2 → 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 (217) hide show
  1. package/.githooks/pre-commit +143 -0
  2. package/.githooks/pre-push +121 -0
  3. package/QUICK-START.md +2 -2
  4. package/Taskfile.yml +934 -0
  5. package/UPGRADING.md +47 -1
  6. package/events/README.md +3 -3
  7. package/package.json +5 -4
  8. package/scripts/_agents_md.py +494 -0
  9. package/scripts/_cache_fetch.py +635 -0
  10. package/scripts/_cache_quota.py +529 -0
  11. package/scripts/_cache_refresh.py +163 -0
  12. package/scripts/_cache_validate.py +209 -0
  13. package/scripts/_content_root.py +42 -0
  14. package/scripts/_doctor_state.py +277 -0
  15. package/scripts/_event_detect.py +305 -0
  16. package/scripts/_events.py +514 -0
  17. package/scripts/_lifecycle_hygiene.py +568 -0
  18. package/scripts/_pathspec.py +91 -0
  19. package/scripts/_policy_show_cli.py +266 -0
  20. package/scripts/_precutover.py +92 -0
  21. package/scripts/_project_context.py +224 -0
  22. package/scripts/_project_definition_io.py +164 -0
  23. package/scripts/_relocate_snapshot.py +209 -0
  24. package/scripts/_relocate_states.py +343 -0
  25. package/scripts/_resolve_preflight_path.py +152 -0
  26. package/scripts/_safe_subprocess.py +167 -0
  27. package/scripts/_session_start_hook.py +205 -0
  28. package/scripts/_sor_gate_diff.py +365 -0
  29. package/scripts/_stdio_utf8.py +59 -0
  30. package/scripts/_triage_bootstrap_gitignore.py +904 -0
  31. package/scripts/_triage_classify_cli.py +122 -0
  32. package/scripts/_triage_queue_cli.py +625 -0
  33. package/scripts/_triage_scope_cli.py +343 -0
  34. package/scripts/_triage_scope_drift_cli.py +121 -0
  35. package/scripts/_triage_scope_ignores.py +286 -0
  36. package/scripts/_triage_scope_milestone.py +432 -0
  37. package/scripts/_triage_scope_mutations.py +337 -0
  38. package/scripts/_triage_scope_renderers.py +207 -0
  39. package/scripts/_triage_smoketest_stages.py +674 -0
  40. package/scripts/_triage_subscribe_cli.py +140 -0
  41. package/scripts/_triage_welcome_cli.py +421 -0
  42. package/scripts/_vbrief_build.py +239 -0
  43. package/scripts/_vbrief_fidelity.py +479 -0
  44. package/scripts/_vbrief_legacy.py +589 -0
  45. package/scripts/_vbrief_reconciliation.py +883 -0
  46. package/scripts/_vbrief_routing.py +277 -0
  47. package/scripts/_vbrief_safety.py +778 -0
  48. package/scripts/_vbrief_sources.py +312 -0
  49. package/scripts/_vbrief_speckit.py +262 -0
  50. package/scripts/_vbrief_story_quality.py +353 -0
  51. package/scripts/_vbrief_validation.py +299 -0
  52. package/scripts/build_dist.py +412 -0
  53. package/scripts/cache.py +1078 -0
  54. package/scripts/cache_scanner.py +745 -0
  55. package/scripts/candidates_log.py +432 -0
  56. package/scripts/capacity_backfill.py +680 -0
  57. package/scripts/capacity_show.py +653 -0
  58. package/scripts/ci_local.py +689 -0
  59. package/scripts/code_structure_validate.py +765 -0
  60. package/scripts/codebase_default_extractor.py +495 -0
  61. package/scripts/codebase_map.py +304 -0
  62. package/scripts/codebase_map_fresh.py +104 -0
  63. package/scripts/codebase_projection_registry.py +94 -0
  64. package/scripts/codebase_provider.py +582 -0
  65. package/scripts/doctor.py +2257 -0
  66. package/scripts/framework_commands.py +505 -0
  67. package/scripts/gh_rest.py +882 -0
  68. package/scripts/github_auth_modes.py +437 -0
  69. package/scripts/github_body.py +292 -0
  70. package/scripts/ip_risk.py +531 -0
  71. package/scripts/issue_emit.py +670 -0
  72. package/scripts/issue_ingest.py +1064 -0
  73. package/scripts/migrate_preflight.py +418 -0
  74. package/scripts/migrate_vbrief.py +2677 -0
  75. package/scripts/monitor_pr.py +401 -0
  76. package/scripts/pack_migrate_lessons.py +336 -0
  77. package/scripts/pack_migrate_patterns.py +254 -0
  78. package/scripts/pack_migrate_rules.py +350 -0
  79. package/scripts/pack_migrate_skills.py +423 -0
  80. package/scripts/pack_migrate_strategies.py +311 -0
  81. package/scripts/pack_migrate_swarm_spec.py +250 -0
  82. package/scripts/pack_render.py +434 -0
  83. package/scripts/packs_slice.py +712 -0
  84. package/scripts/platform_capabilities.py +336 -0
  85. package/scripts/policy.py +2826 -0
  86. package/scripts/policy_set.py +324 -0
  87. package/scripts/pr_check_closing_keywords.py +524 -0
  88. package/scripts/pr_check_protected_issues.py +267 -0
  89. package/scripts/pr_merge_readiness.py +1004 -0
  90. package/scripts/pr_wait_mergeable.py +669 -0
  91. package/scripts/prd_render.py +159 -0
  92. package/scripts/preflight_architecture_sor.py +974 -0
  93. package/scripts/preflight_branch.py +289 -0
  94. package/scripts/preflight_cache.py +974 -0
  95. package/scripts/preflight_gh.py +721 -0
  96. package/scripts/preflight_implementation.py +272 -0
  97. package/scripts/preflight_story_start.py +838 -0
  98. package/scripts/preflight_wip_cap.py +149 -0
  99. package/scripts/probe_session.py +545 -0
  100. package/scripts/project_render.py +293 -0
  101. package/scripts/quarantine_ext.py +237 -0
  102. package/scripts/reconcile_issues.py +1442 -0
  103. package/scripts/refresh-path.ps1 +107 -0
  104. package/scripts/release.py +2030 -0
  105. package/scripts/release_e2e.py +1011 -0
  106. package/scripts/release_publish.py +486 -0
  107. package/scripts/release_rollback.py +980 -0
  108. package/scripts/relocate.py +1034 -0
  109. package/scripts/resolve_changelog_unreleased.py +667 -0
  110. package/scripts/resolve_version.py +490 -0
  111. package/scripts/resume_conditions.py +706 -0
  112. package/scripts/ritual_sentinel.py +609 -0
  113. package/scripts/roadmap_render.py +635 -0
  114. package/scripts/rule_ownership_lint.py +325 -0
  115. package/scripts/scm.py +591 -0
  116. package/scripts/scope_audit_log.py +387 -0
  117. package/scripts/scope_decompose.py +654 -0
  118. package/scripts/scope_demote.py +509 -0
  119. package/scripts/scope_lifecycle.py +1126 -0
  120. package/scripts/scope_undo.py +772 -0
  121. package/scripts/session_start.py +406 -0
  122. package/scripts/setup_ghx.py +339 -0
  123. package/scripts/setup_windows.ps1 +220 -0
  124. package/scripts/slice_audit.py +585 -0
  125. package/scripts/slice_record.py +530 -0
  126. package/scripts/slice_record_existing.py +692 -0
  127. package/scripts/slug_normalize.py +178 -0
  128. package/scripts/spec_render.py +477 -0
  129. package/scripts/spec_validate.py +238 -0
  130. package/scripts/subagent_monitor.py +658 -0
  131. package/scripts/swarm_complete_cohort.py +644 -0
  132. package/scripts/swarm_launch.py +1206 -0
  133. package/scripts/swarm_readiness.py +554 -0
  134. package/scripts/swarm_verify_review_clean.py +438 -0
  135. package/scripts/swarm_worktrees.py +497 -0
  136. package/scripts/toolchain-check.py +52 -0
  137. package/scripts/triage_actions.py +871 -0
  138. package/scripts/triage_bootstrap.py +1153 -0
  139. package/scripts/triage_bulk.py +630 -0
  140. package/scripts/triage_classify.py +932 -0
  141. package/scripts/triage_help.py +1685 -0
  142. package/scripts/triage_queue.py +1944 -0
  143. package/scripts/triage_reconcile.py +581 -0
  144. package/scripts/triage_refresh.py +643 -0
  145. package/scripts/triage_scope.py +999 -0
  146. package/scripts/triage_scope_drift.py +575 -0
  147. package/scripts/triage_smoketest.py +396 -0
  148. package/scripts/triage_subscribe.py +399 -0
  149. package/scripts/triage_summary.py +1011 -0
  150. package/scripts/triage_welcome.py +1178 -0
  151. package/scripts/ts_check_lane.py +86 -0
  152. package/scripts/validate-links.py +64 -0
  153. package/scripts/validate_strategy_output.py +212 -0
  154. package/scripts/vbrief_activate.py +228 -0
  155. package/scripts/vbrief_migrate_conformance.py +368 -0
  156. package/scripts/vbrief_reconcile_graph.py +306 -0
  157. package/scripts/vbrief_reconcile_labels.py +460 -0
  158. package/scripts/vbrief_reconcile_umbrellas.py +741 -0
  159. package/scripts/vbrief_validate.py +1195 -0
  160. package/scripts/verify-stubs.py +61 -0
  161. package/scripts/verify_capacity.py +160 -0
  162. package/scripts/verify_encoding.py +699 -0
  163. package/scripts/verify_hooks_installed.py +206 -0
  164. package/scripts/verify_investigation.py +360 -0
  165. package/scripts/verify_judgment_gates.py +827 -0
  166. package/scripts/verify_no_task_runtime.py +171 -0
  167. package/scripts/verify_scm_boundary.py +509 -0
  168. package/scripts/verify_session_ritual.py +389 -0
  169. package/scripts/verify_tools.py +426 -0
  170. package/scripts/verify_vbrief_conformance.py +478 -0
  171. package/tasks/architecture.yml +13 -0
  172. package/tasks/cache.yml +69 -0
  173. package/tasks/capacity.yml +38 -0
  174. package/tasks/change.yml +46 -0
  175. package/tasks/changelog.yml +24 -0
  176. package/tasks/ci.yml +49 -0
  177. package/tasks/codebase.yml +47 -0
  178. package/tasks/commit.yml +30 -0
  179. package/tasks/core.yml +126 -0
  180. package/tasks/deployments.yml +54 -0
  181. package/tasks/framework.yml +74 -0
  182. package/tasks/install.yml +60 -0
  183. package/tasks/issue.yml +50 -0
  184. package/tasks/migrate.yml +73 -0
  185. package/tasks/packs.yml +92 -0
  186. package/tasks/policy.yml +75 -0
  187. package/tasks/pr.yml +89 -0
  188. package/tasks/prd.yml +39 -0
  189. package/tasks/project.yml +27 -0
  190. package/tasks/reconcile.yml +32 -0
  191. package/tasks/relocate.yml +56 -0
  192. package/tasks/roadmap.yml +28 -0
  193. package/tasks/scm.yml +126 -0
  194. package/tasks/scope-undo.yml +36 -0
  195. package/tasks/scope.yml +141 -0
  196. package/tasks/session.yml +19 -0
  197. package/tasks/setup.yml +37 -0
  198. package/tasks/slice.yml +69 -0
  199. package/tasks/spec.yml +41 -0
  200. package/tasks/swarm.yml +85 -0
  201. package/tasks/toolchain.yml +13 -0
  202. package/tasks/triage-actions.yml +94 -0
  203. package/tasks/triage-bootstrap.yml +43 -0
  204. package/tasks/triage-bulk.yml +75 -0
  205. package/tasks/triage-classify.yml +30 -0
  206. package/tasks/triage-queue.yml +50 -0
  207. package/tasks/triage-reconcile.yml +29 -0
  208. package/tasks/triage-scope-drift.yml +29 -0
  209. package/tasks/triage-scope.yml +31 -0
  210. package/tasks/triage-smoketest.yml +33 -0
  211. package/tasks/triage-subscribe.yml +36 -0
  212. package/tasks/triage-summary.yml +29 -0
  213. package/tasks/triage-welcome.yml +32 -0
  214. package/tasks/ts.yml +328 -0
  215. package/tasks/vbrief.yml +206 -0
  216. package/tasks/verify.yml +292 -0
  217. package/templates/agents-entry.md +1 -1
@@ -0,0 +1,838 @@
1
+ #!/usr/bin/env python3
2
+ """preflight_story_start.py -- deterministic story-start Gate 0 (#1378 Story C).
3
+
4
+ The pre-``start_agent`` gate stack (AGENTS.md ``## Session-start ritual``)
5
+ gains a deterministic Gate 0 that fires BEFORE the #810 implementation-intent
6
+ gate. Where ``preflight_implementation.py`` checks only the target vBRIEF's
7
+ lifecycle, this gate inspects the THREE story-start preconditions the prose
8
+ Story Start Gate documents:
9
+
10
+ (a) Working tree -- ``git status --porcelain`` is clean (or the operator
11
+ passed ``--allow-dirty`` for the sanctioned "include existing work" /
12
+ fresh-branch-start path).
13
+ (b) Target vBRIEF -- lives in ``vbrief/active/`` AND ``plan.status ==
14
+ "running"`` (the same lifecycle handoff ``preflight_implementation.py``
15
+ asserts).
16
+ (c) Dispatch envelope -- when a ``## Allocation context`` section is present
17
+ (the #1378 Story A schema), the consent token is machine-checked: a
18
+ ``swarm-cohort`` dispatch is only ready when ``allocation_plan_id`` AND
19
+ ``batching_rationale`` are both non-null. When the section is ABSENT the
20
+ dispatch is treated as solo-interactive and is ready subject to (a)/(b)
21
+ -- this is the #1371 prose carve-out fallback made structural.
22
+
23
+ This turns the #1371 carve-out from prose-trusted into load-bearing: the
24
+ recognition contract ("a section reporting ``dispatch_kind: swarm-cohort``
25
+ with a NON-NULL ``allocation_plan_id`` AND ``batching_rationale`` satisfies
26
+ the Story Start Gate consent-token requirement") is now a gate exit code,
27
+ foreclosing the next #954-class silent failure.
28
+
29
+ Mirrors ``scripts/preflight_branch.py`` (#747) and
30
+ ``scripts/preflight_implementation.py`` (#810) in shape: pure stdlib,
31
+ ``evaluate(...) -> (exit_code, message)`` separated from CLI plumbing for
32
+ testability, a structured ``--json`` variant, and a UTF-8 self-reconfigure
33
+ at ``main`` entry so the success/forbidden glyphs survive a Windows
34
+ codepage-default stdout.
35
+
36
+ Exit codes (three-state, mirrors ``scripts/preflight_branch.py``):
37
+
38
+ - ``0`` -- ready: tree clean (or ``--allow-dirty``), vBRIEF active+running,
39
+ and either no allocation-context section (solo) OR a satisfied consent
40
+ token (``solo`` dispatch, or ``swarm-cohort`` with non-null
41
+ ``allocation_plan_id`` + ``batching_rationale``).
42
+ - ``1`` -- not ready: dirty tree, target vBRIEF not active/running, or a
43
+ ``swarm-cohort`` section whose ``allocation_plan_id`` / ``batching_rationale``
44
+ is null or missing (the incomplete consent token).
45
+ - ``2`` -- config error: the ``## Allocation context`` section is present but
46
+ malformed -- ``dispatch_kind`` missing / unrecognised, no parseable
47
+ fields, an unreadable ``--allocation-context`` file, or the working-tree
48
+ state could not be determined (git absent / not a repo).
49
+
50
+ Slice-7 gate-clearance integration (#1419): on a READY result this gate can
51
+ also evaluate the target story's ``plan.metadata.swarm.file_scope`` against the
52
+ risk-tiered judgment gates (imported from ``scripts/verify_judgment_gates.py``).
53
+ The DEFAULT posture is advisory -- an uncleared active block-tier gate is
54
+ SURFACED but the exit code is unchanged; the opt-in ``--enforce`` posture fails
55
+ closed (exit 1). Clearances ride the ``## Allocation context`` as an inline-JSON
56
+ ``gate_clearances`` bullet; an ABSENT bullet in the advisory default is exactly
57
+ today's behavior (backward compatible). Allocation approvals can be appended to
58
+ the durable ``vbrief/.audit/`` log via ``--record-approval``.
59
+
60
+ Refs:
61
+ - #1378 (this gate; Story C)
62
+ - #1371 (Story Start Gate consent-token carve-out this gate makes structural)
63
+ - #1419 (Slice 7: gate-clearance enforcement + durable authority-event audit)
64
+ - #810 (precedent: ``scripts/preflight_implementation.py`` lifecycle gate)
65
+ - #747 (precedent shape: ``scripts/preflight_branch.py`` three-state exit)
66
+ - #1366 (subprocess capture forces ``encoding="utf-8", errors="replace"``)
67
+ """
68
+
69
+ from __future__ import annotations
70
+
71
+ import argparse
72
+ import json
73
+ import subprocess
74
+ import sys
75
+ import uuid
76
+ from datetime import UTC, datetime
77
+ from pathlib import Path
78
+ from typing import Any
79
+
80
+ # Make sibling scripts importable both when run as __main__ and when the
81
+ # module is loaded directly by the test suite (mirrors swarm_launch.py).
82
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
83
+
84
+ # Judgment-gate engine (#1419 Slice 3, on master). The Slice 7 clearance
85
+ # integration evaluates the target story's ``plan.metadata.swarm.file_scope``
86
+ # against the configured + universal judgment gates via ``build_report`` /
87
+ # ``Candidate``. Guarded so Gate 0 still loads (today's behavior) when the
88
+ # engine is unavailable -- the gate-clearance layer is then simply skipped.
89
+ try: # pragma: no cover - exercised on the real tree; guarded for resilience
90
+ import verify_judgment_gates as _gates # type: ignore # noqa: E402
91
+ except Exception: # noqa: BLE001 - any import failure disables the gate layer
92
+ _gates = None # type: ignore[assignment]
93
+
94
+ #: Canonical eligibility folder for an implementation story (mirrors
95
+ #: ``preflight_implementation.ACTIVE_FOLDER``).
96
+ ACTIVE_FOLDER = "active"
97
+
98
+ #: Canonical eligibility status -- ``running`` is the only ``plan.status``
99
+ #: value that signals an active implementation handoff.
100
+ ELIGIBLE_STATUS = "running"
101
+
102
+ #: The markdown heading that opens the dispatch envelope's allocation block.
103
+ #: Absence of this heading => solo path (the #1371 prose carve-out fallback).
104
+ ALLOCATION_HEADING = "## Allocation context"
105
+
106
+ #: Recognised ``dispatch_kind`` values (Story A FROZEN SCHEMA CONTRACT). Any
107
+ #: other value is a config error -- the gate cannot classify the dispatch.
108
+ SOLO_KIND = "solo"
109
+ SWARM_COHORT_KIND = "swarm-cohort"
110
+ VALID_DISPATCH_KINDS = frozenset({SOLO_KIND, SWARM_COHORT_KIND})
111
+
112
+ #: The five canonical allocation-context fields, in contract order. Used for
113
+ #: documentation / diagnostics; only ``dispatch_kind`` is structurally
114
+ #: required to classify, and (for swarm-cohort) ``allocation_plan_id`` +
115
+ #: ``batching_rationale`` are the consent token.
116
+ ALLOCATION_FIELDS = (
117
+ "dispatch_kind",
118
+ "allocation_plan_id",
119
+ "batching_rationale",
120
+ "cohort_vbriefs",
121
+ "operator_approval_evidence",
122
+ )
123
+
124
+ #: Tokens that normalise to "null" (absent value) when parsing a field.
125
+ _NULL_TOKENS = frozenset({"", "null", "none", "n/a"})
126
+
127
+ #: The ``## Allocation context`` bullet that carries the inline-JSON
128
+ #: gate-clearance array (#1419 Slice 7). Each entry is an object with
129
+ #: ``gate_id`` / ``vbrief_path`` / ``cleared_by`` / ``rationale`` /
130
+ #: ``cleared_at`` / ``cleared_scope``. ABSENCE of this bullet == today's
131
+ #: behavior (no gate-clearance evaluation in the advisory default posture --
132
+ #: backward compatible with every pre-Slice-7 dispatch envelope).
133
+ GATE_CLEARANCES_FIELD = "gate_clearances"
134
+
135
+ #: Gate-clearance evaluation postures (mirrors the verify_judgment_gates
136
+ #: vocabulary). ``advise`` (DEFAULT) NEVER changes the readiness exit code --
137
+ #: an uncleared active block-tier gate is SURFACED but the gate still exits 0.
138
+ #: ``enforce`` fails closed (exit 1) when a mechanical block-tier gate fires
139
+ #: without a recorded clearance. The framework's own ``task verify:story-ready``
140
+ #: never passes ``--enforce`` so Gate 0 stays advisory on directive's own tree.
141
+ GATE_ADVISE = "advise"
142
+ GATE_ENFORCE = "enforce"
143
+
144
+ #: Durable, committed audit log (dir + file) for authority-bearing events --
145
+ #: allocation approvals + gate clearances per RFC #1419 Receipts & Audit
146
+ #: (record-of-record; append-only; must survive). Mirrors the
147
+ #: ``vbrief/.audit/`` location the Slice-3 clearance log already uses.
148
+ AUDIT_DIR_REL = "vbrief/.audit"
149
+ AUTHORITY_LOG_NAME = "authority-events.jsonl"
150
+
151
+
152
+ # ---------------------------------------------------------------------------
153
+ # git working-tree probe
154
+ # ---------------------------------------------------------------------------
155
+
156
+
157
+ def _git_porcelain(project_root: Path) -> str | None:
158
+ """Return ``git status --porcelain`` output, or None when undeterminable.
159
+
160
+ Returns None when git cannot be spawned (any ``OSError`` -- not on PATH,
161
+ no execute permission, cwd not a directory) or the directory is not a git
162
+ work tree (non-zero rc). The caller maps None to a config error (exit 2)
163
+ -- the gate fails closed rather than assuming a clean tree.
164
+
165
+ Per AGENTS.md ``## Safe subprocess capture (#1366)`` the capture forces
166
+ ``encoding="utf-8", errors="replace"`` so a commit message / untracked
167
+ filename carrying non-cp1252 bytes cannot crash the reader thread on a
168
+ Windows host.
169
+ """
170
+ try:
171
+ proc = subprocess.run(
172
+ ["git", "status", "--porcelain"],
173
+ cwd=str(project_root),
174
+ capture_output=True,
175
+ text=True,
176
+ encoding="utf-8",
177
+ errors="replace",
178
+ check=False,
179
+ )
180
+ except OSError:
181
+ # git not on PATH / no execute permission / cwd not a directory --
182
+ # fail closed (caller maps None to config error exit 2).
183
+ return None
184
+ if proc.returncode != 0:
185
+ return None
186
+ return proc.stdout
187
+
188
+
189
+ # ---------------------------------------------------------------------------
190
+ # vBRIEF lifecycle check (condition b) -- mirrors preflight_implementation
191
+ # ---------------------------------------------------------------------------
192
+
193
+
194
+ def _check_vbrief(vbrief_path: Path) -> tuple[bool, str]:
195
+ """Return ``(ok, reason)`` for the target story vBRIEF lifecycle gate.
196
+
197
+ ``ok`` is True only when the file exists, is a readable JSON object,
198
+ lives in ``vbrief/active/``, and carries ``plan.status == "running"``.
199
+ Every failure returns ``(False, <human reason>)``; never raises.
200
+ """
201
+ try:
202
+ path = Path(vbrief_path)
203
+ except TypeError as exc: # extremely defensive
204
+ return False, f"could not interpret vBRIEF path '{vbrief_path}': {exc}"
205
+
206
+ if not path.exists():
207
+ return False, f"target vBRIEF not found at {path}"
208
+ if not path.is_file():
209
+ return False, f"target vBRIEF path {path} is not a regular file"
210
+
211
+ try:
212
+ raw = path.read_text(encoding="utf-8")
213
+ except (OSError, UnicodeDecodeError) as exc:
214
+ return False, f"could not read target vBRIEF at {path}: {exc}"
215
+
216
+ try:
217
+ payload: Any = json.loads(raw)
218
+ except json.JSONDecodeError as exc:
219
+ return False, (f"target vBRIEF at {path} is not valid JSON: {exc.msg} (line {exc.lineno})")
220
+
221
+ if not isinstance(payload, dict):
222
+ return False, f"target vBRIEF at {path} top-level value is not a JSON object"
223
+
224
+ folder = path.parent.name
225
+ if folder != ACTIVE_FOLDER:
226
+ return False, (
227
+ f"target vBRIEF is in {folder}/ -- only vbrief/active/ is eligible "
228
+ f"for a story start (activate it via `task scope:activate -- {path}`)"
229
+ )
230
+
231
+ plan = payload.get("plan")
232
+ if not isinstance(plan, dict):
233
+ return False, f"target vBRIEF at {path} lacks a `plan` object -- malformed"
234
+
235
+ status = plan.get("status")
236
+ if not isinstance(status, str) or not status:
237
+ return False, f"target vBRIEF at {path} lacks `plan.status` -- malformed"
238
+
239
+ if status != ELIGIBLE_STATUS:
240
+ return False, (
241
+ f"target vBRIEF plan.status is '{status}' -- only '{ELIGIBLE_STATUS}' "
242
+ f"is eligible for a story start"
243
+ )
244
+
245
+ return True, ""
246
+
247
+
248
+ # ---------------------------------------------------------------------------
249
+ # `## Allocation context` parser (condition c)
250
+ # ---------------------------------------------------------------------------
251
+
252
+
253
+ def _normalise_value(raw: str) -> str | None:
254
+ """Strip a parsed field value; return None for null-equivalent tokens.
255
+
256
+ Surrounding backticks / quotes are unwrapped so the contract's
257
+ ``dispatch_kind: `swarm-cohort``` doc form and the plain
258
+ ``dispatch_kind: swarm-cohort`` envelope form normalise identically.
259
+ A value that is empty or one of the ``_NULL_TOKENS`` becomes None.
260
+ """
261
+ value = raw.strip()
262
+ # Unwrap a single layer of surrounding backticks or quotes.
263
+ for pair in ("``", "`", '"', "'"):
264
+ if len(value) >= 2 * len(pair) and value.startswith(pair) and value.endswith(pair):
265
+ value = value[len(pair) : len(value) - len(pair)].strip()
266
+ break
267
+ if value.lower() in _NULL_TOKENS:
268
+ return None
269
+ return value
270
+
271
+
272
+ def parse_allocation_section(
273
+ text: str | None,
274
+ ) -> tuple[bool, dict[str, str | None]]:
275
+ """Parse the ``## Allocation context`` section from a dispatch envelope.
276
+
277
+ Returns ``(found, fields)``:
278
+
279
+ - ``found`` -- True iff a ``## Allocation context`` heading is present.
280
+ When False the caller takes the solo path (the #1371 carve-out
281
+ fallback for pre-#1378 / solo-interactive dispatches).
282
+ - ``fields`` -- a dict mapping each ``- key: value`` bullet found under
283
+ the heading (until the next ``#``-prefixed heading or EOF) to its
284
+ normalised value (None when the value is null-equivalent). A key that
285
+ did not appear at all is simply absent from the dict; the caller
286
+ distinguishes "absent key" from "present-but-null" only where the
287
+ contract requires it (both collapse to None via ``dict.get``).
288
+
289
+ Pure -- no I/O. Never raises.
290
+ """
291
+ if text is None:
292
+ return False, {}
293
+ lines = text.splitlines()
294
+ heading_idx = None
295
+ for idx, line in enumerate(lines):
296
+ if line.strip() == ALLOCATION_HEADING:
297
+ heading_idx = idx
298
+ break
299
+ if heading_idx is None:
300
+ return False, {}
301
+
302
+ fields: dict[str, str | None] = {}
303
+ for line in lines[heading_idx + 1 :]:
304
+ stripped = line.strip()
305
+ if stripped.startswith("#"):
306
+ # Next markdown heading ends the section.
307
+ break
308
+ if not stripped.startswith(("- ", "* ")):
309
+ continue
310
+ body = stripped[2:]
311
+ if ":" not in body:
312
+ continue
313
+ key, _, value = body.partition(":")
314
+ key = key.strip().strip("`").strip()
315
+ if key:
316
+ fields[key] = _normalise_value(value)
317
+ return True, fields
318
+
319
+
320
+ # ---------------------------------------------------------------------------
321
+ # gate-clearance integration (#1419 Slice 7)
322
+ # ---------------------------------------------------------------------------
323
+
324
+
325
+ def parse_gate_clearances(
326
+ fields: dict[str, str | None],
327
+ ) -> tuple[list[dict[str, Any]] | None, str | None]:
328
+ """Parse the inline-JSON ``gate_clearances`` bullet from a parsed section.
329
+
330
+ The dispatch envelope carries gate clearances as a single
331
+ ``- gate_clearances: [ {...}, {...} ]`` bullet whose value is a JSON array
332
+ (this keeps the existing flat ``- key: value`` parser unchanged -- the
333
+ value-after-first-colon survives the JSON object colons). Returns
334
+ ``(clearances, warning)``:
335
+
336
+ - ``clearances`` is None when the bullet is ABSENT -- the
337
+ backward-compatible "no gate-clearance section" path (today's behavior in
338
+ the advisory default posture). It is a list of clearance objects when the
339
+ bullet holds a JSON array, or ``[]`` when the bullet is present-but-null
340
+ / malformed / not a list (FAIL-SAFE: a malformed clearance array clears
341
+ nothing, so an enforced block gate still fires -- omitting clearances can
342
+ never silently bypass enforcement).
343
+ - ``warning`` is a human-readable note when the bullet was present but could
344
+ not be parsed, else None.
345
+
346
+ Pure -- no I/O. Never raises.
347
+ """
348
+ if GATE_CLEARANCES_FIELD not in fields:
349
+ return None, None
350
+ raw = fields.get(GATE_CLEARANCES_FIELD)
351
+ if raw is None:
352
+ # Present-but-null -> an explicit empty clearance set.
353
+ return [], None
354
+ try:
355
+ loaded = json.loads(raw)
356
+ except (json.JSONDecodeError, TypeError) as exc:
357
+ return [], f"gate_clearances bullet is not valid JSON ({exc}); treated as empty."
358
+ if not isinstance(loaded, list):
359
+ return [], "gate_clearances bullet is not a JSON array; treated as empty."
360
+ return [entry for entry in loaded if isinstance(entry, dict)], None
361
+
362
+
363
+ def _read_file_scope(vbrief_path: Path) -> tuple[str, ...]:
364
+ """Return ``plan.metadata.swarm.file_scope`` from the target vBRIEF.
365
+
366
+ Best-effort + non-raising: any read / parse / shape error yields an empty
367
+ tuple, which makes the gate layer a no-op for that story (no file_scope ->
368
+ no candidate paths -> no path-glob gate can match). The lifecycle gate
369
+ (:func:`_check_vbrief`) already validated readability; this re-read keeps
370
+ the helper self-contained and side-effect-free.
371
+ """
372
+ try:
373
+ payload = json.loads(Path(vbrief_path).read_text(encoding="utf-8"))
374
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError, TypeError):
375
+ return ()
376
+ if not isinstance(payload, dict):
377
+ return ()
378
+ plan = payload.get("plan")
379
+ if not isinstance(plan, dict):
380
+ return ()
381
+ metadata = plan.get("metadata")
382
+ if not isinstance(metadata, dict):
383
+ return ()
384
+ swarm = metadata.get("swarm")
385
+ if not isinstance(swarm, dict):
386
+ return ()
387
+ scope = swarm.get("file_scope")
388
+ if not isinstance(scope, list):
389
+ return ()
390
+ return tuple(p for p in scope if isinstance(p, str) and p)
391
+
392
+
393
+ def evaluate_gate_clearances(
394
+ project_root: Path,
395
+ vbrief_path: Path,
396
+ *,
397
+ posture: str,
398
+ clearances: list[dict[str, Any]] | None,
399
+ now: datetime | None = None,
400
+ ) -> Any | None:
401
+ """Evaluate the target story's file_scope against the judgment gates.
402
+
403
+ Imports the Slice-3 engine (``verify_judgment_gates.build_report`` /
404
+ ``Candidate``) -- this module never re-implements the gate logic. Returns
405
+ the ``JudgmentGateReport`` (so the caller can inspect ``blocking`` /
406
+ ``block_tier_requirements``), or None when the engine is unavailable or the
407
+ story declares no file_scope (nothing to evaluate).
408
+
409
+ The clearances supplied from the ``## Allocation context`` are merged with
410
+ any already recorded in the durable clearance audit log, so a story cleared
411
+ out-of-band (``verify_judgment_gates.py clear``) is honored too.
412
+ """
413
+ if _gates is None:
414
+ return None
415
+ file_scope = _read_file_scope(vbrief_path)
416
+ if not file_scope:
417
+ return None
418
+ records = list(clearances or [])
419
+ records.extend(_gates.read_clearances(project_root))
420
+ return _gates.build_report(
421
+ project_root,
422
+ _gates.Candidate(paths=file_scope),
423
+ posture=posture,
424
+ clearances=records,
425
+ now=now,
426
+ )
427
+
428
+
429
+ def _gate_surface_note(report: Any) -> str:
430
+ """Render a one-line-per-gate surface of the matched block-tier gates."""
431
+ lines: list[str] = []
432
+ for outcome in report.block_tier_requirements:
433
+ if outcome.cleared:
434
+ status = "cleared"
435
+ elif getattr(outcome, "stale_clearance", None) is not None:
436
+ status = "STALE-CLEARANCE re-triggered"
437
+ else:
438
+ status = "uncleared"
439
+ lines.append(f" - [{outcome.tier}] {outcome.gate_id}: {status}")
440
+ if not lines:
441
+ return "judgment gates: no block-tier gate matched the story file_scope."
442
+ return "judgment gates (block-tier):\n" + "\n".join(lines)
443
+
444
+
445
+ def _apply_gate_layer(
446
+ message: str,
447
+ vbrief_path: Path,
448
+ *,
449
+ project_root: Path | None,
450
+ gate_posture: str,
451
+ gate_clearances: list[dict[str, Any]] | None,
452
+ now: datetime | None,
453
+ ) -> tuple[int, str]:
454
+ """Layer the judgment-gate clearance check onto a READY (exit-0) result.
455
+
456
+ Runs ONLY when a project root is available AND either the posture is
457
+ ``enforce`` (always check -- omitting clearances cannot bypass it) OR a
458
+ ``gate_clearances`` bullet was present (advisory surfacing). When it does
459
+ not run, the original ready ``(0, message)`` is returned unchanged --
460
+ this is the backward-compatible "absent gate_clearances section == today's
461
+ behavior" path. In ``advise`` posture the exit code is NEVER changed; in
462
+ ``enforce`` posture an uncleared mechanical block-tier gate flips the
463
+ result to exit 1 (fail closed).
464
+ """
465
+ should_run = project_root is not None and (
466
+ gate_posture == GATE_ENFORCE or gate_clearances is not None
467
+ )
468
+ if not should_run:
469
+ return 0, message
470
+ report = evaluate_gate_clearances(
471
+ project_root, # type: ignore[arg-type]
472
+ vbrief_path,
473
+ posture=gate_posture,
474
+ clearances=gate_clearances,
475
+ now=now,
476
+ )
477
+ if report is None:
478
+ return 0, message
479
+ note = _gate_surface_note(report)
480
+ if gate_posture == GATE_ENFORCE and report.blocking:
481
+ ids = ", ".join(o.gate_id for o in report.blocking)
482
+ return 1, (
483
+ message + "\n" + note + "\nBLOCKED: uncleared active block-tier "
484
+ f"gate(s): {ids}. Record a clearance in the `## Allocation context` "
485
+ "gate_clearances[] (or via `verify_judgment_gates.py clear`) before "
486
+ "dispatch (enforce posture)."
487
+ )
488
+ return 0, (message + "\n" + note)
489
+
490
+
491
+ def _utc_now_iso(now: datetime | None = None) -> str:
492
+ """Return an ISO-8601 ``...Z`` timestamp (mirrors the clearance-log format)."""
493
+ return (now or datetime.now(UTC)).strftime("%Y-%m-%dT%H:%M:%SZ")
494
+
495
+
496
+ def authority_log_path(
497
+ project_root: Path, *, log_name: str = AUTHORITY_LOG_NAME
498
+ ) -> Path:
499
+ """Resolve the durable authority-events audit log under *project_root*."""
500
+ return project_root / AUDIT_DIR_REL / log_name
501
+
502
+
503
+ def append_authority_event(
504
+ project_root: Path,
505
+ *,
506
+ event_type: str,
507
+ payload: dict[str, Any],
508
+ now: datetime | None = None,
509
+ log_name: str = AUTHORITY_LOG_NAME,
510
+ ) -> dict[str, Any]:
511
+ """Append an authority-bearing event to the durable audit log; return it.
512
+
513
+ Per RFC #1419 (Receipts & Audit), allocation approvals and gate clearances
514
+ are authority-bearing events appended to the durable, committed
515
+ ``vbrief/.audit/*.jsonl`` log (record-of-record; append-only; must
516
+ survive). The record carries a stable ``event_id``, an ISO-8601
517
+ ``timestamp``, the ``event_type``, and the caller-supplied ``payload``
518
+ fields. Shared with :mod:`swarm_launch` so both surfaces write the same
519
+ shape.
520
+ """
521
+ path = authority_log_path(project_root, log_name=log_name)
522
+ path.parent.mkdir(parents=True, exist_ok=True)
523
+ # Build with the payload first, then stamp the three canonical fields LAST
524
+ # so a payload key can never silently overwrite event_id / timestamp /
525
+ # event_type (the protected record-of-record identity).
526
+ entry: dict[str, Any] = dict(payload)
527
+ entry.update(
528
+ {
529
+ "event_id": str(uuid.uuid4()),
530
+ "timestamp": _utc_now_iso(now),
531
+ "event_type": event_type,
532
+ }
533
+ )
534
+ line = json.dumps(entry, sort_keys=True, ensure_ascii=False)
535
+ with open(path, "a", encoding="utf-8") as handle:
536
+ handle.write(line + "\n")
537
+ return entry
538
+
539
+
540
+ # ---------------------------------------------------------------------------
541
+ # core evaluator
542
+ # ---------------------------------------------------------------------------
543
+
544
+
545
+ def evaluate(
546
+ vbrief_path: Path,
547
+ *,
548
+ git_status: str | None,
549
+ allocation_context: str | None = None,
550
+ allow_dirty: bool = False,
551
+ parsed: tuple[bool, dict[str, str | None]] | None = None,
552
+ project_root: Path | None = None,
553
+ gate_posture: str = GATE_ADVISE,
554
+ gate_clearances: list[dict[str, Any]] | None = None,
555
+ now: datetime | None = None,
556
+ ) -> tuple[int, str]:
557
+ """Pure evaluator -- returns ``(exit_code, human_message)``.
558
+
559
+ Separated from :func:`main` so tests can drive every state without
560
+ shelling out to git or round-tripping argparse. ``git_status`` is the
561
+ raw ``git status --porcelain`` output (empty string == clean), or None
562
+ when it could not be determined. ``allocation_context`` is the raw
563
+ dispatch-envelope text (or None when no envelope was supplied).
564
+
565
+ ``parsed`` is an optional pre-parsed :func:`parse_allocation_section`
566
+ result; when provided it is used as-is so callers that already parsed the
567
+ envelope (e.g. :func:`main` building the ``--json`` payload) do not parse
568
+ it a second time. When None the section is parsed here.
569
+
570
+ The Slice-7 gate-clearance layer (#1419) is OPT-IN and backward
571
+ compatible: it runs only on a READY (exit-0) result, only when
572
+ ``project_root`` is supplied, and only when the posture is ``enforce`` OR a
573
+ ``gate_clearances`` list was provided (a present ``gate_clearances`` bullet).
574
+ When ``project_root`` is None (the historical pure-call shape used by the
575
+ bulk of the unit tests) the gate layer is skipped entirely -- today's
576
+ behavior. In ``advise`` posture the exit code is never changed; ``enforce``
577
+ fails closed (exit 1) on an uncleared mechanical block-tier gate.
578
+ """
579
+
580
+ def _ready(msg: str) -> tuple[int, str]:
581
+ return _apply_gate_layer(
582
+ msg,
583
+ vbrief_path,
584
+ project_root=project_root,
585
+ gate_posture=gate_posture,
586
+ gate_clearances=gate_clearances,
587
+ now=now,
588
+ )
589
+
590
+ # --- (a) working tree --------------------------------------------------
591
+ if git_status is None:
592
+ return 2, (
593
+ "config error: could not determine working-tree state -- is this a "
594
+ "git work tree and is git on PATH? (Gate 0 fails closed.)"
595
+ )
596
+ dirty = bool(git_status.strip())
597
+ if dirty and not allow_dirty:
598
+ return 1, (
599
+ "not ready: working tree is dirty. Commit, stash, or include the "
600
+ "existing work (re-run with --allow-dirty after operator approval) "
601
+ "before starting the story."
602
+ )
603
+ # Accurate tree-state phrase for the OK messages: a dirty-but-allowed tree
604
+ # must not be reported as "tree clean".
605
+ tree_note = "dirty tree allowed (--allow-dirty)" if dirty else "tree clean"
606
+
607
+ # --- (b) target vBRIEF lifecycle --------------------------------------
608
+ ok, reason = _check_vbrief(vbrief_path)
609
+ if not ok:
610
+ return 1, f"not ready: {reason}."
611
+
612
+ # --- (c) dispatch-envelope allocation context -------------------------
613
+ found, fields = parsed if parsed is not None else parse_allocation_section(allocation_context)
614
+ if not found:
615
+ return _ready(
616
+ f"OK: ready to start -- {tree_note}, vBRIEF active+running, no "
617
+ "`## Allocation context` section (solo path, #1371 carve-out)."
618
+ )
619
+
620
+ dispatch_kind = fields.get("dispatch_kind")
621
+ if "dispatch_kind" not in fields or dispatch_kind is None:
622
+ return 2, (
623
+ "config error: `## Allocation context` section is present but has no "
624
+ "`dispatch_kind` field -- cannot classify the dispatch (Story A schema "
625
+ "requires dispatch_kind: solo | swarm-cohort)."
626
+ )
627
+ if dispatch_kind not in VALID_DISPATCH_KINDS:
628
+ return 2, (
629
+ f"config error: unrecognised dispatch_kind '{dispatch_kind}' -- "
630
+ f"expected one of {sorted(VALID_DISPATCH_KINDS)}."
631
+ )
632
+
633
+ if dispatch_kind == SOLO_KIND:
634
+ return _ready(
635
+ f"OK: ready to start -- {tree_note}, vBRIEF active+running, dispatch_kind: solo."
636
+ )
637
+
638
+ # swarm-cohort -- the consent token must be complete (#1371 carve-out).
639
+ incomplete = [
640
+ name for name in ("allocation_plan_id", "batching_rationale") if fields.get(name) is None
641
+ ]
642
+ if incomplete:
643
+ return 1, (
644
+ "not ready: swarm-cohort dispatch has an incomplete consent token -- "
645
+ f"null or missing {', '.join(incomplete)}. A swarm-cohort start gate "
646
+ "requires a non-null allocation_plan_id AND batching_rationale "
647
+ "(#1371 carve-out)."
648
+ )
649
+ return _ready(
650
+ f"OK: ready to start -- {tree_note}, vBRIEF active+running, swarm-cohort "
651
+ "consent token satisfied (allocation_plan_id + batching_rationale present)."
652
+ )
653
+
654
+
655
+ # ---------------------------------------------------------------------------
656
+ # CLI plumbing
657
+ # ---------------------------------------------------------------------------
658
+
659
+
660
+ def _emit_json(
661
+ vbrief_path: Path,
662
+ code: int,
663
+ message: str,
664
+ *,
665
+ dispatch_kind: str | None,
666
+ ) -> str:
667
+ """Render the structured ``--json`` payload (schema pinned by tests)."""
668
+ payload = {
669
+ "ready": code == 0,
670
+ "exit_code": code,
671
+ "vbrief_path": str(vbrief_path),
672
+ "dispatch_kind": dispatch_kind,
673
+ "message": message,
674
+ }
675
+ return json.dumps(payload, sort_keys=True)
676
+
677
+
678
+ def _build_parser() -> argparse.ArgumentParser:
679
+ parser = argparse.ArgumentParser(
680
+ prog="preflight_story_start.py",
681
+ description=(
682
+ "Deterministic story-start Gate 0 (#1378 Story C). Inspects the "
683
+ "working tree, the target vBRIEF lifecycle, and the dispatch "
684
+ "envelope's `## Allocation context` consent token before an "
685
+ "implementation story starts. Three-state exit (0 ready / 1 not "
686
+ "ready / 2 config error). Mirrors scripts/preflight_branch.py "
687
+ "(#747) and scripts/preflight_implementation.py (#810)."
688
+ ),
689
+ )
690
+ parser.add_argument(
691
+ "--vbrief-path",
692
+ required=True,
693
+ help="Path to the target story vBRIEF JSON file (must be in vbrief/active/).",
694
+ )
695
+ parser.add_argument(
696
+ "--project-root",
697
+ default=".",
698
+ help="Project root for the git working-tree probe (default: cwd).",
699
+ )
700
+ parser.add_argument(
701
+ "--allocation-context",
702
+ default=None,
703
+ help=(
704
+ "Path to a file containing the dispatch envelope (or just its "
705
+ "`## Allocation context` section). When omitted, or when the file "
706
+ "contains no such section, the dispatch is treated as solo."
707
+ ),
708
+ )
709
+ parser.add_argument(
710
+ "--allow-dirty",
711
+ action="store_true",
712
+ help=(
713
+ "Permit a dirty working tree (the sanctioned 'include existing "
714
+ "work' / fresh-branch-start path; requires operator approval)."
715
+ ),
716
+ )
717
+ parser.add_argument(
718
+ "--json",
719
+ action="store_true",
720
+ dest="emit_json",
721
+ help=(
722
+ "Emit a structured JSON payload to stdout instead of the "
723
+ "human-readable message. Exit code is unchanged."
724
+ ),
725
+ )
726
+ parser.add_argument(
727
+ "--enforce",
728
+ action="store_true",
729
+ help=(
730
+ "Gate-clearance ENFORCE posture (#1419 Slice 7): fail closed (exit 1) "
731
+ "when the target story's file_scope trips a mechanical block-tier "
732
+ "judgment gate that has no recorded clearance. DEFAULT is advisory -- "
733
+ "an uncleared block gate is surfaced but the exit code is unchanged. "
734
+ "The framework's own `task verify:story-ready` never passes this."
735
+ ),
736
+ )
737
+ parser.add_argument(
738
+ "--record-approval",
739
+ action="store_true",
740
+ help=(
741
+ "On a READY (exit-0) result, append a `story:dispatch-approved` "
742
+ "authority-bearing event to the durable audit log "
743
+ "(vbrief/.audit/authority-events.jsonl). Off by default so a routine "
744
+ "story-ready probe stays side-effect-free."
745
+ ),
746
+ )
747
+ return parser
748
+
749
+
750
+ def main(argv: list[str] | None = None) -> int:
751
+ # Force UTF-8 stdout/stderr at entry. A git hook / Taskfile dispatch on
752
+ # Windows defaults these streams to cp1252 / cp437, neither of which can
753
+ # render the messages' punctuation; the reconfigure mirrors
754
+ # scripts/preflight_branch.py (#814). Guarded by hasattr because
755
+ # reconfigure only exists on TextIOWrapper streams.
756
+ if hasattr(sys.stdout, "reconfigure"):
757
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
758
+ if hasattr(sys.stderr, "reconfigure"):
759
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
760
+
761
+ parser = _build_parser()
762
+ args = parser.parse_args(argv)
763
+ vbrief_path = Path(args.vbrief_path)
764
+ project_root = Path(args.project_root).resolve()
765
+
766
+ # Read the dispatch envelope when supplied. A supplied-but-unreadable
767
+ # path is a config error -- the operator asked us to inspect a file we
768
+ # cannot open.
769
+ allocation_context: str | None = None
770
+ if args.allocation_context is not None:
771
+ envelope_path = Path(args.allocation_context)
772
+ try:
773
+ allocation_context = envelope_path.read_text(encoding="utf-8")
774
+ except (OSError, UnicodeDecodeError) as exc:
775
+ message = (
776
+ f"config error: could not read --allocation-context file {envelope_path}: {exc}."
777
+ )
778
+ if args.emit_json:
779
+ print(_emit_json(vbrief_path, 2, message, dispatch_kind=None))
780
+ else:
781
+ print(message, file=sys.stderr)
782
+ return 2
783
+
784
+ git_status = _git_porcelain(project_root)
785
+ # Parse the allocation section ONCE and thread it into evaluate() so the
786
+ # envelope is not parsed twice (evaluate + the --json observability line).
787
+ parsed = parse_allocation_section(allocation_context)
788
+ # Slice-7 gate clearances ride the allocation context as an inline-JSON
789
+ # bullet; absent bullet => None => the gate layer stays dormant in the
790
+ # advisory default (today's behavior).
791
+ gate_clearances, gc_warning = parse_gate_clearances(parsed[1])
792
+ gate_posture = GATE_ENFORCE if args.enforce else GATE_ADVISE
793
+ code, message = evaluate(
794
+ vbrief_path,
795
+ git_status=git_status,
796
+ allocation_context=allocation_context,
797
+ allow_dirty=args.allow_dirty,
798
+ parsed=parsed,
799
+ project_root=project_root,
800
+ gate_posture=gate_posture,
801
+ gate_clearances=gate_clearances,
802
+ )
803
+ if gc_warning:
804
+ message = f"{message}\n ! {gc_warning}"
805
+ dispatch_kind = parsed[1].get("dispatch_kind")
806
+
807
+ # Authority-bearing audit (opt-in): record the dispatch approval only when
808
+ # the story is READY and --record-approval was passed. Best-effort -- an
809
+ # audit write failure warns but never flips a ready story to not-ready.
810
+ if args.record_approval and code == 0:
811
+ try:
812
+ append_authority_event(
813
+ project_root,
814
+ event_type="story:dispatch-approved",
815
+ payload={
816
+ "vbrief_path": str(vbrief_path),
817
+ "dispatch_kind": dispatch_kind,
818
+ "allocation_plan_id": parsed[1].get("allocation_plan_id"),
819
+ "gate_clearances": gate_clearances or [],
820
+ },
821
+ )
822
+ except OSError as exc:
823
+ print(f"warning: could not append authority event: {exc}", file=sys.stderr)
824
+
825
+ if args.emit_json:
826
+ print(_emit_json(vbrief_path, code, message, dispatch_kind=dispatch_kind))
827
+ elif code == 0:
828
+ print(message)
829
+ else:
830
+ # Reject / config-error paths land on stderr so a calling skill can
831
+ # pipe stdout cleanly when chaining gates.
832
+ print(message, file=sys.stderr)
833
+
834
+ return code
835
+
836
+
837
+ if __name__ == "__main__":
838
+ sys.exit(main())