program-context-protocol 0.12.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pcp/__init__.py +3 -0
- pcp/assertions.py +152 -0
- pcp/attest.py +111 -0
- pcp/build_loop_bypass.py +76 -0
- pcp/build_report.py +54 -0
- pcp/capture.py +339 -0
- pcp/cli.py +104 -0
- pcp/commands/__init__.py +0 -0
- pcp/commands/amend.py +283 -0
- pcp/commands/architect_review.py +291 -0
- pcp/commands/architecture_justification.py +164 -0
- pcp/commands/audit.py +371 -0
- pcp/commands/build.py +4523 -0
- pcp/commands/build_plan.py +153 -0
- pcp/commands/build_status.py +83 -0
- pcp/commands/capture.py +72 -0
- pcp/commands/check.py +584 -0
- pcp/commands/context.py +151 -0
- pcp/commands/control_audit_cmd.py +54 -0
- pcp/commands/correct_objective.py +160 -0
- pcp/commands/dashboard.py +732 -0
- pcp/commands/deploy.py +199 -0
- pcp/commands/deploy_check.py +134 -0
- pcp/commands/design_audit.py +323 -0
- pcp/commands/diff.py +153 -0
- pcp/commands/diff_reduce.py +355 -0
- pcp/commands/docs.py +538 -0
- pcp/commands/doctor.py +820 -0
- pcp/commands/escalations_cmd.py +64 -0
- pcp/commands/gate.py +209 -0
- pcp/commands/import_project.py +404 -0
- pcp/commands/init.py +1634 -0
- pcp/commands/install_hook.py +283 -0
- pcp/commands/install_skill.py +48 -0
- pcp/commands/kickoff.py +772 -0
- pcp/commands/narrative_lint.py +54 -0
- pcp/commands/objective_conflicts_cmd.py +68 -0
- pcp/commands/pm.py +504 -0
- pcp/commands/pressure_test_cmd.py +72 -0
- pcp/commands/provenance.py +313 -0
- pcp/commands/prune.py +179 -0
- pcp/commands/report.py +49 -0
- pcp/commands/run_log_cmd.py +122 -0
- pcp/commands/scan.py +346 -0
- pcp/commands/self_update.py +125 -0
- pcp/commands/status.py +180 -0
- pcp/commands/takeover.py +55 -0
- pcp/commands/telemetry_cmd.py +167 -0
- pcp/commands/validate_module.py +153 -0
- pcp/commands/validate_strategy.py +413 -0
- pcp/commands/verify.py +166 -0
- pcp/commands/verify_syntax_fix.py +74 -0
- pcp/commands/watch.py +372 -0
- pcp/config_audit.py +141 -0
- pcp/context_map.py +124 -0
- pcp/control_audit.py +159 -0
- pcp/coupling.py +178 -0
- pcp/coverage_audit.py +77 -0
- pcp/decision_log.py +134 -0
- pcp/discovery/__init__.py +0 -0
- pcp/discovery/clusters.py +124 -0
- pcp/discovery/graph.py +110 -0
- pcp/discovery/scanner.py +109 -0
- pcp/escalations.py +193 -0
- pcp/evidence.py +30 -0
- pcp/evidence_chain.py +56 -0
- pcp/impact.py +164 -0
- pcp/install_approvals.py +44 -0
- pcp/integrity_audit.py +176 -0
- pcp/librarian.py +89 -0
- pcp/llm/__init__.py +0 -0
- pcp/llm/client.py +183 -0
- pcp/llm/coding_agent_contract.py +104 -0
- pcp/llm/harness/__init__.py +12 -0
- pcp/llm/harness/agy.py +121 -0
- pcp/llm/harness/agy_coding_loop.py +180 -0
- pcp/llm/harness/claude.py +241 -0
- pcp/llm/ledger.py +47 -0
- pcp/narrative_lint.py +229 -0
- pcp/nav_graph.py +226 -0
- pcp/objective_conflicts.py +129 -0
- pcp/operational.py +70 -0
- pcp/orphaned_work.py +262 -0
- pcp/pcp_dir.py +35 -0
- pcp/pcp_status.py +313 -0
- pcp/policy.py +81 -0
- pcp/pressure_test.py +196 -0
- pcp/qa.py +445 -0
- pcp/run_log.py +225 -0
- pcp/schema/__init__.py +0 -0
- pcp/schema/ci_rules.schema.json +106 -0
- pcp/schema/controls.schema.json +39 -0
- pcp/schema/module_acceptance.schema.json +144 -0
- pcp/schema/module_spec.schema.json +78 -0
- pcp/schema/sdlc_phase.schema.json +52 -0
- pcp/schema/validator.py +77 -0
- pcp/skill_data/pcp/SKILL.md +1897 -0
- pcp/spec_write.py +269 -0
- pcp/spend.py +77 -0
- pcp/symbols.py +86 -0
- pcp/telemetry.py +308 -0
- pcp/uat.py +271 -0
- pcp/version_drift.py +222 -0
- program_context_protocol-0.12.4.dist-info/METADATA +123 -0
- program_context_protocol-0.12.4.dist-info/RECORD +109 -0
- program_context_protocol-0.12.4.dist-info/WHEEL +4 -0
- program_context_protocol-0.12.4.dist-info/entry_points.txt +2 -0
- program_context_protocol-0.12.4.dist-info/licenses/LICENSE-APACHE +202 -0
- program_context_protocol-0.12.4.dist-info/licenses/LICENSE-MIT +21 -0
pcp/commands/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)
|