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