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/diff.py ADDED
@@ -0,0 +1,153 @@
1
+ """pcp diff — compute .pcp/diff.md (target_state vs current_state)."""
2
+
3
+ import re
4
+ import sys
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+
8
+ import click
9
+ from rich.console import Console
10
+
11
+ from pcp.pcp_dir import find_pcp_dir, NoPCPDir
12
+
13
+ console = Console()
14
+
15
+
16
+ def _extract_pending(current_state_path: Path) -> list[str]:
17
+ """Pull pending criteria lines from current_state.md."""
18
+ if not current_state_path.exists():
19
+ return []
20
+ pending = []
21
+ for line in current_state_path.read_text().splitlines():
22
+ if re.match(r"- \[ \]", line.strip()):
23
+ pending.append(line.strip()[6:]) # strip "- [ ] "
24
+ return pending
25
+
26
+
27
+ def _extract_complete(current_state_path: Path) -> list[str]:
28
+ if not current_state_path.exists():
29
+ return []
30
+ return [line.strip()[6:] for line in current_state_path.read_text().splitlines()
31
+ if re.match(r"- \[x\]", line.strip())]
32
+
33
+
34
+ def _previous_complete_snapshot(diff_md_path: Path) -> set[str]:
35
+ """Criteria recorded complete at the LAST diff run — enables the drift
36
+ split below without any new state file."""
37
+ if not diff_md_path.exists():
38
+ return set()
39
+ lines = diff_md_path.read_text().splitlines()
40
+ try:
41
+ start = lines.index("## Completed Snapshot")
42
+ except ValueError:
43
+ return set()
44
+ snap = set()
45
+ for line in lines[start + 1:]:
46
+ if line.startswith("## "):
47
+ break
48
+ if line.startswith("- "):
49
+ snap.add(line[2:].strip())
50
+ return snap
51
+
52
+
53
+ def _extract_coverage_score(current_state_path: Path) -> str:
54
+ if not current_state_path.exists():
55
+ return "unknown"
56
+ m = re.search(r"acceptance coverage: ([\d.]+)", current_state_path.read_text())
57
+ return f"{float(m.group(1)):.0%}" if m else "unknown"
58
+
59
+
60
+ def run_diff(pcp_dir: Path) -> dict | None:
61
+ """Core diff.md computation, reusable outside the CLI command.
62
+
63
+ Returns {"path": diff_md, "pending": [...], "regressions": [...]}, or
64
+ None if the prerequisites (target_state.md, current_state.md) aren't
65
+ there yet -- silent by design so a caller like `pcp scan` can call this
66
+ on every run without it being an error before a project has a
67
+ target_state.md written.
68
+ """
69
+ target_state_path = pcp_dir / "target_state.md"
70
+ current_state_path = pcp_dir / "current_state.md"
71
+
72
+ if not target_state_path.exists() or not current_state_path.exists():
73
+ return None
74
+
75
+ pending = _extract_pending(current_state_path)
76
+ complete = _extract_complete(current_state_path)
77
+ score = _extract_coverage_score(current_state_path)
78
+ timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
79
+
80
+ diff_md = pcp_dir / "diff.md"
81
+ # Drift split (arXiv:2505.02709 GD_actions/GD_inaction, 2026-07-17):
82
+ # a pending criterion that was COMPLETE at the last diff run regressed —
83
+ # drift by commission (something actively moved away from target). A
84
+ # never-complete pending criterion is drift by omission — not yet
85
+ # pursued. Different failure modes, different urgency.
86
+ prev_complete = _previous_complete_snapshot(diff_md)
87
+ regressions = [p for p in pending if p in prev_complete]
88
+ not_yet = [p for p in pending if p not in prev_complete]
89
+
90
+ lines = [
91
+ "# Diff — Target vs Current State",
92
+ f"Computed: {timestamp}",
93
+ f"Coverage: {score}",
94
+ "",
95
+ "## Target State",
96
+ "",
97
+ target_state_path.read_text().strip(),
98
+ "",
99
+ "## Regressions (drift by commission — was complete, now isn't)",
100
+ "",
101
+ ]
102
+ if regressions:
103
+ lines += [f"- [ ] {p}" for p in regressions]
104
+ else:
105
+ lines.append("_None._")
106
+
107
+ lines += ["", "## Pending Gaps (drift by omission — not yet built)", ""]
108
+ if not_yet:
109
+ lines += [f"- [ ] {p}" for p in not_yet]
110
+ else:
111
+ lines.append("_No pending criteria — all acceptance criteria met._")
112
+
113
+ lines += ["", "## Completed Snapshot", ""]
114
+ lines += [f"- {c}" for c in complete] or ["_None._"]
115
+
116
+ lines += ["", "## Next Actions", ""]
117
+ if regressions:
118
+ lines.append("FIRST: investigate the regressions above — something moved away from target. Then:")
119
+ if pending:
120
+ lines.append("Implement the pending criteria above. Re-run `pcp scan` after each change.")
121
+ else:
122
+ lines.append("All acceptance criteria met. Advance SDLC phase via `pcp deploy-check`.")
123
+
124
+ diff_md.write_text("\n".join(lines) + "\n")
125
+ return {"path": diff_md, "pending": pending, "regressions": regressions}
126
+
127
+
128
+ @click.command()
129
+ @click.option("--path", "project_path", type=click.Path(), default=None)
130
+ def diff(project_path: str | None):
131
+ """Compute .pcp/diff.md — target state vs current state gap."""
132
+ try:
133
+ pcp_dir = find_pcp_dir(Path(project_path) if project_path else None)
134
+ except NoPCPDir as e:
135
+ console.print(f"[red]Error:[/red] {e}")
136
+ sys.exit(2)
137
+
138
+ project_root = pcp_dir.parent
139
+
140
+ if not (pcp_dir / "target_state.md").exists():
141
+ console.print("[yellow]No target_state.md found. Create it to track the ideal end state.[/yellow]")
142
+ sys.exit(0)
143
+
144
+ if not (pcp_dir / "current_state.md").exists():
145
+ console.print("[yellow]No current_state.md found. Run `pcp scan` first.[/yellow]")
146
+ sys.exit(2)
147
+
148
+ result = run_diff(pcp_dir)
149
+
150
+ reg_note = f", [red]{len(result['regressions'])} regression(s)[/red]" if result["regressions"] else ""
151
+ console.print(
152
+ f"[dim]{len(result['pending'])} pending gap(s){reg_note}[/dim] → {result['path'].relative_to(project_root)}"
153
+ )
@@ -0,0 +1,355 @@
1
+ """pcp diff-reduce — Loop 2, the diff-reduction loop.
2
+
3
+ Reads the build capsule (run_ledger.jsonl + acceptance.yaml status +
4
+ validate-strategy's coverage_gaps), figures out what's still open against
5
+ spec, and drives it closed -- bounded and gated, not an unattended
6
+ while-True. Designed 2026-07-31 after the capsule fields (wave_number,
7
+ logic_tier, internal/external deps, real coupling) landed; this is the
8
+ first consumer of that data, not a parallel mechanism.
9
+
10
+ Five gates, each reusing an existing PCP mechanism rather than inventing
11
+ one:
12
+
13
+ A. Round cap (3, PCP's existing 3-attempt convention, not the 6-round
14
+ cap loop_until_dry_breakdown uses -- each round here triggers a real
15
+ build, not a cheap Haiku call). Stops early the moment a round's plan
16
+ is empty; no need for a 2-consecutive-dry pattern since "the diff is
17
+ empty" is a deterministic fact here, not a judgment call that might
18
+ find something on a second look.
19
+ B. Human approval, split by risk:
20
+ - existing pending criteria (already spec/pm-approved, just not
21
+ built) -> straight to `pcp build`, no new gate needed.
22
+ - a capability gap with NO criterion at all -> routed through
23
+ `pcp pm`'s own existing confirm, unconditionally, every time.
24
+ Only attempted when a human is actually attached (stdin is a
25
+ tty) -- headless/cron runs report the gap and stop there, never
26
+ auto-propose new scope unattended.
27
+ C. Execution never bypasses `pcp build`'s own gate stack (test/lint/
28
+ SAST/architect-review/wave-merge) -- called via its real callback,
29
+ not reimplemented.
30
+ D. Freshness re-check every round, not just once: objective_hash (existing
31
+ mechanism) PLUS a per-module spec+acceptance content hash (same
32
+ pattern, one level deeper) stamped at round start and re-checked at
33
+ round end. A mid-round edit to either aborts the loop and escalates
34
+ instead of continuing to build against a target that moved.
35
+ E. Cost ceiling: none of its own. Inherits PCP_MAX_BUILD_SESSIONS /
36
+ PCP_PROJECT_BUDGET_USD via the real `pcp build` call -- no separate
37
+ spend surface.
38
+
39
+ Plus the absence-blindspot fix: before trusting any criterion's
40
+ `status: complete`, deterministic ones get spot-re-checked via the same
41
+ evaluator `pcp verify` uses (scan.py's _evaluate_criterion) -- "check
42
+ aimed at wrong target reads identical to a clean pass" is a confirmed
43
+ 3x-recurring bug class in this codebase (Postgres port squat, pytest venv
44
+ resolution, --version metadata), and a diff computed from a stale
45
+ `status: complete` would silently never surface that gap for a human to
46
+ even see, let alone approve. A criterion that fails its own re-check gets
47
+ reopened (mirrors `_reopen_wave_criteria`'s existing shape) and one
48
+ escalation recorded per module -- never a silent flip.
49
+
50
+ And the round-cap-hit fix: if all `max_rounds` are used and the gap is
51
+ still open, that gets an explicit escalation too. A loop that stops
52
+ without saying so is advisory in practice, not really stopped (same
53
+ lesson as the wave-BLOCK-left-work-marked-verified bug).
54
+ """
55
+
56
+ import hashlib
57
+ import sys
58
+ from pathlib import Path
59
+
60
+ import click
61
+ from rich.console import Console
62
+
63
+ from pcp.pcp_dir import find_pcp_dir, get_modules_dir, NoPCPDir
64
+ from pcp.schema.validator import load_yaml
65
+ from pcp import escalations
66
+ from pcp import run_log
67
+
68
+ console = Console()
69
+
70
+ DEFAULT_MAX_ROUNDS = 3
71
+
72
+
73
+ def _module_spec_hash(pcp_dir: Path, module_name: str) -> str:
74
+ """Same pattern as objective_conflicts.objective_hash, one level deeper
75
+ -- a fingerprint of the files a human would need to edit for this
76
+ module's own scope to have genuinely changed.
77
+
78
+ acceptance.yaml's `status`/`verified_by` fields are stripped before
79
+ hashing -- those are exactly what THIS LOOP's own build step is
80
+ expected to write every round (a completed criterion), so including
81
+ them would make Gate D fire a false "drift" on every single successful
82
+ round. What's still watched: the criteria list itself (a human adding/
83
+ removing/editing a criterion via `pcp pm` mid-loop is real drift), and
84
+ spec.yaml in full (protected, never touched by a build)."""
85
+ mod_dir = pcp_dir / "strategy" / "modules" / module_name
86
+ spec_path = mod_dir / "spec.yaml"
87
+ spec_text = spec_path.read_text() if spec_path.exists() else ""
88
+
89
+ acc_path = mod_dir / "acceptance.yaml"
90
+ acc_text = ""
91
+ if acc_path.exists():
92
+ acc = load_yaml(acc_path) or {}
93
+ stripped = [
94
+ {k: v for k, v in c.items() if k not in ("status", "verified_by")}
95
+ for c in (acc.get("criteria") or [])
96
+ ]
97
+ import json
98
+ acc_text = json.dumps(stripped, sort_keys=True)
99
+
100
+ return hashlib.sha256("\x00".join([spec_text, acc_text]).encode()).hexdigest()
101
+
102
+
103
+ def _in_scope_module_names(pcp_dir: Path, module_name: str | None) -> list[str]:
104
+ modules_dir = get_modules_dir(pcp_dir)
105
+ if not modules_dir.exists():
106
+ return []
107
+ if module_name:
108
+ return [module_name] if (modules_dir / module_name).exists() else []
109
+ return sorted(p.name for p in modules_dir.iterdir() if p.is_dir())
110
+
111
+
112
+ def _freshness_stamp(pcp_dir: Path, module_names: list[str]) -> dict:
113
+ from pcp import objective_conflicts
114
+ return {
115
+ "objective_hash": objective_conflicts.objective_hash(pcp_dir),
116
+ "module_hashes": {m: _module_spec_hash(pcp_dir, m) for m in module_names},
117
+ }
118
+
119
+
120
+ def _freshness_drifted(pcp_dir: Path, stamp: dict, module_names: list[str]) -> str | None:
121
+ """Returns a human-readable reason if something moved since `stamp` was
122
+ taken, else None. Checked, never silently trusted -- see Gate D."""
123
+ from pcp import objective_conflicts
124
+ if objective_conflicts.objective_hash(pcp_dir) != stamp["objective_hash"]:
125
+ return "objective.md/target_state.md changed mid-round"
126
+ for m in module_names:
127
+ if _module_spec_hash(pcp_dir, m) != stamp["module_hashes"].get(m):
128
+ return f"module '{m}'s spec.yaml/acceptance.yaml changed mid-round"
129
+ return None
130
+
131
+
132
+ def _spot_check_complete_criteria(pcp_dir: Path, project_root: Path, module_names: list[str]) -> list[str]:
133
+ """Absence-blindspot fix: re-run each deterministic 'complete' criterion's
134
+ own check (verify.py's evaluator, so this and `pcp verify` never
135
+ disagree about what a check means) before trusting the diff computed
136
+ from acceptance.yaml. A criterion whose re-check now fails gets
137
+ reopened -- status back to pending, verified_by cleared -- because a
138
+ diff computed from a status that's silently gone stale is exactly the
139
+ failure this loop exists to close, not repeat. Returns the list of
140
+ reopened 'module/criterion_id' strings."""
141
+ from pcp.commands.verify import _evaluate
142
+
143
+ _DETERMINISTIC_CHECKS = {"file_exists", "ast_pattern", "url_responds", "dom_contains", "visual"}
144
+ reopened: list[str] = []
145
+
146
+ for mod_name in module_names:
147
+ acc_path = pcp_dir / "strategy" / "modules" / mod_name / "acceptance.yaml"
148
+ if not acc_path.exists():
149
+ continue
150
+ acc = load_yaml(acc_path) or {}
151
+ criteria = acc.get("criteria", []) or []
152
+ mod_reopened: list[str] = []
153
+ for c in criteria:
154
+ if c.get("status") != "complete":
155
+ continue
156
+ check = c.get("check", "manual")
157
+ if check not in _DETERMINISTIC_CHECKS:
158
+ continue # manual/test_passes have no re-check -- can't spot-check these, not a gap this pass can close
159
+ try:
160
+ status, detail = _evaluate(pcp_dir, project_root, mod_name, c)
161
+ except Exception:
162
+ continue # evaluator itself failing isn't evidence the criterion is wrong -- don't compound one failure into a false reopen
163
+ if status != "complete":
164
+ c["status"] = "pending"
165
+ c.pop("verified_by", None)
166
+ mod_reopened.append(c["id"])
167
+ reopened.append(f"{mod_name}/{c['id']}")
168
+ if mod_reopened:
169
+ acc_path.write_text(__import__("yaml").dump(acc, default_flow_style=False))
170
+ escalations.record(
171
+ pcp_dir, mod_name, "diff-reduce-spotcheck",
172
+ route="diff-reduce-reopen",
173
+ findings=[
174
+ f"Re-running {mod_name}/{cid}'s own deterministic check no longer confirms it "
175
+ "complete -- reopened by pcp diff-reduce's spot-check before planning this round."
176
+ for cid in mod_reopened
177
+ ],
178
+ )
179
+ return reopened
180
+
181
+
182
+ def _compute_gap(pcp_dir: Path, module_name: str | None) -> dict:
183
+ """One round's view of what's still open. `pending` reuses build.py's
184
+ own gathering function (kept in sync with `pcp build` by construction);
185
+ coverage_gaps/missing_modules only make sense project-wide, so they're
186
+ skipped when scoped to one module."""
187
+ from pcp.commands.build import gather_modules_to_build
188
+ pending_modules = gather_modules_to_build(pcp_dir, module_name)
189
+ pending_count = sum(len(m["pending_criteria"]) for m in pending_modules)
190
+
191
+ coverage_gaps: list[dict] = []
192
+ missing_modules: list[dict] = []
193
+ if module_name is None:
194
+ from pcp.commands.validate_strategy import run_validate_strategy
195
+ try:
196
+ val = run_validate_strategy(pcp_dir)
197
+ except Exception as e:
198
+ val = None
199
+ console.print(f"[dim]diff-reduce: validate-strategy skipped this round ({e})[/dim]")
200
+ if val:
201
+ coverage_gaps = val.get("coverage_gaps", []) or []
202
+ missing_modules = val.get("missing_modules", []) or []
203
+
204
+ return {
205
+ "pending_modules": pending_modules, "pending_count": pending_count,
206
+ "coverage_gaps": coverage_gaps, "missing_modules": missing_modules,
207
+ }
208
+
209
+
210
+ def run_diff_reduce(
211
+ pcp_dir: Path, project_root: Path, module_name: str | None = None,
212
+ yes: bool = False, max_rounds: int = DEFAULT_MAX_ROUNDS,
213
+ ) -> dict:
214
+ """Reusable core -- returns a summary dict. The CLI command below is a
215
+ thin wrapper, same shape as run_validate_strategy/build's own split."""
216
+ rounds_run = 0
217
+ stopped_reason = "not started"
218
+ interactive = sys.stdin.isatty()
219
+
220
+ for round_num in range(1, max_rounds + 1):
221
+ rounds_run = round_num
222
+ module_names = _in_scope_module_names(pcp_dir, module_name)
223
+
224
+ # Gate: concurrent-writer check (open_runs already exists for
225
+ # exactly this -- a PRE record with no matching POST is a run
226
+ # still in progress).
227
+ open_now = run_log.open_runs(run_log.load(pcp_dir))
228
+ colliding = [r for r in open_now if r.get("module") in module_names]
229
+ if colliding:
230
+ stopped_reason = f"another run is open on module(s) {sorted({r['module'] for r in colliding})} -- not racing it"
231
+ console.print(f"[yellow]diff-reduce: {stopped_reason}[/yellow]")
232
+ break
233
+
234
+ # Gate D, start-of-round stamp.
235
+ stamp = _freshness_stamp(pcp_dir, module_names)
236
+
237
+ # Absence-blindspot spot-check, before the gap is even computed.
238
+ reopened = _spot_check_complete_criteria(pcp_dir, project_root, module_names)
239
+ if reopened:
240
+ console.print(f"[yellow]diff-reduce: spot-check reopened {len(reopened)} criteria that no longer pass their own check: {reopened}[/yellow]")
241
+
242
+ gap = _compute_gap(pcp_dir, module_name)
243
+ console.print(
244
+ f"\n[bold]diff-reduce round {round_num}/{max_rounds}:[/bold] "
245
+ f"{gap['pending_count']} pending criteria, "
246
+ f"{len(gap['coverage_gaps'])} coverage gap(s), {len(gap['missing_modules'])} missing module(s)"
247
+ )
248
+
249
+ if not gap["pending_count"] and not gap["coverage_gaps"] and not gap["missing_modules"]:
250
+ stopped_reason = "dry -- nothing open"
251
+ break
252
+
253
+ # Gate B, expansion half: new criteria only ever proposed with a
254
+ # human attached, and only through pm's own unconditional confirm.
255
+ if (gap["coverage_gaps"] or gap["missing_modules"]) and interactive:
256
+ from pcp.commands.pm import pm as pm_cmd
257
+ for g in gap["coverage_gaps"]:
258
+ intent = f"Cover this gap found by validate-strategy: {g.get('area', '')} -- {g.get('quote', '')}"
259
+ try:
260
+ pm_cmd.callback(intent=intent, project_path=str(project_root))
261
+ except SystemExit as e:
262
+ if e.code:
263
+ console.print(f"[dim]diff-reduce: pm step for coverage gap exited ({e.code}), continuing[/dim]")
264
+ for g in gap["missing_modules"]:
265
+ intent = f"Add missing module found by validate-strategy: {g.get('name', '')} -- {g.get('reason', '')}"
266
+ try:
267
+ pm_cmd.callback(intent=intent, project_path=str(project_root))
268
+ except SystemExit as e:
269
+ if e.code:
270
+ console.print(f"[dim]diff-reduce: pm step for missing module exited ({e.code}), continuing[/dim]")
271
+ gap = _compute_gap(pcp_dir, module_name)
272
+ elif gap["coverage_gaps"] or gap["missing_modules"]:
273
+ console.print(
274
+ f"[dim]diff-reduce: {len(gap['coverage_gaps'])} coverage gap(s)/{len(gap['missing_modules'])} "
275
+ "missing module(s) found, but no human is attached (non-interactive run) -- not "
276
+ "proposing new scope unattended. Run `pcp pm`/`pcp kickoff` yourself, or re-run "
277
+ "diff-reduce interactively.[/dim]"
278
+ )
279
+
280
+ if not gap["pending_count"]:
281
+ stopped_reason = "dry after this round's coverage-gap pass -- nothing buildable"
282
+ break
283
+
284
+ # Gate B, existing-pending half + the round's own spend confirm.
285
+ if not yes:
286
+ names = ", ".join(f"{m['name']} ({len(m['pending_criteria'])})" for m in gap["pending_modules"])
287
+ if not click.confirm(f"\nBuild this round's plan -- {names}?", default=True):
288
+ stopped_reason = "human declined this round's build"
289
+ break
290
+
291
+ # Gate C: real pcp build, unmodified gate stack, real spend ceilings.
292
+ from pcp.commands.build import build as build_cmd
293
+ try:
294
+ build_cmd.callback(module_name=module_name, project_path=str(project_root), yes=yes)
295
+ except SystemExit as e:
296
+ if e.code:
297
+ console.print(f"[dim]diff-reduce: build step exited ({e.code}) this round[/dim]")
298
+
299
+ # Gate D, end-of-round recheck.
300
+ drift = _freshness_drifted(pcp_dir, stamp, module_names)
301
+ if drift:
302
+ escalations.record(
303
+ pcp_dir, module_name or "_diff_reduce", f"round_{round_num}",
304
+ route="diff-reduce-drift",
305
+ findings=[f"diff-reduce round {round_num} aborted: {drift}. Not continuing against a target that moved."],
306
+ )
307
+ stopped_reason = f"aborted -- {drift}"
308
+ break
309
+ else:
310
+ stopped_reason = f"round cap ({max_rounds}) reached"
311
+
312
+ final_gap = _compute_gap(pcp_dir, module_name)
313
+ gap_still_open = bool(final_gap["pending_count"] or final_gap["coverage_gaps"] or final_gap["missing_modules"])
314
+
315
+ if stopped_reason == f"round cap ({max_rounds}) reached" and gap_still_open:
316
+ escalations.record(
317
+ pcp_dir, module_name or "_diff_reduce", "round_cap",
318
+ route="diff-reduce-cap-hit",
319
+ findings=[
320
+ f"pcp diff-reduce used all {max_rounds} round(s) and the gap is still open "
321
+ f"({final_gap['pending_count']} pending criteria, "
322
+ f"{len(final_gap['coverage_gaps'])} coverage gap(s)) -- stopping is silent "
323
+ "otherwise. Re-run diff-reduce, or investigate why it isn't converging."
324
+ ],
325
+ )
326
+
327
+ console.print(f"\n[bold]diff-reduce finished:[/bold] {rounds_run} round(s) run, stopped because: {stopped_reason}")
328
+ return {
329
+ "rounds_run": rounds_run, "stopped_reason": stopped_reason,
330
+ "gap_still_open": gap_still_open, "final_gap": final_gap,
331
+ }
332
+
333
+
334
+ @click.command(name="diff-reduce")
335
+ @click.option("--module", "module_name", default=None, help="Scope to one module only.")
336
+ @click.option("--yes", "yes", is_flag=True,
337
+ help="Skip diff-reduce's own per-round build confirm (CI use). Never bypasses "
338
+ "pcp pm's own confirm for new-scope criteria -- that gate is unconditional.")
339
+ @click.option("--max-rounds", "max_rounds", type=int, default=DEFAULT_MAX_ROUNDS,
340
+ help=f"Round cap, default {DEFAULT_MAX_ROUNDS} (PCP's existing 3-attempt convention).")
341
+ @click.option("--path", "project_path", type=click.Path(), default=None,
342
+ help="Project root override.")
343
+ def diff_reduce(module_name: str | None, yes: bool, max_rounds: int, project_path: str | None):
344
+ """Loop 2 -- read the build capsule, close the gap between spec and
345
+ built state, bounded and gated. See this module's docstring for the
346
+ five gates and why each one reuses an existing PCP mechanism."""
347
+ try:
348
+ pcp_dir = find_pcp_dir(Path(project_path) if project_path else None)
349
+ except NoPCPDir as e:
350
+ console.print(f"[red]Error:[/red] {e}")
351
+ sys.exit(2)
352
+
353
+ project_root = pcp_dir.parent
354
+ result = run_diff_reduce(pcp_dir, project_root, module_name=module_name, yes=yes, max_rounds=max_rounds)
355
+ sys.exit(1 if result["gap_still_open"] else 0)