@deftai/directive-content 0.59.0 → 0.60.0

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