@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.
- 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,827 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""verify_judgment_gates.py -- risk-tiered judgment-gate engine (#1419 Slice 3).
|
|
3
|
+
|
|
4
|
+
Surfaced via the ADVISORY ``task verify:judgment-gates`` target. Evaluates a
|
|
5
|
+
candidate change (diff paths / labels / body) against the configured
|
|
6
|
+
``plan.policy.judgmentGates`` plus four DEFAULT-ON universal safety gates, and
|
|
7
|
+
reports which gates fired, which are cleared, and which carry a stale clearance.
|
|
8
|
+
|
|
9
|
+
Posture (advise vs enforce)
|
|
10
|
+
---------------------------
|
|
11
|
+
The engine supports a fail-closed exit, but the directive-side wiring is
|
|
12
|
+
ADVISORY. The default posture is ``advise``: ``evaluate`` ALWAYS exits 0, so it
|
|
13
|
+
is safe to run anywhere and is deliberately NOT wired into the ``task check``
|
|
14
|
+
aggregate -- a judgment-gate finding MUST NOT fail-closed and wedge the
|
|
15
|
+
framework's own master. The opt-in ``--enforce`` flag (or ``posture="enforce"``)
|
|
16
|
+
flips on the fail-closed behaviour for projects that have rolled out from
|
|
17
|
+
advise -> observe -> block.
|
|
18
|
+
|
|
19
|
+
Gate classes (fail-closed vs fail-open)
|
|
20
|
+
---------------------------------------
|
|
21
|
+
* ``mechanical`` -- the risky condition is mechanically detectable (a secrets
|
|
22
|
+
path in the diff, an infra label). On DETECTION without a valid clearance the
|
|
23
|
+
gate fails CLOSED: under ``enforce`` a fired mechanical block-tier gate exits
|
|
24
|
+
1. The four universal gates are mechanical / block-tier.
|
|
25
|
+
* ``declared`` -- the risky condition depends on a human declaration that the
|
|
26
|
+
framework cannot detect. On OMISSION (no clearance) the gate fails OPEN: it
|
|
27
|
+
emits an advisory requirement but never blocks. When a clearance IS recorded
|
|
28
|
+
the gate validates it (and re-triggers on scope creep).
|
|
29
|
+
|
|
30
|
+
Clearance binding
|
|
31
|
+
-----------------
|
|
32
|
+
A clearance binds to a ``cleared_scope`` fingerprint (a sha256 over the sorted
|
|
33
|
+
matched paths + labels). When the cleared scope later changes (scope creep adds
|
|
34
|
+
or removes a matched path) the recomputed fingerprint no longer matches, the
|
|
35
|
+
stale clearance is rejected, and the gate re-triggers. Clearances are recorded
|
|
36
|
+
to the durable audit log at ``vbrief/.audit/judgment-gate-clearances.jsonl``.
|
|
37
|
+
|
|
38
|
+
Exit codes (three-state, mirrors the other deft verify gates):
|
|
39
|
+
|
|
40
|
+
* ``0`` -- within targets, OR advisory posture (the only state reachable on the
|
|
41
|
+
framework's own advise-default tree).
|
|
42
|
+
* ``1`` -- ``enforce`` posture with at least one fired mechanical block-tier gate.
|
|
43
|
+
* ``2`` -- config error (``--project-root`` is not a directory).
|
|
44
|
+
|
|
45
|
+
Scope boundary (#1419): this engine does NOT integrate clearances into Gate 0 /
|
|
46
|
+
story-start / swarm:launch -- that is Slice 7. It owns the gate logic, the
|
|
47
|
+
universal gates, and the clearance audit log only.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
from __future__ import annotations
|
|
51
|
+
|
|
52
|
+
import argparse
|
|
53
|
+
import hashlib
|
|
54
|
+
import json
|
|
55
|
+
import sys
|
|
56
|
+
import uuid
|
|
57
|
+
from dataclasses import dataclass
|
|
58
|
+
from datetime import UTC, datetime
|
|
59
|
+
from pathlib import Path
|
|
60
|
+
from typing import Any
|
|
61
|
+
|
|
62
|
+
# Make sibling helpers importable both as __main__ and when imported by tests.
|
|
63
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
64
|
+
|
|
65
|
+
from _pathspec import match_any # noqa: E402
|
|
66
|
+
from _safe_subprocess import run_text # noqa: E402
|
|
67
|
+
from _stdio_utf8 import reconfigure_stdio # noqa: E402
|
|
68
|
+
from policy import ( # noqa: E402
|
|
69
|
+
JudgmentGate,
|
|
70
|
+
JudgmentGatesPolicy,
|
|
71
|
+
resolve_judgment_gates,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Reuse the triageAutoClassify match DSL (labels / body-text / state / age-days)
|
|
75
|
+
# verbatim -- the engine only adds the new `paths` glob predicate on top.
|
|
76
|
+
from triage_classify import _consumer_rule_matches # noqa: E402
|
|
77
|
+
|
|
78
|
+
reconfigure_stdio()
|
|
79
|
+
|
|
80
|
+
#: Durable, operator-private clearance audit log location (#1419 Slice 3).
|
|
81
|
+
AUDIT_DIR_REL = "vbrief/.audit"
|
|
82
|
+
CLEARANCE_LOG_NAME = "judgment-gate-clearances.jsonl"
|
|
83
|
+
|
|
84
|
+
UNIVERSAL_SOURCE = "universal"
|
|
85
|
+
CONSUMER_SOURCE = "consumer"
|
|
86
|
+
|
|
87
|
+
#: Four DEFAULT-ON universal safety gates. All are ``mechanical`` /
|
|
88
|
+
#: ``block``-tier: a diff that touches any of these surfaces fails closed under
|
|
89
|
+
#: an ``enforce`` posture unless a clearance is recorded. Each can be switched
|
|
90
|
+
#: off per-project via ``plan.policy.judgmentGatesDisabled`` (by id).
|
|
91
|
+
UNIVERSAL_GATES: tuple[dict[str, Any], ...] = (
|
|
92
|
+
{
|
|
93
|
+
"id": "secrets-and-credentials",
|
|
94
|
+
"class": "mechanical",
|
|
95
|
+
"tier": "block",
|
|
96
|
+
"requiredHumanReviewers": 1,
|
|
97
|
+
"reason": "Touches secrets / credential material; requires human sign-off.",
|
|
98
|
+
"source": UNIVERSAL_SOURCE,
|
|
99
|
+
"match": {
|
|
100
|
+
"paths": {
|
|
101
|
+
"any-of": [
|
|
102
|
+
"secrets/**",
|
|
103
|
+
"**/secrets/**",
|
|
104
|
+
".env",
|
|
105
|
+
"**/.env",
|
|
106
|
+
"**/*.env",
|
|
107
|
+
"**/*.pem",
|
|
108
|
+
"**/*.key",
|
|
109
|
+
"**/*.p12",
|
|
110
|
+
"**/*.pfx",
|
|
111
|
+
"**/id_rsa",
|
|
112
|
+
"**/id_rsa.*",
|
|
113
|
+
"**/*.keystore",
|
|
114
|
+
"**/credentials",
|
|
115
|
+
"**/credentials.*",
|
|
116
|
+
"**/.npmrc",
|
|
117
|
+
"**/.pypirc",
|
|
118
|
+
]
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
"id": "production-infrastructure",
|
|
124
|
+
"class": "mechanical",
|
|
125
|
+
"tier": "block",
|
|
126
|
+
"requiredHumanReviewers": 1,
|
|
127
|
+
"reason": "Touches production infrastructure / deploy config; requires sign-off.",
|
|
128
|
+
"source": UNIVERSAL_SOURCE,
|
|
129
|
+
"match": {
|
|
130
|
+
"paths": {
|
|
131
|
+
"any-of": [
|
|
132
|
+
"**/*.tf",
|
|
133
|
+
"**/*.tfvars",
|
|
134
|
+
"**/*.tfstate",
|
|
135
|
+
"terraform/**",
|
|
136
|
+
"infra/**",
|
|
137
|
+
"**/Dockerfile",
|
|
138
|
+
"**/Dockerfile.*",
|
|
139
|
+
"**/docker-compose*.yml",
|
|
140
|
+
"**/docker-compose*.yaml",
|
|
141
|
+
"**/k8s/**",
|
|
142
|
+
"**/kubernetes/**",
|
|
143
|
+
"**/helm/**",
|
|
144
|
+
"**/.github/workflows/**",
|
|
145
|
+
]
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
"id": "agents-md-and-skills",
|
|
151
|
+
"class": "mechanical",
|
|
152
|
+
"tier": "block",
|
|
153
|
+
"requiredHumanReviewers": 1,
|
|
154
|
+
"reason": "Touches agent directives (AGENTS.md / skills); requires sign-off.",
|
|
155
|
+
"source": UNIVERSAL_SOURCE,
|
|
156
|
+
"match": {
|
|
157
|
+
"paths": {
|
|
158
|
+
"any-of": [
|
|
159
|
+
"AGENTS.md",
|
|
160
|
+
"**/AGENTS.md",
|
|
161
|
+
"skills/**",
|
|
162
|
+
"**/skills/**",
|
|
163
|
+
"templates/agents-entry.md",
|
|
164
|
+
]
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
"id": "installer-and-bootstrap",
|
|
170
|
+
"class": "mechanical",
|
|
171
|
+
"tier": "block",
|
|
172
|
+
"requiredHumanReviewers": 1,
|
|
173
|
+
"reason": "Touches installer / bootstrap surface; requires sign-off.",
|
|
174
|
+
"source": UNIVERSAL_SOURCE,
|
|
175
|
+
"match": {
|
|
176
|
+
"paths": {
|
|
177
|
+
"any-of": [
|
|
178
|
+
"install.ps1",
|
|
179
|
+
"install.sh",
|
|
180
|
+
"**/install.ps1",
|
|
181
|
+
"**/install.sh",
|
|
182
|
+
"installer/**",
|
|
183
|
+
"**/installer/**",
|
|
184
|
+
"scripts/setup*.py",
|
|
185
|
+
"**/deft-install*",
|
|
186
|
+
"bootstrap",
|
|
187
|
+
"**/bootstrap",
|
|
188
|
+
"**/bootstrap.*",
|
|
189
|
+
]
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@dataclass(frozen=True)
|
|
197
|
+
class Candidate:
|
|
198
|
+
"""The change being evaluated: changed paths, labels, body, state, age."""
|
|
199
|
+
|
|
200
|
+
paths: tuple[str, ...] = ()
|
|
201
|
+
labels: tuple[str, ...] = ()
|
|
202
|
+
body: str = ""
|
|
203
|
+
state: str = "open"
|
|
204
|
+
updated_at: str | None = None
|
|
205
|
+
|
|
206
|
+
def as_issue(self) -> dict[str, Any]:
|
|
207
|
+
"""Shape the candidate as a GitHub-issue-ish dict for the triage DSL."""
|
|
208
|
+
return {
|
|
209
|
+
"labels": list(self.labels),
|
|
210
|
+
"body": self.body,
|
|
211
|
+
"state": self.state,
|
|
212
|
+
"updated_at": self.updated_at,
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@dataclass(frozen=True)
|
|
217
|
+
class GateOutcome:
|
|
218
|
+
"""The result of evaluating one matched gate against a candidate."""
|
|
219
|
+
|
|
220
|
+
gate_id: str
|
|
221
|
+
gate_class: str
|
|
222
|
+
tier: str
|
|
223
|
+
reason: str
|
|
224
|
+
required_human_reviewers: int
|
|
225
|
+
source: str # 'universal' | 'consumer'
|
|
226
|
+
matched_paths: tuple[str, ...]
|
|
227
|
+
matched_labels: tuple[str, ...]
|
|
228
|
+
cleared_scope: str
|
|
229
|
+
clearance: dict[str, Any] | None
|
|
230
|
+
stale_clearance: dict[str, Any] | None
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def cleared(self) -> bool:
|
|
234
|
+
"""True when a clearance bound to the current cleared_scope exists."""
|
|
235
|
+
return self.clearance is not None
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def fired(self) -> bool:
|
|
239
|
+
"""True when the gate matched but has no valid (fresh) clearance."""
|
|
240
|
+
return self.clearance is None
|
|
241
|
+
|
|
242
|
+
@property
|
|
243
|
+
def blocking(self) -> bool:
|
|
244
|
+
"""True when this fired gate is a fail-closed mechanical block gate."""
|
|
245
|
+
return self.fired and self.gate_class == "mechanical" and self.tier == "block"
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@dataclass(frozen=True)
|
|
249
|
+
class JudgmentGateReport:
|
|
250
|
+
"""Aggregate of every matched-gate outcome for a candidate."""
|
|
251
|
+
|
|
252
|
+
posture: str
|
|
253
|
+
outcomes: tuple[GateOutcome, ...]
|
|
254
|
+
policy_error: str | None = None
|
|
255
|
+
|
|
256
|
+
@property
|
|
257
|
+
def fired(self) -> tuple[GateOutcome, ...]:
|
|
258
|
+
return tuple(o for o in self.outcomes if o.fired)
|
|
259
|
+
|
|
260
|
+
@property
|
|
261
|
+
def blocking(self) -> tuple[GateOutcome, ...]:
|
|
262
|
+
return tuple(o for o in self.outcomes if o.blocking)
|
|
263
|
+
|
|
264
|
+
@property
|
|
265
|
+
def block_tier_requirements(self) -> tuple[GateOutcome, ...]:
|
|
266
|
+
"""Every matched block-tier gate (the a4 default-on universal surface)."""
|
|
267
|
+
return tuple(o for o in self.outcomes if o.tier == "block")
|
|
268
|
+
|
|
269
|
+
def outcome_for(self, gate_id: str) -> GateOutcome | None:
|
|
270
|
+
for outcome in self.outcomes:
|
|
271
|
+
if outcome.gate_id == gate_id:
|
|
272
|
+
return outcome
|
|
273
|
+
return None
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# ---------------------------------------------------------------------------
|
|
277
|
+
# Clearance audit log (vbrief/.audit/judgment-gate-clearances.jsonl)
|
|
278
|
+
# ---------------------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def clearance_log_path(project_root: Path) -> Path:
|
|
282
|
+
"""Resolve the durable clearance audit log path under *project_root*."""
|
|
283
|
+
return project_root / AUDIT_DIR_REL / CLEARANCE_LOG_NAME
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _utc_now_iso(now: datetime | None = None) -> str:
|
|
287
|
+
return (now or datetime.now(UTC)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def read_clearances(
|
|
291
|
+
project_root: Path, *, log_path: Path | None = None
|
|
292
|
+
) -> list[dict[str, Any]]:
|
|
293
|
+
"""Return every well-formed clearance record in insertion order.
|
|
294
|
+
|
|
295
|
+
Tolerant of malformed lines (skips them) so a torn write never crashes a
|
|
296
|
+
gate evaluation.
|
|
297
|
+
"""
|
|
298
|
+
path = log_path or clearance_log_path(project_root)
|
|
299
|
+
if not path.is_file():
|
|
300
|
+
return []
|
|
301
|
+
out: list[dict[str, Any]] = []
|
|
302
|
+
for raw in path.read_text(encoding="utf-8").splitlines():
|
|
303
|
+
stripped = raw.strip()
|
|
304
|
+
if not stripped:
|
|
305
|
+
continue
|
|
306
|
+
try:
|
|
307
|
+
obj = json.loads(stripped)
|
|
308
|
+
except json.JSONDecodeError:
|
|
309
|
+
continue
|
|
310
|
+
if isinstance(obj, dict):
|
|
311
|
+
out.append(obj)
|
|
312
|
+
return out
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def record_clearance(
|
|
316
|
+
project_root: Path,
|
|
317
|
+
*,
|
|
318
|
+
gate_id: str,
|
|
319
|
+
cleared_scope: str,
|
|
320
|
+
reviewers: list[str] | None = None,
|
|
321
|
+
actor: str = "operator",
|
|
322
|
+
reason: str = "",
|
|
323
|
+
now: datetime | None = None,
|
|
324
|
+
log_path: Path | None = None,
|
|
325
|
+
) -> dict[str, Any]:
|
|
326
|
+
"""Append a clearance record to the durable audit log and return it.
|
|
327
|
+
|
|
328
|
+
The record binds the sign-off to *cleared_scope* so that a later scope
|
|
329
|
+
change rejects the now-stale clearance (the gate re-triggers).
|
|
330
|
+
"""
|
|
331
|
+
path = log_path or clearance_log_path(project_root)
|
|
332
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
333
|
+
entry: dict[str, Any] = {
|
|
334
|
+
"clearance_id": str(uuid.uuid4()),
|
|
335
|
+
"timestamp": _utc_now_iso(now),
|
|
336
|
+
"gate_id": gate_id,
|
|
337
|
+
"cleared_scope": cleared_scope,
|
|
338
|
+
"reviewers": list(reviewers or []),
|
|
339
|
+
"actor": actor,
|
|
340
|
+
"reason": reason,
|
|
341
|
+
}
|
|
342
|
+
line = json.dumps(entry, sort_keys=True, ensure_ascii=False)
|
|
343
|
+
with open(path, "a", encoding="utf-8") as handle:
|
|
344
|
+
handle.write(line + "\n")
|
|
345
|
+
return entry
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def fingerprint_scope(evidence: dict[str, Any]) -> str:
|
|
349
|
+
"""Return a stable sha256 fingerprint of the cleared-scope *evidence*.
|
|
350
|
+
|
|
351
|
+
*evidence* is the per-predicate matched evidence dict produced by
|
|
352
|
+
:func:`match_evidence` -- it carries a key for EVERY predicate the gate
|
|
353
|
+
matched on (``paths`` / ``labels`` / ``body-text`` / ``state`` /
|
|
354
|
+
``age-days``), not just paths + labels. Binding the clearance to the full
|
|
355
|
+
evidence means a change to ANY matched dimension (a new matched path, an
|
|
356
|
+
edited body, a state flip, the issue ageing) yields a different
|
|
357
|
+
fingerprint, so the stale clearance is rejected and the gate re-triggers.
|
|
358
|
+
"""
|
|
359
|
+
payload = json.dumps(evidence, sort_keys=True, ensure_ascii=False)
|
|
360
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _lookup_clearance(
|
|
364
|
+
clearances: list[dict[str, Any]], gate_id: str, scope: str
|
|
365
|
+
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
|
|
366
|
+
"""Return ``(valid, stale)`` clearance records for *gate_id* / *scope*.
|
|
367
|
+
|
|
368
|
+
``valid`` is the most recent clearance bound to the current *scope*;
|
|
369
|
+
``stale`` is the most recent clearance for the gate bound to a DIFFERENT
|
|
370
|
+
scope (the scope-creep / sign-off-then-changed case). Both default to None.
|
|
371
|
+
"""
|
|
372
|
+
valid: dict[str, Any] | None = None
|
|
373
|
+
stale: dict[str, Any] | None = None
|
|
374
|
+
for entry in clearances:
|
|
375
|
+
if entry.get("gate_id") != gate_id:
|
|
376
|
+
continue
|
|
377
|
+
if entry.get("cleared_scope") == scope:
|
|
378
|
+
valid = entry
|
|
379
|
+
else:
|
|
380
|
+
stale = entry
|
|
381
|
+
return valid, stale
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# ---------------------------------------------------------------------------
|
|
385
|
+
# Gate matching + report
|
|
386
|
+
# ---------------------------------------------------------------------------
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _consumer_gate_to_dict(gate: JudgmentGate) -> dict[str, Any]:
|
|
390
|
+
return {
|
|
391
|
+
"id": gate.gate_id,
|
|
392
|
+
"class": gate.gate_class,
|
|
393
|
+
"tier": gate.tier,
|
|
394
|
+
"reason": gate.reason,
|
|
395
|
+
"requiredHumanReviewers": gate.required_human_reviewers,
|
|
396
|
+
"match": gate.match,
|
|
397
|
+
"source": CONSUMER_SOURCE,
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def effective_gates(
|
|
402
|
+
project_root: Path, *, policy: JudgmentGatesPolicy | None = None
|
|
403
|
+
) -> list[dict[str, Any]]:
|
|
404
|
+
"""Return the universal + consumer gates with disabled ids removed."""
|
|
405
|
+
resolved = policy if policy is not None else resolve_judgment_gates(project_root)
|
|
406
|
+
disabled = set(resolved.disabled)
|
|
407
|
+
gates = [g for g in UNIVERSAL_GATES if g["id"] not in disabled]
|
|
408
|
+
gates.extend(
|
|
409
|
+
_consumer_gate_to_dict(g) for g in resolved.gates if g.gate_id not in disabled
|
|
410
|
+
)
|
|
411
|
+
return gates
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _matched_labels(match: dict[str, Any], candidate: Candidate) -> tuple[str, ...]:
|
|
415
|
+
labels_pred = match.get("labels")
|
|
416
|
+
if not isinstance(labels_pred, dict):
|
|
417
|
+
return ()
|
|
418
|
+
names = set(candidate.labels)
|
|
419
|
+
selected = labels_pred.get("any-of")
|
|
420
|
+
if selected is None:
|
|
421
|
+
selected = labels_pred.get("all-of")
|
|
422
|
+
if not isinstance(selected, list):
|
|
423
|
+
return ()
|
|
424
|
+
return tuple(sorted(label for label in selected if label in names))
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
#: Triage-DSL predicate keys handled by ``triage_classify._consumer_rule_matches``
|
|
428
|
+
#: (the ``paths`` glob predicate is owned by this engine, not the triage DSL).
|
|
429
|
+
_TRIAGE_PREDICATES: frozenset[str] = frozenset(
|
|
430
|
+
{"labels", "body-text", "state", "age-days"}
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def match_evidence(
|
|
435
|
+
match: dict[str, Any], candidate: Candidate, matched_paths: tuple[str, ...]
|
|
436
|
+
) -> dict[str, Any]:
|
|
437
|
+
"""Build the per-predicate matched-evidence dict for a matched gate.
|
|
438
|
+
|
|
439
|
+
Only the predicates the gate actually declares contribute a key, and each
|
|
440
|
+
key carries the candidate dimension that determined the match: the sorted
|
|
441
|
+
matched paths, the sorted matched labels, the FULL candidate body (any
|
|
442
|
+
edit re-triggers a body-text gate), the candidate state, and the
|
|
443
|
+
candidate's age basis (``updated_at``). This is the input to
|
|
444
|
+
:func:`fingerprint_scope`.
|
|
445
|
+
"""
|
|
446
|
+
evidence: dict[str, Any] = {}
|
|
447
|
+
if "paths" in match:
|
|
448
|
+
evidence["paths"] = sorted(matched_paths)
|
|
449
|
+
if "labels" in match:
|
|
450
|
+
evidence["labels"] = list(_matched_labels(match, candidate))
|
|
451
|
+
if "body-text" in match:
|
|
452
|
+
evidence["body-text"] = candidate.body
|
|
453
|
+
if "state" in match:
|
|
454
|
+
evidence["state"] = candidate.state
|
|
455
|
+
if "age-days" in match:
|
|
456
|
+
evidence["age-days"] = candidate.updated_at or ""
|
|
457
|
+
return evidence
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _gate_match(
|
|
461
|
+
gate: dict[str, Any], candidate: Candidate, *, now: datetime
|
|
462
|
+
) -> tuple[bool, dict[str, Any], tuple[str, ...], tuple[str, ...]]:
|
|
463
|
+
"""Return ``(matched, evidence, matched_paths, matched_labels)`` for *gate*."""
|
|
464
|
+
match = gate.get("match")
|
|
465
|
+
if not isinstance(match, dict):
|
|
466
|
+
return False, {}, (), ()
|
|
467
|
+
matched_paths: tuple[str, ...] = ()
|
|
468
|
+
if "paths" in match:
|
|
469
|
+
paths_pred = match["paths"]
|
|
470
|
+
globs = paths_pred.get("any-of") if isinstance(paths_pred, dict) else None
|
|
471
|
+
hits = tuple(p for p in candidate.paths if match_any(globs, p))
|
|
472
|
+
if not hits:
|
|
473
|
+
return False, {}, (), ()
|
|
474
|
+
matched_paths = hits
|
|
475
|
+
# Only delegate to the triage DSL matcher when the gate actually declares a
|
|
476
|
+
# triage predicate. A path-only gate (e.g. all four universals) must NOT
|
|
477
|
+
# depend on `_consumer_rule_matches` returning True for an empty predicate
|
|
478
|
+
# set -- an upstream triage_classify change would otherwise silently stop
|
|
479
|
+
# every path-only gate from firing.
|
|
480
|
+
if (set(match) & _TRIAGE_PREDICATES) and not _consumer_rule_matches(
|
|
481
|
+
gate, candidate.as_issue(), now=now
|
|
482
|
+
):
|
|
483
|
+
return False, {}, (), ()
|
|
484
|
+
evidence = match_evidence(match, candidate, matched_paths)
|
|
485
|
+
return True, evidence, matched_paths, _matched_labels(match, candidate)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def build_report(
|
|
489
|
+
project_root: Path,
|
|
490
|
+
candidate: Candidate,
|
|
491
|
+
*,
|
|
492
|
+
posture: str = "advise",
|
|
493
|
+
clearances: list[dict[str, Any]] | None = None,
|
|
494
|
+
now: datetime | None = None,
|
|
495
|
+
) -> JudgmentGateReport:
|
|
496
|
+
"""Evaluate *candidate* against every effective gate; pure (no exit)."""
|
|
497
|
+
now_dt = now or datetime.now(UTC)
|
|
498
|
+
policy = resolve_judgment_gates(project_root)
|
|
499
|
+
records = clearances if clearances is not None else read_clearances(project_root)
|
|
500
|
+
outcomes: list[GateOutcome] = []
|
|
501
|
+
for gate in effective_gates(project_root, policy=policy):
|
|
502
|
+
matched, evidence, matched_paths, matched_labels = _gate_match(
|
|
503
|
+
gate, candidate, now=now_dt
|
|
504
|
+
)
|
|
505
|
+
if not matched:
|
|
506
|
+
continue
|
|
507
|
+
scope = fingerprint_scope(evidence)
|
|
508
|
+
valid, stale = _lookup_clearance(records, gate["id"], scope)
|
|
509
|
+
outcomes.append(
|
|
510
|
+
GateOutcome(
|
|
511
|
+
gate_id=gate["id"],
|
|
512
|
+
gate_class=gate["class"],
|
|
513
|
+
tier=gate["tier"],
|
|
514
|
+
reason=gate.get("reason", ""),
|
|
515
|
+
required_human_reviewers=int(gate.get("requiredHumanReviewers", 0)),
|
|
516
|
+
source=gate.get("source", CONSUMER_SOURCE),
|
|
517
|
+
matched_paths=matched_paths,
|
|
518
|
+
matched_labels=matched_labels,
|
|
519
|
+
cleared_scope=scope,
|
|
520
|
+
clearance=valid,
|
|
521
|
+
stale_clearance=stale,
|
|
522
|
+
)
|
|
523
|
+
)
|
|
524
|
+
return JudgmentGateReport(
|
|
525
|
+
posture=posture, outcomes=tuple(outcomes), policy_error=policy.error
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def render_report(report: JudgmentGateReport) -> str:
|
|
530
|
+
lines = [
|
|
531
|
+
f"judgment-gates ({len(report.outcomes)} matched; posture={report.posture}):"
|
|
532
|
+
]
|
|
533
|
+
if report.policy_error:
|
|
534
|
+
lines.append(f" ! policy self-healed to defaults: {report.policy_error}")
|
|
535
|
+
if not report.outcomes:
|
|
536
|
+
lines.append(" (no gates matched the candidate)")
|
|
537
|
+
return "\n".join(lines)
|
|
538
|
+
for outcome in report.outcomes:
|
|
539
|
+
if outcome.cleared:
|
|
540
|
+
status = "cleared"
|
|
541
|
+
elif outcome.stale_clearance is not None:
|
|
542
|
+
status = "STALE-CLEARANCE re-triggered"
|
|
543
|
+
else:
|
|
544
|
+
status = "fired"
|
|
545
|
+
evidence: list[str] = []
|
|
546
|
+
if outcome.matched_paths:
|
|
547
|
+
evidence.append(f"paths={list(outcome.matched_paths)}")
|
|
548
|
+
if outcome.matched_labels:
|
|
549
|
+
evidence.append(f"labels={list(outcome.matched_labels)}")
|
|
550
|
+
suffix = (" :: " + ", ".join(evidence)) if evidence else ""
|
|
551
|
+
lines.append(
|
|
552
|
+
f" - [{outcome.tier}/{outcome.gate_class}/{outcome.source}] "
|
|
553
|
+
f"{outcome.gate_id}: {status} ({outcome.reason}){suffix}"
|
|
554
|
+
)
|
|
555
|
+
return "\n".join(lines)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def evaluate(
|
|
559
|
+
project_root: Path,
|
|
560
|
+
candidate: Candidate | None = None,
|
|
561
|
+
*,
|
|
562
|
+
posture: str = "advise",
|
|
563
|
+
clearances: list[dict[str, Any]] | None = None,
|
|
564
|
+
now: datetime | None = None,
|
|
565
|
+
) -> tuple[int, str]:
|
|
566
|
+
"""Pure entry point: returns ``(exit_code, message)`` (three-state).
|
|
567
|
+
|
|
568
|
+
The ``advise`` default ALWAYS returns 0 -- the engine reports and defers.
|
|
569
|
+
Only ``enforce`` with a fired mechanical block-tier gate returns 1.
|
|
570
|
+
"""
|
|
571
|
+
if not project_root.is_dir():
|
|
572
|
+
return 2, (
|
|
573
|
+
f"verify_judgment_gates: --project-root is not a directory: {project_root}\n"
|
|
574
|
+
" Recovery: pass an existing project root."
|
|
575
|
+
)
|
|
576
|
+
cand = candidate or Candidate()
|
|
577
|
+
report = build_report(
|
|
578
|
+
project_root, cand, posture=posture, clearances=clearances, now=now
|
|
579
|
+
)
|
|
580
|
+
rendered = render_report(report)
|
|
581
|
+
|
|
582
|
+
if posture == "enforce" and report.blocking:
|
|
583
|
+
ids = ", ".join(o.gate_id for o in report.blocking)
|
|
584
|
+
return 1, (
|
|
585
|
+
f"{rendered}\n"
|
|
586
|
+
f"verify_judgment_gates: BLOCKED -- {len(report.blocking)} mechanical "
|
|
587
|
+
f"block-tier gate(s) fired without clearance: {ids}. Record a clearance "
|
|
588
|
+
"(`verify_judgment_gates.py clear --gate-id <id> ...`) or drop the change."
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
note = (
|
|
592
|
+
"advisory posture; deferring to ordering"
|
|
593
|
+
if posture != "enforce"
|
|
594
|
+
else "enforce posture; no blocking gates fired"
|
|
595
|
+
)
|
|
596
|
+
return 0, f"{rendered}\nverify_judgment_gates: OK -- {note}."
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
# ---------------------------------------------------------------------------
|
|
600
|
+
# CLI
|
|
601
|
+
# ---------------------------------------------------------------------------
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _diff_paths(project_root: str, base_ref: str) -> list[str]:
|
|
605
|
+
"""Return changed paths from ``git diff --name-only <base_ref>`` (best effort)."""
|
|
606
|
+
try:
|
|
607
|
+
result = run_text(
|
|
608
|
+
["git", "-C", str(project_root), "diff", "--name-only", base_ref]
|
|
609
|
+
)
|
|
610
|
+
except (OSError, ValueError):
|
|
611
|
+
return []
|
|
612
|
+
if result.returncode != 0:
|
|
613
|
+
return []
|
|
614
|
+
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _build_candidate_from_args(args: argparse.Namespace) -> Candidate:
|
|
618
|
+
paths: list[str] = list(args.path or [])
|
|
619
|
+
if args.base_ref:
|
|
620
|
+
paths.extend(_diff_paths(args.project_root, args.base_ref))
|
|
621
|
+
seen: set[str] = set()
|
|
622
|
+
unique: list[str] = []
|
|
623
|
+
for path in paths:
|
|
624
|
+
if path and path not in seen:
|
|
625
|
+
seen.add(path)
|
|
626
|
+
unique.append(path)
|
|
627
|
+
return Candidate(
|
|
628
|
+
paths=tuple(unique),
|
|
629
|
+
labels=tuple(args.label or []),
|
|
630
|
+
body=args.body or "",
|
|
631
|
+
state=args.state or "open",
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _outcome_to_json(outcome: GateOutcome) -> dict[str, Any]:
|
|
636
|
+
return {
|
|
637
|
+
"gate_id": outcome.gate_id,
|
|
638
|
+
"class": outcome.gate_class,
|
|
639
|
+
"tier": outcome.tier,
|
|
640
|
+
"source": outcome.source,
|
|
641
|
+
"reason": outcome.reason,
|
|
642
|
+
"matched_paths": list(outcome.matched_paths),
|
|
643
|
+
"matched_labels": list(outcome.matched_labels),
|
|
644
|
+
"cleared_scope": outcome.cleared_scope,
|
|
645
|
+
"cleared": outcome.cleared,
|
|
646
|
+
"fired": outcome.fired,
|
|
647
|
+
"blocking": outcome.blocking,
|
|
648
|
+
"stale_clearance": outcome.stale_clearance is not None,
|
|
649
|
+
"required_human_reviewers": outcome.required_human_reviewers,
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _eval_parser() -> argparse.ArgumentParser:
|
|
654
|
+
parser = argparse.ArgumentParser(
|
|
655
|
+
prog="verify_judgment_gates.py",
|
|
656
|
+
description=(
|
|
657
|
+
"Risk-tiered judgment-gate engine (#1419 Slice 3). Advisory by "
|
|
658
|
+
"default (always exits 0); pass --enforce to fail closed (exit 1) "
|
|
659
|
+
"when a mechanical block-tier gate fires without clearance. Exit 2 "
|
|
660
|
+
"on config error. NOT wired into `task check`."
|
|
661
|
+
),
|
|
662
|
+
)
|
|
663
|
+
parser.add_argument("--project-root", default=".", help="Project root (default: cwd).")
|
|
664
|
+
parser.add_argument(
|
|
665
|
+
"--enforce",
|
|
666
|
+
action="store_true",
|
|
667
|
+
help="Opt-in fail-closed posture (default is advisory; always exits 0).",
|
|
668
|
+
)
|
|
669
|
+
parser.add_argument(
|
|
670
|
+
"--base-ref",
|
|
671
|
+
default=None,
|
|
672
|
+
help="Git ref to diff against for candidate paths (git diff --name-only).",
|
|
673
|
+
)
|
|
674
|
+
parser.add_argument(
|
|
675
|
+
"--path", action="append", default=[], help="Candidate changed path (repeatable)."
|
|
676
|
+
)
|
|
677
|
+
parser.add_argument(
|
|
678
|
+
"--label", action="append", default=[], help="Candidate label (repeatable)."
|
|
679
|
+
)
|
|
680
|
+
parser.add_argument("--body", default="", help="Candidate body text.")
|
|
681
|
+
parser.add_argument(
|
|
682
|
+
"--state",
|
|
683
|
+
default="open",
|
|
684
|
+
choices=("open", "closed"),
|
|
685
|
+
help="Candidate state (default: open).",
|
|
686
|
+
)
|
|
687
|
+
parser.add_argument("--quiet", action="store_true", help="Suppress the OK message.")
|
|
688
|
+
parser.add_argument("--json", action="store_true", help="Emit a JSON report.")
|
|
689
|
+
return parser
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _eval_main(argv: list[str]) -> int:
|
|
693
|
+
args = _eval_parser().parse_args(argv)
|
|
694
|
+
project_root = Path(args.project_root).resolve()
|
|
695
|
+
posture = "enforce" if args.enforce else "advise"
|
|
696
|
+
candidate = _build_candidate_from_args(args)
|
|
697
|
+
|
|
698
|
+
if args.json:
|
|
699
|
+
if not project_root.is_dir():
|
|
700
|
+
print(
|
|
701
|
+
json.dumps({"exit": 2, "error": "project-root is not a directory"}),
|
|
702
|
+
file=sys.stderr,
|
|
703
|
+
)
|
|
704
|
+
return 2
|
|
705
|
+
report = build_report(project_root, candidate, posture=posture)
|
|
706
|
+
code = 1 if (posture == "enforce" and report.blocking) else 0
|
|
707
|
+
print(
|
|
708
|
+
json.dumps(
|
|
709
|
+
{
|
|
710
|
+
"exit": code,
|
|
711
|
+
"posture": report.posture,
|
|
712
|
+
"outcomes": [_outcome_to_json(o) for o in report.outcomes],
|
|
713
|
+
"policy_error": report.policy_error,
|
|
714
|
+
},
|
|
715
|
+
indent=2,
|
|
716
|
+
)
|
|
717
|
+
)
|
|
718
|
+
return code
|
|
719
|
+
|
|
720
|
+
code, message = evaluate(project_root, candidate, posture=posture)
|
|
721
|
+
if code == 0:
|
|
722
|
+
if not args.quiet:
|
|
723
|
+
print(message)
|
|
724
|
+
else:
|
|
725
|
+
print(message, file=sys.stderr)
|
|
726
|
+
return code
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _clear_parser() -> argparse.ArgumentParser:
|
|
730
|
+
parser = argparse.ArgumentParser(
|
|
731
|
+
prog="verify_judgment_gates.py clear",
|
|
732
|
+
description=(
|
|
733
|
+
"Record a judgment-gate clearance to the durable audit log "
|
|
734
|
+
"(vbrief/.audit/judgment-gate-clearances.jsonl). The clearance binds "
|
|
735
|
+
"to the cleared_scope fingerprint of the supplied evidence -- supply "
|
|
736
|
+
"exactly the dimensions the gate matches on (paths / labels / body / "
|
|
737
|
+
"state) so the fingerprint matches what the engine computes."
|
|
738
|
+
),
|
|
739
|
+
)
|
|
740
|
+
parser.add_argument("--project-root", default=".", help="Project root (default: cwd).")
|
|
741
|
+
parser.add_argument("--gate-id", required=True, help="Gate id being cleared.")
|
|
742
|
+
parser.add_argument(
|
|
743
|
+
"--path", action="append", default=[], help="A matched path in scope (repeatable)."
|
|
744
|
+
)
|
|
745
|
+
parser.add_argument(
|
|
746
|
+
"--label", action="append", default=[], help="A matched label in scope (repeatable)."
|
|
747
|
+
)
|
|
748
|
+
parser.add_argument(
|
|
749
|
+
"--body", default="", help="The candidate body (for a body-text gate)."
|
|
750
|
+
)
|
|
751
|
+
parser.add_argument(
|
|
752
|
+
"--state",
|
|
753
|
+
default=None,
|
|
754
|
+
choices=("open", "closed"),
|
|
755
|
+
help="The candidate state (for a state gate).",
|
|
756
|
+
)
|
|
757
|
+
parser.add_argument(
|
|
758
|
+
"--updated-at",
|
|
759
|
+
default=None,
|
|
760
|
+
help=(
|
|
761
|
+
"The candidate's updated_at timestamp (for an age-days gate); pass "
|
|
762
|
+
"an empty string to clear an age-days gate on an undated candidate."
|
|
763
|
+
),
|
|
764
|
+
)
|
|
765
|
+
parser.add_argument(
|
|
766
|
+
"--reviewer", action="append", default=[], help="Human reviewer (repeatable)."
|
|
767
|
+
)
|
|
768
|
+
parser.add_argument("--actor", default="operator", help="Who recorded the clearance.")
|
|
769
|
+
parser.add_argument("--reason", default="", help="Sign-off rationale.")
|
|
770
|
+
return parser
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _clear_evidence(args: argparse.Namespace) -> dict[str, Any]:
|
|
774
|
+
"""Build a cleared-scope evidence dict from the supplied clear args.
|
|
775
|
+
|
|
776
|
+
Mirrors :func:`match_evidence`: only the dimensions the operator supplies
|
|
777
|
+
contribute a key, so the fingerprint matches what the engine computes for
|
|
778
|
+
a gate that matches on exactly those dimensions.
|
|
779
|
+
"""
|
|
780
|
+
evidence: dict[str, Any] = {}
|
|
781
|
+
if args.path:
|
|
782
|
+
evidence["paths"] = sorted(args.path)
|
|
783
|
+
if args.label:
|
|
784
|
+
evidence["labels"] = sorted(args.label)
|
|
785
|
+
if args.body:
|
|
786
|
+
evidence["body-text"] = args.body
|
|
787
|
+
if args.state is not None:
|
|
788
|
+
evidence["state"] = args.state
|
|
789
|
+
if args.updated_at is not None:
|
|
790
|
+
evidence["age-days"] = args.updated_at
|
|
791
|
+
return evidence
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def _clear_main(argv: list[str]) -> int:
|
|
795
|
+
args = _clear_parser().parse_args(argv)
|
|
796
|
+
project_root = Path(args.project_root).resolve()
|
|
797
|
+
if not project_root.is_dir():
|
|
798
|
+
print(
|
|
799
|
+
f"verify_judgment_gates: --project-root is not a directory: {project_root}",
|
|
800
|
+
file=sys.stderr,
|
|
801
|
+
)
|
|
802
|
+
return 2
|
|
803
|
+
scope = fingerprint_scope(_clear_evidence(args))
|
|
804
|
+
entry = record_clearance(
|
|
805
|
+
project_root,
|
|
806
|
+
gate_id=args.gate_id,
|
|
807
|
+
cleared_scope=scope,
|
|
808
|
+
reviewers=args.reviewer,
|
|
809
|
+
actor=args.actor,
|
|
810
|
+
reason=args.reason,
|
|
811
|
+
)
|
|
812
|
+
print(
|
|
813
|
+
f"recorded clearance {entry['clearance_id']} for gate {args.gate_id!r} "
|
|
814
|
+
f"(cleared_scope={scope[:12]}...)"
|
|
815
|
+
)
|
|
816
|
+
return 0
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def main(argv: list[str] | None = None) -> int:
|
|
820
|
+
args_list = list(sys.argv[1:] if argv is None else argv)
|
|
821
|
+
if args_list and args_list[0] == "clear":
|
|
822
|
+
return _clear_main(args_list[1:])
|
|
823
|
+
return _eval_main(args_list)
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
if __name__ == "__main__":
|
|
827
|
+
sys.exit(main())
|