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/narrative_lint.py ADDED
@@ -0,0 +1,229 @@
1
+ """Narrative-lint — flags CLAUDE.md-family narrative prose that has drifted
2
+ from PCP's own tracked state (current_state.md/architecture.md).
3
+
4
+ Fleet evidence (2026-07-24 context-hygiene pass across Project E/
5
+ Project W/Project A/Project M): narrative prose in CLAUDE.md — stage
6
+ descriptions, "Open Decisions," "Pending" lists — drifted from tracked
7
+ state 3-for-3 in projects checked, because nothing checks free-text
8
+ against it. `~/.claude/scripts/session-hygiene-check.sh` already covers
9
+ the two purely mechanical checks (stale dates, missing referenced files)
10
+ as a SessionStart hook; this ports those into PCP's own enforcement
11
+ lifecycle (telemetry, CTRL-036) and adds the piece a bash regex can't do
12
+ — semantic contradiction between a narrative status claim and current_
13
+ state.md/architecture.md, which needs a judge call (rung 6, same posture
14
+ as CTRL-020's rung-necessity check: one batched call, advisory, fail-open).
15
+ """
16
+
17
+ import re
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+
21
+ STALE_DAYS_DEFAULT = 90
22
+
23
+ _DATE_RE = re.compile(r"\b(\d{4})-(\d{2})-(\d{2})\b")
24
+ _BACKTICK_PATH_RE = re.compile(r"`([^`]*(?:/[^`]+)+)`")
25
+ _SKIP_DIRS = {".git", "node_modules", ".venv", "venv", "__pycache__", ".pcp"}
26
+
27
+ _STATUS_KEYWORDS = [
28
+ "pending", "open decision", "planned", "pre-build", "not yet built",
29
+ "not yet implemented", "coming soon", "todo:", "in progress", "wip",
30
+ "not started", "deferred",
31
+ ]
32
+
33
+ NARRATIVE_CONTRADICTION_SYSTEM_PROMPT = """You are a documentation-drift auditor. \
34
+ You are given status-shaped lines pulled from a project's CLAUDE.md-family files \
35
+ (narrative context docs) alongside excerpts of that project's auto-generated \
36
+ ground-truth state (current_state.md / architecture.md). Flag any narrative line \
37
+ that is CONTRADICTED by the tracked state — e.g. a line claims something is \
38
+ "Pending"/"Planned"/"Pre-build" but the tracked state shows it already built or \
39
+ decided; a tech-stack claim contradicted by the actual stack; an "Open Decision" \
40
+ already answered. Do not flag lines that are merely unrelated to the tracked \
41
+ state, or that are still accurate. Respond with JSON: \
42
+ {"contradictions": [{"index": <int, index of the narrative line>, \
43
+ "reason": "<short explanation citing the tracked-state evidence>"}]}. \
44
+ Empty list if none found."""
45
+
46
+
47
+ def find_claude_md_files(project_root: Path) -> list[Path]:
48
+ """CLAUDE.md-family files: any CLAUDE.md/CLAUDE.local.md (path-scoped or
49
+ root) plus .claude/*.md — same scope session-hygiene-check.sh already
50
+ covers, ported to Python so PCP's own enforcement lifecycle can wire it
51
+ in instead of only ever reaching a human via a SessionStart hook print."""
52
+ found = []
53
+ for p in project_root.rglob("*"):
54
+ if p.is_dir() or any(seg in _SKIP_DIRS for seg in p.parts):
55
+ continue
56
+ if p.name in ("CLAUDE.md", "CLAUDE.local.md"):
57
+ found.append(p)
58
+ claude_dir = project_root / ".claude"
59
+ if claude_dir.is_dir():
60
+ found += [p for p in claude_dir.glob("*.md") if p.is_file()]
61
+ return sorted(set(found))
62
+
63
+
64
+ def check_stale_dates(files: list[Path], stale_days: int = STALE_DAYS_DEFAULT) -> list[str]:
65
+ """Deterministic — a dated reference (e.g. "RESOLVED 2026-01-01") older
66
+ than stale_days nobody cleaned up."""
67
+ findings = []
68
+ now = datetime.now(timezone.utc)
69
+ for f in files:
70
+ try:
71
+ text = f.read_text(errors="replace")
72
+ except OSError:
73
+ continue
74
+ for lineno, line in enumerate(text.splitlines(), start=1):
75
+ for m in _DATE_RE.finditer(line):
76
+ try:
77
+ d = datetime(int(m.group(1)), int(m.group(2)), int(m.group(3)), tzinfo=timezone.utc)
78
+ except ValueError:
79
+ continue
80
+ age_days = (now - d).days
81
+ if age_days > stale_days:
82
+ findings.append(
83
+ f"{f}:{lineno}: date {m.group(0)} is {age_days}d old (>{stale_days}d threshold): "
84
+ f"{line.strip()[:140]}"
85
+ )
86
+ return findings
87
+
88
+
89
+ def _looks_like_non_path(p: str) -> bool:
90
+ if any(c in p for c in (" ", ":", "<", ">", "*")):
91
+ return True
92
+ if "://" in p:
93
+ return True
94
+ if any(seg in p for seg in (".com/", ".org/", ".io/", ".net/", ".dev/")) or p.endswith(".git"):
95
+ return True
96
+ if p.startswith("/") and "/" not in p[1:] and "." not in p:
97
+ return True # bare slash-command like /pcp, not a filesystem path
98
+ return False
99
+
100
+
101
+ def check_missing_files(files: list[Path], project_root: Path) -> list[str]:
102
+ """Deterministic — a backtick-quoted path referenced in a CLAUDE.md-family
103
+ file that no longer exists on disk."""
104
+ findings = []
105
+ for f in files:
106
+ try:
107
+ text = f.read_text(errors="replace")
108
+ except OSError:
109
+ continue
110
+ for lineno, line in enumerate(text.splitlines(), start=1):
111
+ for m in _BACKTICK_PATH_RE.finditer(line):
112
+ p = m.group(1)
113
+ if _looks_like_non_path(p) or "*" in p:
114
+ continue
115
+ expanded = Path(p).expanduser()
116
+ if not expanded.is_absolute():
117
+ expanded = project_root / expanded
118
+ if not expanded.exists():
119
+ findings.append(f"{f}:{lineno}: referenced path not found on disk: `{p}`")
120
+ return findings
121
+
122
+
123
+ def collect_status_lines(files: list[Path]) -> list[tuple[str, int, str]]:
124
+ """Deterministic pre-filter — only status-shaped lines get sent to the
125
+ judge call (Token Discipline: never paste whole CLAUDE.md files)."""
126
+ hits = []
127
+ for f in files:
128
+ try:
129
+ text = f.read_text(errors="replace")
130
+ except OSError:
131
+ continue
132
+ for lineno, line in enumerate(text.splitlines(), start=1):
133
+ low = line.lower()
134
+ if any(k in low for k in _STATUS_KEYWORDS):
135
+ hits.append((str(f), lineno, line.strip()))
136
+ return hits
137
+
138
+
139
+ def _tracked_state_excerpt(pcp_dir: Path, max_chars: int) -> str:
140
+ parts = []
141
+ for name in ("current_state.md", "architecture.md"):
142
+ p = pcp_dir / name
143
+ if p.exists():
144
+ parts.append(f"### {name}\n{p.read_text(errors='replace')}")
145
+ excerpt = "\n\n".join(parts)
146
+ return excerpt[:max_chars]
147
+
148
+
149
+ def check_narrative_contradictions(
150
+ pcp_dir: Path, status_lines: list[tuple[str, int, str]],
151
+ max_state_chars: int = 8000,
152
+ ) -> list[str]:
153
+ """ONE batched judge call (rung 6 — semantic contradiction is the one
154
+ irreducibly judgment-shaped part of this lint, same posture CTRL-020's
155
+ rung-necessity check already uses). Advisory; fails open on any error
156
+ or if there's nothing tracked to compare against."""
157
+ if not status_lines:
158
+ return []
159
+ state_excerpt = _tracked_state_excerpt(pcp_dir, max_state_chars)
160
+ if not state_excerpt.strip():
161
+ return []
162
+
163
+ from pcp.llm import client as llm
164
+
165
+ numbered = "\n".join(f"[{i}] {path}:{lineno}: {text}" for i, (path, lineno, text) in enumerate(status_lines))
166
+ user_prompt = f"Narrative lines:\n{numbered}\n\nTracked state:\n{state_excerpt}"
167
+ findings: list[str] = []
168
+ try:
169
+ res = llm.call_json(
170
+ NARRATIVE_CONTRADICTION_SYSTEM_PROMPT, user_prompt, model=llm.JUDGE_MODEL,
171
+ pcp_dir=pcp_dir, command="narrative-lint",
172
+ )
173
+ for c in res.get("contradictions", []):
174
+ if not isinstance(c, dict):
175
+ continue
176
+ i = c.get("index")
177
+ if isinstance(i, int) and 0 <= i < len(status_lines):
178
+ path, lineno, text = status_lines[i]
179
+ findings.append(
180
+ f"{path}:{lineno}: narrative claim contradicted by tracked state — "
181
+ f"{c.get('reason', '')[:200]} (claim: {text[:140]!r})"
182
+ )
183
+ except Exception:
184
+ pass # advisory judge call — fail open, same posture as every other wave-merge judge check
185
+ return findings
186
+
187
+
188
+ def run(pcp_dir: Path, stale_days: int = STALE_DAYS_DEFAULT, skip_llm: bool = False) -> dict:
189
+ """Full lint: deterministic checks always run; the semantic contradiction
190
+ check is skippable (CI/cost-sensitive callers) via skip_llm."""
191
+ project_root = pcp_dir.parent
192
+ files = find_claude_md_files(project_root)
193
+ stale = check_stale_dates(files, stale_days)
194
+ missing = check_missing_files(files, project_root)
195
+ contradictions: list[str] = []
196
+ if not skip_llm:
197
+ status_lines = collect_status_lines(files)
198
+ contradictions = check_narrative_contradictions(pcp_dir, status_lines)
199
+ return {
200
+ "files_scanned": [str(f) for f in files],
201
+ "stale_dates": stale,
202
+ "missing_files": missing,
203
+ "contradictions": contradictions,
204
+ }
205
+
206
+
207
+ def render_markdown(result: dict, timestamp: str) -> str:
208
+ lines = [
209
+ "# Narrative Lint",
210
+ f"Generated: {timestamp}",
211
+ "",
212
+ f"CLAUDE.md-family files scanned: {len(result['files_scanned'])}",
213
+ "",
214
+ ]
215
+ sections = [
216
+ ("Stale dated references", result["stale_dates"]),
217
+ ("Missing referenced files", result["missing_files"]),
218
+ ("Narrative vs. tracked-state contradictions (advisory judge call)", result["contradictions"]),
219
+ ]
220
+ for title, findings in sections:
221
+ lines.append(f"## {title}")
222
+ lines.append("")
223
+ if findings:
224
+ for f in findings:
225
+ lines.append(f"- {f}")
226
+ else:
227
+ lines.append("_None found._")
228
+ lines.append("")
229
+ return "\n".join(lines)
pcp/nav_graph.py ADDED
@@ -0,0 +1,226 @@
1
+ """Measured navigation reachability — is a screen actually reachable, and how deep.
2
+
3
+ The Feature Exposure Ladder previously classified a criterion by whether it
4
+ carried a `design_justification` field. Measured 2026-07-27 on Project O,
5
+ that produced 101 "Built, Hidden" and 24 "Exposed, Enriched" with **zero** at
6
+ rungs 2 and 3 -- a binary condition wearing a four-rung costume. "101 hidden
7
+ features" was really "101 criteria lack an optional field": a statement about
8
+ PCP's own paperwork presented as a statement about the product, and the exact
9
+ Goodhart shape `coverage_audit.py` exists to guard against elsewhere.
10
+
11
+ This module measures the artifact instead. It builds a page/route graph from the
12
+ UI source actually on disk and computes shortest-path depth from the app's entry
13
+ point. Deterministic, rung 1, no LLM.
14
+
15
+ Two navigation styles are recognised, both by reading what is there:
16
+
17
+ * multi-page (Vite `rollupOptions.input`, or bare `*.html`) with `href` edges --
18
+ Project O's canvas-next is this: `index.html` and `query.html` linking
19
+ to each other.
20
+ * single-page router (`<Route path=...>`) with `<Link to=...>` / `navigate(...)`
21
+ edges.
22
+
23
+ The honest part is the fallback. When the graph cannot be determined -- no UI
24
+ source, no pages found, or a criterion that cannot be tied to a screen -- the
25
+ answer is `None`, meaning "not determinable", NOT "hidden". Reporting an absent
26
+ measurement as a bad measurement is how the previous version produced a
27
+ discoverability crisis out of an unpopulated field.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import re
34
+ from collections import deque
35
+ from pathlib import Path
36
+
37
+ _SKIP_DIRS = {"node_modules", ".venv", "venv", "dist", "build", ".git", "__pycache__"}
38
+
39
+ # Multi-page: Vite's own registry of entry documents.
40
+ _VITE_INPUT_BLOCK = re.compile(r"input\s*:\s*\{(.*?)\}", re.DOTALL)
41
+ _VITE_INPUT_ENTRY = re.compile(r"""["']?(\w+)["']?\s*:\s*resolve\([^,]+,\s*["']([^"']+)["']\)""")
42
+
43
+ # Edges.
44
+ _HREF = re.compile(r"""href\s*=\s*["']([^"'#?]+)""")
45
+ _LINK_TO = re.compile(r"""<Link[^>]*\bto\s*=\s*["']([^"']+)["']""")
46
+ _NAVIGATE = re.compile(r"""navigate\(\s*["']([^"']+)["']""")
47
+ _LOCATION = re.compile(r"""location\.(?:href|assign|replace)\s*=?\s*\(?\s*["']([^"']+)["']""")
48
+
49
+ # Single-page routes.
50
+ _ROUTE = re.compile(r"""<Route[^>]*\bpath\s*=\s*["']([^"']+)["']""")
51
+
52
+
53
+ def _source_files(root: Path) -> list[Path]:
54
+ out: list[Path] = []
55
+ if not root.is_dir():
56
+ return out
57
+ for p in root.rglob("*"):
58
+ if not p.is_file() or p.suffix not in {".tsx", ".ts", ".jsx", ".js", ".html", ".vue", ".svelte"}:
59
+ continue
60
+ if _SKIP_DIRS & set(p.parts):
61
+ continue
62
+ out.append(p)
63
+ return out
64
+
65
+
66
+ def _ui_roots(project_root: Path) -> list[Path]:
67
+ """Directories that look like a front end, without hardcoding a layout."""
68
+ roots: list[Path] = []
69
+ for cand in project_root.rglob("vite.config.*"):
70
+ if not (_SKIP_DIRS & set(cand.parts)):
71
+ roots.append(cand.parent)
72
+ if roots:
73
+ return roots
74
+ for name in ("web", "frontend", "ui", "client"):
75
+ d = project_root / name
76
+ if d.is_dir():
77
+ roots.append(d)
78
+ return roots
79
+
80
+
81
+ def discover_pages(ui_root: Path) -> tuple[list[str], str | None]:
82
+ """(page ids, entry page) for one UI root. Entry is None if undecidable."""
83
+ pages: list[str] = []
84
+ entry: str | None = None
85
+
86
+ for cfg in ui_root.glob("vite.config.*"):
87
+ block = _VITE_INPUT_BLOCK.search(cfg.read_text(errors="ignore"))
88
+ if not block:
89
+ continue
90
+ for name, path in _VITE_INPUT_ENTRY.findall(block.group(1)):
91
+ page = Path(path).name
92
+ pages.append(page)
93
+ # Vite convention: `main` (or the only entry) is the app's front door.
94
+ if name in {"main", "index"} or entry is None:
95
+ entry = page
96
+
97
+ if not pages:
98
+ for html in ui_root.glob("*.html"):
99
+ pages.append(html.name)
100
+ if "index.html" in pages:
101
+ entry = "index.html"
102
+ elif len(pages) == 1:
103
+ entry = pages[0]
104
+
105
+ if not pages:
106
+ routes: set[str] = set()
107
+ for f in _source_files(ui_root):
108
+ routes.update(_ROUTE.findall(f.read_text(errors="ignore")))
109
+ if routes:
110
+ pages = sorted(routes)
111
+ entry = "/" if "/" in routes else None
112
+
113
+ return sorted(set(pages)), entry
114
+
115
+
116
+ def _normalise(target: str, pages: list[str]) -> str | None:
117
+ """Map a raw href/route to a known page id, or None if it leaves the app."""
118
+ t = target.strip()
119
+ if not t or t.startswith(("http://", "https://", "mailto:", "tel:", "javascript:")):
120
+ return None
121
+ base = Path(t).name or t
122
+ if base in pages:
123
+ return base
124
+ if t in pages:
125
+ return t
126
+ if not t.startswith("/"):
127
+ cand = "/" + t.lstrip("./")
128
+ if cand in pages:
129
+ return cand
130
+ return None
131
+
132
+
133
+ def build_link_graph(ui_root: Path, pages: list[str]) -> dict[str, set[str]]:
134
+ """page -> pages it links to, from hrefs / <Link to> / navigate() / location.
135
+
136
+ A file's edges are attributed to every page whose name it matches, and to
137
+ every page when the file is shared (a component imported by several pages).
138
+ Erring toward MORE edges makes the depth estimate optimistic, so an
139
+ "unreachable" verdict is conservative -- the direction worth erring in when
140
+ the output is "this feature is hidden".
141
+ """
142
+ graph: dict[str, set[str]] = {p: set() for p in pages}
143
+ for f in _source_files(ui_root):
144
+ text = f.read_text(errors="ignore")
145
+ targets = set()
146
+ for pat in (_HREF, _LINK_TO, _NAVIGATE, _LOCATION):
147
+ targets.update(pat.findall(text))
148
+ resolved = {r for t in targets if (r := _normalise(t, pages))}
149
+ if not resolved:
150
+ continue
151
+ owner = f.name if f.name in pages else None
152
+ for src in ([owner] if owner else pages):
153
+ graph.setdefault(src, set()).update(resolved - {src})
154
+ return graph
155
+
156
+
157
+ def reachability(graph: dict[str, set[str]], entry: str) -> dict[str, int]:
158
+ """Shortest-path depth from `entry`. Absent key == not reachable."""
159
+ depths = {entry: 0}
160
+ q = deque([entry])
161
+ while q:
162
+ cur = q.popleft()
163
+ for nxt in graph.get(cur, ()): # noqa: SIM118
164
+ if nxt not in depths:
165
+ depths[nxt] = depths[cur] + 1
166
+ q.append(nxt)
167
+ return depths
168
+
169
+
170
+ def analyse(project_root: Path) -> dict:
171
+ """{"available": bool, "pages": [...], "entry": str, "depths": {page: int}}.
172
+
173
+ `available: False` means the front end could not be located or its entry
174
+ could not be determined -- callers MUST treat that as "not measured", never
175
+ as "nothing is reachable".
176
+ """
177
+ # A project can hold several front ends (Project O has `web/` and
178
+ # `web/canvas-next/`, both with a vite config). First-match-wins picked the
179
+ # thinner one and reported a single unreachable-from-nothing page, so pick
180
+ # the most substantive graph and record that others exist rather than
181
+ # silently analysing a stub.
182
+ candidates = []
183
+ for ui_root in _ui_roots(project_root):
184
+ pages, entry = discover_pages(ui_root)
185
+ if not pages or not entry:
186
+ continue
187
+ graph = build_link_graph(ui_root, pages)
188
+ depths = reachability(graph, entry)
189
+ candidates.append({
190
+ "available": True,
191
+ "ui_root": str(ui_root.relative_to(project_root)) if ui_root != project_root else ".",
192
+ "pages": pages,
193
+ "entry": entry,
194
+ "depths": depths,
195
+ "unreachable": sorted(set(pages) - set(depths)),
196
+ })
197
+ if not candidates:
198
+ return {"available": False, "reason": "no front end with a determinable entry page found"}
199
+ candidates.sort(key=lambda c: (len(c["pages"]), len(c["depths"])), reverse=True)
200
+ best = candidates[0]
201
+ others = [c["ui_root"] for c in candidates[1:]]
202
+ if others:
203
+ best["other_ui_roots"] = others
204
+ return best
205
+
206
+
207
+ def screen_for_target(target: str, analysis: dict) -> str | None:
208
+ """Which page a criterion's declared `target` file belongs to, if knowable.
209
+
210
+ A target that IS a page resolves directly. A component file resolves only
211
+ when its name matches a page stem; otherwise None -- import-graph tracing is
212
+ deliberately not attempted here, because guessing which screen a shared
213
+ component belongs to would reintroduce exactly the fabricated precision this
214
+ module exists to remove.
215
+ """
216
+ if not target or not analysis.get("available"):
217
+ return None
218
+ pages = analysis.get("pages", [])
219
+ direct = _normalise(target, pages)
220
+ if direct:
221
+ return direct
222
+ stem = Path(target).stem.lower()
223
+ for page in pages:
224
+ if Path(page).stem.lower() == stem:
225
+ return page
226
+ return None
@@ -0,0 +1,129 @@
1
+ """Objective-conflict gate.
2
+
3
+ Closes a real incident, 2026-07-22 (Project O dogfood): a business
4
+ decision to stop storing business-transaction instances was made and agreed
5
+ 2026-07-20; objective.md/target_state.md (human-AUTHORIZED, Hard Rule 2 — see
6
+ `pcp correct-objective` for the propose/diff/approve path) never
7
+ got rewritten; two days later a 30+-agent, multi-million-token `pcp build`
8
+ cycle built exactly the rejected shape end-to-end (storage layer, agent tools,
9
+ UI) — every gate passed, because every gate validates the build against
10
+ objective.md as given, never against whether objective.md is still true.
11
+
12
+ `capture.py`'s classifier already sets `drift_flag` on a brd_items.yaml entry
13
+ when a captured business item conflicts with objective.md's text — that
14
+ machinery existed and, if it had run, would have caught this. But the flag was
15
+ purely advisory (buried in brd.md prose, three days before the incident, in a
16
+ session nobody reread). This module gives it teeth: an active item with a live
17
+ drift_flag hard-blocks `pcp build` until objective.md/target_state.md is
18
+ actually edited (verified by content hash — proof of an edit, not a checkbox a
19
+ human can click without doing the work) or a human explicitly dismisses it
20
+ with a reason.
21
+ """
22
+
23
+ import hashlib
24
+ from datetime import datetime, timezone
25
+ from pathlib import Path
26
+
27
+ import yaml
28
+
29
+ _TS_FMT = "%Y-%m-%dT%H:%M:%SZ"
30
+
31
+
32
+ def objective_hash(pcp_dir: Path) -> str:
33
+ """Deterministic fingerprint of the immutable spec files a business
34
+ correction would need to change. Comparing this at flag-time vs.
35
+ check-time is the proof an edit actually happened."""
36
+ parts = []
37
+ for name in ("objective.md", "target_state.md"):
38
+ p = pcp_dir / name
39
+ parts.append(p.read_text() if p.exists() else "")
40
+ return hashlib.sha256("\x00".join(parts).encode()).hexdigest()
41
+
42
+
43
+ def _load_items(pcp_dir: Path) -> list[dict]:
44
+ path = pcp_dir / "brd_items.yaml"
45
+ if not path.exists():
46
+ return []
47
+ try:
48
+ data = yaml.safe_load(path.read_text()) or {}
49
+ except yaml.YAMLError:
50
+ return []
51
+ items = data.get("items", [])
52
+ return items if isinstance(items, list) else []
53
+
54
+
55
+ def _save_items(pcp_dir: Path, items: list[dict]) -> None:
56
+ (pcp_dir / "brd_items.yaml").write_text(yaml.dump({"items": items}, default_flow_style=False))
57
+
58
+
59
+ def is_unresolved_conflict(item: dict) -> bool:
60
+ """One definition of "this conflict is still open", shared by the build
61
+ gate and every renderer.
62
+
63
+ It existed only inside reconcile()'s loop until 2026-07-25, when a real
64
+ divergence surfaced: `pcp objective-conflicts --dismiss` writes
65
+ drift_dismissed_at/_reason but deliberately leaves drift_flag set (the flag
66
+ is the historical record of what was flagged). The gate honoured the
67
+ dismissal; capture._write_brd_md() filtered on drift_flag alone, so a
68
+ dismissed item kept rendering under "Drift Flags" in brd.md forever —
69
+ reporting an open conflict the build no longer had. Any new consumer must
70
+ call this rather than re-deriving the predicate."""
71
+ if item.get("status") != "active" or not item.get("drift_flag"):
72
+ return False
73
+ return not (item.get("drift_resolved_at") or item.get("drift_dismissed_at"))
74
+
75
+
76
+ def reconcile(pcp_dir: Path) -> list[dict]:
77
+ """Auto-clears drift flags whose objective_hash_at_flag no longer matches
78
+ current objective.md/target_state.md content -- the file actually got
79
+ edited since the conflict was raised. Deterministic, no LLM judgment
80
+ involved in the resolution itself (only in the original flagging, inside
81
+ capture.py). Returns the still-unresolved conflicts: active, drift_flag
82
+ set, neither hash-cleared nor dismissed.
83
+
84
+ An item with a drift_flag but no objective_hash_at_flag (written before
85
+ this mechanism existed, or by any other path) is treated as unresolved —
86
+ fails loud rather than silently trusting an unstamped flag, same posture
87
+ as this project's other fail-open-gate fixes."""
88
+ items = _load_items(pcp_dir)
89
+ current_hash = objective_hash(pcp_dir)
90
+ now = datetime.now(timezone.utc).strftime(_TS_FMT)
91
+ changed = False
92
+ unresolved = []
93
+
94
+ for item in items:
95
+ if not is_unresolved_conflict(item):
96
+ continue
97
+ flagged_hash = item.get("objective_hash_at_flag")
98
+ if flagged_hash and flagged_hash != current_hash:
99
+ item["drift_resolved_at"] = now
100
+ item["drift_resolved_reason"] = "objective.md/target_state.md edited since this conflict was flagged"
101
+ changed = True
102
+ continue
103
+ unresolved.append(item)
104
+
105
+ if changed:
106
+ _save_items(pcp_dir, items)
107
+ return unresolved
108
+
109
+
110
+ def dismiss(pcp_dir: Path, item_id: str, reason: str) -> bool:
111
+ """Human explicitly dismisses a flagged conflict without editing
112
+ objective.md -- for real false positives (classifier flagged something
113
+ that doesn't actually require a spec change). Requires a non-empty
114
+ reason -- same accountability posture as `[pcp-bypass: reason]`. Returns
115
+ True if a matching active, undismissed, unresolved item was found."""
116
+ if not reason or not reason.strip():
117
+ raise ValueError("dismiss requires a non-empty reason")
118
+ items = _load_items(pcp_dir)
119
+ now = datetime.now(timezone.utc).strftime(_TS_FMT)
120
+ found = False
121
+ for item in items:
122
+ if item.get("id") == item_id and item.get("drift_flag") and not item.get("drift_resolved_at") and not item.get("drift_dismissed_at"):
123
+ item["drift_dismissed_at"] = now
124
+ item["drift_dismissed_reason"] = reason.strip()
125
+ found = True
126
+ break
127
+ if found:
128
+ _save_items(pcp_dir, items)
129
+ return found
pcp/operational.py ADDED
@@ -0,0 +1,70 @@
1
+ """Files PCP itself generates while running — never subject matter for a gate.
2
+
3
+ These are machine-written records *about* the code (telemetry, ledgers, scans,
4
+ audit trails). They are not code, nobody authored them, and no rule should ever
5
+ be evaluated against their contents.
6
+
7
+ `build.py` already excluded them from criterion diffs. `check.py` did not, and
8
+ that gap produced a real Layer 1 failure on Project O, 2026-07-30:
9
+
10
+ reason: R008 matched its own rule text quoted inside generated
11
+ telemetry.jsonl, not a real property_hints persistence
12
+
13
+ An `ast_pattern` rule searched `.pcp/telemetry.jsonl` and found its own pattern
14
+ there — because telemetry records the findings of the rules, so a rule's pattern
15
+ text is written into the very file the next commit stages and scans. The gate
16
+ flagged its own audit trail.
17
+
18
+ The consequence was worse than one false finding. A `[pcp-bypass]` is
19
+ all-or-nothing across rules, so that single self-match caused **R001-R010 to be
20
+ bypassed together** for that commit, in an unattended run, with nobody reading it.
21
+ One false positive from a generated file voided the whole Layer 1 gate.
22
+
23
+ Kept in its own module because `build.py` imports `check.py`, so the constants
24
+ cannot live in either without a cycle. Any NEW file PCP writes under `.pcp/`
25
+ during a run must be added here at the same time it starts being written.
26
+ """
27
+
28
+ OPERATIONAL_PATHS: tuple[str, ...] = (
29
+ ".pcp/token_ledger.yaml", ".pcp/telemetry.jsonl", ".pcp/decision_log.jsonl",
30
+ ".pcp/brd.md", ".pcp/brd_items.yaml", ".pcp/coverage_audit.jsonl",
31
+ ".pcp/escalations.yaml", ".pcp/prune_log.yaml", ".pcp/current_state.md",
32
+ ".pcp/diff.md", ".pcp/notify_heartbeat.yaml", ".pcp/build_progress.yaml",
33
+ ".pcp/bypass_log.yaml", ".pcp/audit_trend.jsonl", ".pcp/attestations.jsonl",
34
+ ".pcp/symbol_fingerprints.json", ".pcp/install_approvals.yaml",
35
+ ".pcp/run_ledger.jsonl", ".pcp/pressure_test_log.jsonl",
36
+ ".pcp/audit.md", ".pcp/provenance.md", ".pcp/control_audit.md",
37
+ ".pcp/build_report.md", ".pcp/design_audit.md", ".pcp/narrative_lint.md",
38
+ ".pcp/architecture_justification.md",
39
+ "pcp.md",
40
+ )
41
+
42
+ OPERATIONAL_DIRS: tuple[str, ...] = (".pcp/evidence/", ".pcp/transcripts/")
43
+
44
+
45
+ def is_operational(path: str) -> bool:
46
+ """Is this a file PCP wrote about itself, rather than project content?
47
+
48
+ Note the prefix strip is deliberate and `lstrip` would be wrong here:
49
+ `lstrip("./")` removes a character SET, so it turns `.pcp/telemetry.jsonl`
50
+ into `pcp/telemetry.jsonl` and every path stops matching. That is exactly
51
+ what the first version did, and it silently made this whole module a no-op —
52
+ the tests caught it, the code read fine.
53
+ """
54
+ norm = str(path).replace("\\", "/")
55
+ while norm.startswith("./"):
56
+ norm = norm[2:]
57
+ return norm in OPERATIONAL_PATHS or any(norm.startswith(d) for d in OPERATIONAL_DIRS)
58
+
59
+
60
+ def filter_operational(paths: list[str]) -> tuple[list[str], list[str]]:
61
+ """(paths to gate, paths skipped as PCP's own output).
62
+
63
+ Returns both halves rather than silently dropping: a gate that quietly
64
+ narrows its own scope is indistinguishable from one that found nothing, and
65
+ that conflation is the single most repeated defect in this codebase.
66
+ """
67
+ keep, skipped = [], []
68
+ for p in paths:
69
+ (skipped if is_operational(p) else keep).append(p)
70
+ return keep, skipped