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/spec_write.py ADDED
@@ -0,0 +1,269 @@
1
+ """Shared propose -> real-diff -> human-approve -> write mechanic for every
2
+ human-authorized `.pcp/` file.
3
+
4
+ Hard Rule #2 is "human-AUTHORIZED only", not "human-typed only". The
5
+ `protected_path` ci_rule it rides on has always meant exactly that -- read
6
+ `check.run_protected_path_rule`: it returns zero violations unless
7
+ `PCP_AGENT_SESSION=1`, i.e. it only ever blocks `pcp build`'s unattended
8
+ coding agent. A human-present session was never blocked by code.
9
+
10
+ What DID block it was doctrine wording. "Spec files are human-written only /
11
+ never edit these as an agent" (scaffolded into every PCP project's CLAUDE.md
12
+ by `pcp init`) told agents to refuse, and `pcp init`'s own escape hatch --
13
+ "propose changes via `pcp pm`" -- was a dead end for 6 of the 10 protected
14
+ paths, because `pm` only ever writes module spec.yaml/acceptance.yaml. So a
15
+ detailed feature discussion would end with "update the specs", the agent
16
+ would decline on doctrine, and nothing got written. Reported by Ganesh
17
+ 2026-07-25 as a recurring, cross-project failure.
18
+
19
+ This module is the generalisation of what `pcp correct-objective` already did
20
+ for objective.md/target_state.md: an LLM proposes the rewrite from stated
21
+ intent, the human sees a real unified diff of every affected file, approves,
22
+ and only then is anything written. Every human-authorized file now has such a
23
+ path (`pcp amend`), so "human-authorized" is an achievable instruction rather
24
+ than a refusal.
25
+
26
+ Guardrail for the governance files (ci_rules.yaml, controls.yaml,
27
+ SDLC_phase.yaml): an LLM proposing edits to the rules that police it is a
28
+ genuine self-reference risk. Those three are schema-validated before write,
29
+ and any proposal that deletes a rule/control or downgrades a hard_block is
30
+ refused unless the human passes --allow-weakening (and it is recorded either
31
+ way). Approval alone is not enough there -- weakening must be deliberate and
32
+ named.
33
+ """
34
+
35
+ import difflib
36
+ import sys
37
+ import tempfile
38
+ from dataclasses import dataclass, field
39
+ from pathlib import Path
40
+
41
+ import click
42
+ import yaml
43
+ from rich.console import Console
44
+
45
+ from pcp import decision_log
46
+ from pcp.llm import client as llm
47
+ from pcp.schema import validator
48
+
49
+ console = Console()
50
+
51
+
52
+ @dataclass
53
+ class SpecTarget:
54
+ """One human-authorized file participating in a propose/approve write."""
55
+
56
+ name: str # display + diff label, e.g. "strategy/decomposition.md"
57
+ path: Path
58
+ key: str # JSON key the LLM returns the full replacement content under
59
+ schema: str | None = None # schema name for validator.validate_file, if any
60
+ guarded: bool = False # True => weakening check applies (governance files)
61
+ description: str = ""
62
+
63
+
64
+ @dataclass
65
+ class WriteResult:
66
+ written: bool
67
+ result: dict = field(default_factory=dict)
68
+ reason: str = ""
69
+
70
+
71
+ def render_diff(old: str, new: str, name: str) -> str:
72
+ return "\n".join(
73
+ difflib.unified_diff(
74
+ old.splitlines(),
75
+ new.splitlines(),
76
+ fromfile=f"a/{name}",
77
+ tofile=f"b/{name}",
78
+ lineterm="",
79
+ )
80
+ )
81
+
82
+
83
+ def build_output_schema(targets: list[SpecTarget]) -> str:
84
+ """The JSON output contract to append to a caller's system prompt, so every
85
+ amend prompt describes its return shape identically."""
86
+ lines = ["{"]
87
+ for t in targets:
88
+ lines.append(f' "{t.key}": "full replacement content of {t.name}",')
89
+ lines.append(' "summary": "one paragraph: exactly what changed and why, for the audit trail"')
90
+ lines.append("}")
91
+ return "\n".join(lines)
92
+
93
+
94
+ def _schema_errors(text: str, schema_name: str) -> list[str]:
95
+ """Validate proposed content without writing it to its real path first."""
96
+ with tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False) as fh:
97
+ fh.write(text)
98
+ tmp = Path(fh.name)
99
+ try:
100
+ return validator.validate_file(tmp, schema_name)
101
+ finally:
102
+ tmp.unlink(missing_ok=True)
103
+
104
+
105
+ HARD = "hard_block"
106
+ _SEVERITY_RANK = {"advisory": 0, "warn": 1, "warning": 1, HARD: 2}
107
+
108
+
109
+ def detect_weakening(old_text: str, new_text: str) -> list[str]:
110
+ """Governance-file weakening detector: removed rules/controls, or a
111
+ severity downgraded away from hard_block. Deterministic (rung 1) -- never
112
+ an LLM judging whether its own proposed edit weakened the gate.
113
+
114
+ Unparseable-old is treated as no findings (nothing trustworthy to compare);
115
+ unparseable-new is caught by the schema check, not here."""
116
+ try:
117
+ old = yaml.safe_load(old_text) or {}
118
+ new = yaml.safe_load(new_text) or {}
119
+ except yaml.YAMLError:
120
+ return []
121
+ if not isinstance(old, dict) or not isinstance(new, dict):
122
+ return []
123
+
124
+ findings = []
125
+ for collection in ("rules", "controls"):
126
+ old_items = {i.get("id"): i for i in (old.get(collection) or []) if isinstance(i, dict) and i.get("id")}
127
+ new_items = {i.get("id"): i for i in (new.get(collection) or []) if isinstance(i, dict) and i.get("id")}
128
+ for rid in sorted(set(old_items) - set(new_items)):
129
+ findings.append(f"{collection}: {rid} removed")
130
+ for rid in sorted(set(old_items) & set(new_items)):
131
+ old_sev = str(old_items[rid].get("severity", "")).lower()
132
+ new_sev = str(new_items[rid].get("severity", "")).lower()
133
+ if old_sev == new_sev:
134
+ continue
135
+ if _SEVERITY_RANK.get(new_sev, 0) < _SEVERITY_RANK.get(old_sev, 0):
136
+ findings.append(f"{collection}: {rid} severity {old_sev} -> {new_sev} (downgrade)")
137
+
138
+ # SDLC_phase.yaml has no rules/controls list — dropped phases, or dropped
139
+ # exit criteria within a surviving phase, are the equivalent weakening.
140
+ old_phases = {p.get("name"): p for p in (old.get("phases") or []) if isinstance(p, dict) and p.get("name")}
141
+ new_phases = {p.get("name"): p for p in (new.get("phases") or []) if isinstance(p, dict) and p.get("name")}
142
+ for name in sorted(set(old_phases) - set(new_phases)):
143
+ findings.append(f"phases: {name} removed")
144
+ for name in sorted(set(old_phases) & set(new_phases)):
145
+ old_ids = {c.get("id") for c in (old_phases[name].get("exit_criteria") or []) if isinstance(c, dict)}
146
+ new_ids = {c.get("id") for c in (new_phases[name].get("exit_criteria") or []) if isinstance(c, dict)}
147
+ for cid in sorted(old_ids - new_ids):
148
+ findings.append(f"phases.{name}.exit_criteria: {cid} removed")
149
+
150
+ return findings
151
+
152
+
153
+ def propose_and_write(
154
+ pcp_dir: Path,
155
+ targets: list[SpecTarget],
156
+ system_prompt: str,
157
+ user_prompt: str,
158
+ *,
159
+ command: str,
160
+ intent: str,
161
+ model: str | None = None,
162
+ yes: bool = False,
163
+ allow_weakening: bool = False,
164
+ decision_category: str = "architecture",
165
+ no_change_hint: str = "",
166
+ ) -> WriteResult:
167
+ """Draft a rewrite of every target, show a real diff, require approval, write.
168
+
169
+ Exits(2) on LLM error, a missing key in the response, a schema violation, or
170
+ unapproved weakening of a guarded file. Returns WriteResult(written=False)
171
+ for the benign outcomes (nothing changed, human declined) so callers can
172
+ keep their own exit code at 0.
173
+ """
174
+ old_texts = {t.key: (t.path.read_text() if t.path.exists() else "") for t in targets}
175
+
176
+ console.print(f"[dim]Drafting {', '.join(t.name for t in targets)} rewrite...[/dim]")
177
+ try:
178
+ result = llm.call_json(
179
+ system_prompt,
180
+ user_prompt,
181
+ model=model or llm.BUILD_MODEL,
182
+ pcp_dir=pcp_dir,
183
+ command=command,
184
+ )
185
+ except (RuntimeError, ValueError) as e:
186
+ console.print(f"[red]Error calling LLM:[/red] {e}")
187
+ sys.exit(2)
188
+
189
+ new_texts = {}
190
+ for t in targets:
191
+ content = result.get(t.key, "")
192
+ if not content:
193
+ console.print(f"[red]Error:[/red] LLM did not return content for {t.name}.")
194
+ sys.exit(2)
195
+ new_texts[t.key] = content
196
+
197
+ diffs = {t.name: render_diff(old_texts[t.key], new_texts[t.key], t.name) for t in targets}
198
+ if not any(diffs.values()):
199
+ console.print(
200
+ "[yellow]No changes -- the stated change doesn't appear to require a rewrite. "
201
+ f"{no_change_hint or 'If this is unexpected, restate it more concretely.'}[/yellow]"
202
+ )
203
+ return WriteResult(written=False, result=result, reason="no-change")
204
+
205
+ # Schema + weakening checks run BEFORE the human is asked to approve, so an
206
+ # invalid or gate-weakening proposal never even reaches the prompt.
207
+ for t in targets:
208
+ if not diffs[t.name]:
209
+ continue
210
+ if t.schema:
211
+ errors = _schema_errors(new_texts[t.key], t.schema)
212
+ if errors:
213
+ console.print(f"[red]Error:[/red] proposed {t.name} fails its schema — not written.")
214
+ for err in errors[:10]:
215
+ console.print(f" {err}")
216
+ sys.exit(2)
217
+ if t.guarded:
218
+ weakened = detect_weakening(old_texts[t.key], new_texts[t.key])
219
+ if weakened and not allow_weakening:
220
+ console.print(
221
+ f"[red]Refused:[/red] proposed {t.name} weakens the gates that police this project:"
222
+ )
223
+ for w in weakened:
224
+ console.print(f" {w}")
225
+ console.print(
226
+ "\n[dim]Approval alone is not enough for a governance file. Re-run with "
227
+ "--allow-weakening if this is deliberate — it is recorded in decision_log.jsonl.[/dim]"
228
+ )
229
+ sys.exit(2)
230
+
231
+ console.print(f"\n[bold]Summary:[/bold] {result.get('summary', '')}\n")
232
+ for t in targets:
233
+ if diffs[t.name]:
234
+ console.print(f"[bold]{t.name} diff:[/bold]")
235
+ console.print(diffs[t.name])
236
+ console.print("")
237
+
238
+ changed = [t.name for t in targets if diffs[t.name]]
239
+ if not yes and not click.confirm(f"Apply this rewrite to {', '.join(changed)}?"):
240
+ console.print("[yellow]Aborted -- files unchanged.[/yellow]")
241
+ return WriteResult(written=False, result=result, reason="declined")
242
+
243
+ weakenings = []
244
+ for t in targets:
245
+ if not diffs[t.name]:
246
+ continue
247
+ if t.guarded:
248
+ weakenings += [f"{t.name}: {w}" for w in detect_weakening(old_texts[t.key], new_texts[t.key])]
249
+ t.path.parent.mkdir(parents=True, exist_ok=True)
250
+ t.path.write_text(new_texts[t.key])
251
+ console.print(f"[green]✓[/green] {', '.join(changed)} rewritten.")
252
+
253
+ evidence = result.get("summary", "")
254
+ if weakenings:
255
+ console.print("[yellow]⚠ gate weakening applied with --allow-weakening:[/yellow]")
256
+ for w in weakenings:
257
+ console.print(f" {w}")
258
+ evidence = f"{evidence}\n\nGATE WEAKENING (--allow-weakening): " + "; ".join(weakenings)
259
+
260
+ decision_log.record(
261
+ pcp_dir,
262
+ source=command,
263
+ session_id=None,
264
+ category=decision_category,
265
+ summary=f"{', '.join(changed)} amended: {intent}",
266
+ evidence=evidence,
267
+ )
268
+
269
+ return WriteResult(written=True, result=result)
pcp/spend.py ADDED
@@ -0,0 +1,77 @@
1
+ """Rolling project-level spend ceiling.
2
+
3
+ Closes the gap found in the 2026-07-17 market sweep: PCP bounded individual
4
+ attempts (PCP_BUILD_AGENT_MAX_BUDGET_USD) and runs (PCP_MAX_BUILD_SESSIONS)
5
+ but nothing capped cumulative project spend over time — the primitive
6
+ Portkey's auto-expiring budget keys and OpenRouter's hard spend guardrails
7
+ treat as table stakes. Reads .pcp/token_ledger.yaml (already written by every
8
+ LLM call), no new bookkeeping.
9
+
10
+ Ceilings (both optional, unset = unlimited, matching every other PCP cap):
11
+ PCP_PROJECT_BUDGET_USD — lifetime ceiling for this project
12
+ PCP_PROJECT_DAILY_BUDGET_USD — rolling ceiling for the current UTC day
13
+
14
+ Enforcement is refuse-loudly-at-session-start (build/watch check before
15
+ spawning a new agent session), never mid-attempt kill — an attempt already
16
+ paid for gets to finish and be recorded.
17
+ """
18
+
19
+ import os
20
+ from datetime import datetime, timezone
21
+ from pathlib import Path
22
+
23
+ import yaml
24
+
25
+
26
+ def _ledger_costs(pcp_dir: Path) -> list[tuple[str, float]]:
27
+ path = Path(pcp_dir) / "token_ledger.yaml"
28
+ if not path.exists():
29
+ return []
30
+ try:
31
+ data = yaml.safe_load(path.read_text()) or {}
32
+ except yaml.YAMLError:
33
+ return []
34
+ out = []
35
+ for c in data.get("calls", []):
36
+ cost = c.get("cost_usd")
37
+ if isinstance(cost, (int, float)):
38
+ out.append((str(c.get("timestamp", "")), float(cost)))
39
+ return out
40
+
41
+
42
+ def project_spend(pcp_dir: Path) -> dict:
43
+ """Total and today's (UTC) spend from the ledger."""
44
+ costs = _ledger_costs(pcp_dir)
45
+ today_prefix = datetime.now(timezone.utc).strftime("%Y-%m-%d")
46
+ return {
47
+ "total_usd": round(sum(c for _, c in costs), 4),
48
+ "today_usd": round(sum(c for ts, c in costs if ts.startswith(today_prefix)), 4),
49
+ }
50
+
51
+
52
+ def check_ceiling(pcp_dir: Path) -> tuple[bool, str]:
53
+ """(allowed, reason). Allowed unless a configured ceiling is breached."""
54
+ total_cap = os.environ.get("PCP_PROJECT_BUDGET_USD")
55
+ daily_cap = os.environ.get("PCP_PROJECT_DAILY_BUDGET_USD")
56
+ if not total_cap and not daily_cap:
57
+ return True, "no project spend ceiling configured"
58
+ spend = project_spend(pcp_dir)
59
+ if total_cap:
60
+ try:
61
+ if spend["total_usd"] >= float(total_cap):
62
+ return False, (
63
+ f"project spend ${spend['total_usd']:.2f} >= ceiling ${float(total_cap):.2f} "
64
+ "(PCP_PROJECT_BUDGET_USD) — raise the ceiling or archive the ledger to proceed"
65
+ )
66
+ except ValueError:
67
+ pass
68
+ if daily_cap:
69
+ try:
70
+ if spend["today_usd"] >= float(daily_cap):
71
+ return False, (
72
+ f"today's spend ${spend['today_usd']:.2f} >= daily ceiling ${float(daily_cap):.2f} "
73
+ "(PCP_PROJECT_DAILY_BUDGET_USD) — resumes next UTC day or raise the ceiling"
74
+ )
75
+ except ValueError:
76
+ pass
77
+ return True, "within configured ceilings"
pcp/symbols.py ADDED
@@ -0,0 +1,86 @@
1
+ """Per-symbol AST fingerprints (Swimm token-tracking / Fiberplane drift.lock
2
+ reference patterns, 2026-07-17, build plan 3.4).
3
+
4
+ File-level change detection can't distinguish "this file was touched" from
5
+ "the symbol a criterion actually depends on changed". Fingerprinting each
6
+ top-level function/class by a normalized AST hash (structure + names, not
7
+ whitespace/comments/positions) lets `pcp scan` report symbol-level churn —
8
+ the noise-reduction the drift-tool field converged on.
9
+
10
+ Python-only today (ast stdlib); other languages report no symbols rather than
11
+ guessing. Deterministic, zero LLM.
12
+ """
13
+
14
+ import ast
15
+ import hashlib
16
+ import json
17
+ from pathlib import Path
18
+
19
+ FINGERPRINTS_FILE = "symbol_fingerprints.json"
20
+
21
+
22
+ def fingerprint_python_file(path: Path) -> dict[str, str]:
23
+ import warnings
24
+ try:
25
+ # Scanned projects' own SyntaxWarnings (invalid escape sequences etc.)
26
+ # are their lint problem, not scan output — suppress here or every
27
+ # `pcp scan` re-emits them for files PCP merely fingerprints.
28
+ with warnings.catch_warnings():
29
+ warnings.simplefilter("ignore", SyntaxWarning)
30
+ tree = ast.parse(path.read_text(errors="replace"))
31
+ except (SyntaxError, OSError):
32
+ return {}
33
+ out = {}
34
+ for node in tree.body:
35
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
36
+ # ast.dump without attributes normalizes away line/col/whitespace;
37
+ # docstrings remain part of the structure deliberately — a
38
+ # contract-describing docstring change IS a symbol change.
39
+ digest = hashlib.sha256(ast.dump(node, annotate_fields=False).encode()).hexdigest()[:16]
40
+ out[node.name] = digest
41
+ return out
42
+
43
+
44
+ def fingerprint_project(project_root: Path) -> dict[str, dict[str, str]]:
45
+ result = {}
46
+ for p in sorted(project_root.rglob("*.py")):
47
+ if any(seg in p.parts for seg in ("__pycache__", ".venv", "venv", "node_modules", ".git", ".pcp")):
48
+ continue
49
+ fp = fingerprint_python_file(p)
50
+ if fp:
51
+ result[str(p.relative_to(project_root))] = fp
52
+ return result
53
+
54
+
55
+ def diff_fingerprints(old: dict, new: dict) -> dict:
56
+ """{changed: [file:symbol], added: [...], removed: [...]}."""
57
+ changed, added, removed = [], [], []
58
+ for f, symbols in new.items():
59
+ old_symbols = old.get(f, {})
60
+ for name, h in symbols.items():
61
+ if name not in old_symbols:
62
+ added.append(f"{f}:{name}")
63
+ elif old_symbols[name] != h:
64
+ changed.append(f"{f}:{name}")
65
+ for name in old_symbols:
66
+ if name not in symbols:
67
+ removed.append(f"{f}:{name}")
68
+ for f in old:
69
+ if f not in new:
70
+ removed.extend(f"{f}:{name}" for name in old[f])
71
+ return {"changed": changed, "added": added, "removed": removed}
72
+
73
+
74
+ def update_fingerprints(pcp_dir: Path) -> dict:
75
+ """Recompute, diff vs stored, persist, return the diff summary."""
76
+ path = pcp_dir / FINGERPRINTS_FILE
77
+ old = {}
78
+ if path.exists():
79
+ try:
80
+ old = json.loads(path.read_text())
81
+ except json.JSONDecodeError:
82
+ old = {}
83
+ new = fingerprint_project(pcp_dir.parent)
84
+ delta = diff_fingerprints(old, new)
85
+ path.write_text(json.dumps(new, indent=1, sort_keys=True))
86
+ return delta