@deftai/directive-content 0.55.1 → 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 (220) hide show
  1. package/.githooks/pre-commit +143 -0
  2. package/.githooks/pre-push +121 -0
  3. package/QUICK-START.md +13 -3
  4. package/Taskfile.yml +934 -0
  5. package/UPGRADING.md +82 -11
  6. package/events/README.md +3 -3
  7. package/package.json +5 -4
  8. package/packs/skills/skills-pack-0.1.json +22 -22
  9. package/scripts/_agents_md.py +494 -0
  10. package/scripts/_cache_fetch.py +635 -0
  11. package/scripts/_cache_quota.py +529 -0
  12. package/scripts/_cache_refresh.py +163 -0
  13. package/scripts/_cache_validate.py +209 -0
  14. package/scripts/_content_root.py +42 -0
  15. package/scripts/_doctor_state.py +277 -0
  16. package/scripts/_event_detect.py +305 -0
  17. package/scripts/_events.py +514 -0
  18. package/scripts/_lifecycle_hygiene.py +568 -0
  19. package/scripts/_pathspec.py +91 -0
  20. package/scripts/_policy_show_cli.py +266 -0
  21. package/scripts/_precutover.py +92 -0
  22. package/scripts/_project_context.py +224 -0
  23. package/scripts/_project_definition_io.py +164 -0
  24. package/scripts/_relocate_snapshot.py +209 -0
  25. package/scripts/_relocate_states.py +343 -0
  26. package/scripts/_resolve_preflight_path.py +152 -0
  27. package/scripts/_safe_subprocess.py +167 -0
  28. package/scripts/_session_start_hook.py +205 -0
  29. package/scripts/_sor_gate_diff.py +365 -0
  30. package/scripts/_stdio_utf8.py +59 -0
  31. package/scripts/_triage_bootstrap_gitignore.py +904 -0
  32. package/scripts/_triage_classify_cli.py +122 -0
  33. package/scripts/_triage_queue_cli.py +625 -0
  34. package/scripts/_triage_scope_cli.py +343 -0
  35. package/scripts/_triage_scope_drift_cli.py +121 -0
  36. package/scripts/_triage_scope_ignores.py +286 -0
  37. package/scripts/_triage_scope_milestone.py +432 -0
  38. package/scripts/_triage_scope_mutations.py +337 -0
  39. package/scripts/_triage_scope_renderers.py +207 -0
  40. package/scripts/_triage_smoketest_stages.py +674 -0
  41. package/scripts/_triage_subscribe_cli.py +140 -0
  42. package/scripts/_triage_welcome_cli.py +421 -0
  43. package/scripts/_vbrief_build.py +239 -0
  44. package/scripts/_vbrief_fidelity.py +479 -0
  45. package/scripts/_vbrief_legacy.py +589 -0
  46. package/scripts/_vbrief_reconciliation.py +883 -0
  47. package/scripts/_vbrief_routing.py +277 -0
  48. package/scripts/_vbrief_safety.py +778 -0
  49. package/scripts/_vbrief_sources.py +312 -0
  50. package/scripts/_vbrief_speckit.py +262 -0
  51. package/scripts/_vbrief_story_quality.py +353 -0
  52. package/scripts/_vbrief_validation.py +299 -0
  53. package/scripts/build_dist.py +412 -0
  54. package/scripts/cache.py +1078 -0
  55. package/scripts/cache_scanner.py +745 -0
  56. package/scripts/candidates_log.py +432 -0
  57. package/scripts/capacity_backfill.py +680 -0
  58. package/scripts/capacity_show.py +653 -0
  59. package/scripts/ci_local.py +689 -0
  60. package/scripts/code_structure_validate.py +765 -0
  61. package/scripts/codebase_default_extractor.py +495 -0
  62. package/scripts/codebase_map.py +304 -0
  63. package/scripts/codebase_map_fresh.py +104 -0
  64. package/scripts/codebase_projection_registry.py +94 -0
  65. package/scripts/codebase_provider.py +582 -0
  66. package/scripts/doctor.py +2257 -0
  67. package/scripts/framework_commands.py +505 -0
  68. package/scripts/gh_rest.py +882 -0
  69. package/scripts/github_auth_modes.py +437 -0
  70. package/scripts/github_body.py +292 -0
  71. package/scripts/ip_risk.py +531 -0
  72. package/scripts/issue_emit.py +670 -0
  73. package/scripts/issue_ingest.py +1064 -0
  74. package/scripts/migrate_preflight.py +418 -0
  75. package/scripts/migrate_vbrief.py +2677 -0
  76. package/scripts/monitor_pr.py +401 -0
  77. package/scripts/pack_migrate_lessons.py +336 -0
  78. package/scripts/pack_migrate_patterns.py +254 -0
  79. package/scripts/pack_migrate_rules.py +350 -0
  80. package/scripts/pack_migrate_skills.py +423 -0
  81. package/scripts/pack_migrate_strategies.py +311 -0
  82. package/scripts/pack_migrate_swarm_spec.py +250 -0
  83. package/scripts/pack_render.py +434 -0
  84. package/scripts/packs_slice.py +712 -0
  85. package/scripts/platform_capabilities.py +336 -0
  86. package/scripts/policy.py +2826 -0
  87. package/scripts/policy_set.py +324 -0
  88. package/scripts/pr_check_closing_keywords.py +524 -0
  89. package/scripts/pr_check_protected_issues.py +267 -0
  90. package/scripts/pr_merge_readiness.py +1004 -0
  91. package/scripts/pr_wait_mergeable.py +669 -0
  92. package/scripts/prd_render.py +159 -0
  93. package/scripts/preflight_architecture_sor.py +974 -0
  94. package/scripts/preflight_branch.py +289 -0
  95. package/scripts/preflight_cache.py +974 -0
  96. package/scripts/preflight_gh.py +721 -0
  97. package/scripts/preflight_implementation.py +272 -0
  98. package/scripts/preflight_story_start.py +838 -0
  99. package/scripts/preflight_wip_cap.py +149 -0
  100. package/scripts/probe_session.py +545 -0
  101. package/scripts/project_render.py +293 -0
  102. package/scripts/quarantine_ext.py +237 -0
  103. package/scripts/reconcile_issues.py +1442 -0
  104. package/scripts/refresh-path.ps1 +107 -0
  105. package/scripts/release.py +2030 -0
  106. package/scripts/release_e2e.py +1011 -0
  107. package/scripts/release_publish.py +486 -0
  108. package/scripts/release_rollback.py +980 -0
  109. package/scripts/relocate.py +1034 -0
  110. package/scripts/resolve_changelog_unreleased.py +667 -0
  111. package/scripts/resolve_version.py +490 -0
  112. package/scripts/resume_conditions.py +706 -0
  113. package/scripts/ritual_sentinel.py +609 -0
  114. package/scripts/roadmap_render.py +635 -0
  115. package/scripts/rule_ownership_lint.py +325 -0
  116. package/scripts/scm.py +591 -0
  117. package/scripts/scope_audit_log.py +387 -0
  118. package/scripts/scope_decompose.py +654 -0
  119. package/scripts/scope_demote.py +509 -0
  120. package/scripts/scope_lifecycle.py +1126 -0
  121. package/scripts/scope_undo.py +772 -0
  122. package/scripts/session_start.py +406 -0
  123. package/scripts/setup_ghx.py +339 -0
  124. package/scripts/setup_windows.ps1 +220 -0
  125. package/scripts/slice_audit.py +585 -0
  126. package/scripts/slice_record.py +530 -0
  127. package/scripts/slice_record_existing.py +692 -0
  128. package/scripts/slug_normalize.py +178 -0
  129. package/scripts/spec_render.py +477 -0
  130. package/scripts/spec_validate.py +238 -0
  131. package/scripts/subagent_monitor.py +658 -0
  132. package/scripts/swarm_complete_cohort.py +644 -0
  133. package/scripts/swarm_launch.py +1206 -0
  134. package/scripts/swarm_readiness.py +554 -0
  135. package/scripts/swarm_verify_review_clean.py +438 -0
  136. package/scripts/swarm_worktrees.py +497 -0
  137. package/scripts/toolchain-check.py +52 -0
  138. package/scripts/triage_actions.py +871 -0
  139. package/scripts/triage_bootstrap.py +1153 -0
  140. package/scripts/triage_bulk.py +630 -0
  141. package/scripts/triage_classify.py +932 -0
  142. package/scripts/triage_help.py +1685 -0
  143. package/scripts/triage_queue.py +1944 -0
  144. package/scripts/triage_reconcile.py +581 -0
  145. package/scripts/triage_refresh.py +643 -0
  146. package/scripts/triage_scope.py +999 -0
  147. package/scripts/triage_scope_drift.py +575 -0
  148. package/scripts/triage_smoketest.py +396 -0
  149. package/scripts/triage_subscribe.py +399 -0
  150. package/scripts/triage_summary.py +1011 -0
  151. package/scripts/triage_welcome.py +1178 -0
  152. package/scripts/ts_check_lane.py +86 -0
  153. package/scripts/validate-links.py +64 -0
  154. package/scripts/validate_strategy_output.py +212 -0
  155. package/scripts/vbrief_activate.py +228 -0
  156. package/scripts/vbrief_migrate_conformance.py +368 -0
  157. package/scripts/vbrief_reconcile_graph.py +306 -0
  158. package/scripts/vbrief_reconcile_labels.py +460 -0
  159. package/scripts/vbrief_reconcile_umbrellas.py +741 -0
  160. package/scripts/vbrief_validate.py +1195 -0
  161. package/scripts/verify-stubs.py +61 -0
  162. package/scripts/verify_capacity.py +160 -0
  163. package/scripts/verify_encoding.py +699 -0
  164. package/scripts/verify_hooks_installed.py +206 -0
  165. package/scripts/verify_investigation.py +360 -0
  166. package/scripts/verify_judgment_gates.py +827 -0
  167. package/scripts/verify_no_task_runtime.py +171 -0
  168. package/scripts/verify_scm_boundary.py +509 -0
  169. package/scripts/verify_session_ritual.py +389 -0
  170. package/scripts/verify_tools.py +426 -0
  171. package/scripts/verify_vbrief_conformance.py +478 -0
  172. package/skills/deft-directive-swarm/SKILL.md +7 -26
  173. package/skills/deft-directive-sync/SKILL.md +1 -1
  174. package/tasks/architecture.yml +13 -0
  175. package/tasks/cache.yml +69 -0
  176. package/tasks/capacity.yml +38 -0
  177. package/tasks/change.yml +46 -0
  178. package/tasks/changelog.yml +24 -0
  179. package/tasks/ci.yml +49 -0
  180. package/tasks/codebase.yml +47 -0
  181. package/tasks/commit.yml +30 -0
  182. package/tasks/core.yml +126 -0
  183. package/tasks/deployments.yml +54 -0
  184. package/tasks/framework.yml +74 -0
  185. package/tasks/install.yml +60 -0
  186. package/tasks/issue.yml +50 -0
  187. package/tasks/migrate.yml +73 -0
  188. package/tasks/packs.yml +92 -0
  189. package/tasks/policy.yml +75 -0
  190. package/tasks/pr.yml +89 -0
  191. package/tasks/prd.yml +39 -0
  192. package/tasks/project.yml +27 -0
  193. package/tasks/reconcile.yml +32 -0
  194. package/tasks/relocate.yml +56 -0
  195. package/tasks/roadmap.yml +28 -0
  196. package/tasks/scm.yml +126 -0
  197. package/tasks/scope-undo.yml +36 -0
  198. package/tasks/scope.yml +141 -0
  199. package/tasks/session.yml +19 -0
  200. package/tasks/setup.yml +37 -0
  201. package/tasks/slice.yml +69 -0
  202. package/tasks/spec.yml +41 -0
  203. package/tasks/swarm.yml +85 -0
  204. package/tasks/toolchain.yml +13 -0
  205. package/tasks/triage-actions.yml +94 -0
  206. package/tasks/triage-bootstrap.yml +43 -0
  207. package/tasks/triage-bulk.yml +75 -0
  208. package/tasks/triage-classify.yml +30 -0
  209. package/tasks/triage-queue.yml +50 -0
  210. package/tasks/triage-reconcile.yml +29 -0
  211. package/tasks/triage-scope-drift.yml +29 -0
  212. package/tasks/triage-scope.yml +31 -0
  213. package/tasks/triage-smoketest.yml +33 -0
  214. package/tasks/triage-subscribe.yml +36 -0
  215. package/tasks/triage-summary.yml +29 -0
  216. package/tasks/triage-welcome.yml +32 -0
  217. package/tasks/ts.yml +328 -0
  218. package/tasks/vbrief.yml +206 -0
  219. package/tasks/verify.yml +292 -0
  220. package/templates/agents-entry.md +2 -2
@@ -0,0 +1,2826 @@
1
+ #!/usr/bin/env python3
2
+ """policy.py -- shared helper for the typed PROJECT-DEFINITION.vbrief.json policy surface.
3
+
4
+ Introduced by #746 (no-feature-branch opt-out) as the single read/write surface for
5
+ ``plan.policy.allowDirectCommitsToMaster``. Replaces the legacy free-form
6
+ ``plan.narratives['Allow direct commits to master']`` narrative key (case-sensitive,
7
+ typo-prone, type-coerced). The legacy key is still recognized at read time with a
8
+ deprecation warning so existing PROJECT-DEFINITION files keep working until they
9
+ are migrated; new writes always go through this typed surface.
10
+
11
+ This module is consumed by:
12
+
13
+ - ``scripts/preflight_branch.py`` (#747 detection-bound branch gate)
14
+ - ``scripts/policy_show.py`` / ``scripts/policy_set.py`` (reconfiguration surface)
15
+ - skill-level guards in ``deft-directive-{swarm,review-cycle,pre-pr,release}``
16
+ - ``scripts/vbrief_validate.py`` (typed-field enforcement on PROJECT-DEFINITION)
17
+
18
+ Pure stdlib so the helper can be invoked from git hooks without ``uv``.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import os
25
+ import re
26
+ import sys
27
+ from collections.abc import Callable
28
+ from dataclasses import dataclass
29
+ from datetime import UTC, datetime
30
+ from pathlib import Path
31
+ from typing import Any
32
+
33
+ # Public constants ----------------------------------------------------------
34
+
35
+ #: Filesystem-relative location of the project-definition vBRIEF.
36
+ PROJECT_DEFINITION_REL_PATH = "vbrief/PROJECT-DEFINITION.vbrief.json"
37
+
38
+ #: Environment variable that lets the operator bypass the branch-protection
39
+ #: policy enforcement WITHOUT editing the typed flag. Documented in #747 as
40
+ #: the explicit emergency-escape hatch (e.g. CI on a release tag, automated
41
+ #: hot-fix). When set to a truthy value, hooks/scripts that defer to
42
+ #: :func:`is_direct_commit_allowed` MUST treat the policy as ``allowed``.
43
+ ENV_BYPASS = "DEFT_ALLOW_DEFAULT_BRANCH_COMMIT"
44
+
45
+ #: Recognized truthy strings for ``DEFT_ALLOW_DEFAULT_BRANCH_COMMIT``.
46
+ _TRUTHY = frozenset({"1", "true", "yes", "on"})
47
+
48
+ #: Legacy narrative key that the typed flag replaces. Kept here so the
49
+ #: deprecation warning emitted during read-time can cite the exact spelling
50
+ #: the user likely has in their PROJECT-DEFINITION.
51
+ LEGACY_NARRATIVE_KEY = "Allow direct commits to master"
52
+
53
+ #: Sigil written by ``policy_set`` to ``meta/policy-changes.log`` so the
54
+ #: audit trail is grep-friendly across PowerShell and POSIX shells.
55
+ AUDIT_LOG_REL_PATH = "meta/policy-changes.log"
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # WIP cap surface (#1124 / D4 of #1119)
59
+ # ---------------------------------------------------------------------------
60
+ #
61
+ # Framework default WIP cap. Used by ``scope:promote`` enforcement,
62
+ # ``verify:wip-cap`` re-validation, and the D2 (#1122) ``triage:summary``
63
+ # one-liner. **10** per umbrella #1119 Current Shape v3 (comment
64
+ # 4471269010); supersedes the literal 12 in the D4 (#1124) issue body.
65
+ # Importing the constant from ``scripts.policy`` is mandatory for any
66
+ # component that surfaces the cap so D2 / D4 cannot drift again.
67
+ DEFAULT_WIP_CAP: int = 10
68
+
69
+ #: vBRIEF lifecycle folders that count toward the WIP set. Mirrors the
70
+ #: D4 cap target (`pending/ + active/`).
71
+ WIP_LIFECYCLE_DIRS: tuple[str, ...] = ("pending", "active")
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class PolicyResult:
76
+ """Resolved policy state. ``source`` documents which surface won."""
77
+
78
+ allow_direct_commits: bool
79
+ source: str # one of: 'typed', 'legacy-narrative', 'env-bypass', 'default-fail-closed'
80
+ deprecation_warning: str | None = None
81
+ error: str | None = None
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class WipCapResult:
86
+ """Resolved ``plan.policy.wipCap`` state. Mirrors :class:`PolicyResult` shape.
87
+
88
+ Fields:
89
+
90
+ * ``cap`` -- resolved integer cap (``>= 0``).
91
+ * ``source`` -- ``'typed'`` (typed field present and well-formed),
92
+ ``'default'`` (no typed field; framework default applied), or
93
+ ``'default-on-error'`` (typed field present but malformed -- the
94
+ caller can surface ``error`` to the operator).
95
+ * ``error`` -- one-line diagnostic when the typed field is
96
+ unreadable / non-int / negative; ``None`` on success / default.
97
+ """
98
+
99
+ cap: int
100
+ source: str # one of: 'typed', 'default', 'default-on-error'
101
+ error: str | None = None
102
+
103
+
104
+ DEFAULT_SESSION_RITUAL_STALENESS_HOURS: int = 4
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class SessionRitualStalenessResult:
109
+ """Resolved ``plan.policy.sessionRitualStalenessHours`` state (#1348)."""
110
+
111
+ hours: int
112
+ source: str # one of: 'typed', 'default', 'default-on-error'
113
+ error: str | None = None
114
+
115
+
116
+ def project_definition_path(project_root: Path | None = None) -> Path:
117
+ """Resolve the absolute path to ``vbrief/PROJECT-DEFINITION.vbrief.json``."""
118
+ root = project_root or Path.cwd()
119
+ return root / PROJECT_DEFINITION_REL_PATH
120
+
121
+
122
+ def _env_bypass_active() -> bool:
123
+ """True when ``DEFT_ALLOW_DEFAULT_BRANCH_COMMIT`` is set to a truthy value."""
124
+ raw = os.environ.get(ENV_BYPASS, "")
125
+ return raw.strip().lower() in _TRUTHY
126
+
127
+
128
+ def _coerce_legacy_narrative(value: Any) -> tuple[bool, str]:
129
+ """Best-effort coerce a legacy narrative value to a boolean.
130
+
131
+ Returns (allow, raw) where raw is the original string for diagnostics.
132
+ Accepts ``true``, ``yes``, ``allow direct commits to master: true``,
133
+ case-insensitive. Anything else is treated as ``False`` (enforce branches).
134
+ """
135
+ if isinstance(value, bool):
136
+ return value, repr(value)
137
+ if not isinstance(value, str):
138
+ return False, repr(value)
139
+ raw = value.strip()
140
+ low = raw.lower()
141
+ # Two shapes seen in the wild: "true" / "yes" or
142
+ # "Allow direct commits to master: true" (re-stating the key inline).
143
+ if low in {"true", "yes", "on", "1"}:
144
+ return True, raw
145
+ match = re.search(r":\s*(true|yes|on|1)\b", low)
146
+ if match:
147
+ return True, raw
148
+ return False, raw
149
+
150
+
151
+ def load_project_definition(project_root: Path | None = None) -> tuple[dict | None, str | None]:
152
+ """Load and parse PROJECT-DEFINITION. Returns (data, error)."""
153
+ path = project_definition_path(project_root)
154
+ if not path.is_file():
155
+ return None, f"PROJECT-DEFINITION not found at {path}"
156
+ try:
157
+ return json.loads(path.read_text(encoding="utf-8")), None
158
+ except json.JSONDecodeError as exc:
159
+ return None, f"PROJECT-DEFINITION at {path} is not valid JSON: {exc}"
160
+ except OSError as exc:
161
+ return None, f"PROJECT-DEFINITION at {path} cannot be read: {exc}"
162
+
163
+
164
+ def resolve_policy(project_root: Path | None = None) -> PolicyResult:
165
+ """Resolve the effective branch-commit policy.
166
+
167
+ Resolution order (#746 / #747):
168
+
169
+ 1. ``DEFT_ALLOW_DEFAULT_BRANCH_COMMIT`` env-var bypass -- explicit escape.
170
+ 2. ``plan.policy.allowDirectCommitsToMaster`` typed boolean (new).
171
+ 3. ``plan.narratives['Allow direct commits to master']`` legacy narrative.
172
+ Emits a deprecation warning the caller can surface.
173
+ 4. Default fail-closed: ``allow=False`` (enforce feature branches).
174
+ """
175
+ if _env_bypass_active():
176
+ return PolicyResult(
177
+ allow_direct_commits=True,
178
+ source="env-bypass",
179
+ deprecation_warning=None,
180
+ error=None,
181
+ )
182
+
183
+ data, err = load_project_definition(project_root)
184
+ if data is None:
185
+ # Fail-closed when PROJECT-DEFINITION is missing -- the only way to
186
+ # bypass without it is the env-var (already handled above). The
187
+ # caller may still surface ``err`` to the user.
188
+ return PolicyResult(
189
+ allow_direct_commits=False,
190
+ source="default-fail-closed",
191
+ deprecation_warning=None,
192
+ error=err,
193
+ )
194
+
195
+ plan = data.get("plan", {}) if isinstance(data, dict) else {}
196
+ if not isinstance(plan, dict):
197
+ return PolicyResult(
198
+ allow_direct_commits=False,
199
+ source="default-fail-closed",
200
+ deprecation_warning=None,
201
+ error="PROJECT-DEFINITION 'plan' is not an object",
202
+ )
203
+
204
+ # 2. Typed flag.
205
+ policy_block = plan.get("policy")
206
+ if isinstance(policy_block, dict) and "allowDirectCommitsToMaster" in policy_block:
207
+ raw = policy_block["allowDirectCommitsToMaster"]
208
+ if not isinstance(raw, bool):
209
+ return PolicyResult(
210
+ allow_direct_commits=False,
211
+ source="default-fail-closed",
212
+ deprecation_warning=None,
213
+ error=(
214
+ "plan.policy.allowDirectCommitsToMaster must be a boolean; "
215
+ f"got {type(raw).__name__} ({raw!r})"
216
+ ),
217
+ )
218
+ return PolicyResult(
219
+ allow_direct_commits=raw,
220
+ source="typed",
221
+ deprecation_warning=None,
222
+ error=None,
223
+ )
224
+
225
+ # 3. Legacy narrative fallback.
226
+ narratives = plan.get("narratives", {})
227
+ if isinstance(narratives, dict) and LEGACY_NARRATIVE_KEY in narratives:
228
+ allow, raw = _coerce_legacy_narrative(narratives[LEGACY_NARRATIVE_KEY])
229
+ warn = (
230
+ f"DEPRECATED: PROJECT-DEFINITION uses the legacy narrative key "
231
+ f"'{LEGACY_NARRATIVE_KEY}' ({raw!r}). Migrate to typed "
232
+ f"plan.policy.allowDirectCommitsToMaster (#746). Run "
233
+ f"`task policy:enforce-branches` or `task policy:allow-direct-commits "
234
+ f"-- --confirm` to set the typed flag explicitly."
235
+ )
236
+ return PolicyResult(
237
+ allow_direct_commits=allow,
238
+ source="legacy-narrative",
239
+ deprecation_warning=warn,
240
+ error=None,
241
+ )
242
+
243
+ # 4. Default fail-closed.
244
+ return PolicyResult(
245
+ allow_direct_commits=False,
246
+ source="default-fail-closed",
247
+ deprecation_warning=None,
248
+ error=None,
249
+ )
250
+
251
+
252
+ def is_direct_commit_allowed(project_root: Path | None = None) -> bool:
253
+ """Convenience boolean wrapper -- True when direct commits to master are allowed."""
254
+ return resolve_policy(project_root).allow_direct_commits
255
+
256
+
257
+ # ---------------------------------------------------------------------------
258
+ # WIP cap helpers (#1124 / D4 of #1119)
259
+ # ---------------------------------------------------------------------------
260
+
261
+
262
+ def resolve_wip_cap(project_root: Path | None = None) -> WipCapResult:
263
+ """Resolve ``plan.policy.wipCap`` from PROJECT-DEFINITION.
264
+
265
+ Resolution order:
266
+
267
+ 1. ``plan.policy.wipCap`` typed integer (``>= 0``) -- ``source='typed'``.
268
+ 2. Missing / unreadable / non-int / negative -- ``source='default'``
269
+ (with ``error`` set when malformed so the caller can surface it).
270
+
271
+ Pure-stdlib; no live ``gh`` / cache calls. Mirrors the
272
+ :func:`resolve_policy` shape so callers can use the same
273
+ pattern-match-on-source style. Default = :data:`DEFAULT_WIP_CAP`
274
+ (10 per umbrella #1119 Current Shape v3).
275
+ """
276
+ data, err = load_project_definition(project_root)
277
+ if data is None:
278
+ # Missing PROJECT-DEFINITION is not an error for the WIP cap --
279
+ # we fall back to the framework default. ``err`` is propagated as
280
+ # observability for the caller.
281
+ return WipCapResult(
282
+ cap=DEFAULT_WIP_CAP,
283
+ source="default",
284
+ error=err,
285
+ )
286
+
287
+ plan = data.get("plan") if isinstance(data, dict) else None
288
+ if not isinstance(plan, dict):
289
+ return WipCapResult(
290
+ cap=DEFAULT_WIP_CAP,
291
+ source="default",
292
+ error="PROJECT-DEFINITION 'plan' is not an object",
293
+ )
294
+ policy_block = plan.get("policy")
295
+ if not isinstance(policy_block, dict) or "wipCap" not in policy_block:
296
+ return WipCapResult(cap=DEFAULT_WIP_CAP, source="default", error=None)
297
+
298
+ raw = policy_block["wipCap"]
299
+ # ``bool`` is a subclass of ``int`` in Python -- explicitly reject it
300
+ # so ``True`` does not silently parse as cap=1.
301
+ if not isinstance(raw, int) or isinstance(raw, bool) or raw < 0:
302
+ return WipCapResult(
303
+ cap=DEFAULT_WIP_CAP,
304
+ source="default-on-error",
305
+ error=(
306
+ "plan.policy.wipCap must be a non-negative integer; got "
307
+ f"{type(raw).__name__} ({raw!r})"
308
+ ),
309
+ )
310
+ return WipCapResult(cap=raw, source="typed", error=None)
311
+
312
+
313
+ def count_vbrief_wip(project_root: Path) -> int:
314
+ """Count ``*.vbrief.json`` files in ``vbrief/pending/`` + ``vbrief/active/``.
315
+
316
+ Files are filtered by the ``.vbrief.json`` suffix so scratch /
317
+ README artefacts dropped into the lifecycle folders do not pollute
318
+ the count. Missing folders contribute 0. Mirrors the D4 / #1124 cap
319
+ target -- the single canonical WIP definition shared with D2.
320
+ """
321
+ total = 0
322
+ vbrief_root = project_root / "vbrief"
323
+ for sub in WIP_LIFECYCLE_DIRS:
324
+ folder = vbrief_root / sub
325
+ if not folder.is_dir():
326
+ continue
327
+ total += sum(
328
+ 1
329
+ for child in folder.iterdir()
330
+ if child.is_file() and child.name.endswith(".vbrief.json")
331
+ )
332
+ return total
333
+
334
+
335
+ def validate_wip_cap(value: Any) -> list[str]:
336
+ """Validate a ``plan.policy.wipCap`` payload. Returns a list of error strings.
337
+
338
+ Rules:
339
+
340
+ * ``None`` / unset is valid (resolver falls back to the default).
341
+ * Must be an integer (``bool`` explicitly rejected).
342
+ * Must be ``>= 0`` (``0`` is a legitimate operator state -- freezes
343
+ promotion entirely; useful for code-freeze windows).
344
+ """
345
+ errors: list[str] = []
346
+ if value is None:
347
+ return errors
348
+ if not isinstance(value, int) or isinstance(value, bool):
349
+ errors.append(
350
+ "plan.policy.wipCap must be an integer; got "
351
+ f"{type(value).__name__} ({value!r})"
352
+ )
353
+ return errors
354
+ if value < 0:
355
+ errors.append(
356
+ f"plan.policy.wipCap must be >= 0; got {value}"
357
+ )
358
+ return errors
359
+
360
+
361
+ def validate_wip_cap_on_plan(plan: Any, filepath: Any) -> list[str]:
362
+ """vbrief_validate hook: validate ``plan.policy.wipCap`` (#1124).
363
+
364
+ Returns formatted error strings prefixed with ``<filepath>:`` so
365
+ ``vbrief_validate.validate_project_definition`` can splice them into
366
+ its existing error list. Unset / missing is treated as the framework
367
+ default and returns an empty list. Mirrors the D11 / D12 / D10
368
+ hook shape.
369
+ """
370
+ out: list[str] = []
371
+ if not isinstance(plan, dict):
372
+ return out
373
+ policy = plan.get("policy")
374
+ if not isinstance(policy, dict) or "wipCap" not in policy:
375
+ return out
376
+ for err in validate_wip_cap(policy["wipCap"]):
377
+ out.append(f"{filepath}: {err} (#1124)")
378
+ return out
379
+
380
+
381
+ def resolve_session_ritual_staleness_hours(
382
+ project_root: Path | None = None,
383
+ ) -> SessionRitualStalenessResult:
384
+ """Resolve ``plan.policy.sessionRitualStalenessHours`` (#1348)."""
385
+ data, err = load_project_definition(project_root)
386
+ if data is None:
387
+ return SessionRitualStalenessResult(
388
+ hours=DEFAULT_SESSION_RITUAL_STALENESS_HOURS,
389
+ source="default",
390
+ error=err,
391
+ )
392
+ plan = data.get("plan") if isinstance(data, dict) else None
393
+ if not isinstance(plan, dict):
394
+ return SessionRitualStalenessResult(
395
+ hours=DEFAULT_SESSION_RITUAL_STALENESS_HOURS,
396
+ source="default",
397
+ error="PROJECT-DEFINITION 'plan' is not an object",
398
+ )
399
+ policy_block = plan.get("policy")
400
+ if (
401
+ not isinstance(policy_block, dict)
402
+ or "sessionRitualStalenessHours" not in policy_block
403
+ ):
404
+ return SessionRitualStalenessResult(
405
+ hours=DEFAULT_SESSION_RITUAL_STALENESS_HOURS,
406
+ source="default",
407
+ error=None,
408
+ )
409
+ raw = policy_block["sessionRitualStalenessHours"]
410
+ if raw is None:
411
+ return SessionRitualStalenessResult(
412
+ hours=DEFAULT_SESSION_RITUAL_STALENESS_HOURS,
413
+ source="default",
414
+ error=None,
415
+ )
416
+ errors = validate_session_ritual_staleness_hours(raw)
417
+ if errors:
418
+ return SessionRitualStalenessResult(
419
+ hours=DEFAULT_SESSION_RITUAL_STALENESS_HOURS,
420
+ source="default-on-error",
421
+ error=errors[0],
422
+ )
423
+ return SessionRitualStalenessResult(hours=raw, source="typed", error=None)
424
+
425
+
426
+ def validate_session_ritual_staleness_hours(value: Any) -> list[str]:
427
+ """Validate ``plan.policy.sessionRitualStalenessHours`` (#1348)."""
428
+ if value is None:
429
+ return []
430
+ if not isinstance(value, int) or isinstance(value, bool):
431
+ return [
432
+ "plan.policy.sessionRitualStalenessHours must be an integer; got "
433
+ f"{type(value).__name__} ({value!r})"
434
+ ]
435
+ if value <= 0:
436
+ return [
437
+ "plan.policy.sessionRitualStalenessHours must be > 0; "
438
+ f"got {value}"
439
+ ]
440
+ return []
441
+
442
+
443
+ def validate_session_ritual_staleness_hours_on_plan(
444
+ plan: Any,
445
+ filepath: Any,
446
+ ) -> list[str]:
447
+ """vbrief_validate hook for ``sessionRitualStalenessHours`` (#1348)."""
448
+ out: list[str] = []
449
+ if not isinstance(plan, dict):
450
+ return out
451
+ policy = plan.get("policy")
452
+ if not isinstance(policy, dict) or "sessionRitualStalenessHours" not in policy:
453
+ return out
454
+ for err in validate_session_ritual_staleness_hours(
455
+ policy["sessionRitualStalenessHours"]
456
+ ):
457
+ out.append(f"{filepath}: {err} (#1348)")
458
+ return out
459
+
460
+
461
+ def set_wip_cap(
462
+ project_root: Path,
463
+ *,
464
+ cap: int,
465
+ actor: str = "agent",
466
+ note: str = "",
467
+ ) -> tuple[bool, str]:
468
+ """Write ``plan.policy.wipCap`` to PROJECT-DEFINITION.
469
+
470
+ Returns ``(changed, audit_entry)``. Performs an in-place edit
471
+ (preserves all other keys). Audit-log entry appended to
472
+ ``meta/policy-changes.log`` (shared with the existing
473
+ branch-protection writer; one log = one canonical timeline).
474
+
475
+ Raises ``FileNotFoundError`` when PROJECT-DEFINITION is missing --
476
+ the caller should produce a fail-closed message in that case.
477
+ """
478
+ if not isinstance(cap, int) or isinstance(cap, bool) or cap < 0:
479
+ raise ValueError(
480
+ f"wipCap must be a non-negative integer; got {cap!r}"
481
+ )
482
+ path = project_definition_path(project_root)
483
+ if not path.is_file():
484
+ raise FileNotFoundError(f"PROJECT-DEFINITION not found at {path}")
485
+ data = json.loads(path.read_text(encoding="utf-8"))
486
+ plan = data.setdefault("plan", {})
487
+ if not isinstance(plan, dict):
488
+ raise ValueError("PROJECT-DEFINITION 'plan' is not an object")
489
+ policy_block = plan.setdefault("policy", {})
490
+ if not isinstance(policy_block, dict):
491
+ raise ValueError("plan.policy is not an object")
492
+
493
+ previous = policy_block.get("wipCap")
494
+ policy_block["wipCap"] = int(cap)
495
+ path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
496
+
497
+ changed = previous != int(cap)
498
+ parts = [
499
+ f"actor={actor}",
500
+ f"wipCap={cap}",
501
+ f"previous={previous!r}",
502
+ ]
503
+ if note:
504
+ parts.append("note=" + note.replace("\n", " ").replace("\r", " "))
505
+ audit_entry = " ".join(parts)
506
+ append_audit_log(project_root, audit_entry)
507
+ return changed, audit_entry
508
+
509
+
510
+ # ---------------------------------------------------------------------------
511
+ # Capacity allocation surface (#1419 Delivery Slice 4)
512
+ # ---------------------------------------------------------------------------
513
+ #
514
+ # ``plan.policy.capacityAllocation`` lets a project track effort against
515
+ # protected buckets (e.g. ``debt`` / ``feature`` / ``urgent``) so debt
516
+ # paydown is not starved by urgent work. The schema is ADVISORY by default
517
+ # (``enforcement = "advise"``): the capacity engine reports target-vs-actual
518
+ # mix and defers to the existing selection ordering. The ``cost`` unit is
519
+ # SELECTABLE (per OQ2, resolved 2026-06-04) but self-guards -- the cost path
520
+ # falls back to advisory ``vbrief-count`` when grounded cost actuals are
521
+ # insufficient (the Warp Analytics cost-sync telemetry is out of scope /
522
+ # upstream-blocked). The resolver below returns the requested unit verbatim;
523
+ # the guarded fallback decision lives in ``scripts/capacity_show.py`` where
524
+ # the grounded-actuals coverage can actually be measured against the
525
+ # lifecycle folders.
526
+
527
+ #: Capacity accounting unit. ``vbrief-count`` (default, directive's mode)
528
+ #: tallies vBRIEF weights; ``cost`` is the opt-in unit that overlays cost
529
+ #: actuals and self-guards with an advisory count fallback (OQ2).
530
+ DEFAULT_CAPACITY_UNIT: str = "vbrief-count"
531
+ CAPACITY_UNIT_COST: str = "cost"
532
+ CAPACITY_UNITS: frozenset[str] = frozenset({DEFAULT_CAPACITY_UNIT, CAPACITY_UNIT_COST})
533
+
534
+ #: Trailing accounting window (days) when ``window`` is absent on a
535
+ #: well-formed-but-partial block. A configured block MUST carry ``window``
536
+ #: (validated), so this default only applies to the unconfigured-default
537
+ #: resolver result.
538
+ DEFAULT_CAPACITY_WINDOW_DAYS: int = 30
539
+
540
+ #: Enforcement posture. ``advise`` (default) NEVER blocks -- the engine
541
+ #: reports and defers to ordering. ``enforce`` is opt-in and only surfaces
542
+ #: a non-zero gate exit when a real deficit accrues past the sample guard;
543
+ #: the framework's own tree leaves this at ``advise`` so a capacity gate
544
+ #: cannot wedge master.
545
+ DEFAULT_CAPACITY_ENFORCEMENT: str = "advise"
546
+ CAPACITY_ENFORCEMENTS: frozenset[str] = frozenset({"advise", "enforce"})
547
+
548
+ #: Minimum classified completions before backward (target-vs-actual)
549
+ #: accounting is treated as load-bearing. Below this, the engine reports
550
+ #: advisory mode and defers to ordering (acceptance a1).
551
+ DEFAULT_CAPACITY_MIN_SAMPLE_SIZE: int = 20
552
+
553
+ #: Weight attributed to an UNDECOMPOSED epic / phase (one with no child
554
+ #: stories on disk). A decomposed parent counts 0 -- its children are
555
+ #: counted directly (acceptance a2).
556
+ DEFAULT_EPIC_ESTIMATE: int = 3
557
+
558
+ #: Age (days) past which an undecomposed epic estimate is considered stale
559
+ #: (surfaced by the capacity engine as a hint; advisory only).
560
+ DEFAULT_EPIC_STALENESS_DAYS: int = 30
561
+
562
+ #: Absolute tolerance for the ``sum(bucket.target) == 1.0`` invariant so
563
+ #: float round-trips (e.g. 0.3 + 0.3 + 0.4) validate cleanly.
564
+ CAPACITY_TARGET_SUM_TOLERANCE: float = 1e-6
565
+
566
+
567
+ @dataclass(frozen=True)
568
+ class CapacityBucket:
569
+ """One protected capacity bucket: a stable id and its target fraction."""
570
+
571
+ bucket_id: str
572
+ target: float
573
+
574
+
575
+ @dataclass(frozen=True)
576
+ class CapacityAllocation:
577
+ """Resolved ``plan.policy.capacityAllocation`` state.
578
+
579
+ ``source`` mirrors :class:`WipCapResult` semantics: ``'typed'`` when a
580
+ well-formed block is present, ``'default'`` when absent, and
581
+ ``'default-on-error'`` when present-but-malformed (``error`` carries the
582
+ first diagnostic so the caller can surface it). ``configured`` is the
583
+ convenience predicate the capacity engine uses to decide whether to
584
+ render the target-vs-actual table or the unconfigured advisory banner.
585
+ """
586
+
587
+ unit: str
588
+ window_days: int
589
+ enforcement: str
590
+ min_sample_size: int
591
+ buckets: tuple[CapacityBucket, ...]
592
+ default_bucket: str
593
+ default_epic_estimate: int
594
+ epic_staleness_days: int
595
+ source: str # one of: 'typed', 'default', 'default-on-error'
596
+ error: str | None = None
597
+
598
+ @property
599
+ def configured(self) -> bool:
600
+ """True when a well-formed block with at least one bucket is present."""
601
+ return self.source == "typed" and bool(self.buckets)
602
+
603
+
604
+ def _is_number(value: Any) -> bool:
605
+ """True for a real numeric value (``bool`` is explicitly excluded)."""
606
+ return isinstance(value, int | float) and not isinstance(value, bool)
607
+
608
+
609
+ def _is_positive_int(value: Any) -> bool:
610
+ """True for an ``int`` strictly greater than zero (``bool`` excluded)."""
611
+ return isinstance(value, int) and not isinstance(value, bool) and value > 0
612
+
613
+
614
+ def _default_capacity_allocation(
615
+ *, source: str, error: str | None = None
616
+ ) -> CapacityAllocation:
617
+ """Return the framework-default :class:`CapacityAllocation`.
618
+
619
+ Buckets are intentionally empty -- with no configured buckets the
620
+ capacity engine renders the unconfigured advisory banner rather than a
621
+ target-vs-actual table.
622
+ """
623
+ return CapacityAllocation(
624
+ unit=DEFAULT_CAPACITY_UNIT,
625
+ window_days=DEFAULT_CAPACITY_WINDOW_DAYS,
626
+ enforcement=DEFAULT_CAPACITY_ENFORCEMENT,
627
+ min_sample_size=DEFAULT_CAPACITY_MIN_SAMPLE_SIZE,
628
+ buckets=(),
629
+ default_bucket="",
630
+ default_epic_estimate=DEFAULT_EPIC_ESTIMATE,
631
+ epic_staleness_days=DEFAULT_EPIC_STALENESS_DAYS,
632
+ source=source,
633
+ error=error,
634
+ )
635
+
636
+
637
+ def validate_capacity_allocation(value: Any) -> list[str]:
638
+ """Validate a ``plan.policy.capacityAllocation`` payload.
639
+
640
+ Returns a list of error strings (empty == valid). ``None`` / unset is
641
+ valid (the resolver falls back to the framework default). The invariants
642
+ enforced (per the #1419 Slice 4 acceptance criteria) are:
643
+
644
+ * ``unit`` (if present) is one of :data:`CAPACITY_UNITS`.
645
+ * ``enforcement`` (if present) is one of :data:`CAPACITY_ENFORCEMENTS`.
646
+ * ``window`` is REQUIRED and a positive integer (days).
647
+ * ``minSampleSize`` (if present) is a non-negative integer.
648
+ * ``defaultEpicEstimate`` / ``epicStalenessDays`` (if present) are
649
+ positive integers.
650
+ * ``buckets`` is a non-empty array of ``{id, target}`` objects with
651
+ unique ids and targets that sum to 1.0 (within
652
+ :data:`CAPACITY_TARGET_SUM_TOLERANCE`).
653
+ * ``defaultBucket`` (if present) matches a declared bucket id.
654
+ """
655
+ errors: list[str] = []
656
+ if value is None:
657
+ return errors
658
+ if not isinstance(value, dict):
659
+ errors.append(
660
+ "plan.policy.capacityAllocation must be an object; got "
661
+ f"{type(value).__name__} ({value!r})"
662
+ )
663
+ return errors
664
+
665
+ unit = value.get("unit", DEFAULT_CAPACITY_UNIT)
666
+ if unit not in CAPACITY_UNITS:
667
+ errors.append(
668
+ "plan.policy.capacityAllocation.unit must be one of "
669
+ f"{sorted(CAPACITY_UNITS)}; got {unit!r}"
670
+ )
671
+
672
+ enforcement = value.get("enforcement", DEFAULT_CAPACITY_ENFORCEMENT)
673
+ if enforcement not in CAPACITY_ENFORCEMENTS:
674
+ errors.append(
675
+ "plan.policy.capacityAllocation.enforcement must be one of "
676
+ f"{sorted(CAPACITY_ENFORCEMENTS)}; got {enforcement!r}"
677
+ )
678
+
679
+ if "window" not in value:
680
+ errors.append(
681
+ "plan.policy.capacityAllocation.window is required "
682
+ "(trailing accounting window in days)"
683
+ )
684
+ elif not _is_positive_int(value["window"]):
685
+ errors.append(
686
+ "plan.policy.capacityAllocation.window must be a positive integer "
687
+ f"(days); got {value['window']!r}"
688
+ )
689
+
690
+ if "minSampleSize" in value:
691
+ mss = value["minSampleSize"]
692
+ if not isinstance(mss, int) or isinstance(mss, bool) or mss < 0:
693
+ errors.append(
694
+ "plan.policy.capacityAllocation.minSampleSize must be a "
695
+ f"non-negative integer; got {mss!r}"
696
+ )
697
+
698
+ if "defaultEpicEstimate" in value and not _is_positive_int(
699
+ value["defaultEpicEstimate"]
700
+ ):
701
+ errors.append(
702
+ "plan.policy.capacityAllocation.defaultEpicEstimate must be a "
703
+ f"positive integer; got {value['defaultEpicEstimate']!r}"
704
+ )
705
+
706
+ if "epicStalenessDays" in value and not _is_positive_int(
707
+ value["epicStalenessDays"]
708
+ ):
709
+ errors.append(
710
+ "plan.policy.capacityAllocation.epicStalenessDays must be a "
711
+ f"positive integer; got {value['epicStalenessDays']!r}"
712
+ )
713
+
714
+ errors.extend(_validate_capacity_buckets(value))
715
+ return errors
716
+
717
+
718
+ def _validate_capacity_buckets(value: dict) -> list[str]:
719
+ """Validate the ``buckets`` array + ``defaultBucket`` cross-reference."""
720
+ errors: list[str] = []
721
+ buckets = value.get("buckets")
722
+ if not isinstance(buckets, list) or not buckets:
723
+ errors.append(
724
+ "plan.policy.capacityAllocation.buckets must be a non-empty array"
725
+ )
726
+ return errors
727
+
728
+ ids: list[str] = []
729
+ total = 0.0
730
+ for idx, bucket in enumerate(buckets):
731
+ if not isinstance(bucket, dict):
732
+ errors.append(
733
+ f"plan.policy.capacityAllocation.buckets[{idx}] must be an object"
734
+ )
735
+ continue
736
+ bucket_id = bucket.get("id")
737
+ if not isinstance(bucket_id, str) or not bucket_id.strip():
738
+ errors.append(
739
+ f"plan.policy.capacityAllocation.buckets[{idx}].id must be a "
740
+ "non-empty string"
741
+ )
742
+ else:
743
+ ids.append(bucket_id)
744
+ target = bucket.get("target")
745
+ if not _is_number(target):
746
+ errors.append(
747
+ f"plan.policy.capacityAllocation.buckets[{idx}].target must be "
748
+ f"a number; got {target!r}"
749
+ )
750
+ elif not 0.0 <= float(target) <= 1.0:
751
+ errors.append(
752
+ f"plan.policy.capacityAllocation.buckets[{idx}].target must be "
753
+ f"between 0.0 and 1.0; got {target!r}"
754
+ )
755
+ else:
756
+ total += float(target)
757
+
758
+ duplicates = sorted({bid for bid in ids if ids.count(bid) > 1})
759
+ if duplicates:
760
+ errors.append(
761
+ "plan.policy.capacityAllocation.buckets ids must be unique; "
762
+ f"duplicates: {duplicates}"
763
+ )
764
+
765
+ if ids and abs(total - 1.0) > CAPACITY_TARGET_SUM_TOLERANCE:
766
+ errors.append(
767
+ "plan.policy.capacityAllocation.buckets targets must sum to 1.0; "
768
+ f"got {total:.6f}"
769
+ )
770
+
771
+ default_bucket = value.get("defaultBucket")
772
+ if default_bucket is not None:
773
+ if not isinstance(default_bucket, str):
774
+ errors.append(
775
+ "plan.policy.capacityAllocation.defaultBucket must be a string"
776
+ )
777
+ elif default_bucket not in ids:
778
+ errors.append(
779
+ "plan.policy.capacityAllocation.defaultBucket "
780
+ f"{default_bucket!r} must match a declared bucket id"
781
+ )
782
+ return errors
783
+
784
+
785
+ def validate_capacity_allocation_on_plan(plan: Any, filepath: Any) -> list[str]:
786
+ """vbrief_validate hook: validate ``plan.policy.capacityAllocation`` (#1419).
787
+
788
+ Returns formatted error strings prefixed with ``<filepath>:`` so a
789
+ PROJECT-DEFINITION validator can splice them into its error list.
790
+ Unset / missing is valid and returns an empty list. Mirrors the
791
+ :func:`validate_wip_cap_on_plan` hook shape.
792
+
793
+ NOTE (#1419): this hook is provided + unit-tested as the canonical
794
+ validation entry point, but is intentionally NOT yet spliced into
795
+ ``scripts/vbrief_validate.py`` in this slice -- capacity is advisory in
796
+ Slice 4 and a malformed block self-heals to defaults (the resolver
797
+ returns ``source='default-on-error'`` and ``capacity:show`` surfaces the
798
+ error). Wiring this into the ``task check`` validation aggregate is a
799
+ follow-up slice's concern; doing it here would touch out-of-scope files
800
+ and risk a fail-closed posture on the framework's own tree.
801
+ """
802
+ out: list[str] = []
803
+ if not isinstance(plan, dict):
804
+ return out
805
+ policy = plan.get("policy")
806
+ if not isinstance(policy, dict) or "capacityAllocation" not in policy:
807
+ return out
808
+ for err in validate_capacity_allocation(policy["capacityAllocation"]):
809
+ out.append(f"{filepath}: {err} (#1419)")
810
+ return out
811
+
812
+
813
+ def resolve_capacity_allocation(
814
+ project_root: Path | None = None,
815
+ ) -> CapacityAllocation:
816
+ """Resolve ``plan.policy.capacityAllocation`` from PROJECT-DEFINITION.
817
+
818
+ Resolution order (mirrors :func:`resolve_wip_cap`):
819
+
820
+ 1. A well-formed ``plan.policy.capacityAllocation`` block -> ``'typed'``.
821
+ 2. Missing -> framework default (``'default'``).
822
+ 3. Present-but-malformed -> framework default (``'default-on-error'``,
823
+ with ``error`` set so the caller can surface it).
824
+
825
+ Pure-stdlib; no live ``gh`` / cache calls. The ``cost`` unit is
826
+ returned verbatim -- the guarded advisory fallback is applied downstream
827
+ in :mod:`scripts.capacity_show` where grounded-actuals coverage can be
828
+ measured (OQ2).
829
+ """
830
+ data, err = load_project_definition(project_root)
831
+ if data is None:
832
+ return _default_capacity_allocation(source="default", error=err)
833
+
834
+ policy_block = _get_policy_block(data)
835
+ if "capacityAllocation" not in policy_block:
836
+ return _default_capacity_allocation(source="default")
837
+
838
+ raw = policy_block["capacityAllocation"]
839
+ validation_errors = validate_capacity_allocation(raw)
840
+ if validation_errors or not isinstance(raw, dict):
841
+ return _default_capacity_allocation(
842
+ source="default-on-error",
843
+ error=(
844
+ validation_errors[0]
845
+ if validation_errors
846
+ else "capacityAllocation must be an object"
847
+ ),
848
+ )
849
+
850
+ buckets = tuple(
851
+ CapacityBucket(bucket_id=bucket["id"], target=float(bucket["target"]))
852
+ for bucket in raw["buckets"]
853
+ )
854
+ default_bucket = raw.get("defaultBucket", "")
855
+ if not isinstance(default_bucket, str):
856
+ default_bucket = ""
857
+ return CapacityAllocation(
858
+ unit=raw.get("unit", DEFAULT_CAPACITY_UNIT),
859
+ window_days=int(raw["window"]),
860
+ enforcement=raw.get("enforcement", DEFAULT_CAPACITY_ENFORCEMENT),
861
+ min_sample_size=int(raw.get("minSampleSize", DEFAULT_CAPACITY_MIN_SAMPLE_SIZE)),
862
+ buckets=buckets,
863
+ default_bucket=default_bucket,
864
+ default_epic_estimate=int(
865
+ raw.get("defaultEpicEstimate", DEFAULT_EPIC_ESTIMATE)
866
+ ),
867
+ epic_staleness_days=int(
868
+ raw.get("epicStalenessDays", DEFAULT_EPIC_STALENESS_DAYS)
869
+ ),
870
+ source="typed",
871
+ error=None,
872
+ )
873
+
874
+
875
+ # ---------------------------------------------------------------------------
876
+ # Judgment-gate surface (#1419 Delivery Slice 3)
877
+ # ---------------------------------------------------------------------------
878
+ #
879
+ # ``plan.policy.judgmentGates`` declares risk-tiered gates that require human
880
+ # clearance before sensitive changes (secrets, infra, AGENTS.md / skills,
881
+ # installer) are dispatched. Each gate carries a stable ``id``, a ``class``
882
+ # (``mechanical`` -- mechanically detectable, fail-closed on detection; or
883
+ # ``declared`` -- depends on a human declaration, fail-open on omission), a
884
+ # ``match`` block that REUSES the triageAutoClassify DSL
885
+ # (``labels`` / ``body-text`` / ``state`` / ``age-days``) plus a NEW ``paths``
886
+ # glob predicate, a risk ``tier`` (``auto`` / ``review`` / ``block``), an
887
+ # optional ``requiredHumanReviewers`` count, and a ``reason``.
888
+ #
889
+ # ``plan.policy.judgmentGatesDisabled`` is a list of gate ids to disable --
890
+ # including the four DEFAULT-ON universal safety gates owned by
891
+ # ``scripts/verify_judgment_gates.py``.
892
+ #
893
+ # This module owns the TYPED SCHEMA + validation + resolver ONLY. The gate
894
+ # engine, the default-on universal gates, the pathspec matcher, and the
895
+ # clearance audit log live in ``scripts/verify_judgment_gates.py`` (the
896
+ # advisory ``task verify:judgment-gates`` surface). The capacityAllocation
897
+ # surface above is unaffected.
898
+
899
+ #: Recognised ``class`` values for a judgment gate.
900
+ GATE_CLASSES: frozenset[str] = frozenset({"mechanical", "declared"})
901
+
902
+ #: Recognised risk ``tier`` values.
903
+ GATE_TIERS: frozenset[str] = frozenset({"auto", "review", "block"})
904
+
905
+ #: Recognised ``match`` predicates (triage DSL + the new ``paths`` glob).
906
+ GATE_MATCH_PREDICATES: frozenset[str] = frozenset(
907
+ {"labels", "body-text", "paths", "state", "age-days"}
908
+ )
909
+
910
+ #: Recognised ``match.state`` values (mirrors triageAutoClassify).
911
+ GATE_MATCH_STATES: frozenset[str] = frozenset({"open", "closed"})
912
+
913
+
914
+ @dataclass(frozen=True)
915
+ class JudgmentGate:
916
+ """One resolved judgment gate from ``plan.policy.judgmentGates``."""
917
+
918
+ gate_id: str
919
+ gate_class: str # 'mechanical' | 'declared'
920
+ match: dict[str, Any]
921
+ tier: str # 'auto' | 'review' | 'block'
922
+ reason: str
923
+ required_human_reviewers: int = 0
924
+
925
+
926
+ @dataclass(frozen=True)
927
+ class JudgmentGatesPolicy:
928
+ """Resolved ``judgmentGates`` + ``judgmentGatesDisabled`` state.
929
+
930
+ ``source`` mirrors :class:`CapacityAllocation` semantics: ``'typed'`` when
931
+ a well-formed config is present, ``'default'`` when both fields are absent,
932
+ and ``'default-on-error'`` when present-but-malformed (``error`` carries
933
+ the first diagnostic so the caller can surface it).
934
+ """
935
+
936
+ gates: tuple[JudgmentGate, ...]
937
+ disabled: tuple[str, ...]
938
+ source: str # one of: 'typed', 'default', 'default-on-error'
939
+ error: str | None = None
940
+
941
+ @property
942
+ def configured(self) -> bool:
943
+ """True when a well-formed block with at least one consumer gate exists."""
944
+ return self.source == "typed" and bool(self.gates)
945
+
946
+
947
+ def _validate_str_list(value: Any, prefix: str, key: str) -> list[str]:
948
+ """Validate that ``value`` is a non-empty list of non-empty strings."""
949
+ if not isinstance(value, list) or not value:
950
+ return [f"{prefix}.{key} must be a non-empty list of strings"]
951
+ errors: list[str] = []
952
+ for j, item in enumerate(value):
953
+ if not isinstance(item, str) or not item:
954
+ errors.append(f"{prefix}.{key}[{j}] must be a non-empty string")
955
+ return errors
956
+
957
+
958
+ def _validate_glob_predicate(value: Any, prefix: str) -> list[str]:
959
+ """Validate the NEW ``paths`` glob predicate: ``{any-of: [glob, ...]}``."""
960
+ if not isinstance(value, dict):
961
+ return [f"{prefix} must be an object with an 'any-of' glob list"]
962
+ if "any-of" not in value:
963
+ return [f"{prefix} requires 'any-of'"]
964
+ return _validate_str_list(value["any-of"], prefix, "any-of")
965
+
966
+
967
+ def _validate_gate_labels(value: Any, prefix: str) -> list[str]:
968
+ """Validate the ``labels`` predicate (``any-of`` XOR ``all-of``)."""
969
+ if not isinstance(value, dict):
970
+ return [f"{prefix} must be an object"]
971
+ any_of = value.get("any-of")
972
+ all_of = value.get("all-of")
973
+ if any_of is None and all_of is None:
974
+ return [f"{prefix} requires 'any-of' or 'all-of'"]
975
+ if any_of is not None and all_of is not None:
976
+ return [f"{prefix}: 'any-of' and 'all-of' are mutually exclusive"]
977
+ key = "any-of" if any_of is not None else "all-of"
978
+ return _validate_str_list(value[key], prefix, key)
979
+
980
+
981
+ def _validate_gate_any_of(value: Any, prefix: str) -> list[str]:
982
+ """Validate the ``body-text`` predicate: ``{any-of: [text, ...]}``."""
983
+ if not isinstance(value, dict):
984
+ return [f"{prefix} must be an object"]
985
+ if "any-of" not in value:
986
+ return [f"{prefix} requires 'any-of'"]
987
+ return _validate_str_list(value["any-of"], prefix, "any-of")
988
+
989
+
990
+ def _validate_gate_age_days(value: Any, prefix: str) -> list[str]:
991
+ """Validate the ``age-days`` predicate: ``{gt: N}`` (non-negative int)."""
992
+ if not isinstance(value, dict):
993
+ return [f"{prefix} must be an object"]
994
+ if "gt" not in value:
995
+ return [f"{prefix} requires a 'gt' integer threshold"]
996
+ gt = value["gt"]
997
+ if not isinstance(gt, int) or isinstance(gt, bool) or gt < 0:
998
+ return [f"{prefix}.gt must be a non-negative integer; got {gt!r}"]
999
+ return []
1000
+
1001
+
1002
+ def _validate_gate_match(match: Any, prefix: str) -> list[str]:
1003
+ """Validate a gate ``match`` block (triage DSL predicates + ``paths``)."""
1004
+ if not isinstance(match, dict):
1005
+ return [f"{prefix} must be an object"]
1006
+ used = sorted(set(match) & GATE_MATCH_PREDICATES)
1007
+ if not used:
1008
+ return [f"{prefix} requires at least one of {sorted(GATE_MATCH_PREDICATES)}"]
1009
+ errors: list[str] = []
1010
+ # Reject unrecognised predicate keys so a misspelling (e.g. ``path`` for
1011
+ # ``paths``) fails validation loudly instead of being silently dropped at
1012
+ # match time -- the gate would otherwise appear valid but match as if that
1013
+ # predicate were absent.
1014
+ extra = sorted(set(match) - GATE_MATCH_PREDICATES)
1015
+ if extra:
1016
+ errors.append(
1017
+ f"{prefix} has unrecognised predicate(s) {extra}; "
1018
+ f"expected only {sorted(GATE_MATCH_PREDICATES)}"
1019
+ )
1020
+ if "paths" in match:
1021
+ errors.extend(_validate_glob_predicate(match["paths"], f"{prefix}.paths"))
1022
+ if "labels" in match:
1023
+ errors.extend(_validate_gate_labels(match["labels"], f"{prefix}.labels"))
1024
+ if "body-text" in match:
1025
+ errors.extend(
1026
+ _validate_gate_any_of(match["body-text"], f"{prefix}.body-text")
1027
+ )
1028
+ if "state" in match and match["state"] not in GATE_MATCH_STATES:
1029
+ errors.append(
1030
+ f"{prefix}.state must be one of {sorted(GATE_MATCH_STATES)}; "
1031
+ f"got {match['state']!r}"
1032
+ )
1033
+ if "age-days" in match:
1034
+ errors.extend(
1035
+ _validate_gate_age_days(match["age-days"], f"{prefix}.age-days")
1036
+ )
1037
+ return errors
1038
+
1039
+
1040
+ def _validate_single_gate(gate: Any, prefix: str) -> tuple[list[str], str | None]:
1041
+ """Validate one gate object. Returns ``(errors, gate_id_or_None)``."""
1042
+ if not isinstance(gate, dict):
1043
+ return [f"{prefix} must be an object; got {type(gate).__name__}"], None
1044
+ errors: list[str] = []
1045
+ gid = gate.get("id")
1046
+ resolved_id: str | None = None
1047
+ if not isinstance(gid, str) or not gid.strip():
1048
+ errors.append(f"{prefix}.id must be a non-empty string")
1049
+ else:
1050
+ resolved_id = gid
1051
+ gclass = gate.get("class")
1052
+ if gclass not in GATE_CLASSES:
1053
+ errors.append(
1054
+ f"{prefix}.class must be one of {sorted(GATE_CLASSES)}; got {gclass!r}"
1055
+ )
1056
+ tier = gate.get("tier")
1057
+ if tier not in GATE_TIERS:
1058
+ errors.append(
1059
+ f"{prefix}.tier must be one of {sorted(GATE_TIERS)}; got {tier!r}"
1060
+ )
1061
+ reason = gate.get("reason")
1062
+ if not isinstance(reason, str) or not reason.strip():
1063
+ errors.append(f"{prefix}.reason must be a non-empty string")
1064
+ if "requiredHumanReviewers" in gate:
1065
+ rhr = gate["requiredHumanReviewers"]
1066
+ if not isinstance(rhr, int) or isinstance(rhr, bool) or rhr < 0:
1067
+ errors.append(
1068
+ f"{prefix}.requiredHumanReviewers must be a non-negative integer; "
1069
+ f"got {rhr!r}"
1070
+ )
1071
+ errors.extend(_validate_gate_match(gate.get("match"), f"{prefix}.match"))
1072
+ return errors, resolved_id
1073
+
1074
+
1075
+ def validate_judgment_gates(value: Any) -> list[str]:
1076
+ """Validate a ``plan.policy.judgmentGates`` payload.
1077
+
1078
+ Returns a list of error strings (empty == valid). ``None`` / unset is
1079
+ valid (the resolver falls back to the framework default). Each gate is an
1080
+ object with ``id`` / ``class`` / ``tier`` / ``reason`` / ``match`` (and an
1081
+ optional ``requiredHumanReviewers``); gate ids must be unique.
1082
+ """
1083
+ errors: list[str] = []
1084
+ if value is None:
1085
+ return errors
1086
+ if not isinstance(value, list):
1087
+ errors.append(
1088
+ "plan.policy.judgmentGates must be a list of gate objects; got "
1089
+ f"{type(value).__name__}"
1090
+ )
1091
+ return errors
1092
+ ids: list[str] = []
1093
+ for idx, gate in enumerate(value):
1094
+ gate_errors, gate_id = _validate_single_gate(
1095
+ gate, f"plan.policy.judgmentGates[{idx}]"
1096
+ )
1097
+ errors.extend(gate_errors)
1098
+ if gate_id is not None:
1099
+ ids.append(gate_id)
1100
+ duplicates = sorted({g for g in ids if ids.count(g) > 1})
1101
+ if duplicates:
1102
+ errors.append(
1103
+ f"plan.policy.judgmentGates ids must be unique; duplicates: {duplicates}"
1104
+ )
1105
+ return errors
1106
+
1107
+
1108
+ def validate_judgment_gates_disabled(value: Any) -> list[str]:
1109
+ """Validate a ``plan.policy.judgmentGatesDisabled`` payload (list of ids)."""
1110
+ errors: list[str] = []
1111
+ if value is None:
1112
+ return errors
1113
+ if not isinstance(value, list):
1114
+ errors.append(
1115
+ "plan.policy.judgmentGatesDisabled must be a list of gate ids; got "
1116
+ f"{type(value).__name__}"
1117
+ )
1118
+ return errors
1119
+ for j, item in enumerate(value):
1120
+ if not isinstance(item, str) or not item.strip():
1121
+ errors.append(
1122
+ f"plan.policy.judgmentGatesDisabled[{j}] must be a non-empty string"
1123
+ )
1124
+ return errors
1125
+
1126
+
1127
+ def validate_judgment_gates_on_plan(plan: Any, filepath: Any) -> list[str]:
1128
+ """vbrief_validate hook: validate the judgment-gate fields (#1419).
1129
+
1130
+ Returns formatted error strings prefixed with ``<filepath>:``. Mirrors the
1131
+ :func:`validate_capacity_allocation_on_plan` hook shape. As with capacity
1132
+ in Slice 4, this hook is provided + unit-tested as the canonical
1133
+ validation entry point but is intentionally NOT yet spliced into
1134
+ ``scripts/vbrief_validate.py`` -- judgment gates are advisory in v1 and a
1135
+ malformed block self-heals to defaults; wiring it into the ``task check``
1136
+ validation aggregate is out-of-scope here (it would touch files outside
1137
+ this slice and risk a fail-closed posture on the framework's own tree).
1138
+ """
1139
+ out: list[str] = []
1140
+ if not isinstance(plan, dict):
1141
+ return out
1142
+ policy = plan.get("policy")
1143
+ if not isinstance(policy, dict):
1144
+ return out
1145
+ if "judgmentGates" in policy:
1146
+ for err in validate_judgment_gates(policy["judgmentGates"]):
1147
+ out.append(f"{filepath}: {err} (#1419)")
1148
+ if "judgmentGatesDisabled" in policy:
1149
+ for err in validate_judgment_gates_disabled(policy["judgmentGatesDisabled"]):
1150
+ out.append(f"{filepath}: {err} (#1419)")
1151
+ return out
1152
+
1153
+
1154
+ def _default_judgment_gates_policy(
1155
+ *, source: str, error: str | None = None
1156
+ ) -> JudgmentGatesPolicy:
1157
+ return JudgmentGatesPolicy(gates=(), disabled=(), source=source, error=error)
1158
+
1159
+
1160
+ def resolve_judgment_gates(
1161
+ project_root: Path | None = None,
1162
+ ) -> JudgmentGatesPolicy:
1163
+ """Resolve ``judgmentGates`` + ``judgmentGatesDisabled`` from PROJECT-DEFINITION.
1164
+
1165
+ Resolution order (mirrors :func:`resolve_capacity_allocation`):
1166
+
1167
+ 1. A well-formed config -> ``'typed'``.
1168
+ 2. Both fields absent -> framework default (``'default'``, empty).
1169
+ 3. Present-but-malformed -> framework default (``'default-on-error'`` with
1170
+ ``error`` set so the caller can surface it -- the gate engine
1171
+ self-heals to the universal gates only).
1172
+
1173
+ Pure-stdlib; no live ``gh`` / cache calls.
1174
+ """
1175
+ data, err = load_project_definition(project_root)
1176
+ if data is None:
1177
+ return _default_judgment_gates_policy(source="default", error=err)
1178
+
1179
+ policy_block = _get_policy_block(data)
1180
+ raw_gates = policy_block.get("judgmentGates")
1181
+ raw_disabled = policy_block.get("judgmentGatesDisabled")
1182
+ if raw_gates is None and raw_disabled is None:
1183
+ return _default_judgment_gates_policy(source="default")
1184
+
1185
+ errors = validate_judgment_gates(raw_gates) + validate_judgment_gates_disabled(
1186
+ raw_disabled
1187
+ )
1188
+ if errors:
1189
+ return _default_judgment_gates_policy(
1190
+ source="default-on-error", error=errors[0]
1191
+ )
1192
+
1193
+ gates = tuple(
1194
+ JudgmentGate(
1195
+ gate_id=gate["id"],
1196
+ gate_class=gate["class"],
1197
+ match=dict(gate["match"]),
1198
+ tier=gate["tier"],
1199
+ reason=gate["reason"],
1200
+ required_human_reviewers=int(gate.get("requiredHumanReviewers", 0)),
1201
+ )
1202
+ for gate in (raw_gates or [])
1203
+ )
1204
+ disabled = tuple(d for d in (raw_disabled or []) if isinstance(d, str))
1205
+ return JudgmentGatesPolicy(
1206
+ gates=gates, disabled=disabled, source="typed", error=None
1207
+ )
1208
+
1209
+
1210
+ # ---------------------------------------------------------------------------
1211
+ # Pending human-clearance backlog + earned-autonomy dial (#1419 Slice 5)
1212
+ # ---------------------------------------------------------------------------
1213
+ #
1214
+ # Two ADVISORY surfaces sit on top of the judgment-gate clearance machinery
1215
+ # (``scripts/verify_judgment_gates.py``, #1419 Slice 3):
1216
+ #
1217
+ # 1. The PENDING HUMAN-DECISIONS BACKLOG -- a durable append-only audit log
1218
+ # (``vbrief/.audit/pending-human-decisions.jsonl``) of decisions that need
1219
+ # human adjudication but are not yet resolved. Each line is one event for a
1220
+ # ``decision_id``: a ``pending`` event opens the decision (a judgment gate
1221
+ # fired without clearance, or -- per OQ4 -- a multi-LLM reviewer split on a
1222
+ # P0/P1 finding escalated), and a later ``resolved`` event closes it. The
1223
+ # backlog count is the number of decision_ids whose LATEST event is still
1224
+ # ``pending``. ``capacity:show`` and ``triage:welcome`` surface that count
1225
+ # and, when it exceeds the Tier-1 threshold, emit a nudge so ``wipCap`` can
1226
+ # be tuned to real human-review throughput. The log lives beside (but is
1227
+ # distinct from) the Slice-3 clearance log so this module does not have to
1228
+ # edit ``verify_judgment_gates.py``.
1229
+ #
1230
+ # 2. The EARNED-AUTONOMY DIAL -- a per-project (optionally per gate-id) policy
1231
+ # that RECOMMENDS one of three levels (Observe / Escalate / Execute,
1232
+ # default Escalate). The dial signal is the clearance-override rate
1233
+ # (primary) plus the rework rate (guardrail) over the capacity window. It
1234
+ # advances asymmetrically (advance only when override < advanceMax AND
1235
+ # rework <= baseline AND the resolved-decision sample is large enough;
1236
+ # retreat IMMEDIATELY on any P0 reversal or override > retreatRate). It is
1237
+ # ADVISORY-ONLY in v1: :func:`recommend_autonomy_level` returns a
1238
+ # recommendation a human confirms -- nothing here auto-ratchets a level or
1239
+ # auto-reduces a gate's required clearances.
1240
+
1241
+ #: Durable, operator-private pending-decisions backlog log location. Shares
1242
+ #: the ``vbrief/.audit/`` directory with the Slice-3 clearance log but is a
1243
+ #: separate file (this module owns the backlog; the clearance log is owned by
1244
+ #: ``scripts/verify_judgment_gates.py``).
1245
+ PENDING_DECISIONS_AUDIT_DIR_REL: str = "vbrief/.audit"
1246
+ PENDING_DECISIONS_LOG_NAME: str = "pending-human-decisions.jsonl"
1247
+
1248
+ #: Decision-event status tokens. Compare via these constants so a rename
1249
+ #: surfaces as a NameError at import time rather than a silent mismatch.
1250
+ DECISION_STATUS_PENDING: str = "pending"
1251
+ DECISION_STATUS_RESOLVED: str = "resolved"
1252
+
1253
+ #: Backlog size at which ``capacity:show`` / ``triage:welcome`` emit the
1254
+ #: Tier-1 pending-decisions nudge (count STRICTLY greater than this fires).
1255
+ DEFAULT_PENDING_DECISIONS_THRESHOLD: int = 5
1256
+
1257
+ #: ``kind`` tag for a pending decision opened by a multi-LLM reviewer split
1258
+ #: (OQ4). Mirrors the #526 errored-state escalation contract.
1259
+ REVIEWER_DISAGREEMENT_KIND: str = "reviewer-disagreement"
1260
+
1261
+ #: Severities that escalate a reviewer split on a review/block-tier gate
1262
+ #: (OQ4: "a P0/P1 reviewer split or errored-on-HEAD escalates to a human").
1263
+ _ESCALATING_SEVERITIES: frozenset[str] = frozenset({"p0", "p1"})
1264
+
1265
+
1266
+ def _parse_iso_ts(value: Any) -> datetime | None:
1267
+ """Parse an ISO-8601 ``...Z`` timestamp to an aware datetime, or None."""
1268
+ if not isinstance(value, str) or not value:
1269
+ return None
1270
+ try:
1271
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
1272
+ except ValueError:
1273
+ return None
1274
+ if parsed.tzinfo is None:
1275
+ parsed = parsed.replace(tzinfo=UTC)
1276
+ return parsed
1277
+
1278
+
1279
+ def pending_decisions_log_path(project_root: Path) -> Path:
1280
+ """Resolve the durable pending-decisions backlog log under *project_root*."""
1281
+ return project_root / PENDING_DECISIONS_AUDIT_DIR_REL / PENDING_DECISIONS_LOG_NAME
1282
+
1283
+
1284
+ def _append_decision_event(
1285
+ project_root: Path,
1286
+ *,
1287
+ decision_id: str,
1288
+ status: str,
1289
+ kind: str,
1290
+ gate_id: str,
1291
+ severity: str,
1292
+ reviewers: list[str] | None,
1293
+ actor: str,
1294
+ reason: str,
1295
+ override: bool,
1296
+ p0_reversal: bool,
1297
+ now: datetime | None,
1298
+ log_path: Path | None,
1299
+ ) -> dict[str, Any]:
1300
+ """Append one decision event to the backlog log and return the record."""
1301
+ if not isinstance(decision_id, str) or not decision_id.strip():
1302
+ raise ValueError("decision_id must be a non-empty string")
1303
+ path = log_path or pending_decisions_log_path(project_root)
1304
+ path.parent.mkdir(parents=True, exist_ok=True)
1305
+ timestamp = (
1306
+ _now_iso()
1307
+ if now is None
1308
+ else now.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
1309
+ )
1310
+ entry: dict[str, Any] = {
1311
+ "decision_id": decision_id,
1312
+ "timestamp": timestamp,
1313
+ "status": status,
1314
+ "kind": kind,
1315
+ "gate_id": gate_id,
1316
+ "severity": severity,
1317
+ "reviewers": list(reviewers or []),
1318
+ "actor": actor,
1319
+ "reason": reason,
1320
+ "override": bool(override),
1321
+ "p0_reversal": bool(p0_reversal),
1322
+ }
1323
+ line = json.dumps(entry, sort_keys=True, ensure_ascii=False)
1324
+ with open(path, "a", encoding="utf-8") as handle:
1325
+ handle.write(line + "\n")
1326
+ return entry
1327
+
1328
+
1329
+ def record_pending_decision(
1330
+ project_root: Path,
1331
+ *,
1332
+ decision_id: str,
1333
+ kind: str,
1334
+ gate_id: str = "",
1335
+ severity: str = "",
1336
+ reviewers: list[str] | None = None,
1337
+ actor: str = "agent",
1338
+ reason: str = "",
1339
+ now: datetime | None = None,
1340
+ log_path: Path | None = None,
1341
+ ) -> dict[str, Any]:
1342
+ """Open a pending human decision (append a ``pending`` event).
1343
+
1344
+ Idempotency note: each call appends an event. Opening the same
1345
+ ``decision_id`` twice without an intervening resolution leaves the
1346
+ decision pending (the latest event still says ``pending``), so the
1347
+ backlog count is unchanged -- the audit trail keeps both rows.
1348
+ """
1349
+ return _append_decision_event(
1350
+ project_root,
1351
+ decision_id=decision_id,
1352
+ status=DECISION_STATUS_PENDING,
1353
+ kind=kind,
1354
+ gate_id=gate_id,
1355
+ severity=severity,
1356
+ reviewers=reviewers,
1357
+ actor=actor,
1358
+ reason=reason,
1359
+ override=False,
1360
+ p0_reversal=False,
1361
+ now=now,
1362
+ log_path=log_path,
1363
+ )
1364
+
1365
+
1366
+ def resolve_pending_decision(
1367
+ project_root: Path,
1368
+ *,
1369
+ decision_id: str,
1370
+ kind: str = "",
1371
+ gate_id: str = "",
1372
+ severity: str = "",
1373
+ reviewers: list[str] | None = None,
1374
+ actor: str = "operator",
1375
+ reason: str = "",
1376
+ override: bool = False,
1377
+ p0_reversal: bool = False,
1378
+ now: datetime | None = None,
1379
+ log_path: Path | None = None,
1380
+ ) -> dict[str, Any]:
1381
+ """Close a pending human decision (append a ``resolved`` event).
1382
+
1383
+ ``override`` records that the human reversed the autonomy recommendation
1384
+ (the primary dial signal); ``p0_reversal`` records that the resolution
1385
+ reversed a P0 outcome (the immediate-retreat trigger). Both are read back
1386
+ by :func:`summarize_decision_backlog` to drive the dial.
1387
+ """
1388
+ return _append_decision_event(
1389
+ project_root,
1390
+ decision_id=decision_id,
1391
+ status=DECISION_STATUS_RESOLVED,
1392
+ kind=kind,
1393
+ gate_id=gate_id,
1394
+ severity=severity,
1395
+ reviewers=reviewers,
1396
+ actor=actor,
1397
+ reason=reason,
1398
+ override=override,
1399
+ p0_reversal=p0_reversal,
1400
+ now=now,
1401
+ log_path=log_path,
1402
+ )
1403
+
1404
+
1405
+ def read_decision_events(
1406
+ project_root: Path, *, log_path: Path | None = None
1407
+ ) -> list[dict[str, Any]]:
1408
+ """Return every well-formed decision event in insertion (chronological) order.
1409
+
1410
+ Tolerant of malformed / partial lines (skips them) so a torn write never
1411
+ crashes a backlog summary or a session-start surface.
1412
+ """
1413
+ path = log_path or pending_decisions_log_path(project_root)
1414
+ if not path.is_file():
1415
+ return []
1416
+ out: list[dict[str, Any]] = []
1417
+ for raw in path.read_text(encoding="utf-8").splitlines():
1418
+ stripped = raw.strip()
1419
+ if not stripped:
1420
+ continue
1421
+ try:
1422
+ obj = json.loads(stripped)
1423
+ except json.JSONDecodeError:
1424
+ continue
1425
+ if isinstance(obj, dict) and isinstance(obj.get("decision_id"), str):
1426
+ out.append(obj)
1427
+ return out
1428
+
1429
+
1430
+ @dataclass(frozen=True)
1431
+ class DecisionBacklog:
1432
+ """Rolled-up view of the pending-decisions log.
1433
+
1434
+ ``pending_count`` / ``by_kind`` describe the CURRENT backlog (latest event
1435
+ per ``decision_id`` is ``pending``). The remaining fields summarise
1436
+ decisions RESOLVED within the accounting window and feed the autonomy dial.
1437
+ """
1438
+
1439
+ pending_count: int
1440
+ by_kind: dict[str, int]
1441
+ resolved_in_window: int
1442
+ override_count: int
1443
+ p0_reversal_in_window: bool
1444
+
1445
+ @property
1446
+ def override_rate(self) -> float:
1447
+ """Clearance-override rate over resolved-in-window decisions (0.0..1.0)."""
1448
+ if self.resolved_in_window <= 0:
1449
+ return 0.0
1450
+ return self.override_count / self.resolved_in_window
1451
+
1452
+
1453
+ def summarize_decision_backlog(
1454
+ project_root: Path,
1455
+ *,
1456
+ now: datetime | None = None,
1457
+ window_days: int | None = None,
1458
+ events: list[dict[str, Any]] | None = None,
1459
+ ) -> DecisionBacklog:
1460
+ """Summarise the pending-decisions log into a :class:`DecisionBacklog`.
1461
+
1462
+ The latest event per ``decision_id`` wins (the log is append-only and
1463
+ chronological). When *window_days* is provided, only decisions whose
1464
+ resolving event falls inside the trailing window contribute to the
1465
+ override / rework signal; the pending count is always the live backlog.
1466
+ """
1467
+ records = events if events is not None else read_decision_events(project_root)
1468
+ latest: dict[str, dict[str, Any]] = {}
1469
+ for event in records:
1470
+ decision_id = event.get("decision_id")
1471
+ if isinstance(decision_id, str) and decision_id:
1472
+ latest[decision_id] = event # later events override earlier ones
1473
+
1474
+ by_kind: dict[str, int] = {}
1475
+ pending_count = 0
1476
+ for event in latest.values():
1477
+ if event.get("status") == DECISION_STATUS_PENDING:
1478
+ pending_count += 1
1479
+ kind = event.get("kind") or "unspecified"
1480
+ by_kind[kind] = by_kind.get(kind, 0) + 1
1481
+
1482
+ now_dt = now or datetime.now(UTC)
1483
+ resolved_in_window = 0
1484
+ override_count = 0
1485
+ p0_reversal = False
1486
+ for event in latest.values():
1487
+ if event.get("status") != DECISION_STATUS_RESOLVED:
1488
+ continue
1489
+ if window_days is not None:
1490
+ stamp = _parse_iso_ts(event.get("timestamp"))
1491
+ if stamp is None:
1492
+ continue
1493
+ age_days = (now_dt - stamp).total_seconds() / 86400.0
1494
+ if age_days < 0 or age_days > window_days:
1495
+ continue
1496
+ resolved_in_window += 1
1497
+ if event.get("override") is True:
1498
+ override_count += 1
1499
+ if event.get("p0_reversal") is True:
1500
+ p0_reversal = True
1501
+ return DecisionBacklog(
1502
+ pending_count=pending_count,
1503
+ by_kind=by_kind,
1504
+ resolved_in_window=resolved_in_window,
1505
+ override_count=override_count,
1506
+ p0_reversal_in_window=p0_reversal,
1507
+ )
1508
+
1509
+
1510
+ def count_pending_decisions(
1511
+ project_root: Path, *, events: list[dict[str, Any]] | None = None
1512
+ ) -> int:
1513
+ """Convenience: the current pending-human-decisions backlog count."""
1514
+ return summarize_decision_backlog(project_root, events=events).pending_count
1515
+
1516
+
1517
+ def pending_decisions_nudge_line(
1518
+ count: int, threshold: int = DEFAULT_PENDING_DECISIONS_THRESHOLD
1519
+ ) -> str:
1520
+ """Return the Tier-1 backlog nudge string, or ``""`` when at/under threshold.
1521
+
1522
+ Shared by ``capacity:show`` and ``triage:welcome`` so the wording stays in
1523
+ lockstep across both surfaces.
1524
+ """
1525
+ if count <= threshold:
1526
+ return ""
1527
+ return (
1528
+ f"[TIER-1] pending human-clearance backlog: {count} decision(s) "
1529
+ f"awaiting adjudication (> threshold {threshold}). Tune wipCap to real "
1530
+ "review throughput or clear the backlog before dispatching more work."
1531
+ )
1532
+
1533
+
1534
+ # Reviewer-disagreement routing (OQ4) -- reuse of the #526 errored-state path.
1535
+
1536
+
1537
+ @dataclass(frozen=True)
1538
+ class ReviewerRouting:
1539
+ """Routing decision for a multi-LLM reviewer disagreement (OQ4).
1540
+
1541
+ ``escalates`` is the load-bearing field: when True the disagreement goes to
1542
+ a human and (via :func:`escalate_reviewer_disagreement`) increments the
1543
+ pending-decisions backlog. ``upgraded`` records the auto->review upgrade an
1544
+ contested P0 triggers.
1545
+ """
1546
+
1547
+ severity: str
1548
+ requested_tier: str
1549
+ effective_tier: str
1550
+ escalates: bool
1551
+ required_human_reviewers: int
1552
+ upgraded: bool
1553
+ reason: str
1554
+
1555
+
1556
+ def route_reviewer_disagreement(
1557
+ *, severity: str, tier: str, errored_on_head: bool = False
1558
+ ) -> ReviewerRouting:
1559
+ """Route a multi-LLM reviewer split per the OQ4 tier-interaction rule.
1560
+
1561
+ * ``block`` -- fails closed: any reviewer split (or an errored-on-HEAD
1562
+ review) escalates to a human.
1563
+ * ``review`` -- a P0/P1 split or an errored-on-HEAD review escalates to 1
1564
+ human; a lower-severity split stays advisory and defers to ordering.
1565
+ * ``auto`` -- only a contested P0 (or errored-on-HEAD) upgrades the gate to
1566
+ ``review`` and escalates; lower-severity auto splits do not escalate.
1567
+
1568
+ Advisory where it touches directive's own flow -- this returns the routing;
1569
+ it never fails the build closed.
1570
+ """
1571
+ sev = (severity or "").strip().lower()
1572
+ requested = (tier or "").strip().lower()
1573
+ escalating_sev = sev in _ESCALATING_SEVERITIES or errored_on_head
1574
+
1575
+ if requested == "block":
1576
+ return ReviewerRouting(
1577
+ severity=sev,
1578
+ requested_tier="block",
1579
+ effective_tier="block",
1580
+ escalates=True,
1581
+ required_human_reviewers=1,
1582
+ upgraded=False,
1583
+ reason="block-tier reviewer split fails closed -- human sign-off required",
1584
+ )
1585
+ if requested == "review":
1586
+ if escalating_sev:
1587
+ # Distinguish the escalation trigger (mirrors the auto-tier branch
1588
+ # below): an errored-on-HEAD review on a low-severity split is not
1589
+ # a severity-driven escalation, so do not label it with `sev`.
1590
+ review_reason = (
1591
+ "errored-on-HEAD review on a review-tier gate escalates to 1 human"
1592
+ if errored_on_head and sev not in _ESCALATING_SEVERITIES
1593
+ else f"review-tier {sev or 'errored'} reviewer split escalates to 1 human"
1594
+ )
1595
+ return ReviewerRouting(
1596
+ severity=sev,
1597
+ requested_tier="review",
1598
+ effective_tier="review",
1599
+ escalates=True,
1600
+ required_human_reviewers=1,
1601
+ upgraded=False,
1602
+ reason=review_reason,
1603
+ )
1604
+ return ReviewerRouting(
1605
+ severity=sev,
1606
+ requested_tier="review",
1607
+ effective_tier="review",
1608
+ escalates=False,
1609
+ required_human_reviewers=0,
1610
+ upgraded=False,
1611
+ reason="review-tier reviewer split below P1 -- advisory, deferred to ordering",
1612
+ )
1613
+ if requested == "auto":
1614
+ if sev == "p0" or errored_on_head:
1615
+ # Distinguish the two auto->review upgrade triggers so the audit
1616
+ # reason is accurate (an errored-on-HEAD review is not a P0 split).
1617
+ auto_reason = (
1618
+ "errored-on-HEAD review on an auto-tier gate upgrades to "
1619
+ "review (1 human)"
1620
+ if errored_on_head and sev != "p0"
1621
+ else "contested P0 on an auto-tier gate upgrades to review (1 human)"
1622
+ )
1623
+ return ReviewerRouting(
1624
+ severity=sev,
1625
+ requested_tier="auto",
1626
+ effective_tier="review",
1627
+ escalates=True,
1628
+ required_human_reviewers=1,
1629
+ upgraded=True,
1630
+ reason=auto_reason,
1631
+ )
1632
+ return ReviewerRouting(
1633
+ severity=sev,
1634
+ requested_tier="auto",
1635
+ effective_tier="auto",
1636
+ escalates=False,
1637
+ required_human_reviewers=0,
1638
+ upgraded=False,
1639
+ reason="auto-tier reviewer split below P0 -- no escalation (advisory)",
1640
+ )
1641
+ # Unknown tier: be conservative and escalate when the severity warrants it.
1642
+ return ReviewerRouting(
1643
+ severity=sev,
1644
+ requested_tier=requested,
1645
+ effective_tier=requested,
1646
+ escalates=escalating_sev,
1647
+ required_human_reviewers=1 if escalating_sev else 0,
1648
+ upgraded=False,
1649
+ reason=(
1650
+ "unknown tier -- escalating on P0/P1 by default"
1651
+ if escalating_sev
1652
+ else "unknown tier -- no escalation"
1653
+ ),
1654
+ )
1655
+
1656
+
1657
+ def escalate_reviewer_disagreement(
1658
+ project_root: Path,
1659
+ *,
1660
+ decision_id: str,
1661
+ severity: str,
1662
+ tier: str,
1663
+ errored_on_head: bool = False,
1664
+ reviewers: list[str] | None = None,
1665
+ actor: str = "agent",
1666
+ reason: str = "",
1667
+ now: datetime | None = None,
1668
+ log_path: Path | None = None,
1669
+ ) -> ReviewerRouting:
1670
+ """Route a reviewer split and, when it escalates, open a pending decision.
1671
+
1672
+ Returns the :class:`ReviewerRouting`. When ``routing.escalates`` is True a
1673
+ ``pending`` event is appended to the backlog (incrementing the count); when
1674
+ it is False nothing is written (advisory, deferred to ordering).
1675
+ """
1676
+ routing = route_reviewer_disagreement(
1677
+ severity=severity, tier=tier, errored_on_head=errored_on_head
1678
+ )
1679
+ if routing.escalates:
1680
+ record_pending_decision(
1681
+ project_root,
1682
+ decision_id=decision_id,
1683
+ kind=REVIEWER_DISAGREEMENT_KIND,
1684
+ severity=routing.severity,
1685
+ reviewers=reviewers,
1686
+ actor=actor,
1687
+ reason=reason or routing.reason,
1688
+ now=now,
1689
+ log_path=log_path,
1690
+ )
1691
+ return routing
1692
+
1693
+
1694
+ # Earned-autonomy dial ------------------------------------------------------
1695
+
1696
+ #: Dial levels, ordered conservative -> permissive. The dial advances one step
1697
+ #: right and retreats one step left.
1698
+ AUTONOMY_LEVELS: tuple[str, ...] = ("observe", "escalate", "execute")
1699
+ DEFAULT_AUTONOMY_LEVEL: str = "escalate"
1700
+
1701
+ #: Recommendation actions emitted by :func:`recommend_autonomy_level`.
1702
+ AUTONOMY_ACTION_ADVANCE: str = "advance"
1703
+ AUTONOMY_ACTION_HOLD: str = "hold"
1704
+ AUTONOMY_ACTION_RETREAT: str = "retreat"
1705
+
1706
+ #: Advance only when the clearance-override rate is STRICTLY below this.
1707
+ DEFAULT_AUTONOMY_ADVANCE_OVERRIDE_MAX: float = 0.05
1708
+ #: Retreat immediately when the override rate STRICTLY exceeds this.
1709
+ DEFAULT_AUTONOMY_RETREAT_OVERRIDE_RATE: float = 0.20
1710
+ #: Rework-rate guardrail: advance only when rework <= this baseline.
1711
+ DEFAULT_AUTONOMY_REWORK_BASELINE: float = 0.15
1712
+ #: Minimum resolved-decision sample before an advance is considered.
1713
+ DEFAULT_AUTONOMY_MIN_SAMPLE_SIZE: int = 20
1714
+
1715
+
1716
+ @dataclass(frozen=True)
1717
+ class AutonomyPolicy:
1718
+ """Resolved ``plan.policy.autonomy`` state.
1719
+
1720
+ ``source`` mirrors :class:`CapacityAllocation` semantics (``'typed'`` /
1721
+ ``'default'`` / ``'default-on-error'``). ``gate_levels`` carries optional
1722
+ per-gate-id level overrides on top of ``default_level``.
1723
+ """
1724
+
1725
+ enabled: bool
1726
+ default_level: str
1727
+ min_sample_size: int
1728
+ advance_override_max: float
1729
+ retreat_override_rate: float
1730
+ rework_baseline: float
1731
+ gate_levels: dict[str, str]
1732
+ source: str # one of: 'typed', 'default', 'default-on-error'
1733
+ error: str | None = None
1734
+
1735
+ @property
1736
+ def configured(self) -> bool:
1737
+ """True when a well-formed ``autonomy`` block is present."""
1738
+ return self.source == "typed"
1739
+
1740
+ def level_for(self, gate_id: str | None = None) -> str:
1741
+ """Resolved level for *gate_id* (per-gate override, else the default)."""
1742
+ if gate_id and gate_id in self.gate_levels:
1743
+ return self.gate_levels[gate_id]
1744
+ return self.default_level
1745
+
1746
+
1747
+ @dataclass(frozen=True)
1748
+ class AutonomyRecommendation:
1749
+ """Advisory autonomy-level recommendation. NEVER auto-applied (v1).
1750
+
1751
+ ``advisory`` is True for every recommendation in v1 -- the dial RECOMMENDS
1752
+ a level flip and a human confirms it; nothing ratchets automatically.
1753
+ """
1754
+
1755
+ current_level: str
1756
+ recommended_level: str
1757
+ action: str # 'advance' | 'hold' | 'retreat'
1758
+ rationale: str
1759
+ gate_id: str | None = None
1760
+ advisory: bool = True
1761
+
1762
+ @property
1763
+ def reduces_required_clearances(self) -> bool:
1764
+ """Advancing WOULD reduce required human clearances (if confirmed)."""
1765
+ return self.action == AUTONOMY_ACTION_ADVANCE
1766
+
1767
+ @property
1768
+ def restores_required_clearances(self) -> bool:
1769
+ """Retreating restores required human clearances (if confirmed)."""
1770
+ return self.action == AUTONOMY_ACTION_RETREAT
1771
+
1772
+
1773
+ def _validate_autonomy_gates(gates: Any) -> list[str]:
1774
+ """Validate the optional ``autonomy.gates`` per-gate-id level map."""
1775
+ if not isinstance(gates, dict):
1776
+ return [
1777
+ "plan.policy.autonomy.gates must be an object mapping gate-id -> level"
1778
+ ]
1779
+ errors: list[str] = []
1780
+ for gid, level in gates.items():
1781
+ if not isinstance(gid, str) or not gid.strip():
1782
+ errors.append(
1783
+ "plan.policy.autonomy.gates keys must be non-empty gate-id strings"
1784
+ )
1785
+ if level not in AUTONOMY_LEVELS:
1786
+ errors.append(
1787
+ f"plan.policy.autonomy.gates[{gid!r}] must be one of "
1788
+ f"{sorted(AUTONOMY_LEVELS)}; got {level!r}"
1789
+ )
1790
+ return errors
1791
+
1792
+
1793
+ def validate_autonomy(value: Any) -> list[str]:
1794
+ """Validate a ``plan.policy.autonomy`` payload.
1795
+
1796
+ Returns a list of error strings (empty == valid). ``None`` / unset is
1797
+ valid (the resolver falls back to the framework default).
1798
+ """
1799
+ errors: list[str] = []
1800
+ if value is None:
1801
+ return errors
1802
+ if not isinstance(value, dict):
1803
+ errors.append(
1804
+ f"plan.policy.autonomy must be an object; got {type(value).__name__}"
1805
+ )
1806
+ return errors
1807
+ if "enabled" in value and not isinstance(value["enabled"], bool):
1808
+ errors.append("plan.policy.autonomy.enabled must be a boolean")
1809
+ if "defaultLevel" in value and value["defaultLevel"] not in AUTONOMY_LEVELS:
1810
+ errors.append(
1811
+ "plan.policy.autonomy.defaultLevel must be one of "
1812
+ f"{sorted(AUTONOMY_LEVELS)}; got {value['defaultLevel']!r}"
1813
+ )
1814
+ if "minSampleSize" in value:
1815
+ mss = value["minSampleSize"]
1816
+ if not isinstance(mss, int) or isinstance(mss, bool) or mss < 0:
1817
+ errors.append(
1818
+ "plan.policy.autonomy.minSampleSize must be a non-negative "
1819
+ f"integer; got {mss!r}"
1820
+ )
1821
+ for key in ("advanceOverrideRateMax", "retreatOverrideRate", "reworkBaseline"):
1822
+ if key in value:
1823
+ rate = value[key]
1824
+ if not _is_number(rate) or not 0.0 <= float(rate) <= 1.0:
1825
+ errors.append(
1826
+ f"plan.policy.autonomy.{key} must be a number between 0.0 "
1827
+ f"and 1.0; got {rate!r}"
1828
+ )
1829
+ if "gates" in value:
1830
+ errors.extend(_validate_autonomy_gates(value["gates"]))
1831
+ return errors
1832
+
1833
+
1834
+ def validate_autonomy_on_plan(plan: Any, filepath: Any) -> list[str]:
1835
+ """vbrief_validate hook: validate ``plan.policy.autonomy`` (#1419).
1836
+
1837
+ Mirrors :func:`validate_capacity_allocation_on_plan`. Provided + unit-tested
1838
+ as the canonical validation entry point but intentionally NOT yet spliced
1839
+ into ``scripts/vbrief_validate.py`` -- the autonomy dial is advisory in v1
1840
+ and a malformed block self-heals to defaults, so wiring it into the
1841
+ ``task check`` validation aggregate (a fail-closed surface on the
1842
+ framework's own tree) is a follow-up slice's concern.
1843
+ """
1844
+ out: list[str] = []
1845
+ if not isinstance(plan, dict):
1846
+ return out
1847
+ policy = plan.get("policy")
1848
+ if not isinstance(policy, dict) or "autonomy" not in policy:
1849
+ return out
1850
+ for err in validate_autonomy(policy["autonomy"]):
1851
+ out.append(f"{filepath}: {err} (#1419)")
1852
+ return out
1853
+
1854
+
1855
+ def _default_autonomy_policy(
1856
+ *, source: str, error: str | None = None
1857
+ ) -> AutonomyPolicy:
1858
+ return AutonomyPolicy(
1859
+ enabled=True,
1860
+ default_level=DEFAULT_AUTONOMY_LEVEL,
1861
+ min_sample_size=DEFAULT_AUTONOMY_MIN_SAMPLE_SIZE,
1862
+ advance_override_max=DEFAULT_AUTONOMY_ADVANCE_OVERRIDE_MAX,
1863
+ retreat_override_rate=DEFAULT_AUTONOMY_RETREAT_OVERRIDE_RATE,
1864
+ rework_baseline=DEFAULT_AUTONOMY_REWORK_BASELINE,
1865
+ gate_levels={},
1866
+ source=source,
1867
+ error=error,
1868
+ )
1869
+
1870
+
1871
+ def resolve_autonomy(project_root: Path | None = None) -> AutonomyPolicy:
1872
+ """Resolve ``plan.policy.autonomy`` from PROJECT-DEFINITION.
1873
+
1874
+ Resolution order (mirrors :func:`resolve_capacity_allocation`):
1875
+
1876
+ 1. A well-formed ``autonomy`` block -> ``'typed'``.
1877
+ 2. Missing -> framework default (``'default'``).
1878
+ 3. Present-but-malformed -> framework default (``'default-on-error'`` with
1879
+ ``error`` set so the caller can surface it).
1880
+
1881
+ Pure-stdlib; no live ``gh`` / cache calls.
1882
+ """
1883
+ data, err = load_project_definition(project_root)
1884
+ if data is None:
1885
+ return _default_autonomy_policy(source="default", error=err)
1886
+ policy_block = _get_policy_block(data)
1887
+ if "autonomy" not in policy_block:
1888
+ return _default_autonomy_policy(source="default")
1889
+ raw = policy_block["autonomy"]
1890
+ errors = validate_autonomy(raw)
1891
+ if errors or not isinstance(raw, dict):
1892
+ return _default_autonomy_policy(
1893
+ source="default-on-error",
1894
+ error=errors[0] if errors else "autonomy must be an object",
1895
+ )
1896
+ gate_levels = {
1897
+ gid: level
1898
+ for gid, level in (raw.get("gates") or {}).items()
1899
+ if isinstance(gid, str)
1900
+ }
1901
+ return AutonomyPolicy(
1902
+ enabled=bool(raw.get("enabled", True)),
1903
+ default_level=raw.get("defaultLevel", DEFAULT_AUTONOMY_LEVEL),
1904
+ min_sample_size=int(raw.get("minSampleSize", DEFAULT_AUTONOMY_MIN_SAMPLE_SIZE)),
1905
+ advance_override_max=float(
1906
+ raw.get("advanceOverrideRateMax", DEFAULT_AUTONOMY_ADVANCE_OVERRIDE_MAX)
1907
+ ),
1908
+ retreat_override_rate=float(
1909
+ raw.get("retreatOverrideRate", DEFAULT_AUTONOMY_RETREAT_OVERRIDE_RATE)
1910
+ ),
1911
+ rework_baseline=float(
1912
+ raw.get("reworkBaseline", DEFAULT_AUTONOMY_REWORK_BASELINE)
1913
+ ),
1914
+ gate_levels=gate_levels,
1915
+ source="typed",
1916
+ error=None,
1917
+ )
1918
+
1919
+
1920
+ def recommend_autonomy_level(
1921
+ current_level: str,
1922
+ *,
1923
+ override_rate: float,
1924
+ rework_rate: float,
1925
+ sample_size: int,
1926
+ p0_reversal: bool = False,
1927
+ policy: AutonomyPolicy | None = None,
1928
+ gate_id: str | None = None,
1929
+ ) -> AutonomyRecommendation:
1930
+ """Recommend an autonomy-level flip from the dial signal (ADVISORY-ONLY).
1931
+
1932
+ Asymmetric:
1933
+
1934
+ * RETREAT one step immediately on any P0 reversal OR an override rate above
1935
+ ``policy.retreat_override_rate`` (no sample-size gate -- safety first).
1936
+ * ADVANCE one step only when the resolved-decision sample meets
1937
+ ``policy.min_sample_size`` AND override rate is below
1938
+ ``policy.advance_override_max`` AND rework is within
1939
+ ``policy.rework_baseline``.
1940
+ * Otherwise HOLD.
1941
+
1942
+ The returned recommendation is advisory: a human confirms the flip. This
1943
+ function NEVER mutates policy or required clearances.
1944
+ """
1945
+ pol = policy or _default_autonomy_policy(source="default")
1946
+ cur = current_level if current_level in AUTONOMY_LEVELS else pol.default_level
1947
+ idx = AUTONOMY_LEVELS.index(cur)
1948
+
1949
+ # Asymmetric RETREAT -- fires immediately, no sample-size gate.
1950
+ if p0_reversal or override_rate > pol.retreat_override_rate:
1951
+ trigger = (
1952
+ "P0 reversal observed"
1953
+ if p0_reversal
1954
+ else (
1955
+ f"override rate {override_rate:.0%} > retreat threshold "
1956
+ f"{pol.retreat_override_rate:.0%}"
1957
+ )
1958
+ )
1959
+ if idx == 0:
1960
+ return AutonomyRecommendation(
1961
+ cur,
1962
+ cur,
1963
+ AUTONOMY_ACTION_HOLD,
1964
+ f"hold at {cur}: {trigger} but already at the most conservative "
1965
+ "level (Observe). ADVISORY: a human confirms.",
1966
+ gate_id,
1967
+ )
1968
+ return AutonomyRecommendation(
1969
+ cur,
1970
+ AUTONOMY_LEVELS[idx - 1],
1971
+ AUTONOMY_ACTION_RETREAT,
1972
+ f"retreat: {trigger} -- recommend {AUTONOMY_LEVELS[idx - 1]} "
1973
+ "(restores required human clearances). ADVISORY: a human confirms.",
1974
+ gate_id,
1975
+ )
1976
+
1977
+ # Asymmetric ADVANCE -- gated on sample size + override + rework guardrail.
1978
+ advance_ok = (
1979
+ sample_size >= pol.min_sample_size
1980
+ and override_rate < pol.advance_override_max
1981
+ and rework_rate <= pol.rework_baseline
1982
+ )
1983
+ if advance_ok:
1984
+ basis = (
1985
+ f"override {override_rate:.0%} < {pol.advance_override_max:.0%}, "
1986
+ f"rework {rework_rate:.0%} <= baseline {pol.rework_baseline:.0%}, "
1987
+ f"sample {sample_size} >= {pol.min_sample_size}"
1988
+ )
1989
+ if idx == len(AUTONOMY_LEVELS) - 1:
1990
+ return AutonomyRecommendation(
1991
+ cur,
1992
+ cur,
1993
+ AUTONOMY_ACTION_HOLD,
1994
+ f"hold at {cur}: advance criteria met ({basis}) but already at "
1995
+ "the most permissive level (Execute).",
1996
+ gate_id,
1997
+ )
1998
+ return AutonomyRecommendation(
1999
+ cur,
2000
+ AUTONOMY_LEVELS[idx + 1],
2001
+ AUTONOMY_ACTION_ADVANCE,
2002
+ f"advance: {basis} -- recommend {AUTONOMY_LEVELS[idx + 1]} "
2003
+ "(would reduce required human clearances). ADVISORY: a human "
2004
+ "confirms; no auto-ratchet.",
2005
+ gate_id,
2006
+ )
2007
+
2008
+ # HOLD -- neither retreat nor advance criteria met.
2009
+ return AutonomyRecommendation(
2010
+ cur,
2011
+ cur,
2012
+ AUTONOMY_ACTION_HOLD,
2013
+ f"hold at {cur}: override {override_rate:.0%}, rework {rework_rate:.0%}, "
2014
+ f"sample {sample_size} -- advance criteria not met, no retreat trigger.",
2015
+ gate_id,
2016
+ )
2017
+
2018
+
2019
+ # Reconfiguration surface (used by tasks/policy.yml + slash commands) -----
2020
+
2021
+
2022
+ def _now_iso() -> str:
2023
+ """ISO-8601 UTC timestamp with seconds precision."""
2024
+ return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
2025
+
2026
+
2027
+ def append_audit_log(project_root: Path, entry: str) -> Path:
2028
+ """Append a one-line audit entry to ``meta/policy-changes.log``.
2029
+
2030
+ File is created (with a one-line header) if missing. Uses ``open(..., "a")``
2031
+ so the append is atomic on standard filesystems and concurrent writers
2032
+ cannot lose entries (#777 Greptile P2 review -- the previous
2033
+ read-modify-write pattern raced under parallel ``task policy:*`` calls).
2034
+ Pure stdlib + utf-8 write keeps PowerShell 5.1 / Windows out of the
2035
+ round-trip path.
2036
+ """
2037
+ log_path = project_root / AUDIT_LOG_REL_PATH
2038
+ log_path.parent.mkdir(parents=True, exist_ok=True)
2039
+ line = f"{_now_iso()} {entry}\n"
2040
+ # Header on first write only -- ``write_text`` is fine here because the
2041
+ # file is being created from scratch and there is no concurrent writer
2042
+ # to race with on the initial creation.
2043
+ if not log_path.exists():
2044
+ header = (
2045
+ "# meta/policy-changes.log -- audit trail for "
2046
+ "policy.allowDirectCommitsToMaster transitions (#746)\n"
2047
+ )
2048
+ log_path.write_text(header, encoding="utf-8")
2049
+ # Subsequent writes use append mode for atomicity.
2050
+ with open(log_path, "a", encoding="utf-8") as handle:
2051
+ handle.write(line)
2052
+ return log_path
2053
+
2054
+
2055
+ def set_policy(
2056
+ project_root: Path,
2057
+ *,
2058
+ allow_direct_commits: bool,
2059
+ actor: str = "agent",
2060
+ note: str = "",
2061
+ ) -> tuple[bool, str]:
2062
+ """Write the typed policy flag back to PROJECT-DEFINITION.
2063
+
2064
+ Returns (changed, message). Performs an in-place edit (preserves all
2065
+ other keys). Migrates any legacy narrative key to the typed surface in
2066
+ the same write so the deprecation warning is satisfied.
2067
+
2068
+ Raises FileNotFoundError when PROJECT-DEFINITION is missing -- the
2069
+ caller should produce a fail-closed message in that case (the
2070
+ bootstrap fallback in #746 acceptance criterion E).
2071
+ """
2072
+ path = project_definition_path(project_root)
2073
+ if not path.is_file():
2074
+ raise FileNotFoundError(f"PROJECT-DEFINITION not found at {path}")
2075
+ data = json.loads(path.read_text(encoding="utf-8"))
2076
+ plan = data.setdefault("plan", {})
2077
+ if not isinstance(plan, dict):
2078
+ raise ValueError("PROJECT-DEFINITION 'plan' is not an object")
2079
+ policy_block = plan.setdefault("policy", {})
2080
+ if not isinstance(policy_block, dict):
2081
+ raise ValueError("plan.policy is not an object")
2082
+
2083
+ previous = policy_block.get("allowDirectCommitsToMaster")
2084
+ policy_block["allowDirectCommitsToMaster"] = bool(allow_direct_commits)
2085
+
2086
+ # One-shot legacy migration: if the narrative key exists, drop it so the
2087
+ # typed surface is the only source of truth on subsequent reads.
2088
+ narratives = plan.get("narratives")
2089
+ legacy_dropped = False
2090
+ if isinstance(narratives, dict) and LEGACY_NARRATIVE_KEY in narratives:
2091
+ del narratives[LEGACY_NARRATIVE_KEY]
2092
+ legacy_dropped = True
2093
+
2094
+ path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
2095
+
2096
+ changed = previous != bool(allow_direct_commits) or legacy_dropped
2097
+ parts = [
2098
+ f"actor={actor}",
2099
+ f"allowDirectCommitsToMaster={'true' if allow_direct_commits else 'false'}",
2100
+ f"previous={previous!r}",
2101
+ ]
2102
+ if legacy_dropped:
2103
+ parts.append("legacy-narrative-migrated=true")
2104
+ if note:
2105
+ # Sanitize note (strip newlines so log line stays single-line).
2106
+ parts.append("note=" + note.replace("\n", " ").replace("\r", " "))
2107
+ audit_entry = " ".join(parts)
2108
+ append_audit_log(project_root, audit_entry)
2109
+ return changed, audit_entry
2110
+
2111
+
2112
+ # ---------------------------------------------------------------------------
2113
+ # Swarm sub-agent backend policy (#1531a)
2114
+ # ---------------------------------------------------------------------------
2115
+ #
2116
+ # DEPRECATED (#1891): This entire section is superseded by per-role operator
2117
+ # model routing (`.deft/routing.local.json`) introduced in #1739 / #1863.
2118
+ #
2119
+ # Use `task swarm:routing-set` and `task verify:routing` instead of
2120
+ # `plan.policy.swarmSubagentBackend` / `task policy:subagent-backend(s)`.
2121
+ #
2122
+ # The functions and constants below remain functional for consumers that have
2123
+ # not yet migrated. Hard deletion of the Python twin is tracked by #1860.
2124
+ #
2125
+ # Original: ``plan.policy.swarmSubagentBackend`` stores the operator-selected
2126
+ # coding sub-agent backend for swarm leaf workers. The catalog + probe surface
2127
+ # lists stable provider IDs and role capabilities without invoking a real
2128
+ # harness -- availability is inferred from lightweight env signals (or
2129
+ # ``DEFT_PROBE_<BACKEND>`` overrides for tests).
2130
+
2131
+ #: Stable provider IDs for known coding sub-agent backends.
2132
+ KNOWN_SUBAGENT_BACKEND_IDS: frozenset[str] = frozenset(
2133
+ {"composer", "grok-build", "cursor-cloud"}
2134
+ )
2135
+
2136
+ #: Worker roles a backend may advertise for swarm routing (#1531).
2137
+ SWARM_WORKER_ROLES: frozenset[str] = frozenset(
2138
+ {
2139
+ "leaf-implementation",
2140
+ "orchestrator",
2141
+ "review-monitor",
2142
+ "merge-release",
2143
+ }
2144
+ )
2145
+
2146
+ _SUBAGENT_BACKEND_CATALOG: dict[str, dict[str, Any]] = {
2147
+ "composer": {
2148
+ "display_name": "Composer-class coding agent",
2149
+ "roles": ("leaf-implementation",),
2150
+ },
2151
+ "grok-build": {
2152
+ "display_name": "Grok Build (spawn_subagent)",
2153
+ "roles": ("leaf-implementation", "review-monitor"),
2154
+ },
2155
+ "cursor-cloud": {
2156
+ "display_name": "Cursor / cloud agent",
2157
+ "roles": ("leaf-implementation", "orchestrator", "review-monitor"),
2158
+ },
2159
+ }
2160
+
2161
+
2162
+ @dataclass(frozen=True)
2163
+ class SubagentBackendDescriptor:
2164
+ """One catalogued sub-agent backend with probe availability."""
2165
+
2166
+ backend_id: str
2167
+ display_name: str
2168
+ roles: tuple[str, ...]
2169
+ available: bool
2170
+
2171
+
2172
+ @dataclass(frozen=True)
2173
+ class SwarmSubagentBackendResult:
2174
+ """Resolved ``plan.policy.swarmSubagentBackend`` state."""
2175
+
2176
+ backend_id: str | None
2177
+ source: str # one of: 'typed', 'default', 'default-on-error'
2178
+ error: str | None = None
2179
+
2180
+
2181
+ def _probe_env_truthy(name: str) -> bool:
2182
+ """True when *name* is set to a recognised truthy string."""
2183
+ return os.environ.get(name, "").strip().lower() in _TRUTHY
2184
+
2185
+
2186
+ def _probe_backend_available(backend_id: str) -> bool:
2187
+ """Lightweight availability probe -- never invokes a real harness.
2188
+
2189
+ Tests (and operators) MAY force availability via
2190
+ ``DEFT_PROBE_<BACKEND>`` where ``<BACKEND>`` is the uppercased id
2191
+ with hyphens replaced by underscores (e.g. ``DEFT_PROBE_GROK_BUILD``).
2192
+ """
2193
+ env_key = f"DEFT_PROBE_{backend_id.upper().replace('-', '_')}"
2194
+ override = os.environ.get(env_key)
2195
+ if override is not None:
2196
+ return override.strip().lower() in _TRUTHY
2197
+
2198
+ if backend_id == "grok-build":
2199
+ runtime = os.environ.get("DEFT_AGENT_RUNTIME", "").strip().lower()
2200
+ return _probe_env_truthy("GROK_BUILD") or runtime == "grok-build"
2201
+ if backend_id == "composer":
2202
+ return _probe_env_truthy("CURSOR_COMPOSER")
2203
+ if backend_id == "cursor-cloud":
2204
+ return _probe_env_truthy("CURSOR_AGENT")
2205
+ return False
2206
+
2207
+
2208
+ def probe_subagent_backends() -> list[SubagentBackendDescriptor]:
2209
+ """Return the stable backend catalog with per-entry availability.
2210
+
2211
+ Pure-stdlib; does not spawn sub-agents or shell out to a harness.
2212
+ Sorted by ``backend_id`` for deterministic CLI / JSON output.
2213
+ """
2214
+ out: list[SubagentBackendDescriptor] = []
2215
+ for backend_id in sorted(_SUBAGENT_BACKEND_CATALOG):
2216
+ meta = _SUBAGENT_BACKEND_CATALOG[backend_id]
2217
+ out.append(
2218
+ SubagentBackendDescriptor(
2219
+ backend_id=backend_id,
2220
+ display_name=str(meta["display_name"]),
2221
+ roles=tuple(meta["roles"]),
2222
+ available=_probe_backend_available(backend_id),
2223
+ )
2224
+ )
2225
+ return out
2226
+
2227
+
2228
+ def validate_swarm_subagent_backend(value: Any) -> list[str]:
2229
+ """Validate a ``plan.policy.swarmSubagentBackend`` payload."""
2230
+ errors: list[str] = []
2231
+ if value is None:
2232
+ return errors
2233
+ if not isinstance(value, str) or not value.strip():
2234
+ errors.append(
2235
+ "plan.policy.swarmSubagentBackend must be a non-empty string; "
2236
+ f"got {type(value).__name__} ({value!r})"
2237
+ )
2238
+ return errors
2239
+ bid = value.strip()
2240
+ if bid not in KNOWN_SUBAGENT_BACKEND_IDS:
2241
+ errors.append(
2242
+ "plan.policy.swarmSubagentBackend must be one of "
2243
+ f"{sorted(KNOWN_SUBAGENT_BACKEND_IDS)}; got {bid!r}"
2244
+ )
2245
+ return errors
2246
+
2247
+
2248
+ def resolve_swarm_subagent_backend(
2249
+ project_root: Path | None = None,
2250
+ ) -> SwarmSubagentBackendResult:
2251
+ """Resolve ``plan.policy.swarmSubagentBackend`` from PROJECT-DEFINITION."""
2252
+ data, err = load_project_definition(project_root)
2253
+ if data is None:
2254
+ return SwarmSubagentBackendResult(None, "default", error=err)
2255
+
2256
+ policy_block = _get_policy_block(data)
2257
+ if "swarmSubagentBackend" not in policy_block:
2258
+ return SwarmSubagentBackendResult(None, "default", error=None)
2259
+
2260
+ raw = policy_block["swarmSubagentBackend"]
2261
+ if raw is None:
2262
+ return SwarmSubagentBackendResult(
2263
+ None,
2264
+ "default-on-error",
2265
+ error="plan.policy.swarmSubagentBackend must be a string; got null",
2266
+ )
2267
+ validation_errors = validate_swarm_subagent_backend(raw)
2268
+ if validation_errors:
2269
+ return SwarmSubagentBackendResult(
2270
+ None,
2271
+ "default-on-error",
2272
+ error=validation_errors[0],
2273
+ )
2274
+ return SwarmSubagentBackendResult(str(raw).strip(), "typed", error=None)
2275
+
2276
+
2277
+ def set_swarm_subagent_backend(
2278
+ project_root: Path,
2279
+ *,
2280
+ backend_id: str,
2281
+ actor: str = "agent",
2282
+ note: str = "",
2283
+ ) -> tuple[bool, str]:
2284
+ """Write ``plan.policy.swarmSubagentBackend`` to PROJECT-DEFINITION."""
2285
+ bid = backend_id.strip()
2286
+ errors = validate_swarm_subagent_backend(bid)
2287
+ if errors:
2288
+ raise ValueError(errors[0])
2289
+
2290
+ path = project_definition_path(project_root)
2291
+ if not path.is_file():
2292
+ raise FileNotFoundError(f"PROJECT-DEFINITION not found at {path}")
2293
+ data = json.loads(path.read_text(encoding="utf-8"))
2294
+ plan = data.setdefault("plan", {})
2295
+ if not isinstance(plan, dict):
2296
+ raise ValueError("PROJECT-DEFINITION 'plan' is not an object")
2297
+ policy_block = plan.setdefault("policy", {})
2298
+ if not isinstance(policy_block, dict):
2299
+ raise ValueError("plan.policy is not an object")
2300
+
2301
+ previous = policy_block.get("swarmSubagentBackend")
2302
+ policy_block["swarmSubagentBackend"] = bid
2303
+ path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
2304
+
2305
+ changed = previous != bid
2306
+ parts = [
2307
+ f"actor={actor}",
2308
+ f"swarmSubagentBackend={bid}",
2309
+ f"previous={previous!r}",
2310
+ ]
2311
+ if note:
2312
+ parts.append("note=" + note.replace("\n", " ").replace("\r", " "))
2313
+ audit_entry = " ".join(parts)
2314
+ append_audit_log(project_root, audit_entry)
2315
+ return changed, audit_entry
2316
+
2317
+
2318
+ def subagent_backends_to_json(backends: list[SubagentBackendDescriptor]) -> str:
2319
+ """Serialise probe output for ``task policy:subagent-backends --format=json``."""
2320
+ payload = {
2321
+ "backends": [
2322
+ {
2323
+ "id": entry.backend_id,
2324
+ "display_name": entry.display_name,
2325
+ "roles": list(entry.roles),
2326
+ "available": entry.available,
2327
+ }
2328
+ for entry in backends
2329
+ ]
2330
+ }
2331
+ return json.dumps(payload, ensure_ascii=False, indent=2)
2332
+
2333
+
2334
+ def disclosure_line(result: PolicyResult) -> str:
2335
+ """One-liner disclosure phrasing for AGENTS.md / setup interview echo."""
2336
+ if result.allow_direct_commits:
2337
+ if result.source == "env-bypass":
2338
+ return (
2339
+ "[deft policy] DEFT_ALLOW_DEFAULT_BRANCH_COMMIT is set -- "
2340
+ "branch-protection policy bypassed for this session."
2341
+ )
2342
+ return (
2343
+ "[deft policy] Direct commits to the default branch are ENABLED "
2344
+ f"(source: {result.source}). Branch-protection policy is OFF."
2345
+ )
2346
+ if result.error:
2347
+ return (
2348
+ "[deft policy] Branch-protection policy is ON (fail-closed: "
2349
+ f"{result.error}). Direct commits to the default branch are blocked."
2350
+ )
2351
+ return (
2352
+ "[deft policy] Branch-protection policy is ON. Direct commits to the "
2353
+ "default branch are blocked. Use a feature branch."
2354
+ )
2355
+
2356
+
2357
+ # ---------------------------------------------------------------------------
2358
+ # Consolidated typed-policy inspector (#1148 / N8 of #1119 Wave-2d-1)
2359
+ # ---------------------------------------------------------------------------
2360
+ #
2361
+ # ``task policy:show`` walks :data:`_REGISTERED_POLICIES` and renders one
2362
+ # row per registered typed-policy field. Each inspector callable returns a
2363
+ # :class:`PolicyField` carrying the field name, current effective value,
2364
+ # framework default, and resolution source (``typed`` / ``default`` /
2365
+ # ``legacy``). Future typed-flag children append their inspector to the
2366
+ # constant; no consumer-side wiring required.
2367
+ #
2368
+ # Source semantics (per the #1148 issue body):
2369
+ #
2370
+ # * ``typed`` -- ``plan.policy.<field>`` is present and contributes the
2371
+ # effective value (for list fields this also requires a non-empty list
2372
+ # so an accidental ``triageScope: []`` does not masquerade as configured).
2373
+ # * ``default`` -- ``plan.policy.<field>`` is absent, empty, or malformed.
2374
+ # The resolver fell back to the framework default.
2375
+ # * ``legacy`` -- ONLY for ``allowDirectCommitsToMaster``: the typed key is
2376
+ # absent but the deprecated narrative key ``plan.narratives['Allow
2377
+ # direct commits to master']`` is present. Other fields never had a
2378
+ # pre-typed legacy shape so this state cannot fire for them.
2379
+ #
2380
+ # The CLI shim lives in :mod:`_policy_show_cli` so this module stays well
2381
+ # under the 1000-line MUST cap from ``coding/coding.md``.
2382
+
2383
+ #: Canonical dotted-path names for every registered field. These are the
2384
+ #: strings ``--field=<name>`` accepts and the keys ``--format=json`` emits.
2385
+ FIELD_ALLOW_DIRECT_COMMITS: str = "plan.policy.allowDirectCommitsToMaster"
2386
+ FIELD_WIP_CAP: str = "plan.policy.wipCap"
2387
+ FIELD_SESSION_RITUAL_STALENESS_HOURS: str = (
2388
+ "plan.policy.sessionRitualStalenessHours"
2389
+ )
2390
+ FIELD_TRIAGE_SCOPE: str = "plan.policy.triageScope"
2391
+ FIELD_TRIAGE_SCOPE_IGNORES: str = "plan.policy.triageScopeIgnores"
2392
+ FIELD_TRIAGE_RANKING_LABELS: str = "plan.policy.triageRankingLabels"
2393
+ FIELD_TRIAGE_AUTO_CLASSIFY: str = "plan.policy.triageAutoClassify"
2394
+ FIELD_TRIAGE_HOLD_MARKERS: str = "plan.policy.triageHoldMarkers"
2395
+ FIELD_SWARM_SUBAGENT_BACKEND: str = "plan.policy.swarmSubagentBackend"
2396
+
2397
+ #: Framework-default literals for the list-shaped policy fields. The
2398
+ #: branch / WIP defaults are sourced from existing module constants
2399
+ #: (:data:`DEFAULT_WIP_CAP`, the boolean ``False``).
2400
+ DEFAULT_TRIAGE_SCOPE_VALUE: list[dict[str, Any]] = [{"rule": "all-open"}]
2401
+ DEFAULT_TRIAGE_SCOPE_IGNORES_VALUE: list[Any] = []
2402
+ DEFAULT_TRIAGE_RANKING_LABELS_VALUE: list[str] = []
2403
+ DEFAULT_TRIAGE_AUTO_CLASSIFY_VALUE: list[Any] = []
2404
+ #: Fallback mirror of :data:`scripts.triage_classify.DEFAULT_HOLD_MARKERS`
2405
+ #: used when ``triage_classify`` is unimportable (stripped-down install).
2406
+ #: The canonical source is :mod:`triage_classify`; this constant is the
2407
+ #: belt-and-suspenders fallback for the show CLI ONLY.
2408
+ _FALLBACK_HOLD_MARKERS: tuple[str, ...] = (
2409
+ "do not implement",
2410
+ "BLOCKED",
2411
+ "HOLDING",
2412
+ "Holding / capture only",
2413
+ )
2414
+
2415
+
2416
+ @dataclass(frozen=True)
2417
+ class PolicyField:
2418
+ """One row in the :func:`inspect_all_policies` result.
2419
+
2420
+ Fields:
2421
+
2422
+ * ``name`` -- canonical dotted path (e.g. ``plan.policy.wipCap``).
2423
+ * ``current`` -- the effective value (what the corresponding resolver
2424
+ would return for downstream consumers).
2425
+ * ``default`` -- the framework default value for this field.
2426
+ * ``source`` -- one of ``'typed'`` / ``'default'`` / ``'legacy'``.
2427
+ """
2428
+
2429
+ name: str
2430
+ current: Any
2431
+ default: Any
2432
+ source: str
2433
+
2434
+
2435
+ def _get_plan(data: dict | None) -> dict[str, Any]:
2436
+ """Return ``data['plan']`` when it's a dict, else an empty dict."""
2437
+ if not isinstance(data, dict):
2438
+ return {}
2439
+ plan = data.get("plan")
2440
+ return plan if isinstance(plan, dict) else {}
2441
+
2442
+
2443
+ def _get_policy_block(data: dict | None) -> dict[str, Any]:
2444
+ """Return ``data['plan']['policy']`` when it's a dict, else an empty dict."""
2445
+ policy = _get_plan(data).get("policy")
2446
+ return policy if isinstance(policy, dict) else {}
2447
+
2448
+
2449
+ def _get_narratives(data: dict | None) -> dict[str, Any]:
2450
+ """Return ``data['plan']['narratives']`` when it's a dict, else empty."""
2451
+ narratives = _get_plan(data).get("narratives")
2452
+ return narratives if isinstance(narratives, dict) else {}
2453
+
2454
+
2455
+ def _default_hold_markers() -> list[str]:
2456
+ """Return the framework default hold markers as a fresh list.
2457
+
2458
+ Sources :data:`triage_classify.DEFAULT_HOLD_MARKERS` lazily so the
2459
+ show CLI stays importable on installs that strip the triage modules.
2460
+ Falls back to the in-module mirror :data:`_FALLBACK_HOLD_MARKERS`.
2461
+ """
2462
+ try:
2463
+ # Local import: avoid circular import at module load time and
2464
+ # tolerate stripped-down installs that lack triage_classify.
2465
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
2466
+ from triage_classify import DEFAULT_HOLD_MARKERS # type: ignore[import-not-found]
2467
+
2468
+ return list(DEFAULT_HOLD_MARKERS)
2469
+ except Exception: # noqa: BLE001 -- defensive; fall back to mirror
2470
+ return list(_FALLBACK_HOLD_MARKERS)
2471
+
2472
+
2473
+ def _inspect_allow_direct_commits(
2474
+ data: dict | None, project_root: Path
2475
+ ) -> PolicyField:
2476
+ """Inspect ``plan.policy.allowDirectCommitsToMaster`` (#746)."""
2477
+ policy_block = _get_policy_block(data)
2478
+ if "allowDirectCommitsToMaster" in policy_block:
2479
+ raw = policy_block["allowDirectCommitsToMaster"]
2480
+ current = raw if isinstance(raw, bool) else False
2481
+ return PolicyField(
2482
+ name=FIELD_ALLOW_DIRECT_COMMITS,
2483
+ current=current,
2484
+ default=False,
2485
+ source="typed",
2486
+ )
2487
+ narratives = _get_narratives(data)
2488
+ if LEGACY_NARRATIVE_KEY in narratives:
2489
+ coerced, _raw = _coerce_legacy_narrative(narratives[LEGACY_NARRATIVE_KEY])
2490
+ return PolicyField(
2491
+ name=FIELD_ALLOW_DIRECT_COMMITS,
2492
+ current=coerced,
2493
+ default=False,
2494
+ source="legacy",
2495
+ )
2496
+ return PolicyField(
2497
+ name=FIELD_ALLOW_DIRECT_COMMITS,
2498
+ current=False,
2499
+ default=False,
2500
+ source="default",
2501
+ )
2502
+
2503
+
2504
+ def _inspect_wip_cap(data: dict | None, project_root: Path) -> PolicyField:
2505
+ """Inspect ``plan.policy.wipCap`` (#1124 / D4 of #1119)."""
2506
+ policy_block = _get_policy_block(data)
2507
+ if "wipCap" in policy_block:
2508
+ raw = policy_block["wipCap"]
2509
+ if isinstance(raw, int) and not isinstance(raw, bool) and raw >= 0:
2510
+ current: int = raw
2511
+ else:
2512
+ # Malformed -- resolver falls back to the default at runtime;
2513
+ # surface that here for honest reporting.
2514
+ current = DEFAULT_WIP_CAP
2515
+ return PolicyField(
2516
+ name=FIELD_WIP_CAP,
2517
+ current=current,
2518
+ default=DEFAULT_WIP_CAP,
2519
+ source="typed",
2520
+ )
2521
+ return PolicyField(
2522
+ name=FIELD_WIP_CAP,
2523
+ current=DEFAULT_WIP_CAP,
2524
+ default=DEFAULT_WIP_CAP,
2525
+ source="default",
2526
+ )
2527
+
2528
+
2529
+ def _inspect_session_ritual_staleness_hours(
2530
+ data: dict | None,
2531
+ project_root: Path,
2532
+ ) -> PolicyField:
2533
+ """Inspect ``plan.policy.sessionRitualStalenessHours`` (#1348)."""
2534
+ policy_block = _get_policy_block(data)
2535
+ if "sessionRitualStalenessHours" in policy_block:
2536
+ raw = policy_block["sessionRitualStalenessHours"]
2537
+ if raw is None:
2538
+ return PolicyField(
2539
+ name=FIELD_SESSION_RITUAL_STALENESS_HOURS,
2540
+ current=DEFAULT_SESSION_RITUAL_STALENESS_HOURS,
2541
+ default=DEFAULT_SESSION_RITUAL_STALENESS_HOURS,
2542
+ source="default",
2543
+ )
2544
+ if isinstance(raw, int) and not isinstance(raw, bool) and raw > 0:
2545
+ current: int = raw
2546
+ source = "typed"
2547
+ else:
2548
+ current = DEFAULT_SESSION_RITUAL_STALENESS_HOURS
2549
+ source = "default-on-error"
2550
+ return PolicyField(
2551
+ name=FIELD_SESSION_RITUAL_STALENESS_HOURS,
2552
+ current=current,
2553
+ default=DEFAULT_SESSION_RITUAL_STALENESS_HOURS,
2554
+ source=source,
2555
+ )
2556
+ return PolicyField(
2557
+ name=FIELD_SESSION_RITUAL_STALENESS_HOURS,
2558
+ current=DEFAULT_SESSION_RITUAL_STALENESS_HOURS,
2559
+ default=DEFAULT_SESSION_RITUAL_STALENESS_HOURS,
2560
+ source="default",
2561
+ )
2562
+
2563
+
2564
+ def _list_field_inspector(
2565
+ data: dict | None,
2566
+ key: str,
2567
+ name: str,
2568
+ default_value: list[Any],
2569
+ *,
2570
+ empty_is_typed: bool = False,
2571
+ ) -> PolicyField:
2572
+ """Shared helper for the list-shaped typed-policy fields.
2573
+
2574
+ The matching resolvers in :mod:`triage_scope`,
2575
+ :mod:`triage_queue`, :mod:`triage_classify`, and
2576
+ :mod:`_triage_scope_ignores` treat an empty / non-list value as
2577
+ "unset" and fall back to the framework default. Mirror that
2578
+ semantic here so ``source`` agrees with what the consumer-side
2579
+ resolver actually returns. ``empty_is_typed=True`` is reserved for
2580
+ ``triageHoldMarkers`` where an empty list is a meaningful operator
2581
+ opt-out (silence the hold-marker rule entirely; see #1129
2582
+ Decision 3).
2583
+ """
2584
+ policy_block = _get_policy_block(data)
2585
+ if key not in policy_block:
2586
+ return PolicyField(
2587
+ name=name,
2588
+ current=list(default_value),
2589
+ default=list(default_value),
2590
+ source="default",
2591
+ )
2592
+ raw = policy_block[key]
2593
+ if not isinstance(raw, list):
2594
+ return PolicyField(
2595
+ name=name,
2596
+ current=list(default_value),
2597
+ default=list(default_value),
2598
+ source="default",
2599
+ )
2600
+ if not raw and not empty_is_typed:
2601
+ return PolicyField(
2602
+ name=name,
2603
+ current=list(default_value),
2604
+ default=list(default_value),
2605
+ source="default",
2606
+ )
2607
+ # Drop empty-string / non-string entries the same way the
2608
+ # triage_classify resolver does so what we render matches what
2609
+ # downstream consumers see.
2610
+ if empty_is_typed and all(isinstance(s, str) for s in raw):
2611
+ cleaned: list[Any] = [s for s in raw if isinstance(s, str) and s.strip()]
2612
+ return PolicyField(
2613
+ name=name,
2614
+ current=cleaned,
2615
+ default=list(default_value),
2616
+ source="typed",
2617
+ )
2618
+ return PolicyField(
2619
+ name=name,
2620
+ current=list(raw),
2621
+ default=list(default_value),
2622
+ source="typed",
2623
+ )
2624
+
2625
+
2626
+ def _inspect_triage_scope(data: dict | None, project_root: Path) -> PolicyField:
2627
+ """Inspect ``plan.policy.triageScope`` (#1131 / D12 of #1119)."""
2628
+ return _list_field_inspector(
2629
+ data,
2630
+ key="triageScope",
2631
+ name=FIELD_TRIAGE_SCOPE,
2632
+ default_value=DEFAULT_TRIAGE_SCOPE_VALUE,
2633
+ )
2634
+
2635
+
2636
+ def _inspect_triage_scope_ignores(
2637
+ data: dict | None, project_root: Path
2638
+ ) -> PolicyField:
2639
+ """Inspect ``plan.policy.triageScopeIgnores`` (#1133 / D14 + #1182 / D14c)."""
2640
+ return _list_field_inspector(
2641
+ data,
2642
+ key="triageScopeIgnores",
2643
+ name=FIELD_TRIAGE_SCOPE_IGNORES,
2644
+ default_value=DEFAULT_TRIAGE_SCOPE_IGNORES_VALUE,
2645
+ )
2646
+
2647
+
2648
+ def _inspect_triage_ranking_labels(
2649
+ data: dict | None, project_root: Path
2650
+ ) -> PolicyField:
2651
+ """Inspect ``plan.policy.triageRankingLabels`` (#1128 / D11 of #1119)."""
2652
+ return _list_field_inspector(
2653
+ data,
2654
+ key="triageRankingLabels",
2655
+ name=FIELD_TRIAGE_RANKING_LABELS,
2656
+ default_value=DEFAULT_TRIAGE_RANKING_LABELS_VALUE,
2657
+ )
2658
+
2659
+
2660
+ def _inspect_triage_auto_classify(
2661
+ data: dict | None, project_root: Path
2662
+ ) -> PolicyField:
2663
+ """Inspect ``plan.policy.triageAutoClassify`` (#1129 / D10 of #1119)."""
2664
+ return _list_field_inspector(
2665
+ data,
2666
+ key="triageAutoClassify",
2667
+ name=FIELD_TRIAGE_AUTO_CLASSIFY,
2668
+ default_value=DEFAULT_TRIAGE_AUTO_CLASSIFY_VALUE,
2669
+ )
2670
+
2671
+
2672
+ def _inspect_triage_hold_markers(
2673
+ data: dict | None, project_root: Path
2674
+ ) -> PolicyField:
2675
+ """Inspect ``plan.policy.triageHoldMarkers`` (#1129 / D10 of #1119).
2676
+
2677
+ Default is :data:`triage_classify.DEFAULT_HOLD_MARKERS` (4 universal
2678
+ phrases). An EXPLICIT empty list is a legitimate operator opt-out
2679
+ state (silences the hold-marker universal rule entirely) per
2680
+ Decision 3 of #1129 -- ``empty_is_typed=True`` preserves that
2681
+ distinction in the show output.
2682
+ """
2683
+ return _list_field_inspector(
2684
+ data,
2685
+ key="triageHoldMarkers",
2686
+ name=FIELD_TRIAGE_HOLD_MARKERS,
2687
+ default_value=_default_hold_markers(),
2688
+ empty_is_typed=True,
2689
+ )
2690
+
2691
+
2692
+ def _inspect_swarm_subagent_backend(
2693
+ data: dict | None, project_root: Path
2694
+ ) -> PolicyField:
2695
+ """Inspect ``plan.policy.swarmSubagentBackend`` (#1531a)."""
2696
+ policy_block = _get_policy_block(data)
2697
+ if "swarmSubagentBackend" not in policy_block:
2698
+ return PolicyField(
2699
+ name=FIELD_SWARM_SUBAGENT_BACKEND,
2700
+ current=None,
2701
+ default=None,
2702
+ source="default",
2703
+ )
2704
+ raw = policy_block["swarmSubagentBackend"]
2705
+ if (
2706
+ isinstance(raw, str)
2707
+ and raw.strip()
2708
+ and raw.strip() in KNOWN_SUBAGENT_BACKEND_IDS
2709
+ ):
2710
+ return PolicyField(
2711
+ name=FIELD_SWARM_SUBAGENT_BACKEND,
2712
+ current=raw.strip(),
2713
+ default=None,
2714
+ source="typed",
2715
+ )
2716
+ return PolicyField(
2717
+ name=FIELD_SWARM_SUBAGENT_BACKEND,
2718
+ current=None,
2719
+ default=None,
2720
+ source="default-on-error",
2721
+ )
2722
+
2723
+
2724
+ #: Registered typed-policy inspectors. Future typed-flag children append
2725
+ #: a new ``_inspect_<field>`` callable here AND its definition above; the
2726
+ #: show CLI surfaces it automatically with no other wiring. Append-only
2727
+ #: by convention; reorders churn user-visible output ordering.
2728
+ #:
2729
+ #: NOTE (#1419): ``plan.policy.capacityAllocation`` is DELIBERATELY not
2730
+ #: registered here. This registry is the row-per-scalar/list ``task
2731
+ #: policy:show`` surface; ``capacityAllocation`` is a composite object
2732
+ #: (buckets[], window, unit, ...) whose state has its own dedicated,
2733
+ #: richer rendering via ``task capacity:show`` (``scripts/capacity_show.py``).
2734
+ #: Flattening it into a single ``policy:show`` row would lose that detail,
2735
+ #: so it is surfaced through the capacity engine instead.
2736
+ _REGISTERED_POLICIES: tuple[
2737
+ Callable[[dict | None, Path], PolicyField], ...
2738
+ ] = (
2739
+ _inspect_allow_direct_commits,
2740
+ _inspect_wip_cap,
2741
+ _inspect_session_ritual_staleness_hours,
2742
+ _inspect_triage_scope,
2743
+ _inspect_triage_scope_ignores,
2744
+ _inspect_triage_ranking_labels,
2745
+ _inspect_triage_auto_classify,
2746
+ _inspect_triage_hold_markers,
2747
+ _inspect_swarm_subagent_backend,
2748
+ )
2749
+
2750
+
2751
+ def inspect_all_policies(
2752
+ project_root: Path | None = None,
2753
+ ) -> list[PolicyField]:
2754
+ """Walk :data:`_REGISTERED_POLICIES` and return one row per field.
2755
+
2756
+ Loads PROJECT-DEFINITION exactly once so every inspector reads from
2757
+ the same in-memory snapshot. Missing / malformed PROJECT-DEFINITION
2758
+ is tolerated -- every inspector returns its default-source row in
2759
+ that case. The returned list preserves the registration order.
2760
+ """
2761
+ root = project_root or Path.cwd()
2762
+ data, _err = load_project_definition(root)
2763
+ return [inspect(data, root) for inspect in _REGISTERED_POLICIES]
2764
+
2765
+
2766
+ def inspect_one_policy(
2767
+ name: str, project_root: Path | None = None
2768
+ ) -> PolicyField | None:
2769
+ """Look up a single registered field by canonical dotted-path name.
2770
+
2771
+ Returns ``None`` when ``name`` is not a registered field so callers
2772
+ (the CLI shim) can surface an actionable error. ``name`` matching is
2773
+ exact -- no abbreviation / case-folding -- so scripts that parse
2774
+ ``--format=json`` and re-query a specific field cannot silently
2775
+ drift onto an unintended field.
2776
+ """
2777
+ fields = inspect_all_policies(project_root)
2778
+ for field in fields:
2779
+ if field.name == name:
2780
+ return field
2781
+ return None
2782
+
2783
+
2784
+ def registered_policy_names() -> list[str]:
2785
+ """Return the canonical names of every registered typed-policy field.
2786
+
2787
+ Cheap discovery surface for the CLI shim's ``--field=<name>`` error
2788
+ message and for future typed-flag tests that want to assert their
2789
+ field landed in :data:`_REGISTERED_POLICIES`.
2790
+ """
2791
+ # Run the inspectors against a None project_root so we get the
2792
+ # registered names without touching the filesystem.
2793
+ return [
2794
+ inspect(None, Path.cwd()).name for inspect in _REGISTERED_POLICIES
2795
+ ]
2796
+
2797
+
2798
+ def main(argv: list[str] | None = None) -> int:
2799
+ """CLI: ``python -m scripts.policy show`` for diagnostics / shell scripts."""
2800
+ args = list(sys.argv[1:] if argv is None else argv)
2801
+ if not args or args[0] in {"-h", "--help"}:
2802
+ print("Usage: python -m scripts.policy show [--project-root <path>]")
2803
+ return 0
2804
+ if args[0] != "show":
2805
+ print(f"Unknown subcommand: {args[0]}", file=sys.stderr)
2806
+ return 2
2807
+ project_root = Path.cwd()
2808
+ if "--project-root" in args:
2809
+ idx = args.index("--project-root")
2810
+ if idx + 1 >= len(args):
2811
+ print("--project-root requires a value", file=sys.stderr)
2812
+ return 2
2813
+ project_root = Path(args[idx + 1])
2814
+ result = resolve_policy(project_root)
2815
+ print(f"allowDirectCommitsToMaster={str(result.allow_direct_commits).lower()}")
2816
+ print(f"source={result.source}")
2817
+ if result.deprecation_warning:
2818
+ print(f"warning={result.deprecation_warning}")
2819
+ if result.error:
2820
+ print(f"error={result.error}")
2821
+ print(disclosure_line(result))
2822
+ return 0
2823
+
2824
+
2825
+ if __name__ == "__main__":
2826
+ sys.exit(main())