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/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """PCP — Program Context Protocol."""
2
+
3
+ __version__ = "0.1.0"
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
@@ -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