program-context-protocol 0.12.4__py3-none-any.whl
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.
- pcp/__init__.py +3 -0
- pcp/assertions.py +152 -0
- pcp/attest.py +111 -0
- pcp/build_loop_bypass.py +76 -0
- pcp/build_report.py +54 -0
- pcp/capture.py +339 -0
- pcp/cli.py +104 -0
- pcp/commands/__init__.py +0 -0
- pcp/commands/amend.py +283 -0
- pcp/commands/architect_review.py +291 -0
- pcp/commands/architecture_justification.py +164 -0
- pcp/commands/audit.py +371 -0
- pcp/commands/build.py +4523 -0
- pcp/commands/build_plan.py +153 -0
- pcp/commands/build_status.py +83 -0
- pcp/commands/capture.py +72 -0
- pcp/commands/check.py +584 -0
- pcp/commands/context.py +151 -0
- pcp/commands/control_audit_cmd.py +54 -0
- pcp/commands/correct_objective.py +160 -0
- pcp/commands/dashboard.py +732 -0
- pcp/commands/deploy.py +199 -0
- pcp/commands/deploy_check.py +134 -0
- pcp/commands/design_audit.py +323 -0
- pcp/commands/diff.py +153 -0
- pcp/commands/diff_reduce.py +355 -0
- pcp/commands/docs.py +538 -0
- pcp/commands/doctor.py +820 -0
- pcp/commands/escalations_cmd.py +64 -0
- pcp/commands/gate.py +209 -0
- pcp/commands/import_project.py +404 -0
- pcp/commands/init.py +1634 -0
- pcp/commands/install_hook.py +283 -0
- pcp/commands/install_skill.py +48 -0
- pcp/commands/kickoff.py +772 -0
- pcp/commands/narrative_lint.py +54 -0
- pcp/commands/objective_conflicts_cmd.py +68 -0
- pcp/commands/pm.py +504 -0
- pcp/commands/pressure_test_cmd.py +72 -0
- pcp/commands/provenance.py +313 -0
- pcp/commands/prune.py +179 -0
- pcp/commands/report.py +49 -0
- pcp/commands/run_log_cmd.py +122 -0
- pcp/commands/scan.py +346 -0
- pcp/commands/self_update.py +125 -0
- pcp/commands/status.py +180 -0
- pcp/commands/takeover.py +55 -0
- pcp/commands/telemetry_cmd.py +167 -0
- pcp/commands/validate_module.py +153 -0
- pcp/commands/validate_strategy.py +413 -0
- pcp/commands/verify.py +166 -0
- pcp/commands/verify_syntax_fix.py +74 -0
- pcp/commands/watch.py +372 -0
- pcp/config_audit.py +141 -0
- pcp/context_map.py +124 -0
- pcp/control_audit.py +159 -0
- pcp/coupling.py +178 -0
- pcp/coverage_audit.py +77 -0
- pcp/decision_log.py +134 -0
- pcp/discovery/__init__.py +0 -0
- pcp/discovery/clusters.py +124 -0
- pcp/discovery/graph.py +110 -0
- pcp/discovery/scanner.py +109 -0
- pcp/escalations.py +193 -0
- pcp/evidence.py +30 -0
- pcp/evidence_chain.py +56 -0
- pcp/impact.py +164 -0
- pcp/install_approvals.py +44 -0
- pcp/integrity_audit.py +176 -0
- pcp/librarian.py +89 -0
- pcp/llm/__init__.py +0 -0
- pcp/llm/client.py +183 -0
- pcp/llm/coding_agent_contract.py +104 -0
- pcp/llm/harness/__init__.py +12 -0
- pcp/llm/harness/agy.py +121 -0
- pcp/llm/harness/agy_coding_loop.py +180 -0
- pcp/llm/harness/claude.py +241 -0
- pcp/llm/ledger.py +47 -0
- pcp/narrative_lint.py +229 -0
- pcp/nav_graph.py +226 -0
- pcp/objective_conflicts.py +129 -0
- pcp/operational.py +70 -0
- pcp/orphaned_work.py +262 -0
- pcp/pcp_dir.py +35 -0
- pcp/pcp_status.py +313 -0
- pcp/policy.py +81 -0
- pcp/pressure_test.py +196 -0
- pcp/qa.py +445 -0
- pcp/run_log.py +225 -0
- pcp/schema/__init__.py +0 -0
- pcp/schema/ci_rules.schema.json +106 -0
- pcp/schema/controls.schema.json +39 -0
- pcp/schema/module_acceptance.schema.json +144 -0
- pcp/schema/module_spec.schema.json +78 -0
- pcp/schema/sdlc_phase.schema.json +52 -0
- pcp/schema/validator.py +77 -0
- pcp/skill_data/pcp/SKILL.md +1897 -0
- pcp/spec_write.py +269 -0
- pcp/spend.py +77 -0
- pcp/symbols.py +86 -0
- pcp/telemetry.py +308 -0
- pcp/uat.py +271 -0
- pcp/version_drift.py +222 -0
- program_context_protocol-0.12.4.dist-info/METADATA +123 -0
- program_context_protocol-0.12.4.dist-info/RECORD +109 -0
- program_context_protocol-0.12.4.dist-info/WHEEL +4 -0
- program_context_protocol-0.12.4.dist-info/entry_points.txt +2 -0
- program_context_protocol-0.12.4.dist-info/licenses/LICENSE-APACHE +202 -0
- program_context_protocol-0.12.4.dist-info/licenses/LICENSE-MIT +21 -0
pcp/commands/build.py
ADDED
|
@@ -0,0 +1,4523 @@
|
|
|
1
|
+
"""pcp build — autonomous agent execution loop to implement pending criteria."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import signal
|
|
8
|
+
import sys
|
|
9
|
+
import subprocess
|
|
10
|
+
import threading
|
|
11
|
+
import uuid
|
|
12
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
import click
|
|
15
|
+
import yaml
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
|
|
18
|
+
from pcp.pcp_dir import find_pcp_dir, get_modules_dir, NoPCPDir
|
|
19
|
+
from pcp.schema.validator import MalformedSpecError, validate_file, load_yaml
|
|
20
|
+
from pcp.llm import client as llm
|
|
21
|
+
from pcp.llm.client import _claude_bin, _log_usage
|
|
22
|
+
from pcp.pcp_status import write_pcp_md
|
|
23
|
+
from pcp import decision_log
|
|
24
|
+
from pcp import integrity_audit
|
|
25
|
+
from pcp import librarian
|
|
26
|
+
from pcp import narrative_lint
|
|
27
|
+
from pcp import objective_conflicts
|
|
28
|
+
from pcp import run_log
|
|
29
|
+
from pcp import assertions as assertions_lib
|
|
30
|
+
from pcp import telemetry
|
|
31
|
+
from pcp import qa
|
|
32
|
+
from pcp import evidence
|
|
33
|
+
from pcp import spend
|
|
34
|
+
from pcp import uat
|
|
35
|
+
from pcp.install_approvals import log_install_approval
|
|
36
|
+
from pcp.capture import find_transcript_for_session, run_capture
|
|
37
|
+
|
|
38
|
+
console = Console()
|
|
39
|
+
|
|
40
|
+
# Guards every write to a file shared across concurrent module builds
|
|
41
|
+
# (telemetry.jsonl, token_ledger.yaml, brd_items.yaml/decision_log.jsonl via
|
|
42
|
+
# pcp capture). Deliberately NOT held across gate evaluation itself (LLM
|
|
43
|
+
# calls, test/lint/SAST subprocess runs) — those are independent per module
|
|
44
|
+
# and are exactly the work parallelism exists to overlap. Only the brief
|
|
45
|
+
# read-modify-write file operations need to be serialized.
|
|
46
|
+
_STATE_LOCK = threading.Lock()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _max_build_sessions() -> int:
|
|
50
|
+
"""Run-level circuit breaker on raw agent session spawns (sanity cap, not just per-criterion).
|
|
51
|
+
|
|
52
|
+
Override with PCP_MAX_BUILD_SESSIONS for very large builds.
|
|
53
|
+
"""
|
|
54
|
+
return int(os.environ.get("PCP_MAX_BUILD_SESSIONS", "150"))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _build_agent_timeout_sec() -> int:
|
|
58
|
+
"""Wall-clock cap on a single coding-agent attempt. Found 2026-07-01: the
|
|
59
|
+
subprocess.run() call for the coding agent had NO timeout at all — a stuck
|
|
60
|
+
agent could run unbounded, and the session-count circuit breaker above
|
|
61
|
+
can't help mid-session since it only checks before a NEW session starts.
|
|
62
|
+
Override with PCP_BUILD_AGENT_TIMEOUT_SEC."""
|
|
63
|
+
return int(os.environ.get("PCP_BUILD_AGENT_TIMEOUT_SEC", "1800"))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _build_agent_max_budget_usd() -> str:
|
|
67
|
+
"""Per-attempt dollar cap passed to `claude -p --max-budget-usd`. Same gap
|
|
68
|
+
as the timeout above, bounding runaway spend within one session rather
|
|
69
|
+
than only across the whole run. Override with PCP_BUILD_AGENT_MAX_BUDGET_USD."""
|
|
70
|
+
return os.environ.get("PCP_BUILD_AGENT_MAX_BUDGET_USD", "5")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _max_agent_depth() -> int:
|
|
74
|
+
"""Hard cap on pcp build/pcp watch re-entrancy depth -- how many times a
|
|
75
|
+
coding agent spawned by this process may itself trigger another pcp
|
|
76
|
+
build/watch session before being refused. Mirrors Grok Build's
|
|
77
|
+
depth-limit-1 subagent guard (see CLAUDE.md's Token Discipline section,
|
|
78
|
+
2026-07-16 entry) but only covers PCP's own spawn points -- the
|
|
79
|
+
`subprocess.run` calls to `claude -p` in build.py/watch.py. It does NOT
|
|
80
|
+
and cannot enforce depth on Claude Code's own Agent/Workflow tools if a
|
|
81
|
+
spawned agent calls those directly; that remains an instruction-level
|
|
82
|
+
guard only (stated honestly in CLAUDE.md, not overclaimed as fixed here).
|
|
83
|
+
Override with PCP_MAX_AGENT_DEPTH."""
|
|
84
|
+
return int(os.environ.get("PCP_MAX_AGENT_DEPTH", "1"))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def check_agent_depth_or_exit() -> None:
|
|
88
|
+
"""Call once at the top of any command that spawns a coding-agent
|
|
89
|
+
subprocess (pcp build, pcp watch's auto-fix loop). PCP_AGENT_DEPTH is
|
|
90
|
+
set on this process's own environ after the check passes, which
|
|
91
|
+
subprocess.run inherits automatically in every spawned `claude` child --
|
|
92
|
+
the same zero-extra-plumbing mechanism PCP_AGENT_SESSION already uses
|
|
93
|
+
below. If that child agent itself re-invokes `pcp build`/`pcp watch`,
|
|
94
|
+
the nested call reads the inherited depth and is refused once the max
|
|
95
|
+
is reached."""
|
|
96
|
+
current_depth = int(os.environ.get("PCP_AGENT_DEPTH", "0"))
|
|
97
|
+
max_depth = _max_agent_depth()
|
|
98
|
+
if current_depth >= max_depth:
|
|
99
|
+
console.print(
|
|
100
|
+
f"[red bold]Subagent spawn-depth limit hit (depth={current_depth}, max={max_depth}).[/red bold]"
|
|
101
|
+
)
|
|
102
|
+
console.print(
|
|
103
|
+
"[dim]A coding agent already running inside pcp build/watch attempted to spawn "
|
|
104
|
+
"another pcp build/watch session -- refused. Override with PCP_MAX_AGENT_DEPTH=<n> "
|
|
105
|
+
"only if you understand the runaway-recursion risk this guards against.[/dim]"
|
|
106
|
+
)
|
|
107
|
+
sys.exit(1)
|
|
108
|
+
os.environ["PCP_AGENT_DEPTH"] = str(current_depth + 1)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _max_parallel_modules() -> int:
|
|
112
|
+
"""Cap on concurrent module builds within one dependency wave, each in its
|
|
113
|
+
own git worktree + branch — mirrors the /pcp orchestrator skill's module-
|
|
114
|
+
level parallelism (criteria stay sequential within a module; modules in
|
|
115
|
+
the same wave have no dependency on each other by construction, so the
|
|
116
|
+
wave boundary is the only real gate). Default raised 3->5, 2026-07-20:
|
|
117
|
+
a real-world swarm-role/parallelism research pass found 5-7 concurrent
|
|
118
|
+
agents is the practical ceiling on a single machine before rate limits,
|
|
119
|
+
merge conflicts, and review bottleneck erase the parallelism gain --
|
|
120
|
+
3 was an arbitrary conservative guess, not measured against that ceiling.
|
|
121
|
+
Kept below the top of that range since this is still the unattended CLI
|
|
122
|
+
default, not an interactive session where a human is watching cost
|
|
123
|
+
accrue in real time. Override with PCP_BUILD_MAX_PARALLEL.
|
|
124
|
+
|
|
125
|
+
Scope note, 2026-07-30: this cap belongs to THIS headless engine only.
|
|
126
|
+
The Workflow-tool/`pcp build-plan` path (interactive sessions) relies on
|
|
127
|
+
Workflow's own native concurrency cap instead -- do not port this number
|
|
128
|
+
there, it would just be a second, disagreeing cap on the same thing."""
|
|
129
|
+
return int(os.environ.get("PCP_BUILD_MAX_PARALLEL", "5"))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class BudgetExceeded(Exception):
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class _BuildBudget:
|
|
137
|
+
"""Thread-safe session-count/cost tracking shared across module workers."""
|
|
138
|
+
|
|
139
|
+
def __init__(self, max_sessions: int):
|
|
140
|
+
self._lock = threading.Lock()
|
|
141
|
+
self.max_sessions = max_sessions
|
|
142
|
+
self.session_count = 0
|
|
143
|
+
self.run_cost_total = 0.0
|
|
144
|
+
self.tripped = False
|
|
145
|
+
self.infra_signal_streak = 0
|
|
146
|
+
self.infra_anomaly_tripped = False
|
|
147
|
+
self.gate_skip_streaks: dict[str, int] = {}
|
|
148
|
+
self.gate_skip_tripped: set[str] = set()
|
|
149
|
+
|
|
150
|
+
def take_session(self) -> None:
|
|
151
|
+
with self._lock:
|
|
152
|
+
self.session_count += 1
|
|
153
|
+
if self.session_count > self.max_sessions:
|
|
154
|
+
self.tripped = True
|
|
155
|
+
raise BudgetExceeded(self.session_count)
|
|
156
|
+
|
|
157
|
+
def add_cost(self, cost: float | None) -> None:
|
|
158
|
+
with self._lock:
|
|
159
|
+
self.run_cost_total += cost or 0
|
|
160
|
+
|
|
161
|
+
def record_test_timeout_signal(self, timed_out: bool) -> bool:
|
|
162
|
+
"""Cross-criterion anomaly signal. A real 2026-07-21 incident
|
|
163
|
+
(Project O): a squatted DB port made the test-suite gate
|
|
164
|
+
"time out" identically across several criteria before a human
|
|
165
|
+
caught it -- per-criterion escalation (_record_escalation) only
|
|
166
|
+
fires after a criterion exhausts all 3 attempts and never compares
|
|
167
|
+
across criteria, so nothing flagged the repeating pattern itself.
|
|
168
|
+
Returns True the moment PCP_BUILD_INFRA_ANOMALY_THRESHOLD consecutive
|
|
169
|
+
timeout signals land, exactly once per run -- caller escalates loudly
|
|
170
|
+
right then. Any non-timeout result resets the streak."""
|
|
171
|
+
with self._lock:
|
|
172
|
+
self.infra_signal_streak = self.infra_signal_streak + 1 if timed_out else 0
|
|
173
|
+
threshold = int(os.environ.get("PCP_BUILD_INFRA_ANOMALY_THRESHOLD", "3"))
|
|
174
|
+
if not self.infra_anomaly_tripped and self.infra_signal_streak >= threshold:
|
|
175
|
+
self.infra_anomaly_tripped = True
|
|
176
|
+
return True
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
def record_gate_skip_signal(self, check: str, skipped: bool) -> bool:
|
|
180
|
+
"""Generalizes record_test_timeout_signal to any gate that can
|
|
181
|
+
silently no-op when its underlying tool is present but broken
|
|
182
|
+
(network fetch failure, a scan error, a crash) -- e.g. SAST after
|
|
183
|
+
the 2026-07-21 fix now skips instead of blocking on a tool
|
|
184
|
+
failure, the right default (don't false-block on infra), but it
|
|
185
|
+
means a genuinely misconfigured tool could silently never gate
|
|
186
|
+
anything again for the rest of an unattended multi-hour run unless
|
|
187
|
+
something is watching for repeated skips. Deliberately does NOT
|
|
188
|
+
fire for "tool not installed" (qa.py returns tool=None for that,
|
|
189
|
+
never reaches here with skipped=True) -- that's expected, stable
|
|
190
|
+
project config, not an anomaly. Tracked per-check (lint and sast
|
|
191
|
+
streak independently) since one gate silently failing says nothing
|
|
192
|
+
about the others. Fires once per check per run."""
|
|
193
|
+
with self._lock:
|
|
194
|
+
streak = self.gate_skip_streaks.get(check, 0)
|
|
195
|
+
streak = streak + 1 if skipped else 0
|
|
196
|
+
self.gate_skip_streaks[check] = streak
|
|
197
|
+
threshold = int(os.environ.get("PCP_BUILD_GATE_SKIP_ANOMALY_THRESHOLD", "3"))
|
|
198
|
+
if check not in self.gate_skip_tripped and streak >= threshold:
|
|
199
|
+
self.gate_skip_tripped.add(check)
|
|
200
|
+
return True
|
|
201
|
+
return False
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _git_head(project_root: Path) -> str:
|
|
205
|
+
result = subprocess.run(
|
|
206
|
+
["git", "rev-parse", "HEAD"], capture_output=True, text=True, cwd=project_root,
|
|
207
|
+
)
|
|
208
|
+
return result.stdout.strip() if result.returncode == 0 else "HEAD"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def gather_modules_to_build(pcp_dir: Path, module_name: str | None = None) -> list[dict]:
|
|
212
|
+
"""Public — reused by external orchestrators (e.g. a multi-user/Temporal
|
|
213
|
+
build layer) that want this run's exact module/criteria selection logic
|
|
214
|
+
without duplicating it. Kept in sync with `build()`'s own gathering step
|
|
215
|
+
by construction, since `build()` calls this too."""
|
|
216
|
+
modules_dir = get_modules_dir(pcp_dir)
|
|
217
|
+
modules_to_build = []
|
|
218
|
+
for spec_path in sorted(modules_dir.glob("*/spec.yaml")):
|
|
219
|
+
m_name = spec_path.parent.name
|
|
220
|
+
if module_name and m_name != module_name:
|
|
221
|
+
continue
|
|
222
|
+
spec = load_yaml(spec_path)
|
|
223
|
+
if spec.get("deprecated"):
|
|
224
|
+
continue
|
|
225
|
+
acc_path = spec_path.parent / "acceptance.yaml"
|
|
226
|
+
if not acc_path.exists():
|
|
227
|
+
continue
|
|
228
|
+
acc_data = load_yaml(acc_path)
|
|
229
|
+
pending = [c for c in acc_data.get("criteria", []) if c.get("status", "pending") == "pending"]
|
|
230
|
+
if pending:
|
|
231
|
+
modules_to_build.append({
|
|
232
|
+
"name": m_name,
|
|
233
|
+
"spec_path": spec_path,
|
|
234
|
+
"acc_path": acc_path,
|
|
235
|
+
"spec": spec,
|
|
236
|
+
"pending_criteria": pending
|
|
237
|
+
})
|
|
238
|
+
return modules_to_build
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _compute_waves(modules_to_build: list[dict]) -> dict[str, int]:
|
|
242
|
+
"""{module_name: wave_number} via topological sort on each module's spec
|
|
243
|
+
'dependencies' field. No in-set dependencies = wave 0. A module whose
|
|
244
|
+
dependency isn't in this run's module set (already built, or external)
|
|
245
|
+
is treated as satisfied — only in-set deps push it to a later wave."""
|
|
246
|
+
name_to_mod = {m["name"]: m for m in modules_to_build}
|
|
247
|
+
wave_of: dict[str, int] = {}
|
|
248
|
+
|
|
249
|
+
def compute(name: str, seen: frozenset) -> int:
|
|
250
|
+
if name in wave_of:
|
|
251
|
+
return wave_of[name]
|
|
252
|
+
if name in seen:
|
|
253
|
+
return 0 # circular dependency — don't loop forever, treat as wave 0
|
|
254
|
+
mod = name_to_mod.get(name)
|
|
255
|
+
if not mod:
|
|
256
|
+
return 0
|
|
257
|
+
deps = [d for d in (mod["spec"].get("dependencies") or []) if d in name_to_mod and d != name]
|
|
258
|
+
wave = 0 if not deps else 1 + max(compute(d, seen | {name}) for d in deps)
|
|
259
|
+
wave_of[name] = wave
|
|
260
|
+
return wave
|
|
261
|
+
|
|
262
|
+
for m in modules_to_build:
|
|
263
|
+
compute(m["name"], frozenset())
|
|
264
|
+
return wave_of
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# Public alias — external orchestrators reuse this alongside
|
|
268
|
+
# gather_modules_to_build() rather than reaching into a private name.
|
|
269
|
+
compute_waves = _compute_waves
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# ── Criterion-level parallel waves within a single module ──────────────────
|
|
273
|
+
# Opt-in only. Grok Build's subagent model parallelizes at task granularity
|
|
274
|
+
# (not just top-level unit), worktree-isolated per task — reference-pattern
|
|
275
|
+
# borrowed 2026-07-16 rather than assuming criteria are safe to parallelize
|
|
276
|
+
# by default. Without any criterion in a module declaring `depends_on`, this
|
|
277
|
+
# is never consulted and the module's criteria build exactly as before:
|
|
278
|
+
# strictly sequential, each on the prior commit, in declared list order.
|
|
279
|
+
|
|
280
|
+
def _max_parallel_criteria() -> int:
|
|
281
|
+
"""Concurrency cap for criteria WITHIN one module.
|
|
282
|
+
|
|
283
|
+
This pool was uncapped (`max_workers=len(wave_criteria)`) while the
|
|
284
|
+
module-level pool was capped at 5 — the asymmetry behind the 2026-07-22
|
|
285
|
+
30+-agent spawn, where the documented "15" was prose and nothing enforced
|
|
286
|
+
it. Harmless while criterion parallelism was opt-in and almost nothing
|
|
287
|
+
opted in; the moment it became the default, `core-data-model`'s 46
|
|
288
|
+
independent criteria would have started 46 concurrent agents, each with a
|
|
289
|
+
worktree and a test suite hitting the same Postgres.
|
|
290
|
+
|
|
291
|
+
Defaults to 5, matching _max_parallel_modules(). Worst case is therefore
|
|
292
|
+
modules x criteria concurrent agents, bounded overall by
|
|
293
|
+
PCP_MAX_BUILD_SESSIONS. Raise PCP_BUILD_MAX_PARALLEL_CRITERIA for a
|
|
294
|
+
single-module run (`--module X`), where no module-level fan-out is
|
|
295
|
+
competing for the same database.
|
|
296
|
+
|
|
297
|
+
Scope note, 2026-07-30: headless-engine-only, same as _max_parallel_modules()
|
|
298
|
+
above -- the Workflow-tool path schedules criteria via `pipeline()`/`parallel()`
|
|
299
|
+
per `pcp build-plan`'s criterion_waves and lets Workflow's own cap govern."""
|
|
300
|
+
return max(1, int(os.environ.get("PCP_BUILD_MAX_PARALLEL_CRITERIA", "5")))
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _criteria_parallel_enabled(mod: dict) -> bool:
|
|
304
|
+
"""Criteria build in parallel by default.
|
|
305
|
+
|
|
306
|
+
This used to require a module to "opt in" by having ANY criterion declare
|
|
307
|
+
`depends_on`, even an empty list — presence as the signal. That reads
|
|
308
|
+
exactly backwards: a module whose criteria declare NO dependencies is
|
|
309
|
+
stating they are mutually independent, which is the *best* case for
|
|
310
|
+
fanning out. PCP treated it as "not opted in" and ran the whole module one
|
|
311
|
+
criterion at a time.
|
|
312
|
+
|
|
313
|
+
Measured 2026-07-27, Project O: `logic-artifact-storage` has 12
|
|
314
|
+
criteria and 0 declaring `depends_on`, so it ran a single agent
|
|
315
|
+
sequentially. Across the project 145 of 382 criteria are in modules with
|
|
316
|
+
no `depends_on` anywhere — all serial for want of a field whose absence
|
|
317
|
+
already meant "independent".
|
|
318
|
+
|
|
319
|
+
Parallelism is now the default and `depends_on` does the one job it should:
|
|
320
|
+
ORDERING. Declared, it forces a criterion into a later wave; absent, the
|
|
321
|
+
criterion is independent and lands in wave 0. The collision risk that once
|
|
322
|
+
justified caution is handled where it belongs — optimistic scheduling with
|
|
323
|
+
exact detection and a rebuild on conflict (see
|
|
324
|
+
_partition_wave_by_file_scope and the merge path in _build_module_worker).
|
|
325
|
+
|
|
326
|
+
PCP_CRITERIA_SERIAL=1 forces the old one-at-a-time behaviour."""
|
|
327
|
+
if os.environ.get("PCP_CRITERIA_SERIAL") == "1":
|
|
328
|
+
return False
|
|
329
|
+
return len(mod["pending_criteria"]) > 1
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _compute_criterion_waves(mod: dict) -> dict[str, int]:
|
|
333
|
+
"""{criterion_id: wave_number} via topological sort on each pending
|
|
334
|
+
criterion's `depends_on` field, mirroring _compute_waves()'s module-level
|
|
335
|
+
logic one level down. A dependency on a criterion outside this run's
|
|
336
|
+
pending set (already complete, or not yet declared) is treated as
|
|
337
|
+
already satisfied — same "external dep = satisfied" rule as modules."""
|
|
338
|
+
pending = {c["id"]: c for c in mod["pending_criteria"]}
|
|
339
|
+
wave_of: dict[str, int] = {}
|
|
340
|
+
|
|
341
|
+
def compute(cid: str, seen: frozenset) -> int:
|
|
342
|
+
if cid in wave_of:
|
|
343
|
+
return wave_of[cid]
|
|
344
|
+
if cid in seen:
|
|
345
|
+
return 0 # circular dependency — don't loop forever, treat as wave 0
|
|
346
|
+
c = pending.get(cid)
|
|
347
|
+
if not c:
|
|
348
|
+
return 0
|
|
349
|
+
deps = [d for d in (c.get("depends_on") or []) if d in pending and d != cid]
|
|
350
|
+
wave = 0 if not deps else 1 + max(compute(d, seen | {cid}) for d in deps)
|
|
351
|
+
wave_of[cid] = wave
|
|
352
|
+
return wave
|
|
353
|
+
|
|
354
|
+
for c in mod["pending_criteria"]:
|
|
355
|
+
compute(c["id"], frozenset())
|
|
356
|
+
return wave_of
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _partition_wave_by_file_scope(wave_criteria: list[dict]) -> list[list[dict]]:
|
|
360
|
+
"""Split one dependency wave into sub-waves that cannot collide on a file.
|
|
361
|
+
|
|
362
|
+
`depends_on` expresses ORDER, never file disjointness. Two criteria with no
|
|
363
|
+
dependency between them are scheduled together and each builds in its own
|
|
364
|
+
worktree, blind to the other — so if both create the same file, both pass
|
|
365
|
+
their own gates and the second merge dies on CONFLICT (add/add), leaving the
|
|
366
|
+
build stopped and a human holding a git conflict.
|
|
367
|
+
|
|
368
|
+
Observed 2026-07-27 (Project S dogfood): pdf-document-storage A001 and A004
|
|
369
|
+
both created `src/pdf_document_storage/logging_safety.py` and both edited
|
|
370
|
+
`pyproject.toml`. A004 merged; A001 could not. Flagged as a known risk on
|
|
371
|
+
07-25 and left unfixed — this is that fix.
|
|
372
|
+
|
|
373
|
+
The rule is conservative on purpose: run two criteria together only when
|
|
374
|
+
PCP can PROVE they touch different files, which means both declared a
|
|
375
|
+
`target` and the targets differ. A criterion with no declared target has an
|
|
376
|
+
unknown file surface, so it gets a sub-wave to itself. That is the honest
|
|
377
|
+
reading — fanning out work whose blast radius nobody declared is the unsafe
|
|
378
|
+
act, not parallelism as such.
|
|
379
|
+
|
|
380
|
+
Cost is real: `pcp kickoff` did not populate `target` at all until this same
|
|
381
|
+
change taught it to, so existing projects lose criterion-level parallelism
|
|
382
|
+
until their specs declare targets. That is the correct trade — a halted
|
|
383
|
+
build and manual git surgery cost far more than serial execution, and the
|
|
384
|
+
slowdown is exactly the incentive to declare targets. Set
|
|
385
|
+
PCP_CRITERIA_PARALLEL_UNDECLARED=1 to restore the old optimistic behavior.
|
|
386
|
+
|
|
387
|
+
Order within the wave is preserved; this only decides what may run
|
|
388
|
+
alongside what.
|
|
389
|
+
"""
|
|
390
|
+
# OPTIMISTIC by default (corrected 2026-07-27, same day it shipped
|
|
391
|
+
# pessimistic). The first version ran two criteria together only when both
|
|
392
|
+
# declared a `target` and the targets differed -- "prove disjointness or
|
|
393
|
+
# run alone". On Project O that serialised 237 criteria that had
|
|
394
|
+
# explicitly opted into parallel builds via depends_on, because only 51 of
|
|
395
|
+
# 382 declare a target at all. A 15x throughput loss to prevent a collision
|
|
396
|
+
# class that had bitten once.
|
|
397
|
+
#
|
|
398
|
+
# The trade was wrong because the collision is RECOVERABLE and cheap:
|
|
399
|
+
# `_merge_module_branch` aborts cleanly (2026-07-25 fix), so a colliding
|
|
400
|
+
# criterion costs one rebuild, while blanket serialisation costs a
|
|
401
|
+
# multiple on every criterion in the project. Optimistic concurrency with
|
|
402
|
+
# conflict-triggered retry beats pessimistic locking whenever conflicts
|
|
403
|
+
# are rare and detection is exact -- and git merge is exact.
|
|
404
|
+
#
|
|
405
|
+
# Declared targets still buy something: two criteria that BOTH declare the
|
|
406
|
+
# SAME target are known to collide before either runs, so they are still
|
|
407
|
+
# separated up front rather than discovered at merge time.
|
|
408
|
+
# PCP_CRITERIA_PARALLEL_STRICT=1 restores prove-or-serialise.
|
|
409
|
+
strict = os.environ.get("PCP_CRITERIA_PARALLEL_STRICT") == "1"
|
|
410
|
+
optimistic = not strict
|
|
411
|
+
sub_waves: list[list[dict]] = []
|
|
412
|
+
current: list[dict] = []
|
|
413
|
+
claimed: set[str] = set()
|
|
414
|
+
|
|
415
|
+
for c in wave_criteria:
|
|
416
|
+
target = (c.get("target") or "").strip()
|
|
417
|
+
if not target and not optimistic:
|
|
418
|
+
# Unknown blast radius — never run alongside anything else.
|
|
419
|
+
if current:
|
|
420
|
+
sub_waves.append(current)
|
|
421
|
+
current, claimed = [], set()
|
|
422
|
+
sub_waves.append([c])
|
|
423
|
+
continue
|
|
424
|
+
key = target or f"__undeclared__{c['id']}"
|
|
425
|
+
if key in claimed:
|
|
426
|
+
sub_waves.append(current)
|
|
427
|
+
current, claimed = [], set()
|
|
428
|
+
current.append(c)
|
|
429
|
+
claimed.add(key)
|
|
430
|
+
|
|
431
|
+
if current:
|
|
432
|
+
sub_waves.append(current)
|
|
433
|
+
return sub_waves
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
# ── Worktree isolation for parallel module builds ──────────────────────────
|
|
437
|
+
# Each module being built concurrently gets its own git worktree + branch, same
|
|
438
|
+
# pattern as the /pcp skill's Branch Isolation Protocol. Only the coding
|
|
439
|
+
# agent's source-code edits go through the worktree/branch/merge path — all
|
|
440
|
+
# .pcp/ state (telemetry, token_ledger, this module's own acceptance.yaml)
|
|
441
|
+
# is written directly by this Python process to the MAIN pcp_dir, guarded by
|
|
442
|
+
# a lock, regardless of which worktree the agent subprocess ran in. That
|
|
443
|
+
# sidesteps git-merge-conflict risk on shared audit files entirely: they
|
|
444
|
+
# never diverge across branches because only one process ever writes them.
|
|
445
|
+
|
|
446
|
+
def _worktree_dir(project_root: Path, module_name: str) -> Path:
|
|
447
|
+
return project_root.parent / f"{project_root.name}-{module_name}"
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _sync_worktree_to_base(wt_path: Path, base_sha: str, module_name: str) -> None:
|
|
451
|
+
"""Bring a REUSED branch/worktree up to the base commit before building on
|
|
452
|
+
it. A fresh `-b` branch already starts at the base; a branch left over from
|
|
453
|
+
an earlier run (or from an earlier wave whose siblings have since merged)
|
|
454
|
+
can be arbitrarily far behind, and every commit main gained in the meantime
|
|
455
|
+
becomes conflict surface at merge time. Merging base in here moves that
|
|
456
|
+
reconciliation to the START of the criterion, where the agent still has a
|
|
457
|
+
session to fix it, instead of the end, where `_merge_module_branch` can only
|
|
458
|
+
report the conflict and leave the worktree behind.
|
|
459
|
+
|
|
460
|
+
Deliberately NOT a rebase: rebasing rewrites commits an interrupted run may
|
|
461
|
+
already have pushed. No-op when already up to date. On conflict or a dirty
|
|
462
|
+
tree, abort and warn rather than hand the agent a half-merged checkout."""
|
|
463
|
+
if not base_sha:
|
|
464
|
+
return
|
|
465
|
+
dirty = subprocess.run(
|
|
466
|
+
["git", "status", "--porcelain"], cwd=wt_path, capture_output=True, text=True,
|
|
467
|
+
).stdout.strip()
|
|
468
|
+
if dirty:
|
|
469
|
+
console.print(
|
|
470
|
+
f"[yellow]Worktree for '{module_name}' has uncommitted changes from a prior run — "
|
|
471
|
+
f"skipping base sync. It may be behind main.[/yellow]"
|
|
472
|
+
)
|
|
473
|
+
return
|
|
474
|
+
result = subprocess.run(
|
|
475
|
+
["git", "merge", "--no-edit", base_sha], cwd=wt_path, capture_output=True, text=True,
|
|
476
|
+
)
|
|
477
|
+
if result.returncode != 0:
|
|
478
|
+
subprocess.run(["git", "merge", "--abort"], cwd=wt_path, capture_output=True)
|
|
479
|
+
console.print(
|
|
480
|
+
f"[yellow]Could not sync worktree for '{module_name}' to the current base "
|
|
481
|
+
f"(conflict). Building on a stale base; expect a merge conflict at the end.[/yellow]"
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _seed_testmon_cache(project_root: Path, wt_path: Path) -> None:
|
|
486
|
+
"""Copy the main checkout's `.testmondata` into a fresh worktree.
|
|
487
|
+
|
|
488
|
+
`pytest-testmon` was adopted (2026-07-24) so a per-criterion QA gate runs
|
|
489
|
+
only the tests a change actually affects. It delivered **zero** benefit
|
|
490
|
+
inside `pcp build`, because its cache is gitignored and `git worktree add`
|
|
491
|
+
does not carry gitignored files across -- verified empirically on
|
|
492
|
+
Project O, where a 448K `.testmondata` sits in the main checkout and
|
|
493
|
+
a fresh worktree has none.
|
|
494
|
+
|
|
495
|
+
Cold testmon is not merely "no speedup", it is *slower than plain pytest*:
|
|
496
|
+
with no prior coverage DB it must trace coverage across the whole scoped set
|
|
497
|
+
to build one, then the worktree is deleted and the warm cache dies with it.
|
|
498
|
+
So parallel builds paid tracing overhead on every criterion and never once
|
|
499
|
+
collected the selection benefit.
|
|
500
|
+
|
|
501
|
+
Seed-only, deliberately never copied back. N concurrent worktrees each write
|
|
502
|
+
their own sqlite DB reflecting only the subset they ran; merging those back
|
|
503
|
+
would either corrupt the selection state or overwrite a broader baseline
|
|
504
|
+
with a narrower one. The main checkout's DB stays the single baseline, and
|
|
505
|
+
each worktree starts from the same base_sha it was cut from, which is
|
|
506
|
+
exactly the state that DB describes.
|
|
507
|
+
|
|
508
|
+
Best-effort: any failure here leaves testmon cold, which is the current
|
|
509
|
+
behaviour, so it must never break a build."""
|
|
510
|
+
src = project_root / ".testmondata"
|
|
511
|
+
dst = wt_path / ".testmondata"
|
|
512
|
+
if not src.is_file() or dst.exists():
|
|
513
|
+
return
|
|
514
|
+
try:
|
|
515
|
+
shutil.copy2(src, dst)
|
|
516
|
+
except OSError:
|
|
517
|
+
pass
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _setup_worktree(project_root: Path, module_name: str) -> Path:
|
|
521
|
+
wt_path = _worktree_dir(project_root, module_name)
|
|
522
|
+
base_sha = subprocess.run(
|
|
523
|
+
["git", "rev-parse", "HEAD"], cwd=project_root, capture_output=True, text=True,
|
|
524
|
+
).stdout.strip()
|
|
525
|
+
if wt_path.exists():
|
|
526
|
+
# Reuse from a prior interrupted run — but not its stale base.
|
|
527
|
+
_sync_worktree_to_base(wt_path, base_sha, module_name)
|
|
528
|
+
_seed_testmon_cache(project_root, wt_path)
|
|
529
|
+
return wt_path
|
|
530
|
+
branch = f"feat/{module_name}"
|
|
531
|
+
branch_exists = subprocess.run(
|
|
532
|
+
["git", "rev-parse", "--verify", branch], cwd=project_root, capture_output=True,
|
|
533
|
+
).returncode == 0
|
|
534
|
+
cmd = ["git", "worktree", "add", str(wt_path)] + ([branch] if branch_exists else ["-b", branch])
|
|
535
|
+
subprocess.run(cmd, cwd=project_root, capture_output=True, text=True)
|
|
536
|
+
if branch_exists:
|
|
537
|
+
_sync_worktree_to_base(wt_path, base_sha, module_name)
|
|
538
|
+
_seed_testmon_cache(project_root, wt_path)
|
|
539
|
+
return wt_path
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _merge_module_branch(project_root: Path, module_name: str, pcp_dir: Path | None = None) -> tuple[bool, str]:
|
|
543
|
+
branch = f"feat/{module_name}"
|
|
544
|
+
result = subprocess.run(
|
|
545
|
+
["git", "merge", "--no-ff", branch, "-m", f"Merge {branch}"],
|
|
546
|
+
cwd=project_root, capture_output=True, text=True,
|
|
547
|
+
)
|
|
548
|
+
ok = result.returncode == 0
|
|
549
|
+
if not ok:
|
|
550
|
+
# Leave NO half-merged state behind. Without this, a conflicting merge
|
|
551
|
+
# leaves project_root mid-MERGE with conflict markers in the tree, and
|
|
552
|
+
# every subsequent git command in that repo fails on unmerged paths --
|
|
553
|
+
# so one conflicted criterion takes down the whole run and everything
|
|
554
|
+
# after it, including criteria that had already passed their gates.
|
|
555
|
+
# That is what made 2026-07-25's `.claude/settings.json` add/add
|
|
556
|
+
# conflict so destructive: that fix removed one CAUSE of a conflict,
|
|
557
|
+
# this handles the CONSEQUENCE of any conflict at all. The caller
|
|
558
|
+
# already treats `ok=False` as a failure and leaves the worktree up
|
|
559
|
+
# for manual resolution -- aborting here only cleans the main repo,
|
|
560
|
+
# it does not discard the branch or the agent's work.
|
|
561
|
+
subprocess.run(["git", "merge", "--abort"], cwd=project_root, capture_output=True)
|
|
562
|
+
# Conflict-rate telemetry (2026-07-17): AgenticFlict (arXiv:2604.03551)
|
|
563
|
+
# measured a 27.67% merge-conflict baseline for agent-authored PRs; PCP's
|
|
564
|
+
# worktree-isolated wave merges should beat that, and now the data to
|
|
565
|
+
# prove/refute it accumulates — `pcp telemetry` reports the rate.
|
|
566
|
+
if pcp_dir is not None:
|
|
567
|
+
with _STATE_LOCK:
|
|
568
|
+
telemetry.record(
|
|
569
|
+
pcp_dir, cycle="qa", cycle_number=None, check="worktree-merge", control_id=None,
|
|
570
|
+
module=module_name, submodule=None, criterion_id=None, files=[],
|
|
571
|
+
result="pass" if ok else "block",
|
|
572
|
+
errors=[] if ok else [(result.stdout + result.stderr)[-500:]],
|
|
573
|
+
error_count=0 if ok else 1,
|
|
574
|
+
)
|
|
575
|
+
return ok, (result.stdout + result.stderr)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _cleanup_worktree(project_root: Path, module_name: str, wt_path: Path) -> None:
|
|
579
|
+
subprocess.run(["git", "worktree", "remove", str(wt_path), "--force"], cwd=project_root, capture_output=True)
|
|
580
|
+
subprocess.run(["git", "branch", "-D", f"feat/{module_name}"], cwd=project_root, capture_output=True)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _auto_commit_criterion(project_root: Path, module_name: str, criterion: dict) -> None:
|
|
584
|
+
"""Safety net, not a replacement for the agent's own commit.
|
|
585
|
+
`_build_agent_prompt` deliberately leaves committing optional for the
|
|
586
|
+
agent (gates measure the working diff either way) — but Ganesh's global
|
|
587
|
+
Build Cycle rule treats commit as part of the same cycle as build, not a
|
|
588
|
+
separate step a human opts into later. Once a criterion has passed every
|
|
589
|
+
gate, commit whatever is still sitting uncommitted so real work doesn't
|
|
590
|
+
rot in a worktree if the run stops before the module finishes (the
|
|
591
|
+
2026-07-23 Project O web-server worktrees). No-op if the agent
|
|
592
|
+
already committed — working tree is already clean."""
|
|
593
|
+
status = subprocess.run(
|
|
594
|
+
["git", "status", "--porcelain"], capture_output=True, text=True, cwd=project_root,
|
|
595
|
+
)
|
|
596
|
+
if not status.stdout.strip():
|
|
597
|
+
return
|
|
598
|
+
# `git add -A` minus agent-session-local config. Claude Code writes
|
|
599
|
+
# .claude/settings*.json into whatever directory it runs in, with values
|
|
600
|
+
# scoped to THAT directory (TMPDIR, granted permissions) -- so every
|
|
601
|
+
# parallel worktree produces a different version of the same new path.
|
|
602
|
+
# Committing them makes every wave merge an add/add conflict on a scratch
|
|
603
|
+
# file (2026-07-25 Project O: three criteria that had passed all
|
|
604
|
+
# their gates could not be merged). `pcp init`'s .gitignore covers new
|
|
605
|
+
# projects; this covers every project that already had a .gitignore, which
|
|
606
|
+
# init deliberately never modifies.
|
|
607
|
+
subprocess.run(
|
|
608
|
+
["git", "add", "-A", "--", *_AUTO_COMMIT_EXCLUDES],
|
|
609
|
+
cwd=project_root, capture_output=True,
|
|
610
|
+
)
|
|
611
|
+
message = f"{module_name}/{criterion['id']}: {criterion.get('description', '')}".strip()
|
|
612
|
+
result = subprocess.run(
|
|
613
|
+
["git", "commit", "-m", message], cwd=project_root, capture_output=True, text=True,
|
|
614
|
+
)
|
|
615
|
+
if result.returncode != 0:
|
|
616
|
+
console.print(f"[yellow]Auto-commit for {module_name}/{criterion['id']} failed:[/yellow] {result.stderr.strip()[:300]}")
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _auto_push(project_root: Path) -> None:
|
|
620
|
+
"""Step 3 of the global Build Cycle — push if a remote is configured,
|
|
621
|
+
skip silently otherwise (matches the rule's own "if the repo has a
|
|
622
|
+
remote configured" condition). Never force-pushes; a rejected push
|
|
623
|
+
(non-fast-forward, no upstream) is reported, not retried or escalated —
|
|
624
|
+
that's a real divergence a human should look at, not paper over."""
|
|
625
|
+
remote = subprocess.run(["git", "remote"], capture_output=True, text=True, cwd=project_root)
|
|
626
|
+
if not remote.stdout.strip():
|
|
627
|
+
return
|
|
628
|
+
branch = subprocess.run(
|
|
629
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"], capture_output=True, text=True, cwd=project_root,
|
|
630
|
+
).stdout.strip()
|
|
631
|
+
if not branch or branch == "HEAD":
|
|
632
|
+
return
|
|
633
|
+
result = subprocess.run(
|
|
634
|
+
["git", "push", "origin", branch], cwd=project_root, capture_output=True, text=True,
|
|
635
|
+
)
|
|
636
|
+
if result.returncode == 0:
|
|
637
|
+
console.print(f"[dim]Pushed {branch} to origin.[/dim]")
|
|
638
|
+
else:
|
|
639
|
+
console.print(f"[yellow]Auto-push of {branch} failed — resolve manually:[/yellow] {result.stderr.strip()[:300]}")
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def _wave_record(pcp_dir: Path, wave_number: int, check: str, control_id: str, errors: list[str],
|
|
643
|
+
files: list[str] | None = None, result: str | None = None,
|
|
644
|
+
evidence_path: str | None = None) -> None:
|
|
645
|
+
"""Wave-merge gates have no single criterion_id/attempt — record at cycle_number=wave_number
|
|
646
|
+
so they still land in the same telemetry.jsonl audit trail as per-criterion QA checks,
|
|
647
|
+
instead of only ever reaching the user as a console line."""
|
|
648
|
+
if result is None:
|
|
649
|
+
result = "block" if errors else "pass"
|
|
650
|
+
elif result == "pass" and errors:
|
|
651
|
+
# An advisory check passes `result="pass"` explicitly to say "found
|
|
652
|
+
# things, but do not block the wave". Recording that as a literal
|
|
653
|
+
# "pass" made the audit trail lie: eleven controls (CTRL-008, 019,
|
|
654
|
+
# 020, 021, 025, 027, 028, 030, 031, 033, 036) reported a clean pass
|
|
655
|
+
# in telemetry.jsonl no matter what they found, and `pcp provenance`
|
|
656
|
+
# reads exactly that field. A tool selling audit-grade evidence must
|
|
657
|
+
# not have its own controls falsify it.
|
|
658
|
+
#
|
|
659
|
+
# "advisory" is the honest third value: the check ran, it found
|
|
660
|
+
# something, and it deliberately did not block. Distinct from "pass"
|
|
661
|
+
# (found nothing), "block" (found something and stopped the wave),
|
|
662
|
+
# and "skipped" (never ran at all). `error_count` was always correct
|
|
663
|
+
# here, which is why `pcp control-audit` — keying off error_count —
|
|
664
|
+
# was unaffected; provenance keys off `result` and was not.
|
|
665
|
+
result = "advisory"
|
|
666
|
+
telemetry.record(
|
|
667
|
+
pcp_dir,
|
|
668
|
+
cycle="qa", cycle_number=wave_number, check=f"wave-{check}", control_id=control_id,
|
|
669
|
+
module=None, submodule=None, criterion_id=None,
|
|
670
|
+
files=files or [], result=result, errors=errors, error_count=len(errors),
|
|
671
|
+
evidence_path=evidence_path,
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _write_progress(pcp_dir: Path, module: str, criterion_id: str, attempt: int, step: str) -> None:
|
|
676
|
+
"""Live build progress (2026-07-24) -- .pcp/build_progress.yaml, read by
|
|
677
|
+
`pcp build-status`. A backgrounded/parallel-worktree build with no way
|
|
678
|
+
to see what's currently running is exactly what triggered a real
|
|
679
|
+
Project O incident (07-21: 'i want to see whats happening').
|
|
680
|
+
Advisory/UX only -- a write failure here must never fail a real build.
|
|
681
|
+
|
|
682
|
+
`pid` added 2026-07-30, real incident: a human hand-checking whether a
|
|
683
|
+
build was stuck had to piece together `ps`/`date` output themselves and
|
|
684
|
+
misjudged an 18-minute step (well inside the 30-min default agent
|
|
685
|
+
timeout) as a 7-hour hang from misreading `ps aux`'s clock-only START
|
|
686
|
+
column, then told the agent to kill a process that was not stuck at all.
|
|
687
|
+
Without a recorded PID, `pcp build-status` could report elapsed time but
|
|
688
|
+
never actually confirm whether the process behind that elapsed time was
|
|
689
|
+
still alive or long gone -- the exact ambiguity that produced the wrong
|
|
690
|
+
call. `os.getpid()` here is `pcp build`'s own process; that's the one
|
|
691
|
+
whose death actually means "this run stopped", not the transient `claude
|
|
692
|
+
-p` child it spawns per attempt (which comes and goes across attempts
|
|
693
|
+
and retries, but the parent build loop's lifetime is the real signal)."""
|
|
694
|
+
try:
|
|
695
|
+
import os as _os
|
|
696
|
+
from datetime import datetime, timezone
|
|
697
|
+
data = {
|
|
698
|
+
"module": module, "criterion_id": criterion_id, "attempt": attempt, "step": step,
|
|
699
|
+
"updated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
700
|
+
"pid": _os.getpid(),
|
|
701
|
+
}
|
|
702
|
+
(pcp_dir / "build_progress.yaml").write_text(yaml.dump(data, default_flow_style=False))
|
|
703
|
+
except Exception:
|
|
704
|
+
pass
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
_DEP_IN_FINDING = re.compile(r"depends on '([^']+)'")
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _finding_blames_outside_wave(finding: str, wave_mod_names: set[str]) -> bool:
|
|
711
|
+
"""Is this finding a statement about a module the wave did not build?
|
|
712
|
+
|
|
713
|
+
CTRL-007 (see _run_wave_merge step 1) fires when a wave module's declared
|
|
714
|
+
dependency has incomplete criteria. That is a fact about the DEPENDENCY, and
|
|
715
|
+
the dependency is routinely not in this wave at all -- waves exist precisely
|
|
716
|
+
to build dependencies first. Nothing the wave's own agents wrote caused it
|
|
717
|
+
and nothing they could write would fix it.
|
|
718
|
+
|
|
719
|
+
Deterministic, rung 1: read the dependency name out of the finding (producer
|
|
720
|
+
and consumer are both in this file, so the format is not a guess) and ask
|
|
721
|
+
whether it was in the wave. No LLM, no `target` field required -- which is
|
|
722
|
+
what made per-criterion attribution impractical."""
|
|
723
|
+
m = _DEP_IN_FINDING.search(finding)
|
|
724
|
+
return bool(m) and m.group(1) not in wave_mod_names
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _reopen_wave_criteria(pcp_dir: Path, wave_modules: list[dict], wave_number: int,
|
|
728
|
+
findings: list[str]) -> None:
|
|
729
|
+
"""Un-complete the criteria a blocking wave gate just judged defective.
|
|
730
|
+
|
|
731
|
+
Reopens everything completed in the wave rather than guessing WHICH criterion
|
|
732
|
+
caused it: per-criterion attribution would need each criterion's declared
|
|
733
|
+
`target`, which real projects overwhelmingly do not populate (51 of 382 on
|
|
734
|
+
Project O). Reopening too much costs a rebuild; reopening too little
|
|
735
|
+
leaves a vulnerability marked verified. Those failure directions are not
|
|
736
|
+
symmetric, so the coarse choice is right.
|
|
737
|
+
|
|
738
|
+
But "which criterion" and "was this the wave's work at all" are different
|
|
739
|
+
questions, and only the first one needs `target`. Reopening on a finding that
|
|
740
|
+
is purely about pre-existing state elsewhere is not conservative, it is a
|
|
741
|
+
deadlock: the wave gate requires dependencies 100% complete, so a module
|
|
742
|
+
downstream of an incomplete dependency can never pass, and every attempt
|
|
743
|
+
reverts work that was merged and correct.
|
|
744
|
+
|
|
745
|
+
Measured twice on Project O. A036-A039 (agent-query-interface) were
|
|
746
|
+
reverted on five blockers, none from that build. Then on 2026-07-30,
|
|
747
|
+
core-data-model A022/A030/A033/A038 -- **$30.04 spent, all four branches
|
|
748
|
+
merged into main, all four marked `pending`**. They were the four most
|
|
749
|
+
expensive criteria in the run and the four with nothing to show for it.
|
|
750
|
+
|
|
751
|
+
So: if EVERY finding is about a module outside the wave, the wave's own work
|
|
752
|
+
is not implicated and nothing is reopened. The gate still blocks, and the
|
|
753
|
+
escalation is still recorded either way -- the finding is real and forward
|
|
754
|
+
progress still stops. Only the false claim "this criterion is not built" is
|
|
755
|
+
withdrawn.
|
|
756
|
+
|
|
757
|
+
Also records one escalation per module so the finding outlives the console
|
|
758
|
+
line that reported it -- `pcp escalations` can show it, and it is no longer
|
|
759
|
+
possible for a wave BLOCK to leave zero trace in `.pcp/`."""
|
|
760
|
+
from pcp import escalations
|
|
761
|
+
|
|
762
|
+
wave_mod_names = {m["name"] for m in wave_modules}
|
|
763
|
+
external = [f for f in findings if _finding_blames_outside_wave(f, wave_mod_names)]
|
|
764
|
+
attributable = [f for f in findings if f not in external]
|
|
765
|
+
if findings and not attributable:
|
|
766
|
+
console.print(
|
|
767
|
+
f"[yellow]Wave {wave_number} blocked by {len(external)} finding(s) about "
|
|
768
|
+
"module(s) outside this wave — criteria NOT reopened, because this wave's "
|
|
769
|
+
"own work is not what the gate objected to.[/yellow]"
|
|
770
|
+
)
|
|
771
|
+
for f in external:
|
|
772
|
+
console.print(f"[dim] external: {f}[/dim]")
|
|
773
|
+
console.print(
|
|
774
|
+
"[dim]The block stands and an escalation is recorded. Build the named "
|
|
775
|
+
"dependency first; these criteria stay complete because they are.[/dim]"
|
|
776
|
+
)
|
|
777
|
+
for mod in wave_modules:
|
|
778
|
+
with _STATE_LOCK:
|
|
779
|
+
escalations.record(
|
|
780
|
+
pcp_dir, mod["name"], f"wave_{wave_number}", route="wave-block",
|
|
781
|
+
findings=findings,
|
|
782
|
+
)
|
|
783
|
+
return
|
|
784
|
+
|
|
785
|
+
reopened: list[str] = []
|
|
786
|
+
for mod in wave_modules:
|
|
787
|
+
acc_path = pcp_dir / "strategy" / "modules" / mod["name"] / "acceptance.yaml"
|
|
788
|
+
if not acc_path.exists():
|
|
789
|
+
continue
|
|
790
|
+
built_ids = {c["id"] for c in mod.get("pending_criteria", [])}
|
|
791
|
+
if not built_ids:
|
|
792
|
+
continue
|
|
793
|
+
try:
|
|
794
|
+
with _STATE_LOCK:
|
|
795
|
+
acc = load_yaml(acc_path) or {}
|
|
796
|
+
changed = False
|
|
797
|
+
for c in acc.get("criteria", []):
|
|
798
|
+
if c.get("id") in built_ids and c.get("status") == "complete":
|
|
799
|
+
c["status"] = "pending"
|
|
800
|
+
c.pop("verified_by", None)
|
|
801
|
+
reopened.append(f"{mod['name']}/{c['id']}")
|
|
802
|
+
changed = True
|
|
803
|
+
if changed:
|
|
804
|
+
acc_path.write_text(yaml.dump(acc, default_flow_style=False))
|
|
805
|
+
except MalformedSpecError as exc:
|
|
806
|
+
console.print(f"[yellow]Could not reopen '{mod['name']}' criteria: {exc}[/yellow]")
|
|
807
|
+
continue
|
|
808
|
+
with _STATE_LOCK:
|
|
809
|
+
escalations.record(
|
|
810
|
+
pcp_dir, mod["name"], f"wave_{wave_number}", route="wave-block",
|
|
811
|
+
findings=findings,
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
if reopened:
|
|
815
|
+
console.print(
|
|
816
|
+
f"[yellow]Reopened {len(reopened)} criteria judged defective by this wave "
|
|
817
|
+
f"(status complete -> pending): {', '.join(reopened[:8])}"
|
|
818
|
+
f"{'...' if len(reopened) > 8 else ''}[/yellow]"
|
|
819
|
+
)
|
|
820
|
+
console.print(
|
|
821
|
+
"[dim]The code stays merged; the criteria no longer claim to be verified. "
|
|
822
|
+
"The next `pcp build` rebuilds them with these findings as feedback.[/dim]"
|
|
823
|
+
)
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def _run_wave_merge(pcp_dir: Path, wave_modules: list[dict], wave_start_ref: str, wave_number: int = 0) -> list[str]:
|
|
827
|
+
"""Per docs/greenfield.md Phase 4 — contract validation, full integration
|
|
828
|
+
test suite, validate-strategy re-check, wave-level architect-review."""
|
|
829
|
+
project_root = pcp_dir.parent
|
|
830
|
+
findings: list[str] = []
|
|
831
|
+
wave_mod_names = [m["name"] for m in wave_modules]
|
|
832
|
+
|
|
833
|
+
# 1. Contract validation — declared dependencies must be fully complete, not half-built.
|
|
834
|
+
contract_findings: list[str] = []
|
|
835
|
+
for mod in wave_modules:
|
|
836
|
+
for dep in (mod["spec"].get("dependencies") or []):
|
|
837
|
+
dep_acc_path = pcp_dir / "strategy" / "modules" / dep / "acceptance.yaml"
|
|
838
|
+
if not dep_acc_path.exists():
|
|
839
|
+
contract_findings.append(f"Contract: '{mod['name']}' depends on '{dep}', which has no acceptance.yaml")
|
|
840
|
+
continue
|
|
841
|
+
dep_acc = load_yaml(dep_acc_path)
|
|
842
|
+
incomplete = [c["id"] for c in dep_acc.get("criteria", []) if c.get("status", "pending") != "complete"]
|
|
843
|
+
if incomplete:
|
|
844
|
+
contract_findings.append(
|
|
845
|
+
f"Contract: '{mod['name']}' depends on '{dep}', which has incomplete criteria: {', '.join(incomplete)}"
|
|
846
|
+
)
|
|
847
|
+
_wave_record(pcp_dir, wave_number, "contract", "CTRL-007", contract_findings, files=wave_mod_names)
|
|
848
|
+
findings += contract_findings
|
|
849
|
+
|
|
850
|
+
# 2. Full integration test suite on the merged state.
|
|
851
|
+
test_result = qa.run_test_suite(project_root)
|
|
852
|
+
test_findings: list[str] = []
|
|
853
|
+
wave_evidence_path = None
|
|
854
|
+
if test_result["tool"]:
|
|
855
|
+
wave_evidence_path = evidence.store(
|
|
856
|
+
pcp_dir, "_wave", f"wave_{wave_number}", wave_number, "test-suite", test_result["output"],
|
|
857
|
+
)
|
|
858
|
+
if test_result["tool"] and not test_result["passed"]:
|
|
859
|
+
test_findings.append(f"Wave integration suite ({test_result['tool']}) FAILED — full output: {wave_evidence_path}\n{test_result['output'][-1500:]}")
|
|
860
|
+
_wave_record(pcp_dir, wave_number, "test-suite", "CTRL-001", test_findings, files=wave_mod_names,
|
|
861
|
+
result="skipped" if not test_result["tool"] else None, evidence_path=wave_evidence_path)
|
|
862
|
+
findings += test_findings
|
|
863
|
+
|
|
864
|
+
# 3. validate-strategy re-check — coverage/coupling after this wave's changes.
|
|
865
|
+
try:
|
|
866
|
+
from pcp.commands.validate_strategy import run_validate_strategy
|
|
867
|
+
vs = run_validate_strategy(pcp_dir, command="wave-validate-strategy")
|
|
868
|
+
strategy_findings: list[str] = []
|
|
869
|
+
advisory_recorded = False
|
|
870
|
+
vs_evidence_path = evidence.store(
|
|
871
|
+
pcp_dir, "_wave", f"wave_{wave_number}", wave_number, "validate-strategy",
|
|
872
|
+
json.dumps(vs, indent=2, default=str) if vs else "(no result)",
|
|
873
|
+
)
|
|
874
|
+
if vs:
|
|
875
|
+
severe_coupling = [v for v in vs.get("coupling_violations", []) if v.get("type") in ("circular", "god_module", "shared_state")]
|
|
876
|
+
coverage_gaps = vs.get("coverage_gaps") or []
|
|
877
|
+
|
|
878
|
+
# Scorer-consensus rule (dogfood round 3, 2026-07-17): the
|
|
879
|
+
# deterministic assertion scorer's own docstring calls keyword
|
|
880
|
+
# overlap "not ground truth" — a rung-1 heuristic with known
|
|
881
|
+
# false negatives (real coverage, different words). It scored
|
|
882
|
+
# 50% against the LLM's 100% on UNCHANGED specs and hard-blocked
|
|
883
|
+
# the wave. Two scorers disagreeing is an uncertainty signal,
|
|
884
|
+
# not a verdict (the ensemble/consensus mechanism from the
|
|
885
|
+
# Logic-Tier cross-cutting table) — coverage hard-blocks only
|
|
886
|
+
# when both agree it's bad. Severe coupling always blocks: that
|
|
887
|
+
# is real graph math, no second opinion needed.
|
|
888
|
+
llm_score = vs.get("llm_coverage_score")
|
|
889
|
+
# Shared with `pcp validate-strategy` (assertions.scorers_disagree)
|
|
890
|
+
# so the two can never drift into disagreeing about the same data.
|
|
891
|
+
# credibility_floor=1.0: any disagreement is advisory here. The
|
|
892
|
+
# wave gate re-checks UNCHANGED specs after every wave, so a dip
|
|
893
|
+
# is noise rather than new evidence — unlike a standalone audit.
|
|
894
|
+
disagree = assertions_lib.scorers_disagree(vs, credibility_floor=1.0)
|
|
895
|
+
if coverage_gaps and disagree and not severe_coupling:
|
|
896
|
+
console.print(
|
|
897
|
+
f"[yellow]Wave validate-strategy (advisory): deterministic assertion "
|
|
898
|
+
f"coverage {vs.get('coverage_score', 0):.0%} disagrees with LLM coverage "
|
|
899
|
+
f"{llm_score:.0%} on unchanged-spec gaps ({len(coverage_gaps)}) — treating as "
|
|
900
|
+
f"scorer disagreement, not blocking. Full result: {vs_evidence_path}[/yellow]"
|
|
901
|
+
)
|
|
902
|
+
_wave_record(
|
|
903
|
+
pcp_dir, wave_number, "validate-strategy", "CTRL-008",
|
|
904
|
+
[f"scorer disagreement (advisory): deterministic={vs.get('coverage_score', 0):.0%} "
|
|
905
|
+
f"vs llm={llm_score:.0%}, gaps={len(coverage_gaps)}"],
|
|
906
|
+
files=wave_mod_names, result="pass", evidence_path=vs_evidence_path,
|
|
907
|
+
)
|
|
908
|
+
advisory_recorded = True # telemetry written above; not blocking
|
|
909
|
+
elif coverage_gaps or severe_coupling:
|
|
910
|
+
strategy_findings.append(
|
|
911
|
+
f"validate-strategy: coverage={vs.get('coverage_score', 0):.0%}, "
|
|
912
|
+
f"coupling={vs.get('coupling_score', 1):.0%}, "
|
|
913
|
+
f"gaps={len(coverage_gaps)}, "
|
|
914
|
+
f"severe coupling violations={len(severe_coupling)} (circular/god_module/shared_state) — "
|
|
915
|
+
f"full result: {vs_evidence_path}"
|
|
916
|
+
)
|
|
917
|
+
if not advisory_recorded:
|
|
918
|
+
_wave_record(pcp_dir, wave_number, "validate-strategy", "CTRL-008", strategy_findings, files=wave_mod_names,
|
|
919
|
+
evidence_path=vs_evidence_path)
|
|
920
|
+
findings += strategy_findings
|
|
921
|
+
except Exception as e:
|
|
922
|
+
console.print(f"[yellow]Warning: wave validate-strategy check failed: {e}[/yellow]")
|
|
923
|
+
_wave_record(pcp_dir, wave_number, "validate-strategy", "CTRL-008", [f"call failed: {e}"],
|
|
924
|
+
files=wave_mod_names, result="error")
|
|
925
|
+
|
|
926
|
+
# 3.5. Per-module spec alignment (Two Validation Passes, Pass 1) — does
|
|
927
|
+
# each module in this wave still align with the objective/decomposition?
|
|
928
|
+
# Distinct from step 3's validate-strategy (Pass 2: do modules
|
|
929
|
+
# collectively cover the objective) -- this checks each module's own
|
|
930
|
+
# spec individually. Advisory: false-positive rate not measured yet.
|
|
931
|
+
module_align_findings: list[str] = []
|
|
932
|
+
try:
|
|
933
|
+
from pcp.commands.validate_module import run_validate_module
|
|
934
|
+
for mod in wave_modules:
|
|
935
|
+
mod_name = mod["name"]
|
|
936
|
+
result = run_validate_module(pcp_dir, mod_name)
|
|
937
|
+
if result is None:
|
|
938
|
+
continue
|
|
939
|
+
mod_evidence_path = evidence.store(
|
|
940
|
+
pcp_dir, "_wave", f"wave_{wave_number}", wave_number, f"validate-module-{mod_name}",
|
|
941
|
+
json.dumps(result, indent=2, default=str),
|
|
942
|
+
)
|
|
943
|
+
if not result.get("aligned", True):
|
|
944
|
+
console.print(
|
|
945
|
+
f"[yellow]Wave validate-module (advisory): '{mod_name}' alignment "
|
|
946
|
+
f"{result.get('alignment_score', 0):.0%} — full result: {mod_evidence_path}[/yellow]"
|
|
947
|
+
)
|
|
948
|
+
_wave_record(pcp_dir, wave_number, "validate-module", "CTRL-024", [], files=wave_mod_names, result="pass")
|
|
949
|
+
except Exception as e:
|
|
950
|
+
console.print(f"[yellow]Warning: wave validate-module check failed: {e}[/yellow]")
|
|
951
|
+
_wave_record(pcp_dir, wave_number, "validate-module", "CTRL-024", [f"call failed: {e}"],
|
|
952
|
+
files=wave_mod_names, result="error")
|
|
953
|
+
|
|
954
|
+
# 4. Wave-level architect-review — diff since the wave started, not just the last criterion.
|
|
955
|
+
try:
|
|
956
|
+
from pcp.commands.architect_review import (
|
|
957
|
+
SYSTEM_PROMPT as ARCH_SYSTEM_PROMPT, _build_prompt as _arch_build_prompt,
|
|
958
|
+
_load_persona, _load_kb, _get_diff, _changed_files_from_diff,
|
|
959
|
+
)
|
|
960
|
+
wave_diff = _get_diff(wave_start_ref)
|
|
961
|
+
arch_findings: list[str] = []
|
|
962
|
+
if wave_diff.strip():
|
|
963
|
+
changed = _changed_files_from_diff(wave_diff)
|
|
964
|
+
persona = _load_persona(pcp_dir)
|
|
965
|
+
architecture = (pcp_dir / "architecture.md").read_text() if (pcp_dir / "architecture.md").exists() else ""
|
|
966
|
+
kb = _load_kb(pcp_dir, changed)
|
|
967
|
+
prompt = _arch_build_prompt(persona, architecture, kb, wave_diff, "diff")
|
|
968
|
+
# Opus, not Haiku -- a wave-level BLOCK finding stops the entire
|
|
969
|
+
# next wave, a materially higher blast radius than a per-
|
|
970
|
+
# criterion check (see llm/client.py's model-selection strategy).
|
|
971
|
+
res = llm.call_json(ARCH_SYSTEM_PROMPT, prompt, model=llm.ESCALATION_MODEL, pcp_dir=pcp_dir, command="wave-architect-review")
|
|
972
|
+
for f in res.get("findings", []):
|
|
973
|
+
if f.get("severity") == "BLOCK":
|
|
974
|
+
arch_findings.append(f"Wave architect-review: {f.get('location', 'general')}: {f.get('finding', '')} → Fix: {f.get('fix', '')}")
|
|
975
|
+
arch_evidence_path = evidence.store(
|
|
976
|
+
pcp_dir, "_wave", f"wave_{wave_number}", wave_number, "architect-review", json.dumps(res, indent=2),
|
|
977
|
+
)
|
|
978
|
+
# Same adversarial re-verification per-criterion architect-review/gate
|
|
979
|
+
# checks already get (_verify_block_findings) -- wave-level BLOCK
|
|
980
|
+
# findings previously went straight from one Haiku call into a
|
|
981
|
+
# blocked wave-merge with no second opinion, unlike their
|
|
982
|
+
# per-criterion counterparts. wave_ctx mirrors the per-criterion
|
|
983
|
+
# ctx shape (_qa_record/evidence.store both key off module/
|
|
984
|
+
# criterion_id/attempt) with module="_wave" so verify-check
|
|
985
|
+
# telemetry is distinguishable from real per-criterion records.
|
|
986
|
+
wave_ctx = {"module": "_wave", "criterion_id": f"wave_{wave_number}", "attempt": wave_number, "files": changed}
|
|
987
|
+
arch_findings, _dropped = _verify_block_findings(
|
|
988
|
+
pcp_dir, wave_diff, arch_findings, wave_ctx, "wave-architect-review", "CTRL-005",
|
|
989
|
+
)
|
|
990
|
+
_wave_record(pcp_dir, wave_number, "architect-review", "CTRL-005", arch_findings, files=changed,
|
|
991
|
+
evidence_path=arch_evidence_path)
|
|
992
|
+
findings += arch_findings
|
|
993
|
+
except Exception as e:
|
|
994
|
+
console.print(f"[yellow]Warning: wave architect-review failed: {e}[/yellow]")
|
|
995
|
+
_wave_record(pcp_dir, wave_number, "architect-review", "CTRL-005", [f"call failed: {e}"],
|
|
996
|
+
files=wave_mod_names, result="error")
|
|
997
|
+
|
|
998
|
+
# 5. logic_tier drift -- does what actually got built still match the
|
|
999
|
+
# tier a criterion declared at spec time?
|
|
1000
|
+
tier_findings = _run_wave_tier_drift_check(pcp_dir, wave_modules, wave_number)
|
|
1001
|
+
findings += tier_findings
|
|
1002
|
+
|
|
1003
|
+
# 6. build_vs_buy drift -- narrower scope than tier drift, see the
|
|
1004
|
+
# function's own docstring for why only reuse_whole/fork_adapt get
|
|
1005
|
+
# checked, not build_fresh.
|
|
1006
|
+
bvb_findings = _run_wave_build_vs_buy_drift_check(pcp_dir, wave_modules, wave_number)
|
|
1007
|
+
findings += bvb_findings
|
|
1008
|
+
|
|
1009
|
+
# 7-8. Logic-tier integrity, ADVISORY pair (2026-07-18): positive
|
|
1010
|
+
# mechanism-presence check for rungs 2-5 (CTRL-019) and rung-necessity
|
|
1011
|
+
# challenge (CTRL-020). Both record + print, neither blocks — presence
|
|
1012
|
+
# has a known false-positive path (mechanism can live in an imported
|
|
1013
|
+
# helper, not the target file itself), and necessity is a semantic
|
|
1014
|
+
# judgment; per the L1-report-first standing rule both earn hard-block
|
|
1015
|
+
# status only after a measured false-positive rate says they deserve it.
|
|
1016
|
+
_run_wave_tier_presence_check(pcp_dir, wave_modules, wave_number)
|
|
1017
|
+
_run_wave_rung_necessity_check(pcp_dir, wave_modules, wave_number)
|
|
1018
|
+
|
|
1019
|
+
# 9. Context-route staleness (CTRL-021, advisory) — the routing table is
|
|
1020
|
+
# itself a drift surface; a stale route starves agents silently.
|
|
1021
|
+
from pcp import context_map
|
|
1022
|
+
route_findings = context_map.validate(pcp_dir)
|
|
1023
|
+
_wave_record(pcp_dir, wave_number, "context-routes", "CTRL-021", route_findings,
|
|
1024
|
+
files=[], result="pass")
|
|
1025
|
+
for f in route_findings:
|
|
1026
|
+
console.print(f"[yellow]{f}[/yellow]")
|
|
1027
|
+
|
|
1028
|
+
# 10. Navigation depth outliers (CTRL-025, advisory) and 11. top-menu-bar
|
|
1029
|
+
# convention (CTRL-027, advisory, desktop_app archetype only) — neither
|
|
1030
|
+
# blocks, same "report first, measure false-positive rate" posture as
|
|
1031
|
+
# tier-presence/rung-necessity above.
|
|
1032
|
+
_run_wave_nav_depth_check(pcp_dir, wave_modules, wave_number)
|
|
1033
|
+
_run_wave_menu_bar_check(pcp_dir, wave_modules, wave_number)
|
|
1034
|
+
|
|
1035
|
+
# 12. UI kit recipe completeness + import verification (CTRL-028,
|
|
1036
|
+
# advisory) — inert unless .pcp/ui_kit_recipes.yaml exists.
|
|
1037
|
+
_run_wave_ui_kit_check(pcp_dir, wave_modules, wave_number)
|
|
1038
|
+
|
|
1039
|
+
# 13. module_logic_breakdown built-code verification (CTRL-031,
|
|
1040
|
+
# advisory) — inert unless a module declares module_logic_breakdown.
|
|
1041
|
+
_run_wave_logic_breakdown_check(pcp_dir, wave_modules, wave_number)
|
|
1042
|
+
|
|
1043
|
+
# 13.5. ci_rules.yaml contract completeness (CTRL-033, advisory,
|
|
1044
|
+
# project-wide, not per-module).
|
|
1045
|
+
_run_wave_contract_completeness_check(pcp_dir, wave_number)
|
|
1046
|
+
|
|
1047
|
+
# 13.6. Narrative lint (CTRL-036, advisory, project-wide) — CLAUDE.md-
|
|
1048
|
+
# family narrative prose vs. tracked state (current_state.md/
|
|
1049
|
+
# architecture.md). Costs one Haiku call only when a status-shaped line
|
|
1050
|
+
# exists to check.
|
|
1051
|
+
_run_wave_narrative_lint_check(pcp_dir, wave_number)
|
|
1052
|
+
|
|
1053
|
+
# 14. Integrity Auditor (CTRL-030, advisory) — retrospective statistical-
|
|
1054
|
+
# drift signals across ALL completed criteria so far: fast completions
|
|
1055
|
+
# vs. declared logic_tier, per-module placeholder-flag concentration,
|
|
1056
|
+
# findings recurring across many criteria without resolving, uniform/
|
|
1057
|
+
# templated evidence. Reads only; can't correct what's already built —
|
|
1058
|
+
# flags for human review, same posture escalations.yaml already has.
|
|
1059
|
+
# Runs at the wave boundary, not per-criterion — the value is seeing
|
|
1060
|
+
# patterns across many completed criteria no single-criterion CTRL
|
|
1061
|
+
# check can see by design.
|
|
1062
|
+
integrity_findings = integrity_audit.analyze(pcp_dir)
|
|
1063
|
+
_wave_record(pcp_dir, wave_number, "integrity-audit", "CTRL-030", integrity_findings,
|
|
1064
|
+
files=[], result="pass")
|
|
1065
|
+
for f in integrity_findings:
|
|
1066
|
+
console.print(f"[yellow]Integrity Auditor (advisory):[/yellow] {f}")
|
|
1067
|
+
|
|
1068
|
+
return findings
|
|
1069
|
+
|
|
1070
|
+
|
|
1071
|
+
LLM_SDK_IMPORT_PATTERN = re.compile(
|
|
1072
|
+
r"^\s*(?:import|from)\s+(anthropic|openai|google\.generativeai|google\.genai|mistralai|cohere|ollama)\b",
|
|
1073
|
+
re.MULTILINE,
|
|
1074
|
+
)
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
def _run_wave_tier_drift_check(pcp_dir: Path, wave_modules: list[dict], wave_number: int) -> list[str]:
|
|
1078
|
+
"""5th wave-merge sub-check, CTRL-014. Per CLAUDE.md's Logic-Tier
|
|
1079
|
+
Selection section: "Layer 1 gets a deterministic tier-honesty sub-check
|
|
1080
|
+
where possible (e.g. a criterion declaring logic_tier <= 5 whose target
|
|
1081
|
+
file imports an LLM SDK)" -- not built until now. Deterministic, no LLM
|
|
1082
|
+
call: a completed criterion declaring logic_tier 1-5 (deterministic
|
|
1083
|
+
through cached-reuse -- no runtime LLM call expected by definition) whose
|
|
1084
|
+
own target file demonstrably imports an LLM SDK is a real signal the
|
|
1085
|
+
declared decision no longer matches what was actually built. Only rung 6
|
|
1086
|
+
(deep-think LLM) is expected to import one. build_vs_buy gets its own,
|
|
1087
|
+
narrower, separate check -- _run_wave_build_vs_buy_drift_check below."""
|
|
1088
|
+
project_root = pcp_dir.parent
|
|
1089
|
+
findings: list[str] = []
|
|
1090
|
+
checked_files: list[str] = []
|
|
1091
|
+
|
|
1092
|
+
for mod in wave_modules:
|
|
1093
|
+
acc_path = pcp_dir / "strategy" / "modules" / mod["name"] / "acceptance.yaml"
|
|
1094
|
+
if not acc_path.exists():
|
|
1095
|
+
continue
|
|
1096
|
+
acc = load_yaml(acc_path)
|
|
1097
|
+
for c in acc.get("criteria", []):
|
|
1098
|
+
if c.get("status") != "complete":
|
|
1099
|
+
continue
|
|
1100
|
+
tier = c.get("logic_tier")
|
|
1101
|
+
target = c.get("target")
|
|
1102
|
+
if tier is None or tier > 5 or not target:
|
|
1103
|
+
continue
|
|
1104
|
+
full_path = project_root / target
|
|
1105
|
+
if not full_path.exists() or not full_path.is_file():
|
|
1106
|
+
continue
|
|
1107
|
+
checked_files.append(target)
|
|
1108
|
+
try:
|
|
1109
|
+
content = full_path.read_text(errors="replace")
|
|
1110
|
+
except OSError:
|
|
1111
|
+
continue
|
|
1112
|
+
m = LLM_SDK_IMPORT_PATTERN.search(content)
|
|
1113
|
+
if m:
|
|
1114
|
+
findings.append(
|
|
1115
|
+
f"Tier drift: '{mod['name']}/{c['id']}' declares logic_tier={tier} "
|
|
1116
|
+
f"(rung <=5, no runtime LLM call expected) but {target} imports "
|
|
1117
|
+
f"{m.group(1)} -- the declared decision no longer matches what was built."
|
|
1118
|
+
)
|
|
1119
|
+
|
|
1120
|
+
_wave_record(pcp_dir, wave_number, "tier-drift", "CTRL-014", findings, files=checked_files)
|
|
1121
|
+
return findings
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
def _stdlib_module_names() -> frozenset[str]:
|
|
1125
|
+
import sys
|
|
1126
|
+
names = getattr(sys, "stdlib_module_names", None)
|
|
1127
|
+
return frozenset(names) if names else frozenset()
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
def _local_package_names(project_root: Path) -> frozenset[str]:
|
|
1131
|
+
"""Top-level directory names under src/ (or the project root itself if
|
|
1132
|
+
no src/ layout) -- a Python `import` of one of these is a local project
|
|
1133
|
+
import, not an external dependency."""
|
|
1134
|
+
src = project_root / "src"
|
|
1135
|
+
base = src if src.exists() else project_root
|
|
1136
|
+
return frozenset(p.name for p in base.iterdir() if p.is_dir() and not p.name.startswith("."))
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
def _external_python_imports(target_path: Path, project_root: Path) -> set[str]:
|
|
1140
|
+
"""Top-level import names from a Python file, excluding stdlib and this
|
|
1141
|
+
project's own local packages -- what's left is a real external/
|
|
1142
|
+
third-party dependency. Python-only: generalizing import extraction
|
|
1143
|
+
across every EXTRACTORS language (discovery/graph.py) for one heuristic
|
|
1144
|
+
check isn't worth the added surface for this pass."""
|
|
1145
|
+
if target_path.suffix != ".py":
|
|
1146
|
+
return set()
|
|
1147
|
+
from pcp.discovery.graph import extract_imports_python
|
|
1148
|
+
raw = extract_imports_python(target_path, project_root)
|
|
1149
|
+
return {i for i in raw if i not in _stdlib_module_names() and i not in _local_package_names(project_root)}
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
def _run_wave_build_vs_buy_drift_check(pcp_dir: Path, wave_modules: list[dict], wave_number: int) -> list[str]:
|
|
1153
|
+
"""6th wave-merge sub-check, CTRL-016. Same "does the declared decision
|
|
1154
|
+
still match what got built" question _run_wave_tier_drift_check asks of
|
|
1155
|
+
logic_tier, applied to build_vs_buy -- but deliberately narrower scope:
|
|
1156
|
+
only `reuse_whole`/`fork_adapt` get checked (declaring one of these
|
|
1157
|
+
means an external dependency SHOULD be there -- a target file with zero
|
|
1158
|
+
external imports despite that claim is a real, cheap, low-false-positive
|
|
1159
|
+
signal). `build_fresh` is NOT checked in the other direction ("does it
|
|
1160
|
+
import something new"): package names routinely differ from their
|
|
1161
|
+
import names (pyyaml->yaml, beautifulsoup4->bs4, pillow->PIL), which
|
|
1162
|
+
would make a "no new external import" check noisy enough to be
|
|
1163
|
+
untrustworthy as a hard_block gate. `reuse_partial`/
|
|
1164
|
+
`reimplement_from_reference` are skipped entirely -- vendored or
|
|
1165
|
+
reimplemented code has no distinguishing import signature either way.
|
|
1166
|
+
Left for a future pass rather than shipping something that guesses."""
|
|
1167
|
+
project_root = pcp_dir.parent
|
|
1168
|
+
findings: list[str] = []
|
|
1169
|
+
checked_files: list[str] = []
|
|
1170
|
+
|
|
1171
|
+
for mod in wave_modules:
|
|
1172
|
+
acc_path = pcp_dir / "strategy" / "modules" / mod["name"] / "acceptance.yaml"
|
|
1173
|
+
if not acc_path.exists():
|
|
1174
|
+
continue
|
|
1175
|
+
acc = load_yaml(acc_path)
|
|
1176
|
+
for c in acc.get("criteria", []):
|
|
1177
|
+
if c.get("status") != "complete":
|
|
1178
|
+
continue
|
|
1179
|
+
decision = (c.get("build_vs_buy") or {}).get("decision")
|
|
1180
|
+
target = c.get("target")
|
|
1181
|
+
if decision not in ("reuse_whole", "fork_adapt") or not target:
|
|
1182
|
+
continue
|
|
1183
|
+
full_path = project_root / target
|
|
1184
|
+
if not full_path.exists() or not full_path.is_file():
|
|
1185
|
+
continue
|
|
1186
|
+
checked_files.append(target)
|
|
1187
|
+
externals = _external_python_imports(full_path, project_root)
|
|
1188
|
+
if not externals:
|
|
1189
|
+
findings.append(
|
|
1190
|
+
f"Build-vs-buy drift: '{mod['name']}/{c['id']}' declares "
|
|
1191
|
+
f"build_vs_buy={decision} but {target} imports no external package -- "
|
|
1192
|
+
f"the declared decision no longer matches what was built."
|
|
1193
|
+
)
|
|
1194
|
+
|
|
1195
|
+
_wave_record(pcp_dir, wave_number, "build-vs-buy-drift", "CTRL-016", findings, files=checked_files)
|
|
1196
|
+
return findings
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
# PCP's own operational writes during a build attempt (usage logging, telemetry,
|
|
1200
|
+
# evidence, capture). Found dogfooding 2026-07-17: every LLM call appends to the
|
|
1201
|
+
# project's .pcp/token_ledger.yaml, which then landed in changed_files and the
|
|
1202
|
+
# judge diff — attempt 1's alignment gate literally scored the token ledger as
|
|
1203
|
+
# the PR ("Score 0%: token ledger entry; no implementation progress") and the
|
|
1204
|
+
# scope guard flagged PCP's own write as agent over-reach. These paths are never
|
|
1205
|
+
# an agent deliverable; they are excluded from gate inputs entirely.
|
|
1206
|
+
_PCP_OPERATIONAL_PATHS = (
|
|
1207
|
+
".pcp/token_ledger.yaml", ".pcp/telemetry.jsonl", ".pcp/decision_log.jsonl",
|
|
1208
|
+
".pcp/brd.md", ".pcp/brd_items.yaml", ".pcp/coverage_audit.jsonl",
|
|
1209
|
+
".pcp/escalations.yaml", ".pcp/prune_log.yaml", ".pcp/current_state.md",
|
|
1210
|
+
".pcp/diff.md", ".pcp/notify_heartbeat.yaml",
|
|
1211
|
+
# Added 2026-07-27. `_write_progress` (added 07-24) writes this on every
|
|
1212
|
+
# single build attempt, so omitting it re-created the exact 07-17 bug the
|
|
1213
|
+
# comment above describes: PCP's own bookkeeping landing in changed_files,
|
|
1214
|
+
# polluting the judge diff and drawing scope-guard findings against the
|
|
1215
|
+
# agent. Any NEW file PCP writes under .pcp/ during a build attempt must be
|
|
1216
|
+
# added here at the same time it is introduced.
|
|
1217
|
+
".pcp/build_progress.yaml",
|
|
1218
|
+
# run_log.py's pre/post audit bracket, added 2026-07-23 and never
|
|
1219
|
+
# registered here — found 2026-07-27 the same hour the rule above was
|
|
1220
|
+
# written, which is the point: the rule is not self-enforcing, so
|
|
1221
|
+
# test_no_unregistered_pcp_runtime_writer() now checks it mechanically.
|
|
1222
|
+
".pcp/run_ledger.jsonl",
|
|
1223
|
+
# hidden_coupling.json — written by validate_strategy.py's _add_coupling
|
|
1224
|
+
# (cached git co-change result) and read by build.py's run_log wiring at
|
|
1225
|
+
# start_run time. Registered at introduction (2026-07-31), same rule.
|
|
1226
|
+
".pcp/hidden_coupling.json",
|
|
1227
|
+
)
|
|
1228
|
+
_PCP_OPERATIONAL_DIRS = (".pcp/evidence/", ".pcp/transcripts/")
|
|
1229
|
+
|
|
1230
|
+
|
|
1231
|
+
# Paths `_auto_commit_criterion` must never stage. Two distinct hazards, one
|
|
1232
|
+
# rule: any file that (a) is written continuously by PCP or the agent harness
|
|
1233
|
+
# during a build and (b) is not an agent deliverable will, if committed by a
|
|
1234
|
+
# worktree branch, break the merge that brings that branch home.
|
|
1235
|
+
#
|
|
1236
|
+
# 2026-07-25 was the `.claude/settings.json` case: every worktree wrote a
|
|
1237
|
+
# DIFFERENT version of the same new path, so merging two branches was an
|
|
1238
|
+
# add/add conflict. That got patched by naming those two files here.
|
|
1239
|
+
#
|
|
1240
|
+
# 2026-07-27 (Project S dogfood) was the same shape through the other door:
|
|
1241
|
+
# `.pcp/token_ledger.yaml` and friends are TRACKED, and PCP appends to them in
|
|
1242
|
+
# the main pcp_dir throughout the run by design. So at merge time the main
|
|
1243
|
+
# repo has uncommitted changes to a tracked file the incoming branch also
|
|
1244
|
+
# committed, and git refuses before it even starts:
|
|
1245
|
+
# "Your local changes to the following files would be overwritten by
|
|
1246
|
+
# merge: .pcp/token_ledger.yaml"
|
|
1247
|
+
# Criterion A001 halted the build on exactly this, after A002 and A004 had
|
|
1248
|
+
# already merged cleanly.
|
|
1249
|
+
#
|
|
1250
|
+
# Deriving this from _PCP_OPERATIONAL_PATHS rather than listing files again is
|
|
1251
|
+
# the point: those tuples already define "PCP's own bookkeeping, not agent
|
|
1252
|
+
# output", and every consumer of that idea should read the same source. Naming
|
|
1253
|
+
# one offending file at a time is what let the identical bug return twice.
|
|
1254
|
+
_AGENT_LOCAL_CONFIG = (
|
|
1255
|
+
".claude/settings.json", ".claude/settings.local.json",
|
|
1256
|
+
# testmon's per-test dependency cache. Written on every build, differs
|
|
1257
|
+
# per worktree, and is not an agent deliverable -- precisely the shape
|
|
1258
|
+
# that broke wave merges twice on 2026-07-27 (.claude/settings.json as
|
|
1259
|
+
# an add/add conflict, .pcp/token_ledger.yaml as "your local changes
|
|
1260
|
+
# would be overwritten by merge"). Excluded before it can do it again.
|
|
1261
|
+
".testmondata", ".testmondata-journal",
|
|
1262
|
+
)
|
|
1263
|
+
|
|
1264
|
+
_AUTO_COMMIT_EXCLUDES = tuple(
|
|
1265
|
+
f":!{p}" for p in (*_AGENT_LOCAL_CONFIG, *_PCP_OPERATIONAL_PATHS)
|
|
1266
|
+
) + tuple(f":!{d.rstrip('/')}" for d in _PCP_OPERATIONAL_DIRS)
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
def _is_pcp_operational(path: str) -> bool:
|
|
1270
|
+
norm = path.replace("\\", "/").removeprefix("./")
|
|
1271
|
+
return norm in _PCP_OPERATIONAL_PATHS or any(norm.startswith(d) for d in _PCP_OPERATIONAL_DIRS)
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
def _get_unstaged_files(cwd: Path) -> list[str]:
|
|
1275
|
+
result = subprocess.run(
|
|
1276
|
+
["git", "diff", "--name-only"],
|
|
1277
|
+
capture_output=True, text=True, cwd=cwd,
|
|
1278
|
+
)
|
|
1279
|
+
if result.returncode != 0:
|
|
1280
|
+
return []
|
|
1281
|
+
return [f.strip() for f in result.stdout.splitlines() if f.strip()]
|
|
1282
|
+
|
|
1283
|
+
|
|
1284
|
+
def _get_staged_files(cwd: Path) -> list[str]:
|
|
1285
|
+
result = subprocess.run(
|
|
1286
|
+
["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"],
|
|
1287
|
+
capture_output=True, text=True, cwd=cwd,
|
|
1288
|
+
)
|
|
1289
|
+
if result.returncode != 0:
|
|
1290
|
+
return []
|
|
1291
|
+
return [f.strip() for f in result.stdout.splitlines() if f.strip()]
|
|
1292
|
+
|
|
1293
|
+
|
|
1294
|
+
def _get_untracked_files(cwd: Path) -> list[str]:
|
|
1295
|
+
result = subprocess.run(
|
|
1296
|
+
["git", "ls-files", "--others", "--exclude-standard"],
|
|
1297
|
+
capture_output=True, text=True, cwd=cwd,
|
|
1298
|
+
)
|
|
1299
|
+
if result.returncode != 0:
|
|
1300
|
+
return []
|
|
1301
|
+
return [f.strip() for f in result.stdout.splitlines() if f.strip()]
|
|
1302
|
+
|
|
1303
|
+
|
|
1304
|
+
def _get_committed_files_since(cwd: Path, since_ref: str) -> list[str]:
|
|
1305
|
+
result = subprocess.run(
|
|
1306
|
+
["git", "diff", "--name-only", "--diff-filter=ACMR", since_ref],
|
|
1307
|
+
capture_output=True, text=True, cwd=cwd,
|
|
1308
|
+
)
|
|
1309
|
+
if result.returncode != 0:
|
|
1310
|
+
return []
|
|
1311
|
+
return [f.strip() for f in result.stdout.splitlines() if f.strip()]
|
|
1312
|
+
|
|
1313
|
+
|
|
1314
|
+
def _get_changed_files_since(cwd: Path, since_ref: str | None) -> list[str]:
|
|
1315
|
+
"""Everything the agent touched this criterion, however it left it:
|
|
1316
|
+
committed (diff since the criterion-start ref), staged, unstaged, or
|
|
1317
|
+
still untracked. Found dogfooding 2026-07-17 (round 2): the agent
|
|
1318
|
+
COMMITTED its work — perfectly reasonable, nothing told it not to — and
|
|
1319
|
+
the old staged+unstaged-only view reported 'No files were modified by
|
|
1320
|
+
the agent' against a 65-line committed implementation, then the gates
|
|
1321
|
+
judged an empty diff. An agent must not be able to make its work
|
|
1322
|
+
invisible to the gates by committing it."""
|
|
1323
|
+
files = set(_get_staged_files(cwd) + _get_unstaged_files(cwd) + _get_untracked_files(cwd))
|
|
1324
|
+
if since_ref:
|
|
1325
|
+
files.update(_get_committed_files_since(cwd, since_ref))
|
|
1326
|
+
return sorted(files)
|
|
1327
|
+
|
|
1328
|
+
|
|
1329
|
+
def _get_working_diff(cwd: Path, since_ref: str | None = None) -> str:
|
|
1330
|
+
# :(exclude) pathspecs keep PCP's own operational writes (token ledger,
|
|
1331
|
+
# telemetry, evidence) out of the diff the LLM judges see — see
|
|
1332
|
+
# _PCP_OPERATIONAL_PATHS above for why. Diff base is the criterion-start
|
|
1333
|
+
# ref when given (covers work the agent committed), falling back to HEAD.
|
|
1334
|
+
excludes = [f":(exclude){p}" for p in _PCP_OPERATIONAL_PATHS] + \
|
|
1335
|
+
[f":(exclude){d.rstrip('/')}" for d in _PCP_OPERATIONAL_DIRS]
|
|
1336
|
+
base = since_ref or "HEAD"
|
|
1337
|
+
result = subprocess.run(
|
|
1338
|
+
["git", "diff", base, "--", ".", *excludes],
|
|
1339
|
+
capture_output=True, text=True, cwd=cwd,
|
|
1340
|
+
)
|
|
1341
|
+
if result.returncode != 0:
|
|
1342
|
+
# Fallback to general diff
|
|
1343
|
+
result = subprocess.run(
|
|
1344
|
+
["git", "diff", "--", ".", *excludes],
|
|
1345
|
+
capture_output=True, text=True, cwd=cwd,
|
|
1346
|
+
)
|
|
1347
|
+
out = result.stdout
|
|
1348
|
+
|
|
1349
|
+
# `git diff` NEVER shows untracked files, so a criterion whose agent created
|
|
1350
|
+
# only NEW files and left them unstaged produced an empty diff here while
|
|
1351
|
+
# `_get_changed_files_since` (right above) correctly reported them. The
|
|
1352
|
+
# gates then judged nothing and returned "No diff provided; cannot assess
|
|
1353
|
+
# alignment" -- a guaranteed 0% BLOCK on work that plainly existed.
|
|
1354
|
+
#
|
|
1355
|
+
# Observed live 2026-07-27, Project S dogfood, pdf-document-storage/A004:
|
|
1356
|
+
# scope guard listed 3 modified files in the same attempt the alignment
|
|
1357
|
+
# gate reported no diff at all.
|
|
1358
|
+
#
|
|
1359
|
+
# This is the SAME bug the sibling function's docstring describes fixing on
|
|
1360
|
+
# 2026-07-17 ("an agent must not be able to make its work invisible to the
|
|
1361
|
+
# gates"). That fix taught the file LIST about all four states and left the
|
|
1362
|
+
# DIFF beside it knowing only three. Read-only on purpose: `git add -N`
|
|
1363
|
+
# would surface them too but mutates the index during what must stay a
|
|
1364
|
+
# pure gate evaluation.
|
|
1365
|
+
for path in _get_untracked_files(cwd):
|
|
1366
|
+
if _is_pcp_operational(path):
|
|
1367
|
+
continue
|
|
1368
|
+
shown = subprocess.run(
|
|
1369
|
+
["git", "diff", "--no-index", "--", os.devnull, path],
|
|
1370
|
+
capture_output=True, text=True, cwd=cwd,
|
|
1371
|
+
)
|
|
1372
|
+
# --no-index exits 1 when the files differ, which is the normal case here.
|
|
1373
|
+
if shown.stdout:
|
|
1374
|
+
out += shown.stdout
|
|
1375
|
+
|
|
1376
|
+
return out[:14000]
|
|
1377
|
+
|
|
1378
|
+
|
|
1379
|
+
UI_KEYWORDS = (
|
|
1380
|
+
"render", "renders", "display", "displays", "dashboard", "portal",
|
|
1381
|
+
"screen", "view", "form", "ui", "page", "widget",
|
|
1382
|
+
)
|
|
1383
|
+
|
|
1384
|
+
|
|
1385
|
+
def _is_ui_facing_criterion(criterion: dict) -> bool:
|
|
1386
|
+
"""Cheap, deterministic keyword check (rung 1 — no LLM call needed to
|
|
1387
|
+
decide whether to mention the design system). False negatives just mean
|
|
1388
|
+
a UI criterion doesn't get the design-system hint; false positives just
|
|
1389
|
+
mean a harmless, ignorable pointer gets included for a non-UI criterion.
|
|
1390
|
+
Neither costs anything beyond a few extra prompt tokens."""
|
|
1391
|
+
text = criterion.get("description", "").lower()
|
|
1392
|
+
return any(kw in text for kw in UI_KEYWORDS)
|
|
1393
|
+
|
|
1394
|
+
|
|
1395
|
+
_QA_TAIL = (
|
|
1396
|
+
"The full test suite, lint, and a SAST/secret scan will run against your changes "
|
|
1397
|
+
"after you finish — fix anything those would flag before considering the criterion done."
|
|
1398
|
+
)
|
|
1399
|
+
|
|
1400
|
+
# Checks PCP already evaluates deterministically at Layer 1, for free, on every
|
|
1401
|
+
# run. A pytest that re-asserts one of these buys nothing and costs forever.
|
|
1402
|
+
_SELF_VERIFYING_CHECKS = {"file_exists", "ast_pattern"}
|
|
1403
|
+
|
|
1404
|
+
|
|
1405
|
+
def _tdd_instruction(criterion: dict) -> str:
|
|
1406
|
+
"""The TDD line, conditioned on whether PCP already verifies this criterion.
|
|
1407
|
+
|
|
1408
|
+
"Follow TDD: write a failing test for this criterion first" was unconditional,
|
|
1409
|
+
so a criterion declaring `check: file_exists` got a pytest asserting that the
|
|
1410
|
+
file exists -- duplicating, at permanent runtime cost, a check PCP performs
|
|
1411
|
+
deterministically for free.
|
|
1412
|
+
|
|
1413
|
+
The agents were not being lazy; they were being obedient, and one of them said
|
|
1414
|
+
so in a docstring: "Mirrors the file_exists check declared in
|
|
1415
|
+
.pcp/strategy/modules/agent-query-interface/acceptance.yaml."
|
|
1416
|
+
|
|
1417
|
+
Measured on Project O 2026-07-30: `test_interface_file_exists` and
|
|
1418
|
+
`test_feature_flag_file_exists` each appear **20 times**, once per module, ~40
|
|
1419
|
+
tests asserting only that a path exists. They are not free. `core-data-model`'s
|
|
1420
|
+
blast radius is 99% of the suite (every module depends on it), so every one of
|
|
1421
|
+
these runs on essentially every scoped test run, on a suite that hit the 900s
|
|
1422
|
+
timeout three times that day.
|
|
1423
|
+
|
|
1424
|
+
Behaviour still gets tested. A feature flag's *content* ("defaults to false"),
|
|
1425
|
+
an interface's *shape* (abstract, uninstantiable, fully typed), a registry
|
|
1426
|
+
*not* registering a dark module -- those are real assertions and are asked for
|
|
1427
|
+
explicitly. Only "the file is on disk" is dropped, because that sentence is
|
|
1428
|
+
already in acceptance.yaml as machine-checked data."""
|
|
1429
|
+
check = (criterion.get("check") or "").strip()
|
|
1430
|
+
if check not in _SELF_VERIFYING_CHECKS:
|
|
1431
|
+
return (
|
|
1432
|
+
"Follow TDD: write a failing test for this criterion first, confirm it fails, "
|
|
1433
|
+
"then write the implementation and confirm the test passes. " + _QA_TAIL
|
|
1434
|
+
)
|
|
1435
|
+
what = "the file exists" if check == "file_exists" else "the pattern is present"
|
|
1436
|
+
return (
|
|
1437
|
+
f"This criterion declares `check: {check}`, which PCP evaluates deterministically "
|
|
1438
|
+
f"at Layer 1 on every run. Do NOT write a test that asserts {what} — that "
|
|
1439
|
+
f"duplicates a free check and adds permanent runtime to every future test run. "
|
|
1440
|
+
"Write tests only for BEHAVIOUR the deterministic check cannot see (what the file "
|
|
1441
|
+
"must contain, what the interface must guarantee, what must happen at runtime), "
|
|
1442
|
+
"and write none if this criterion genuinely has no behaviour beyond existence. "
|
|
1443
|
+
"Follow TDD for whatever behavioural tests you do write: failing first, then the "
|
|
1444
|
+
"implementation. " + _QA_TAIL
|
|
1445
|
+
)
|
|
1446
|
+
|
|
1447
|
+
|
|
1448
|
+
def _build_agent_prompt(
|
|
1449
|
+
pcp_dir: Path,
|
|
1450
|
+
module_name: str,
|
|
1451
|
+
criterion: dict,
|
|
1452
|
+
spec: dict,
|
|
1453
|
+
) -> str:
|
|
1454
|
+
"""First-attempt prompt. You have filesystem access — read .pcp/ context yourself
|
|
1455
|
+
instead of having it pasted here. Pasting it costs input tokens on every single
|
|
1456
|
+
criterion/attempt for content that's identical across the whole build run."""
|
|
1457
|
+
# Context routing (2026-07-18): the file list comes from the declarative
|
|
1458
|
+
# context_map (scenario -> files), not a hardcoded paste-adjacent list.
|
|
1459
|
+
# module_state routes to THIS module's generated state slice
|
|
1460
|
+
# (docs/built.md — a projection regenerated from acceptance.yaml), not
|
|
1461
|
+
# program-wide current_state.md: on a many-module project the global
|
|
1462
|
+
# file is mostly other modules' context — measured contamination, not
|
|
1463
|
+
# useful grounding. Falls back to current_state.md when the slice
|
|
1464
|
+
# doesn't exist yet (pre-docs-kit projects).
|
|
1465
|
+
from pcp import context_map
|
|
1466
|
+
always_files = context_map.resolve(pcp_dir, "always")
|
|
1467
|
+
state_files = context_map.resolve(pcp_dir, "module_state", module=module_name)
|
|
1468
|
+
read_list = [f"- {p}" for p in always_files + state_files] or [
|
|
1469
|
+
"- .pcp/objective.md", "- .pcp/architecture.md", "- .pcp/current_state.md",
|
|
1470
|
+
]
|
|
1471
|
+
prompt_parts = [
|
|
1472
|
+
"You are an AI coding agent implementing an acceptance criterion for a program module.",
|
|
1473
|
+
"Your task is to write/modify code in the project to implement this feature.",
|
|
1474
|
+
f"Module: {module_name}",
|
|
1475
|
+
f"Criterion: [{criterion['id']}] {criterion['description']}",
|
|
1476
|
+
"",
|
|
1477
|
+
"Before editing, read these files yourself for context (don't ask — just Read them). "
|
|
1478
|
+
"Read ONLY these — they are routed for this specific criterion; pulling in other "
|
|
1479
|
+
".pcp/ files adds noise, not grounding:",
|
|
1480
|
+
*read_list,
|
|
1481
|
+
"",
|
|
1482
|
+
]
|
|
1483
|
+
|
|
1484
|
+
# This criterion's own acceptance.yaml already declares the file it's
|
|
1485
|
+
# about (`target`, and `pattern` for ast_pattern checks) — found
|
|
1486
|
+
# 2026-07-08 that without this hint the agent spent several turns per
|
|
1487
|
+
# criterion re-discovering it via `find`/`grep`, real turns/cache_read
|
|
1488
|
+
# volume for information already on disk.
|
|
1489
|
+
target = criterion.get("target")
|
|
1490
|
+
if target:
|
|
1491
|
+
prompt_parts.append(
|
|
1492
|
+
f"This criterion's target file is `{target}` — start there instead of "
|
|
1493
|
+
f"searching the repo for it. If it doesn't exist yet, create it there."
|
|
1494
|
+
)
|
|
1495
|
+
pattern = criterion.get("pattern")
|
|
1496
|
+
if pattern:
|
|
1497
|
+
prompt_parts.append(f"It must satisfy this pattern: `{pattern}`")
|
|
1498
|
+
prompt_parts.append("")
|
|
1499
|
+
|
|
1500
|
+
# Learned-decision injection (ECC "instincts" reference-pattern, 2026-07-17):
|
|
1501
|
+
# decision_log.jsonl was captured but never fed back — every criterion
|
|
1502
|
+
# agent re-discovered root causes / library picks / workarounds earlier
|
|
1503
|
+
# sessions already distilled. Deterministic selection, bounded count+chars,
|
|
1504
|
+
# zero LLM cost (see decision_log.select_relevant).
|
|
1505
|
+
decision_lines = decision_log.format_for_prompt(pcp_dir, module_name)
|
|
1506
|
+
if decision_lines:
|
|
1507
|
+
prompt_parts.append(
|
|
1508
|
+
"## Prior technical decisions in this project (distilled from earlier "
|
|
1509
|
+
"sessions — treat as established context, don't re-derive or contradict "
|
|
1510
|
+
"them without saying why):"
|
|
1511
|
+
)
|
|
1512
|
+
prompt_parts += decision_lines
|
|
1513
|
+
prompt_parts.append("")
|
|
1514
|
+
|
|
1515
|
+
# Librarian retrieval (2026-07-20, swarm-role design): deterministic
|
|
1516
|
+
# keyword-overlap scan over EXISTING definitions in the project, so this
|
|
1517
|
+
# criterion's builder doesn't independently re-explore the codebase for
|
|
1518
|
+
# a pattern another module already has. Rung-4-shaped (retrieval, not a
|
|
1519
|
+
# conversational search agent) — pure query/response, never corrects or
|
|
1520
|
+
# blocks. Bounded count/chars, zero LLM cost, same Token Discipline
|
|
1521
|
+
# posture as the decision-log injection above.
|
|
1522
|
+
if os.environ.get("PCP_BUILD_INJECT_LIBRARIAN", "1") != "0":
|
|
1523
|
+
librarian_lines = librarian.format_for_prompt(pcp_dir.parent, criterion)
|
|
1524
|
+
if librarian_lines:
|
|
1525
|
+
prompt_parts.append(
|
|
1526
|
+
"## Possibly-related existing code in this project (keyword match on "
|
|
1527
|
+
"this criterion's own description — not verified relevance, check before "
|
|
1528
|
+
"reusing):"
|
|
1529
|
+
)
|
|
1530
|
+
prompt_parts += librarian_lines
|
|
1531
|
+
prompt_parts.append("")
|
|
1532
|
+
|
|
1533
|
+
# Rung-specific implementation guidance (2026-07-18): the tier is already
|
|
1534
|
+
# declared — point the agent at the guide's process + search-first list
|
|
1535
|
+
# for exactly that rung. One line; the guide is read on demand, never
|
|
1536
|
+
# pasted (Token Discipline).
|
|
1537
|
+
declared_tier = criterion.get("logic_tier")
|
|
1538
|
+
if isinstance(declared_tier, int):
|
|
1539
|
+
prompt_parts.append(
|
|
1540
|
+
f"This criterion declares logic_tier={declared_tier}. Before implementing, read "
|
|
1541
|
+
f"the 'Rung {declared_tier}' section of `.pcp/logic_tier_guide.md` (if present) — "
|
|
1542
|
+
"it gives the implementation process and what to SEARCH FOR before building "
|
|
1543
|
+
"(existing packages/models/patterns). If your implementation ends up needing a "
|
|
1544
|
+
"different rung than declared, STOP and say so in your summary rather than "
|
|
1545
|
+
"quietly building at the wrong tier — the wave gate checks tier honesty."
|
|
1546
|
+
)
|
|
1547
|
+
prompt_parts.append("")
|
|
1548
|
+
|
|
1549
|
+
if _is_ui_facing_criterion(criterion):
|
|
1550
|
+
reference_image = criterion.get("reference_image")
|
|
1551
|
+
reference_line = (
|
|
1552
|
+
f" A reference image is declared for this criterion at `{reference_image}` — "
|
|
1553
|
+
"look at it before building; it's also fed to the automated visual-quality "
|
|
1554
|
+
"check as a comparison target after you finish (layout/structure similarity, "
|
|
1555
|
+
"not pixel-perfect)."
|
|
1556
|
+
if reference_image else ""
|
|
1557
|
+
)
|
|
1558
|
+
recipes_path = pcp_dir / "ui_kit_recipes.yaml"
|
|
1559
|
+
ui_kit_line = (
|
|
1560
|
+
" If `.pcp/ui_kit_recipes.yaml` exists, read it: it maps this screen's "
|
|
1561
|
+
"archetype(s) to the organisms (data-table, primary-nav, modal, ...) it needs, "
|
|
1562
|
+
"and each organism to a real shadcn/ui component to vendor (use the shadcn MCP "
|
|
1563
|
+
"server if available, or `npx shadcn add <component>` directly) rather than "
|
|
1564
|
+
"hand-rolling markup — PCP doesn't maintain UI component code itself, shadcn "
|
|
1565
|
+
"already does. Declare `screen_archetypes` and `ui_organisms` on this criterion "
|
|
1566
|
+
"in acceptance.yaml matching what you actually built; the wave-merge gate "
|
|
1567
|
+
"checks these against the recipe and against real imports in your target file."
|
|
1568
|
+
if recipes_path.exists() else ""
|
|
1569
|
+
)
|
|
1570
|
+
prompt_parts.append(
|
|
1571
|
+
"This criterion renders user-facing UI. Read `.pcp/design_system.md` first "
|
|
1572
|
+
"and apply its established tokens/conventions rather than deciding a look "
|
|
1573
|
+
"fresh — if it's still the empty scaffold, this is the first UI screen: "
|
|
1574
|
+
"establish the system now (see the `pcp-ui-design` skill) and write it there "
|
|
1575
|
+
"so later screens stay consistent instead of each looking like a different "
|
|
1576
|
+
f"vanilla template.{reference_line}{ui_kit_line} Before finishing, add a "
|
|
1577
|
+
"`design_justification` block to this criterion in acceptance.yaml: "
|
|
1578
|
+
"`checklist_passed` (which design-system conventions this screen actually "
|
|
1579
|
+
"followed), `jtbd_framing` (one sentence, 'when a user is X, this lets them "
|
|
1580
|
+
"Y' — not a restatement of the description), and `deviations_from_system` if "
|
|
1581
|
+
"this screen needed a new pattern the system didn't have yet. If a "
|
|
1582
|
+
"`webapp-testing` skill is available, use it to actually load the running "
|
|
1583
|
+
"page and verify it renders/behaves as intended before finishing — don't "
|
|
1584
|
+
"just trust that the code compiles."
|
|
1585
|
+
)
|
|
1586
|
+
prompt_parts.append("")
|
|
1587
|
+
|
|
1588
|
+
prompt_parts += [
|
|
1589
|
+
"## Module Specification",
|
|
1590
|
+
yaml.dump(spec, default_flow_style=False),
|
|
1591
|
+
"",
|
|
1592
|
+
_tdd_instruction(criterion),
|
|
1593
|
+
"Use editing tools to modify files and run tests to verify your implementation.",
|
|
1594
|
+
"Git rules: stay on the current branch — never create or switch branches. "
|
|
1595
|
+
"You may commit your work or leave it uncommitted; the build loop measures "
|
|
1596
|
+
"everything you changed since this criterion started either way. Never "
|
|
1597
|
+
"`git add` build artifacts (__pycache__, *.pyc, node_modules, dist, coverage "
|
|
1598
|
+
"files) — committed artifacts break the module merge step.",
|
|
1599
|
+
]
|
|
1600
|
+
return "\n".join(prompt_parts)
|
|
1601
|
+
|
|
1602
|
+
|
|
1603
|
+
def _build_escalation_prompt(pcp_dir: Path, module_name: str, criterion: dict, spec: dict,
|
|
1604
|
+
attempt_history: list[str]) -> str:
|
|
1605
|
+
"""Final-attempt (escalated-model) prompt: a FRESH session — never a
|
|
1606
|
+
--resume of the failed attempts. Contaminated retry context raises error
|
|
1607
|
+
rates ~7x (CCRM, arXiv:2605.08563); the escalated model gets a structured
|
|
1608
|
+
summary of the prior failures instead of their raw trajectory
|
|
1609
|
+
(summarize-don't-replay, arXiv:2604.16529). Costs a fresh repo
|
|
1610
|
+
exploration — deliberately: on the final attempt before human escalation,
|
|
1611
|
+
a clean read of the problem is worth more than the cached context."""
|
|
1612
|
+
base = _build_agent_prompt(pcp_dir, module_name, criterion, spec)
|
|
1613
|
+
history = "\n".join(f"- {h}" for h in attempt_history) or "- (no structured history captured)"
|
|
1614
|
+
return base + "\n".join([
|
|
1615
|
+
"",
|
|
1616
|
+
"## Prior attempts on this criterion FAILED — summary (you are a fresh session; "
|
|
1617
|
+
"do not repeat these approaches without addressing why they failed):",
|
|
1618
|
+
history,
|
|
1619
|
+
"",
|
|
1620
|
+
"Diagnose from the current state of the working tree (their partial work may still "
|
|
1621
|
+
"be present) and take a genuinely different approach where the summary suggests the "
|
|
1622
|
+
"previous one was structurally wrong.",
|
|
1623
|
+
])
|
|
1624
|
+
|
|
1625
|
+
|
|
1626
|
+
def _build_retry_prompt(constraint_feedback: str) -> str:
|
|
1627
|
+
"""Follow-up prompt for a --resume'd session. No re-pasted context — the agent
|
|
1628
|
+
already has it from the same session's earlier turn."""
|
|
1629
|
+
return "\n".join([
|
|
1630
|
+
"⚠️ Your previous attempt at this criterion was BLOCKED by quality/architecture gates:",
|
|
1631
|
+
"",
|
|
1632
|
+
constraint_feedback,
|
|
1633
|
+
"",
|
|
1634
|
+
"Fix these violations in your next edits. Make sure to adhere to all principles. "
|
|
1635
|
+
"This is the same session as your last attempt — don't re-read files you've already "
|
|
1636
|
+
"reviewed unless something changed.",
|
|
1637
|
+
])
|
|
1638
|
+
|
|
1639
|
+
|
|
1640
|
+
# Sentinel distinct from None: None means "tool not detected", NOTSET means
|
|
1641
|
+
# "not tool-based, always applicable" (layer1, architect-review, gate).
|
|
1642
|
+
_NOTSET = object()
|
|
1643
|
+
|
|
1644
|
+
|
|
1645
|
+
def _qa_record(
|
|
1646
|
+
pcp_dir: Path, ctx: dict, check: str, errors: list[str], meta: dict | None = None,
|
|
1647
|
+
*, control_id: str | None = None, files: list[str] | None = None,
|
|
1648
|
+
tool: str | None = _NOTSET, result: str | None = None, evidence_path: str | None = None,
|
|
1649
|
+
) -> None:
|
|
1650
|
+
"""Records one gate outcome. `result` resolution order: explicit override,
|
|
1651
|
+
then "skipped" if a tool-based check found no tool installed, then
|
|
1652
|
+
block/pass from `errors`. A skip must never collapse into "pass" — that's
|
|
1653
|
+
what makes an unenforced control invisible in the audit trail.
|
|
1654
|
+
|
|
1655
|
+
evidence_path: relative path (under pcp_dir) to the FULL, untruncated raw
|
|
1656
|
+
artifact for this check (test output, lint issue list, judge response) —
|
|
1657
|
+
see evidence.py. telemetry only ever stored a truncated error summary;
|
|
1658
|
+
this is the pointer to actual proof, written on every outcome including
|
|
1659
|
+
a pass, not just when something blocks."""
|
|
1660
|
+
if result is None:
|
|
1661
|
+
if tool is not _NOTSET and tool is None:
|
|
1662
|
+
result = "skipped"
|
|
1663
|
+
else:
|
|
1664
|
+
result = "block" if errors else "pass"
|
|
1665
|
+
elif result == "pass" and errors:
|
|
1666
|
+
# Same invariant as _wave_record (see there for the full reasoning):
|
|
1667
|
+
# an advisory check forces result="pass" to mean "found things, don't
|
|
1668
|
+
# block", and recording that literally made the audit trail claim a
|
|
1669
|
+
# clean pass. CTRL-032 (architect pre-flight) did exactly this, and
|
|
1670
|
+
# the test suite asserted the falsified value rather than catching it.
|
|
1671
|
+
result = "advisory"
|
|
1672
|
+
|
|
1673
|
+
# Evidence-integrity self-check, 2026-07-21: a block with no real
|
|
1674
|
+
# evidence behind it is itself an anomaly, not a normal outcome to
|
|
1675
|
+
# trust silently -- this is the exact shape of the SAST-phantom-block
|
|
1676
|
+
# incident (qa.py's semgrep wrapper conflated a tool failure with a
|
|
1677
|
+
# real finding; the "block" had nothing behind it, only caught because
|
|
1678
|
+
# a human happened to read an empty evidence file). This tripwire is
|
|
1679
|
+
# deliberately generic -- it doesn't know or care which check produced
|
|
1680
|
+
# the block, so it still fires the next time this bug SHAPE recurs in
|
|
1681
|
+
# a different tool, not just the one instance patched tonight. Multi-
|
|
1682
|
+
# hour unattended runs have nobody reading evidence files live, so this
|
|
1683
|
+
# has to be loud (console) and durable (escalations.yaml), not just
|
|
1684
|
+
# printed and forgotten.
|
|
1685
|
+
if result == "block" and evidence_path:
|
|
1686
|
+
try:
|
|
1687
|
+
evidence_empty = not (pcp_dir / evidence_path).read_text().strip()
|
|
1688
|
+
except Exception:
|
|
1689
|
+
evidence_empty = False # can't read it -- don't compound one failure into a false alarm
|
|
1690
|
+
if evidence_empty:
|
|
1691
|
+
console.print(
|
|
1692
|
+
f"[red bold]Evidence-integrity anomaly:[/red bold] check '{check}' blocked "
|
|
1693
|
+
f"({len(errors)} finding(s)) but its evidence file ({evidence_path}) is empty -- "
|
|
1694
|
+
"this block is likely not grounded in a real finding (tool-failure-misreported-"
|
|
1695
|
+
"as-finding, the 2026-07-21 SAST incident shape). Treat it with suspicion."
|
|
1696
|
+
)
|
|
1697
|
+
from pcp import escalations
|
|
1698
|
+
with _STATE_LOCK:
|
|
1699
|
+
escalations.record(
|
|
1700
|
+
pcp_dir, ctx["module"], ctx["criterion_id"], route="evidence-integrity-anomaly",
|
|
1701
|
+
findings=[
|
|
1702
|
+
f"Check '{check}' reported a block with an empty evidence file -- the block "
|
|
1703
|
+
"is likely ungrounded (a tool failure misreported as a real finding), not a "
|
|
1704
|
+
"genuine issue with the criterion's code. Verify before trusting this block.",
|
|
1705
|
+
],
|
|
1706
|
+
)
|
|
1707
|
+
|
|
1708
|
+
usage = (meta or {}).get("usage", {})
|
|
1709
|
+
with _STATE_LOCK:
|
|
1710
|
+
telemetry.record(
|
|
1711
|
+
pcp_dir,
|
|
1712
|
+
cycle="qa", cycle_number=ctx["attempt"], check=check, control_id=control_id,
|
|
1713
|
+
module=ctx["module"], submodule=ctx.get("submodule"), criterion_id=ctx["criterion_id"],
|
|
1714
|
+
run_id=ctx.get("run_id"),
|
|
1715
|
+
files=files or ctx.get("files") or [],
|
|
1716
|
+
result=result, errors=errors, error_count=len(errors), evidence_path=evidence_path,
|
|
1717
|
+
model=(meta or {}).get("model"), session_id=(meta or {}).get("session_id"),
|
|
1718
|
+
token_input=usage.get("input_tokens", 0) + usage.get("cache_creation_input_tokens", 0),
|
|
1719
|
+
token_output=usage.get("output_tokens", 0),
|
|
1720
|
+
token_cache_read=usage.get("cache_read_input_tokens", 0),
|
|
1721
|
+
cost_usd=(meta or {}).get("cost_usd"), duration_ms=(meta or {}).get("duration_ms"),
|
|
1722
|
+
)
|
|
1723
|
+
|
|
1724
|
+
|
|
1725
|
+
def _apply_rule_recovery(pcp_dir: Path, ctx: dict, rule: dict, violation_msg: str) -> None:
|
|
1726
|
+
"""ABC contract-shape reference pattern (arXiv:2602.22302, see
|
|
1727
|
+
docs/research-rigidity-vs-reliability-2026-07.md) -- Governance is
|
|
1728
|
+
already `severity` (hard_block/advisory), not duplicated here.
|
|
1729
|
+
`recovery` is the one contract field with real behavior in this
|
|
1730
|
+
version: 'escalate' immediately logs an escalation entry the moment
|
|
1731
|
+
this rule fires, instead of only escalating after a criterion
|
|
1732
|
+
exhausts all 3 build attempts. 'retry'/'quarantine'/'block' are
|
|
1733
|
+
declared in the schema for completeness but don't change behavior yet
|
|
1734
|
+
-- same honest-scope posture as every other partially-built check in
|
|
1735
|
+
this catalog (e.g. CTRL-016's build_fresh carve-out)."""
|
|
1736
|
+
if rule.get("contract", {}).get("recovery") != "escalate":
|
|
1737
|
+
return
|
|
1738
|
+
from pcp import escalations
|
|
1739
|
+
escalations.record(
|
|
1740
|
+
pcp_dir, ctx["module"], ctx["criterion_id"], route="human",
|
|
1741
|
+
findings=[f"Immediate escalation (contract.recovery=escalate) on rule [{rule.get('id')}]: {violation_msg}"],
|
|
1742
|
+
)
|
|
1743
|
+
|
|
1744
|
+
|
|
1745
|
+
def _run_layer1_check(pcp_dir: Path, project_root: Path, changed_files: list[str], ctx: dict) -> list[str]:
|
|
1746
|
+
"""Run AST check logic and return violations. Deterministic — no LLM/tokens."""
|
|
1747
|
+
ci_rules_path = pcp_dir / "ci_rules.yaml"
|
|
1748
|
+
violations: list[str] = []
|
|
1749
|
+
|
|
1750
|
+
if not ci_rules_path.exists():
|
|
1751
|
+
_qa_record(pcp_dir, ctx, "layer1", violations, control_id="CTRL-004", files=changed_files, tool=None)
|
|
1752
|
+
return violations
|
|
1753
|
+
|
|
1754
|
+
try:
|
|
1755
|
+
data = load_yaml(ci_rules_path)
|
|
1756
|
+
ast_rules = [r for r in data.get("rules", []) if r.get("check") == "ast_pattern"]
|
|
1757
|
+
file_rules = [r for r in data.get("rules", []) if r.get("check") == "file_exists"]
|
|
1758
|
+
protected_rules = [r for r in data.get("rules", []) if r.get("check") == "protected_path"]
|
|
1759
|
+
from pcp.commands.check import _run_ast_rule, run_file_exists_rule, run_protected_path_rule, get_module_names
|
|
1760
|
+
for r in ast_rules:
|
|
1761
|
+
if r.get("severity") == "hard_block":
|
|
1762
|
+
v = _run_ast_rule(r, changed_files, project_root)
|
|
1763
|
+
if v:
|
|
1764
|
+
msg = f"AST Rule [{r['id']}] {r['name']} violation: {', '.join(v)}"
|
|
1765
|
+
if r.get("message"):
|
|
1766
|
+
msg += f" → Fix: {r['message']}"
|
|
1767
|
+
violations.append(msg)
|
|
1768
|
+
_apply_rule_recovery(pcp_dir, ctx, r, msg)
|
|
1769
|
+
for r in protected_rules:
|
|
1770
|
+
if r.get("severity") == "hard_block":
|
|
1771
|
+
v = run_protected_path_rule(r, changed_files)
|
|
1772
|
+
if v:
|
|
1773
|
+
msg = f"Protected Path Rule [{r['id']}] {r['name']} violation: {', '.join(v)}"
|
|
1774
|
+
if r.get("message"):
|
|
1775
|
+
msg += f" → Fix: {r['message']}"
|
|
1776
|
+
violations.append(msg)
|
|
1777
|
+
_apply_rule_recovery(pcp_dir, ctx, r, msg)
|
|
1778
|
+
if file_rules:
|
|
1779
|
+
module_names = get_module_names(pcp_dir)
|
|
1780
|
+
for r in file_rules:
|
|
1781
|
+
if r.get("severity") == "hard_block":
|
|
1782
|
+
v = run_file_exists_rule(r, project_root, module_names)
|
|
1783
|
+
if v:
|
|
1784
|
+
msg = f"File Rule [{r['id']}] {r['name']} violation: {', '.join(v)}"
|
|
1785
|
+
if r.get("message"):
|
|
1786
|
+
msg += f" → Fix: {r['message']}"
|
|
1787
|
+
violations.append(msg)
|
|
1788
|
+
_apply_rule_recovery(pcp_dir, ctx, r, msg)
|
|
1789
|
+
except Exception:
|
|
1790
|
+
violations.append("Invalid ci_rules.yaml schema")
|
|
1791
|
+
|
|
1792
|
+
_qa_record(pcp_dir, ctx, "layer1", violations, control_id="CTRL-004", files=changed_files, tool="ci_rules.yaml")
|
|
1793
|
+
return violations
|
|
1794
|
+
|
|
1795
|
+
|
|
1796
|
+
def _run_test_suite_check(pcp_dir: Path, project_root: Path, ctx: dict) -> list[str]:
|
|
1797
|
+
"""Scoped to the blast radius of this attempt's changed files (impact.py):
|
|
1798
|
+
the changed module(s), every module that transitively depends on them, and
|
|
1799
|
+
the modularity drop-tests. The unscoped full suite is the wave-merge gate's
|
|
1800
|
+
job (_run_wave_merge_gate's own qa.run_test_suite call) -- it is not run
|
|
1801
|
+
here on every one of up to 3 attempts per criterion.
|
|
1802
|
+
|
|
1803
|
+
This was the documented design from the start but sat behind an opt-in flag
|
|
1804
|
+
that defaulted off, so the full suite ran every time regardless. Measured
|
|
1805
|
+
2026-07-27 on Project O: 1,098 tests / ~7m46s per attempt, versus 478
|
|
1806
|
+
scoped. PCP_QA_FULL_SUITE=1 restores the old behaviour."""
|
|
1807
|
+
result = qa.run_test_suite(project_root, pcp_dir=pcp_dir, changed_files=ctx.get("files"))
|
|
1808
|
+
if result.get("scoped_to"):
|
|
1809
|
+
detail = f"[dim]Test suite scoped to impacted modules: {', '.join(result['scoped_to'])}"
|
|
1810
|
+
if result.get("incremental"):
|
|
1811
|
+
detail += " (testmon: only tests whose dependencies changed)"
|
|
1812
|
+
# Name the binary. A gate run by the wrong pytest -- the global one,
|
|
1813
|
+
# because the project venv was not on PATH -- passes exactly like a
|
|
1814
|
+
# correct one. Saying which interpreter produced the result is the
|
|
1815
|
+
# cheapest defence against that whole class.
|
|
1816
|
+
if result.get("pytest_bin"):
|
|
1817
|
+
detail += f" via {result['pytest_bin']}"
|
|
1818
|
+
console.print(detail + "[/dim]")
|
|
1819
|
+
violations: list[str] = []
|
|
1820
|
+
evidence_path = None
|
|
1821
|
+
if result["tool"]:
|
|
1822
|
+
evidence_path = evidence.store(
|
|
1823
|
+
pcp_dir, ctx["module"], ctx["criterion_id"], ctx["attempt"], "test-suite", result["output"],
|
|
1824
|
+
)
|
|
1825
|
+
if result["tool"] and not result["passed"]:
|
|
1826
|
+
violations.append(
|
|
1827
|
+
f"Test suite ({result['tool']}) FAILED — full output: {evidence_path}\n{result['output'][-1500:]}"
|
|
1828
|
+
)
|
|
1829
|
+
_qa_record(pcp_dir, ctx, "test-suite", violations, control_id="CTRL-001", tool=result["tool"], evidence_path=evidence_path)
|
|
1830
|
+
return violations
|
|
1831
|
+
|
|
1832
|
+
|
|
1833
|
+
def _report_gate_skip_anomaly(budget: "_BuildBudget", check: str, tool: str, skipped: bool) -> None:
|
|
1834
|
+
"""Shared by lint/SAST -- see _BuildBudget.record_gate_skip_signal.
|
|
1835
|
+
Only called when the tool was actually detected (never for a genuinely
|
|
1836
|
+
absent tool, which is expected stable config, not an anomaly)."""
|
|
1837
|
+
if not tool:
|
|
1838
|
+
return
|
|
1839
|
+
if budget.record_gate_skip_signal(check, skipped):
|
|
1840
|
+
streak = budget.gate_skip_streaks[check]
|
|
1841
|
+
console.print(
|
|
1842
|
+
f"[red bold]Gate anomaly suspected:[/red bold] '{check}' ({tool}) has silently skipped "
|
|
1843
|
+
f"{streak} consecutive attempts instead of actually checking anything. This usually means "
|
|
1844
|
+
"the tool itself is broken or misconfigured, not that the code is clean -- verify it before "
|
|
1845
|
+
"trusting any further pass/skip result from this gate this run."
|
|
1846
|
+
)
|
|
1847
|
+
|
|
1848
|
+
|
|
1849
|
+
def _run_lint_check(pcp_dir: Path, project_root: Path, changed_files: list[str], ctx: dict, budget: "_BuildBudget") -> list[str]:
|
|
1850
|
+
"""Lint on changed files only. Skips (never blocks) if no linter detected."""
|
|
1851
|
+
result = qa.run_lint(project_root, changed_files)
|
|
1852
|
+
if result.get("skipped"):
|
|
1853
|
+
console.print(f"[yellow]Lint tool issue (not a finding, not blocking):[/yellow] {result['skipped']}")
|
|
1854
|
+
_report_gate_skip_anomaly(budget, "lint", result["tool"], bool(result.get("skipped")))
|
|
1855
|
+
violations: list[str] = []
|
|
1856
|
+
evidence_path = None
|
|
1857
|
+
if result["tool"]:
|
|
1858
|
+
evidence_path = evidence.store(
|
|
1859
|
+
pcp_dir, ctx["module"], ctx["criterion_id"], ctx["attempt"], "lint", "\n".join(result["issues"]),
|
|
1860
|
+
)
|
|
1861
|
+
if result["tool"] and not result["passed"]:
|
|
1862
|
+
issues = "\n".join(result["issues"][:10])
|
|
1863
|
+
violations.append(f"Lint ({result['tool']}) found issues — full list: {evidence_path}\n{issues}")
|
|
1864
|
+
_qa_record(pcp_dir, ctx, "lint", violations, control_id="CTRL-002", files=changed_files, tool=result["tool"], evidence_path=evidence_path)
|
|
1865
|
+
return violations
|
|
1866
|
+
|
|
1867
|
+
|
|
1868
|
+
def _run_sast_check(pcp_dir: Path, project_root: Path, changed_files: list[str], ctx: dict, budget: "_BuildBudget") -> list[str]:
|
|
1869
|
+
"""SAST + secret-scan via semgrep, if installed. Scoped to changed files."""
|
|
1870
|
+
result = qa.run_sast(project_root, changed_files)
|
|
1871
|
+
if result.get("skipped"):
|
|
1872
|
+
console.print(f"[yellow]SAST tool issue (not a finding, not blocking):[/yellow] {result['skipped']}")
|
|
1873
|
+
_report_gate_skip_anomaly(budget, "sast", result["tool"], bool(result.get("skipped")))
|
|
1874
|
+
violations: list[str] = []
|
|
1875
|
+
evidence_path = None
|
|
1876
|
+
if result["tool"]:
|
|
1877
|
+
evidence_path = evidence.store(
|
|
1878
|
+
pcp_dir, ctx["module"], ctx["criterion_id"], ctx["attempt"], "sast", "\n".join(result["findings"]),
|
|
1879
|
+
)
|
|
1880
|
+
if result["tool"] and not result["passed"]:
|
|
1881
|
+
findings = "\n".join(result["findings"][:10])
|
|
1882
|
+
violations.append(f"SAST ({result['tool']}) found issues — full list: {evidence_path}\n{findings}")
|
|
1883
|
+
_qa_record(pcp_dir, ctx, "sast", violations, control_id="CTRL-003", files=changed_files, tool=result["tool"], evidence_path=evidence_path)
|
|
1884
|
+
return violations
|
|
1885
|
+
|
|
1886
|
+
|
|
1887
|
+
VERIFY_SYSTEM_PROMPT = (
|
|
1888
|
+
"You are an adversarial verifier for AI-generated code-review findings. "
|
|
1889
|
+
"You are given a diff and a numbered list of findings another reviewer flagged as "
|
|
1890
|
+
"blocking issues. For each finding, decide whether it is actually grounded in the "
|
|
1891
|
+
"diff shown -- concrete, specific, and checkable against the code present, not vague "
|
|
1892
|
+
"or referring to code/behavior that isn't actually there. Do not look for NEW issues "
|
|
1893
|
+
"of your own -- only judge whether each GIVEN finding holds up. Default to "
|
|
1894
|
+
"refuted=true whenever you cannot confirm a finding directly against the diff shown."
|
|
1895
|
+
)
|
|
1896
|
+
|
|
1897
|
+
|
|
1898
|
+
def _verify_block_findings(
|
|
1899
|
+
pcp_dir: Path, diff: str, findings: list[str], ctx: dict, check: str, control_id: str,
|
|
1900
|
+
) -> tuple[list[str], list[str]]:
|
|
1901
|
+
"""Adversarial second pass over a gate/architect-review call's own BLOCK
|
|
1902
|
+
findings before they're trusted enough to fail a criterion -- reference-
|
|
1903
|
+
pattern borrowed from CodeRabbit's judge-model verification layer
|
|
1904
|
+
(scores each finding against gathered context, drops what it can't
|
|
1905
|
+
ground, before it ever reaches a human). Batched as ONE extra call per
|
|
1906
|
+
check, not one per finding, to stay inside Token Discipline.
|
|
1907
|
+
|
|
1908
|
+
Fails OPEN on any verifier error (timeout, bad JSON, call failure):
|
|
1909
|
+
keeps every original finding unchanged rather than risk silently
|
|
1910
|
+
swallowing a real block because the verifier itself broke. The
|
|
1911
|
+
asymmetry is deliberate -- a hallucinated BLOCK that slips through
|
|
1912
|
+
costs one wasted retry attempt; a real BLOCK silently dropped ships an
|
|
1913
|
+
actual defect, a strictly worse outcome.
|
|
1914
|
+
|
|
1915
|
+
Returns (kept, dropped_with_reason).
|
|
1916
|
+
"""
|
|
1917
|
+
if not findings:
|
|
1918
|
+
return [], []
|
|
1919
|
+
|
|
1920
|
+
# Deterministic pre-check (CodeRabbit pattern, validated by the grounded-
|
|
1921
|
+
# code-review production system arXiv:2510.10290): a finding that cites a
|
|
1922
|
+
# file path appearing nowhere in the diff is dropped at zero LLM cost
|
|
1923
|
+
# before the verifier call. Only fires on findings that DO cite a path —
|
|
1924
|
+
# conceptual findings with no file reference pass straight through to the
|
|
1925
|
+
# LLM verifier, which judges substance.
|
|
1926
|
+
# A file counts as "in scope" if its basename appears in the diff text OR
|
|
1927
|
+
# in the criterion's changed-files list — the diff is capped at 14k chars,
|
|
1928
|
+
# so text absence alone is not proof of fabrication.
|
|
1929
|
+
known_basenames = {Path(p).name for p in (ctx.get("files") or [])}
|
|
1930
|
+
pre_kept, pre_dropped = [], []
|
|
1931
|
+
for f in findings:
|
|
1932
|
+
cited = re.findall(r"[\w./-]+\.(?:py|js|ts|tsx|jsx|go|rs|java|rb|php|c|cpp|h|html|css|yaml|yml|json|toml|md)\b", f)
|
|
1933
|
+
# Paths PCP itself wrote into the finding text (evidence pointers) don't count as citations.
|
|
1934
|
+
cited = [p for p in cited if not p.replace("\\", "/").lstrip("./").startswith((".pcp/", "evidence/"))]
|
|
1935
|
+
if cited and not any(p.split("/")[-1] in diff or p.split("/")[-1] in known_basenames for p in cited):
|
|
1936
|
+
pre_dropped.append(f"{f} [dropped by deterministic pre-check: cites {cited[0]} which appears nowhere in the diff or changed files]")
|
|
1937
|
+
else:
|
|
1938
|
+
pre_kept.append(f)
|
|
1939
|
+
findings = pre_kept
|
|
1940
|
+
if not findings:
|
|
1941
|
+
if pre_dropped:
|
|
1942
|
+
console.print(f"[dim]{check} pre-check dropped {len(pre_dropped)} finding(s) citing files absent from the diff.[/dim]")
|
|
1943
|
+
return [], pre_dropped
|
|
1944
|
+
|
|
1945
|
+
numbered = "\n".join(f"[{i}] {f}" for i, f in enumerate(findings))
|
|
1946
|
+
prompt = (
|
|
1947
|
+
f"## Diff\n{diff[:14000]}\n\n"
|
|
1948
|
+
f"## Findings to verify\n{numbered}\n\n"
|
|
1949
|
+
'## Respond with JSON only\n'
|
|
1950
|
+
'{"verdicts": [{"index": 0, "refuted": false, "reason": "..."}, ...]} '
|
|
1951
|
+
"-- exactly one entry per finding above, in order."
|
|
1952
|
+
)
|
|
1953
|
+
# Judge decorrelation (2026-07-17, from the academic sweep): the original
|
|
1954
|
+
# finding comes from JUDGE_MODEL (Haiku); a same-model verifier adds
|
|
1955
|
+
# almost no independent signal (correlated-judges result, arXiv:2605.29800
|
|
1956
|
+
# "nine judges ≈ two effective votes"; same-family preference leakage,
|
|
1957
|
+
# arXiv:2502.01534). Default verifier is therefore a DIFFERENT model
|
|
1958
|
+
# (BUILD_MODEL/Sonnet — acceptable cost since this only runs on BLOCK
|
|
1959
|
+
# findings, which are rare). PCP_VERIFIER_MODEL overrides for teams that
|
|
1960
|
+
# can route cross-vendor — honestly noted: Sonnet-verifying-Haiku is
|
|
1961
|
+
# cross-model but still same-vendor, weaker decorrelation than the
|
|
1962
|
+
# literature's ideal; the deterministic pre-check above is the fully
|
|
1963
|
+
# decorrelated layer.
|
|
1964
|
+
verifier_model = os.environ.get("PCP_VERIFIER_MODEL") or llm.BUILD_MODEL
|
|
1965
|
+
try:
|
|
1966
|
+
res, meta = llm.call_json(
|
|
1967
|
+
VERIFY_SYSTEM_PROMPT, prompt, model=verifier_model, pcp_dir=pcp_dir,
|
|
1968
|
+
command=f"build-{check}-verify", return_meta=True,
|
|
1969
|
+
)
|
|
1970
|
+
except Exception as e:
|
|
1971
|
+
console.print(f"[yellow]Warning: {check} verification call failed, keeping all findings unverified: {e}[/yellow]")
|
|
1972
|
+
_qa_record(pcp_dir, ctx, f"{check}-verify", [f"call failed: {e}"], control_id=control_id, result="error")
|
|
1973
|
+
return findings, pre_dropped
|
|
1974
|
+
|
|
1975
|
+
verdicts = {v.get("index"): v for v in res.get("verdicts", []) if isinstance(v, dict)}
|
|
1976
|
+
|
|
1977
|
+
# Opt-in two-verifier ensemble (FUSE, arXiv:2604.18547; disagreement-as-
|
|
1978
|
+
# signal rather than majority-silencing). Second verifier gets an
|
|
1979
|
+
# INVERTED framing (confirm, don't refute) — prompt-level decorrelation.
|
|
1980
|
+
# A finding is dropped only when BOTH agree it's ungrounded; disagreement
|
|
1981
|
+
# keeps the finding, tagged, so the retry agent (and telemetry) see that
|
|
1982
|
+
# verification was contested. PCP_VERIFIER_ENSEMBLE=1 to enable — one
|
|
1983
|
+
# extra call per check, only on BLOCK findings.
|
|
1984
|
+
confirm_system = (
|
|
1985
|
+
"You are a supportive verifier for code-review findings: for each GIVEN finding, "
|
|
1986
|
+
"try to CONFIRM it against the diff. Mark refuted=true only if you find clear "
|
|
1987
|
+
"evidence the finding is wrong or refers to code not present."
|
|
1988
|
+
)
|
|
1989
|
+
secondary_verdicts: list[tuple[str, dict]] = []
|
|
1990
|
+
|
|
1991
|
+
if os.environ.get("PCP_VERIFIER_ENSEMBLE") == "1":
|
|
1992
|
+
try:
|
|
1993
|
+
res2, _ = llm.call_json(
|
|
1994
|
+
confirm_system, prompt, model=verifier_model, pcp_dir=pcp_dir,
|
|
1995
|
+
command=f"build-{check}-verify2", return_meta=True,
|
|
1996
|
+
)
|
|
1997
|
+
secondary_verdicts.append(
|
|
1998
|
+
("same-vendor-ensemble", {v.get("index"): v for v in res2.get("verdicts", []) if isinstance(v, dict)})
|
|
1999
|
+
)
|
|
2000
|
+
except Exception:
|
|
2001
|
+
pass
|
|
2002
|
+
|
|
2003
|
+
# Cross-vendor leg (Loop 3, resumed 2026-07-31 -- see
|
|
2004
|
+
# call_json_agy's docstring for the deferral history). Deliberately
|
|
2005
|
+
# scoped to CTRL-005/CTRL-006 only, matching the original 2026-07-22
|
|
2006
|
+
# proposal's Token Discipline boundary -- these are the rare,
|
|
2007
|
+
# high-blast-radius BLOCK findings, not every gate call. Same INVERTED
|
|
2008
|
+
# framing as the same-vendor ensemble leg, decorrelated by vendor this
|
|
2009
|
+
# time instead of just prompt. A failed/unavailable agy call is treated
|
|
2010
|
+
# exactly like a failed same-vendor ensemble call below -- it does not
|
|
2011
|
+
# protect a finding by itself, it just isn't counted as a vote either
|
|
2012
|
+
# way (see the merge loop's comment).
|
|
2013
|
+
if os.environ.get("PCP_VERIFIER_CROSS_VENDOR") == "1" and control_id in ("CTRL-005", "CTRL-006"):
|
|
2014
|
+
try:
|
|
2015
|
+
res3 = llm.call_json_agy(confirm_system, prompt, pcp_dir=pcp_dir, command=f"build-{check}-verify-crossvendor")
|
|
2016
|
+
secondary_verdicts.append(
|
|
2017
|
+
("cross-vendor-agy", {v.get("index"): v for v in res3.get("verdicts", []) if isinstance(v, dict)})
|
|
2018
|
+
)
|
|
2019
|
+
except Exception as e:
|
|
2020
|
+
# agy not installed / quota / network -- degrade, don't just
|
|
2021
|
+
# drop the leg outright. ESCALATION_MODEL (Opus) is still
|
|
2022
|
+
# same-vendor, so this is honestly a WEAKER decorrelation than
|
|
2023
|
+
# real cross-vendor -- tagged "fallback", never mislabeled as
|
|
2024
|
+
# "cross-vendor-agy", so a human reading a contested finding
|
|
2025
|
+
# can tell which kind of second opinion it actually got.
|
|
2026
|
+
console.print(f"[dim]{check} cross-vendor verifier (agy) unavailable ({e}) — falling back to {llm.ESCALATION_MODEL}.[/dim]")
|
|
2027
|
+
try:
|
|
2028
|
+
res3b, _ = llm.call_json(
|
|
2029
|
+
confirm_system, prompt, model=llm.ESCALATION_MODEL, pcp_dir=pcp_dir,
|
|
2030
|
+
command=f"build-{check}-verify-crossvendor-fallback", return_meta=True,
|
|
2031
|
+
)
|
|
2032
|
+
secondary_verdicts.append(
|
|
2033
|
+
("same-vendor-fallback", {v.get("index"): v for v in res3b.get("verdicts", []) if isinstance(v, dict)})
|
|
2034
|
+
)
|
|
2035
|
+
except Exception as e2:
|
|
2036
|
+
console.print(f"[dim]{check} fallback verifier also unavailable ({e2}) — skipping the cross-vendor leg entirely.[/dim]")
|
|
2037
|
+
|
|
2038
|
+
# Merge: a finding is dropped only when the primary verifier refutes it
|
|
2039
|
+
# AND every secondary verifier that actually returned a verdict for it
|
|
2040
|
+
# also refutes it. Any secondary verifier disagreeing (or simply not
|
|
2041
|
+
# having run) keeps the finding, tagged with which verifier(s)
|
|
2042
|
+
# disagreed -- a second (or third) opinion can only ADD signal here,
|
|
2043
|
+
# never silently override the primary on its own. With zero secondary
|
|
2044
|
+
# verifiers enabled this reduces to exactly the original single-
|
|
2045
|
+
# verifier behavior.
|
|
2046
|
+
kept: list[str] = []
|
|
2047
|
+
dropped: list[str] = list(pre_dropped)
|
|
2048
|
+
for i, f in enumerate(findings):
|
|
2049
|
+
v = verdicts.get(i)
|
|
2050
|
+
refuted1 = bool(v and v.get("refuted"))
|
|
2051
|
+
if not refuted1:
|
|
2052
|
+
kept.append(f)
|
|
2053
|
+
continue
|
|
2054
|
+
if not secondary_verdicts:
|
|
2055
|
+
dropped.append(f"{f} [dropped by verifier: {(v or {}).get('reason', '(no reason given)')}]")
|
|
2056
|
+
continue
|
|
2057
|
+
disagreeing = [label for label, vd in secondary_verdicts if (vd.get(i) is not None and not vd[i].get("refuted"))]
|
|
2058
|
+
if disagreeing:
|
|
2059
|
+
kept.append(f"{f} [verifier disagreement ({', '.join(disagreeing)}) — kept, contested]")
|
|
2060
|
+
else:
|
|
2061
|
+
dropped.append(f"{f} [dropped by verifier: {(v or {}).get('reason', '(no reason given)')}]")
|
|
2062
|
+
|
|
2063
|
+
evidence_path = evidence.store(
|
|
2064
|
+
pcp_dir, ctx["module"], ctx["criterion_id"], ctx["attempt"], f"{check}-verify",
|
|
2065
|
+
json.dumps(res, indent=2),
|
|
2066
|
+
)
|
|
2067
|
+
_qa_record(
|
|
2068
|
+
pcp_dir, ctx, f"{check}-verify", [], meta, control_id=control_id,
|
|
2069
|
+
evidence_path=evidence_path, result="pass",
|
|
2070
|
+
)
|
|
2071
|
+
if dropped:
|
|
2072
|
+
console.print(f"[dim]{check} verifier dropped {len(dropped)} ungrounded finding(s) before they could block:[/dim]")
|
|
2073
|
+
for d in dropped:
|
|
2074
|
+
console.print(f" [dim]· {d}[/dim]")
|
|
2075
|
+
return kept, dropped
|
|
2076
|
+
|
|
2077
|
+
|
|
2078
|
+
def _dismissal_context(pcp_dir: Path, module: str) -> str:
|
|
2079
|
+
"""Learnings from past human overrides (Greptile feedback-loop reference
|
|
2080
|
+
pattern: storing dismissals and reusing them took comments-addressed from
|
|
2081
|
+
19%→55%+). PCP's dismissal signal = attributed [pcp-bypass] entries for
|
|
2082
|
+
this module — a human explicitly overrode a gate there. Surfaced as
|
|
2083
|
+
context to the next judge call so equivalent findings aren't re-raised
|
|
2084
|
+
without new evidence. Deterministic read, no LLM."""
|
|
2085
|
+
try:
|
|
2086
|
+
import yaml as _yaml
|
|
2087
|
+
path = pcp_dir / "bypass_log.yaml"
|
|
2088
|
+
if not path.exists():
|
|
2089
|
+
return ""
|
|
2090
|
+
data = _yaml.safe_load(path.read_text()) or {}
|
|
2091
|
+
entries = [e for e in data.get("bypasses", [])
|
|
2092
|
+
if module in (e.get("modules") or [])][-5:]
|
|
2093
|
+
if not entries:
|
|
2094
|
+
return ""
|
|
2095
|
+
lines = [f"- {e.get('timestamp', '')}: {e.get('reason', '')}" for e in entries]
|
|
2096
|
+
return (
|
|
2097
|
+
"\n\nPRIOR HUMAN OVERRIDES in this module (gate findings a human explicitly "
|
|
2098
|
+
"bypassed — do not re-raise equivalent findings without new evidence):\n"
|
|
2099
|
+
+ "\n".join(lines) + "\n"
|
|
2100
|
+
)
|
|
2101
|
+
except Exception:
|
|
2102
|
+
return ""
|
|
2103
|
+
|
|
2104
|
+
|
|
2105
|
+
def _criterion_scope_framing(ctx: dict) -> str:
|
|
2106
|
+
"""Prepended to build-loop judge prompts (gate + architect-review) so a
|
|
2107
|
+
single criterion's diff is judged as an increment, not the finished
|
|
2108
|
+
product. Found dogfooding 2026-07-17: without this, the alignment gate
|
|
2109
|
+
scored criterion 1 of 13 against the ENTIRE target state and blocked all
|
|
2110
|
+
3 attempts with 'regressions' like 'no CLI entry point' — functionality
|
|
2111
|
+
that simply belonged to later criteria. Incompleteness is not drift.
|
|
2112
|
+
The standalone `pcp gate` command (a real whole-PR review) deliberately
|
|
2113
|
+
keeps its original framing — this applies only inside the build loop."""
|
|
2114
|
+
return (
|
|
2115
|
+
"IMPORTANT CONTEXT: this diff implements exactly ONE acceptance criterion of an "
|
|
2116
|
+
f"in-progress multi-criterion build — [{ctx['criterion_id']}] "
|
|
2117
|
+
f"{ctx.get('criterion_description', '')} (module '{ctx['module']}'). "
|
|
2118
|
+
"Most other criteria and modules are intentionally NOT built yet. Judge only "
|
|
2119
|
+
"whether THIS increment moves correctly: flag genuine contradictions of the "
|
|
2120
|
+
"objective/target state, rule violations, or code that moves away from them. "
|
|
2121
|
+
"Functionality that is merely missing because it belongs to another criterion or "
|
|
2122
|
+
"module is NOT a regression — do not list it and do not lower the score for it.\n\n"
|
|
2123
|
+
)
|
|
2124
|
+
|
|
2125
|
+
|
|
2126
|
+
def _gate_infrastructure_failure(check: str, exc: Exception) -> list[str]:
|
|
2127
|
+
"""A gate that COULD NOT RUN is not a gate that PASSED.
|
|
2128
|
+
|
|
2129
|
+
Both LLM gates used to `return []` when `llm.call_json` raised -- a rate
|
|
2130
|
+
limit, a timeout, an unauthenticated CLI. An empty finding list means "no
|
|
2131
|
+
problems found", so a criterion whose review never actually happened was
|
|
2132
|
+
marked complete, committed and merged. In an unattended run nobody reads
|
|
2133
|
+
the console warning that was the only signal.
|
|
2134
|
+
|
|
2135
|
+
This is the exact bug class already fixed twice in `qa.py` (the semgrep
|
|
2136
|
+
phantom block, the QA timeout masking): conflating "the tool could not
|
|
2137
|
+
run" with "the tool found nothing". Those fixes were made file-locally
|
|
2138
|
+
instead of as a rule, which is why the same shape survived here.
|
|
2139
|
+
|
|
2140
|
+
Returning a blocking finding is the honest answer, and it composes
|
|
2141
|
+
correctly with the retry loop: a transient failure clears on attempt 2 or
|
|
2142
|
+
3, a persistent one exhausts the attempts and escalates -- which is right,
|
|
2143
|
+
because PCP genuinely could not verify the work and must not claim it did.
|
|
2144
|
+
|
|
2145
|
+
PCP_ALLOW_UNVERIFIED_GATES=1 restores the old advisory behavior for anyone
|
|
2146
|
+
deliberately running without LLM budget. Opt-in and loud, never default --
|
|
2147
|
+
it means completed criteria carry no LLM review at all."""
|
|
2148
|
+
if os.environ.get("PCP_ALLOW_UNVERIFIED_GATES") == "1":
|
|
2149
|
+
console.print(
|
|
2150
|
+
f"[yellow]{check}: gate could not run, and PCP_ALLOW_UNVERIFIED_GATES=1 "
|
|
2151
|
+
f"is set — treating as advisory. This criterion carries NO {check} review.[/yellow]"
|
|
2152
|
+
)
|
|
2153
|
+
return []
|
|
2154
|
+
# Deliberately does NOT lead with the escape hatch. Reported from
|
|
2155
|
+
# Project O 2026-07-27: a transient malformed-JSON response cost
|
|
2156
|
+
# three attempts on one criterion, and the remedy this message offered was
|
|
2157
|
+
# "turn the gate off" -- for the same review that had caught a real
|
|
2158
|
+
# path-traversal vulnerability an hour earlier. Offering "skip the check"
|
|
2159
|
+
# as the cure for a flaky check points at the wrong lever, so the mechanical
|
|
2160
|
+
# causes come first and the opt-out is named last, with what it costs.
|
|
2161
|
+
return [
|
|
2162
|
+
f"{check}: gate could not be evaluated ({exc}). This is an infrastructure "
|
|
2163
|
+
f"failure, not a code finding — the review never ran, so the criterion "
|
|
2164
|
+
f"cannot be marked verified.\n"
|
|
2165
|
+
f" Most likely: a transient LLM/CLI failure — re-run, malformed JSON is "
|
|
2166
|
+
f"already retried {os.environ.get('PCP_LLM_JSON_RETRIES', '2')}x internally.\n"
|
|
2167
|
+
f" Then check: is `claude` authenticated, is a rate limit active, is "
|
|
2168
|
+
f"PCP_LLM_TIMEOUT (default 300s) too low for this diff?\n"
|
|
2169
|
+
f" Last resort: PCP_ALLOW_UNVERIFIED_GATES=1 completes criteria with NO "
|
|
2170
|
+
f"{check} review at all — this gate catches real defects, so disabling it "
|
|
2171
|
+
f"to get past a flaky run trades a correctness check for a convenience."
|
|
2172
|
+
]
|
|
2173
|
+
|
|
2174
|
+
|
|
2175
|
+
def _run_architect_review(pcp_dir: Path, diff: str, changed_files: list[str], ctx: dict) -> list[str]:
|
|
2176
|
+
"""Run architect review and return BLOCK findings that survive adversarial verification."""
|
|
2177
|
+
from pcp.commands.architect_review import SYSTEM_PROMPT, _build_prompt, _load_persona, _load_kb
|
|
2178
|
+
persona = _load_persona(pcp_dir)
|
|
2179
|
+
architecture = (pcp_dir / "architecture.md").read_text() if (pcp_dir / "architecture.md").exists() else ""
|
|
2180
|
+
kb = _load_kb(pcp_dir, changed_files)
|
|
2181
|
+
|
|
2182
|
+
prompt = _criterion_scope_framing(ctx) + _build_prompt(persona, architecture, kb, diff, "diff") + _dismissal_context(pcp_dir, ctx["module"])
|
|
2183
|
+
try:
|
|
2184
|
+
res, meta = llm.call_json(
|
|
2185
|
+
SYSTEM_PROMPT, prompt, model=llm.JUDGE_MODEL, pcp_dir=pcp_dir,
|
|
2186
|
+
command="build-architect-review", return_meta=True,
|
|
2187
|
+
)
|
|
2188
|
+
except Exception as e:
|
|
2189
|
+
console.print(f"[red]Architect review call failed: {e}[/red]")
|
|
2190
|
+
_qa_record(
|
|
2191
|
+
pcp_dir, ctx, "architect-review", [f"call failed: {e}"],
|
|
2192
|
+
control_id="CTRL-005", files=changed_files, result="error",
|
|
2193
|
+
)
|
|
2194
|
+
return _gate_infrastructure_failure("architect-review", e)
|
|
2195
|
+
|
|
2196
|
+
blocks = []
|
|
2197
|
+
for f in res.get("findings", []):
|
|
2198
|
+
if f.get("severity") == "BLOCK":
|
|
2199
|
+
blocks.append(f"{f.get('location', 'general')}: {f.get('finding', '')} (Principle: {f.get('principle', '')}) → Fix: {f.get('fix', '')}")
|
|
2200
|
+
evidence_path = evidence.store(
|
|
2201
|
+
pcp_dir, ctx["module"], ctx["criterion_id"], ctx["attempt"], "architect-review", json.dumps(res, indent=2),
|
|
2202
|
+
)
|
|
2203
|
+
kept, _dropped = _verify_block_findings(pcp_dir, diff, blocks, {**ctx, "files": changed_files}, "architect-review", "CTRL-005")
|
|
2204
|
+
_qa_record(pcp_dir, ctx, "architect-review", kept, meta, control_id="CTRL-005", files=changed_files, evidence_path=evidence_path)
|
|
2205
|
+
return kept
|
|
2206
|
+
|
|
2207
|
+
|
|
2208
|
+
def _run_gate_check(pcp_dir: Path, diff: str, ctx: dict) -> list[str]:
|
|
2209
|
+
"""Run gate review and return block issues that survive adversarial verification."""
|
|
2210
|
+
from pcp.commands.gate import SYSTEM_PROMPT, _build_prompt, _load_llm_rules
|
|
2211
|
+
objective = (pcp_dir / "objective.md").read_text() if (pcp_dir / "objective.md").exists() else ""
|
|
2212
|
+
target_state = (pcp_dir / "target_state.md").read_text() if (pcp_dir / "target_state.md").exists() else ""
|
|
2213
|
+
current_state = (pcp_dir / "current_state.md").read_text() if (pcp_dir / "current_state.md").exists() else ""
|
|
2214
|
+
llm_rules = _load_llm_rules(pcp_dir)
|
|
2215
|
+
|
|
2216
|
+
prompt = _criterion_scope_framing(ctx) + _build_prompt(objective, target_state, current_state, diff, llm_rules) + _dismissal_context(pcp_dir, ctx["module"])
|
|
2217
|
+
try:
|
|
2218
|
+
res, meta = llm.call_json(
|
|
2219
|
+
SYSTEM_PROMPT, prompt, model=llm.JUDGE_MODEL, pcp_dir=pcp_dir,
|
|
2220
|
+
command="build-gate-check", return_meta=True,
|
|
2221
|
+
)
|
|
2222
|
+
except Exception as e:
|
|
2223
|
+
console.print(f"[red]Gate check call failed: {e}[/red]")
|
|
2224
|
+
_qa_record(pcp_dir, ctx, "gate", [f"call failed: {e}"], control_id="CTRL-006", result="error")
|
|
2225
|
+
return _gate_infrastructure_failure("gate", e)
|
|
2226
|
+
|
|
2227
|
+
rec = res.get("recommendation", "merge")
|
|
2228
|
+
score = res.get("alignment_score", 1.0)
|
|
2229
|
+
issues = []
|
|
2230
|
+
if rec == "block" or score < 0.4:
|
|
2231
|
+
issues.append(f"PR alignment recommendation is BLOCK (Score: {score:.0%}). Summary: {res.get('summary', '')}")
|
|
2232
|
+
for r in res.get("regressions", []):
|
|
2233
|
+
issues.append(f"Regression: {r}")
|
|
2234
|
+
for v in res.get("llm_rule_violations", []):
|
|
2235
|
+
issues.append(f"Violation: {v}")
|
|
2236
|
+
evidence_path = evidence.store(
|
|
2237
|
+
pcp_dir, ctx["module"], ctx["criterion_id"], ctx["attempt"], "gate", json.dumps(res, indent=2),
|
|
2238
|
+
)
|
|
2239
|
+
kept, _dropped = _verify_block_findings(pcp_dir, diff, issues, ctx, "gate", "CTRL-006")
|
|
2240
|
+
_qa_record(pcp_dir, ctx, "gate", kept, meta, control_id="CTRL-006", evidence_path=evidence_path)
|
|
2241
|
+
return kept
|
|
2242
|
+
|
|
2243
|
+
|
|
2244
|
+
def _prior_ui_screens_checked(pcp_dir: Path, ctx: dict) -> int:
|
|
2245
|
+
"""Count distinct (module, criterion_id) pairs that already went through
|
|
2246
|
+
the design-consistency check, excluding this criterion's own -- the
|
|
2247
|
+
"how many screens has this project already built" signal progressive
|
|
2248
|
+
tightening needs. Deterministic, reads telemetry.jsonl only, no LLM."""
|
|
2249
|
+
seen = set()
|
|
2250
|
+
for rec in telemetry.load(pcp_dir):
|
|
2251
|
+
if rec.get("check") != "design-consistency":
|
|
2252
|
+
continue
|
|
2253
|
+
key = (rec.get("module"), rec.get("criterion_id"))
|
|
2254
|
+
if key == (ctx["module"], ctx["criterion_id"]):
|
|
2255
|
+
continue
|
|
2256
|
+
seen.add(key)
|
|
2257
|
+
return len(seen)
|
|
2258
|
+
|
|
2259
|
+
|
|
2260
|
+
def _design_establishing_window() -> int:
|
|
2261
|
+
"""First N UI-facing criteria are establishing the design system --
|
|
2262
|
+
findings there are exploration, not drift. Configurable since what
|
|
2263
|
+
counts as "established" genuinely varies by project size."""
|
|
2264
|
+
return int(os.environ.get("PCP_DESIGN_ESTABLISHING_SCREENS", "2"))
|
|
2265
|
+
|
|
2266
|
+
|
|
2267
|
+
def _run_design_consistency_check(pcp_dir: Path, project_root: Path, criterion: dict, ctx: dict) -> None:
|
|
2268
|
+
"""PCP Design lifecycle, stage 4 (Verify). Advisory only — never returned
|
|
2269
|
+
into block_findings, never blocks a criterion. Only fires for UI-facing
|
|
2270
|
+
criteria once .pcp/design_system.md has real established color tokens
|
|
2271
|
+
(not the empty scaffold): flags hardcoded hex color literals in the
|
|
2272
|
+
criterion's target file as a heuristic signal the screen may not be
|
|
2273
|
+
using the project's own design system. Not proof either way — a
|
|
2274
|
+
legitimate reason to hardcode a specific value (a brand-mandated exact
|
|
2275
|
+
color) is common; this surfaces a signal for human review, same posture
|
|
2276
|
+
as pcp audit's dead-code findings.
|
|
2277
|
+
|
|
2278
|
+
Progressive tightening (2026-07-20, research backlog item 5, "first
|
|
2279
|
+
screen establishes, later screens conform harder"): once a project has
|
|
2280
|
+
already built PCP_DESIGN_ESTABLISHING_SCREENS UI screens against an
|
|
2281
|
+
established system, the SAME findings read as drift from a known
|
|
2282
|
+
pattern, not exploration -- reworded accordingly. Deliberately stays
|
|
2283
|
+
advisory-only regardless of screen count (never joins block_findings) --
|
|
2284
|
+
escalating an unmeasured advisory check straight to a hard gate is
|
|
2285
|
+
exactly the shortcut this codebase's own warn-first rollout doctrine
|
|
2286
|
+
exists to avoid; false-positive rate isn't measured yet at either tier."""
|
|
2287
|
+
if not _is_ui_facing_criterion(criterion):
|
|
2288
|
+
return
|
|
2289
|
+
|
|
2290
|
+
design_system_path = pcp_dir / "design_system.md"
|
|
2291
|
+
if not design_system_path.exists() or "(not yet established)" in design_system_path.read_text():
|
|
2292
|
+
_qa_record(pcp_dir, ctx, "design-consistency", [], control_id="CTRL-013", tool=None)
|
|
2293
|
+
return
|
|
2294
|
+
|
|
2295
|
+
target = criterion.get("target")
|
|
2296
|
+
target_path = project_root / target if target else None
|
|
2297
|
+
if not target_path or not target_path.is_file():
|
|
2298
|
+
_qa_record(pcp_dir, ctx, "design-consistency", [], control_id="CTRL-013", tool=None)
|
|
2299
|
+
return
|
|
2300
|
+
|
|
2301
|
+
content = target_path.read_text(errors="replace")
|
|
2302
|
+
prior_screens = _prior_ui_screens_checked(pcp_dir, ctx)
|
|
2303
|
+
established = prior_screens >= _design_establishing_window()
|
|
2304
|
+
severity_prefix = "established-system drift" if established else "exploration"
|
|
2305
|
+
|
|
2306
|
+
hex_matches = re.findall(r"#[0-9a-fA-F]{3,8}\b", content)
|
|
2307
|
+
findings = []
|
|
2308
|
+
if hex_matches:
|
|
2309
|
+
findings.append(
|
|
2310
|
+
f"[{severity_prefix}] {len(hex_matches)} hardcoded hex color literal(s) in {target} while "
|
|
2311
|
+
f".pcp/design_system.md has established color tokens — consider reusing "
|
|
2312
|
+
f"those instead. Examples: {', '.join(hex_matches[:5])}"
|
|
2313
|
+
+ (f" (screen #{prior_screens + 1} against an already-established system)" if established else "")
|
|
2314
|
+
)
|
|
2315
|
+
# Positive check (stylelint no-raw-colors posture, 2026-07-17): absence of
|
|
2316
|
+
# violations isn't adherence — a UI file that references ZERO named tokens
|
|
2317
|
+
# from the established system isn't using it at all. Token vocabulary =
|
|
2318
|
+
# CSS custom properties declared in design_system.md. Advisory, same as
|
|
2319
|
+
# the hex check; "token systems erode within weeks without hard gates" is
|
|
2320
|
+
# the eventual argument for upgrading this, measured first.
|
|
2321
|
+
declared_tokens = set(re.findall(r"--[\w-]{3,}", design_system_path.read_text()))
|
|
2322
|
+
if declared_tokens and not any(t in content for t in declared_tokens):
|
|
2323
|
+
findings.append(
|
|
2324
|
+
f"[{severity_prefix}] {target} references none of the {len(declared_tokens)} named design-system "
|
|
2325
|
+
"tokens (--*) declared in .pcp/design_system.md — the screen may be styled "
|
|
2326
|
+
"outside the system entirely"
|
|
2327
|
+
+ (f" (screen #{prior_screens + 1} against an already-established system)" if established else "")
|
|
2328
|
+
)
|
|
2329
|
+
evidence_path = evidence.store(
|
|
2330
|
+
pcp_dir, ctx["module"], ctx["criterion_id"], ctx["attempt"], "design-consistency",
|
|
2331
|
+
"\n".join(findings) if findings else "no hardcoded colors found",
|
|
2332
|
+
)
|
|
2333
|
+
_qa_record(
|
|
2334
|
+
pcp_dir, ctx, "design-consistency", findings, control_id="CTRL-013", tool="regex",
|
|
2335
|
+
evidence_path=evidence_path,
|
|
2336
|
+
)
|
|
2337
|
+
if findings:
|
|
2338
|
+
console.print(f"[yellow]Design consistency (advisory):[/yellow] {findings[0]}")
|
|
2339
|
+
|
|
2340
|
+
|
|
2341
|
+
def _run_a11y_check(pcp_dir: Path, criterion: dict, ctx: dict) -> None:
|
|
2342
|
+
"""PCP Design lifecycle, stage 4 addendum. Advisory only -- never
|
|
2343
|
+
returned into block_findings, same posture as _run_design_consistency_check.
|
|
2344
|
+
Deterministic WCAG scan (axe-core via npx, uat.check_axe) against a
|
|
2345
|
+
UI-facing criterion's declared url. Only fires when both hold -- most
|
|
2346
|
+
criteria have no url at all, and this can't scan a page it can't reach.
|
|
2347
|
+
CTRL-022."""
|
|
2348
|
+
if not _is_ui_facing_criterion(criterion):
|
|
2349
|
+
return
|
|
2350
|
+
url = criterion.get("url")
|
|
2351
|
+
if not url:
|
|
2352
|
+
_qa_record(pcp_dir, ctx, "a11y", [], control_id="CTRL-022", tool=None)
|
|
2353
|
+
return
|
|
2354
|
+
ok, detail = uat.check_axe(url)
|
|
2355
|
+
if ok is None:
|
|
2356
|
+
# npx not on PATH -- "could not check", not "failed" (see uat.check_axe).
|
|
2357
|
+
_qa_record(pcp_dir, ctx, "a11y", [], control_id="CTRL-022", tool=None)
|
|
2358
|
+
return
|
|
2359
|
+
findings = [] if ok else [detail]
|
|
2360
|
+
evidence_path = evidence.store(
|
|
2361
|
+
pcp_dir, ctx["module"], ctx["criterion_id"], ctx["attempt"], "a11y", detail,
|
|
2362
|
+
)
|
|
2363
|
+
_qa_record(pcp_dir, ctx, "a11y", findings, control_id="CTRL-022", tool="axe-core", evidence_path=evidence_path)
|
|
2364
|
+
if findings:
|
|
2365
|
+
console.print(f"[yellow]Accessibility (advisory):[/yellow] {detail.splitlines()[0][:200]}")
|
|
2366
|
+
|
|
2367
|
+
|
|
2368
|
+
def _run_visual_quality_check(pcp_dir: Path, project_root: Path, criterion: dict, ctx: dict) -> None:
|
|
2369
|
+
"""PCP Design lifecycle, stage 4 addendum. Advisory only -- never
|
|
2370
|
+
returned into block_findings. Checklist-anchored VLM judge
|
|
2371
|
+
(uat.check_visual_quality) over a fresh screenshot of a UI-facing
|
|
2372
|
+
criterion's declared url -- research finding behind why this is
|
|
2373
|
+
checklist-anchored rather than a freeform "does this look good" prompt:
|
|
2374
|
+
a checklist-anchored VLM judge measures ~94% human-correlation vs. ~21%
|
|
2375
|
+
for a bare Nielsen-heuristics-style review (ArtifactsBench, 2026).
|
|
2376
|
+
Compares against the criterion's own reference_image when declared.
|
|
2377
|
+
CTRL-023."""
|
|
2378
|
+
if not _is_ui_facing_criterion(criterion):
|
|
2379
|
+
return
|
|
2380
|
+
url = criterion.get("url")
|
|
2381
|
+
if not url:
|
|
2382
|
+
_qa_record(pcp_dir, ctx, "visual-quality", [], control_id="CTRL-023", tool=None)
|
|
2383
|
+
return
|
|
2384
|
+
|
|
2385
|
+
screenshot_path = pcp_dir / "evidence" / "_visual" / ctx["module"] / f"{ctx['criterion_id']}.png"
|
|
2386
|
+
rendered, _render_detail = uat.check_visual(url, screenshot_path)
|
|
2387
|
+
if not rendered:
|
|
2388
|
+
# Either playwright isn't installed (None) or the page failed to
|
|
2389
|
+
# render (False) -- either way there's no screenshot to judge.
|
|
2390
|
+
_qa_record(pcp_dir, ctx, "visual-quality", [], control_id="CTRL-023", tool=None)
|
|
2391
|
+
return
|
|
2392
|
+
|
|
2393
|
+
reference_image = criterion.get("reference_image")
|
|
2394
|
+
reference_path = (project_root / reference_image) if reference_image else None
|
|
2395
|
+
ok, detail, items = uat.check_visual_quality(
|
|
2396
|
+
screenshot_path, reference_image_path=reference_path, pcp_dir=pcp_dir,
|
|
2397
|
+
)
|
|
2398
|
+
if ok is None:
|
|
2399
|
+
_qa_record(pcp_dir, ctx, "visual-quality", [], control_id="CTRL-023", tool=None)
|
|
2400
|
+
return
|
|
2401
|
+
|
|
2402
|
+
findings = [] if ok else [detail]
|
|
2403
|
+
evidence_path = evidence.store(
|
|
2404
|
+
pcp_dir, ctx["module"], ctx["criterion_id"], ctx["attempt"], "visual-quality",
|
|
2405
|
+
json.dumps(items, indent=2) if items else detail,
|
|
2406
|
+
)
|
|
2407
|
+
_qa_record(
|
|
2408
|
+
pcp_dir, ctx, "visual-quality", findings, control_id="CTRL-023", tool="vlm-judge",
|
|
2409
|
+
evidence_path=evidence_path,
|
|
2410
|
+
)
|
|
2411
|
+
if findings:
|
|
2412
|
+
console.print(f"[yellow]Visual quality (advisory):[/yellow] {detail[:200]}")
|
|
2413
|
+
|
|
2414
|
+
|
|
2415
|
+
DESIGN_JUSTIFICATION_SYSTEM_PROMPT = (
|
|
2416
|
+
"You judge whether a UI criterion's design_justification block reflects real design "
|
|
2417
|
+
"thinking or was filled in lazily just to pass validation. You are given the "
|
|
2418
|
+
"criterion's own description, an excerpt of the project's design_system.md, and the "
|
|
2419
|
+
"submitted checklist_passed/jtbd_framing/deviations_from_system fields. Flag it as NOT "
|
|
2420
|
+
"substantive if: checklist_passed is empty or contains junk/placeholder strings; "
|
|
2421
|
+
"jtbd_framing is a generic restatement of the criterion description rather than a real "
|
|
2422
|
+
"'when a user is X, this lets them Y' conditional; or the whole block reads as "
|
|
2423
|
+
"boilerplate. Default to substantive=true when genuinely uncertain -- you are the first "
|
|
2424
|
+
"check on this, not the only one; a human still reviews design_audit.md."
|
|
2425
|
+
)
|
|
2426
|
+
|
|
2427
|
+
|
|
2428
|
+
def _run_design_justification_check(pcp_dir: Path, mod: dict, criterion: dict, ctx: dict) -> list[str]:
|
|
2429
|
+
"""PCP Design lifecycle stage 4, closing the gap CLAUDE.md names for this
|
|
2430
|
+
pillar: design_audit.py's Feature Exposure Ladder (_classify_rung) is
|
|
2431
|
+
pure presence/keyword logic -- a checklist_passed full of junk strings or
|
|
2432
|
+
a jtbd_framing sentence that merely contains the word "when" anywhere
|
|
2433
|
+
still classifies as rung 3/4. That's a passive rollup computed after the
|
|
2434
|
+
fact, not enforcement. This is the active check during the build itself:
|
|
2435
|
+
same llm.call_json + _verify_block_findings adversarial pattern as
|
|
2436
|
+
_run_architect_review/_run_gate_check, and findings BLOCK the criterion
|
|
2437
|
+
the same way -- a lazily filled design_justification is exactly the
|
|
2438
|
+
"structural-forcing" mechanism CLAUDE.md flags as still missing here.
|
|
2439
|
+
|
|
2440
|
+
Re-reads acceptance.yaml fresh rather than trusting the `criterion` dict
|
|
2441
|
+
passed in, which is the pre-attempt snapshot from before the coding
|
|
2442
|
+
agent ran -- design_justification is written BY the agent during this
|
|
2443
|
+
attempt, so the caller's copy is always stale for this field."""
|
|
2444
|
+
if not _is_ui_facing_criterion(criterion):
|
|
2445
|
+
return []
|
|
2446
|
+
|
|
2447
|
+
acc_data = load_yaml(mod["acc_path"])
|
|
2448
|
+
fresh = next((c for c in acc_data.get("criteria", []) if c["id"] == criterion["id"]), None)
|
|
2449
|
+
dj = (fresh or {}).get("design_justification")
|
|
2450
|
+
if not dj:
|
|
2451
|
+
return [] # rung 1 (Built, Hidden) -- design_audit.py's rollup already surfaces this
|
|
2452
|
+
|
|
2453
|
+
design_system = (pcp_dir / "design_system.md").read_text() if (pcp_dir / "design_system.md").exists() else ""
|
|
2454
|
+
prompt = (
|
|
2455
|
+
f"## Criterion\n{criterion.get('description', '')}\n\n"
|
|
2456
|
+
f"## design_system.md excerpt\n{design_system[:3000]}\n\n"
|
|
2457
|
+
f"## design_justification submitted\n"
|
|
2458
|
+
f"checklist_passed: {dj.get('checklist_passed')}\n"
|
|
2459
|
+
f"jtbd_framing: {dj.get('jtbd_framing')}\n"
|
|
2460
|
+
f"deviations_from_system: {dj.get('deviations_from_system')}\n\n"
|
|
2461
|
+
'## Respond with JSON only\n'
|
|
2462
|
+
'{"substantive": true, "reason": "..."}'
|
|
2463
|
+
)
|
|
2464
|
+
try:
|
|
2465
|
+
res, meta = llm.call_json(
|
|
2466
|
+
DESIGN_JUSTIFICATION_SYSTEM_PROMPT, prompt, model=llm.JUDGE_MODEL, pcp_dir=pcp_dir,
|
|
2467
|
+
command="build-design-justification", return_meta=True,
|
|
2468
|
+
)
|
|
2469
|
+
except Exception as e:
|
|
2470
|
+
console.print(f"[yellow]Warning: design_justification check call failed: {e}[/yellow]")
|
|
2471
|
+
_qa_record(pcp_dir, ctx, "design-justification", [f"call failed: {e}"], control_id="CTRL-015", result="error")
|
|
2472
|
+
return []
|
|
2473
|
+
|
|
2474
|
+
findings = []
|
|
2475
|
+
if not res.get("substantive", True):
|
|
2476
|
+
findings.append(
|
|
2477
|
+
f"design_justification for {criterion['id']} reads as lazily filled, not real "
|
|
2478
|
+
f"design thinking: {res.get('reason', '')}"
|
|
2479
|
+
)
|
|
2480
|
+
evidence_path = evidence.store(
|
|
2481
|
+
pcp_dir, ctx["module"], ctx["criterion_id"], ctx["attempt"], "design-justification", json.dumps(res, indent=2),
|
|
2482
|
+
)
|
|
2483
|
+
# _verify_block_findings' first positional param is normally a code diff to
|
|
2484
|
+
# ground findings against -- repurposed here as the submitted justification
|
|
2485
|
+
# block itself, since that (not a code diff) is what this finding is about.
|
|
2486
|
+
kept, _dropped = _verify_block_findings(
|
|
2487
|
+
pcp_dir, json.dumps(dj), findings, ctx, "design-justification", "CTRL-015",
|
|
2488
|
+
)
|
|
2489
|
+
_qa_record(pcp_dir, ctx, "design-justification", kept, meta, control_id="CTRL-015", evidence_path=evidence_path)
|
|
2490
|
+
return kept
|
|
2491
|
+
|
|
2492
|
+
|
|
2493
|
+
_CUSTOMIZATION_SIGNAL_KEYWORDS = (
|
|
2494
|
+
"setting", "settings", "preference", "preferences", "config", "configure",
|
|
2495
|
+
"configuration", "customiz", "personaliz", "toggle", "user_config", "userconfig",
|
|
2496
|
+
)
|
|
2497
|
+
|
|
2498
|
+
|
|
2499
|
+
def _run_customization_check(pcp_dir: Path, mod: dict, criterion: dict, ctx: dict) -> None:
|
|
2500
|
+
"""CTRL-026 -- deterministic structural check for design_justification.
|
|
2501
|
+
customizable, same posture as CTRL-017's build_vs_buy placeholder check:
|
|
2502
|
+
a declared customizable=true should show SOME settings-shaped signal in
|
|
2503
|
+
the criterion's own target file, or the declaration reads the same way
|
|
2504
|
+
an empty design_justification does -- a claim with nothing behind it.
|
|
2505
|
+
Deterministic keyword scan, not a semantic judge call: whether a feature
|
|
2506
|
+
is "really" customizable in a way that matters to a user is exactly the
|
|
2507
|
+
kind of judgment call CTRL-015's LLM check already makes on the whole
|
|
2508
|
+
design_justification block; this only catches the cheap, structural
|
|
2509
|
+
failure mode (true declared, zero corroborating signal anywhere).
|
|
2510
|
+
|
|
2511
|
+
Advisory only -- never returned into block_findings, same posture as
|
|
2512
|
+
_run_design_consistency_check. Re-reads acceptance.yaml fresh since
|
|
2513
|
+
design_justification is written by the coding agent during this attempt."""
|
|
2514
|
+
if not _is_ui_facing_criterion(criterion):
|
|
2515
|
+
return
|
|
2516
|
+
|
|
2517
|
+
acc_data = load_yaml(mod["acc_path"])
|
|
2518
|
+
fresh = next((c for c in acc_data.get("criteria", []) if c["id"] == criterion["id"]), None)
|
|
2519
|
+
dj = (fresh or {}).get("design_justification") or {}
|
|
2520
|
+
if not dj.get("customizable"):
|
|
2521
|
+
_qa_record(pcp_dir, ctx, "customization", [], control_id="CTRL-026", tool=None)
|
|
2522
|
+
return
|
|
2523
|
+
|
|
2524
|
+
findings = []
|
|
2525
|
+
notes = (dj.get("customization_notes") or "").strip()
|
|
2526
|
+
if not notes or len(notes.split()) < 3:
|
|
2527
|
+
findings.append(
|
|
2528
|
+
f"{criterion['id']} declares customizable=true but customization_notes is "
|
|
2529
|
+
f"empty or trivially short ({notes!r}) -- what's actually configurable?"
|
|
2530
|
+
)
|
|
2531
|
+
|
|
2532
|
+
target = criterion.get("target")
|
|
2533
|
+
target_path = (pcp_dir.parent / target) if target else None
|
|
2534
|
+
if target_path and target_path.is_file():
|
|
2535
|
+
content = target_path.read_text(errors="replace").lower()
|
|
2536
|
+
if not any(k in content for k in _CUSTOMIZATION_SIGNAL_KEYWORDS):
|
|
2537
|
+
findings.append(
|
|
2538
|
+
f"{criterion['id']} declares customizable=true but {target} shows no "
|
|
2539
|
+
"settings/preference/config-shaped signal -- the declaration may be aspirational, not built yet"
|
|
2540
|
+
)
|
|
2541
|
+
|
|
2542
|
+
evidence_path = evidence.store(
|
|
2543
|
+
pcp_dir, ctx["module"], ctx["criterion_id"], ctx["attempt"], "customization",
|
|
2544
|
+
"\n".join(findings) if findings else f"customizable=true, notes={notes!r}",
|
|
2545
|
+
)
|
|
2546
|
+
_qa_record(
|
|
2547
|
+
pcp_dir, ctx, "customization", findings, control_id="CTRL-026", tool="keyword-scan",
|
|
2548
|
+
evidence_path=evidence_path,
|
|
2549
|
+
)
|
|
2550
|
+
if findings:
|
|
2551
|
+
console.print(f"[yellow]Customization check (advisory):[/yellow] {findings[0]}")
|
|
2552
|
+
|
|
2553
|
+
|
|
2554
|
+
_BVB_PLACEHOLDER_PHRASES = frozenset({
|
|
2555
|
+
"not specified", "not specified by generator", "todo", "tbd", "n/a", "na",
|
|
2556
|
+
"placeholder", "reason", "why this decision", "why this decision, one sentence",
|
|
2557
|
+
"one sentence rationale", "one-sentence rationale", "...", "x", "-", "unspecified",
|
|
2558
|
+
"not specified by generator -- coerced placeholder, review before treating as a real decision.",
|
|
2559
|
+
})
|
|
2560
|
+
_BVB_MIN_WORDS = 4
|
|
2561
|
+
|
|
2562
|
+
|
|
2563
|
+
def _run_build_vs_buy_justification_check(pcp_dir: Path, mod: dict, criterion: dict, ctx: dict) -> list[str]:
|
|
2564
|
+
"""Structural-forcing for build_vs_buy, same enforcement posture
|
|
2565
|
+
design_justification just got (CTRL-015) -- CLAUDE.md names this exact
|
|
2566
|
+
gap: build_vs_buy's rationale field is schema-required (must be present)
|
|
2567
|
+
but never checked for substance, so "x" or the literal unfilled prompt
|
|
2568
|
+
template text passes validation as a real decision.
|
|
2569
|
+
|
|
2570
|
+
Deterministic, NOT an LLM judge call -- unlike design_justification
|
|
2571
|
+
(fires only for the UI-facing subset of criteria), build_vs_buy is
|
|
2572
|
+
required on EVERY criterion, so an LLM call here on every attempt of
|
|
2573
|
+
every criterion would be a real Token Discipline violation for a field
|
|
2574
|
+
that mostly just needs a placeholder-text check, not genuine semantic
|
|
2575
|
+
judgment. Same placeholder-rejection posture bypass_approval.rego
|
|
2576
|
+
already established for bypass reasons (see policy.py), reimplemented
|
|
2577
|
+
here in plain Python so it works with zero OPA setup -- build_vs_buy
|
|
2578
|
+
validation can't depend on an optional external tool being installed.
|
|
2579
|
+
|
|
2580
|
+
Re-reads acceptance.yaml fresh for the same reason
|
|
2581
|
+
_run_design_justification_check does: build_vs_buy can be touched by
|
|
2582
|
+
the coding agent during this attempt, so the pre-attempt `criterion`
|
|
2583
|
+
snapshot is stale for this field."""
|
|
2584
|
+
acc_data = load_yaml(mod["acc_path"])
|
|
2585
|
+
fresh = next((c for c in acc_data.get("criteria", []) if c["id"] == criterion["id"]), None)
|
|
2586
|
+
bvb = (fresh or {}).get("build_vs_buy") or {}
|
|
2587
|
+
decision = bvb.get("decision")
|
|
2588
|
+
rationale = (bvb.get("rationale") or "").strip()
|
|
2589
|
+
|
|
2590
|
+
findings = []
|
|
2591
|
+
if decision and decision != "not_applicable":
|
|
2592
|
+
normalized = rationale.lower().rstrip(".")
|
|
2593
|
+
word_count = len(rationale.split())
|
|
2594
|
+
if not rationale or normalized in _BVB_PLACEHOLDER_PHRASES or word_count < _BVB_MIN_WORDS:
|
|
2595
|
+
findings.append(
|
|
2596
|
+
f"build_vs_buy rationale for {criterion['id']} reads as a placeholder, not a "
|
|
2597
|
+
f"real decision: '{rationale or '(empty)'}'"
|
|
2598
|
+
)
|
|
2599
|
+
|
|
2600
|
+
evidence_path = evidence.store(
|
|
2601
|
+
pcp_dir, ctx["module"], ctx["criterion_id"], ctx["attempt"], "build-vs-buy-justification",
|
|
2602
|
+
rationale or "(empty)",
|
|
2603
|
+
)
|
|
2604
|
+
_qa_record(
|
|
2605
|
+
pcp_dir, ctx, "build-vs-buy-justification", findings, control_id="CTRL-017",
|
|
2606
|
+
tool="regex", evidence_path=evidence_path,
|
|
2607
|
+
)
|
|
2608
|
+
return findings
|
|
2609
|
+
|
|
2610
|
+
|
|
2611
|
+
_TEST_PATH_SEGMENTS = ("tests", "test", "__tests__", "spec", "specs")
|
|
2612
|
+
|
|
2613
|
+
|
|
2614
|
+
def _is_test_file(path: str) -> bool:
|
|
2615
|
+
parts = Path(path).parts
|
|
2616
|
+
if any(seg in _TEST_PATH_SEGMENTS for seg in parts[:-1]):
|
|
2617
|
+
return True
|
|
2618
|
+
name = Path(path).name.lower()
|
|
2619
|
+
return (
|
|
2620
|
+
name.startswith("test_") or name == "conftest.py"
|
|
2621
|
+
or "_test." in name or ".test." in name or ".spec." in name
|
|
2622
|
+
)
|
|
2623
|
+
|
|
2624
|
+
|
|
2625
|
+
def _scope_mode() -> str:
|
|
2626
|
+
"""PCP_BUILD_SCOPE_MODE: warn (default) | block | off. Ships warn-first
|
|
2627
|
+
deliberately -- the same L1-report-only-before-L2-enforcement rollout
|
|
2628
|
+
discipline PCP recommends for any new automated gate: measure the
|
|
2629
|
+
false-positive rate on real builds (legit cross-cutting writes like a
|
|
2630
|
+
module registry exist) before letting it cost retry attempts."""
|
|
2631
|
+
mode = os.environ.get("PCP_BUILD_SCOPE_MODE", "warn").lower()
|
|
2632
|
+
return mode if mode in ("warn", "block", "off") else "warn"
|
|
2633
|
+
|
|
2634
|
+
|
|
2635
|
+
def _scope_allowlist_violations(mod: dict, criterion: dict, changed_files: list[str]) -> list[str]:
|
|
2636
|
+
"""Deterministic over-reach check (no LLM): which changed files fall
|
|
2637
|
+
outside what this criterion could legitimately touch? Allowed:
|
|
2638
|
+
- any `target` file declared by ANY criterion in this module (the module's
|
|
2639
|
+
own declared surface, not just this one criterion's file)
|
|
2640
|
+
- anything under .pcp/strategy/modules/<module>/ (the agent legitimately
|
|
2641
|
+
writes design_justification back into its own acceptance.yaml)
|
|
2642
|
+
- .pcp/design_system.md (first UI criterion establishes it)
|
|
2643
|
+
- test files (TDD is mandatory -- tests are always in scope)
|
|
2644
|
+
"""
|
|
2645
|
+
module_name = mod["name"]
|
|
2646
|
+
try:
|
|
2647
|
+
all_criteria = load_yaml(mod["acc_path"]).get("criteria", [])
|
|
2648
|
+
except Exception:
|
|
2649
|
+
all_criteria = mod.get("pending_criteria", [])
|
|
2650
|
+
declared_targets = {c.get("target") for c in all_criteria if c.get("target")}
|
|
2651
|
+
if criterion.get("target"):
|
|
2652
|
+
declared_targets.add(criterion["target"])
|
|
2653
|
+
module_prefix = f".pcp/strategy/modules/{module_name}/"
|
|
2654
|
+
|
|
2655
|
+
violations = []
|
|
2656
|
+
for f in changed_files:
|
|
2657
|
+
norm = f.replace("\\", "/").removeprefix("./")
|
|
2658
|
+
if norm in declared_targets:
|
|
2659
|
+
continue
|
|
2660
|
+
if norm.startswith(module_prefix) or norm == ".pcp/design_system.md":
|
|
2661
|
+
continue
|
|
2662
|
+
if _is_test_file(norm):
|
|
2663
|
+
continue
|
|
2664
|
+
violations.append(norm)
|
|
2665
|
+
return violations
|
|
2666
|
+
|
|
2667
|
+
|
|
2668
|
+
def _run_scope_check(pcp_dir: Path, mod: dict, criterion: dict, changed_files: list[str], ctx: dict) -> list[str]:
|
|
2669
|
+
"""Over-reach guard (CTRL-018): a criterion agent writing files outside
|
|
2670
|
+
its module's declared surface is the "loop touches unrelated code"
|
|
2671
|
+
failure mode -- PCP had a denylist (protected_path) but no allowlist
|
|
2672
|
+
until now. Warn-only by default (see _scope_mode); PCP_BUILD_SCOPE_MODE=
|
|
2673
|
+
block returns the finding into block_findings so it costs the attempt."""
|
|
2674
|
+
mode = _scope_mode()
|
|
2675
|
+
if mode == "off":
|
|
2676
|
+
# Disabled by a human -- still visible in the audit trail as skipped,
|
|
2677
|
+
# never silently indistinguishable from "ran clean".
|
|
2678
|
+
_qa_record(pcp_dir, ctx, "build-scope", [], control_id="CTRL-018", result="skipped")
|
|
2679
|
+
return []
|
|
2680
|
+
|
|
2681
|
+
out_of_scope = _scope_allowlist_violations(mod, criterion, changed_files)
|
|
2682
|
+
findings = []
|
|
2683
|
+
if out_of_scope:
|
|
2684
|
+
findings.append(
|
|
2685
|
+
f"Scope Guard [CTRL-018]: agent modified {len(out_of_scope)} file(s) outside "
|
|
2686
|
+
f"module '{mod['name']}'s declared surface (criterion targets, module spec dir, "
|
|
2687
|
+
f"tests): {', '.join(out_of_scope[:8])}"
|
|
2688
|
+
+ (" …" if len(out_of_scope) > 8 else "")
|
|
2689
|
+
)
|
|
2690
|
+
evidence_path = evidence.store(
|
|
2691
|
+
pcp_dir, ctx["module"], ctx["criterion_id"], ctx["attempt"], "build-scope",
|
|
2692
|
+
"\n".join(out_of_scope) if out_of_scope else "all changed files within declared scope",
|
|
2693
|
+
)
|
|
2694
|
+
# In warn mode a finding here does NOT block, so recording it as `block`
|
|
2695
|
+
# makes the audit trail claim something that never happened. Measured on
|
|
2696
|
+
# Project O 2026-07-30: 110 of the project's 259 `block` records were
|
|
2697
|
+
# this check in warn mode -- **42.5% of every block PCP had ever recorded
|
|
2698
|
+
# there never blocked anything**, so any provenance or block-rate reading of
|
|
2699
|
+
# that project was wrong by nearly half. `advisory` already exists as a
|
|
2700
|
+
# result value for exactly this (CTRL-025/030/033/036 use it); this check
|
|
2701
|
+
# simply wasn't using it. Same invariant _qa_record and _wave_record already
|
|
2702
|
+
# state: a result must describe what happened, not what the check found.
|
|
2703
|
+
_qa_record(
|
|
2704
|
+
pcp_dir, ctx, "build-scope", findings, control_id="CTRL-018", tool="git-diff",
|
|
2705
|
+
evidence_path=evidence_path,
|
|
2706
|
+
result=("advisory" if findings and mode == "warn" else None),
|
|
2707
|
+
)
|
|
2708
|
+
if findings and mode == "warn":
|
|
2709
|
+
console.print(f"[yellow]Scope guard (advisory):[/yellow] {findings[0]}")
|
|
2710
|
+
return []
|
|
2711
|
+
return findings
|
|
2712
|
+
|
|
2713
|
+
|
|
2714
|
+
def _run_wave_contract_completeness_check(pcp_dir: Path, wave_number: int) -> list[str]:
|
|
2715
|
+
"""CTRL-033, ADVISORY, deterministic, project-wide (not per-module --
|
|
2716
|
+
ci_rules.yaml is one file). ABC contract-shape reference pattern
|
|
2717
|
+
(arXiv:2602.22302, see docs/research-rigidity-vs-reliability-2026-07.md
|
|
2718
|
+
and _apply_rule_recovery's own docstring): a hard_block rule with no
|
|
2719
|
+
`contract` block at all has no declared recovery plan beyond the flat
|
|
2720
|
+
binary severity gate -- same "declared-but-not-enforced-yet" posture
|
|
2721
|
+
CTRL-019 already uses for logic_tier presence. Advisory, never blocks;
|
|
2722
|
+
a rule without a contract block behaves exactly as it always has."""
|
|
2723
|
+
ci_rules_path = pcp_dir / "ci_rules.yaml"
|
|
2724
|
+
if not ci_rules_path.exists():
|
|
2725
|
+
return []
|
|
2726
|
+
try:
|
|
2727
|
+
data = load_yaml(ci_rules_path)
|
|
2728
|
+
except Exception:
|
|
2729
|
+
return []
|
|
2730
|
+
findings = [
|
|
2731
|
+
f"Rule [{r.get('id')}] '{r.get('name')}' is hard_block with no declared contract "
|
|
2732
|
+
"(preconditions/invariants/recovery) -- relies on the flat severity gate only"
|
|
2733
|
+
for r in data.get("rules", []) or []
|
|
2734
|
+
if r.get("severity") == "hard_block" and not r.get("contract")
|
|
2735
|
+
]
|
|
2736
|
+
_wave_record(pcp_dir, wave_number, "contract-completeness", "CTRL-033", findings,
|
|
2737
|
+
files=["ci_rules.yaml"], result="pass")
|
|
2738
|
+
for f in findings:
|
|
2739
|
+
console.print(f"[yellow]Contract completeness (advisory):[/yellow] {f}")
|
|
2740
|
+
return findings
|
|
2741
|
+
|
|
2742
|
+
|
|
2743
|
+
def _run_wave_narrative_lint_check(pcp_dir: Path, wave_number: int) -> list[str]:
|
|
2744
|
+
"""CTRL-036, ADVISORY, project-wide (not per-module — CLAUDE.md-family
|
|
2745
|
+
files aren't scoped to one module). See narrative_lint.py's module
|
|
2746
|
+
docstring for the fleet evidence (2026-07-24 context-hygiene pass):
|
|
2747
|
+
narrative prose in CLAUDE.md drifted from tracked state 3-for-3 in
|
|
2748
|
+
projects checked, undetected by every other gate in this catalog
|
|
2749
|
+
because they all validate code against spec, never free-text prose
|
|
2750
|
+
against current_state.md/architecture.md. Deterministic sub-checks
|
|
2751
|
+
(stale dates, missing referenced files) plus one batched judge call
|
|
2752
|
+
for semantic contradiction — same rung-6 posture as CTRL-020."""
|
|
2753
|
+
result = narrative_lint.run(pcp_dir)
|
|
2754
|
+
findings = result["stale_dates"] + result["missing_files"] + result["contradictions"]
|
|
2755
|
+
_wave_record(pcp_dir, wave_number, "narrative-lint", "CTRL-036", findings,
|
|
2756
|
+
files=result["files_scanned"], result="pass")
|
|
2757
|
+
for f in findings:
|
|
2758
|
+
console.print(f"[yellow]Narrative lint (advisory):[/yellow] {f}")
|
|
2759
|
+
return findings
|
|
2760
|
+
|
|
2761
|
+
|
|
2762
|
+
def _run_wave_logic_breakdown_check(pcp_dir: Path, wave_modules: list[dict], wave_number: int) -> list[str]:
|
|
2763
|
+
"""CTRL-031, ADVISORY, deterministic-only in this pass. module_logic_
|
|
2764
|
+
breakdown backlog item 9's verification half: kickoff/pm already
|
|
2765
|
+
keyword-check a declared breakdown item against this module's OWN
|
|
2766
|
+
criteria descriptions BEFORE build (check_module_logic_breakdown_
|
|
2767
|
+
coverage) -- this re-checks AFTER build, against completed criteria's
|
|
2768
|
+
actual target-file content: "does code exist that plausibly reflects
|
|
2769
|
+
each declared component," not just "did a criterion get worded to
|
|
2770
|
+
mention it." Deterministic keyword scan, not the CTRL-015-style LLM
|
|
2771
|
+
judge the backlog item originally sketched -- same rung-1-first posture
|
|
2772
|
+
every other check in this catalog started with; the semantic half
|
|
2773
|
+
(does the code genuinely FULFILL the component, not just mention it)
|
|
2774
|
+
stays deferred."""
|
|
2775
|
+
from pcp.commands.kickoff import _keyword_miss_check
|
|
2776
|
+
|
|
2777
|
+
findings: list[str] = []
|
|
2778
|
+
for mod in wave_modules:
|
|
2779
|
+
spec_path = pcp_dir / "strategy" / "modules" / mod["name"] / "spec.yaml"
|
|
2780
|
+
acc_path = pcp_dir / "strategy" / "modules" / mod["name"] / "acceptance.yaml"
|
|
2781
|
+
if not spec_path.exists() or not acc_path.exists():
|
|
2782
|
+
continue
|
|
2783
|
+
spec = load_yaml(spec_path)
|
|
2784
|
+
breakdown = spec.get("module_logic_breakdown") or []
|
|
2785
|
+
if not breakdown:
|
|
2786
|
+
continue
|
|
2787
|
+
acc = load_yaml(acc_path)
|
|
2788
|
+
built_text_parts = []
|
|
2789
|
+
for c in acc.get("criteria", []):
|
|
2790
|
+
if c.get("status") != "complete":
|
|
2791
|
+
continue
|
|
2792
|
+
built_text_parts.append(c.get("description", ""))
|
|
2793
|
+
target = c.get("target")
|
|
2794
|
+
target_path = (pcp_dir.parent / target) if target else None
|
|
2795
|
+
if target_path and target_path.is_file():
|
|
2796
|
+
built_text_parts.append(target_path.read_text(errors="replace"))
|
|
2797
|
+
built_text = " ".join(built_text_parts)
|
|
2798
|
+
for f in _keyword_miss_check(
|
|
2799
|
+
breakdown, built_text, "Logic-breakdown item",
|
|
2800
|
+
"any completed criterion's description or target file",
|
|
2801
|
+
):
|
|
2802
|
+
findings.append(f"{mod['name']}: {f}")
|
|
2803
|
+
|
|
2804
|
+
_wave_record(pcp_dir, wave_number, "logic-breakdown", "CTRL-031", findings,
|
|
2805
|
+
files=[m["name"] for m in wave_modules], result="pass")
|
|
2806
|
+
for f in findings:
|
|
2807
|
+
console.print(f"[yellow]Logic-breakdown check (advisory):[/yellow] {f}")
|
|
2808
|
+
return findings
|
|
2809
|
+
|
|
2810
|
+
|
|
2811
|
+
_LAZY_MARKER_PATTERN = re.compile(
|
|
2812
|
+
r"\b(TODO|FIXME|XXX|HACK)\b|"
|
|
2813
|
+
r"\bnot\s+(?:yet\s+)?implement(?:ed)?\b|\bplaceholder\b|\bcoming\s+soon\b",
|
|
2814
|
+
re.IGNORECASE,
|
|
2815
|
+
)
|
|
2816
|
+
# def foo(...):\n pass (or ... / bare docstring only) -- a stub body,
|
|
2817
|
+
# not necessarily wrong (abstract methods do this legitimately) but worth
|
|
2818
|
+
# a glance when it shows up in a criterion's own newly-changed lines.
|
|
2819
|
+
_STUB_BODY_PATTERN = re.compile(
|
|
2820
|
+
r"^\s*def\s+\w+\([^)]*\)[^\n:]*:\s*\n\s*(pass|\.\.\.)\s*$", re.MULTILINE,
|
|
2821
|
+
)
|
|
2822
|
+
_LAZY_MARKER_MAX_CHARS = 20_000 # skip pathologically large generated/vendored files
|
|
2823
|
+
|
|
2824
|
+
|
|
2825
|
+
def _run_lazy_marker_check(pcp_dir: Path, project_root: Path, changed_files: list[str], ctx: dict) -> None:
|
|
2826
|
+
"""Generic lazy-marker scan (lazy-agent backlog item 3, 2026-07-20).
|
|
2827
|
+
PCP previously only checked for placeholder text narrowly, inside
|
|
2828
|
+
build_vs_buy/design_justification's own free-text fields (CTRL-017/015).
|
|
2829
|
+
This is the general form: a deterministic scan of ALL changed code for
|
|
2830
|
+
TODO/FIXME/placeholder-style markers and stub function bodies -- a cheap,
|
|
2831
|
+
non-semantic signal that a criterion may have been marked complete over
|
|
2832
|
+
unfinished work.
|
|
2833
|
+
|
|
2834
|
+
Advisory only, never blocks -- these markers are not proof of laziness
|
|
2835
|
+
(a TODO can be a legitimate forward-looking note, a stub can be a real
|
|
2836
|
+
abstract method); this surfaces the count/location for a human to judge,
|
|
2837
|
+
same posture as _run_design_consistency_check."""
|
|
2838
|
+
findings = []
|
|
2839
|
+
for f in changed_files:
|
|
2840
|
+
if _is_test_file(f):
|
|
2841
|
+
continue
|
|
2842
|
+
path = project_root / f
|
|
2843
|
+
if not path.is_file():
|
|
2844
|
+
continue
|
|
2845
|
+
try:
|
|
2846
|
+
content = path.read_text(errors="replace")
|
|
2847
|
+
except OSError:
|
|
2848
|
+
continue
|
|
2849
|
+
if len(content) > _LAZY_MARKER_MAX_CHARS:
|
|
2850
|
+
continue
|
|
2851
|
+
markers = _LAZY_MARKER_PATTERN.findall(content)
|
|
2852
|
+
stub_bodies = _STUB_BODY_PATTERN.findall(content)
|
|
2853
|
+
if markers:
|
|
2854
|
+
findings.append(f"{f}: {len(markers)} lazy-marker hit(s) ({', '.join(sorted(set(m.upper() for m in markers if m))[:5])})")
|
|
2855
|
+
if stub_bodies:
|
|
2856
|
+
findings.append(f"{f}: {len(stub_bodies)} stub function body/bodies (pass/... only)")
|
|
2857
|
+
|
|
2858
|
+
evidence_path = evidence.store(
|
|
2859
|
+
pcp_dir, ctx["module"], ctx["criterion_id"], ctx["attempt"], "lazy-marker",
|
|
2860
|
+
"\n".join(findings) if findings else "no lazy markers found in changed files",
|
|
2861
|
+
)
|
|
2862
|
+
_qa_record(
|
|
2863
|
+
pcp_dir, ctx, "lazy-marker", findings, control_id="CTRL-029", tool="regex",
|
|
2864
|
+
evidence_path=evidence_path,
|
|
2865
|
+
)
|
|
2866
|
+
if findings:
|
|
2867
|
+
console.print(f"[yellow]Lazy-marker scan (advisory):[/yellow] {findings[0]}")
|
|
2868
|
+
|
|
2869
|
+
|
|
2870
|
+
# Mechanism-signature libraries per rung, for the POSITIVE tier check
|
|
2871
|
+
# (CTRL-019). Import-name based, so the same caveat as CTRL-016 applies —
|
|
2872
|
+
# package names differ from import names — but these are the import names
|
|
2873
|
+
# themselves, curated per rung. Rung 5 additionally matches stdlib
|
|
2874
|
+
# `lru_cache`/`cache` decorators by content, since caching legitimately
|
|
2875
|
+
# needs no third-party dependency.
|
|
2876
|
+
_TIER_MECHANISM_LIBS: dict[int, set[str]] = {
|
|
2877
|
+
2: {"ortools", "pulp", "cvxpy", "z3", "pyomo", "mip", "scipy"},
|
|
2878
|
+
3: {"sklearn", "xgboost", "lightgbm", "catboost", "torch", "tensorflow", "statsmodels", "prophet"},
|
|
2879
|
+
4: {"chromadb", "faiss", "qdrant_client", "weaviate", "pinecone", "semantic_router", "rank_bm25", "whoosh", "elasticsearch", "opensearchpy"},
|
|
2880
|
+
5: {"gptcache", "redis", "diskcache", "cachetools", "memcache", "pymemcache"},
|
|
2881
|
+
}
|
|
2882
|
+
|
|
2883
|
+
_RUNG5_STDLIB_CACHE_PATTERN = re.compile(r"\blru_cache\b|\bfunctools\.cache\b")
|
|
2884
|
+
|
|
2885
|
+
# Judgment-shaped verbs in a criterion description are a cheap contradiction
|
|
2886
|
+
# signal against a declared rung 1 ("no judgment, fixed conditions").
|
|
2887
|
+
_JUDGMENT_KEYWORDS = (
|
|
2888
|
+
"recommend", "summarize", "summarise", "classify sentiment", "interpret",
|
|
2889
|
+
"understand intent", "natural language", "judge", "assess quality", "generate text",
|
|
2890
|
+
)
|
|
2891
|
+
|
|
2892
|
+
|
|
2893
|
+
def _run_wave_tier_presence_check(pcp_dir: Path, wave_modules: list[dict], wave_number: int) -> list[str]:
|
|
2894
|
+
"""7th wave-merge sub-check, CTRL-019, ADVISORY. CTRL-014 checks the
|
|
2895
|
+
NEGATIVE for rungs <=5 (no LLM SDK may appear); this checks the POSITIVE
|
|
2896
|
+
for rungs 2-5: the declared mechanism should be visible — a rung-2
|
|
2897
|
+
criterion whose target imports no solver, a rung-4 with no retrieval
|
|
2898
|
+
dependency, a rung-5 with no cache layer is likely a tier declared but
|
|
2899
|
+
not actually implemented AT that tier. Advisory because the mechanism may
|
|
2900
|
+
legitimately live in a shared helper module the target imports — measure
|
|
2901
|
+
the false-positive rate before this can earn hard-block status."""
|
|
2902
|
+
project_root = pcp_dir.parent
|
|
2903
|
+
findings: list[str] = []
|
|
2904
|
+
checked_files: list[str] = []
|
|
2905
|
+
|
|
2906
|
+
for mod in wave_modules:
|
|
2907
|
+
acc_path = pcp_dir / "strategy" / "modules" / mod["name"] / "acceptance.yaml"
|
|
2908
|
+
if not acc_path.exists():
|
|
2909
|
+
continue
|
|
2910
|
+
acc = load_yaml(acc_path)
|
|
2911
|
+
for c in acc.get("criteria", []):
|
|
2912
|
+
if c.get("status") != "complete":
|
|
2913
|
+
continue
|
|
2914
|
+
tier = c.get("logic_tier")
|
|
2915
|
+
target = c.get("target")
|
|
2916
|
+
if tier not in _TIER_MECHANISM_LIBS or not target:
|
|
2917
|
+
continue
|
|
2918
|
+
full_path = project_root / target
|
|
2919
|
+
if not full_path.exists() or not full_path.is_file():
|
|
2920
|
+
continue
|
|
2921
|
+
checked_files.append(target)
|
|
2922
|
+
imports = _external_python_imports(full_path, project_root)
|
|
2923
|
+
expected = _TIER_MECHANISM_LIBS[tier]
|
|
2924
|
+
present = bool(imports & expected)
|
|
2925
|
+
if not present and tier == 5:
|
|
2926
|
+
try:
|
|
2927
|
+
present = bool(_RUNG5_STDLIB_CACHE_PATTERN.search(full_path.read_text(errors="replace")))
|
|
2928
|
+
except OSError:
|
|
2929
|
+
present = False
|
|
2930
|
+
if not present:
|
|
2931
|
+
findings.append(
|
|
2932
|
+
f"Tier presence (advisory): '{mod['name']}/{c['id']}' declares logic_tier={tier} "
|
|
2933
|
+
f"but {target} shows none of that rung's mechanism signatures "
|
|
2934
|
+
f"({', '.join(sorted(expected)[:4])}…) — tier may be declared but not implemented"
|
|
2935
|
+
)
|
|
2936
|
+
|
|
2937
|
+
_wave_record(pcp_dir, wave_number, "tier-presence", "CTRL-019", findings,
|
|
2938
|
+
files=checked_files, result="pass")
|
|
2939
|
+
for f in findings:
|
|
2940
|
+
console.print(f"[yellow]{f}[/yellow]")
|
|
2941
|
+
return findings
|
|
2942
|
+
|
|
2943
|
+
|
|
2944
|
+
def _nav_depth_threshold() -> int:
|
|
2945
|
+
return int(os.environ.get("PCP_NAV_DEPTH_THRESHOLD", "3"))
|
|
2946
|
+
|
|
2947
|
+
|
|
2948
|
+
def _run_wave_nav_depth_check(pcp_dir: Path, wave_modules: list[dict], wave_number: int) -> list[str]:
|
|
2949
|
+
"""CTRL-025, ADVISORY. nav_depth is self-declared (like logic_tier/
|
|
2950
|
+
build_vs_buy), not computed from a real routing graph -- per-framework
|
|
2951
|
+
route parsing (React Router, Next.js file routes, Vue Router, ...) is a
|
|
2952
|
+
bigger build than a single audit field earns on its own. This is the
|
|
2953
|
+
audit half: flags UI-facing completed criteria missing the field
|
|
2954
|
+
entirely (same "declared-but-absent is itself a finding" posture as
|
|
2955
|
+
design_justification), and ones declaring a value past
|
|
2956
|
+
PCP_NAV_DEPTH_THRESHOLD (default 3, the classic UX heuristic)."""
|
|
2957
|
+
findings: list[str] = []
|
|
2958
|
+
checked: list[str] = []
|
|
2959
|
+
|
|
2960
|
+
for mod in wave_modules:
|
|
2961
|
+
acc_path = pcp_dir / "strategy" / "modules" / mod["name"] / "acceptance.yaml"
|
|
2962
|
+
if not acc_path.exists():
|
|
2963
|
+
continue
|
|
2964
|
+
acc = load_yaml(acc_path)
|
|
2965
|
+
for c in acc.get("criteria", []):
|
|
2966
|
+
if c.get("status") != "complete" or not _is_ui_facing_criterion(c):
|
|
2967
|
+
continue
|
|
2968
|
+
checked.append(f"{mod['name']}/{c['id']}")
|
|
2969
|
+
depth = c.get("nav_depth")
|
|
2970
|
+
if depth is None:
|
|
2971
|
+
findings.append(
|
|
2972
|
+
f"Nav depth (advisory): '{mod['name']}/{c['id']}' has no nav_depth declared — "
|
|
2973
|
+
"how many clicks from the app entry point does this feature take to reach?"
|
|
2974
|
+
)
|
|
2975
|
+
elif depth > _nav_depth_threshold():
|
|
2976
|
+
findings.append(
|
|
2977
|
+
f"Nav depth (advisory): '{mod['name']}/{c['id']}' declares nav_depth={depth}, "
|
|
2978
|
+
f"past the {_nav_depth_threshold()}-click threshold — consider surfacing it closer to entry"
|
|
2979
|
+
)
|
|
2980
|
+
|
|
2981
|
+
_wave_record(pcp_dir, wave_number, "nav-depth", "CTRL-025", findings, files=checked, result="pass")
|
|
2982
|
+
for f in findings:
|
|
2983
|
+
console.print(f"[yellow]{f}[/yellow]")
|
|
2984
|
+
return findings
|
|
2985
|
+
|
|
2986
|
+
|
|
2987
|
+
def _run_wave_menu_bar_check(pcp_dir: Path, wave_modules: list[dict], wave_number: int) -> list[str]:
|
|
2988
|
+
"""CTRL-027, ADVISORY, desktop_app archetype only. Stays completely
|
|
2989
|
+
inert -- never even records a telemetry entry -- unless a human has
|
|
2990
|
+
explicitly set ui_archetype: desktop_app in .pcp/design_conventions.yaml
|
|
2991
|
+
(default web_app). A File/Edit/View/Help-style top menu bar is a
|
|
2992
|
+
desktop-app convention, not a universal one; running this
|
|
2993
|
+
unconditionally on every project would false-positive on every
|
|
2994
|
+
dashboard/SaaS-shaped product PCP builds."""
|
|
2995
|
+
conventions_path = pcp_dir / "design_conventions.yaml"
|
|
2996
|
+
if not conventions_path.exists():
|
|
2997
|
+
return []
|
|
2998
|
+
try:
|
|
2999
|
+
conventions = load_yaml(conventions_path) or {}
|
|
3000
|
+
except Exception:
|
|
3001
|
+
return []
|
|
3002
|
+
if conventions.get("ui_archetype") != "desktop_app":
|
|
3003
|
+
return []
|
|
3004
|
+
required = (conventions.get("top_menu_bar") or {}).get("required_menus") or ["File", "Edit", "View", "Help"]
|
|
3005
|
+
|
|
3006
|
+
project_root = pcp_dir.parent
|
|
3007
|
+
found_labels: set[str] = set()
|
|
3008
|
+
checked: list[str] = []
|
|
3009
|
+
for mod in wave_modules:
|
|
3010
|
+
acc_path = pcp_dir / "strategy" / "modules" / mod["name"] / "acceptance.yaml"
|
|
3011
|
+
if not acc_path.exists():
|
|
3012
|
+
continue
|
|
3013
|
+
acc = load_yaml(acc_path)
|
|
3014
|
+
for c in acc.get("criteria", []):
|
|
3015
|
+
if c.get("status") != "complete" or not _is_ui_facing_criterion(c):
|
|
3016
|
+
continue
|
|
3017
|
+
target = c.get("target")
|
|
3018
|
+
if not target:
|
|
3019
|
+
continue
|
|
3020
|
+
full_path = project_root / target
|
|
3021
|
+
if not full_path.is_file():
|
|
3022
|
+
continue
|
|
3023
|
+
checked.append(target)
|
|
3024
|
+
content = full_path.read_text(errors="replace")
|
|
3025
|
+
for label in required:
|
|
3026
|
+
if label in content:
|
|
3027
|
+
found_labels.add(label)
|
|
3028
|
+
|
|
3029
|
+
missing = [m for m in required if m not in found_labels]
|
|
3030
|
+
findings = []
|
|
3031
|
+
if missing and checked:
|
|
3032
|
+
findings.append(
|
|
3033
|
+
f"Top menu bar (advisory): ui_archetype=desktop_app declares required menus "
|
|
3034
|
+
f"{required}, but {', '.join(missing)} not found in any scanned UI-facing target file"
|
|
3035
|
+
)
|
|
3036
|
+
_wave_record(pcp_dir, wave_number, "menu-bar", "CTRL-027", findings, files=checked, result="pass")
|
|
3037
|
+
for f in findings:
|
|
3038
|
+
console.print(f"[yellow]{f}[/yellow]")
|
|
3039
|
+
return findings
|
|
3040
|
+
|
|
3041
|
+
|
|
3042
|
+
def _run_wave_ui_kit_check(pcp_dir: Path, wave_modules: list[dict], wave_number: int) -> list[str]:
|
|
3043
|
+
"""CTRL-028, ADVISORY. Stays completely inert (no telemetry record at
|
|
3044
|
+
all) unless .pcp/ui_kit_recipes.yaml exists -- same posture as CTRL-027's
|
|
3045
|
+
ui_archetype gate. Two checks, both deterministic substring matches, no
|
|
3046
|
+
LLM:
|
|
3047
|
+
|
|
3048
|
+
1. Recipe completeness: a criterion declaring screen_archetypes should
|
|
3049
|
+
show, among its own ui_organisms, the organisms that archetype's
|
|
3050
|
+
recipe requires. Catches "declared dashboard, didn't include a
|
|
3051
|
+
chart-panel or data-table" -- a criterion claiming an archetype
|
|
3052
|
+
without actually building what that archetype needs.
|
|
3053
|
+
|
|
3054
|
+
2. Import verification: a declared ui_organism should have a matching
|
|
3055
|
+
import in the criterion's own target file, per the recipe's
|
|
3056
|
+
organism -> import_path_hint mapping. This is the whole point of
|
|
3057
|
+
vendoring real component code (shadcn/ui) instead of prose guidance
|
|
3058
|
+
-- usage becomes checkable the same way CTRL-019 already checks
|
|
3059
|
+
logic_tier mechanism presence via import scanning, not a claim taken
|
|
3060
|
+
on faith.
|
|
3061
|
+
|
|
3062
|
+
Both findings are advisory -- an organism can legitimately come from a
|
|
3063
|
+
different import path (a re-exported wrapper, a renamed local alias),
|
|
3064
|
+
so this is a signal for review, not proof of non-use."""
|
|
3065
|
+
recipes_path = pcp_dir / "ui_kit_recipes.yaml"
|
|
3066
|
+
if not recipes_path.exists():
|
|
3067
|
+
return []
|
|
3068
|
+
try:
|
|
3069
|
+
recipes = load_yaml(recipes_path) or {}
|
|
3070
|
+
except Exception:
|
|
3071
|
+
return []
|
|
3072
|
+
organism_map = recipes.get("organisms") or {}
|
|
3073
|
+
archetype_map = recipes.get("archetypes") or {}
|
|
3074
|
+
|
|
3075
|
+
project_root = pcp_dir.parent
|
|
3076
|
+
findings: list[str] = []
|
|
3077
|
+
checked: list[str] = []
|
|
3078
|
+
|
|
3079
|
+
for mod in wave_modules:
|
|
3080
|
+
acc_path = pcp_dir / "strategy" / "modules" / mod["name"] / "acceptance.yaml"
|
|
3081
|
+
if not acc_path.exists():
|
|
3082
|
+
continue
|
|
3083
|
+
acc = load_yaml(acc_path)
|
|
3084
|
+
for c in acc.get("criteria", []):
|
|
3085
|
+
if c.get("status") != "complete" or not _is_ui_facing_criterion(c):
|
|
3086
|
+
continue
|
|
3087
|
+
declared_organisms = set(c.get("ui_organisms") or [])
|
|
3088
|
+
archetypes = c.get("screen_archetypes") or []
|
|
3089
|
+
|
|
3090
|
+
for archetype in archetypes:
|
|
3091
|
+
required = set(archetype_map.get(archetype) or [])
|
|
3092
|
+
missing_for_archetype = required - declared_organisms
|
|
3093
|
+
if missing_for_archetype:
|
|
3094
|
+
findings.append(
|
|
3095
|
+
f"UI kit (advisory): '{mod['name']}/{c['id']}' declares screen_archetypes="
|
|
3096
|
+
f"[{archetype}] but its ui_organisms is missing {sorted(missing_for_archetype)} "
|
|
3097
|
+
"from that archetype's recipe"
|
|
3098
|
+
)
|
|
3099
|
+
|
|
3100
|
+
target = c.get("target")
|
|
3101
|
+
if not target or not declared_organisms:
|
|
3102
|
+
continue
|
|
3103
|
+
full_path = project_root / target
|
|
3104
|
+
if not full_path.is_file():
|
|
3105
|
+
continue
|
|
3106
|
+
checked.append(target)
|
|
3107
|
+
content = full_path.read_text(errors="replace")
|
|
3108
|
+
for organism in declared_organisms:
|
|
3109
|
+
hint = (organism_map.get(organism) or {}).get("import_path_hint")
|
|
3110
|
+
if hint and hint not in content:
|
|
3111
|
+
findings.append(
|
|
3112
|
+
f"UI kit (advisory): '{mod['name']}/{c['id']}' declares ui_organisms "
|
|
3113
|
+
f"including '{organism}' but {target} shows no import matching "
|
|
3114
|
+
f"'{hint}' -- declaration may be unverified"
|
|
3115
|
+
)
|
|
3116
|
+
|
|
3117
|
+
_wave_record(pcp_dir, wave_number, "ui-kit", "CTRL-028", findings, files=checked, result="pass")
|
|
3118
|
+
for f in findings:
|
|
3119
|
+
console.print(f"[yellow]{f}[/yellow]")
|
|
3120
|
+
return findings
|
|
3121
|
+
|
|
3122
|
+
|
|
3123
|
+
RUNG_NECESSITY_SYSTEM_PROMPT = (
|
|
3124
|
+
"You audit logic-tier declarations for over-use of LLM reasoning. For each numbered "
|
|
3125
|
+
"criterion (all declared rung 6 = deep-think LLM, last resort), answer: could a CHEAPER "
|
|
3126
|
+
"rung correctly make this decision — 1 fixed rules/lookup, 2 solver, 3 trained model, "
|
|
3127
|
+
"4 retrieval over a bounded corpus, 5 cached replay? The rung-6 test is: would two "
|
|
3128
|
+
"competent humans reasonably disagree on the correct output? If they would NOT (the "
|
|
3129
|
+
"answer is mechanically derivable), rung 6 is over-declared. Respond JSON only: "
|
|
3130
|
+
'{"verdicts": [{"index": 0, "over_declared": false, "cheaper_rung": null, "reason": "..."}]} '
|
|
3131
|
+
"— one entry per criterion, in order. Default over_declared=false when genuinely uncertain."
|
|
3132
|
+
)
|
|
3133
|
+
|
|
3134
|
+
|
|
3135
|
+
def _run_wave_rung_necessity_check(pcp_dir: Path, wave_modules: list[dict], wave_number: int) -> list[str]:
|
|
3136
|
+
"""8th wave-merge sub-check, CTRL-020, ADVISORY — the deferred "Decision
|
|
3137
|
+
Integrity" half: nothing previously challenged a criterion lazily
|
|
3138
|
+
declared rung 6 that a truth table could serve. Two layers, cheapest
|
|
3139
|
+
first:
|
|
3140
|
+
- Deterministic: rung-1 declarations whose description contains
|
|
3141
|
+
judgment-shaped language (summarize/recommend/interpret…) — a
|
|
3142
|
+
contradiction needing zero LLM.
|
|
3143
|
+
- LLM (ONE batched Haiku call per wave, rung-6 criteria only — Token
|
|
3144
|
+
Discipline): "did this genuinely need rung 6" is the one irreducibly
|
|
3145
|
+
semantic gate in the ladder, so it gets the same judge treatment as
|
|
3146
|
+
coverage_score, advisory + recorded, never trusted blindly and never
|
|
3147
|
+
blocking. Surfaced in architecture_justification via telemetry."""
|
|
3148
|
+
findings: list[str] = []
|
|
3149
|
+
rung6: list[tuple[str, str, str]] = [] # (module, id, description)
|
|
3150
|
+
|
|
3151
|
+
for mod in wave_modules:
|
|
3152
|
+
acc_path = pcp_dir / "strategy" / "modules" / mod["name"] / "acceptance.yaml"
|
|
3153
|
+
if not acc_path.exists():
|
|
3154
|
+
continue
|
|
3155
|
+
acc = load_yaml(acc_path)
|
|
3156
|
+
for c in acc.get("criteria", []):
|
|
3157
|
+
if c.get("status") != "complete":
|
|
3158
|
+
continue
|
|
3159
|
+
tier = c.get("logic_tier")
|
|
3160
|
+
desc = c.get("description", "")
|
|
3161
|
+
if tier == 1:
|
|
3162
|
+
hits = [k for k in _JUDGMENT_KEYWORDS if k in desc.lower()]
|
|
3163
|
+
if hits:
|
|
3164
|
+
findings.append(
|
|
3165
|
+
f"Rung necessity (advisory): '{mod['name']}/{c['id']}' declares logic_tier=1 "
|
|
3166
|
+
f"(fixed rules, no judgment) but its description contains judgment-shaped "
|
|
3167
|
+
f"language ({hits[0]!r}) — tier may be under-declared"
|
|
3168
|
+
)
|
|
3169
|
+
elif tier == 6:
|
|
3170
|
+
rung6.append((mod["name"], c["id"], desc))
|
|
3171
|
+
|
|
3172
|
+
if rung6:
|
|
3173
|
+
numbered = "\n".join(f"[{i}] {m}/{cid}: {desc}" for i, (m, cid, desc) in enumerate(rung6))
|
|
3174
|
+
try:
|
|
3175
|
+
res = llm.call_json(
|
|
3176
|
+
RUNG_NECESSITY_SYSTEM_PROMPT, numbered, model=llm.JUDGE_MODEL,
|
|
3177
|
+
pcp_dir=pcp_dir, command="wave-rung-necessity",
|
|
3178
|
+
)
|
|
3179
|
+
for v in res.get("verdicts", []):
|
|
3180
|
+
if isinstance(v, dict) and v.get("over_declared"):
|
|
3181
|
+
i = v.get("index")
|
|
3182
|
+
if isinstance(i, int) and 0 <= i < len(rung6):
|
|
3183
|
+
m, cid, _ = rung6[i]
|
|
3184
|
+
findings.append(
|
|
3185
|
+
f"Rung necessity (advisory): '{m}/{cid}' declared rung 6 but judge "
|
|
3186
|
+
f"assesses rung {v.get('cheaper_rung')} could serve: {v.get('reason', '')[:200]}"
|
|
3187
|
+
)
|
|
3188
|
+
except Exception as e:
|
|
3189
|
+
console.print(f"[dim]Rung-necessity judge call failed (advisory check skipped): {e}[/dim]")
|
|
3190
|
+
|
|
3191
|
+
_wave_record(pcp_dir, wave_number, "rung-necessity", "CTRL-020", findings,
|
|
3192
|
+
files=[], result="pass")
|
|
3193
|
+
for f in findings:
|
|
3194
|
+
console.print(f"[yellow]{f}[/yellow]")
|
|
3195
|
+
return findings
|
|
3196
|
+
|
|
3197
|
+
|
|
3198
|
+
def _record_escalation(pcp_dir: Path, module_name: str, criterion_id: str, block_findings: list[str]) -> None:
|
|
3199
|
+
"""Route this criterion's final-attempt failure through OPA's escalation
|
|
3200
|
+
policy (.pcp/policies/escalation.rego) -- advisory only, doesn't change
|
|
3201
|
+
control flow (a 3rd-attempt failure already stops the build and hands
|
|
3202
|
+
back to a human either way). Confidence is a simple proxy: how many
|
|
3203
|
+
distinct gate categories still had violations on the last attempt.
|
|
3204
|
+
high_stakes fires on any SEC_* (secrets/eval/sql-injection) finding --
|
|
3205
|
+
those should never be treated as routine, low-stakes retries.
|
|
3206
|
+
|
|
3207
|
+
Degrades silently if opa isn't installed or no escalation.rego is
|
|
3208
|
+
scaffolded -- this is informational, never a hard dependency on OPA.
|
|
3209
|
+
|
|
3210
|
+
Regardless of OPA availability, the escalation itself is appended to
|
|
3211
|
+
.pcp/escalations.yaml -- the staleness watchdog (escalations.find_stale,
|
|
3212
|
+
surfaced by `pcp watch` and `pcp status`) needs a ledger that exists on
|
|
3213
|
+
every project, not only ones with opa installed. Recording an escalation
|
|
3214
|
+
and a human actually seeing it are different facts; the ledger is what
|
|
3215
|
+
lets the second one be checked."""
|
|
3216
|
+
from pcp import escalations, policy
|
|
3217
|
+
|
|
3218
|
+
escalations.record(pcp_dir, module_name, criterion_id, findings=block_findings)
|
|
3219
|
+
gate_categories = 6 # test-suite, lint, sast, layer1, architect-review, gate
|
|
3220
|
+
distinct_violations = len(block_findings)
|
|
3221
|
+
confidence_score = max(0.0, 1.0 - (distinct_violations / gate_categories))
|
|
3222
|
+
high_stakes = any(f.startswith("File Rule [SEC_") or f.startswith("AST Rule [SEC_") for f in block_findings)
|
|
3223
|
+
|
|
3224
|
+
decision = policy.evaluate(
|
|
3225
|
+
pcp_dir, "data.pcp.escalation.route",
|
|
3226
|
+
{"confidence_score": confidence_score, "high_stakes": high_stakes},
|
|
3227
|
+
)
|
|
3228
|
+
if not decision.get("available") or decision.get("undefined"):
|
|
3229
|
+
return
|
|
3230
|
+
|
|
3231
|
+
route = decision.get("value", "human")
|
|
3232
|
+
console.print(
|
|
3233
|
+
f"[dim]Escalation policy: route={route} "
|
|
3234
|
+
f"(confidence={confidence_score:.2f}, high_stakes={high_stakes})[/dim]"
|
|
3235
|
+
)
|
|
3236
|
+
with _STATE_LOCK:
|
|
3237
|
+
telemetry.record(
|
|
3238
|
+
pcp_dir, cycle="qa", cycle_number=None, check="escalation",
|
|
3239
|
+
module=module_name, submodule=None, criterion_id=criterion_id,
|
|
3240
|
+
files=[], result="pass", errors=[f"route={route}"], error_count=0,
|
|
3241
|
+
)
|
|
3242
|
+
|
|
3243
|
+
|
|
3244
|
+
_COMPLEXITY_KEYWORDS = (
|
|
3245
|
+
"integrat", "concurren", "parallel", "migrat", "auth", "encrypt", "distributed",
|
|
3246
|
+
"real-time", "realtime", "websocket", "transaction", "cache invalidat", "state machine",
|
|
3247
|
+
)
|
|
3248
|
+
|
|
3249
|
+
|
|
3250
|
+
def _complexity_route(pcp_dir: Path, mod: dict, c: dict) -> tuple[bool, dict]:
|
|
3251
|
+
"""Deterministic pre-attempt-1 complexity signal (2026-07-17). Routing
|
|
3252
|
+
beats cascading — a cascade pays the cheap model's cost BEFORE the
|
|
3253
|
+
escalation decision ("Is Escalation Worth It?", arXiv:2605.06350) — but
|
|
3254
|
+
PCP has no learned router yet, so this is a rung-1 heuristic: description
|
|
3255
|
+
length, complexity keywords, module dependency count, and this module's
|
|
3256
|
+
own historical retry rate from telemetry (bandit-ish: only outcomes PCP
|
|
3257
|
+
actually observed, the BaRP framing).
|
|
3258
|
+
|
|
3259
|
+
REPORT-FIRST rollout (standing rule): by default this only records what
|
|
3260
|
+
it WOULD do (telemetry check="complexity-route", result="pass"); routing
|
|
3261
|
+
only takes effect with PCP_COMPLEXITY_ROUTING=1. Returns
|
|
3262
|
+
(route_to_escalation_model, signal_dict)."""
|
|
3263
|
+
desc = c.get("description", "")
|
|
3264
|
+
score = 0.0
|
|
3265
|
+
if len(desc) > 200:
|
|
3266
|
+
score += 1
|
|
3267
|
+
hits = [k for k in _COMPLEXITY_KEYWORDS if k in desc.lower()]
|
|
3268
|
+
score += min(len(hits), 3)
|
|
3269
|
+
deps = (mod.get("spec") or {}).get("dependencies") or []
|
|
3270
|
+
if len(deps) >= 2:
|
|
3271
|
+
score += 1
|
|
3272
|
+
# historical: this module's build records — retries per criterion
|
|
3273
|
+
module_builds = [r for r in telemetry.load(pcp_dir)
|
|
3274
|
+
if r.get("cycle") == "build" and r.get("module") == mod["name"]]
|
|
3275
|
+
retries = sum(1 for r in module_builds if (r.get("cycle_number") or 1) > 1)
|
|
3276
|
+
if module_builds and retries / max(len(module_builds), 1) > 0.4:
|
|
3277
|
+
score += 2
|
|
3278
|
+
route = score >= 3
|
|
3279
|
+
return route, {"score": score, "keyword_hits": hits, "deps": len(deps),
|
|
3280
|
+
"module_retry_ratio": round(retries / max(len(module_builds), 1), 2) if module_builds else 0.0}
|
|
3281
|
+
|
|
3282
|
+
|
|
3283
|
+
_ARCHITECT_PREFLIGHT_SYSTEM_PROMPT = """\
|
|
3284
|
+
You are a software architect doing a PRE-IMPLEMENTATION sanity check — no code exists yet for this criterion. \
|
|
3285
|
+
Review the PLANNED approach (its description, declared logic_tier, declared build_vs_buy decision, and the \
|
|
3286
|
+
module it belongs to) for genuine red flags before any code is written: a declared logic_tier that contradicts \
|
|
3287
|
+
the description's own language (e.g. rung 1 "deterministic" but the description asks for judgment/summarization), \
|
|
3288
|
+
a declared build_vs_buy that conflicts with what the module's dependencies/constraints already establish, or a \
|
|
3289
|
+
plan that looks structurally unsound given the architecture doc. Do NOT invent hypothetical implementation \
|
|
3290
|
+
mistakes that haven't happened yet — only flag concerns groundable in the declared fields themselves. \
|
|
3291
|
+
Output ONLY valid JSON: {"findings": [{"concern": "...", "suggestion": "..."}]}. Empty list if nothing to flag."""
|
|
3292
|
+
|
|
3293
|
+
|
|
3294
|
+
def _run_architect_preflight(pcp_dir: Path, mod: dict, criterion: dict) -> list[str]:
|
|
3295
|
+
"""Architect pre-flight (swarm-role backlog, 2026-07-20): PCP's existing
|
|
3296
|
+
architect-review (_run_architect_review) is POST-HOC only -- it reviews
|
|
3297
|
+
the diff after code is written. This is the genuinely new lifecycle
|
|
3298
|
+
point the backlog named: a pre-implementation consult, before any code
|
|
3299
|
+
exists, for HIGH-RISK criteria only (logic_tier >= 5, or a criterion-
|
|
3300
|
+
level build_vs_buy of reuse_whole/fork_adapt -- a real external-
|
|
3301
|
+
dependency commitment worth a second look before it's acted on).
|
|
3302
|
+
|
|
3303
|
+
Advisory in this pass, NOT the block_findings channel the backlog
|
|
3304
|
+
sketched -- PCP's attempt loop has no separate "submit a plan, then
|
|
3305
|
+
code" step (one agent session does both), so wiring this into
|
|
3306
|
+
block_findings would mean skipping a whole attempt with no code
|
|
3307
|
+
written, a real behavior change to a heavily-relied-on 3-attempt
|
|
3308
|
+
contract. Same L1-report-first rollout discipline as every other new
|
|
3309
|
+
check in this catalog: advisory now, upgrade to blocking only after a
|
|
3310
|
+
measured false-positive rate earns it. Returns lines to inject into the
|
|
3311
|
+
criterion's own attempt-1 prompt (empty if not high-risk or nothing to flag)."""
|
|
3312
|
+
tier = criterion.get("logic_tier")
|
|
3313
|
+
bvb_decision = (criterion.get("build_vs_buy") or {}).get("decision")
|
|
3314
|
+
high_risk = (isinstance(tier, int) and tier >= 5) or bvb_decision in ("reuse_whole", "fork_adapt")
|
|
3315
|
+
if not high_risk:
|
|
3316
|
+
return []
|
|
3317
|
+
|
|
3318
|
+
spec_summary = {
|
|
3319
|
+
"module": mod["name"], "description": mod.get("spec", {}).get("description", ""),
|
|
3320
|
+
"dependencies": mod.get("spec", {}).get("dependencies", []),
|
|
3321
|
+
"constraints": mod.get("spec", {}).get("constraints", []),
|
|
3322
|
+
}
|
|
3323
|
+
user_prompt = (
|
|
3324
|
+
f"Criterion: [{criterion.get('id')}] {criterion.get('description', '')}\n"
|
|
3325
|
+
f"Declared logic_tier: {tier}\n"
|
|
3326
|
+
f"Declared build_vs_buy: {criterion.get('build_vs_buy')}\n"
|
|
3327
|
+
f"Module context: {json.dumps(spec_summary, default=str)}"
|
|
3328
|
+
)
|
|
3329
|
+
try:
|
|
3330
|
+
res = llm.call_json(
|
|
3331
|
+
_ARCHITECT_PREFLIGHT_SYSTEM_PROMPT, user_prompt,
|
|
3332
|
+
model=llm.JUDGE_MODEL, pcp_dir=pcp_dir, command="architect-preflight",
|
|
3333
|
+
)
|
|
3334
|
+
except Exception as e:
|
|
3335
|
+
console.print(f"[yellow]Warning: Architect pre-flight call failed: {e}[/yellow]")
|
|
3336
|
+
return []
|
|
3337
|
+
|
|
3338
|
+
findings = res.get("findings", []) if isinstance(res, dict) else []
|
|
3339
|
+
ctx = {"module": mod["name"], "submodule": None, "criterion_id": criterion.get("id"), "attempt": 0, "files": []}
|
|
3340
|
+
rendered = [f"{f.get('concern', '')} — {f.get('suggestion', '')}" for f in findings if f.get("concern")]
|
|
3341
|
+
evidence_path = evidence.store(
|
|
3342
|
+
pcp_dir, ctx["module"], ctx["criterion_id"], 0, "architect-preflight",
|
|
3343
|
+
"\n".join(rendered) if rendered else "no pre-flight concerns",
|
|
3344
|
+
)
|
|
3345
|
+
_qa_record(
|
|
3346
|
+
pcp_dir, ctx, "architect-preflight", rendered, control_id="CTRL-032", tool="judge-model",
|
|
3347
|
+
result="pass", evidence_path=evidence_path,
|
|
3348
|
+
)
|
|
3349
|
+
if rendered:
|
|
3350
|
+
console.print(f"[yellow]Architect pre-flight (advisory):[/yellow] {rendered[0]}")
|
|
3351
|
+
return rendered
|
|
3352
|
+
|
|
3353
|
+
|
|
3354
|
+
def _run_install_only(
|
|
3355
|
+
pcp_dir: Path, project_root: Path, mod: dict, *,
|
|
3356
|
+
criterion: dict | None, install_command: str, candidate_desc: str, yes: bool,
|
|
3357
|
+
budget: "_BuildBudget",
|
|
3358
|
+
) -> tuple[bool, list[str]]:
|
|
3359
|
+
"""Fast path for a human-confirmed direct prior-art match — skip the full
|
|
3360
|
+
TDD/architect-review/LLM-gate cycle entirely, just install + verify with
|
|
3361
|
+
deterministic checks (full test suite + Layer 1 ci_rules — CTRL-034, no
|
|
3362
|
+
LLM calls). criterion=None means module-level (whole module satisfied by
|
|
3363
|
+
one dependency, see spec.yaml's install_only). This is never a silent
|
|
3364
|
+
skip: declining the approval prompt, or a failed smoke test, both fall
|
|
3365
|
+
through to the normal full build path unchanged — the caller decides
|
|
3366
|
+
what "fall through" means at its own scope (retry the one criterion, or
|
|
3367
|
+
resume the module's normal per-criterion loop)."""
|
|
3368
|
+
scope_label = f"{mod['name']}/{criterion['id']}" if criterion else f"{mod['name']} (whole module)"
|
|
3369
|
+
console.print(f"\n[bold]Install-only fast path — {scope_label}[/bold]")
|
|
3370
|
+
console.print(f"[dim]Candidate:[/dim] {candidate_desc}")
|
|
3371
|
+
console.print(f"[dim]Install command:[/dim] {install_command}")
|
|
3372
|
+
|
|
3373
|
+
criterion_id = criterion["id"] if criterion else None
|
|
3374
|
+
if not yes:
|
|
3375
|
+
if not click.confirm("Confirm this is a direct match and proceed with install-only?", default=False):
|
|
3376
|
+
console.print("[yellow]Declined — falling through to full build.[/yellow]")
|
|
3377
|
+
log_install_approval(
|
|
3378
|
+
pcp_dir, module=mod["name"], criterion_id=criterion_id,
|
|
3379
|
+
candidate=candidate_desc, install_command=install_command,
|
|
3380
|
+
decision="reject", actor="human",
|
|
3381
|
+
)
|
|
3382
|
+
return False, ["human declined install-only approval"]
|
|
3383
|
+
actor = "human"
|
|
3384
|
+
else:
|
|
3385
|
+
actor = "yes-flag"
|
|
3386
|
+
|
|
3387
|
+
log_install_approval(
|
|
3388
|
+
pcp_dir, module=mod["name"], criterion_id=criterion_id,
|
|
3389
|
+
candidate=candidate_desc, install_command=install_command,
|
|
3390
|
+
decision="confirm", actor=actor,
|
|
3391
|
+
)
|
|
3392
|
+
|
|
3393
|
+
start_ref = _git_head(project_root)
|
|
3394
|
+
try:
|
|
3395
|
+
result = subprocess.run(
|
|
3396
|
+
install_command, shell=True, cwd=project_root,
|
|
3397
|
+
capture_output=True, text=True, timeout=_build_agent_timeout_sec(),
|
|
3398
|
+
)
|
|
3399
|
+
except subprocess.TimeoutExpired:
|
|
3400
|
+
return False, [f"install_command timed out after {_build_agent_timeout_sec()}s"]
|
|
3401
|
+
|
|
3402
|
+
changed_files = _get_changed_files_since(project_root, start_ref)
|
|
3403
|
+
ctx = {
|
|
3404
|
+
"module": mod["name"], "submodule": None,
|
|
3405
|
+
"criterion_id": criterion_id or "MODULE",
|
|
3406
|
+
"attempt": 1, "files": changed_files,
|
|
3407
|
+
}
|
|
3408
|
+
|
|
3409
|
+
if result.returncode != 0:
|
|
3410
|
+
errors = [f"install_command failed (exit {result.returncode}): {(result.stderr or '')[-1000:]}"]
|
|
3411
|
+
_qa_record(pcp_dir, ctx, "install-only", errors, control_id="CTRL-034", tool="install", result="block")
|
|
3412
|
+
console.print(f"[red]Install failed:[/red] {errors[0]}")
|
|
3413
|
+
return False, errors
|
|
3414
|
+
|
|
3415
|
+
violations = _run_layer1_check(pcp_dir, project_root, changed_files, ctx)
|
|
3416
|
+
violations += _run_test_suite_check(pcp_dir, project_root, ctx)
|
|
3417
|
+
# SAST added 2026-07-27. This fast path exists precisely to pull in
|
|
3418
|
+
# THIRD-PARTY code on a human's say-so, which makes it the single place a
|
|
3419
|
+
# supply-chain problem is most likely to enter — and it was the one path
|
|
3420
|
+
# that skipped the secret/SAST scan entirely. The LLM gates are genuinely
|
|
3421
|
+
# not worth running here (there is no agent-written diff to review, which
|
|
3422
|
+
# is the whole point of the fast path), but a deterministic scan of what
|
|
3423
|
+
# the install actually put on disk costs one semgrep run and is exactly
|
|
3424
|
+
# the check this path most needs. Cheap, deterministic, no LLM calls —
|
|
3425
|
+
# consistent with CTRL-034's "skip the expensive cycle, keep the
|
|
3426
|
+
# verification" posture.
|
|
3427
|
+
violations += _run_sast_check(pcp_dir, project_root, changed_files, ctx, budget)
|
|
3428
|
+
|
|
3429
|
+
if violations:
|
|
3430
|
+
console.print("[red]Install-only smoke test failed — falling through to full build.[/red]")
|
|
3431
|
+
_qa_record(pcp_dir, ctx, "install-only", violations, control_id="CTRL-034", tool="install", result="block")
|
|
3432
|
+
return False, violations
|
|
3433
|
+
|
|
3434
|
+
_qa_record(pcp_dir, ctx, "install-only", [], control_id="CTRL-034", tool="install", result="pass")
|
|
3435
|
+
console.print(f"[green]✓ Install-only fast path passed — {scope_label}[/green]")
|
|
3436
|
+
_auto_commit_criterion(project_root, mod["name"], criterion or {"id": "MODULE", "description": candidate_desc})
|
|
3437
|
+
return True, []
|
|
3438
|
+
|
|
3439
|
+
|
|
3440
|
+
def _build_one_criterion(
|
|
3441
|
+
pcp_dir: Path, project_root: Path, mod: dict, c: dict,
|
|
3442
|
+
build_model: str | None, build_model_explicit: bool, budget: "_BuildBudget",
|
|
3443
|
+
yes: bool = False, module_wave_number: int | None = None, criterion_wave_number: int | None = None,
|
|
3444
|
+
) -> tuple[bool, list[str]]:
|
|
3445
|
+
"""Runs the up-to-3-attempt loop for ONE criterion. `project_root` is
|
|
3446
|
+
where the coding agent actually runs and where gates are evaluated —
|
|
3447
|
+
either the main project root (serial/single-module path) or a per-module
|
|
3448
|
+
git worktree (parallel path). Shared-file writes (telemetry, cost ledger,
|
|
3449
|
+
capture) always target the real `pcp_dir`, never a worktree copy, and are
|
|
3450
|
+
internally guarded by `_STATE_LOCK` (see _qa_record/_log_usage call
|
|
3451
|
+
sites) — never held across gate evaluation itself, since the LLM calls
|
|
3452
|
+
and test/lint/SAST subprocesses are exactly the work parallelism exists
|
|
3453
|
+
to overlap. Returns (success, last block_findings)."""
|
|
3454
|
+
if c.get("install_only"):
|
|
3455
|
+
install_command = c.get("install_command")
|
|
3456
|
+
if not install_command:
|
|
3457
|
+
console.print(f"[red]{mod['name']}/{c['id']} declares install_only but has no install_command — falling through to full build.[/red]")
|
|
3458
|
+
else:
|
|
3459
|
+
candidate_desc = (c.get("build_vs_buy") or {}).get("rationale") or install_command
|
|
3460
|
+
ok, findings = _run_install_only(
|
|
3461
|
+
pcp_dir, project_root, mod, criterion=c,
|
|
3462
|
+
install_command=install_command, candidate_desc=candidate_desc, yes=yes,
|
|
3463
|
+
budget=budget,
|
|
3464
|
+
)
|
|
3465
|
+
if ok:
|
|
3466
|
+
return True, []
|
|
3467
|
+
# Falls through to the normal full build loop below — a
|
|
3468
|
+
# declined approval or failed smoke test is a real signal
|
|
3469
|
+
# this wasn't actually a direct match, not a reason to give up.
|
|
3470
|
+
|
|
3471
|
+
feedback = None
|
|
3472
|
+
success = False
|
|
3473
|
+
block_findings: list[str] = []
|
|
3474
|
+
attempt_history: list[str] = []
|
|
3475
|
+
agent_session_id = str(uuid.uuid4())
|
|
3476
|
+
# Everything the agent does this criterion — committed or not — is
|
|
3477
|
+
# measured against this ref, so committing can't hide work from gates.
|
|
3478
|
+
criterion_start_ref = _git_head(project_root)
|
|
3479
|
+
|
|
3480
|
+
# run_log bracket — pre/post audit entry, actor="pcp-build-agent" so this
|
|
3481
|
+
# is queryable as real pipeline work, distinct from manual/interactive
|
|
3482
|
+
# runs bracketed via `pcp run-log`. Never blocks a real build on failure.
|
|
3483
|
+
run_log_id = None
|
|
3484
|
+
run_log_tokens = {"input": 0, "output": 0, "cache_read": 0, "cost": 0.0}
|
|
3485
|
+
run_log_last_checks: list[str] = []
|
|
3486
|
+
changed_files: list[str] = []
|
|
3487
|
+
try:
|
|
3488
|
+
wave_for_capsule = criterion_wave_number if criterion_wave_number is not None else module_wave_number
|
|
3489
|
+
internal_deps_real: list[str] = []
|
|
3490
|
+
hc_path = pcp_dir / "hidden_coupling.json"
|
|
3491
|
+
if hc_path.exists():
|
|
3492
|
+
try:
|
|
3493
|
+
for pair in json.loads(hc_path.read_text()):
|
|
3494
|
+
pair_modules = pair.get("modules") or []
|
|
3495
|
+
if mod["name"] in pair_modules:
|
|
3496
|
+
internal_deps_real.extend(m for m in pair_modules if m != mod["name"])
|
|
3497
|
+
except Exception:
|
|
3498
|
+
pass
|
|
3499
|
+
run_log_id = run_log.start_run(
|
|
3500
|
+
pcp_dir, module=mod["name"], feature=f"{c['id']}: {c.get('description', '')}",
|
|
3501
|
+
run_type="dev", actor="pcp-build-agent", model=build_model,
|
|
3502
|
+
criterion_id=c["id"], wave_number=wave_for_capsule,
|
|
3503
|
+
logic_tier=c.get("logic_tier"), build_vs_buy=(c.get("build_vs_buy") or {}).get("decision"),
|
|
3504
|
+
internal_deps_declared=c.get("depends_on") or [], internal_deps_real=internal_deps_real,
|
|
3505
|
+
)
|
|
3506
|
+
except Exception as e:
|
|
3507
|
+
console.print(f"[dim]run-log start skipped: {e}[/dim]")
|
|
3508
|
+
|
|
3509
|
+
# Complexity routing (report-first; see _complexity_route). Never
|
|
3510
|
+
# overrides an explicit human PCP_BUILD_MODEL.
|
|
3511
|
+
route_up, route_signal = _complexity_route(pcp_dir, mod, c)
|
|
3512
|
+
routing_active = os.environ.get("PCP_COMPLEXITY_ROUTING") == "1"
|
|
3513
|
+
with _STATE_LOCK:
|
|
3514
|
+
telemetry.record(
|
|
3515
|
+
pcp_dir, cycle="qa", cycle_number=0, check="complexity-route", control_id=None,
|
|
3516
|
+
module=mod["name"], submodule=None, criterion_id=c["id"], run_id=run_log_id, files=[],
|
|
3517
|
+
result="pass",
|
|
3518
|
+
errors=[f"would_route_to_escalation_model={route_up} active={routing_active} signal={route_signal}"],
|
|
3519
|
+
error_count=0,
|
|
3520
|
+
)
|
|
3521
|
+
if route_up and routing_active and not build_model_explicit:
|
|
3522
|
+
console.print(f"[dim]Complexity routing: starting on {llm.ESCALATION_MODEL} (signal {route_signal['score']}).[/dim]")
|
|
3523
|
+
build_model = llm.ESCALATION_MODEL
|
|
3524
|
+
|
|
3525
|
+
# Architect pre-flight (swarm-role backlog): one Haiku call, high-risk
|
|
3526
|
+
# criteria only, BEFORE any code exists. See _run_architect_preflight's
|
|
3527
|
+
# own docstring for why this stays advisory (prompt injection) rather
|
|
3528
|
+
# than the block_findings channel in this pass.
|
|
3529
|
+
preflight_lines = _run_architect_preflight(pcp_dir, mod, c)
|
|
3530
|
+
|
|
3531
|
+
for attempt in range(1, 4):
|
|
3532
|
+
console.print(f"\n[dim]Attempt {attempt}/3 — {mod['name']}/{c['id']}...[/dim]")
|
|
3533
|
+
_write_progress(pcp_dir, mod["name"], c["id"], attempt, "coding")
|
|
3534
|
+
|
|
3535
|
+
allowed, spend_reason = spend.check_ceiling(pcp_dir)
|
|
3536
|
+
if not allowed:
|
|
3537
|
+
console.print(f"[red bold]Project spend ceiling reached:[/red bold] {spend_reason}")
|
|
3538
|
+
console.print("[dim]No further agent sessions will be spawned this run.[/dim]")
|
|
3539
|
+
raise BudgetExceeded(spend_reason)
|
|
3540
|
+
|
|
3541
|
+
try:
|
|
3542
|
+
budget.take_session()
|
|
3543
|
+
except BudgetExceeded:
|
|
3544
|
+
console.print(
|
|
3545
|
+
f"[red bold]Budget circuit breaker: exceeded {budget.max_sessions} agent "
|
|
3546
|
+
"sessions this run.[/red bold]"
|
|
3547
|
+
)
|
|
3548
|
+
console.print("[dim]Override with PCP_MAX_BUILD_SESSIONS=<n> if this build genuinely needs more.[/dim]")
|
|
3549
|
+
raise
|
|
3550
|
+
|
|
3551
|
+
# Attempt 1 opens a fresh session; attempt 2 --resumes it (avoids
|
|
3552
|
+
# re-exploring the repo — Token Discipline). Attempt 3 (escalation)
|
|
3553
|
+
# deliberately does NOT resume: failed-attempt context contaminates
|
|
3554
|
+
# retries (CCRM, arXiv:2605.08563 — contaminated-context error rate
|
|
3555
|
+
# 7.1x baseline, "clean-restart dominance") — the escalated model gets
|
|
3556
|
+
# a FRESH session plus a structured summary of what failed, not the
|
|
3557
|
+
# raw failure trajectory (summarize-don't-replay, arXiv:2604.16529).
|
|
3558
|
+
if attempt == 1:
|
|
3559
|
+
agent_prompt = _build_agent_prompt(pcp_dir, mod["name"], c, mod["spec"])
|
|
3560
|
+
if preflight_lines:
|
|
3561
|
+
agent_prompt += "\n".join([
|
|
3562
|
+
"",
|
|
3563
|
+
"## Architect pre-flight concerns (advisory, raised before you started — address or explicitly reason past them):",
|
|
3564
|
+
*[f"- {line}" for line in preflight_lines],
|
|
3565
|
+
])
|
|
3566
|
+
session_flag = ["--session-id", agent_session_id]
|
|
3567
|
+
elif attempt == 2:
|
|
3568
|
+
agent_prompt = _build_retry_prompt(feedback)
|
|
3569
|
+
session_flag = ["--resume", agent_session_id]
|
|
3570
|
+
else:
|
|
3571
|
+
escalation_session_id = str(uuid.uuid4())
|
|
3572
|
+
agent_prompt = _build_escalation_prompt(pcp_dir, mod["name"], c, mod["spec"], attempt_history)
|
|
3573
|
+
session_flag = ["--session-id", escalation_session_id]
|
|
3574
|
+
agent_session_id = escalation_session_id
|
|
3575
|
+
|
|
3576
|
+
# Commit-trailer attribution: the installed commit-msg hook stamps
|
|
3577
|
+
# PCP-Agent-Session onto any commit made inside this subprocess —
|
|
3578
|
+
# set AFTER the escalation branch so attempt 3 carries its own id.
|
|
3579
|
+
os.environ["PCP_AGENT_SESSION_ID"] = agent_session_id
|
|
3580
|
+
|
|
3581
|
+
# Escalate to Opus on the final attempt -- two Sonnet attempts already
|
|
3582
|
+
# failed, a real complexity signal worth paying up for before handing
|
|
3583
|
+
# off to human escalation. Never overrides an explicit human choice:
|
|
3584
|
+
# PCP_BUILD_MODEL set on attempt 1 stays in effect on attempt 3 too,
|
|
3585
|
+
# rather than silently switching models without being asked.
|
|
3586
|
+
attempt_model = build_model
|
|
3587
|
+
if attempt == 3 and not build_model_explicit:
|
|
3588
|
+
attempt_model = llm.ESCALATION_MODEL
|
|
3589
|
+
|
|
3590
|
+
cmd = [
|
|
3591
|
+
_claude_bin(),
|
|
3592
|
+
"-p",
|
|
3593
|
+
"--permission-mode", "acceptEdits",
|
|
3594
|
+
"--output-format", "json",
|
|
3595
|
+
"--max-budget-usd", _build_agent_max_budget_usd(),
|
|
3596
|
+
*session_flag,
|
|
3597
|
+
]
|
|
3598
|
+
if attempt_model:
|
|
3599
|
+
cmd += ["--model", attempt_model]
|
|
3600
|
+
|
|
3601
|
+
# Run Claude agent — wall-clock capped. A stuck/looping agent must
|
|
3602
|
+
# not be able to run unbounded just because it hasn't returned yet.
|
|
3603
|
+
# start_new_session=True puts the child in its own process group so
|
|
3604
|
+
# the timeout handler below can kill it AND any descendants (a
|
|
3605
|
+
# background bash call, an MCP server the CLI spawned) with one
|
|
3606
|
+
# os.killpg -- plain subprocess.run(timeout=...) only kills the
|
|
3607
|
+
# direct `claude` child on timeout, orphaning everything under it
|
|
3608
|
+
# (same failure class as the AppleMDM testcontainers worktree gap).
|
|
3609
|
+
proc = subprocess.Popen(
|
|
3610
|
+
cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
3611
|
+
text=True, cwd=project_root, start_new_session=True,
|
|
3612
|
+
)
|
|
3613
|
+
try:
|
|
3614
|
+
stdout, stderr = proc.communicate(input=agent_prompt, timeout=_build_agent_timeout_sec())
|
|
3615
|
+
result = subprocess.CompletedProcess(cmd, proc.returncode, stdout, stderr)
|
|
3616
|
+
except subprocess.TimeoutExpired:
|
|
3617
|
+
try:
|
|
3618
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
3619
|
+
except ProcessLookupError:
|
|
3620
|
+
pass
|
|
3621
|
+
proc.communicate() # reap, discard -- process is already dead
|
|
3622
|
+
timeout_sec = _build_agent_timeout_sec()
|
|
3623
|
+
console.print(f"[red]Claude agent timed out after {timeout_sec}s.[/red]")
|
|
3624
|
+
feedback = f"Previous attempt exceeded the {timeout_sec}s per-attempt timeout and was killed."
|
|
3625
|
+
attempt_history.append(f"Attempt {attempt}: {feedback}")
|
|
3626
|
+
with _STATE_LOCK:
|
|
3627
|
+
telemetry.record(
|
|
3628
|
+
pcp_dir,
|
|
3629
|
+
cycle="build", cycle_number=attempt,
|
|
3630
|
+
module=mod["name"], submodule=None, criterion_id=c["id"], run_id=run_log_id,
|
|
3631
|
+
files=[], languages=[], lines_added=0, lines_removed=0,
|
|
3632
|
+
model=attempt_model, result="timeout", errors=[feedback],
|
|
3633
|
+
duration_ms=timeout_sec * 1000,
|
|
3634
|
+
)
|
|
3635
|
+
continue
|
|
3636
|
+
|
|
3637
|
+
if result.returncode != 0:
|
|
3638
|
+
console.print("[red]Claude agent exited with error.[/red]")
|
|
3639
|
+
feedback = "Claude CLI agent run failed or exited with non-zero code."
|
|
3640
|
+
attempt_history.append(f"Attempt {attempt}: {feedback}")
|
|
3641
|
+
continue
|
|
3642
|
+
|
|
3643
|
+
agent_usage = {}
|
|
3644
|
+
try:
|
|
3645
|
+
envelope = json.loads(result.stdout)
|
|
3646
|
+
if envelope.get("is_error"):
|
|
3647
|
+
console.print(f"[red]Claude agent reported an error:[/red] {envelope.get('result', '')}")
|
|
3648
|
+
feedback = f"Previous attempt errored: {envelope.get('result', '')}"
|
|
3649
|
+
attempt_history.append(f"Attempt {attempt}: {feedback[:500]}")
|
|
3650
|
+
continue
|
|
3651
|
+
with _STATE_LOCK:
|
|
3652
|
+
_log_usage(
|
|
3653
|
+
pcp_dir, "build-agent", attempt_model, envelope.get("session_id"),
|
|
3654
|
+
envelope.get("usage", {}), envelope.get("total_cost_usd"),
|
|
3655
|
+
)
|
|
3656
|
+
budget.add_cost(envelope.get("total_cost_usd"))
|
|
3657
|
+
agent_usage = {
|
|
3658
|
+
"model": attempt_model or "default",
|
|
3659
|
+
"session_id": envelope.get("session_id"),
|
|
3660
|
+
"usage": envelope.get("usage", {}),
|
|
3661
|
+
"cost_usd": envelope.get("total_cost_usd"),
|
|
3662
|
+
"duration_ms": envelope.get("duration_ms"),
|
|
3663
|
+
}
|
|
3664
|
+
_u = envelope.get("usage", {})
|
|
3665
|
+
run_log_tokens["input"] += _u.get("input_tokens", 0) + _u.get("cache_creation_input_tokens", 0)
|
|
3666
|
+
run_log_tokens["output"] += _u.get("output_tokens", 0)
|
|
3667
|
+
run_log_tokens["cache_read"] += _u.get("cache_read_input_tokens", 0)
|
|
3668
|
+
run_log_tokens["cost"] += envelope.get("total_cost_usd") or 0
|
|
3669
|
+
except (json.JSONDecodeError, TypeError):
|
|
3670
|
+
pass
|
|
3671
|
+
|
|
3672
|
+
# Run checks. PCP's own operational writes (token ledger, telemetry)
|
|
3673
|
+
# are not agent work product — never fed to gates or the scope guard.
|
|
3674
|
+
changed_files = [
|
|
3675
|
+
f for f in _get_changed_files_since(project_root, criterion_start_ref)
|
|
3676
|
+
if not _is_pcp_operational(f)
|
|
3677
|
+
]
|
|
3678
|
+
|
|
3679
|
+
if not changed_files:
|
|
3680
|
+
console.print("[yellow]No files were modified by the agent (committed or uncommitted).[/yellow]")
|
|
3681
|
+
|
|
3682
|
+
diff = _get_working_diff(project_root, criterion_start_ref)
|
|
3683
|
+
|
|
3684
|
+
lines_added, lines_removed = telemetry.count_diff_lines(diff)
|
|
3685
|
+
usage = agent_usage.get("usage", {})
|
|
3686
|
+
with _STATE_LOCK:
|
|
3687
|
+
telemetry.record(
|
|
3688
|
+
pcp_dir,
|
|
3689
|
+
cycle="build", cycle_number=attempt,
|
|
3690
|
+
module=mod["name"], submodule=None, criterion_id=c["id"], run_id=run_log_id,
|
|
3691
|
+
files=changed_files, languages=telemetry.infer_languages(changed_files),
|
|
3692
|
+
lines_added=lines_added, lines_removed=lines_removed,
|
|
3693
|
+
model=agent_usage.get("model"), session_id=agent_usage.get("session_id"),
|
|
3694
|
+
token_input=usage.get("input_tokens", 0) + usage.get("cache_creation_input_tokens", 0),
|
|
3695
|
+
token_output=usage.get("output_tokens", 0),
|
|
3696
|
+
token_cache_read=usage.get("cache_read_input_tokens", 0),
|
|
3697
|
+
cost_usd=agent_usage.get("cost_usd"), duration_ms=agent_usage.get("duration_ms"),
|
|
3698
|
+
)
|
|
3699
|
+
|
|
3700
|
+
# Conversational drift capture — classify this agent session's own
|
|
3701
|
+
# transcript into business/technical drift. Advisory, never blocks;
|
|
3702
|
+
# silently skips if the session transcript can't be located.
|
|
3703
|
+
agent_session_id_actual = agent_usage.get("session_id")
|
|
3704
|
+
if agent_session_id_actual:
|
|
3705
|
+
transcript_path = find_transcript_for_session(agent_session_id_actual)
|
|
3706
|
+
if transcript_path:
|
|
3707
|
+
run_capture(
|
|
3708
|
+
pcp_dir, transcript_path,
|
|
3709
|
+
source=f"build:{mod['name']}:{c['id']}",
|
|
3710
|
+
session_id=agent_session_id_actual,
|
|
3711
|
+
)
|
|
3712
|
+
|
|
3713
|
+
# Running gates -- all thirteen checks below are mutually independent
|
|
3714
|
+
# (each reads disk/git/subprocess/an LLM call and writes only its own
|
|
3715
|
+
# evidence file + a lock-guarded _qa_record/_log_usage call), so they
|
|
3716
|
+
# run concurrently rather than one after another. Until 2026-07-18
|
|
3717
|
+
# these ran strictly sequentially within one criterion even though
|
|
3718
|
+
# nothing here depends on another check's output -- a real dogfood
|
|
3719
|
+
# finding (Project O): with 3 of these being LLM calls and the
|
|
3720
|
+
# rest subprocess/network calls, sequential execution was pure wasted
|
|
3721
|
+
# wall-clock. The comment this replaced only justified running
|
|
3722
|
+
# OUTSIDE _STATE_LOCK for overlap ACROSS concurrently-building
|
|
3723
|
+
# modules -- it never actually parallelized the checks WITHIN one
|
|
3724
|
+
# criterion, which is what actually happens here now. Each check
|
|
3725
|
+
# function's own _qa_record call remains lock-guarded, and
|
|
3726
|
+
# llm.client._log_usage (token_ledger.yaml) now has its own lock too
|
|
3727
|
+
# (previously unguarded -- fine when only one gate call ever ran at
|
|
3728
|
+
# a time, a real race the moment more than one runs concurrently).
|
|
3729
|
+
console.print(f"[dim]Evaluating gates ({mod['name']}/{c['id']})...[/dim]")
|
|
3730
|
+
_write_progress(pcp_dir, mod["name"], c["id"], attempt, "qa: evaluating gates")
|
|
3731
|
+
ctx = {
|
|
3732
|
+
"module": mod["name"], "submodule": None, "criterion_id": c["id"],
|
|
3733
|
+
"criterion_description": c.get("description", ""),
|
|
3734
|
+
"attempt": attempt, "files": changed_files, "run_id": run_log_id,
|
|
3735
|
+
}
|
|
3736
|
+
# A criterion gate tests THE BUILT PRODUCT. Nothing here inspects PCP's
|
|
3737
|
+
# own paperwork -- declarations about how the code was decided on.
|
|
3738
|
+
#
|
|
3739
|
+
# Measured on Project O 2026-07-27, 1,632 gate executions:
|
|
3740
|
+
# 35% of them checked declarations rather than the product, and those
|
|
3741
|
+
# produced 108 of 187 total blocks -- 58%. Of those, 97 were the scope
|
|
3742
|
+
# guard reporting "agent modified N files outside the declared surface"
|
|
3743
|
+
# against a surface derived from `target`, which 331 of 382 criteria
|
|
3744
|
+
# never declared. PCP was blocking on its own missing metadata, then
|
|
3745
|
+
# charging that check to every attempt of every criterion.
|
|
3746
|
+
#
|
|
3747
|
+
# Removed from this loop: scope (CTRL-018), build_vs_buy justification
|
|
3748
|
+
# (CTRL-017), design justification (CTRL-015), customization (CTRL-026).
|
|
3749
|
+
# All four grade declaration TEXT or declared file surfaces; none can
|
|
3750
|
+
# tell you whether the thing works. If that reporting is wanted it
|
|
3751
|
+
# belongs in `pcp audit` over a finished project, not in the build's
|
|
3752
|
+
# hot path.
|
|
3753
|
+
#
|
|
3754
|
+
# What stays is exactly what can fail the product: does it pass its
|
|
3755
|
+
# tests, is it clean (lint/SAST/ci_rules), does the diff hold up to
|
|
3756
|
+
# review, does the UI actually render and meet a11y, and did the agent
|
|
3757
|
+
# leave stubs behind (lazy_marker catches TODO/placeholder bodies
|
|
3758
|
+
# shipped as complete -- a real defect, not paperwork).
|
|
3759
|
+
gate_calls = {
|
|
3760
|
+
"tests": lambda: _run_test_suite_check(pcp_dir, project_root, ctx),
|
|
3761
|
+
"lint": lambda: _run_lint_check(pcp_dir, project_root, changed_files, ctx, budget),
|
|
3762
|
+
"sast": lambda: _run_sast_check(pcp_dir, project_root, changed_files, ctx, budget),
|
|
3763
|
+
"l1": lambda: _run_layer1_check(pcp_dir, project_root, changed_files, ctx),
|
|
3764
|
+
"arch": lambda: _run_architect_review(pcp_dir, diff, changed_files, ctx),
|
|
3765
|
+
"gate": lambda: _run_gate_check(pcp_dir, diff, ctx),
|
|
3766
|
+
"design_consistency": lambda: _run_design_consistency_check(pcp_dir, project_root, c, ctx),
|
|
3767
|
+
"a11y": lambda: _run_a11y_check(pcp_dir, c, ctx),
|
|
3768
|
+
"visual_quality": lambda: _run_visual_quality_check(pcp_dir, project_root, c, ctx),
|
|
3769
|
+
"lazy_marker": lambda: _run_lazy_marker_check(pcp_dir, project_root, changed_files, ctx),
|
|
3770
|
+
}
|
|
3771
|
+
with ThreadPoolExecutor(max_workers=len(gate_calls)) as pool:
|
|
3772
|
+
futures = {name: pool.submit(fn) for name, fn in gate_calls.items()}
|
|
3773
|
+
gate_results = {name: f.result() for name, f in futures.items()}
|
|
3774
|
+
run_log_last_checks = list(gate_calls.keys())
|
|
3775
|
+
|
|
3776
|
+
test_timed_out = any("timed out" in v.lower() for v in gate_results["tests"])
|
|
3777
|
+
if budget.record_test_timeout_signal(test_timed_out):
|
|
3778
|
+
console.print(
|
|
3779
|
+
f"[red bold]Infra anomaly suspected:[/red bold] the test-suite gate has now "
|
|
3780
|
+
f"\"timed out\" on {budget.infra_signal_streak} consecutive attempts. This usually "
|
|
3781
|
+
"means the environment is broken (wrong/unreachable DB, a squatted port, a hung "
|
|
3782
|
+
"service), not the agent's code -- see the 2026-07-21 Project O incident. "
|
|
3783
|
+
"Verify the environment before trusting further gate results this run."
|
|
3784
|
+
)
|
|
3785
|
+
from pcp import escalations
|
|
3786
|
+
with _STATE_LOCK:
|
|
3787
|
+
escalations.record(
|
|
3788
|
+
pcp_dir, mod["name"], c["id"], route="infra-anomaly",
|
|
3789
|
+
findings=[
|
|
3790
|
+
f"{budget.infra_signal_streak} consecutive test-suite gate timeouts across "
|
|
3791
|
+
"criteria -- likely environment/infra issue, not per-criterion agent code "
|
|
3792
|
+
"quality. Check DB/service connectivity and for port conflicts before trusting "
|
|
3793
|
+
"further results this run.",
|
|
3794
|
+
],
|
|
3795
|
+
)
|
|
3796
|
+
|
|
3797
|
+
# Blocking set = product failures only. `scope`, `design_justification`
|
|
3798
|
+
# and `bvb_justification` used to block here; see the gate_calls comment
|
|
3799
|
+
# above for why declaration-grading no longer stops a build.
|
|
3800
|
+
block_findings = (
|
|
3801
|
+
gate_results["tests"] + gate_results["lint"] + gate_results["sast"]
|
|
3802
|
+
+ gate_results["l1"] + gate_results["arch"] + gate_results["gate"]
|
|
3803
|
+
)
|
|
3804
|
+
|
|
3805
|
+
if block_findings:
|
|
3806
|
+
console.print(f"[red]BLOCKED by quality/architecture gates ({mod['name']}/{c['id']}):[/red]")
|
|
3807
|
+
for v in block_findings:
|
|
3808
|
+
console.print(f" ✗ {v}")
|
|
3809
|
+
feedback = "\n".join(block_findings)
|
|
3810
|
+
attempt_history.append(
|
|
3811
|
+
f"Attempt {attempt}: blocked by gates — " + "; ".join(v[:200] for v in block_findings[:5])
|
|
3812
|
+
)
|
|
3813
|
+
else:
|
|
3814
|
+
success = True
|
|
3815
|
+
break
|
|
3816
|
+
|
|
3817
|
+
# Unconditional — pass or fail. A criterion that exhausts all 3 attempts
|
|
3818
|
+
# still leaves its worktree "for inspection" (never merged to main; only
|
|
3819
|
+
# a successful criterion gets merged), but real agent work must not sit
|
|
3820
|
+
# as raw uncommitted files that a stale worktree removal could lose —
|
|
3821
|
+
# the exact Project O web-server-A013/14/15 pattern, 2026-07-23.
|
|
3822
|
+
_auto_commit_criterion(project_root, mod["name"], c)
|
|
3823
|
+
|
|
3824
|
+
if run_log_id:
|
|
3825
|
+
try:
|
|
3826
|
+
external_deps: set[str] = set()
|
|
3827
|
+
for f in changed_files:
|
|
3828
|
+
full_path = project_root / f
|
|
3829
|
+
if full_path.exists() and full_path.is_file():
|
|
3830
|
+
external_deps |= _external_python_imports(full_path, project_root)
|
|
3831
|
+
entry = run_log.end_run(
|
|
3832
|
+
pcp_dir, run_log_id, result="success" if success else "failure",
|
|
3833
|
+
model=build_model,
|
|
3834
|
+
token_input=run_log_tokens["input"], token_output=run_log_tokens["output"],
|
|
3835
|
+
token_cache_read=run_log_tokens["cache_read"], cost_usd=run_log_tokens["cost"],
|
|
3836
|
+
tests_ran="tests" in run_log_last_checks,
|
|
3837
|
+
tests_passed=(success if "tests" in run_log_last_checks else None),
|
|
3838
|
+
real_gates_passed=[k for k in run_log_last_checks if k in run_log._DETERMINISTIC_CHECKS],
|
|
3839
|
+
llm_judged_gates_passed=[k for k in run_log_last_checks if k in run_log._LLM_JUDGED_CHECKS],
|
|
3840
|
+
self_reported_usage=False, external_deps=sorted(external_deps),
|
|
3841
|
+
)
|
|
3842
|
+
if entry["anomaly_flags"]:
|
|
3843
|
+
console.print(f"[yellow]run-log anomalies ({mod['name']}/{c['id']}):[/yellow] " + "; ".join(entry["anomaly_flags"]))
|
|
3844
|
+
except Exception as e:
|
|
3845
|
+
console.print(f"[dim]run-log end skipped: {e}[/dim]")
|
|
3846
|
+
|
|
3847
|
+
return success, block_findings
|
|
3848
|
+
|
|
3849
|
+
|
|
3850
|
+
def _mark_criterion_complete(mod: dict, criterion_id: str, verified_by: str = "pcp_build") -> None:
|
|
3851
|
+
"""No cross-module contention (each module only ever touches its own
|
|
3852
|
+
acceptance.yaml), but still guarded for consistency — and, under
|
|
3853
|
+
criterion-level parallelism, this same file IS written by concurrent
|
|
3854
|
+
threads for different criteria in the same module, so the guard is load-
|
|
3855
|
+
bearing there, not just defensive.
|
|
3856
|
+
|
|
3857
|
+
`verified_by` (2026-07-24): the ONLY place a criterion's status ever
|
|
3858
|
+
flips to complete through this real gated loop -- stamping it here is
|
|
3859
|
+
what makes current_state.md/dashboard able to show "audited complete"
|
|
3860
|
+
vs a hand-edited acceptance.yaml (which never touches this function, so
|
|
3861
|
+
a manually-flipped criterion simply has no verified_by field at all).
|
|
3862
|
+
Closes the "pcp build and a regular build say completed the same way"
|
|
3863
|
+
gap named 2026-07-24."""
|
|
3864
|
+
with _STATE_LOCK:
|
|
3865
|
+
acc_data = load_yaml(mod["acc_path"])
|
|
3866
|
+
for crit in acc_data.get("criteria", []):
|
|
3867
|
+
if crit["id"] == criterion_id:
|
|
3868
|
+
crit["status"] = "complete"
|
|
3869
|
+
crit["verified_by"] = verified_by
|
|
3870
|
+
mod["acc_path"].write_text(yaml.dump(acc_data, default_flow_style=False))
|
|
3871
|
+
|
|
3872
|
+
|
|
3873
|
+
def _build_module_worker(
|
|
3874
|
+
pcp_dir: Path, mod: dict, project_root: Path,
|
|
3875
|
+
build_model: str | None, build_model_explicit: bool, budget: "_BuildBudget",
|
|
3876
|
+
yes: bool = False, module_wave_number: int | None = None,
|
|
3877
|
+
) -> dict:
|
|
3878
|
+
# A malformed spec must fail THIS module, not the run. On 2026-07-27 a build
|
|
3879
|
+
# agent hand-edited an acceptance.yaml into invalid YAML and the raw
|
|
3880
|
+
# ScannerError ended a run that had already completed two modules. Other
|
|
3881
|
+
# modules had nothing to do with that file and should keep going.
|
|
3882
|
+
try:
|
|
3883
|
+
return _build_module_worker_inner(
|
|
3884
|
+
pcp_dir, mod, project_root, build_model, build_model_explicit, budget, yes,
|
|
3885
|
+
module_wave_number=module_wave_number,
|
|
3886
|
+
)
|
|
3887
|
+
except MalformedSpecError as exc:
|
|
3888
|
+
console.print(f"[red]✗ Module '{mod['name']}' has an unreadable spec:[/red] {exc}")
|
|
3889
|
+
return {"module": mod["name"], "success": False,
|
|
3890
|
+
"failed_criterion": None, "block_findings": [str(exc)]}
|
|
3891
|
+
|
|
3892
|
+
|
|
3893
|
+
def _build_module_worker_inner(
|
|
3894
|
+
pcp_dir: Path, mod: dict, project_root: Path,
|
|
3895
|
+
build_model: str | None, build_model_explicit: bool, budget: "_BuildBudget",
|
|
3896
|
+
yes: bool = False, module_wave_number: int | None = None,
|
|
3897
|
+
) -> dict:
|
|
3898
|
+
"""Runs all of one module's pending criteria inside `project_root` (its
|
|
3899
|
+
own worktree when building in parallel across modules). Stops at the
|
|
3900
|
+
first criterion (or criterion-wave) that fails. Never raises for a
|
|
3901
|
+
build/gate failure — only BudgetExceeded propagates, since that's a
|
|
3902
|
+
whole-run circuit breaker, not a per-module outcome.
|
|
3903
|
+
|
|
3904
|
+
Sequential by default (`_criteria_parallel_enabled` is False for any
|
|
3905
|
+
module where no criterion declares `depends_on`) — the pre-existing,
|
|
3906
|
+
unchanged code path. Opt-in criterion-level parallel waves are a
|
|
3907
|
+
separate branch below, not a rewrite of the default one."""
|
|
3908
|
+
console.print(f"\n[bold]Building Module:[/bold] [cyan]'{mod['name']}'[/cyan] ({len(mod['pending_criteria'])} pending criteria)")
|
|
3909
|
+
|
|
3910
|
+
# Whole-module direct-match fast path (spec.yaml's install_only) — one
|
|
3911
|
+
# approval + one install + one smoke test covers every pending criterion
|
|
3912
|
+
# in this module at once. A decline or failed smoke test falls straight
|
|
3913
|
+
# through into the normal per-criterion loop below, unchanged.
|
|
3914
|
+
if mod["spec"].get("install_only") and mod["pending_criteria"]:
|
|
3915
|
+
install_command = mod["spec"].get("install_command")
|
|
3916
|
+
if not install_command:
|
|
3917
|
+
console.print(f"[red]{mod['name']} declares install_only but has no install_command — falling through to full build.[/red]")
|
|
3918
|
+
else:
|
|
3919
|
+
candidate_desc = (mod["spec"].get("build_vs_buy") or {}).get("rationale") or install_command
|
|
3920
|
+
ok, _findings = _run_install_only(
|
|
3921
|
+
pcp_dir, project_root, mod, criterion=None,
|
|
3922
|
+
install_command=install_command, candidate_desc=candidate_desc, yes=yes,
|
|
3923
|
+
budget=budget,
|
|
3924
|
+
)
|
|
3925
|
+
if ok:
|
|
3926
|
+
for c in mod["pending_criteria"]:
|
|
3927
|
+
_mark_criterion_complete(mod, c["id"], verified_by="pcp_build_install_only")
|
|
3928
|
+
console.print(f"\n[green]✓ Module '{mod['name']}' built successfully (install-only)![/green]")
|
|
3929
|
+
return {"module": mod["name"], "success": True}
|
|
3930
|
+
|
|
3931
|
+
if not _criteria_parallel_enabled(mod):
|
|
3932
|
+
for c in mod["pending_criteria"]:
|
|
3933
|
+
console.print(f"\n[bold underline]Criterion [{c['id']}]:[/bold underline] {c['description']}")
|
|
3934
|
+
success, block_findings = _build_one_criterion(
|
|
3935
|
+
pcp_dir, project_root, mod, c, build_model, build_model_explicit, budget, yes,
|
|
3936
|
+
module_wave_number=module_wave_number,
|
|
3937
|
+
)
|
|
3938
|
+
|
|
3939
|
+
if success:
|
|
3940
|
+
console.print(f"[green]✓ Criterion [{c['id']}] passed all gates successfully![/green]")
|
|
3941
|
+
_mark_criterion_complete(mod, c["id"])
|
|
3942
|
+
_write_progress(pcp_dir, mod["name"], c["id"], 0, "done")
|
|
3943
|
+
else:
|
|
3944
|
+
console.print(f"[red]✗ Failed to build Criterion [{c['id']}] after 3 attempts.[/red]")
|
|
3945
|
+
_record_escalation(pcp_dir, mod["name"], c["id"], block_findings)
|
|
3946
|
+
_write_progress(pcp_dir, mod["name"], c["id"], 0, "failed")
|
|
3947
|
+
return {"module": mod["name"], "success": False, "failed_criterion": c["id"], "block_findings": block_findings}
|
|
3948
|
+
|
|
3949
|
+
console.print(f"\n[green]✓ Module '{mod['name']}' built successfully![/green]")
|
|
3950
|
+
return {"module": mod["name"], "success": True}
|
|
3951
|
+
|
|
3952
|
+
# Opt-in path: criteria grouped into dependency waves, independent
|
|
3953
|
+
# criteria within a wave built concurrently, each in its own git
|
|
3954
|
+
# worktree nested off `project_root` (same _setup_worktree/_merge_
|
|
3955
|
+
# module_branch/_cleanup_worktree helpers as module-level parallelism,
|
|
3956
|
+
# just given a criterion-scoped unit name instead of a module name).
|
|
3957
|
+
wave_of = _compute_criterion_waves(mod)
|
|
3958
|
+
num_waves = max(wave_of.values(), default=0) + 1
|
|
3959
|
+
scheduled: list[list[dict]] = []
|
|
3960
|
+
for wave_number in range(num_waves):
|
|
3961
|
+
in_wave = [c for c in mod["pending_criteria"] if wave_of.get(c["id"], 0) == wave_number]
|
|
3962
|
+
if in_wave:
|
|
3963
|
+
scheduled.extend(_partition_wave_by_file_scope(in_wave))
|
|
3964
|
+
for wave_number, wave_criteria in enumerate(scheduled):
|
|
3965
|
+
if not wave_criteria:
|
|
3966
|
+
continue
|
|
3967
|
+
|
|
3968
|
+
if len(wave_criteria) == 1:
|
|
3969
|
+
c = wave_criteria[0]
|
|
3970
|
+
console.print(f"\n[bold underline]Criterion [{c['id']}]:[/bold underline] {c['description']}")
|
|
3971
|
+
success, block_findings = _build_one_criterion(
|
|
3972
|
+
pcp_dir, project_root, mod, c, build_model, build_model_explicit, budget, yes,
|
|
3973
|
+
module_wave_number=module_wave_number, criterion_wave_number=wave_number,
|
|
3974
|
+
)
|
|
3975
|
+
if success:
|
|
3976
|
+
console.print(f"[green]✓ Criterion [{c['id']}] passed all gates successfully![/green]")
|
|
3977
|
+
_mark_criterion_complete(mod, c["id"])
|
|
3978
|
+
_write_progress(pcp_dir, mod["name"], c["id"], 0, "done")
|
|
3979
|
+
else:
|
|
3980
|
+
console.print(f"[red]✗ Failed to build Criterion [{c['id']}] after 3 attempts.[/red]")
|
|
3981
|
+
_record_escalation(pcp_dir, mod["name"], c["id"], block_findings)
|
|
3982
|
+
_write_progress(pcp_dir, mod["name"], c["id"], 0, "failed")
|
|
3983
|
+
return {"module": mod["name"], "success": False, "failed_criterion": c["id"], "block_findings": block_findings}
|
|
3984
|
+
continue
|
|
3985
|
+
|
|
3986
|
+
console.print(
|
|
3987
|
+
f"\n[bold]Criterion wave {wave_number}:[/bold] {len(wave_criteria)} independent "
|
|
3988
|
+
f"criteria in '{mod['name']}' building in parallel "
|
|
3989
|
+
f"(up to {min(_max_parallel_criteria(), len(wave_criteria))} at once, each in its own worktree)..."
|
|
3990
|
+
)
|
|
3991
|
+
units = {c["id"]: f"{mod['name']}-{c['id']}" for c in wave_criteria}
|
|
3992
|
+
worktrees = {c["id"]: _setup_worktree(project_root, units[c["id"]]) for c in wave_criteria}
|
|
3993
|
+
results: dict[str, tuple[bool, list[str]]] = {}
|
|
3994
|
+
with ThreadPoolExecutor(
|
|
3995
|
+
max_workers=min(_max_parallel_criteria(), len(wave_criteria))
|
|
3996
|
+
) as executor:
|
|
3997
|
+
futures = {
|
|
3998
|
+
executor.submit(
|
|
3999
|
+
_build_one_criterion, pcp_dir, worktrees[c["id"]], mod, c, build_model, build_model_explicit, budget, yes,
|
|
4000
|
+
module_wave_number, wave_number,
|
|
4001
|
+
): c["id"]
|
|
4002
|
+
for c in wave_criteria
|
|
4003
|
+
}
|
|
4004
|
+
for future in as_completed(futures):
|
|
4005
|
+
cid = futures[future]
|
|
4006
|
+
try:
|
|
4007
|
+
results[cid] = future.result()
|
|
4008
|
+
except BudgetExceeded:
|
|
4009
|
+
# Mirrors the module-level parallel path's handling
|
|
4010
|
+
# (build()'s own ThreadPoolExecutor loop below): convert
|
|
4011
|
+
# to a graceful failure result rather than letting the
|
|
4012
|
+
# run-level circuit breaker crash out with a raw
|
|
4013
|
+
# traceback mid-wave.
|
|
4014
|
+
results[cid] = (False, [f"budget circuit breaker: exceeded {budget.max_sessions} agent sessions this run"])
|
|
4015
|
+
|
|
4016
|
+
any_failed = False
|
|
4017
|
+
failed_id = None
|
|
4018
|
+
failed_findings: list[str] = []
|
|
4019
|
+
for c in wave_criteria:
|
|
4020
|
+
cid = c["id"]
|
|
4021
|
+
success, block_findings = results.get(cid, (False, ["no result — worker crashed"]))
|
|
4022
|
+
if success:
|
|
4023
|
+
ok, merge_output = _merge_module_branch(project_root, units[cid], pcp_dir=pcp_dir)
|
|
4024
|
+
if ok:
|
|
4025
|
+
_cleanup_worktree(project_root, units[cid], worktrees[cid])
|
|
4026
|
+
console.print(f"[green]✓ Criterion [{cid}] passed all gates successfully![/green]")
|
|
4027
|
+
_mark_criterion_complete(mod, cid)
|
|
4028
|
+
_write_progress(pcp_dir, mod["name"], cid, 0, "done")
|
|
4029
|
+
else:
|
|
4030
|
+
# A merge conflict here means two criteria in this wave
|
|
4031
|
+
# genuinely touched the same code -- the collision that
|
|
4032
|
+
# optimistic scheduling accepts as recoverable rather than
|
|
4033
|
+
# prevents by serialising everything (see
|
|
4034
|
+
# _partition_wave_by_file_scope). The merge already aborted
|
|
4035
|
+
# cleanly, so the correct move is to rebuild this criterion
|
|
4036
|
+
# against the now-updated main, not to stop the module and
|
|
4037
|
+
# hand a human a git conflict.
|
|
4038
|
+
#
|
|
4039
|
+
# Bounded to one retry: a second conflict on the same
|
|
4040
|
+
# criterion is not contention, it is something structural
|
|
4041
|
+
# that a human should look at.
|
|
4042
|
+
console.print(
|
|
4043
|
+
f"[yellow]Criterion '{cid}' collided on merge with work that landed "
|
|
4044
|
+
f"first in this wave — rebuilding it against the updated base.[/yellow]"
|
|
4045
|
+
)
|
|
4046
|
+
_cleanup_worktree(project_root, units[cid], worktrees[cid])
|
|
4047
|
+
retry_wt = _setup_worktree(project_root, units[cid])
|
|
4048
|
+
retry_ok, retry_findings = _build_one_criterion(
|
|
4049
|
+
pcp_dir, retry_wt, mod, c, build_model, build_model_explicit, budget, yes,
|
|
4050
|
+
module_wave_number=module_wave_number, criterion_wave_number=wave_number,
|
|
4051
|
+
)
|
|
4052
|
+
remerged = False
|
|
4053
|
+
if retry_ok:
|
|
4054
|
+
remerged, merge_output = _merge_module_branch(
|
|
4055
|
+
project_root, units[cid], pcp_dir=pcp_dir)
|
|
4056
|
+
if remerged:
|
|
4057
|
+
_cleanup_worktree(project_root, units[cid], retry_wt)
|
|
4058
|
+
console.print(f"[green]✓ Criterion [{cid}] passed all gates after collision rebuild![/green]")
|
|
4059
|
+
_mark_criterion_complete(mod, cid)
|
|
4060
|
+
_write_progress(pcp_dir, mod["name"], cid, 0, "done")
|
|
4061
|
+
else:
|
|
4062
|
+
any_failed, failed_id = True, cid
|
|
4063
|
+
failed_findings = retry_findings or [f"merge conflict after rebuild: {merge_output[-500:]}"]
|
|
4064
|
+
console.print(
|
|
4065
|
+
f"[red]✗ Criterion '{cid}' still could not be merged into "
|
|
4066
|
+
f"'{mod['name']}' after a rebuild:[/red]\n{merge_output}"
|
|
4067
|
+
)
|
|
4068
|
+
console.print(f"[dim]Worktree left at {retry_wt} for manual resolution.[/dim]")
|
|
4069
|
+
else:
|
|
4070
|
+
any_failed, failed_id, failed_findings = True, cid, block_findings
|
|
4071
|
+
console.print(f"[red]✗ Failed to build Criterion [{cid}] after 3 attempts.[/red]")
|
|
4072
|
+
_record_escalation(pcp_dir, mod["name"], cid, block_findings)
|
|
4073
|
+
|
|
4074
|
+
if any_failed:
|
|
4075
|
+
return {"module": mod["name"], "success": False, "failed_criterion": failed_id, "block_findings": failed_findings}
|
|
4076
|
+
|
|
4077
|
+
console.print(f"\n[green]✓ Module '{mod['name']}' built successfully![/green]")
|
|
4078
|
+
return {"module": mod["name"], "success": True}
|
|
4079
|
+
|
|
4080
|
+
|
|
4081
|
+
def _refresh_state(pcp_dir: Path, modules_dir: Path) -> None:
|
|
4082
|
+
"""Regenerate current_state.md + pcp.md once — after a wave, not per
|
|
4083
|
+
criterion. Aggregating across all modules' acceptance.yaml is not safe
|
|
4084
|
+
to do per-criterion under parallel module builds (nothing meaningful
|
|
4085
|
+
to merge, and it's wasted work when only the -- soon to be reharvested
|
|
4086
|
+
-- last snapshot matters)."""
|
|
4087
|
+
from datetime import datetime, timezone
|
|
4088
|
+
from pcp.commands.scan import _scan_module, _write_current_state, _load_prior_manual_status
|
|
4089
|
+
prior_manual = _load_prior_manual_status(pcp_dir / "current_state.md")
|
|
4090
|
+
modules_results = []
|
|
4091
|
+
for af in sorted(modules_dir.glob("*/acceptance.yaml")):
|
|
4092
|
+
m_name = af.parent.name
|
|
4093
|
+
res = _scan_module(m_name, af, pcp_dir.parent, prior_manual)
|
|
4094
|
+
modules_results.append(res)
|
|
4095
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
4096
|
+
_write_current_state(pcp_dir, modules_results, timestamp)
|
|
4097
|
+
total = sum(len(m["criteria"]) for m in modules_results)
|
|
4098
|
+
complete = sum(1 for m in modules_results for c in m["criteria"] if c["status"] == "complete")
|
|
4099
|
+
write_pcp_md(pcp_dir, modules_results, timestamp, total, complete)
|
|
4100
|
+
|
|
4101
|
+
|
|
4102
|
+
@click.command()
|
|
4103
|
+
@click.option("--module", "module_name", default=None,
|
|
4104
|
+
help="Build specific module only.")
|
|
4105
|
+
@click.option("--path", "project_path", type=click.Path(), default=None,
|
|
4106
|
+
help="Project root override.")
|
|
4107
|
+
@click.option("--yes", "yes", is_flag=True,
|
|
4108
|
+
help="Skip the interactive install-only approval prompt (CI/non-interactive use — opt-in, not default). Only affects criteria/modules declaring install_only; every other criterion builds exactly as before.")
|
|
4109
|
+
def build(module_name: str | None, project_path: str | None, yes: bool):
|
|
4110
|
+
"""Run autonomous AI coding loops for pending acceptance criteria.
|
|
4111
|
+
|
|
4112
|
+
This is the HEADLESS executor (ThreadPoolExecutor, worktree-per-criterion,
|
|
4113
|
+
merge-then-retry) -- for CI, `pcp watch`'s auto-fix loop, and cron, where
|
|
4114
|
+
no Claude Code session exists to hand a plan to. An interactive session
|
|
4115
|
+
has a better option: `pcp build-plan` + the `/pcp` skill's Workflow-tool
|
|
4116
|
+
execution (2026-07-30 redesign, see build_plan.py's module docstring) --
|
|
4117
|
+
same wave computation, but the harness governs concurrency and worktree
|
|
4118
|
+
isolation natively instead of this engine reinventing merge/retry. This
|
|
4119
|
+
command still nudges toward that path below when it detects one."""
|
|
4120
|
+
try:
|
|
4121
|
+
pcp_dir = find_pcp_dir(Path(project_path) if project_path else None)
|
|
4122
|
+
except NoPCPDir as e:
|
|
4123
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
4124
|
+
sys.exit(2)
|
|
4125
|
+
|
|
4126
|
+
from datetime import datetime, timezone
|
|
4127
|
+
_build_start_ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
4128
|
+
|
|
4129
|
+
from pcp.commands.doctor import check_environment
|
|
4130
|
+
check_environment(pcp_dir)
|
|
4131
|
+
|
|
4132
|
+
# Self-capture the CALLING session before the objective-conflict gate below
|
|
4133
|
+
# even looks at brd_items.yaml. Real incident, 2026-07-22: a correction was
|
|
4134
|
+
# discussed and "go ahead" given in the SAME still-open Claude Code session
|
|
4135
|
+
# -- `pcp capture` is normally wired to a SessionEnd hook, which never fires
|
|
4136
|
+
# until the session ends, so the correction never got classified at all and
|
|
4137
|
+
# the gate below would have found zero conflicts to block on. This makes
|
|
4138
|
+
# `pcp build` capture its own live, still-open session -- deterministic,
|
|
4139
|
+
# not dependent on a skill/orchestrator remembering to call `pcp capture`
|
|
4140
|
+
# itself. Advisory, never blocks: any failure here must not stop a build
|
|
4141
|
+
# that has nothing to do with capture working.
|
|
4142
|
+
_self_session_id = os.environ.get("CLAUDE_CODE_SESSION_ID")
|
|
4143
|
+
if _self_session_id:
|
|
4144
|
+
try:
|
|
4145
|
+
_self_transcript = find_transcript_for_session(_self_session_id)
|
|
4146
|
+
if _self_transcript:
|
|
4147
|
+
console.print("[dim]Capturing current session for business/technical drift before build...[/dim]")
|
|
4148
|
+
run_capture(pcp_dir, _self_transcript, source=f"session:{_self_session_id}", session_id=_self_session_id)
|
|
4149
|
+
except Exception as e:
|
|
4150
|
+
console.print(f"[dim]Self-capture skipped: {e}[/dim]")
|
|
4151
|
+
|
|
4152
|
+
# Interactive-session nudge, 2026-07-30. This engine is the headless
|
|
4153
|
+
# executor (see the command docstring above) -- a session exists here,
|
|
4154
|
+
# which means `pcp build-plan` + Workflow-tool execution is available
|
|
4155
|
+
# and lets the harness govern concurrency instead of this engine's own
|
|
4156
|
+
# merge-then-retry path. Advisory only, never blocks.
|
|
4157
|
+
console.print(
|
|
4158
|
+
"[yellow]Note:[/yellow] running inside an interactive session. This is the "
|
|
4159
|
+
"headless build engine (CI/watch/cron). Prefer `pcp build-plan` + the `/pcp` "
|
|
4160
|
+
"skill's Workflow-tool execution for interactive builds. Continuing here..."
|
|
4161
|
+
)
|
|
4162
|
+
|
|
4163
|
+
# Objective-conflict gate (CTRL-035) -- a captured business decision that
|
|
4164
|
+
# conflicts with objective.md/target_state.md's actual text must not sit
|
|
4165
|
+
# silently in brd.md prose while a build cycle spends millions of tokens
|
|
4166
|
+
# against the stale target. reconcile() also auto-clears any conflict
|
|
4167
|
+
# whose flagged objective_hash no longer matches current file content --
|
|
4168
|
+
# i.e. a human already made the edit -- so this only blocks on conflicts
|
|
4169
|
+
# nobody has actually resolved yet. See objective_conflicts.py.
|
|
4170
|
+
unresolved_conflicts = objective_conflicts.reconcile(pcp_dir)
|
|
4171
|
+
if unresolved_conflicts:
|
|
4172
|
+
telemetry.record(
|
|
4173
|
+
pcp_dir, cycle="build", check="objective-conflict-gate", control_id="CTRL-035",
|
|
4174
|
+
result="blocked", error_count=len(unresolved_conflicts),
|
|
4175
|
+
errors=[c.get("id", "?") for c in unresolved_conflicts],
|
|
4176
|
+
)
|
|
4177
|
+
console.print("[bold red]Build blocked -- unresolved objective conflict(s):[/bold red]")
|
|
4178
|
+
for c in unresolved_conflicts:
|
|
4179
|
+
console.print(f" [red]{c.get('id')}[/red]: {c.get('description')}")
|
|
4180
|
+
console.print(f" [dim]conflict: {c.get('drift_flag')}[/dim]")
|
|
4181
|
+
# This message used to say "rewrite the spec by hand (spec files stay
|
|
4182
|
+
# human-only)" -- the exact doctrine bug corrected on 2026-07-25:
|
|
4183
|
+
# protected files are human-AUTHORIZED, not human-TYPED, and every one
|
|
4184
|
+
# of them has a propose -> real-diff -> approve -> write path. Sending
|
|
4185
|
+
# the user off to hand-edit, without naming the command built for
|
|
4186
|
+
# precisely this moment (`--from-conflict` exists to pull the
|
|
4187
|
+
# correction text straight out of the flagged item), was PCP telling
|
|
4188
|
+
# people to bypass its own gated mechanism.
|
|
4189
|
+
first_id = unresolved_conflicts[0].get("id", "<ID>")
|
|
4190
|
+
console.print(
|
|
4191
|
+
"\n[yellow]A captured business decision conflicts with objective.md/target_state.md.[/yellow]"
|
|
4192
|
+
)
|
|
4193
|
+
console.print(
|
|
4194
|
+
f" Resolve it: [dim]pcp correct-objective --from-conflict {first_id}[/dim]\n"
|
|
4195
|
+
f" [dim](LLM proposes the rewrite, you approve a real diff before anything is written)[/dim]\n"
|
|
4196
|
+
f" False positive: [dim]pcp objective-conflicts --dismiss {first_id} --reason \"...\"[/dim]"
|
|
4197
|
+
)
|
|
4198
|
+
sys.exit(2)
|
|
4199
|
+
telemetry.record(
|
|
4200
|
+
pcp_dir, cycle="build", check="objective-conflict-gate", control_id="CTRL-035", result="pass",
|
|
4201
|
+
)
|
|
4202
|
+
|
|
4203
|
+
modules_dir = get_modules_dir(pcp_dir)
|
|
4204
|
+
if not modules_dir.exists():
|
|
4205
|
+
console.print("[yellow]No modules found. Run `pcp kickoff` or `pcp init` first.[/yellow]")
|
|
4206
|
+
sys.exit(0)
|
|
4207
|
+
|
|
4208
|
+
project_root = pcp_dir.parent
|
|
4209
|
+
|
|
4210
|
+
modules_to_build = gather_modules_to_build(pcp_dir, module_name)
|
|
4211
|
+
|
|
4212
|
+
# Refuse to rebuild work that already landed. `pcp build` picks its work from
|
|
4213
|
+
# `status: pending`, so a criterion whose status was never written back gets
|
|
4214
|
+
# built again from scratch -- paying full agent cost to reproduce code that is
|
|
4215
|
+
# already in `main`, and risking a conflicting second implementation of it.
|
|
4216
|
+
#
|
|
4217
|
+
# Observed live on Project O 2026-07-30: a run was rebuilding
|
|
4218
|
+
# query-eval-harness A001, A008 and MOD_A002 thirteen minutes and $6.27 in,
|
|
4219
|
+
# all three already merged. Twelve criteria across three modules were in that
|
|
4220
|
+
# state at the time.
|
|
4221
|
+
#
|
|
4222
|
+
# The window is real and narrow in the parallel path: `_merge_module_branch`
|
|
4223
|
+
# runs, and only then `_mark_criterion_complete`. An interruption between
|
|
4224
|
+
# those two leaves exactly this footprint -- merged, still pending. Rather
|
|
4225
|
+
# than only shrinking the window, this refuses to act on the bad state at all,
|
|
4226
|
+
# which also covers the wave-reopen path and any future cause.
|
|
4227
|
+
#
|
|
4228
|
+
# Escape hatch is explicit, because a legitimate reason exists: a criterion
|
|
4229
|
+
# genuinely reworked after its first landing.
|
|
4230
|
+
if os.environ.get("PCP_ALLOW_REBUILD_LANDED") != "1":
|
|
4231
|
+
from pcp import orphaned_work
|
|
4232
|
+
try:
|
|
4233
|
+
landed = orphaned_work.find_orphaned_work(pcp_dir, project_root)
|
|
4234
|
+
except Exception:
|
|
4235
|
+
landed = []
|
|
4236
|
+
wanted = {(m["name"], c["id"]) for m in modules_to_build for c in m["pending_criteria"]}
|
|
4237
|
+
clashing = [f for f in landed if (f["module"], f["criterion_id"]) in wanted]
|
|
4238
|
+
if clashing:
|
|
4239
|
+
telemetry.record(
|
|
4240
|
+
pcp_dir, cycle="build", check="landed-work-guard", control_id="CTRL-038",
|
|
4241
|
+
result="blocked", error_count=len(clashing),
|
|
4242
|
+
errors=[f"{f['module']}/{f['criterion_id']} <- {f['evidence']}" for f in clashing],
|
|
4243
|
+
)
|
|
4244
|
+
console.print(
|
|
4245
|
+
f"[bold red]Build blocked -- {len(clashing)} criterion(s) this run would build "
|
|
4246
|
+
f"are already merged:[/bold red]"
|
|
4247
|
+
)
|
|
4248
|
+
for f in clashing[:12]:
|
|
4249
|
+
console.print(f" [red]{f['module']}/{f['criterion_id']}[/red] "
|
|
4250
|
+
f"[dim]<- commit: {f['evidence']}[/dim]")
|
|
4251
|
+
if len(clashing) > 12:
|
|
4252
|
+
console.print(f" [dim]... and {len(clashing) - 12} more[/dim]")
|
|
4253
|
+
console.print(
|
|
4254
|
+
"\n[yellow]Their status says pending but the work landed — building them again "
|
|
4255
|
+
"pays full agent cost to reproduce code that already exists.[/yellow]"
|
|
4256
|
+
)
|
|
4257
|
+
console.print(
|
|
4258
|
+
" Verify, then mark complete: [dim]pcp pm \"...\"[/dim] "
|
|
4259
|
+
"[dim](acceptance.yaml is human-approved; PCP will not flip status itself)[/dim]\n"
|
|
4260
|
+
" Genuinely rebuilding them: [dim]PCP_ALLOW_REBUILD_LANDED=1 pcp build[/dim]"
|
|
4261
|
+
)
|
|
4262
|
+
sys.exit(2)
|
|
4263
|
+
|
|
4264
|
+
if not modules_to_build:
|
|
4265
|
+
console.print("[green]All acceptance criteria are complete. Nothing to build![/green]")
|
|
4266
|
+
sys.exit(0)
|
|
4267
|
+
|
|
4268
|
+
# Self-reporting nudge, 2026-07-20 -- found dogfooding two real projects
|
|
4269
|
+
# where every build was called --module X one at a time, leaving genuine
|
|
4270
|
+
# wave-parallelism headroom (10+ independent modules in some waves)
|
|
4271
|
+
# completely unused. --module is often the right call (reviewing one
|
|
4272
|
+
# module's PR before starting the next), but a human/orchestrator should
|
|
4273
|
+
# at least see what they're trading away, not discover it by re-reading
|
|
4274
|
+
# the wave-parallelism docs later.
|
|
4275
|
+
if module_name:
|
|
4276
|
+
other_pending = gather_modules_to_build(pcp_dir, None)
|
|
4277
|
+
other_count = len([m for m in other_pending if m["name"] != module_name])
|
|
4278
|
+
if other_count:
|
|
4279
|
+
console.print(
|
|
4280
|
+
f"[yellow]Note:[/yellow] {other_count} other module(s) also have pending criteria. "
|
|
4281
|
+
"`--module` builds this one alone -- run `pcp build` with no `--module` filter to let "
|
|
4282
|
+
"the wave engine build independent modules concurrently instead."
|
|
4283
|
+
)
|
|
4284
|
+
|
|
4285
|
+
# Order modules into dependency waves. Modules within a wave have no
|
|
4286
|
+
# declared dependency on each other by construction — the wave boundary
|
|
4287
|
+
# is the real gate, not build order within it — so they build in
|
|
4288
|
+
# parallel, each in its own git worktree + branch (mirrors the /pcp
|
|
4289
|
+
# skill's Branch Isolation Protocol). Criteria within one module stay
|
|
4290
|
+
# sequential (each builds on the prior commit).
|
|
4291
|
+
wave_of = _compute_waves(modules_to_build)
|
|
4292
|
+
modules_to_build.sort(key=lambda m: wave_of.get(m["name"], 0))
|
|
4293
|
+
num_waves = max(wave_of.values(), default=0) + 1
|
|
4294
|
+
if num_waves > 1:
|
|
4295
|
+
order_desc = ", ".join(f"{m['name']}(w{wave_of[m['name']]})" for m in modules_to_build)
|
|
4296
|
+
console.print(f"[dim]Build order: {num_waves} wave(s) by dependency — {order_desc}[/dim]")
|
|
4297
|
+
|
|
4298
|
+
# Marks this process (and any subprocess it spawns — the coding agent, and
|
|
4299
|
+
# any git commit that agent runs via its own shell access) as an automated
|
|
4300
|
+
# build-agent session. check.py's protected_path rule (R003) only hard-blocks
|
|
4301
|
+
# spec-file edits when this is set — a human's own interactive commit never
|
|
4302
|
+
# sets it and is never blocked from editing spec files directly.
|
|
4303
|
+
os.environ["PCP_AGENT_SESSION"] = "1"
|
|
4304
|
+
check_agent_depth_or_exit()
|
|
4305
|
+
|
|
4306
|
+
timeout_sec, timeout_is_default = qa.test_timeout_info()
|
|
4307
|
+
if timeout_is_default:
|
|
4308
|
+
console.print(
|
|
4309
|
+
f"[yellow]Note:[/yellow] PCP_QA_TEST_TIMEOUT_SEC not set -- test-suite gate uses the "
|
|
4310
|
+
f"{timeout_sec}s default. A slow-but-passing suite and a hung dependency (wrong/unreachable "
|
|
4311
|
+
"DB, a squatted port) both surface identically as \"timed out\" -- set it explicitly if this "
|
|
4312
|
+
"project's real suite legitimately runs long."
|
|
4313
|
+
)
|
|
4314
|
+
else:
|
|
4315
|
+
console.print(f"[dim]QA test-suite timeout: {timeout_sec}s (PCP_QA_TEST_TIMEOUT_SEC).[/dim]")
|
|
4316
|
+
|
|
4317
|
+
budget = _BuildBudget(_max_build_sessions())
|
|
4318
|
+
# Model-selection strategy (see llm/client.py) -- Sonnet is the reviewed
|
|
4319
|
+
# default for the coding agent, escalating to Opus on a criterion's
|
|
4320
|
+
# final attempt. A human's explicit PCP_BUILD_MODEL always wins outright
|
|
4321
|
+
# and disables escalation -- an explicit override on attempt 1 shouldn't
|
|
4322
|
+
# silently change model again on attempt 3 without being asked.
|
|
4323
|
+
_explicit_build_model = os.environ.get("PCP_BUILD_MODEL")
|
|
4324
|
+
build_model = _explicit_build_model or llm.BUILD_MODEL
|
|
4325
|
+
build_model_explicit = bool(_explicit_build_model)
|
|
4326
|
+
max_parallel = _max_parallel_modules()
|
|
4327
|
+
|
|
4328
|
+
for wave_number in range(num_waves):
|
|
4329
|
+
wave_modules = [m for m in modules_to_build if wave_of.get(m["name"], 0) == wave_number]
|
|
4330
|
+
if not wave_modules:
|
|
4331
|
+
continue
|
|
4332
|
+
|
|
4333
|
+
wave_start_ref = _git_head(project_root)
|
|
4334
|
+
use_worktrees = len(wave_modules) > 1 and max_parallel > 1
|
|
4335
|
+
|
|
4336
|
+
if not use_worktrees:
|
|
4337
|
+
# Single module (or parallelism disabled) — run directly against
|
|
4338
|
+
# the main project root, no worktree machinery needed at all.
|
|
4339
|
+
for mod in wave_modules:
|
|
4340
|
+
result = _build_module_worker(pcp_dir, mod, project_root, build_model, build_model_explicit, budget, yes, module_wave_number=wave_number)
|
|
4341
|
+
if not result["success"]:
|
|
4342
|
+
console.print("[bold red]Build execution stopped. Please resolve findings manually.[/bold red]")
|
|
4343
|
+
sys.exit(1)
|
|
4344
|
+
else:
|
|
4345
|
+
console.print(
|
|
4346
|
+
f"\n[bold]Wave {wave_number}:[/bold] building {len(wave_modules)} module(s) in parallel "
|
|
4347
|
+
f"(up to {min(max_parallel, len(wave_modules))} at once, each in its own worktree)..."
|
|
4348
|
+
)
|
|
4349
|
+
worktrees = {mod["name"]: _setup_worktree(project_root, mod["name"]) for mod in wave_modules}
|
|
4350
|
+
results = {}
|
|
4351
|
+
try:
|
|
4352
|
+
with ThreadPoolExecutor(max_workers=min(max_parallel, len(wave_modules))) as executor:
|
|
4353
|
+
futures = {
|
|
4354
|
+
executor.submit(
|
|
4355
|
+
_build_module_worker, pcp_dir, mod, worktrees[mod["name"]], build_model, build_model_explicit, budget, yes,
|
|
4356
|
+
wave_number,
|
|
4357
|
+
): mod["name"]
|
|
4358
|
+
for mod in wave_modules
|
|
4359
|
+
}
|
|
4360
|
+
for future in as_completed(futures):
|
|
4361
|
+
m_name = futures[future]
|
|
4362
|
+
try:
|
|
4363
|
+
results[m_name] = future.result()
|
|
4364
|
+
except BudgetExceeded:
|
|
4365
|
+
results[m_name] = {"module": m_name, "success": False, "budget_exceeded": True}
|
|
4366
|
+
finally:
|
|
4367
|
+
pass
|
|
4368
|
+
|
|
4369
|
+
# Merge successful modules' branches back into main, serialized
|
|
4370
|
+
# (git operations on the same repo should not run concurrently).
|
|
4371
|
+
# Failed modules' worktrees are left in place for inspection —
|
|
4372
|
+
# never silently discarded.
|
|
4373
|
+
any_failed = False
|
|
4374
|
+
for mod in wave_modules:
|
|
4375
|
+
m_name = mod["name"]
|
|
4376
|
+
result = results.get(m_name, {"success": False})
|
|
4377
|
+
if result["success"]:
|
|
4378
|
+
ok, merge_output = _merge_module_branch(project_root, m_name, pcp_dir=pcp_dir)
|
|
4379
|
+
if ok:
|
|
4380
|
+
_cleanup_worktree(project_root, m_name, worktrees[m_name])
|
|
4381
|
+
else:
|
|
4382
|
+
any_failed = True
|
|
4383
|
+
console.print(f"[red]✗ Merge conflict bringing '{m_name}' back into main:[/red]\n{merge_output}")
|
|
4384
|
+
console.print(f"[dim]Worktree left at {worktrees[m_name]} for manual resolution.[/dim]")
|
|
4385
|
+
else:
|
|
4386
|
+
any_failed = True
|
|
4387
|
+
if result.get("budget_exceeded"):
|
|
4388
|
+
console.print(f"[red]'{m_name}' stopped — run-level session budget exceeded.[/red]")
|
|
4389
|
+
console.print(f"[dim]Worktree left at {worktrees[m_name]} for inspection.[/dim]")
|
|
4390
|
+
|
|
4391
|
+
if any_failed:
|
|
4392
|
+
console.print("[bold red]Build execution stopped. Please resolve findings manually.[/bold red]")
|
|
4393
|
+
sys.exit(1)
|
|
4394
|
+
|
|
4395
|
+
# Advisory dead-code/bloat sweep + audit-evidence refresh — once per
|
|
4396
|
+
# wave, after all this wave's modules are merged into main.
|
|
4397
|
+
try:
|
|
4398
|
+
from pcp.commands.audit import _run_audit, _write_audit_md
|
|
4399
|
+
from datetime import datetime, timezone
|
|
4400
|
+
audit_result = _run_audit(project_root)
|
|
4401
|
+
audit_ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
4402
|
+
_write_audit_md(pcp_dir, audit_result, audit_ts)
|
|
4403
|
+
if audit_result["tool"]:
|
|
4404
|
+
console.print(
|
|
4405
|
+
f"[dim]Audit: {len(audit_result['findings'])} dead-code finding(s) "
|
|
4406
|
+
f"({audit_result['tool']}) → .pcp/audit.md[/dim]"
|
|
4407
|
+
)
|
|
4408
|
+
except Exception as e:
|
|
4409
|
+
console.print(f"[dim]Audit skipped: {e}[/dim]")
|
|
4410
|
+
|
|
4411
|
+
try:
|
|
4412
|
+
from pcp.commands.provenance import write_provenance
|
|
4413
|
+
write_provenance(pcp_dir)
|
|
4414
|
+
except Exception as e:
|
|
4415
|
+
console.print(f"[dim]Provenance refresh skipped: {e}[/dim]")
|
|
4416
|
+
|
|
4417
|
+
try:
|
|
4418
|
+
from pcp.commands.docs import write_module_docs
|
|
4419
|
+
for mod in wave_modules:
|
|
4420
|
+
write_module_docs(pcp_dir, mod["spec_path"].parent)
|
|
4421
|
+
except Exception as e:
|
|
4422
|
+
console.print(f"[dim]Module docs refresh skipped: {e}[/dim]")
|
|
4423
|
+
|
|
4424
|
+
try:
|
|
4425
|
+
from pcp.commands.design_audit import write_design_audit
|
|
4426
|
+
write_design_audit(pcp_dir)
|
|
4427
|
+
except Exception as e:
|
|
4428
|
+
console.print(f"[dim]Design audit refresh skipped: {e}[/dim]")
|
|
4429
|
+
|
|
4430
|
+
_refresh_state(pcp_dir, modules_dir)
|
|
4431
|
+
|
|
4432
|
+
if num_waves > 1:
|
|
4433
|
+
console.print(f"\n[bold]Wave {wave_number} merge checks...[/bold]")
|
|
4434
|
+
wave_findings = _run_wave_merge(pcp_dir, wave_modules, wave_start_ref, wave_number)
|
|
4435
|
+
if wave_findings:
|
|
4436
|
+
console.print("[red bold]BLOCKED — wave merge findings:[/red bold]")
|
|
4437
|
+
for f in wave_findings:
|
|
4438
|
+
console.print(f" ✗ {f}")
|
|
4439
|
+
# A wave BLOCK used to print this and exit, leaving every criterion
|
|
4440
|
+
# in the wave marked `complete`. On 2026-07-27 the wave-level
|
|
4441
|
+
# architect review found a path-traversal vulnerability -- arbitrary
|
|
4442
|
+
# local file read through the one method named to keep that path
|
|
4443
|
+
# narrow -- said "fix before the next wave proceeds", and the
|
|
4444
|
+
# criteria that introduced it stayed complete. `pcp scan`,
|
|
4445
|
+
# current_state.md and the dashboard all reported them done, and
|
|
4446
|
+
# nothing recorded that the finding was ever raised.
|
|
4447
|
+
#
|
|
4448
|
+
# A gate that stops forward progress but leaves the defective work
|
|
4449
|
+
# marked verified is advisory in practice. If the wave says the work
|
|
4450
|
+
# is wrong, the work is not done: reopen it so the next run rebuilds
|
|
4451
|
+
# it WITH the finding as feedback, and record an escalation so the
|
|
4452
|
+
# finding survives the process that printed it.
|
|
4453
|
+
_reopen_wave_criteria(pcp_dir, wave_modules, wave_number, wave_findings)
|
|
4454
|
+
console.print("[bold red]Fix these before the next wave proceeds.[/bold red]")
|
|
4455
|
+
sys.exit(1)
|
|
4456
|
+
elif num_waves > 1:
|
|
4457
|
+
console.print(f"[green]✓ Wave {wave_number} merge checks passed.[/green]")
|
|
4458
|
+
|
|
4459
|
+
# Step 3 of the global Build Cycle — push once this wave's merges are
|
|
4460
|
+
# clean, not just at the very end, so completed work is safe on the
|
|
4461
|
+
# remote even if a later wave fails.
|
|
4462
|
+
_auto_push(project_root)
|
|
4463
|
+
|
|
4464
|
+
# Build Cycle Report (2026-07-24) — the evidence pcp build already
|
|
4465
|
+
# generates (run_log proof-of-delivery, .pcp/evidence/, telemetry.jsonl)
|
|
4466
|
+
# gets handed to the human here instead of sitting in files nobody
|
|
4467
|
+
# opens. Never fails the run — a report-writing bug must not fail a
|
|
4468
|
+
# build that otherwise succeeded.
|
|
4469
|
+
try:
|
|
4470
|
+
from pcp import build_report
|
|
4471
|
+
report_path = build_report.write(pcp_dir, _build_start_ts)
|
|
4472
|
+
console.print(f"\n[bold]Build Cycle Report[/bold] → {report_path.relative_to(project_root)}")
|
|
4473
|
+
except Exception as e:
|
|
4474
|
+
console.print(f"[dim]Build report skipped: {e}[/dim]")
|
|
4475
|
+
|
|
4476
|
+
console.print(
|
|
4477
|
+
f"\n[bold]Run total:[/bold] {budget.session_count} agent session(s), "
|
|
4478
|
+
f"~${budget.run_cost_total:.2f} (build-agent only — see .pcp/token_ledger.yaml for judge-call spend too)"
|
|
4479
|
+
)
|
|
4480
|
+
|
|
4481
|
+
# Auto-summarize telemetry — baked into the lifecycle, not a separate manual step.
|
|
4482
|
+
try:
|
|
4483
|
+
records = telemetry.load(pcp_dir)
|
|
4484
|
+
agg = telemetry.aggregate(records)
|
|
4485
|
+
total_qa = sum(v["qa_total"] for v in agg["by_module"].values())
|
|
4486
|
+
total_blocks = sum(v["qa_blocks"] for v in agg["by_module"].values())
|
|
4487
|
+
total_attempts = len(agg["build_records"])
|
|
4488
|
+
total_criteria = len({(m, c) for m, v in agg["by_module"].items() for c in v["criteria"]})
|
|
4489
|
+
avg_attempts = total_attempts / total_criteria if total_criteria else 0.0
|
|
4490
|
+
qa_rate = f"{total_blocks}/{total_qa}" if total_qa else "—"
|
|
4491
|
+
console.print(
|
|
4492
|
+
f"[dim]Telemetry: {total_criteria} criteria, {avg_attempts:.1f} avg attempts/criterion, "
|
|
4493
|
+
f"QA blocks {qa_rate} → .pcp/telemetry.jsonl ([cyan]pcp telemetry[/cyan] for full breakdown)[/dim]"
|
|
4494
|
+
)
|
|
4495
|
+
except Exception as e:
|
|
4496
|
+
console.print(f"[dim]Telemetry summary skipped: {e}[/dim]")
|
|
4497
|
+
|
|
4498
|
+
# Step 4+ of the global Build Cycle — the cycle isn't done at "committed
|
|
4499
|
+
# and pushed," it's done when the product is actually running. `pcp
|
|
4500
|
+
# deploy` already owns the checklist/gate/mandatory-approval/smoke-test
|
|
4501
|
+
# machinery (irreversible production action — human approval stays
|
|
4502
|
+
# mandatory, `--yes` still opts out for CI, unaffected by this build
|
|
4503
|
+
# run's own --yes which only ever covered the install-only prompt). This
|
|
4504
|
+
# just stops deploy from being a separate step a human has to remember
|
|
4505
|
+
# to run — every successful build run surfaces the approval prompt
|
|
4506
|
+
# itself. No-op (never blocks a successful build) if no deploy command
|
|
4507
|
+
# is configured yet, or if this session has no attached terminal (a
|
|
4508
|
+
# blocking confirm() prompt would just hang a headless/CI run — those
|
|
4509
|
+
# cases still need an explicit `pcp deploy` call).
|
|
4510
|
+
from pcp.commands.doctor import load_integrations
|
|
4511
|
+
from pcp.commands.deploy import deploy as deploy_cmd
|
|
4512
|
+
deploy_command = (load_integrations(pcp_dir).get("deploy") or {}).get("command")
|
|
4513
|
+
if deploy_command and sys.stdin.isatty():
|
|
4514
|
+
console.print("\n[bold]Build cycle complete — running deploy checklist...[/bold]")
|
|
4515
|
+
try:
|
|
4516
|
+
deploy_cmd.callback(project_path=str(project_root), yes=False, rollout="100")
|
|
4517
|
+
except SystemExit as e:
|
|
4518
|
+
if e.code:
|
|
4519
|
+
console.print(f"[dim]Deploy step exited ({e.code}) — the build itself still succeeded.[/dim]")
|
|
4520
|
+
elif deploy_command:
|
|
4521
|
+
console.print("[dim]Deploy configured but this session has no attached terminal — run `pcp deploy` manually to ship this.[/dim]")
|
|
4522
|
+
else:
|
|
4523
|
+
console.print("[dim]No deploy command configured — run `pcp doctor` then `pcp deploy` to ship this.[/dim]")
|