@deftai/directive-content 0.55.2 → 0.56.0

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