@deftai/directive-content 0.55.2 → 0.56.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.githooks/pre-commit +143 -0
- package/.githooks/pre-push +121 -0
- package/QUICK-START.md +2 -2
- package/Taskfile.yml +934 -0
- package/UPGRADING.md +47 -1
- package/events/README.md +3 -3
- package/package.json +5 -4
- package/scripts/_agents_md.py +494 -0
- package/scripts/_cache_fetch.py +635 -0
- package/scripts/_cache_quota.py +529 -0
- package/scripts/_cache_refresh.py +163 -0
- package/scripts/_cache_validate.py +209 -0
- package/scripts/_content_root.py +42 -0
- package/scripts/_doctor_state.py +277 -0
- package/scripts/_event_detect.py +305 -0
- package/scripts/_events.py +514 -0
- package/scripts/_lifecycle_hygiene.py +568 -0
- package/scripts/_pathspec.py +91 -0
- package/scripts/_policy_show_cli.py +266 -0
- package/scripts/_precutover.py +92 -0
- package/scripts/_project_context.py +224 -0
- package/scripts/_project_definition_io.py +164 -0
- package/scripts/_relocate_snapshot.py +209 -0
- package/scripts/_relocate_states.py +343 -0
- package/scripts/_resolve_preflight_path.py +152 -0
- package/scripts/_safe_subprocess.py +167 -0
- package/scripts/_session_start_hook.py +205 -0
- package/scripts/_sor_gate_diff.py +365 -0
- package/scripts/_stdio_utf8.py +59 -0
- package/scripts/_triage_bootstrap_gitignore.py +904 -0
- package/scripts/_triage_classify_cli.py +122 -0
- package/scripts/_triage_queue_cli.py +625 -0
- package/scripts/_triage_scope_cli.py +343 -0
- package/scripts/_triage_scope_drift_cli.py +121 -0
- package/scripts/_triage_scope_ignores.py +286 -0
- package/scripts/_triage_scope_milestone.py +432 -0
- package/scripts/_triage_scope_mutations.py +337 -0
- package/scripts/_triage_scope_renderers.py +207 -0
- package/scripts/_triage_smoketest_stages.py +674 -0
- package/scripts/_triage_subscribe_cli.py +140 -0
- package/scripts/_triage_welcome_cli.py +421 -0
- package/scripts/_vbrief_build.py +239 -0
- package/scripts/_vbrief_fidelity.py +479 -0
- package/scripts/_vbrief_legacy.py +589 -0
- package/scripts/_vbrief_reconciliation.py +883 -0
- package/scripts/_vbrief_routing.py +277 -0
- package/scripts/_vbrief_safety.py +778 -0
- package/scripts/_vbrief_sources.py +312 -0
- package/scripts/_vbrief_speckit.py +262 -0
- package/scripts/_vbrief_story_quality.py +353 -0
- package/scripts/_vbrief_validation.py +299 -0
- package/scripts/build_dist.py +412 -0
- package/scripts/cache.py +1078 -0
- package/scripts/cache_scanner.py +745 -0
- package/scripts/candidates_log.py +432 -0
- package/scripts/capacity_backfill.py +680 -0
- package/scripts/capacity_show.py +653 -0
- package/scripts/ci_local.py +689 -0
- package/scripts/code_structure_validate.py +765 -0
- package/scripts/codebase_default_extractor.py +495 -0
- package/scripts/codebase_map.py +304 -0
- package/scripts/codebase_map_fresh.py +104 -0
- package/scripts/codebase_projection_registry.py +94 -0
- package/scripts/codebase_provider.py +582 -0
- package/scripts/doctor.py +2257 -0
- package/scripts/framework_commands.py +505 -0
- package/scripts/gh_rest.py +882 -0
- package/scripts/github_auth_modes.py +437 -0
- package/scripts/github_body.py +292 -0
- package/scripts/ip_risk.py +531 -0
- package/scripts/issue_emit.py +670 -0
- package/scripts/issue_ingest.py +1064 -0
- package/scripts/migrate_preflight.py +418 -0
- package/scripts/migrate_vbrief.py +2677 -0
- package/scripts/monitor_pr.py +401 -0
- package/scripts/pack_migrate_lessons.py +336 -0
- package/scripts/pack_migrate_patterns.py +254 -0
- package/scripts/pack_migrate_rules.py +350 -0
- package/scripts/pack_migrate_skills.py +423 -0
- package/scripts/pack_migrate_strategies.py +311 -0
- package/scripts/pack_migrate_swarm_spec.py +250 -0
- package/scripts/pack_render.py +434 -0
- package/scripts/packs_slice.py +712 -0
- package/scripts/platform_capabilities.py +336 -0
- package/scripts/policy.py +2826 -0
- package/scripts/policy_set.py +324 -0
- package/scripts/pr_check_closing_keywords.py +524 -0
- package/scripts/pr_check_protected_issues.py +267 -0
- package/scripts/pr_merge_readiness.py +1004 -0
- package/scripts/pr_wait_mergeable.py +669 -0
- package/scripts/prd_render.py +159 -0
- package/scripts/preflight_architecture_sor.py +974 -0
- package/scripts/preflight_branch.py +289 -0
- package/scripts/preflight_cache.py +974 -0
- package/scripts/preflight_gh.py +721 -0
- package/scripts/preflight_implementation.py +272 -0
- package/scripts/preflight_story_start.py +838 -0
- package/scripts/preflight_wip_cap.py +149 -0
- package/scripts/probe_session.py +545 -0
- package/scripts/project_render.py +293 -0
- package/scripts/quarantine_ext.py +237 -0
- package/scripts/reconcile_issues.py +1442 -0
- package/scripts/refresh-path.ps1 +107 -0
- package/scripts/release.py +2030 -0
- package/scripts/release_e2e.py +1011 -0
- package/scripts/release_publish.py +486 -0
- package/scripts/release_rollback.py +980 -0
- package/scripts/relocate.py +1034 -0
- package/scripts/resolve_changelog_unreleased.py +667 -0
- package/scripts/resolve_version.py +490 -0
- package/scripts/resume_conditions.py +706 -0
- package/scripts/ritual_sentinel.py +609 -0
- package/scripts/roadmap_render.py +635 -0
- package/scripts/rule_ownership_lint.py +325 -0
- package/scripts/scm.py +591 -0
- package/scripts/scope_audit_log.py +387 -0
- package/scripts/scope_decompose.py +654 -0
- package/scripts/scope_demote.py +509 -0
- package/scripts/scope_lifecycle.py +1126 -0
- package/scripts/scope_undo.py +772 -0
- package/scripts/session_start.py +406 -0
- package/scripts/setup_ghx.py +339 -0
- package/scripts/setup_windows.ps1 +220 -0
- package/scripts/slice_audit.py +585 -0
- package/scripts/slice_record.py +530 -0
- package/scripts/slice_record_existing.py +692 -0
- package/scripts/slug_normalize.py +178 -0
- package/scripts/spec_render.py +477 -0
- package/scripts/spec_validate.py +238 -0
- package/scripts/subagent_monitor.py +658 -0
- package/scripts/swarm_complete_cohort.py +644 -0
- package/scripts/swarm_launch.py +1206 -0
- package/scripts/swarm_readiness.py +554 -0
- package/scripts/swarm_verify_review_clean.py +438 -0
- package/scripts/swarm_worktrees.py +497 -0
- package/scripts/toolchain-check.py +52 -0
- package/scripts/triage_actions.py +871 -0
- package/scripts/triage_bootstrap.py +1153 -0
- package/scripts/triage_bulk.py +630 -0
- package/scripts/triage_classify.py +932 -0
- package/scripts/triage_help.py +1685 -0
- package/scripts/triage_queue.py +1944 -0
- package/scripts/triage_reconcile.py +581 -0
- package/scripts/triage_refresh.py +643 -0
- package/scripts/triage_scope.py +999 -0
- package/scripts/triage_scope_drift.py +575 -0
- package/scripts/triage_smoketest.py +396 -0
- package/scripts/triage_subscribe.py +399 -0
- package/scripts/triage_summary.py +1011 -0
- package/scripts/triage_welcome.py +1178 -0
- package/scripts/ts_check_lane.py +86 -0
- package/scripts/validate-links.py +64 -0
- package/scripts/validate_strategy_output.py +212 -0
- package/scripts/vbrief_activate.py +228 -0
- package/scripts/vbrief_migrate_conformance.py +368 -0
- package/scripts/vbrief_reconcile_graph.py +306 -0
- package/scripts/vbrief_reconcile_labels.py +460 -0
- package/scripts/vbrief_reconcile_umbrellas.py +741 -0
- package/scripts/vbrief_validate.py +1195 -0
- package/scripts/verify-stubs.py +61 -0
- package/scripts/verify_capacity.py +160 -0
- package/scripts/verify_encoding.py +699 -0
- package/scripts/verify_hooks_installed.py +206 -0
- package/scripts/verify_investigation.py +360 -0
- package/scripts/verify_judgment_gates.py +827 -0
- package/scripts/verify_no_task_runtime.py +171 -0
- package/scripts/verify_scm_boundary.py +509 -0
- package/scripts/verify_session_ritual.py +389 -0
- package/scripts/verify_tools.py +426 -0
- package/scripts/verify_vbrief_conformance.py +478 -0
- package/tasks/architecture.yml +13 -0
- package/tasks/cache.yml +69 -0
- package/tasks/capacity.yml +38 -0
- package/tasks/change.yml +46 -0
- package/tasks/changelog.yml +24 -0
- package/tasks/ci.yml +49 -0
- package/tasks/codebase.yml +47 -0
- package/tasks/commit.yml +30 -0
- package/tasks/core.yml +126 -0
- package/tasks/deployments.yml +54 -0
- package/tasks/framework.yml +74 -0
- package/tasks/install.yml +60 -0
- package/tasks/issue.yml +50 -0
- package/tasks/migrate.yml +73 -0
- package/tasks/packs.yml +92 -0
- package/tasks/policy.yml +75 -0
- package/tasks/pr.yml +89 -0
- package/tasks/prd.yml +39 -0
- package/tasks/project.yml +27 -0
- package/tasks/reconcile.yml +32 -0
- package/tasks/relocate.yml +56 -0
- package/tasks/roadmap.yml +28 -0
- package/tasks/scm.yml +126 -0
- package/tasks/scope-undo.yml +36 -0
- package/tasks/scope.yml +141 -0
- package/tasks/session.yml +19 -0
- package/tasks/setup.yml +37 -0
- package/tasks/slice.yml +69 -0
- package/tasks/spec.yml +41 -0
- package/tasks/swarm.yml +85 -0
- package/tasks/toolchain.yml +13 -0
- package/tasks/triage-actions.yml +94 -0
- package/tasks/triage-bootstrap.yml +43 -0
- package/tasks/triage-bulk.yml +75 -0
- package/tasks/triage-classify.yml +30 -0
- package/tasks/triage-queue.yml +50 -0
- package/tasks/triage-reconcile.yml +29 -0
- package/tasks/triage-scope-drift.yml +29 -0
- package/tasks/triage-scope.yml +31 -0
- package/tasks/triage-smoketest.yml +33 -0
- package/tasks/triage-subscribe.yml +36 -0
- package/tasks/triage-summary.yml +29 -0
- package/tasks/triage-welcome.yml +32 -0
- package/tasks/ts.yml +328 -0
- package/tasks/vbrief.yml +206 -0
- package/tasks/verify.yml +292 -0
- 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())
|