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/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)
|