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/control_audit.py ADDED
@@ -0,0 +1,159 @@
1
+ """Control-catalog self-audit -- PCP applying its own philosophy to its own
2
+ catalog, not just to projects PCP builds. Real gap named in the 2026-07-21
3
+ self-evaluation: 33 CTRL checks accumulated with no pruning discipline to
4
+ match the modularity/token-discipline philosophy PCP preaches everywhere
5
+ else. Pure aggregation over `.pcp/controls.yaml` + `.pcp/telemetry.jsonl`,
6
+ no LLM, same posture as coverage_audit.py/architecture_justification.py --
7
+ surfaces a signal, never auto-prunes, never blocks.
8
+
9
+ Deliberately narrow: detects the ONE thing checkable without judgment --
10
+ a control declared in the catalog that has never once produced a real
11
+ finding across its recorded history (a `zero-findings` candidate for
12
+ human review: retire, merge into a related check, or leave as intentional
13
+ belt-and-suspenders coverage). Does NOT attempt automated mechanism-overlap
14
+ detection (e.g. "these three are all presence checks") -- that's a
15
+ judgment call a human should make reading the rendered report, not
16
+ something worth a fuzzy heuristic that could be wrong in either direction.
17
+ """
18
+
19
+ from collections import defaultdict
20
+ from datetime import datetime, timezone
21
+ from pathlib import Path
22
+
23
+ import yaml
24
+
25
+ # Below this many recorded runs, "zero findings" isn't a real signal yet --
26
+ # a brand-new control with 2 runs and no findings just hasn't been tested,
27
+ # not proven unnecessary.
28
+ MIN_RUNS_FOR_SIGNAL = 20
29
+
30
+
31
+ def _load_controls(pcp_dir: Path) -> list[dict]:
32
+ path = pcp_dir / "controls.yaml"
33
+ if not path.exists():
34
+ return []
35
+ try:
36
+ data = yaml.safe_load(path.read_text()) or {}
37
+ except yaml.YAMLError:
38
+ return []
39
+ return data.get("controls", []) or []
40
+
41
+
42
+ def build_control_audit(pcp_dir: Path) -> dict:
43
+ """Returns {control_id: {name, total_runs, findings, rate, layer,
44
+ enforcement, signal}}. `signal` is one of: "insufficient-data" (below
45
+ MIN_RUNS_FOR_SIGNAL), "never-fired" (>= MIN_RUNS_FOR_SIGNAL runs, zero
46
+ findings -- retire/merge candidate), "active" (has fired at least
47
+ once)."""
48
+ from pcp import telemetry
49
+
50
+ controls = _load_controls(pcp_dir)
51
+ records = telemetry.load(pcp_dir)
52
+
53
+ runs_by_control = defaultdict(int)
54
+ findings_by_control = defaultdict(int)
55
+ for r in records:
56
+ cid = r.get("control_id")
57
+ if not cid:
58
+ continue
59
+ runs_by_control[cid] += 1
60
+ if r.get("result") == "block" or (r.get("error_count") or 0) > 0:
61
+ findings_by_control[cid] += 1
62
+
63
+ out = {}
64
+ for c in controls:
65
+ cid = c["id"]
66
+ total = runs_by_control.get(cid, 0)
67
+ findings = findings_by_control.get(cid, 0)
68
+ if total < MIN_RUNS_FOR_SIGNAL:
69
+ signal = "insufficient-data"
70
+ elif findings == 0:
71
+ signal = "never-fired"
72
+ else:
73
+ signal = "active"
74
+ out[cid] = {
75
+ "name": c.get("name", ""), "layer": c.get("layer", ""),
76
+ "enforcement": c.get("enforcement", ""),
77
+ "total_runs": total, "findings": findings,
78
+ "rate": round(findings / total, 3) if total else None,
79
+ "signal": signal,
80
+ }
81
+ return out
82
+
83
+
84
+ def render_markdown(audit: dict) -> str:
85
+ lines = [
86
+ "# Control Catalog Audit",
87
+ "",
88
+ "AUTO-GENERATED by `pcp control-audit`. Never edit -- regenerate instead.",
89
+ "",
90
+ "Pure aggregation over `.pcp/controls.yaml` + `.pcp/telemetry.jsonl`, no LLM. "
91
+ f"A control needs at least {MIN_RUNS_FOR_SIGNAL} recorded runs before "
92
+ "'never-fired' becomes a real signal rather than just untested.",
93
+ "",
94
+ "## Never-fired (retire/merge review candidates)",
95
+ "",
96
+ ]
97
+ never_fired = {k: v for k, v in audit.items() if v["signal"] == "never-fired"}
98
+ if not never_fired:
99
+ lines.append("None -- every control with enough recorded runs has fired at least once.")
100
+ else:
101
+ lines.append("| Control | Name | Layer | Runs |")
102
+ lines.append("|---|---|---|---|")
103
+ for cid, v in sorted(never_fired.items()):
104
+ lines.append(f"| {cid} | {v['name']} | {v['layer']} | {v['total_runs']} |")
105
+ lines.append("")
106
+
107
+ lines.append("## Full catalog")
108
+ lines.append("")
109
+ lines.append("| Control | Name | Layer | Enforcement | Runs | Findings | Rate | Signal |")
110
+ lines.append("|---|---|---|---|---|---|---|---|")
111
+ for cid, v in sorted(audit.items()):
112
+ rate = f"{v['rate']:.0%}" if v["rate"] is not None else "—"
113
+ lines.append(
114
+ f"| {cid} | {v['name']} | {v['layer']} | {v['enforcement']} | "
115
+ f"{v['total_runs']} | {v['findings']} | {rate} | {v['signal']} |"
116
+ )
117
+ return "\n".join(lines) + "\n"
118
+
119
+
120
+ def write_control_audit(pcp_dir: Path) -> dict:
121
+ audit = build_control_audit(pcp_dir)
122
+ (pcp_dir / "control_audit.md").write_text(render_markdown(audit))
123
+ return audit
124
+
125
+
126
+ def sync_catalog(pcp_dir: Path) -> list[str]:
127
+ """Additive-only merge of the CURRENT package's control catalog into a
128
+ project's existing controls.yaml. Real gap found 2026-07-22 auditing
129
+ Project O: `controls.yaml` is scaffolded once at `pcp init` and
130
+ never refreshed as the `pcp` package adds new controls over time -- one
131
+ project had 23 distinct control_ids firing in telemetry.jsonl against a
132
+ 10-entry catalog, invisible to `pcp control-audit`/`pcp provenance`
133
+ entirely. `pcp init --force` would fix this but also blindly overwrites
134
+ objective.md/architecture.md/every other scaffolded file in the same
135
+ pass -- far too destructive just to refresh a catalog. This only ever
136
+ APPENDS entries whose id isn't already present; never rewrites, reorders,
137
+ or removes an existing entry (a project may have hand-edited descriptions
138
+ or removed a control deliberately -- this must not silently undo that).
139
+ Returns the list of newly-added control ids; [] if the project's catalog
140
+ is already current or has no controls.yaml at all (use `pcp init` to
141
+ create one from scratch instead)."""
142
+ from pcp.commands.init import CONTROLS_TEMPLATE
143
+
144
+ path = pcp_dir / "controls.yaml"
145
+ if not path.exists():
146
+ return []
147
+
148
+ template_controls = (yaml.safe_load(CONTROLS_TEMPLATE) or {}).get("controls", [])
149
+ existing = _load_controls(pcp_dir)
150
+ existing_ids = {c["id"] for c in existing}
151
+
152
+ added = [c for c in template_controls if c["id"] not in existing_ids]
153
+ if not added:
154
+ return []
155
+
156
+ data = yaml.safe_load(path.read_text()) or {}
157
+ data["controls"] = existing + added
158
+ path.write_text(yaml.dump(data, default_flow_style=False, sort_keys=False))
159
+ return [c["id"] for c in added]
pcp/coupling.py ADDED
@@ -0,0 +1,178 @@
1
+ """Deterministic coupling analysis — replaces the LLM-judged coupling_score.
2
+
3
+ Coupling, unlike coverage ("does the plan cover the objective" — genuinely
4
+ semantic), is graph math: circular deps, dependency counts, and god modules
5
+ are all mechanically computable from each module's declared 'dependencies'
6
+ field. No LLM needed, no non-determinism, no cost, no flakiness.
7
+
8
+ The scoring formula here is the exact one PCP's own validate-strategy prompt
9
+ already specified — it was already fully deterministic, just being computed
10
+ by an LLM instead of code. Community detection (graphify, if installed) is
11
+ optional enrichment layered on top: it surfaces informal coupling — modules
12
+ that cluster together beyond their declared dependencies — it does not feed
13
+ the numeric coupling_score itself.
14
+ """
15
+
16
+ import networkx as nx
17
+
18
+ DEP_PENALTY = 0.1
19
+ CYCLE_PENALTY = 0.2
20
+ GOD_MODULE_PENALTY = 0.15
21
+ GOD_MODULE_THRESHOLD = 3
22
+
23
+
24
+ def _hub_modules(G: nx.DiGraph) -> set[str]:
25
+ """Modules more than half the other modules depend on — shared
26
+ infrastructure (a 'core' or 'utils' module), not harmful coupling.
27
+ Same reasoning graphify's own cluster() has an exclude_hubs_percentile
28
+ for: a widely-depended-on utility module skews naive graph metrics."""
29
+ n = G.number_of_nodes()
30
+ if n < 3:
31
+ return set()
32
+ threshold = n / 2
33
+ return {node for node, indeg in G.in_degree() if indeg > threshold}
34
+
35
+
36
+ def build_dependency_graph(modules: dict[str, dict]) -> nx.DiGraph:
37
+ """modules: {module_name: spec_dict}. Edge A->B means A depends on B.
38
+ Dependencies pointing outside this module set are ignored (external/
39
+ already-built, not something this graph can score)."""
40
+ G = nx.DiGraph()
41
+ for name in modules:
42
+ G.add_node(name)
43
+ for name, spec in modules.items():
44
+ for dep in (spec.get("dependencies") or []):
45
+ if dep in modules and dep != name:
46
+ G.add_edge(name, dep)
47
+ return G
48
+
49
+
50
+ def compute_coupling(G: nx.DiGraph) -> dict:
51
+ """Returns {coupling_score, coupling_violations, direct_dependencies,
52
+ circular_dependencies, god_modules, hub_modules} — same shape
53
+ validate-strategy's LLM prompt used to return, now computed directly.
54
+
55
+ Edges into a hub module (shared infrastructure) don't count against the
56
+ direct-dependency penalty — depending on a genuine shared kernel isn't
57
+ the harmful coupling this score is meant to catch. Cycles and god-modules
58
+ still count regardless of hub status: a cycle through 'core', or a module
59
+ with too many outgoing deps, is still a real structural problem."""
60
+ hubs = _hub_modules(G)
61
+ scored_edges = [(a, b) for a, b in G.edges if b not in hubs]
62
+ direct_deps = len(scored_edges)
63
+ cycles = [c for c in nx.simple_cycles(G) if len(c) > 1]
64
+ god_modules = [n for n in G.nodes if G.out_degree(n) > GOD_MODULE_THRESHOLD]
65
+
66
+ score = 1.0 - DEP_PENALTY * direct_deps - CYCLE_PENALTY * len(cycles) - GOD_MODULE_PENALTY * len(god_modules)
67
+ score = max(0.0, min(1.0, score))
68
+
69
+ violations = []
70
+ for cycle in cycles:
71
+ path = " -> ".join(cycle + [cycle[0]])
72
+ violations.append({
73
+ "type": "circular", "modules": cycle,
74
+ "description": f"Circular dependency: {path}",
75
+ "fix": "Break the cycle — extract a shared interface or invert one dependency.",
76
+ })
77
+ for n in god_modules:
78
+ deps = sorted(G.successors(n))
79
+ violations.append({
80
+ "type": "god_module", "modules": [n],
81
+ "description": f"'{n}' depends on {len(deps)} other modules: {', '.join(deps)}",
82
+ "fix": "Split into smaller modules or reduce dependencies.",
83
+ })
84
+ for a, b in sorted(scored_edges):
85
+ violations.append({
86
+ "type": "direct_dependency", "modules": [a, b],
87
+ "description": f"'{a}' directly depends on '{b}'",
88
+ "fix": "Confirm this is core infrastructure, not incidental coupling.",
89
+ })
90
+
91
+ return {
92
+ "coupling_score": round(score, 2),
93
+ "coupling_violations": violations,
94
+ "direct_dependencies": direct_deps,
95
+ "circular_dependencies": len(cycles),
96
+ "god_modules": god_modules,
97
+ "hub_modules": sorted(hubs),
98
+ }
99
+
100
+
101
+ def compute_communities(G: nx.DiGraph) -> dict:
102
+ """Optional enrichment via graphify's community detection (Leiden/Louvain).
103
+ Advisory only — surfaces modules that cluster together, doesn't affect
104
+ coupling_score. Returns {"available": False} if graphify isn't installed."""
105
+ try:
106
+ from graphify.cluster import cluster, score_all
107
+ except ImportError:
108
+ return {"available": False}
109
+ if G.number_of_nodes() < 2:
110
+ return {"available": True, "communities": {}, "cohesion": {}}
111
+ communities = cluster(G)
112
+ cohesion = score_all(G, communities)
113
+ multi_node = {cid: nodes for cid, nodes in communities.items() if len(nodes) > 1}
114
+ return {"available": True, "communities": multi_node, "cohesion": {cid: cohesion[cid] for cid in multi_node}}
115
+
116
+
117
+ def compute_change_coupling(project_root, modules: dict[str, dict], max_commits: int = 200) -> list[dict]:
118
+ """Git-history co-change analysis (CodeScene behavioral-analysis reference
119
+ pattern, 2026-07-17) — a second, independent coupling signal. Files that
120
+ change together over time reveal coupling a static import graph
121
+ structurally cannot see (implicit contracts, config coupling, copy-paste).
122
+ Deterministic (rung 1), zero LLM.
123
+
124
+ Maps commits to modules via each module's declared criterion `target`
125
+ files. Flags module pairs that co-change in >=50% of the less-active
126
+ module's commits (min 5 co-changes) AND have no declared dependency in
127
+ either direction — "hidden coupling", advisory only, never feeds
128
+ coupling_score.
129
+ """
130
+ import subprocess
131
+ from collections import defaultdict
132
+ from pathlib import Path as _P
133
+
134
+ module_files: dict[str, set] = {}
135
+ declared: set = set()
136
+ for name, m in modules.items():
137
+ targets = set()
138
+ for c in (m.get("acceptance") or {}).get("criteria", []):
139
+ if c.get("target"):
140
+ targets.add(c["target"].replace("\\", "/"))
141
+ module_files[name] = targets
142
+ for dep in (m.get("spec") or {}).get("dependencies", []) or []:
143
+ declared.add((name, dep))
144
+ declared.add((dep, name))
145
+
146
+ result = subprocess.run(
147
+ ["git", "log", f"-{max_commits}", "--name-only", "--pretty=format:@@COMMIT@@"],
148
+ capture_output=True, text=True, cwd=project_root,
149
+ )
150
+ if result.returncode != 0:
151
+ return []
152
+
153
+ commit_counts: dict[str, int] = defaultdict(int)
154
+ pair_counts: dict[tuple, int] = defaultdict(int)
155
+ for chunk in result.stdout.split("@@COMMIT@@"):
156
+ files = {f.strip().replace("\\", "/") for f in chunk.splitlines() if f.strip()}
157
+ if not files:
158
+ continue
159
+ touched = sorted(n for n, targets in module_files.items() if targets & files)
160
+ for n in touched:
161
+ commit_counts[n] += 1
162
+ for i in range(len(touched)):
163
+ for j in range(i + 1, len(touched)):
164
+ pair_counts[(touched[i], touched[j])] += 1
165
+
166
+ hidden = []
167
+ for (a, b), count in sorted(pair_counts.items()):
168
+ if count < 5 or (a, b) in declared:
169
+ continue
170
+ denom = min(commit_counts[a], commit_counts[b])
171
+ ratio = count / denom if denom else 0.0
172
+ if ratio >= 0.5:
173
+ hidden.append({
174
+ "modules": [a, b], "co_changes": count,
175
+ "ratio": round(ratio, 2),
176
+ "note": "co-change without declared dependency — possible hidden coupling",
177
+ })
178
+ return hidden
pcp/coverage_audit.py ADDED
@@ -0,0 +1,77 @@
1
+ """Coverage-score audit trail — mitigates Goodhart risk on validate-strategy's
2
+ LLM-judged coverage_score. Unlike coupling_score (see coupling.py), coverage
3
+ can't be made deterministic — "does this cover the objective" is genuinely
4
+ semantic. What CAN be deterministic: catching internal inconsistency (a high
5
+ score reported alongside real open gaps) and drift (the same inputs producing
6
+ a meaningfully different score run to run). Both are surfaced, never silently
7
+ corrected — same append-only audit-trail posture as decision_log.py/telemetry.py.
8
+ """
9
+
10
+ import hashlib
11
+ import json
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+
15
+ # A score this high alongside any open gap is internally inconsistent —
16
+ # gaps are the real gate, the number shouldn't read as "basically done".
17
+ INCONSISTENCY_THRESHOLD = 0.85
18
+
19
+ # Score delta on unchanged objective/modules worth flagging as possible
20
+ # LLM non-determinism rather than a real coverage change.
21
+ DRIFT_THRESHOLD = 0.15
22
+
23
+
24
+ def _hash_inputs(objective: str, modules: dict) -> str:
25
+ material = objective + "".join(f"{k}:{v}" for k, v in sorted(modules.items()))
26
+ return hashlib.sha256(material.encode()).hexdigest()[:12]
27
+
28
+
29
+ def load(pcp_dir: Path) -> list[dict]:
30
+ path = Path(pcp_dir) / "coverage_audit.jsonl"
31
+ if not path.exists():
32
+ return []
33
+ records = []
34
+ for line in path.read_text().splitlines():
35
+ line = line.strip()
36
+ if not line:
37
+ continue
38
+ try:
39
+ records.append(json.loads(line))
40
+ except json.JSONDecodeError:
41
+ continue
42
+ return records
43
+
44
+
45
+ def record(pcp_dir: Path, coverage_score: float, gaps: list, objective: str, modules: dict) -> list[str]:
46
+ """Appends one entry to .pcp/coverage_audit.jsonl. Returns any findings
47
+ (inconsistency/drift) for the caller to surface — never raises, never
48
+ silently corrects the score itself."""
49
+ path = Path(pcp_dir) / "coverage_audit.jsonl"
50
+ inputs_hash = _hash_inputs(objective, modules)
51
+ findings: list[str] = []
52
+
53
+ if gaps and coverage_score >= INCONSISTENCY_THRESHOLD:
54
+ findings.append(
55
+ f"coverage_score reported {coverage_score:.0%} despite {len(gaps)} open gap(s) — "
56
+ "internally inconsistent, treat the score with skepticism (the gaps are the real gate)."
57
+ )
58
+
59
+ prior = load(pcp_dir)
60
+ same_input_runs = [r for r in prior if r.get("inputs_hash") == inputs_hash]
61
+ if same_input_runs:
62
+ last = same_input_runs[-1]
63
+ delta = abs(coverage_score - last["coverage_score"])
64
+ if delta >= DRIFT_THRESHOLD:
65
+ findings.append(
66
+ f"coverage_score drifted {last['coverage_score']:.0%} -> {coverage_score:.0%} "
67
+ "on an unchanged objective/module set — possible LLM non-determinism, not a real coverage change."
68
+ )
69
+
70
+ entry = {
71
+ "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
72
+ "coverage_score": coverage_score, "gap_count": len(gaps), "inputs_hash": inputs_hash,
73
+ "findings": findings,
74
+ }
75
+ with open(path, "a") as f:
76
+ f.write(json.dumps(entry) + "\n")
77
+ return findings
pcp/decision_log.py ADDED
@@ -0,0 +1,134 @@
1
+ """Technical decision log — distilled from session/build-loop conversation.
2
+
3
+ Distinct from telemetry.jsonl (build-cycle QA/cost events) — this is the
4
+ *technical input* half of conversational drift capture (see capture.py for
5
+ the classifier and its business-logic counterpart, brd_items.yaml). Append-only
6
+ JSONL, one record per distilled technical decision/rationale. Auto-appended by
7
+ `pcp capture`. Never edit.
8
+ """
9
+
10
+ import json
11
+ from collections import defaultdict
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+
15
+ from pcp.evidence_chain import chain_entry
16
+
17
+
18
+ def record(pcp_dir: Path, **fields) -> None:
19
+ """Append one JSONL record to .pcp/decision_log.jsonl.
20
+
21
+ Suggested fields: source ("session:<id>"|"build:<module>:<criterion_id>"),
22
+ session_id, category (freeform, e.g. "library-choice"|"architecture"|"workaround"),
23
+ summary, evidence (quoted excerpt), module, criterion_id.
24
+ """
25
+ fields = {"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), **fields}
26
+ path = Path(pcp_dir) / "decision_log.jsonl"
27
+ entry = chain_entry(_last_entry_hash(path), fields)
28
+ with open(path, "a") as f:
29
+ f.write(json.dumps(entry) + "\n")
30
+
31
+
32
+ def _last_entry_hash(path: Path) -> str | None:
33
+ if not path.exists():
34
+ return None
35
+ last_line = None
36
+ for line in path.read_text().splitlines():
37
+ line = line.strip()
38
+ if line:
39
+ last_line = line
40
+ if not last_line:
41
+ return None
42
+ try:
43
+ return json.loads(last_line).get("entry_hash")
44
+ except json.JSONDecodeError:
45
+ return None
46
+
47
+
48
+ def load(pcp_dir: Path) -> list[dict]:
49
+ path = Path(pcp_dir) / "decision_log.jsonl"
50
+ if not path.exists():
51
+ return []
52
+ records = []
53
+ for line in path.read_text().splitlines():
54
+ line = line.strip()
55
+ if not line:
56
+ continue
57
+ try:
58
+ records.append(json.loads(line))
59
+ except json.JSONDecodeError:
60
+ continue
61
+ return records
62
+
63
+
64
+ def select_relevant(pcp_dir: Path, module: str, limit: int | None = None,
65
+ max_chars: int | None = None) -> list[dict]:
66
+ """Deterministic selection of decisions worth injecting into a build
67
+ agent's prompt (no LLM — rung 1). This closes the loop this file's own
68
+ docstring promised ("intended to feed future build-loop context") but
69
+ that never got built: records went in, nothing ever came out, so
70
+ criterion agent #14 re-discovered what agent #3 already learned.
71
+
72
+ Priority: this module's own decisions first (module field or
73
+ build:<module>: source), newest first; then module-less project-wide
74
+ decisions, newest first. Other modules' decisions are never injected —
75
+ cross-module context is what architecture.md is for.
76
+
77
+ Bounded twice (count and chars) — prompt injection must never become
78
+ its own token-discipline violation. PCP_BUILD_MAX_DECISIONS (default 6),
79
+ PCP_BUILD_DECISIONS_MAX_CHARS (default 2500)."""
80
+ import os
81
+ limit = limit if limit is not None else int(os.environ.get("PCP_BUILD_MAX_DECISIONS", "6"))
82
+ max_chars = max_chars if max_chars is not None else int(os.environ.get("PCP_BUILD_DECISIONS_MAX_CHARS", "2500"))
83
+ if limit <= 0:
84
+ return []
85
+
86
+ records = load(pcp_dir)
87
+ module_prefix = f"build:{module}:"
88
+
89
+ def _is_module_match(r: dict) -> bool:
90
+ return r.get("module") == module or str(r.get("source", "")).startswith(module_prefix)
91
+
92
+ def _ts(r: dict) -> str:
93
+ return r.get("timestamp", "")
94
+
95
+ matched = sorted([r for r in records if _is_module_match(r) and r.get("summary")], key=_ts, reverse=True)
96
+ global_ = sorted(
97
+ [r for r in records if not _is_module_match(r) and not r.get("module") and r.get("summary")],
98
+ key=_ts, reverse=True,
99
+ )
100
+
101
+ selected, used_chars = [], 0
102
+ for r in matched + global_:
103
+ if len(selected) >= limit:
104
+ break
105
+ line_len = len(_render_line(r))
106
+ if used_chars + line_len > max_chars:
107
+ continue
108
+ selected.append(r)
109
+ used_chars += line_len
110
+ return selected
111
+
112
+
113
+ def _render_line(r: dict) -> str:
114
+ category = r.get("category") or "decision"
115
+ summary = str(r.get("summary", "")).strip()
116
+ return f"- [{category}] {summary}"
117
+
118
+
119
+ def format_for_prompt(pcp_dir: Path, module: str) -> list[str]:
120
+ """Rendered lines for _build_agent_prompt. Empty list when there's nothing
121
+ to inject (or PCP_BUILD_INJECT_DECISIONS=0) — caller skips the section."""
122
+ import os
123
+ if os.environ.get("PCP_BUILD_INJECT_DECISIONS", "1") in ("0", "false", "no"):
124
+ return []
125
+ return [_render_line(r) for r in select_relevant(pcp_dir, module)]
126
+
127
+
128
+ def aggregate(records: list[dict]) -> dict:
129
+ """Roll up decisions per category. Shared by `pcp telemetry`-style reporting
130
+ and the pcp.md 'Technical Decisions' section."""
131
+ by_category = defaultdict(list)
132
+ for r in records:
133
+ by_category[r.get("category") or "uncategorized"].append(r)
134
+ return {"by_category": by_category, "records": records}
File without changes
@@ -0,0 +1,124 @@
1
+ """Cluster detection: find natural module boundaries from dependency graph.
2
+
3
+ Algorithm: Union-Find on bidirectional edges, then group by top-level directory.
4
+ Directory grouping is the primary signal — import graph refines it.
5
+ Files with no cluster affinity (shared utils, config) → 'shared' pseudo-cluster.
6
+ """
7
+
8
+ from collections import defaultdict
9
+ from pathlib import Path
10
+
11
+
12
+ class _UnionFind:
13
+ def __init__(self, nodes):
14
+ self.parent = {n: n for n in nodes}
15
+ self.rank = {n: 0 for n in nodes}
16
+
17
+ def find(self, x):
18
+ while self.parent[x] != x:
19
+ self.parent[x] = self.parent[self.parent[x]]
20
+ x = self.parent[x]
21
+ return x
22
+
23
+ def union(self, a, b):
24
+ ra, rb = self.find(a), self.find(b)
25
+ if ra == rb:
26
+ return
27
+ if self.rank[ra] < self.rank[rb]:
28
+ ra, rb = rb, ra
29
+ self.parent[rb] = ra
30
+ if self.rank[ra] == self.rank[rb]:
31
+ self.rank[ra] += 1
32
+
33
+
34
+ def _top_dir(file_key: str) -> str:
35
+ parts = file_key.split("/")
36
+ if len(parts) == 1:
37
+ return "__root__"
38
+ # skip 'src/' prefix as it's not a domain
39
+ if parts[0] in ("src", "lib", "app", "pkg") and len(parts) > 2:
40
+ return parts[1]
41
+ return parts[0]
42
+
43
+
44
+ def detect_clusters(
45
+ graph: dict[str, set[str]],
46
+ files: list[Path],
47
+ root: Path,
48
+ ) -> dict[str, list[str]]:
49
+ """
50
+ Returns {cluster_name: [file_key, ...]}
51
+
52
+ Strategy:
53
+ 1. Primary grouping: top-level directory (or src/<dir>)
54
+ 2. Refinement: if two directories are heavily cross-coupled (>50% of edges
55
+ cross between them), merge into one cluster
56
+ 3. Root-level files with no dir affinity → 'shared'
57
+ """
58
+ file_keys = [str(f.relative_to(root)) for f in files]
59
+
60
+ # Step 1: group by top-level dir
61
+ dir_groups: dict[str, list[str]] = defaultdict(list)
62
+ for key in file_keys:
63
+ dir_groups[_top_dir(key)].append(key)
64
+
65
+ # Step 2: count cross-dir edges
66
+ cross: dict[tuple[str, str], int] = defaultdict(int)
67
+ internal: dict[str, int] = defaultdict(int)
68
+
69
+ for src, targets in graph.items():
70
+ src_dir = _top_dir(src)
71
+ for tgt in targets:
72
+ tgt_dir = _top_dir(tgt)
73
+ if src_dir == tgt_dir:
74
+ internal[src_dir] += 1
75
+ else:
76
+ key = tuple(sorted([src_dir, tgt_dir]))
77
+ cross[key] += 1
78
+
79
+ # Step 3: merge dirs where cross-edges > internal edges of both (tight coupling)
80
+ uf = _UnionFind(list(dir_groups.keys()))
81
+ for (a, b), cross_count in cross.items():
82
+ total_a = internal[a] + cross_count
83
+ total_b = internal[b] + cross_count
84
+ if total_a == 0 or total_b == 0:
85
+ continue
86
+ # merge if cross traffic is dominant in either direction
87
+ if cross_count / total_a > 0.6 or cross_count / total_b > 0.6:
88
+ uf.union(a, b)
89
+
90
+ # Step 4: build final clusters
91
+ clusters: dict[str, list[str]] = defaultdict(list)
92
+ for dir_name, file_list in dir_groups.items():
93
+ cluster_root = uf.find(dir_name)
94
+ # name: use the most specific dir in the merged group
95
+ cluster_name = cluster_root if cluster_root != "__root__" else "shared"
96
+ clusters[cluster_name].extend(file_list)
97
+
98
+ # rename __root__ entries
99
+ if "__root__" in clusters:
100
+ clusters["shared"].extend(clusters.pop("__root__"))
101
+
102
+ return dict(clusters)
103
+
104
+
105
+ def compute_coupling_matrix(
106
+ graph: dict[str, set[str]],
107
+ clusters: dict[str, list[str]],
108
+ ) -> dict[tuple[str, str], int]:
109
+ """Count cross-cluster edges. High count = coupling violation."""
110
+ file_to_cluster = {}
111
+ for cluster, files in clusters.items():
112
+ for f in files:
113
+ file_to_cluster[f] = cluster
114
+
115
+ cross_edges: dict[tuple[str, str], int] = defaultdict(int)
116
+ for src, targets in graph.items():
117
+ src_cluster = file_to_cluster.get(src)
118
+ for tgt in targets:
119
+ tgt_cluster = file_to_cluster.get(tgt)
120
+ if src_cluster and tgt_cluster and src_cluster != tgt_cluster:
121
+ key = (src_cluster, tgt_cluster)
122
+ cross_edges[key] += 1
123
+
124
+ return dict(cross_edges)