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