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
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""pcp narrative-lint — advisory scan of CLAUDE.md-family narrative prose
|
|
2
|
+
against PCP's own tracked state. Writes .pcp/narrative_lint.md. Never blocks
|
|
3
|
+
(see narrative_lint.py's module docstring for the fleet evidence behind this).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
from pcp import narrative_lint
|
|
14
|
+
from pcp import telemetry
|
|
15
|
+
from pcp.pcp_dir import find_pcp_dir, NoPCPDir
|
|
16
|
+
|
|
17
|
+
console = Console()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@click.command(name="narrative-lint")
|
|
21
|
+
@click.option("--path", "project_path", type=click.Path(), default=None,
|
|
22
|
+
help="Project root (default: cwd, walks up to find .pcp/).")
|
|
23
|
+
@click.option("--stale-days", type=int, default=narrative_lint.STALE_DAYS_DEFAULT,
|
|
24
|
+
help="Age threshold (days) for flagging a dated reference as stale.")
|
|
25
|
+
@click.option("--skip-llm", is_flag=True, help="Skip the semantic contradiction check (deterministic checks only).")
|
|
26
|
+
@click.option("--quiet", is_flag=True, help="Suppress output.")
|
|
27
|
+
def narrative_lint_cmd(project_path: str | None, stale_days: int, skip_llm: bool, quiet: bool):
|
|
28
|
+
"""Advisory lint: CLAUDE.md-family narrative prose vs. tracked state (CTRL-036). Never blocks."""
|
|
29
|
+
try:
|
|
30
|
+
pcp_dir = find_pcp_dir(Path(project_path) if project_path else None)
|
|
31
|
+
except NoPCPDir as e:
|
|
32
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
33
|
+
sys.exit(2)
|
|
34
|
+
|
|
35
|
+
result = narrative_lint.run(pcp_dir, stale_days=stale_days, skip_llm=skip_llm)
|
|
36
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
37
|
+
out_path = pcp_dir / "narrative_lint.md"
|
|
38
|
+
out_path.write_text(narrative_lint.render_markdown(result, timestamp))
|
|
39
|
+
|
|
40
|
+
total = len(result["stale_dates"]) + len(result["missing_files"]) + len(result["contradictions"])
|
|
41
|
+
telemetry.record(
|
|
42
|
+
pcp_dir, cycle="qa", check="narrative-lint", control_id="CTRL-036",
|
|
43
|
+
module=None, submodule=None, criterion_id=None,
|
|
44
|
+
files=result["files_scanned"], result="pass",
|
|
45
|
+
errors=result["stale_dates"] + result["missing_files"] + result["contradictions"],
|
|
46
|
+
error_count=total,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
if quiet:
|
|
50
|
+
sys.exit(0)
|
|
51
|
+
|
|
52
|
+
color = "green" if total == 0 else "yellow"
|
|
53
|
+
console.print(f"[{color}]{total} narrative-lint finding(s)[/{color}] → {out_path.relative_to(pcp_dir.parent)}")
|
|
54
|
+
sys.exit(0)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""pcp objective-conflicts — view/dismiss flagged objective-vs-business-decision
|
|
2
|
+
conflicts (CTRL-035). Unresolved ones hard-block `pcp build`."""
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from pcp.pcp_dir import find_pcp_dir, NoPCPDir
|
|
12
|
+
from pcp import objective_conflicts
|
|
13
|
+
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@click.command("objective-conflicts")
|
|
18
|
+
@click.option("--path", "project_path", type=click.Path(), default=None)
|
|
19
|
+
@click.option("--dismiss", "dismiss_id", default=None, metavar="ITEM_ID",
|
|
20
|
+
help="Dismiss a flagged conflict without editing objective.md (false positive). Requires --reason.")
|
|
21
|
+
@click.option("--reason", "reason", default=None, help="Required with --dismiss.")
|
|
22
|
+
@click.option("--json", "as_json", is_flag=True, help="Machine-readable output.")
|
|
23
|
+
def objective_conflicts_cmd(project_path: str | None, dismiss_id: str | None, reason: str | None, as_json: bool):
|
|
24
|
+
"""List objective-conflict flags; --dismiss clears a false positive."""
|
|
25
|
+
try:
|
|
26
|
+
pcp_dir = find_pcp_dir(Path(project_path) if project_path else None)
|
|
27
|
+
except NoPCPDir as e:
|
|
28
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
29
|
+
sys.exit(2)
|
|
30
|
+
|
|
31
|
+
if dismiss_id:
|
|
32
|
+
if not reason:
|
|
33
|
+
console.print("[red]Error:[/red] --dismiss requires --reason")
|
|
34
|
+
sys.exit(2)
|
|
35
|
+
found = objective_conflicts.dismiss(pcp_dir, dismiss_id, reason)
|
|
36
|
+
if found:
|
|
37
|
+
console.print(f"[green]✓[/green] dismissed {dismiss_id}: {reason}")
|
|
38
|
+
else:
|
|
39
|
+
console.print(f"[yellow]No unresolved conflict found with id {dismiss_id}[/yellow]")
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
unresolved = objective_conflicts.reconcile(pcp_dir)
|
|
43
|
+
|
|
44
|
+
if as_json:
|
|
45
|
+
import json as _json
|
|
46
|
+
console.print(_json.dumps({"unresolved": unresolved}, indent=2))
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
if not unresolved:
|
|
50
|
+
console.print("[green]No unresolved objective conflicts.[/green] `pcp build` is not blocked by this gate.")
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
table = Table(title="Unresolved Objective Conflicts (blocking pcp build)")
|
|
54
|
+
for col in ("ID", "Description", "Conflict", "Source"):
|
|
55
|
+
table.add_column(col)
|
|
56
|
+
for c in unresolved:
|
|
57
|
+
table.add_row(
|
|
58
|
+
c.get("id", ""), c.get("description", ""), c.get("drift_flag", ""), c.get("source", ""),
|
|
59
|
+
)
|
|
60
|
+
console.print(table)
|
|
61
|
+
# Not "edit by hand": these files are human-AUTHORIZED, not human-typed,
|
|
62
|
+
# and `correct-objective --from-conflict` exists to pull the correction
|
|
63
|
+
# text straight out of the flagged item and diff it for approval.
|
|
64
|
+
first_id = unresolved[0].get("id", "<ID>")
|
|
65
|
+
console.print(
|
|
66
|
+
f"\n[dim]Resolve: pcp correct-objective --from-conflict {first_id}[/dim]\n"
|
|
67
|
+
f"[dim]False positive: pcp objective-conflicts --dismiss {first_id} --reason \"...\"[/dim]"
|
|
68
|
+
)
|
pcp/commands/pm.py
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
"""pcp pm — translate natural language intent to spec modifications."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import click
|
|
8
|
+
import yaml
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from pcp.pcp_dir import find_pcp_dir, NoPCPDir, get_modules_dir
|
|
12
|
+
from pcp.llm import client as llm
|
|
13
|
+
from pcp.pcp_status import write_pcp_md
|
|
14
|
+
from pcp.commands.kickoff import (
|
|
15
|
+
_normalize_acceptance, _normalize_spec, check_capability_coverage,
|
|
16
|
+
check_module_logic_breakdown_coverage, check_prior_art_evidence,
|
|
17
|
+
)
|
|
18
|
+
from pcp.commands.validate_strategy import (
|
|
19
|
+
_build_user_prompt as build_val_prompt,
|
|
20
|
+
SYSTEM_PROMPT as VAL_SYSTEM_PROMPT,
|
|
21
|
+
_render_results as render_val_results,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
console = Console()
|
|
25
|
+
|
|
26
|
+
def _max_context_chars() -> int:
|
|
27
|
+
"""Reject-loud, not truncate-silent -- same posture as kickoff.py's
|
|
28
|
+
vision-doc guard. Bounds _load_project_context's assembled prompt (every
|
|
29
|
+
existing module's spec.yaml + acceptance.yaml). A function, not a
|
|
30
|
+
module-level constant, so PCP_PM_MAX_CONTEXT_CHARS is read live at call
|
|
31
|
+
time rather than frozen at import time.
|
|
32
|
+
|
|
33
|
+
Default raised 60,000 -> 400,000 (2026-07-29). 60k chars is ~15k tokens --
|
|
34
|
+
that guard was not measuring "this project no longer fits in a context
|
|
35
|
+
window", it was firing on ordinary project size. Measured across the 8
|
|
36
|
+
local PCP-managed projects, 4 of 8 exceeded it (Project O 392k,
|
|
37
|
+
Project W 94k, Project M 68k, Project G 43k), so `pcp pm` was dead on half
|
|
38
|
+
the fleet with an error suggesting the fix was to "split into smaller
|
|
39
|
+
modules" -- i.e. restructure a healthy 27-module project to satisfy an
|
|
40
|
+
arbitrary constant. 400k chars is ~100k tokens, half a 200k window, which
|
|
41
|
+
is what the guard should actually be protecting."""
|
|
42
|
+
return int(os.environ.get("PCP_PM_MAX_CONTEXT_CHARS", "400000"))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# Fields on an EXISTING criterion that pm demonstrably never reads.
|
|
46
|
+
# pm's job is: route an intent to module(s), then emit new criteria. It needs
|
|
47
|
+
# the existing IDs (collision avoidance), descriptions (don't re-add what
|
|
48
|
+
# exists), and the scheduling fields it must itself populate. It does not need
|
|
49
|
+
# other criteria's build_vs_buy rationales, design_justification memos, QA
|
|
50
|
+
# evidence, or verifier notes -- and on Project O those four fields
|
|
51
|
+
# alone were 111k of the 341k spec+acceptance payload (build_vs_buy 74k,
|
|
52
|
+
# design_justification 28k, test 8.7k, notes/verified_by/pattern 3.5k).
|
|
53
|
+
#
|
|
54
|
+
# This is a projection, not a truncation: whole fields are dropped by name, so
|
|
55
|
+
# nothing is cut mid-sentence and no module's constraints or dependencies are
|
|
56
|
+
# lost. Module-level spec.yaml stays verbatim for every module, which is where
|
|
57
|
+
# the cross-module constraints the old error message worried about actually
|
|
58
|
+
# live.
|
|
59
|
+
_PM_CRITERION_KEEP_FIELDS = (
|
|
60
|
+
"id", "description", "check", "status",
|
|
61
|
+
"logic_tier", "depends_on", "target", "pattern",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _slim_acceptance(acc_text: str) -> str:
|
|
66
|
+
"""Project an acceptance.yaml down to the fields pm uses.
|
|
67
|
+
|
|
68
|
+
Fails OPEN: any parse problem returns the original text unchanged rather
|
|
69
|
+
than risk handing the LLM a mangled spec."""
|
|
70
|
+
try:
|
|
71
|
+
data = yaml.safe_load(acc_text) or {}
|
|
72
|
+
criteria = data.get("criteria")
|
|
73
|
+
if not isinstance(criteria, list):
|
|
74
|
+
return acc_text
|
|
75
|
+
slim = {
|
|
76
|
+
"version": data.get("version"),
|
|
77
|
+
"module": data.get("module"),
|
|
78
|
+
"criteria": [
|
|
79
|
+
{k: c[k] for k in _PM_CRITERION_KEEP_FIELDS if k in c}
|
|
80
|
+
for c in criteria if isinstance(c, dict)
|
|
81
|
+
],
|
|
82
|
+
}
|
|
83
|
+
return yaml.dump(slim, default_flow_style=False, sort_keys=False)
|
|
84
|
+
except Exception:
|
|
85
|
+
return acc_text
|
|
86
|
+
|
|
87
|
+
SYSTEM_PROMPT = """\
|
|
88
|
+
You are an expert product manager.
|
|
89
|
+
Your task is to take a feature intent expressed in natural language and translate it into modifications for the project's PCP module specifications and acceptance criteria.
|
|
90
|
+
|
|
91
|
+
You are given the current program objective, strategy decomposition, and list of existing modules with their specs.
|
|
92
|
+
|
|
93
|
+
DECOMPOSE FIRST, THEN MAP (GUIDE pattern, arXiv:2502.21068 -- the one academically validated fix for LLMs silently dropping requirements during one-shot generation): before deciding which module(s) this intent touches, populate `capabilities_enumerated` with EVERY distinct capability/requirement this intent implies, however small. Only after that list is complete, decide which module(s) each capability belongs to.
|
|
94
|
+
|
|
95
|
+
DECOMPOSE FIRST applies one layer deeper too: if this intent adds real internal complexity to a module (not just one more criterion of the same shape), update that module's `spec_changes.module_logic_breakdown` with the new internal components/sub-flows/edge-cases before writing its new criteria -- derive the criteria from the updated breakdown. Skip this field entirely for a small, same-shape addition that doesn't change the module's actual internal decomposition.
|
|
96
|
+
|
|
97
|
+
A real feature intent routinely spans MORE THAN ONE existing or new module (e.g. "add payments" may touch billing, notifications, and auth) -- do not force everything into a single module just because the schema used to only allow one. `modules` is a LIST: include one entry per module this intent actually touches, whether that's one module or several. Analyze which module (or modules) are responsible, and for each, generate the updated or new spec and acceptance criteria for that module only.
|
|
98
|
+
|
|
99
|
+
Ensure that new acceptance criteria IDs do not conflict with existing ones within their own module (e.g. if a module already has A001, its new ones start at A002).
|
|
100
|
+
|
|
101
|
+
You must output ONLY valid JSON — no prose, no markdown, no code fences.
|
|
102
|
+
|
|
103
|
+
Output schema:
|
|
104
|
+
{
|
|
105
|
+
"capabilities_enumerated": ["Every distinct capability/requirement this intent implies, one per discrete thing -- populate BEFORE deciding modules."],
|
|
106
|
+
"overall_explanation": "A plain-English summary of what will be built and why, across all modules this intent touches.",
|
|
107
|
+
"modules": [
|
|
108
|
+
{
|
|
109
|
+
"module_action": "modify | create",
|
|
110
|
+
"module_name": "module-name",
|
|
111
|
+
"module_explanation": "What this specific module handles for this intent.",
|
|
112
|
+
"spec_changes": {
|
|
113
|
+
"version": "2.0",
|
|
114
|
+
"module": "module-name",
|
|
115
|
+
"description": "Description of the module including the new features (minimum 10 words).",
|
|
116
|
+
"objective_coverage": ["Explain how this module covers objective.md objectives"],
|
|
117
|
+
"module_logic_breakdown": ["Only if this intent adds real internal complexity -- this module's updated internal components/sub-flows/edge-cases. Omit the key entirely for a small, same-shape addition."],
|
|
118
|
+
"dependencies": ["dependency-module-name"],
|
|
119
|
+
"constraints": [],
|
|
120
|
+
"build_vs_buy": {
|
|
121
|
+
"decision": "not_applicable",
|
|
122
|
+
"rationale": "Pure business-logic module -- no whole-module tool-adoption choice; see per-criterion build_vs_buy instead.",
|
|
123
|
+
"candidates_considered": []
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
"acceptance_changes": {
|
|
127
|
+
"version": "2.0",
|
|
128
|
+
"module": "module-name",
|
|
129
|
+
"criteria": [
|
|
130
|
+
{
|
|
131
|
+
"id": "A002",
|
|
132
|
+
"description": "Clear description of the new exit criterion",
|
|
133
|
+
"check": "manual | ast_pattern | test_passes | file_exists",
|
|
134
|
+
"status": "pending",
|
|
135
|
+
"logic_tier": 6,
|
|
136
|
+
"build_vs_buy": {
|
|
137
|
+
"decision": "build_fresh",
|
|
138
|
+
"rationale": "Why this decision, one sentence.",
|
|
139
|
+
"candidates_considered": []
|
|
140
|
+
},
|
|
141
|
+
"depends_on": [],
|
|
142
|
+
"target": "src/path/to/the/file/this/criterion/writes.py"
|
|
143
|
+
}
|
|
144
|
+
]
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
]
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
Every NEW criterion MUST declare logic_tier (1-6, the cheapest rung that correctly makes this decision: 1=deterministic, 2=optimization/solver, 3=statistical/ML, 4=RAG, 5=cached reuse, 6=deep-think LLM -- default to the cheapest rung that genuinely fits, do not default everything to 6) and build_vs_buy: {decision, rationale, candidates_considered} where decision is exactly one of: reuse_whole, reuse_partial (vendor one file/function, not the whole repo), reimplement_from_reference (study a solved approach and write original code, no code copied), fork_adapt, build_fresh. If a module this intent touches is infrastructure-shaped (portal, auth, integrations, orchestration engine), its spec_changes ALSO needs a real module-level build_vs_buy decision instead of 'not_applicable'.
|
|
151
|
+
|
|
152
|
+
Every NEW criterion MUST also declare depends_on: a list of OTHER criterion ids (within the same module) that must be built first. Default to an EMPTY list -- most criteria are genuinely independent and should build in parallel. Only list a real id when this criterion's implementation would break or be meaningless without that other one existing first. When genuinely unsure, prefer the empty list -- a false dependency costs real parallelism for nothing.
|
|
153
|
+
|
|
154
|
+
Every criterion SHOULD also declare `target`: the single primary file path it will create or modify (e.g. "src/storage/upload.py"). This is not documentation -- build.py schedules criterion-level parallelism from it. Two criteria run concurrently, each in its own isolated worktree blind to the other, ONLY when both declare a target and the targets differ; a criterion with no declared target has an unknown file surface and is run alone. Declaring accurate, DISTINCT targets is therefore what buys parallel builds. Two criteria that will genuinely both touch the same file must either declare that same target (so they are serialised) or be split differently -- never leave it blank hoping it works out.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _load_project_context(pcp_dir: Path) -> tuple[str, int]:
|
|
159
|
+
"""Returns (context_string, chars_dropped_by_projection).
|
|
160
|
+
|
|
161
|
+
The second value exists so the projection is *visible* -- the whole reason
|
|
162
|
+
the old code pasted everything verbatim was a refusal to cut silently, and
|
|
163
|
+
a projection nobody is told about would repeat that mistake in reverse."""
|
|
164
|
+
objective = (pcp_dir / "objective.md").read_text() if (pcp_dir / "objective.md").exists() else ""
|
|
165
|
+
decomposition = (pcp_dir / "strategy" / "decomposition.md").read_text() if (pcp_dir / "strategy" / "decomposition.md").exists() else ""
|
|
166
|
+
|
|
167
|
+
parts = [
|
|
168
|
+
f"## Program Objective\n{objective}\n",
|
|
169
|
+
f"## Strategy Decomposition\n{decomposition}\n",
|
|
170
|
+
"## Existing Modules Specs\n"
|
|
171
|
+
"Each module's spec.yaml is verbatim. Each acceptance.yaml lists every "
|
|
172
|
+
"existing criterion, projected to the fields relevant here "
|
|
173
|
+
f"({', '.join(_PM_CRITERION_KEEP_FIELDS)}) -- other criteria's "
|
|
174
|
+
"build_vs_buy/design_justification/QA-evidence fields are omitted as "
|
|
175
|
+
"irrelevant to routing this intent, not because they are absent.\n"
|
|
176
|
+
]
|
|
177
|
+
|
|
178
|
+
dropped = 0
|
|
179
|
+
modules_dir = pcp_dir / "strategy" / "modules"
|
|
180
|
+
if modules_dir.exists():
|
|
181
|
+
for spec_path in sorted(modules_dir.glob("*/spec.yaml")):
|
|
182
|
+
mod_name = spec_path.parent.name
|
|
183
|
+
spec_content = spec_path.read_text()
|
|
184
|
+
acc_content = ""
|
|
185
|
+
acc_path = spec_path.parent / "acceptance.yaml"
|
|
186
|
+
if acc_path.exists():
|
|
187
|
+
raw = acc_path.read_text()
|
|
188
|
+
acc_content = _slim_acceptance(raw)
|
|
189
|
+
dropped += max(0, len(raw) - len(acc_content))
|
|
190
|
+
parts.append(
|
|
191
|
+
f"### Module: {mod_name}\n"
|
|
192
|
+
f"#### spec.yaml:\n```yaml\n{spec_content}```\n"
|
|
193
|
+
f"#### acceptance.yaml:\n```yaml\n{acc_content}```\n"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
return "\n".join(parts), dropped
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _write_one_module(pcp_dir: Path, mod_result: dict) -> list[str]:
|
|
200
|
+
"""Applies one module's spec_changes/acceptance_changes to disk -- same
|
|
201
|
+
coercion/merge logic the old single-module pm always had, now callable
|
|
202
|
+
per-entry in the modules list. Returns coercion warnings."""
|
|
203
|
+
mod_name = mod_result.get("module_name", "").strip().lower()
|
|
204
|
+
mod_dir = pcp_dir / "strategy" / "modules" / mod_name
|
|
205
|
+
mod_dir.mkdir(parents=True, exist_ok=True)
|
|
206
|
+
|
|
207
|
+
spec_path = mod_dir / "spec.yaml"
|
|
208
|
+
acc_path = mod_dir / "acceptance.yaml"
|
|
209
|
+
|
|
210
|
+
# Force version 2.0 regardless of what the LLM returned -- same reasoning
|
|
211
|
+
# as kickoff.py: a spec pm touches must always get logic_tier/build_vs_buy
|
|
212
|
+
# enforcement, never silently stay on (or revert to) the ungated 1.0 shape.
|
|
213
|
+
spec_changes = mod_result["spec_changes"]
|
|
214
|
+
spec_changes["version"] = "2.0"
|
|
215
|
+
|
|
216
|
+
# On modify, a real prior module-level build_vs_buy decision must not be
|
|
217
|
+
# silently discarded just because this pm call's response omitted it --
|
|
218
|
+
# only coerce to a flagged placeholder if one never existed.
|
|
219
|
+
existing_spec = {}
|
|
220
|
+
if spec_path.exists():
|
|
221
|
+
try:
|
|
222
|
+
existing_spec = yaml.safe_load(spec_path.read_text()) or {}
|
|
223
|
+
except Exception:
|
|
224
|
+
pass
|
|
225
|
+
if "build_vs_buy" not in spec_changes and existing_spec.get("build_vs_buy"):
|
|
226
|
+
spec_changes["build_vs_buy"] = existing_spec["build_vs_buy"]
|
|
227
|
+
|
|
228
|
+
# Same preservation rule for module_logic_breakdown -- the prompt tells
|
|
229
|
+
# the LLM to OMIT this key for a small, same-shape addition (deliberately,
|
|
230
|
+
# to avoid forcing a re-declaration on every trivial pm call), so an
|
|
231
|
+
# omitted key must mean "unchanged", not "delete the prior breakdown".
|
|
232
|
+
if "module_logic_breakdown" not in spec_changes and existing_spec.get("module_logic_breakdown"):
|
|
233
|
+
spec_changes["module_logic_breakdown"] = existing_spec["module_logic_breakdown"]
|
|
234
|
+
|
|
235
|
+
coercion_warnings = _normalize_spec(spec_changes, mod_name)
|
|
236
|
+
spec_path.write_text(yaml.dump(spec_changes, default_flow_style=False))
|
|
237
|
+
|
|
238
|
+
# Save/Merge acceptance.yaml
|
|
239
|
+
existing_criteria = []
|
|
240
|
+
if acc_path.exists():
|
|
241
|
+
try:
|
|
242
|
+
acc_data = yaml.safe_load(acc_path.read_text()) or {}
|
|
243
|
+
existing_criteria = acc_data.get("criteria", [])
|
|
244
|
+
except Exception:
|
|
245
|
+
pass
|
|
246
|
+
|
|
247
|
+
criteria_map = {c["id"]: c for c in existing_criteria}
|
|
248
|
+
for new_c in mod_result["acceptance_changes"].get("criteria", []):
|
|
249
|
+
# Field-level merge onto the existing entry, not a full replacement --
|
|
250
|
+
# same reasoning as spec_changes's build_vs_buy/module_logic_breakdown
|
|
251
|
+
# preservation above, applied per-criterion. `verified_by` is
|
|
252
|
+
# deliberately excluded from _PM_CRITERION_KEEP_FIELDS (the LLM never
|
|
253
|
+
# sees it, and shouldn't have to), so the LLM's response can NEVER
|
|
254
|
+
# legitimately carry it -- a bare `criteria_map[id] = new_c` replace
|
|
255
|
+
# would silently strip it off any already-`pcp verify`'d criterion pm
|
|
256
|
+
# happens to touch for ANY reason, including an unrelated wording
|
|
257
|
+
# tweak elsewhere in the same module, corrupting the exact
|
|
258
|
+
# complete-but-unverified ambiguity `pcp verify` exists to prevent.
|
|
259
|
+
# Same logic protects evidence/notes/design_justification and any
|
|
260
|
+
# other field pm doesn't manage: preserved unless the response
|
|
261
|
+
# explicitly overwrites it.
|
|
262
|
+
existing_c = criteria_map.get(new_c["id"], {})
|
|
263
|
+
criteria_map[new_c["id"]] = {**existing_c, **new_c}
|
|
264
|
+
|
|
265
|
+
merged_acceptance = {
|
|
266
|
+
"version": "2.0",
|
|
267
|
+
"module": mod_name,
|
|
268
|
+
"criteria": sorted(list(criteria_map.values()), key=lambda x: x["id"])
|
|
269
|
+
}
|
|
270
|
+
# Coerces the WHOLE merged list, not just the new criteria -- retroactively
|
|
271
|
+
# upgrades any pre-existing criterion (e.g. from an old 1.0-era module)
|
|
272
|
+
# that's missing logic_tier/build_vs_buy the first time pm touches it.
|
|
273
|
+
coercion_warnings += _normalize_acceptance(merged_acceptance, mod_name)
|
|
274
|
+
acc_path.write_text(yaml.dump(merged_acceptance, default_flow_style=False))
|
|
275
|
+
|
|
276
|
+
return coercion_warnings
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@click.command()
|
|
280
|
+
@click.argument("intent")
|
|
281
|
+
@click.option("--path", "project_path", type=click.Path(), default=None,
|
|
282
|
+
help="Project root override.")
|
|
283
|
+
def pm(intent: str, project_path: str | None):
|
|
284
|
+
"""Translate natural language intent to spec/acceptance criteria modifications."""
|
|
285
|
+
try:
|
|
286
|
+
pcp_dir = find_pcp_dir(Path(project_path) if project_path else None)
|
|
287
|
+
except NoPCPDir as e:
|
|
288
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
289
|
+
sys.exit(2)
|
|
290
|
+
|
|
291
|
+
context_str, dropped = _load_project_context(pcp_dir)
|
|
292
|
+
user_prompt = f"## Intent\n{intent}\n\n{context_str}"
|
|
293
|
+
|
|
294
|
+
if dropped:
|
|
295
|
+
console.print(
|
|
296
|
+
f"[dim]Context projection: {dropped:,} chars of existing criteria's "
|
|
297
|
+
f"build_vs_buy/design_justification/QA-evidence fields omitted "
|
|
298
|
+
f"(whole fields by name, nothing truncated mid-value). "
|
|
299
|
+
f"Assembled prompt: {len(user_prompt):,} chars.[/dim]"
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
max_context_chars = _max_context_chars()
|
|
303
|
+
if len(user_prompt) > max_context_chars:
|
|
304
|
+
console.print(
|
|
305
|
+
f"[red]Error:[/red] assembled project context is {len(user_prompt):,} chars, "
|
|
306
|
+
f"over the {max_context_chars:,}-char pm limit (~{max_context_chars // 4:,} tokens)."
|
|
307
|
+
)
|
|
308
|
+
console.print(
|
|
309
|
+
"[dim]Already projected down to the fields pm uses and still over the limit -- this "
|
|
310
|
+
"project's spec surface no longer fits in a single context window with room to answer. "
|
|
311
|
+
"Not truncated automatically: a silent cut could drop an unrelated module's constraints "
|
|
312
|
+
"the new intent actually depends on. Either raise PCP_PM_MAX_CONTEXT_CHARS (the model's "
|
|
313
|
+
"real ceiling, not this default, is the binding constraint), or run `pcp pm` against a "
|
|
314
|
+
"narrower project -- a program this size is a candidate for splitting into separate "
|
|
315
|
+
"PCP-managed programs, not just smaller modules.[/dim]"
|
|
316
|
+
)
|
|
317
|
+
sys.exit(2)
|
|
318
|
+
|
|
319
|
+
console.print("[dim]Analyzing intent against project context...[/dim]")
|
|
320
|
+
|
|
321
|
+
try:
|
|
322
|
+
# Sonnet is the reviewed default for generation calls (see
|
|
323
|
+
# llm/client.py's model-selection strategy) -- replaces the prior
|
|
324
|
+
# ambiguous "inherited/default". PCP_MODEL still overrides.
|
|
325
|
+
result = llm.call_json(SYSTEM_PROMPT, user_prompt, model=llm.BUILD_MODEL, pcp_dir=pcp_dir, command="pm")
|
|
326
|
+
except RuntimeError as e:
|
|
327
|
+
console.print(f"[red]Error calling LLM:[/red] {e}")
|
|
328
|
+
sys.exit(2)
|
|
329
|
+
except ValueError as e:
|
|
330
|
+
console.print(f"[red]LLM returned invalid JSON:[/red] {e}")
|
|
331
|
+
sys.exit(2)
|
|
332
|
+
|
|
333
|
+
modules_result = result.get("modules") or []
|
|
334
|
+
if not modules_result:
|
|
335
|
+
console.print("[red]Error: LLM did not identify any module for this intent.[/red]")
|
|
336
|
+
sys.exit(2)
|
|
337
|
+
|
|
338
|
+
console.print(f"\n[bold]Intent spans {len(modules_result)} module(s).[/bold]")
|
|
339
|
+
console.print(f"[dim]{result.get('overall_explanation', '')}[/dim]\n")
|
|
340
|
+
|
|
341
|
+
for mr in modules_result:
|
|
342
|
+
mod_name = (mr.get("module_name") or "").strip().lower()
|
|
343
|
+
if not mod_name:
|
|
344
|
+
console.print("[red]Error: a module entry is missing module_name.[/red]")
|
|
345
|
+
sys.exit(2)
|
|
346
|
+
console.print(f"[bold]{mr.get('module_action', 'modify').upper()} module[/bold] [cyan]'{mod_name}'[/cyan]")
|
|
347
|
+
console.print(f"[dim]{mr.get('module_explanation', '')}[/dim]")
|
|
348
|
+
console.print("[bold]Proposed spec.yaml changes:[/bold]")
|
|
349
|
+
console.print(yaml.dump(mr["spec_changes"], default_flow_style=False))
|
|
350
|
+
console.print("[bold]Proposed acceptance.yaml criteria to add:[/bold]")
|
|
351
|
+
for c in mr["acceptance_changes"].get("criteria", []):
|
|
352
|
+
console.print(f" - [{c['id']}] {c['description']} (check: {c.get('check', 'manual')})")
|
|
353
|
+
console.print("")
|
|
354
|
+
|
|
355
|
+
if not click.confirm(f"Approve these changes across {len(modules_result)} module(s) and queue them for build?"):
|
|
356
|
+
console.print("[yellow]Changes aborted.[/yellow]")
|
|
357
|
+
sys.exit(0)
|
|
358
|
+
|
|
359
|
+
coercion_warnings: list[str] = []
|
|
360
|
+
for mr in modules_result:
|
|
361
|
+
coercion_warnings += _write_one_module(pcp_dir, mr)
|
|
362
|
+
|
|
363
|
+
console.print(f"[green]✓[/green] {len(modules_result)} module(s) updated.")
|
|
364
|
+
if coercion_warnings:
|
|
365
|
+
console.print(f"[yellow]⚠ {len(coercion_warnings)} field(s) didn't match the schema, coerced to a safe default:[/yellow]")
|
|
366
|
+
for w in coercion_warnings:
|
|
367
|
+
console.print(f" {w}")
|
|
368
|
+
|
|
369
|
+
# Refresh current state & pcp.md snapshot
|
|
370
|
+
from pcp.commands.scan import scan
|
|
371
|
+
ctx = click.get_current_context(silent=True)
|
|
372
|
+
if ctx:
|
|
373
|
+
ctx.invoke(scan, project_path=str(pcp_dir.parent), quiet=True)
|
|
374
|
+
else:
|
|
375
|
+
# Fallback to direct call logic
|
|
376
|
+
from datetime import datetime, timezone
|
|
377
|
+
from pcp.commands.scan import _scan_module, _write_current_state, _load_prior_manual_status
|
|
378
|
+
modules_dir = get_modules_dir(pcp_dir)
|
|
379
|
+
prior_manual = _load_prior_manual_status(pcp_dir / "current_state.md")
|
|
380
|
+
modules_results = []
|
|
381
|
+
for af in sorted(modules_dir.glob("*/acceptance.yaml")):
|
|
382
|
+
m_name = af.parent.name
|
|
383
|
+
res = _scan_module(m_name, af, pcp_dir.parent, prior_manual)
|
|
384
|
+
modules_results.append(res)
|
|
385
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
386
|
+
_write_current_state(pcp_dir, modules_results, timestamp)
|
|
387
|
+
total = sum(len(m["criteria"]) for m in modules_results)
|
|
388
|
+
complete = sum(1 for m in modules_results for c in m["criteria"] if c["status"] == "complete")
|
|
389
|
+
write_pcp_md(pcp_dir, modules_results, timestamp, total, complete)
|
|
390
|
+
|
|
391
|
+
# Deterministic, zero-cost capability coverage cross-check (see
|
|
392
|
+
# DECOMPOSE FIRST in SYSTEM_PROMPT) -- runs before the LLM-judged
|
|
393
|
+
# validate-strategy call, not instead of it.
|
|
394
|
+
objective = (pcp_dir / "objective.md").read_text() if (pcp_dir / "objective.md").exists() else ""
|
|
395
|
+
decomposition_path = pcp_dir / "strategy" / "decomposition.md"
|
|
396
|
+
decomposition = decomposition_path.read_text() if decomposition_path.exists() else ""
|
|
397
|
+
all_specs = {}
|
|
398
|
+
for spec_path in sorted((pcp_dir / "strategy" / "modules").glob("*/spec.yaml")):
|
|
399
|
+
try:
|
|
400
|
+
all_specs[spec_path.parent.name] = yaml.safe_load(spec_path.read_text()) or {}
|
|
401
|
+
except Exception:
|
|
402
|
+
pass
|
|
403
|
+
|
|
404
|
+
capability_warnings = check_capability_coverage(result.get("capabilities_enumerated", []), all_specs)
|
|
405
|
+
if capability_warnings:
|
|
406
|
+
console.print(f"[yellow]⚠ {len(capability_warnings)} enumerated capability(ies) may not be covered by any module:[/yellow]")
|
|
407
|
+
for w in capability_warnings:
|
|
408
|
+
console.print(f" {w}")
|
|
409
|
+
|
|
410
|
+
# Same check, one layer deeper -- any module whose spec now declares
|
|
411
|
+
# module_logic_breakdown gets it cross-checked against its OWN criteria.
|
|
412
|
+
all_acceptances = {}
|
|
413
|
+
for acc_path in sorted((pcp_dir / "strategy" / "modules").glob("*/acceptance.yaml")):
|
|
414
|
+
try:
|
|
415
|
+
all_acceptances[acc_path.parent.name] = yaml.safe_load(acc_path.read_text()) or {}
|
|
416
|
+
except Exception:
|
|
417
|
+
pass
|
|
418
|
+
breakdown_warnings = check_module_logic_breakdown_coverage(all_specs, all_acceptances)
|
|
419
|
+
if breakdown_warnings:
|
|
420
|
+
console.print(f"[yellow]⚠ {len(breakdown_warnings)} logic-breakdown item(s) may not be covered by their own module's criteria:[/yellow]")
|
|
421
|
+
for w in breakdown_warnings:
|
|
422
|
+
console.print(f" {w}")
|
|
423
|
+
|
|
424
|
+
# Prior-art evidence cross-check -- see check_prior_art_evidence's
|
|
425
|
+
# docstring in kickoff.py. Same rationale as kickoff's own call: pm can
|
|
426
|
+
# add/modify a module's build_vs_buy just as easily as kickoff can.
|
|
427
|
+
priorart_warnings = check_prior_art_evidence(all_specs)
|
|
428
|
+
if priorart_warnings:
|
|
429
|
+
console.print(f"[yellow]⚠ {len(priorart_warnings)} module(s) may be missing prior-art search evidence:[/yellow]")
|
|
430
|
+
for w in priorart_warnings:
|
|
431
|
+
console.print(f" {w}")
|
|
432
|
+
|
|
433
|
+
# Run validate-strategy automatically -- pm previously had ZERO strategy
|
|
434
|
+
# verification at all (unlike kickoff, which always called this), the
|
|
435
|
+
# real root cause of "pm sometimes misses components" -- a module gets
|
|
436
|
+
# modified/added in isolation with nothing checking whether the project
|
|
437
|
+
# still covers the objective afterward.
|
|
438
|
+
if objective:
|
|
439
|
+
console.print("\n[bold]Running validate-strategy...[/bold]")
|
|
440
|
+
val_user_prompt = build_val_prompt(objective, decomposition, all_specs)
|
|
441
|
+
try:
|
|
442
|
+
val_result = llm.call_json(
|
|
443
|
+
VAL_SYSTEM_PROMPT, val_user_prompt,
|
|
444
|
+
model=llm.JUDGE_MODEL, pcp_dir=pcp_dir, command="pm-validate",
|
|
445
|
+
)
|
|
446
|
+
except Exception as e:
|
|
447
|
+
console.print(f"[yellow]Warning: Could not run validate-strategy automatically: {e}[/yellow]")
|
|
448
|
+
val_result = None
|
|
449
|
+
if val_result:
|
|
450
|
+
render_val_results(pcp_dir, val_result, output_json=False)
|
|
451
|
+
|
|
452
|
+
_warn_stale_decomposition(pcp_dir, decomposition, modules_result, intent)
|
|
453
|
+
|
|
454
|
+
console.print("[green]✓[/green] Project state refreshed. Run [cyan]pcp build[/cyan] to begin development.")
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _decomposition_is_stale(decomposition: str, modules_result: list[dict]) -> list[str]:
|
|
458
|
+
"""Module names this pm call touched that decomposition.md never mentions.
|
|
459
|
+
|
|
460
|
+
Deterministic substring check (rung 1) — no LLM deciding whether the
|
|
461
|
+
strategy doc is still true. Adding a module via `pcp pm` silently left
|
|
462
|
+
decomposition.md describing a module set that no longer exists, and since
|
|
463
|
+
validate-strategy judges specs against that same stale decomposition, the
|
|
464
|
+
gap could persist indefinitely."""
|
|
465
|
+
if not decomposition.strip():
|
|
466
|
+
return []
|
|
467
|
+
haystack = decomposition.lower()
|
|
468
|
+
stale = []
|
|
469
|
+
for mr in modules_result:
|
|
470
|
+
name = (mr.get("module_name") or "").strip().lower()
|
|
471
|
+
if not name:
|
|
472
|
+
continue
|
|
473
|
+
if name not in haystack and name.replace("_", "-") not in haystack and name.replace("_", " ") not in haystack:
|
|
474
|
+
stale.append(name)
|
|
475
|
+
return stale
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _warn_stale_decomposition(pcp_dir, decomposition: str, modules_result: list[dict], intent: str) -> None:
|
|
479
|
+
"""pm deliberately never writes program-level spec files (that separation is
|
|
480
|
+
why correct-objective exists as its own command), so this surfaces the
|
|
481
|
+
drift and offers to run the human-approved `pcp amend` path right here
|
|
482
|
+
rather than leaving a nudge nobody acts on."""
|
|
483
|
+
stale = _decomposition_is_stale(decomposition, modules_result)
|
|
484
|
+
if not stale:
|
|
485
|
+
return
|
|
486
|
+
console.print(
|
|
487
|
+
f"\n[yellow]⚠ decomposition.md does not mention {', '.join(stale)} — "
|
|
488
|
+
"the strategy doc validate-strategy judges against is now stale.[/yellow]"
|
|
489
|
+
)
|
|
490
|
+
change = f"Module(s) {', '.join(stale)} were added/changed via `pcp pm`: {intent}"
|
|
491
|
+
try:
|
|
492
|
+
wants_fix = click.confirm("Amend decomposition.md now (you'll review the diff)?", default=True)
|
|
493
|
+
except (click.Abort, RuntimeError, EOFError):
|
|
494
|
+
wants_fix = False
|
|
495
|
+
if not wants_fix:
|
|
496
|
+
console.print(f'[dim]Run later: pcp amend decomposition "{change}"[/dim]')
|
|
497
|
+
return
|
|
498
|
+
from pcp.commands.amend import amend
|
|
499
|
+
ctx = click.get_current_context(silent=True)
|
|
500
|
+
if ctx:
|
|
501
|
+
ctx.invoke(amend, target_file="decomposition", change=change,
|
|
502
|
+
project_path=str(pcp_dir.parent), yes=False, allow_weakening=False)
|
|
503
|
+
else:
|
|
504
|
+
console.print(f'[dim]Run: pcp amend decomposition "{change}"[/dim]')
|