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.
Files changed (109) hide show
  1. pcp/__init__.py +3 -0
  2. pcp/assertions.py +152 -0
  3. pcp/attest.py +111 -0
  4. pcp/build_loop_bypass.py +76 -0
  5. pcp/build_report.py +54 -0
  6. pcp/capture.py +339 -0
  7. pcp/cli.py +104 -0
  8. pcp/commands/__init__.py +0 -0
  9. pcp/commands/amend.py +283 -0
  10. pcp/commands/architect_review.py +291 -0
  11. pcp/commands/architecture_justification.py +164 -0
  12. pcp/commands/audit.py +371 -0
  13. pcp/commands/build.py +4523 -0
  14. pcp/commands/build_plan.py +153 -0
  15. pcp/commands/build_status.py +83 -0
  16. pcp/commands/capture.py +72 -0
  17. pcp/commands/check.py +584 -0
  18. pcp/commands/context.py +151 -0
  19. pcp/commands/control_audit_cmd.py +54 -0
  20. pcp/commands/correct_objective.py +160 -0
  21. pcp/commands/dashboard.py +732 -0
  22. pcp/commands/deploy.py +199 -0
  23. pcp/commands/deploy_check.py +134 -0
  24. pcp/commands/design_audit.py +323 -0
  25. pcp/commands/diff.py +153 -0
  26. pcp/commands/diff_reduce.py +355 -0
  27. pcp/commands/docs.py +538 -0
  28. pcp/commands/doctor.py +820 -0
  29. pcp/commands/escalations_cmd.py +64 -0
  30. pcp/commands/gate.py +209 -0
  31. pcp/commands/import_project.py +404 -0
  32. pcp/commands/init.py +1634 -0
  33. pcp/commands/install_hook.py +283 -0
  34. pcp/commands/install_skill.py +48 -0
  35. pcp/commands/kickoff.py +772 -0
  36. pcp/commands/narrative_lint.py +54 -0
  37. pcp/commands/objective_conflicts_cmd.py +68 -0
  38. pcp/commands/pm.py +504 -0
  39. pcp/commands/pressure_test_cmd.py +72 -0
  40. pcp/commands/provenance.py +313 -0
  41. pcp/commands/prune.py +179 -0
  42. pcp/commands/report.py +49 -0
  43. pcp/commands/run_log_cmd.py +122 -0
  44. pcp/commands/scan.py +346 -0
  45. pcp/commands/self_update.py +125 -0
  46. pcp/commands/status.py +180 -0
  47. pcp/commands/takeover.py +55 -0
  48. pcp/commands/telemetry_cmd.py +167 -0
  49. pcp/commands/validate_module.py +153 -0
  50. pcp/commands/validate_strategy.py +413 -0
  51. pcp/commands/verify.py +166 -0
  52. pcp/commands/verify_syntax_fix.py +74 -0
  53. pcp/commands/watch.py +372 -0
  54. pcp/config_audit.py +141 -0
  55. pcp/context_map.py +124 -0
  56. pcp/control_audit.py +159 -0
  57. pcp/coupling.py +178 -0
  58. pcp/coverage_audit.py +77 -0
  59. pcp/decision_log.py +134 -0
  60. pcp/discovery/__init__.py +0 -0
  61. pcp/discovery/clusters.py +124 -0
  62. pcp/discovery/graph.py +110 -0
  63. pcp/discovery/scanner.py +109 -0
  64. pcp/escalations.py +193 -0
  65. pcp/evidence.py +30 -0
  66. pcp/evidence_chain.py +56 -0
  67. pcp/impact.py +164 -0
  68. pcp/install_approvals.py +44 -0
  69. pcp/integrity_audit.py +176 -0
  70. pcp/librarian.py +89 -0
  71. pcp/llm/__init__.py +0 -0
  72. pcp/llm/client.py +183 -0
  73. pcp/llm/coding_agent_contract.py +104 -0
  74. pcp/llm/harness/__init__.py +12 -0
  75. pcp/llm/harness/agy.py +121 -0
  76. pcp/llm/harness/agy_coding_loop.py +180 -0
  77. pcp/llm/harness/claude.py +241 -0
  78. pcp/llm/ledger.py +47 -0
  79. pcp/narrative_lint.py +229 -0
  80. pcp/nav_graph.py +226 -0
  81. pcp/objective_conflicts.py +129 -0
  82. pcp/operational.py +70 -0
  83. pcp/orphaned_work.py +262 -0
  84. pcp/pcp_dir.py +35 -0
  85. pcp/pcp_status.py +313 -0
  86. pcp/policy.py +81 -0
  87. pcp/pressure_test.py +196 -0
  88. pcp/qa.py +445 -0
  89. pcp/run_log.py +225 -0
  90. pcp/schema/__init__.py +0 -0
  91. pcp/schema/ci_rules.schema.json +106 -0
  92. pcp/schema/controls.schema.json +39 -0
  93. pcp/schema/module_acceptance.schema.json +144 -0
  94. pcp/schema/module_spec.schema.json +78 -0
  95. pcp/schema/sdlc_phase.schema.json +52 -0
  96. pcp/schema/validator.py +77 -0
  97. pcp/skill_data/pcp/SKILL.md +1897 -0
  98. pcp/spec_write.py +269 -0
  99. pcp/spend.py +77 -0
  100. pcp/symbols.py +86 -0
  101. pcp/telemetry.py +308 -0
  102. pcp/uat.py +271 -0
  103. pcp/version_drift.py +222 -0
  104. program_context_protocol-0.12.4.dist-info/METADATA +123 -0
  105. program_context_protocol-0.12.4.dist-info/RECORD +109 -0
  106. program_context_protocol-0.12.4.dist-info/WHEEL +4 -0
  107. program_context_protocol-0.12.4.dist-info/entry_points.txt +2 -0
  108. program_context_protocol-0.12.4.dist-info/licenses/LICENSE-APACHE +202 -0
  109. 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]")