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/orphaned_work.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Criteria whose work landed in the repo but whose status still says `pending`.
|
|
2
|
+
|
|
3
|
+
Three independent occurrences on Project O inside a week, via two
|
|
4
|
+
different code paths:
|
|
5
|
+
|
|
6
|
+
- **The wave-gate reopen path.** `core-data-model` A022/A030/A033/A038 — $30.04
|
|
7
|
+
spent, all four branches merged into `main`, all four set back to `pending`
|
|
8
|
+
because a wave gate blocked on findings about a module outside the wave. Fixed
|
|
9
|
+
2026-07-30 (`_finding_blames_outside_wave`).
|
|
10
|
+
- **The run-stopped path, still unfixed.** `query-eval-harness`, 2026-07-30 —
|
|
11
|
+
$31.65 spent, A001 and A008 merged into `main` with source and tests present
|
|
12
|
+
(`32be3fa`, `bbb134c`), MOD_A002 committed (`4276c70`), and **all 18 criteria
|
|
13
|
+
reading `pending`**. No wave-block record exists; the run simply stopped after
|
|
14
|
+
its last commit and nothing wrote status back.
|
|
15
|
+
|
|
16
|
+
Whatever the cause, the consequence is the same and it is the worst kind PCP can
|
|
17
|
+
have: the project's own record of what is built becomes false in the direction
|
|
18
|
+
that makes people rebuild finished work. `pcp scan` regenerates `current_state.md`
|
|
19
|
+
from acceptance status, so a wrong status propagates into every downstream view —
|
|
20
|
+
the dashboard, `diff.md`, `validate-strategy`'s coverage, the next build's pending
|
|
21
|
+
list.
|
|
22
|
+
|
|
23
|
+
This module does not fix status. It **reports the contradiction**, deterministically
|
|
24
|
+
(rung 1, no LLM), because `acceptance.yaml` is human-approved and PCP must not
|
|
25
|
+
silently mark work complete on its own — that would trade a false "not built" for
|
|
26
|
+
a false "built", which is strictly worse.
|
|
27
|
+
|
|
28
|
+
The signal is a landed COMMIT written by PCP's own conventions -- `Merge
|
|
29
|
+
feat/<module>-<criterion_id>` from the merge path, or `<module>/<criterion_id>:`
|
|
30
|
+
from the auto-commit path. Branch-merged state is deliberately NOT used: worktree
|
|
31
|
+
reuse resets branches to the current base, so a branch that never carried a commit
|
|
32
|
+
still reports as merged. See `_landed_commit_subjects`. No heuristics about file
|
|
33
|
+
contents, no guessing.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
import subprocess
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
import yaml as _yaml
|
|
40
|
+
|
|
41
|
+
from pcp.schema.validator import load_yaml
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def dump_yaml(data: dict) -> str:
|
|
45
|
+
return _yaml.dump(data, default_flow_style=False, sort_keys=False)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _landed_commit_subjects(project_root: Path) -> list[str]:
|
|
49
|
+
"""Commit subjects reachable from HEAD. Empty on any git failure.
|
|
50
|
+
|
|
51
|
+
`git branch --merged` is NOT a usable signal here and using it was the first
|
|
52
|
+
version of this module. `pcp build` reuses worktrees and resets their branch
|
|
53
|
+
to the current base (`_sync_worktree_to_base`), so a branch that never carried
|
|
54
|
+
a single commit still reports as "merged" — it is an ancestor of HEAD by
|
|
55
|
+
construction. Checked against Project O, that produced 18 findings of
|
|
56
|
+
which most were false: `feat/query-eval-harness-MOD_A001` and
|
|
57
|
+
`feat/core-data-model-A041` have no merge commit and never advanced.
|
|
58
|
+
|
|
59
|
+
The trustworthy signal is PCP's own commit conventions, which only exist when
|
|
60
|
+
work actually landed:
|
|
61
|
+
- `_merge_module_branch` commits `Merge feat/<module>-<criterion_id>`
|
|
62
|
+
- `_auto_commit_criterion` commits `<module>/<criterion_id>: <description>`
|
|
63
|
+
"""
|
|
64
|
+
try:
|
|
65
|
+
proc = subprocess.run(
|
|
66
|
+
["git", "log", "--format=%s", "HEAD"],
|
|
67
|
+
cwd=project_root, capture_output=True, text=True, timeout=60,
|
|
68
|
+
)
|
|
69
|
+
except (OSError, subprocess.SubprocessError):
|
|
70
|
+
return []
|
|
71
|
+
if proc.returncode != 0:
|
|
72
|
+
return []
|
|
73
|
+
return proc.stdout.splitlines()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def find_orphaned_work(pcp_dir: Path, project_root: Path | None = None) -> list[dict]:
|
|
77
|
+
"""Pending criteria that have a landed commit written for them.
|
|
78
|
+
|
|
79
|
+
Deliberately conservative: requires a commit subject matching PCP's own
|
|
80
|
+
conventions exactly. A criterion whose work never produced such a commit is
|
|
81
|
+
treated as genuinely unfinished and is not reported — the first version used
|
|
82
|
+
branch-merged state instead and was wrong on 6 of 18 findings.
|
|
83
|
+
"""
|
|
84
|
+
project_root = project_root or Path(pcp_dir).parent
|
|
85
|
+
subjects = _landed_commit_subjects(project_root)
|
|
86
|
+
if not subjects:
|
|
87
|
+
return []
|
|
88
|
+
|
|
89
|
+
found = []
|
|
90
|
+
modules_dir = Path(pcp_dir) / "strategy" / "modules"
|
|
91
|
+
if not modules_dir.exists():
|
|
92
|
+
return []
|
|
93
|
+
for acc_path in sorted(modules_dir.glob("*/acceptance.yaml")):
|
|
94
|
+
module = acc_path.parent.name
|
|
95
|
+
try:
|
|
96
|
+
data = load_yaml(acc_path) or {}
|
|
97
|
+
except Exception:
|
|
98
|
+
continue
|
|
99
|
+
for c in data.get("criteria", []) or []:
|
|
100
|
+
if not isinstance(c, dict):
|
|
101
|
+
continue
|
|
102
|
+
status = c.get("status", "pending")
|
|
103
|
+
if status == "complete":
|
|
104
|
+
continue
|
|
105
|
+
cid = c.get("id")
|
|
106
|
+
branch = f"feat/{module}-{cid}"
|
|
107
|
+
merge_subject = f"Merge {branch}"
|
|
108
|
+
work_prefix = f"{module}/{cid}:"
|
|
109
|
+
evidence = next(
|
|
110
|
+
(s for s in subjects
|
|
111
|
+
if s.strip() == merge_subject or s.startswith(work_prefix)),
|
|
112
|
+
None,
|
|
113
|
+
)
|
|
114
|
+
if evidence:
|
|
115
|
+
found.append({
|
|
116
|
+
"module": module,
|
|
117
|
+
"criterion_id": cid,
|
|
118
|
+
"branch": branch,
|
|
119
|
+
"status": status,
|
|
120
|
+
"evidence": evidence[:120],
|
|
121
|
+
"description": (c.get("description") or "")[:120],
|
|
122
|
+
})
|
|
123
|
+
return found
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _unverified_now(modules_dir: Path) -> list[dict]:
|
|
127
|
+
found = []
|
|
128
|
+
for acc_path in sorted(modules_dir.glob("*/acceptance.yaml")):
|
|
129
|
+
module = acc_path.parent.name
|
|
130
|
+
try:
|
|
131
|
+
data = load_yaml(acc_path) or {}
|
|
132
|
+
except Exception:
|
|
133
|
+
continue
|
|
134
|
+
for c in data.get("criteria", []) or []:
|
|
135
|
+
if not isinstance(c, dict):
|
|
136
|
+
continue
|
|
137
|
+
if c.get("status") == "complete" and not c.get("verified_by"):
|
|
138
|
+
found.append({
|
|
139
|
+
"module": module,
|
|
140
|
+
"criterion_id": c.get("id"),
|
|
141
|
+
"description": (c.get("description") or "")[:120],
|
|
142
|
+
})
|
|
143
|
+
return found
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _baseline_path(pcp_dir: Path) -> Path:
|
|
147
|
+
return Path(pcp_dir) / "unverified_complete_baseline.yaml"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def find_unverified_complete(pcp_dir: Path) -> list[dict]:
|
|
151
|
+
"""Criteria marked `complete` with no `verified_by` -- the mirror image of
|
|
152
|
+
`find_orphaned_work` -- EXCLUDING a one-time grandfathered baseline.
|
|
153
|
+
|
|
154
|
+
`verified_by` is stamped ONLY by `_mark_criterion_complete` inside `pcp
|
|
155
|
+
build`'s real gated loop (or now `pcp verify`). Its absence on a `complete`
|
|
156
|
+
criterion means the status was hand-edited into acceptance.yaml directly --
|
|
157
|
+
no gate ever ran, no evidence exists, and PCP is reporting work as done that
|
|
158
|
+
it has no basis for.
|
|
159
|
+
|
|
160
|
+
First found live on Project O 2026-07-30: 12 criteria hand-flipped
|
|
161
|
+
after the orphaned-work fix landed, because no sanctioned "mark done" path
|
|
162
|
+
existed yet -- fixed by `pcp verify` in this same change.
|
|
163
|
+
|
|
164
|
+
**The baseline exists because the naive version was shipped and run against
|
|
165
|
+
the real project before the tests caught the problem it caused.** `verified_by`
|
|
166
|
+
is 6 days old (added 2026-07-24); Project O has 432 criteria going back
|
|
167
|
+
to early July. Un-baselined, this returns **282 of 333 complete criteria** --
|
|
168
|
+
85% of all completed work, flagged as suspect for the sole reason that it
|
|
169
|
+
predates a field that did not exist when it was built. That is not a signal,
|
|
170
|
+
it is the field's own introduction date read back as an accusation. It is
|
|
171
|
+
also exactly the shape already named once this session: a check with no
|
|
172
|
+
sanctioned way to comply produces noise users learn to ignore (CTRL-018's 110
|
|
173
|
+
phantom blocks, 42.5% of that project's recorded blocks).
|
|
174
|
+
|
|
175
|
+
The baseline is a one-time snapshot, written to
|
|
176
|
+
`.pcp/unverified_complete_baseline.yaml` on the first call ever made in a
|
|
177
|
+
project (empty findings that call, honestly -- there is no "new" yet to
|
|
178
|
+
report). Every criterion in it is accepted debt from before this check
|
|
179
|
+
existed, and stays grandfathered PERMANENTLY by (module, criterion_id)
|
|
180
|
+
identity -- not by status. A baselined criterion never reappears just from
|
|
181
|
+
cycling pending -> complete again, deliberately: acceptance.yaml is already
|
|
182
|
+
gated behind `pcp pm`/`pcp verify`/human approval, so resurrecting it on a
|
|
183
|
+
status bounce would add a new failure mode (an innocent status edit making
|
|
184
|
+
old debt look like a new violation) without closing any real hole. The way
|
|
185
|
+
out of the baseline is `pcp verify`, which stamps `verified_by` and removes
|
|
186
|
+
the criterion from the unverified set entirely -- not silence, an actual fix.
|
|
187
|
+
Anything unverified-complete that was never in the baseline is new since
|
|
188
|
+
this check started watching, and is reported every time.
|
|
189
|
+
|
|
190
|
+
This is the more dangerous direction of the two orphaned-work checks. A false
|
|
191
|
+
`pending` costs a wasted rebuild. A false `complete` means the work is never
|
|
192
|
+
checked again -- silently absent from every future gate, audit, and coverage
|
|
193
|
+
figure. Advisory only, never blocking, for the same reason CTRL-018 is warn
|
|
194
|
+
by default: refusing to build over inherited debt would be the wrong failure
|
|
195
|
+
mode."""
|
|
196
|
+
modules_dir = Path(pcp_dir) / "strategy" / "modules"
|
|
197
|
+
if not modules_dir.exists():
|
|
198
|
+
return []
|
|
199
|
+
current = _unverified_now(modules_dir)
|
|
200
|
+
|
|
201
|
+
baseline_path = _baseline_path(pcp_dir)
|
|
202
|
+
if not baseline_path.exists():
|
|
203
|
+
keys = sorted(f"{f['module']}/{f['criterion_id']}" for f in current)
|
|
204
|
+
try:
|
|
205
|
+
baseline_path.parent.mkdir(parents=True, exist_ok=True)
|
|
206
|
+
baseline_path.write_text(dump_yaml({
|
|
207
|
+
"note": ("One-time grandfather snapshot, written automatically by the first "
|
|
208
|
+
"`pcp scan`/`pcp build` after this check shipped. Everything listed here "
|
|
209
|
+
"predates verified_by tracking and is accepted debt, not a new problem -- "
|
|
210
|
+
"see find_unverified_complete's docstring. Never hand-edit; a criterion "
|
|
211
|
+
"drops out of this list on its own once it goes through `pcp verify` or "
|
|
212
|
+
"`pcp build`."),
|
|
213
|
+
"criteria": keys,
|
|
214
|
+
}))
|
|
215
|
+
except OSError:
|
|
216
|
+
pass # best-effort -- an unwritable .pcp/ must not break scan/build
|
|
217
|
+
return []
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
baseline = set((load_yaml(baseline_path) or {}).get("criteria") or [])
|
|
221
|
+
except Exception:
|
|
222
|
+
baseline = set()
|
|
223
|
+
return [f for f in current if f"{f['module']}/{f['criterion_id']}" not in baseline]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def format_unverified_findings(found: list[dict]) -> list[str]:
|
|
227
|
+
"""Human-readable lines for find_unverified_complete. Empty when nothing to say."""
|
|
228
|
+
if not found:
|
|
229
|
+
return []
|
|
230
|
+
lines = [
|
|
231
|
+
f"{len(found)} criterion(s) marked 'complete' with no verified_by — no gate ever "
|
|
232
|
+
f"confirmed this, the status was set by hand:"
|
|
233
|
+
]
|
|
234
|
+
for f in found[:12]:
|
|
235
|
+
lines.append(f" {f['module']}/{f['criterion_id']} <- fix: pcp verify {f['module']} {f['criterion_id']}")
|
|
236
|
+
if len(found) > 12:
|
|
237
|
+
lines.append(f" ... and {len(found) - 12} more")
|
|
238
|
+
lines.append(
|
|
239
|
+
"If genuinely done, record it properly with the command above. Leaving it as-is means "
|
|
240
|
+
"no gate, audit, or coverage figure will ever check this again."
|
|
241
|
+
)
|
|
242
|
+
return lines
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def format_findings(found: list[dict]) -> list[str]:
|
|
246
|
+
"""Human-readable lines. Empty list when there is nothing to say."""
|
|
247
|
+
if not found:
|
|
248
|
+
return []
|
|
249
|
+
lines = [
|
|
250
|
+
f"{len(found)} criterion(s) marked '{found[0]['status']}' or pending whose work is "
|
|
251
|
+
f"already merged into this branch — the status is stale, not the code:"
|
|
252
|
+
]
|
|
253
|
+
for f in found[:12]:
|
|
254
|
+
lines.append(f" {f['module']}/{f['criterion_id']} <- commit: {f['evidence']}")
|
|
255
|
+
if len(found) > 12:
|
|
256
|
+
lines.append(f" ... and {len(found) - 12} more")
|
|
257
|
+
lines.append(
|
|
258
|
+
"Verify, then mark them complete via `pcp pm` — acceptance.yaml is human-approved, "
|
|
259
|
+
"so PCP will not flip status on its own. Leaving it stale makes the next build "
|
|
260
|
+
"redo finished work."
|
|
261
|
+
)
|
|
262
|
+
return lines
|
pcp/pcp_dir.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Locate and navigate the .pcp/ directory for a project."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class NoPCPDir(Exception):
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def find_pcp_dir(start: Path | None = None) -> Path:
|
|
11
|
+
"""Walk up from start (default cwd) to find .pcp/. Raises NoPCPDir if not found."""
|
|
12
|
+
current = (start or Path.cwd()).resolve()
|
|
13
|
+
for parent in [current, *current.parents]:
|
|
14
|
+
candidate = parent / ".pcp"
|
|
15
|
+
if candidate.is_dir():
|
|
16
|
+
return candidate
|
|
17
|
+
raise NoPCPDir(
|
|
18
|
+
"No .pcp/ directory found. Run `pcp init` to initialise this project."
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_modules_dir(pcp_dir: Path) -> Path:
|
|
23
|
+
return pcp_dir / "strategy" / "modules"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_objective(pcp_dir: Path) -> Path:
|
|
27
|
+
return pcp_dir / "objective.md"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_decomposition(pcp_dir: Path) -> Path:
|
|
31
|
+
return pcp_dir / "strategy" / "decomposition.md"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_ontology_state(pcp_dir: Path) -> Path:
|
|
35
|
+
return pcp_dir / "ontology_state.yaml"
|
pcp/pcp_status.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""Shared pcp.md governance snapshot writer.
|
|
2
|
+
|
|
3
|
+
Called by `pcp scan` (auto) and `pcp status` (on-demand).
|
|
4
|
+
pcp.md lives at project root — never inside .pcp/.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import re
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _read_optional(path: Path, max_chars: int = 600) -> str:
|
|
13
|
+
if not path.exists():
|
|
14
|
+
return ""
|
|
15
|
+
text = path.read_text().strip()
|
|
16
|
+
return text[:max_chars] + ("\n…" if len(text) > max_chars else "")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _evaluate_exit_criterion(c: dict, project_root: Path) -> bool:
|
|
20
|
+
check = c.get("check", "manual")
|
|
21
|
+
if check == "file_exists":
|
|
22
|
+
return (project_root / c.get("target", "")).exists()
|
|
23
|
+
if check == "ast_pattern":
|
|
24
|
+
target = project_root / c.get("target", "")
|
|
25
|
+
pattern = c.get("pattern", "")
|
|
26
|
+
if not target.exists() or not pattern:
|
|
27
|
+
return c.get("status") == "complete"
|
|
28
|
+
import re
|
|
29
|
+
return bool(re.search(pattern, target.read_text(errors="replace"), re.MULTILINE))
|
|
30
|
+
return c.get("status") == "complete"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _extract_phase(pcp_dir: Path) -> tuple[str, list[dict]]:
|
|
34
|
+
sdlc_path = pcp_dir / "SDLC_phase.yaml"
|
|
35
|
+
if not sdlc_path.exists():
|
|
36
|
+
return "unknown", []
|
|
37
|
+
data = yaml.safe_load(sdlc_path.read_text()) or {}
|
|
38
|
+
current = data.get("current_phase", "unknown")
|
|
39
|
+
phases = data.get("phases", [])
|
|
40
|
+
current_phase_data = next((p for p in phases if p["name"] == current), {})
|
|
41
|
+
project_root = pcp_dir.parent
|
|
42
|
+
criteria = current_phase_data.get("exit_criteria", [])
|
|
43
|
+
# Evaluate deterministic checks live
|
|
44
|
+
evaluated = []
|
|
45
|
+
for c in criteria:
|
|
46
|
+
done = _evaluate_exit_criterion(c, project_root)
|
|
47
|
+
evaluated.append({**c, "_done": done})
|
|
48
|
+
return current, evaluated
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _extract_bypass_count(pcp_dir: Path) -> int:
|
|
52
|
+
log_path = pcp_dir / "bypass_log.yaml"
|
|
53
|
+
if not log_path.exists():
|
|
54
|
+
return 0
|
|
55
|
+
data = yaml.safe_load(log_path.read_text()) or {}
|
|
56
|
+
return len(data.get("bypasses", []))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _extract_audit_summary(pcp_dir: Path) -> str | None:
|
|
60
|
+
audit_path = pcp_dir / "audit.md"
|
|
61
|
+
if not audit_path.exists():
|
|
62
|
+
return None
|
|
63
|
+
generated, findings = "", None
|
|
64
|
+
for line in audit_path.read_text().splitlines():
|
|
65
|
+
if line.startswith("Generated:"):
|
|
66
|
+
generated = line.split(":", 1)[1].strip()
|
|
67
|
+
elif line.startswith("Findings:"):
|
|
68
|
+
findings = line.split(":", 1)[1].strip()
|
|
69
|
+
if findings is None:
|
|
70
|
+
return f"_Last run {generated} — no audit tool detected._" if generated else None
|
|
71
|
+
return f"{findings} dead-code finding(s) — last run {generated}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _extract_token_summary(pcp_dir: Path) -> str | None:
|
|
75
|
+
ledger_path = pcp_dir / "token_ledger.yaml"
|
|
76
|
+
if not ledger_path.exists():
|
|
77
|
+
return None
|
|
78
|
+
data = yaml.safe_load(ledger_path.read_text()) or {}
|
|
79
|
+
calls = data.get("calls", [])
|
|
80
|
+
if not calls:
|
|
81
|
+
return None
|
|
82
|
+
total_input = sum(c.get("input_tokens", 0) + c.get("cache_creation_tokens", 0) for c in calls)
|
|
83
|
+
total_cache_read = sum(c.get("cache_read_tokens", 0) for c in calls)
|
|
84
|
+
total_output = sum(c.get("output_tokens", 0) for c in calls)
|
|
85
|
+
total_cost = sum(c.get("cost_usd") or 0 for c in calls)
|
|
86
|
+
by_model = {}
|
|
87
|
+
for c in calls:
|
|
88
|
+
m = c.get("model", "default")
|
|
89
|
+
by_model[m] = by_model.get(m, 0) + 1
|
|
90
|
+
model_breakdown = ", ".join(f"{m}:{n}" for m, n in sorted(by_model.items()))
|
|
91
|
+
return (
|
|
92
|
+
f"{len(calls)} LLM call(s) — {total_input:,} input / {total_cache_read:,} cache-read / "
|
|
93
|
+
f"{total_output:,} output tokens — ~${total_cost:.2f} — by model: {model_breakdown}"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _extract_test_coverage(pcp_dir: Path) -> str | None:
|
|
98
|
+
cs = pcp_dir / "current_state.md"
|
|
99
|
+
if not cs.exists():
|
|
100
|
+
return None
|
|
101
|
+
text = cs.read_text()
|
|
102
|
+
m = re.search(r"## Test Coverage\n([\d.]+)% \(([^)]+)\)", text)
|
|
103
|
+
return f"{m.group(1)}% ({m.group(2)})" if m else None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _extract_telemetry_summary(pcp_dir: Path) -> str | None:
|
|
107
|
+
from pcp import telemetry
|
|
108
|
+
records = telemetry.load(pcp_dir)
|
|
109
|
+
if not records:
|
|
110
|
+
return None
|
|
111
|
+
agg = telemetry.aggregate(records)
|
|
112
|
+
by_module = agg["by_module"]
|
|
113
|
+
if not by_module:
|
|
114
|
+
return None
|
|
115
|
+
total_qa = sum(v["qa_total"] for v in by_module.values())
|
|
116
|
+
total_blocks = sum(v["qa_blocks"] for v in by_module.values())
|
|
117
|
+
total_attempts = len(agg["build_records"])
|
|
118
|
+
total_criteria = len({(m, c) for m, v in by_module.items() for c in v["criteria"]})
|
|
119
|
+
avg_attempts = total_attempts / total_criteria if total_criteria else 0.0
|
|
120
|
+
qa_rate = f"{total_blocks}/{total_qa}" if total_qa else "—"
|
|
121
|
+
worst_module = max(
|
|
122
|
+
by_module.items(),
|
|
123
|
+
key=lambda kv: (kv[1]["attempts"] / (len(kv[1]["criteria"]) or 1)),
|
|
124
|
+
default=None,
|
|
125
|
+
)
|
|
126
|
+
worst_note = ""
|
|
127
|
+
if worst_module and len(by_module) > 1:
|
|
128
|
+
wname, wv = worst_module
|
|
129
|
+
wavg = wv["attempts"] / (len(wv["criteria"]) or 1)
|
|
130
|
+
if wavg > avg_attempts:
|
|
131
|
+
worst_note = f" — highest retry rate: `{wname}` ({wavg:.1f} attempts/criterion)"
|
|
132
|
+
return (
|
|
133
|
+
f"{total_criteria} criteria built, {avg_attempts:.1f} avg attempts/criterion, "
|
|
134
|
+
f"QA blocks {qa_rate}{worst_note}. Run `pcp telemetry` for the per-module breakdown."
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _extract_brd_summary(pcp_dir: Path) -> str | None:
|
|
139
|
+
brd_path = pcp_dir / "brd.md"
|
|
140
|
+
if not brd_path.exists():
|
|
141
|
+
return None
|
|
142
|
+
items_path = pcp_dir / "brd_items.yaml"
|
|
143
|
+
items = (yaml.safe_load(items_path.read_text()) or {}).get("items", []) if items_path.exists() else []
|
|
144
|
+
active = [i for i in items if i.get("status") == "active"]
|
|
145
|
+
drift = [i for i in active if i.get("drift_flag")]
|
|
146
|
+
if not active:
|
|
147
|
+
return None
|
|
148
|
+
note = f", {len(drift)} drift flag(s) vs objective.md" if drift else ""
|
|
149
|
+
return f"{len(active)} active requirement(s){note}. See `brd.md`."
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _extract_decision_log_summary(pcp_dir: Path) -> str | None:
|
|
153
|
+
from pcp import decision_log
|
|
154
|
+
records = decision_log.load(pcp_dir)
|
|
155
|
+
if not records:
|
|
156
|
+
return None
|
|
157
|
+
agg = decision_log.aggregate(records)
|
|
158
|
+
by_category = agg["by_category"]
|
|
159
|
+
breakdown = ", ".join(f"{cat}:{len(items)}" for cat, items in sorted(by_category.items()))
|
|
160
|
+
return f"{len(records)} technical decision(s) captured — {breakdown}. Run `pcp telemetry` or see `.pcp/decision_log.jsonl`."
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _extract_pending_gaps(pcp_dir: Path) -> list[str]:
|
|
164
|
+
cs = pcp_dir / "current_state.md"
|
|
165
|
+
if not cs.exists():
|
|
166
|
+
return []
|
|
167
|
+
gaps = []
|
|
168
|
+
for line in cs.read_text().splitlines():
|
|
169
|
+
if re.match(r"\s*- \[ \]", line):
|
|
170
|
+
gaps.append(line.strip()[6:])
|
|
171
|
+
return gaps
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _module_table(modules_results: list[dict]) -> list[str]:
|
|
175
|
+
lines = [
|
|
176
|
+
"| Module | Criteria | Complete | Pending |",
|
|
177
|
+
"|---|---|---|---|",
|
|
178
|
+
]
|
|
179
|
+
for m in modules_results:
|
|
180
|
+
total = len(m["criteria"])
|
|
181
|
+
complete = sum(1 for c in m["criteria"] if c["status"] == "complete")
|
|
182
|
+
pending = total - complete
|
|
183
|
+
status = "✓" if pending == 0 else f"{pending} pending"
|
|
184
|
+
lines.append(f"| `{m['module']}` | {total} | {complete} | {status} |")
|
|
185
|
+
return lines
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _phase_exit_table(exit_criteria: list[dict]) -> list[str]:
|
|
189
|
+
lines = []
|
|
190
|
+
for c in exit_criteria:
|
|
191
|
+
done = c.get("_done", c.get("status") == "complete")
|
|
192
|
+
mark = "x" if done else " "
|
|
193
|
+
lines.append(f"- [{mark}] **{c['id']}**: {c['description']}")
|
|
194
|
+
return lines or ["_No exit criteria defined._"]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def extract_objective_text(pcp_dir: Path) -> str:
|
|
198
|
+
"""First non-heading body text from objective.md. Real bug, found
|
|
199
|
+
2026-07-08: a heading immediately followed by its body text on the very
|
|
200
|
+
next line (no blank line between them -- completely normal markdown,
|
|
201
|
+
e.g. "## Why This Exists\nBecause...") used to make the WHOLE block get
|
|
202
|
+
rejected outright (the paragraph, heading+body glued together, "starts
|
|
203
|
+
with #"), silently discarding real objective text and falling through
|
|
204
|
+
to "No objective.md found" even though the file existed and had real
|
|
205
|
+
content. Strips leading heading line(s) off each paragraph block first,
|
|
206
|
+
and only rejects a block if nothing but headings remain. Factored out
|
|
207
|
+
of write_pcp_md so dashboard.py can reuse the same extraction instead
|
|
208
|
+
of duplicating this parsing."""
|
|
209
|
+
obj_path = pcp_dir / "objective.md"
|
|
210
|
+
if not obj_path.exists():
|
|
211
|
+
return ""
|
|
212
|
+
raw = obj_path.read_text()
|
|
213
|
+
for block in re.split(r"\n{2,}", raw):
|
|
214
|
+
lines = block.strip().split("\n")
|
|
215
|
+
while lines and lines[0].strip().startswith("#"):
|
|
216
|
+
lines.pop(0)
|
|
217
|
+
body = "\n".join(lines).strip()
|
|
218
|
+
if body:
|
|
219
|
+
return body[:500]
|
|
220
|
+
return ""
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def write_pcp_md(
|
|
224
|
+
pcp_dir: Path,
|
|
225
|
+
modules_results: list[dict],
|
|
226
|
+
timestamp: str,
|
|
227
|
+
total: int,
|
|
228
|
+
complete: int,
|
|
229
|
+
) -> Path:
|
|
230
|
+
"""Write pcp.md to project root. Returns path."""
|
|
231
|
+
project_root = pcp_dir.parent
|
|
232
|
+
project_name = project_root.name
|
|
233
|
+
score = complete / total if total else 0.0
|
|
234
|
+
score_pct = f"{score:.0%}"
|
|
235
|
+
|
|
236
|
+
phase_name, exit_criteria = _extract_phase(pcp_dir)
|
|
237
|
+
bypass_count = _extract_bypass_count(pcp_dir)
|
|
238
|
+
pending_gaps = _extract_pending_gaps(pcp_dir)
|
|
239
|
+
audit_summary = _extract_audit_summary(pcp_dir)
|
|
240
|
+
token_summary = _extract_token_summary(pcp_dir)
|
|
241
|
+
telemetry_summary = _extract_telemetry_summary(pcp_dir)
|
|
242
|
+
test_coverage = _extract_test_coverage(pcp_dir)
|
|
243
|
+
brd_summary = _extract_brd_summary(pcp_dir)
|
|
244
|
+
decision_log_summary = _extract_decision_log_summary(pcp_dir)
|
|
245
|
+
obj_text = extract_objective_text(pcp_dir)
|
|
246
|
+
|
|
247
|
+
lines = [
|
|
248
|
+
f"# PCP Governance — {project_name}",
|
|
249
|
+
f"_Updated: {timestamp} | Phase: `{phase_name}` | Coverage: {score_pct} | Bypasses: {bypass_count}_",
|
|
250
|
+
"",
|
|
251
|
+
"> Auto-generated by `pcp scan`. Do not edit manually.",
|
|
252
|
+
"",
|
|
253
|
+
"## Objective",
|
|
254
|
+
"",
|
|
255
|
+
obj_text or "_No objective.md found. Run `pcp init`._",
|
|
256
|
+
"",
|
|
257
|
+
f"## SDLC Phase: `{phase_name}`",
|
|
258
|
+
"",
|
|
259
|
+
*_phase_exit_table(exit_criteria),
|
|
260
|
+
"",
|
|
261
|
+
"## Module Status",
|
|
262
|
+
"",
|
|
263
|
+
*_module_table(modules_results),
|
|
264
|
+
"",
|
|
265
|
+
]
|
|
266
|
+
|
|
267
|
+
if pending_gaps:
|
|
268
|
+
lines += [
|
|
269
|
+
"## Pending Gaps",
|
|
270
|
+
"",
|
|
271
|
+
*[f"- [ ] {g}" for g in pending_gaps],
|
|
272
|
+
"",
|
|
273
|
+
]
|
|
274
|
+
else:
|
|
275
|
+
lines += [
|
|
276
|
+
"## Pending Gaps",
|
|
277
|
+
"",
|
|
278
|
+
"_All acceptance criteria met._",
|
|
279
|
+
"",
|
|
280
|
+
]
|
|
281
|
+
|
|
282
|
+
if test_coverage:
|
|
283
|
+
lines += ["## Test Coverage", "", test_coverage, ""]
|
|
284
|
+
|
|
285
|
+
if audit_summary:
|
|
286
|
+
lines += ["## Dead Code / Bloat", "", audit_summary, ""]
|
|
287
|
+
|
|
288
|
+
if token_summary:
|
|
289
|
+
lines += ["## Token Spend", "", token_summary, ""]
|
|
290
|
+
|
|
291
|
+
if telemetry_summary:
|
|
292
|
+
lines += ["## Build Efficiency", "", telemetry_summary, ""]
|
|
293
|
+
|
|
294
|
+
if brd_summary:
|
|
295
|
+
lines += ["## Business Requirements (Living)", "", brd_summary, ""]
|
|
296
|
+
|
|
297
|
+
if decision_log_summary:
|
|
298
|
+
lines += ["## Technical Decisions", "", decision_log_summary, ""]
|
|
299
|
+
|
|
300
|
+
if bypass_count > 0:
|
|
301
|
+
log_path = pcp_dir / "bypass_log.yaml"
|
|
302
|
+
data = yaml.safe_load(log_path.read_text()) or {}
|
|
303
|
+
bypasses = data.get("bypasses", [])
|
|
304
|
+
lines += ["## Bypass Log", ""]
|
|
305
|
+
for b in bypasses[-5:]: # last 5 only
|
|
306
|
+
lines.append(f"- `{b.get('timestamp', '')}` — {b.get('reason', '')} (rules: {', '.join(b.get('rules_bypassed', []))})")
|
|
307
|
+
lines.append("")
|
|
308
|
+
else:
|
|
309
|
+
lines += ["## Bypass Log", "", "_No bypasses._", ""]
|
|
310
|
+
|
|
311
|
+
out = project_root / "pcp.md"
|
|
312
|
+
out.write_text("\n".join(lines))
|
|
313
|
+
return out
|