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/__init__.py
ADDED
pcp/assertions.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Deterministic objective-coverage scoring — replaces the LLM-judged
|
|
2
|
+
coverage_score with keyword-overlap graph reachability when possible,
|
|
3
|
+
mirroring how coupling.py already replaced the LLM-judged coupling_score
|
|
4
|
+
with networkx graph math (same pioneer-claim, same Logic-Tier Selection
|
|
5
|
+
philosophy: cheapest rung that correctly makes the decision).
|
|
6
|
+
|
|
7
|
+
coverage_score is the one LLM-judged number CLAUDE.md's own coverage_audit.py
|
|
8
|
+
section flags as a real Goodhart risk — a spec can be word-smithed to score
|
|
9
|
+
well without real coverage, and coverage_audit.py only ever detects the
|
|
10
|
+
resulting inconsistency/drift after the fact, it can't prevent it. This is a
|
|
11
|
+
genuinely different mitigation: score numbered assertions parsed straight
|
|
12
|
+
from objective.md's own text against each module's declared
|
|
13
|
+
objective_coverage via keyword overlap — zero LLM calls, zero flakiness,
|
|
14
|
+
immune to word-smithing the way a semantic judgment call isn't.
|
|
15
|
+
|
|
16
|
+
Deliberately not a replacement for the LLM's judgment across the board:
|
|
17
|
+
contradictions/overlaps/missing_modules stay genuinely semantic and keep
|
|
18
|
+
going through validate_strategy.py's LLM call unchanged — only
|
|
19
|
+
coverage_score/coverage_gaps get the deterministic treatment here, the same
|
|
20
|
+
narrow scope coupling.py's own history establishes (see CLAUDE.md's
|
|
21
|
+
"Updated 2026-06-30" coupling entry).
|
|
22
|
+
|
|
23
|
+
Honest limitation, stated rather than hidden: "does this module's free-text
|
|
24
|
+
objective_coverage actually, semantically cover this assertion" is still a
|
|
25
|
+
real judgment call that keyword overlap can get wrong in both directions —
|
|
26
|
+
false positive (shared vocabulary, no real coverage) and false negative
|
|
27
|
+
(real coverage, different words). Same class of approximation
|
|
28
|
+
docs.py's _brd_keywords() already accepts for BRD-item matching — a rung-1
|
|
29
|
+
heuristic, not ground truth, and it says so rather than pretending
|
|
30
|
+
precision it doesn't have.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import re
|
|
34
|
+
|
|
35
|
+
_ASSERTION_PATTERN = re.compile(r"^[ \t]*(\d+)\.[ \t]+(.+)$", re.MULTILINE)
|
|
36
|
+
_KEYWORD_PATTERN = re.compile(r"[a-zA-Z]{5,}")
|
|
37
|
+
_OVERLAP_THRESHOLD = 0.25 # fraction of an assertion's own keywords that must appear in a module's coverage text
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse_assertions(objective_text: str) -> list[dict]:
|
|
41
|
+
"""Numbered list items anywhere in objective.md — e.g. the numbered
|
|
42
|
+
outcomes under a '## What Success Looks Like' heading, the exact shape
|
|
43
|
+
`pcp kickoff`'s own SYSTEM_PROMPT already generates for every new
|
|
44
|
+
project. Returns [] if the document has no numbered list at all (every
|
|
45
|
+
pre-existing objective.md written before this convention existed) —
|
|
46
|
+
callers MUST treat an empty list as "cannot score deterministically
|
|
47
|
+
yet", not as "zero assertions found", and fall back to the LLM-judged
|
|
48
|
+
score. Never hard-breaks on an old-format file."""
|
|
49
|
+
assertions = []
|
|
50
|
+
for i, m in enumerate(_ASSERTION_PATTERN.finditer(objective_text), 1):
|
|
51
|
+
text = m.group(2).strip()
|
|
52
|
+
if text:
|
|
53
|
+
assertions.append({"id": f"A{i}", "text": text})
|
|
54
|
+
return assertions
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _keywords(text: str) -> set[str]:
|
|
58
|
+
return {w.lower() for w in _KEYWORD_PATTERN.findall(text)}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _covers(assertion_text: str, coverage_text: str) -> bool:
|
|
62
|
+
a_kw = _keywords(assertion_text)
|
|
63
|
+
if not a_kw:
|
|
64
|
+
return False
|
|
65
|
+
c_kw = _keywords(coverage_text)
|
|
66
|
+
return len(a_kw & c_kw) / len(a_kw) >= _OVERLAP_THRESHOLD
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def compute_coverage(assertions: list[dict], modules: dict[str, dict]) -> dict:
|
|
70
|
+
"""Deterministic reachability: an assertion counts as covered if at
|
|
71
|
+
least one module's objective_coverage list has keyword overlap with its
|
|
72
|
+
text above _OVERLAP_THRESHOLD. Returns the same coverage_score/
|
|
73
|
+
coverage_gaps shape the LLM-judged path already returns, so callers
|
|
74
|
+
don't need to branch on which path produced the result — only the
|
|
75
|
+
scoring_method field distinguishes them."""
|
|
76
|
+
gaps = []
|
|
77
|
+
covered_by: dict[str, list[str]] = {}
|
|
78
|
+
for a in assertions:
|
|
79
|
+
covering_modules = sorted(
|
|
80
|
+
name for name, spec in modules.items()
|
|
81
|
+
if any(_covers(a["text"], cov) for cov in (spec.get("objective_coverage") or []))
|
|
82
|
+
)
|
|
83
|
+
if covering_modules:
|
|
84
|
+
covered_by[a["id"]] = covering_modules
|
|
85
|
+
else:
|
|
86
|
+
gaps.append({"area": a["text"], "quote": a["text"]})
|
|
87
|
+
|
|
88
|
+
score = round(len(covered_by) / len(assertions), 2) if assertions else 0.0
|
|
89
|
+
return {
|
|
90
|
+
"coverage_score": score,
|
|
91
|
+
"coverage_gaps": gaps,
|
|
92
|
+
"assertions_total": len(assertions),
|
|
93
|
+
"assertions_covered": len(covered_by),
|
|
94
|
+
"assertion_coverage_map": covered_by,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# Confidence floor above which the LLM's own coverage judgment is trusted
|
|
99
|
+
# enough that a much lower deterministic score reads as scorer disagreement
|
|
100
|
+
# rather than as real missing coverage.
|
|
101
|
+
LLM_CONFIDENCE_FLOOR = 0.85
|
|
102
|
+
|
|
103
|
+
# Below this, the deterministic scorer failed to match a MAJORITY of the
|
|
104
|
+
# objective's assertions, which indicates the keyword heuristic does not work
|
|
105
|
+
# against this objective's phrasing at all — not that coverage is genuinely
|
|
106
|
+
# absent. At or above it, individual misses are credible and still block.
|
|
107
|
+
SCORER_CREDIBILITY_FLOOR = 0.5
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def scorers_disagree(result: dict, floor: float = LLM_CONFIDENCE_FLOOR,
|
|
111
|
+
credibility_floor: float = SCORER_CREDIBILITY_FLOOR) -> bool:
|
|
112
|
+
"""True when the deterministic score contradicts a confident LLM score.
|
|
113
|
+
|
|
114
|
+
Keyword overlap is explicitly "not ground truth" (see this module's own
|
|
115
|
+
docstring): it has systematic false negatives whenever an objective's
|
|
116
|
+
numbered list is phrased in different vocabulary from the modules'
|
|
117
|
+
`objective_coverage`. Measured 2026-07-27 across four real projects, the
|
|
118
|
+
deterministic score ranged 0%-100% on healthy decompositions — Project P
|
|
119
|
+
scored 0/5 with 11 real modules because its assertions are terse feature
|
|
120
|
+
names, and Project S scored 1/4 because two of its assertions are
|
|
121
|
+
non-functional outcomes ("the sender never has to explain it") that no
|
|
122
|
+
module coverage text will ever share words with.
|
|
123
|
+
|
|
124
|
+
Two scorers disagreeing is an uncertainty signal, not a verdict. This
|
|
125
|
+
lives here, callable, because the rule previously existed ONLY inside
|
|
126
|
+
build.py's wave gate: `pcp build` correctly treated the disagreement as
|
|
127
|
+
advisory while standalone `pcp validate-strategy` exited 1 on the very
|
|
128
|
+
same data. One rule, one definition, both callers.
|
|
129
|
+
"""
|
|
130
|
+
llm_score = result.get("llm_coverage_score")
|
|
131
|
+
det_score = result.get("coverage_score", 0)
|
|
132
|
+
return (
|
|
133
|
+
result.get("scoring_method") == "deterministic"
|
|
134
|
+
and llm_score is not None
|
|
135
|
+
and llm_score >= floor
|
|
136
|
+
and det_score < llm_score
|
|
137
|
+
# ...and the deterministic scorer must look BROKEN on this objective,
|
|
138
|
+
# not merely stricter. Vocabulary mismatch is systematic: it fails to
|
|
139
|
+
# match assertions uniformly and drives the score toward zero (Project P
|
|
140
|
+
# 0/5, Project S 1/4). A genuine missing module is localised — most
|
|
141
|
+
# assertions still match and one does not (9/10, or the 1/2 case in
|
|
142
|
+
# test_deterministic_gap_shown_in_cli_output, a real uncovered
|
|
143
|
+
# module the LLM claimed was fine).
|
|
144
|
+
#
|
|
145
|
+
# Without this clause the rule would have disarmed the very Goodhart
|
|
146
|
+
# mitigation the deterministic scorer exists to provide: any gap the
|
|
147
|
+
# LLM word-smithed past would have been waved through as
|
|
148
|
+
# "disagreement". Below-majority means the scorer is not working here
|
|
149
|
+
# and cannot be trusted to BLOCK; at or above majority its misses are
|
|
150
|
+
# credible and still fail the check.
|
|
151
|
+
and det_score < credibility_floor
|
|
152
|
+
)
|
pcp/attest.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""in-toto attestation export for PCP evidence.
|
|
2
|
+
|
|
3
|
+
2026-07-17 (build plan 2.1): PCP's per-check evidence records are semantically
|
|
4
|
+
in-toto attestations (subject = file/criterion, predicate = QA outcome) but
|
|
5
|
+
lived in a bespoke JSONL schema. This module exports them as in-toto
|
|
6
|
+
Statement v1 objects wrapped in DSSE envelopes so GUAC/Archivista/cosign-
|
|
7
|
+
verify tooling can consume them — "PCP evidence is in-toto-compatible" is a
|
|
8
|
+
stronger claim than "PCP has its own audit format".
|
|
9
|
+
|
|
10
|
+
HONEST BOUNDARY: envelopes are exported UNSIGNED (signatures: []) unless
|
|
11
|
+
cosign is installed and --sign is passed. Unsigned DSSE is structurally
|
|
12
|
+
valid and machine-readable but provides no non-repudiation — the hash-chain
|
|
13
|
+
(evidence_chain.py) remains the tamper-evidence layer until Sigstore signing
|
|
14
|
+
(build-plan 2.2) is completed. Stated here and in the export itself.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import base64
|
|
18
|
+
import hashlib
|
|
19
|
+
import json
|
|
20
|
+
import shutil
|
|
21
|
+
import subprocess
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from pcp import telemetry
|
|
26
|
+
|
|
27
|
+
STATEMENT_TYPE = "https://in-toto.io/Statement/v1"
|
|
28
|
+
PREDICATE_TYPE = "https://pcp-protocol.com/attestation/qa-evidence/v1"
|
|
29
|
+
DSSE_PAYLOAD_TYPE = "application/vnd.in-toto+json"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _sha256_file(path: Path) -> str | None:
|
|
33
|
+
try:
|
|
34
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
35
|
+
except OSError:
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_statements(pcp_dir: Path) -> list[dict]:
|
|
40
|
+
"""One in-toto Statement per qa-cycle telemetry record that has a
|
|
41
|
+
resolvable control and at least one subject file still on disk."""
|
|
42
|
+
project_root = pcp_dir.parent
|
|
43
|
+
statements = []
|
|
44
|
+
for rec in telemetry.load(pcp_dir):
|
|
45
|
+
if rec.get("cycle") != "qa" or not rec.get("control_id"):
|
|
46
|
+
continue
|
|
47
|
+
subjects = []
|
|
48
|
+
for f in rec.get("files") or []:
|
|
49
|
+
digest = _sha256_file(project_root / f)
|
|
50
|
+
if digest:
|
|
51
|
+
subjects.append({"name": f, "digest": {"sha256": digest}})
|
|
52
|
+
if not subjects:
|
|
53
|
+
continue
|
|
54
|
+
statements.append({
|
|
55
|
+
"_type": STATEMENT_TYPE,
|
|
56
|
+
"subject": subjects,
|
|
57
|
+
"predicateType": PREDICATE_TYPE,
|
|
58
|
+
"predicate": {
|
|
59
|
+
"control_id": rec.get("control_id"),
|
|
60
|
+
"check": rec.get("check"),
|
|
61
|
+
"result": rec.get("result"),
|
|
62
|
+
"module": rec.get("module"),
|
|
63
|
+
"criterion_id": rec.get("criterion_id"),
|
|
64
|
+
"attempt": rec.get("cycle_number"),
|
|
65
|
+
"timestamp": rec.get("timestamp"),
|
|
66
|
+
"evidence_path": rec.get("evidence_path"),
|
|
67
|
+
"entry_hash": rec.get("entry_hash"),
|
|
68
|
+
"note": "subject digests are CURRENT file state at export time, "
|
|
69
|
+
"not at check time — PCP telemetry does not yet snapshot "
|
|
70
|
+
"per-check content digests (honest limitation)",
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
return statements
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _dsse_envelope(statement: dict) -> dict:
|
|
77
|
+
payload = base64.b64encode(json.dumps(statement, sort_keys=True).encode()).decode()
|
|
78
|
+
return {"payloadType": DSSE_PAYLOAD_TYPE, "payload": payload, "signatures": []}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def export_attestations(pcp_dir: Path, sign: bool = False) -> tuple[Path, int, str]:
|
|
82
|
+
"""Write .pcp/attestations.jsonl (one DSSE envelope per line).
|
|
83
|
+
Returns (path, count, signing_note)."""
|
|
84
|
+
statements = build_statements(pcp_dir)
|
|
85
|
+
out = pcp_dir / "attestations.jsonl"
|
|
86
|
+
with open(out, "w") as f:
|
|
87
|
+
for s in statements:
|
|
88
|
+
f.write(json.dumps(_dsse_envelope(s)) + "\n")
|
|
89
|
+
|
|
90
|
+
signing_note = "UNSIGNED (no non-repudiation; hash-chain remains the tamper-evidence layer)"
|
|
91
|
+
if sign:
|
|
92
|
+
if shutil.which("cosign"):
|
|
93
|
+
bundle = pcp_dir / "attestations.jsonl.sig"
|
|
94
|
+
result = subprocess.run(
|
|
95
|
+
["cosign", "sign-blob", "--yes", str(out),
|
|
96
|
+
"--output-signature", str(bundle)],
|
|
97
|
+
capture_output=True, text=True, timeout=300,
|
|
98
|
+
)
|
|
99
|
+
signing_note = (
|
|
100
|
+
f"signed via cosign -> {bundle.name}" if result.returncode == 0
|
|
101
|
+
else f"cosign signing FAILED: {(result.stderr or '').strip()[:200]}"
|
|
102
|
+
)
|
|
103
|
+
else:
|
|
104
|
+
signing_note = "--sign requested but cosign not installed — exported unsigned"
|
|
105
|
+
|
|
106
|
+
meta = {
|
|
107
|
+
"exported_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
108
|
+
"count": len(statements), "signing": signing_note,
|
|
109
|
+
}
|
|
110
|
+
(pcp_dir / "attestations.meta.json").write_text(json.dumps(meta, indent=2))
|
|
111
|
+
return out, len(statements), signing_note
|
pcp/build_loop_bypass.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""CTRL-037: build-loop bypass detector (2026-07-24).
|
|
2
|
+
|
|
3
|
+
Real, recurring incident (Project O, 07-08 / mid-July / 07-21-onward,
|
|
4
|
+
3 occurrences): `pcp build`'s formal gated loop (architect-review, QA,
|
|
5
|
+
wave-merge, telemetry) silently stops being used -- not by decision, just by
|
|
6
|
+
default -- while real commits keep landing via `pcp pm` + ad-hoc/manual
|
|
7
|
+
work. `telemetry.jsonl` going stale IS the finding, not evidence the project
|
|
8
|
+
went idle; nothing previously surfaced that at the moment it started
|
|
9
|
+
happening. Deterministic: git log commit dates vs telemetry.jsonl's last
|
|
10
|
+
entry timestamp, no LLM judgment.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
from datetime import datetime, timedelta, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from pcp import telemetry
|
|
19
|
+
|
|
20
|
+
DEFAULT_THRESHOLD_DAYS = 3
|
|
21
|
+
_TS_FMT = "%Y-%m-%dT%H:%M:%SZ"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _last_telemetry_ts(pcp_dir: Path) -> datetime | None:
|
|
25
|
+
records = telemetry.load(pcp_dir)
|
|
26
|
+
if not records:
|
|
27
|
+
return None
|
|
28
|
+
timestamps = [r["timestamp"] for r in records if r.get("timestamp")]
|
|
29
|
+
if not timestamps:
|
|
30
|
+
return None
|
|
31
|
+
latest = max(timestamps)
|
|
32
|
+
try:
|
|
33
|
+
return datetime.strptime(latest, _TS_FMT).replace(tzinfo=timezone.utc)
|
|
34
|
+
except ValueError:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _commits_since(project_root: Path, since: datetime) -> int:
|
|
39
|
+
result = subprocess.run(
|
|
40
|
+
["git", "log", f"--since={since.isoformat()}", "--format=%H"],
|
|
41
|
+
cwd=project_root, capture_output=True, text=True,
|
|
42
|
+
)
|
|
43
|
+
if result.returncode != 0:
|
|
44
|
+
return 0
|
|
45
|
+
return len([l for l in result.stdout.splitlines() if l.strip()])
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def check(pcp_dir: Path, project_root: Path, threshold_days: int | None = None) -> list[str]:
|
|
49
|
+
"""Returns findings; [] if inert (no telemetry history yet, not a git
|
|
50
|
+
repo) or nothing to flag. Never raises -- advisory, same posture as
|
|
51
|
+
every other doctor-time check."""
|
|
52
|
+
threshold_days = threshold_days if threshold_days is not None else int(
|
|
53
|
+
os.environ.get("PCP_BUILD_LOOP_BYPASS_THRESHOLD_DAYS", DEFAULT_THRESHOLD_DAYS)
|
|
54
|
+
)
|
|
55
|
+
last_ts = _last_telemetry_ts(pcp_dir)
|
|
56
|
+
if last_ts is None:
|
|
57
|
+
return [] # never built via pcp build at all yet -- nothing to compare against
|
|
58
|
+
if not (project_root / ".git").exists():
|
|
59
|
+
return []
|
|
60
|
+
|
|
61
|
+
cutoff = last_ts + timedelta(days=threshold_days)
|
|
62
|
+
now = datetime.now(timezone.utc)
|
|
63
|
+
if cutoff >= now:
|
|
64
|
+
return [] # still inside the grace window
|
|
65
|
+
|
|
66
|
+
commit_count = _commits_since(project_root, cutoff)
|
|
67
|
+
if commit_count == 0:
|
|
68
|
+
return []
|
|
69
|
+
|
|
70
|
+
age_days = (now - last_ts).days
|
|
71
|
+
return [
|
|
72
|
+
f"{commit_count} commit(s) landed since {cutoff.strftime('%Y-%m-%d')} "
|
|
73
|
+
f"(>{threshold_days}d past telemetry.jsonl's last entry, {age_days}d ago) — "
|
|
74
|
+
"`pcp build`'s formal gated loop (architect-review/QA/wave-merge/telemetry) "
|
|
75
|
+
"may have been bypassed; work is happening outside it"
|
|
76
|
+
]
|
pcp/build_report.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""End-of-cycle build report (2026-07-24). `pcp build` already generates
|
|
2
|
+
real evidence -- run_log.jsonl's proof-of-delivery bracket, raw QA output
|
|
3
|
+
under .pcp/evidence/, per-attempt telemetry.jsonl records -- but until now
|
|
4
|
+
none of it ever got handed to the human at the moment a run finished, just
|
|
5
|
+
one dim summary line. This renders what actually happened THIS run, with
|
|
6
|
+
evidence, so "pcp build completed" reads as something more than a plain
|
|
7
|
+
commit does. Pure/testable: takes a since-timestamp, does no clock reads
|
|
8
|
+
or side effects of its own.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from pcp import run_log
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def render(pcp_dir: Path, since_ts: str) -> str:
|
|
17
|
+
"""since_ts: ISO timestamp, run_log's own format (%Y-%m-%dT%H:%M:%SZ).
|
|
18
|
+
Only paired runs (criteria) whose POST record lands at or after this
|
|
19
|
+
count as "this run" -- string comparison is safe since the format is
|
|
20
|
+
fixed-width and zero-padded."""
|
|
21
|
+
pairs = [p for p in run_log.pair_runs(run_log.load(pcp_dir)) if p.get("timestamp", "") >= since_ts]
|
|
22
|
+
|
|
23
|
+
lines = ["# Build Cycle Report", ""]
|
|
24
|
+
if not pairs:
|
|
25
|
+
lines.append("_No criteria completed this run._")
|
|
26
|
+
return "\n".join(lines) + "\n"
|
|
27
|
+
|
|
28
|
+
succeeded = [p for p in pairs if p.get("result") == "success"]
|
|
29
|
+
lines.append(f"{len(succeeded)}/{len(pairs)} criteria succeeded this run.")
|
|
30
|
+
lines.append("")
|
|
31
|
+
|
|
32
|
+
for p in pairs:
|
|
33
|
+
module = p.get("module", "?")
|
|
34
|
+
feature = p.get("feature", "?")
|
|
35
|
+
proof = p.get("proof_of_delivery", {})
|
|
36
|
+
mark = "✓" if p.get("result") == "success" else "✗"
|
|
37
|
+
lines.append(f"## {mark} {module}: {feature}")
|
|
38
|
+
lines.append(f"- Deterministic gates passed: {', '.join(proof.get('real_gates_passed') or []) or 'none'}")
|
|
39
|
+
lines.append(f"- LLM-judged gates passed: {', '.join(proof.get('llm_judged_gates_passed') or []) or 'none'}")
|
|
40
|
+
commit_note = f" ({proof['post_commit_sha'][:8]})" if proof.get("post_commit_sha") else ""
|
|
41
|
+
lines.append(f"- Committed: {proof.get('committed')} Pushed: {proof.get('pushed')}{commit_note}")
|
|
42
|
+
if p.get("anomaly_flags"):
|
|
43
|
+
lines.append(f"- ⚠ Anomalies: {'; '.join(p['anomaly_flags'])}")
|
|
44
|
+
lines.append(f"- Raw evidence: `.pcp/evidence/{module}/` (full test/lint/SAST/architect-review output)")
|
|
45
|
+
lines.append("")
|
|
46
|
+
|
|
47
|
+
lines.append("Full audit trail: `.pcp/provenance.md` · `pcp telemetry` for per-attempt token/cost breakdown.")
|
|
48
|
+
return "\n".join(lines) + "\n"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def write(pcp_dir: Path, since_ts: str) -> Path:
|
|
52
|
+
out = Path(pcp_dir) / "build_report.md"
|
|
53
|
+
out.write_text(render(pcp_dir, since_ts))
|
|
54
|
+
return out
|