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/commands/kickoff.py
ADDED
|
@@ -0,0 +1,772 @@
|
|
|
1
|
+
"""pcp kickoff — vision → strategy generation via LLM."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import click
|
|
9
|
+
import yaml
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
from pcp.pcp_dir import find_pcp_dir, NoPCPDir, get_modules_dir
|
|
13
|
+
from pcp.schema.validator import validate_file
|
|
14
|
+
from pcp.llm import client as llm
|
|
15
|
+
from pcp.commands.init import ADR_EXAMPLE, DOMAIN_KB_TEMPLATE
|
|
16
|
+
from pcp.commands.validate_strategy import (
|
|
17
|
+
_build_user_prompt as build_val_prompt,
|
|
18
|
+
SYSTEM_PROMPT as VAL_SYSTEM_PROMPT,
|
|
19
|
+
_render_results as render_val_results
|
|
20
|
+
)
|
|
21
|
+
from pcp.pcp_status import write_pcp_md
|
|
22
|
+
|
|
23
|
+
console = Console()
|
|
24
|
+
|
|
25
|
+
SYSTEM_PROMPT = """\
|
|
26
|
+
You are an expert product manager and software architect.
|
|
27
|
+
Your task is to take a product vision document (in plain English) and decompose it into a structured set of program modules and SDLC phases using the PCP (Program Context Protocol) design system.
|
|
28
|
+
|
|
29
|
+
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 on modules, populate `capabilities_enumerated` with EVERY distinct capability/requirement/feature the vision document implies, however small -- one item per discrete thing a user or the business needs, not per module. Only after that list is complete, assign each capability to a module in `modules`. Every entry in `capabilities_enumerated` must be covered by at least one module's `objective_coverage` -- a capability with no covering module is exactly the failure mode this field exists to catch.
|
|
30
|
+
|
|
31
|
+
DECOMPOSE FIRST applies one layer deeper too: within EACH module, before writing that module's acceptance criteria, populate its `module_logic_breakdown` with the module's own internal components/sub-flows/edge-cases -- however small. Derive the module's criteria FROM this breakdown rather than restating the module description at a high level; a module whose criteria don't visibly trace back to a declared breakdown item is exactly "technically a module but not really a module" -- vision-level features discussed without real internal decomposition.
|
|
32
|
+
|
|
33
|
+
Decompose the vision into modules. Each module must cover a distinct set of features/requirements.
|
|
34
|
+
The strategy decomposition must detail how these modules cover the objective.
|
|
35
|
+
Also generate acceptance criteria for each module (e.g. A001, A002) with clear descriptions.
|
|
36
|
+
|
|
37
|
+
WRITING STYLE for objective/target_state/architecture/decomposition/architect_persona: state facts and
|
|
38
|
+
decisions only -- what the objective/target/tech-stack/module-order IS -- never a narrative retelling of
|
|
39
|
+
HOW you arrived at it or a journal-style justification. A one-clause reason is fine where a field already
|
|
40
|
+
asks for one (architecture's "Why" column, a module-order "reason"); anything longer than one clause is
|
|
41
|
+
narrative and does not belong in these files -- these get re-read into every future session, and a
|
|
42
|
+
project-management journal costs real agent performance every time it's reloaded (this is a measured
|
|
43
|
+
finding, not a style preference). objective.md's "Why This Exists" section is the one exception -- stating
|
|
44
|
+
the business objective IS that file's content, not incidental narrative, so write it directly and plainly,
|
|
45
|
+
just don't pad it with extra exposition.
|
|
46
|
+
|
|
47
|
+
You must output ONLY valid JSON — no prose, no markdown, no code fences.
|
|
48
|
+
|
|
49
|
+
Output schema:
|
|
50
|
+
{
|
|
51
|
+
"capabilities_enumerated": ["Every distinct capability/requirement the vision implies, one per discrete thing a user or the business needs -- populate this BEFORE deciding modules, per the DECOMPOSE FIRST instruction above."],
|
|
52
|
+
"objective": "# Program Objective\\n\\n## Why This Exists\\n[Explain why]\\n\\n## What Success Looks Like\\n1. [Outcome 1]\\n\\n## Out of Scope\\n- [Out of scope items]",
|
|
53
|
+
"target_state": "# Target State\\n\\n[Ideal end state]",
|
|
54
|
+
"architecture": "# Architecture\\n\\n## Tech Stack\\n| Layer | Choice | Why |\\n|---|---|---|\\n| Backend | Python/FastAPI | ... |\\n\\n## Key Constraints\\n- [Constraint]",
|
|
55
|
+
"decomposition": "# Strategy Decomposition\\n\\n## How the Objective Breaks Down\\n[Explanation]\\n\\n## Module Dependency Order\\n1. [module-a] - reason",
|
|
56
|
+
"sdlc_phase": {
|
|
57
|
+
"version": "1.0",
|
|
58
|
+
"current_phase": "planning",
|
|
59
|
+
"phases": [
|
|
60
|
+
{
|
|
61
|
+
"name": "planning",
|
|
62
|
+
"exit_criteria": [
|
|
63
|
+
{
|
|
64
|
+
"id": "E001",
|
|
65
|
+
"description": "Strategy decomposition approved by PM",
|
|
66
|
+
"check": "manual",
|
|
67
|
+
"status": "pending"
|
|
68
|
+
}
|
|
69
|
+
]
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"name": "alpha",
|
|
73
|
+
"exit_criteria": [
|
|
74
|
+
{
|
|
75
|
+
"id": "E001",
|
|
76
|
+
"description": "Core features implemented",
|
|
77
|
+
"check": "manual",
|
|
78
|
+
"status": "pending"
|
|
79
|
+
}
|
|
80
|
+
]
|
|
81
|
+
}
|
|
82
|
+
]
|
|
83
|
+
},
|
|
84
|
+
"modules": [
|
|
85
|
+
{
|
|
86
|
+
"name": "module-name",
|
|
87
|
+
"spec": {
|
|
88
|
+
"version": "2.0",
|
|
89
|
+
"module": "module-name",
|
|
90
|
+
"description": "Short description of what the module does (at least 10 words).",
|
|
91
|
+
"objective_coverage": ["What part of objective.md is covered"],
|
|
92
|
+
"module_logic_breakdown": ["This module's internal components/sub-flows/edge-cases -- populate this BEFORE writing this module's acceptance criteria, per the DECOMPOSE FIRST instruction, one layer deeper than capabilities_enumerated. Derive criteria FROM this list rather than restating the description."],
|
|
93
|
+
"dependencies": [],
|
|
94
|
+
"constraints": [],
|
|
95
|
+
"build_vs_buy": {
|
|
96
|
+
"decision": "not_applicable",
|
|
97
|
+
"rationale": "Pure business-logic module -- no whole-module tool-adoption choice; see per-criterion build_vs_buy instead.",
|
|
98
|
+
"candidates_considered": []
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
"acceptance": {
|
|
102
|
+
"version": "2.0",
|
|
103
|
+
"module": "module-name",
|
|
104
|
+
"criteria": [
|
|
105
|
+
{
|
|
106
|
+
"id": "A001",
|
|
107
|
+
"description": "Description of exit criterion",
|
|
108
|
+
"check": "manual",
|
|
109
|
+
"status": "pending",
|
|
110
|
+
"logic_tier": 6,
|
|
111
|
+
"build_vs_buy": {
|
|
112
|
+
"decision": "build_fresh",
|
|
113
|
+
"rationale": "Why this decision, one sentence.",
|
|
114
|
+
"candidates_considered": []
|
|
115
|
+
},
|
|
116
|
+
"depends_on": [],
|
|
117
|
+
"target": "src/path/to/the/file/this/criterion/writes.py"
|
|
118
|
+
}
|
|
119
|
+
]
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
],
|
|
123
|
+
"_comment_criteria_enums": "Every criterion's check MUST be exactly one of: ast_pattern, file_exists, test_passes, manual, dom_contains, url_responds, visual. Every criterion's status MUST be exactly one of: pending, complete, deferred, blocked-ci, blocked-secret, blocked-regression. Do not invent other values (e.g. 'automated' or 'done') even if they seem descriptive -- these are the only ones a validator will accept. When generating a strategy from a vision doc (not yet built), every criterion's status should be 'pending' unless the vision explicitly states something is already implemented.",
|
|
124
|
+
"_comment_logic_tier": "Every criterion MUST declare logic_tier (1-6). Choose by classifying the CORRECTNESS ORACLE, not the task: correctness = 'satisfies rules I can write down completely' -> 1 (litmus: could you write unit tests asserting EXACT outputs for every input class right now?); correctness = 'best feasible option under known constraints' -> 2 (litmus: enumerable constraints + a writable objective function -- OR-Tools/CBC); correctness = 'matches historical outcomes' -> 3 (litmus: hundreds of labeled rows exist or are cheap to collect); correctness = 'faithful to what our documents actually say' -> 4 (answer already exists as text in a bounded corpus); 'same as last time for same question' -> 5 (an overlay on other rungs, rarely a destination); correctness = 'a reasonable human would accept it, and another might accept a different answer' -> 6, ONLY after 1-4 each failed for a stated reason. DECOMPOSE FIRST: a criterion is rarely one decision -- classify each decision point, declare the highest rung actually present. Judgment verbs (recommend/interpret/assess) in a description contradict a rung-1 declaration. Do not default everything to 6.",
|
|
125
|
+
"_comment_build_vs_buy": "Every criterion MUST also declare build_vs_buy: {decision, rationale, candidates_considered}. decision is exactly one of: reuse_whole (take an existing package/repo as a dependency), reuse_partial (vendor one file/function/module out of a larger repo), reimplement_from_reference (study a solved approach -- possibly GPL/AGPL, possibly another language -- and write original code implementing the same logic, no code copied), fork_adapt (fork a whole repo, continuously modify), build_fresh (nothing comparable exists). Each infrastructure-shaped module (portal, auth, integrations, orchestration engine) ALSO gets a module-level build_vs_buy in its spec -- pure business-logic modules use 'not_applicable' there since the per-criterion decisions already cover it.",
|
|
126
|
+
"_comment_depends_on": "Every 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 in a well-decomposed module are genuinely independent (different files, different concerns) 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 (e.g. an 'edit' criterion needing the 'create' criterion's data model first). Declaring a false dependency costs real build parallelism for nothing; missing a true one risks a broken build order -- when genuinely unsure, prefer the empty list and express any shared-file risk through `target` instead (see below) -- a merge-time file conflict between two concurrently-built criteria stops the whole build and needs manual git resolution, so it must never be the plan.\n\nEvery 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.",
|
|
127
|
+
"ci_rules": {
|
|
128
|
+
"version": "1.0",
|
|
129
|
+
"rules": [
|
|
130
|
+
{
|
|
131
|
+
"id": "R001",
|
|
132
|
+
"name": "No hardcoded secrets",
|
|
133
|
+
"check": "ast_pattern",
|
|
134
|
+
"pattern": "(password|secret|api_key)\\\\s*=\\\\s*['\\\"][^'\\\"]{8,}['\\\"]",
|
|
135
|
+
"severity": "hard_block"
|
|
136
|
+
}
|
|
137
|
+
]
|
|
138
|
+
},
|
|
139
|
+
"architect_persona": "# Architect Persona\\n\\n## Principles I Enforce\\n- [Enforced principles]\\n\\n## Anti-Patterns I Block (BLOCK severity)\\n- [Blocked anti-patterns]\\n\\n## Patterns I Warn About (WARN severity)\\n- [Warnings]"
|
|
140
|
+
}
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _write_file(path: Path, content: str) -> None:
|
|
145
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
146
|
+
path.write_text(content)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _max_vision_chars() -> int:
|
|
150
|
+
"""Reject-loud, not truncate-silent: unlike capture.py's transcript cap
|
|
151
|
+
(where "most recent" is a sane proxy for "most relevant"), a vision doc
|
|
152
|
+
has no such structure -- a blind truncation could quietly drop the
|
|
153
|
+
important part and generate a wrong strategy with no visible sign why.
|
|
154
|
+
A function, not a module-level constant, so PCP_KICKOFF_MAX_VISION_CHARS
|
|
155
|
+
is read live at call time rather than frozen at import time."""
|
|
156
|
+
return int(os.environ.get("PCP_KICKOFF_MAX_VISION_CHARS", "40000"))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
VALID_CHECKS = {"ast_pattern", "file_exists", "test_passes", "manual", "dom_contains", "url_responds", "visual"}
|
|
160
|
+
VALID_STATUSES = {"pending", "complete", "deferred", "blocked-ci", "blocked-secret", "blocked-regression"}
|
|
161
|
+
VALID_LOGIC_TIERS = {1, 2, 3, 4, 5, 6}
|
|
162
|
+
VALID_BVB_DECISIONS = {"reuse_whole", "reuse_partial", "reimplement_from_reference", "fork_adapt", "build_fresh"}
|
|
163
|
+
VALID_MODULE_BVB_DECISIONS = VALID_BVB_DECISIONS | {"not_applicable"}
|
|
164
|
+
# Best-effort mapping for common LLM-invented values that don't match the
|
|
165
|
+
# closed schema enum but have an obvious intended meaning.
|
|
166
|
+
_STATUS_ALIASES = {"done": "complete", "finished": "complete", "in_progress": "pending", "todo": "pending"}
|
|
167
|
+
_CHECK_ALIASES = {"automated": "manual", "auto": "manual", "unit_test": "test_passes", "integration_test": "test_passes"}
|
|
168
|
+
|
|
169
|
+
# ci_rules.yaml's own check/severity enums are DIFFERENT from module_acceptance's
|
|
170
|
+
# above -- found 2026-07-09, same class of bug as the acceptance.yaml one this
|
|
171
|
+
# file already fixed on 2026-07-08, just never applied to ci_rules.yaml: kickoff
|
|
172
|
+
# wrote result["ci_rules"] straight to disk with zero validation, so an
|
|
173
|
+
# LLM-invented check type ('file_pair_diff', 'grep') or severity ('warn' instead
|
|
174
|
+
# of 'advisory') silently reached disk and hard-blocked every future commit via
|
|
175
|
+
# pcp check's schema validation -- confirmed live in a real kicked-off project
|
|
176
|
+
# (Project A), not hypothetical.
|
|
177
|
+
VALID_CI_CHECKS = {"ast_pattern", "file_exists", "llm_semantic", "protected_path"}
|
|
178
|
+
VALID_CI_SEVERITIES = {"hard_block", "advisory"}
|
|
179
|
+
_CI_SEVERITY_ALIASES = {"warn": "advisory", "warning": "advisory", "block": "hard_block", "error": "hard_block", "critical": "hard_block"}
|
|
180
|
+
# grep-as-string-search is exactly what ast_pattern already is -- a clean,
|
|
181
|
+
# safe re-mapping since these rules already carry a real `pattern` field.
|
|
182
|
+
# Anything else (e.g. 'file_pair_diff' -- a cross-file consistency check with
|
|
183
|
+
# no deterministic equivalent in this schema) falls back to llm_semantic:
|
|
184
|
+
# advisory, not silently dropped, and genuinely the closest honest fit.
|
|
185
|
+
_CI_CHECK_ALIASES = {"grep": "ast_pattern", "regex": "ast_pattern"}
|
|
186
|
+
_CI_ID_PATTERN = re.compile(r"^[A-Z]+_?[0-9]+$")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _coerce_build_vs_buy(bvb, module_name: str, criterion_id: str, valid_decisions: set) -> tuple[dict, list[str]]:
|
|
190
|
+
"""Coerces a build_vs_buy block to a schema-valid shape. Missing or
|
|
191
|
+
malformed input is never silently dropped -- it's replaced with an
|
|
192
|
+
explicitly-flagged placeholder (build_fresh/not-specified) so a missing
|
|
193
|
+
deliberation is visibly a coercion, not indistinguishable from a real
|
|
194
|
+
build_fresh decision someone actually made."""
|
|
195
|
+
warnings = []
|
|
196
|
+
if not isinstance(bvb, dict) or "decision" not in bvb or "rationale" not in bvb:
|
|
197
|
+
warnings.append(f"{module_name}/{criterion_id}: build_vs_buy missing or malformed, coerced to a flagged placeholder")
|
|
198
|
+
return {
|
|
199
|
+
"decision": "build_fresh",
|
|
200
|
+
"rationale": "Not specified by generator -- coerced placeholder, review before treating as a real decision.",
|
|
201
|
+
"candidates_considered": [],
|
|
202
|
+
}, warnings
|
|
203
|
+
decision = bvb.get("decision")
|
|
204
|
+
if decision not in valid_decisions:
|
|
205
|
+
warnings.append(f"{module_name}/{criterion_id}: build_vs_buy decision '{decision}' is not valid, coerced to 'build_fresh'")
|
|
206
|
+
bvb = {**bvb, "decision": "build_fresh"}
|
|
207
|
+
bvb.setdefault("candidates_considered", [])
|
|
208
|
+
return bvb, warnings
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _normalize_acceptance(acceptance: dict, module_name: str) -> list[str]:
|
|
212
|
+
"""Coerces check/status/logic_tier/build_vs_buy values outside the
|
|
213
|
+
schema's closed enums to a safe default in place, returns a list of
|
|
214
|
+
human-readable warnings for anything it had to coerce. Found necessary
|
|
215
|
+
2026-07-08: kickoff's LLM generation invented plausible-but-invalid
|
|
216
|
+
values ('automated', 'done') for a real, more complex vision doc --
|
|
217
|
+
validate_file was imported here but never actually called, so these
|
|
218
|
+
silently reached disk and only surfaced later, opaquely, whenever
|
|
219
|
+
`pcp scan` happened to run next. logic_tier/build_vs_buy get the same
|
|
220
|
+
treatment from day one rather than repeating that gap."""
|
|
221
|
+
warnings = []
|
|
222
|
+
for c in acceptance.get("criteria", []):
|
|
223
|
+
check = c.get("check")
|
|
224
|
+
if check not in VALID_CHECKS:
|
|
225
|
+
fixed = _CHECK_ALIASES.get(check, "manual")
|
|
226
|
+
warnings.append(f"{module_name}/{c.get('id', '?')}: check '{check}' is not valid, coerced to '{fixed}'")
|
|
227
|
+
c["check"] = fixed
|
|
228
|
+
status = c.get("status")
|
|
229
|
+
if status not in VALID_STATUSES:
|
|
230
|
+
fixed = _STATUS_ALIASES.get(status, "pending")
|
|
231
|
+
warnings.append(f"{module_name}/{c.get('id', '?')}: status '{status}' is not valid, coerced to '{fixed}'")
|
|
232
|
+
c["status"] = fixed
|
|
233
|
+
tier = c.get("logic_tier")
|
|
234
|
+
if tier not in VALID_LOGIC_TIERS:
|
|
235
|
+
warnings.append(f"{module_name}/{c.get('id', '?')}: logic_tier '{tier}' is not valid, coerced to 6 (deep-think -- safest default when unknown)")
|
|
236
|
+
c["logic_tier"] = 6
|
|
237
|
+
bvb, bvb_warnings = _coerce_build_vs_buy(c.get("build_vs_buy"), module_name, c.get("id", "?"), VALID_BVB_DECISIONS)
|
|
238
|
+
c["build_vs_buy"] = bvb
|
|
239
|
+
warnings += bvb_warnings
|
|
240
|
+
# Safety-net only, matches logic_tier's own coercion posture -- a
|
|
241
|
+
# criterion that never got depends_on from the generator (missing
|
|
242
|
+
# key entirely) defaults to independent, since that's the common
|
|
243
|
+
# case for a well-decomposed module and the safer direction to err
|
|
244
|
+
# in (a false-independence guess costs nothing but a merge check;
|
|
245
|
+
# a false-dependency guess costs real, silently-lost parallelism).
|
|
246
|
+
# Never overrides a real value the generator actually supplied,
|
|
247
|
+
# even an empty list -- that's a deliberate declaration, not a gap.
|
|
248
|
+
if "depends_on" not in c:
|
|
249
|
+
warnings.append(f"{module_name}/{c.get('id', '?')}: depends_on missing, defaulted to [] (independent)")
|
|
250
|
+
c["depends_on"] = []
|
|
251
|
+
return warnings
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _normalize_spec(spec: dict, module_name: str) -> list[str]:
|
|
255
|
+
"""Coerces a module spec's module-level build_vs_buy (infrastructure-
|
|
256
|
+
shaped modules -- portal, auth, integrations, orchestration engine) the
|
|
257
|
+
same way _normalize_acceptance coerces per-criterion fields."""
|
|
258
|
+
bvb, warnings = _coerce_build_vs_buy(spec.get("build_vs_buy"), module_name, "(module-level)", VALID_MODULE_BVB_DECISIONS)
|
|
259
|
+
spec["build_vs_buy"] = bvb
|
|
260
|
+
return warnings
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _keyword_miss_check(items: list[str], coverage_text: str, label: str, against: str) -> list[str]:
|
|
264
|
+
"""Deterministic, no LLM: does each item in `items` keyword-overlap
|
|
265
|
+
somewhere in `coverage_text`? A miss doesn't prove a real gap (keyword
|
|
266
|
+
overlap is a blunt instrument), but it's a free, zero-cost second
|
|
267
|
+
opinion worth surfacing -- specifically because noticing an OMISSION is
|
|
268
|
+
a harder task for a fast/cheap judge model than confirming a presence
|
|
269
|
+
is correct. Shared shape behind check_capability_coverage (program-level)
|
|
270
|
+
and check_module_logic_breakdown_coverage (module-level, one layer
|
|
271
|
+
deeper) -- same check, same reasoning, different granularity."""
|
|
272
|
+
warnings = []
|
|
273
|
+
coverage_text = coverage_text.lower()
|
|
274
|
+
for item in items:
|
|
275
|
+
item_words = set(re.findall(r"[a-zA-Z]{5,}", item.lower()))
|
|
276
|
+
if item_words and not any(w in coverage_text for w in item_words):
|
|
277
|
+
warnings.append(f"{label} '{item}' does not keyword-match {against} — possible gap")
|
|
278
|
+
return warnings
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
_COMPLETENESS_LENSES = ("data-model", "edge-case", "integration-dependency")
|
|
282
|
+
|
|
283
|
+
_COMPLETENESS_SYSTEM_PROMPT = """\
|
|
284
|
+
You are reviewing a module's own declared internal breakdown for completeness, one lens at a time. \
|
|
285
|
+
Given the module's description and its current module_logic_breakdown list, from the {lens} lens ONLY, \
|
|
286
|
+
list any genuinely missing internal components/sub-flows/edge-cases this lens would catch that aren't \
|
|
287
|
+
already on the list (don't repeat items that are already there in substance, even if worded differently). \
|
|
288
|
+
Output ONLY valid JSON: {{"new_items": ["..."]}}. Empty list if nothing missing from this lens."""
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _is_genuinely_new(item: str, existing: list[str]) -> bool:
|
|
292
|
+
"""Deterministic dedup, no LLM: an item whose own distinctive words
|
|
293
|
+
already mostly appear somewhere in the existing list reads as a
|
|
294
|
+
reworded duplicate, not a genuinely new finding."""
|
|
295
|
+
item_words = set(re.findall(r"[a-zA-Z]{5,}", item.lower()))
|
|
296
|
+
if not item_words:
|
|
297
|
+
return False
|
|
298
|
+
existing_text = " ".join(existing).lower()
|
|
299
|
+
overlap = sum(1 for w in item_words if w in existing_text)
|
|
300
|
+
return overlap < max(1, len(item_words) // 2)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def loop_until_dry_breakdown(
|
|
304
|
+
pcp_dir: Path, module_name: str, description: str, breakdown: list[str], max_rounds: int = 6,
|
|
305
|
+
) -> tuple[list[str], list[dict]]:
|
|
306
|
+
"""Lazy-agent backlog item 10: multi-lens completeness pass over a
|
|
307
|
+
module's own module_logic_breakdown, looping until 2 CONSECUTIVE rounds
|
|
308
|
+
add nothing genuinely new (loop-until-dry) rather than a fixed-N-loop
|
|
309
|
+
count -- a fixed count misses the tail on a genuinely complex module
|
|
310
|
+
and overspends on a simple one. Each round cycles through a DIFFERENT
|
|
311
|
+
lens (data-model / edge-case / integration-dependency) rather than
|
|
312
|
+
re-asking the same question, which risks diminishing or fabricated
|
|
313
|
+
returns on repetition. Feeds module_logic_breakdown -- doesn't replace
|
|
314
|
+
CTRL-031's built-code verification step.
|
|
315
|
+
|
|
316
|
+
Opt-in only (see PCP_KICKOFF_DEEP_BREAKDOWN in kickoff()) -- a real
|
|
317
|
+
LLM-call loop, not something every kickoff should pay for by default
|
|
318
|
+
(Token Discipline). Returns (ENRICHED breakdown (original + new),
|
|
319
|
+
additions) where additions is [{"item": ..., "lens": ...}] for each
|
|
320
|
+
genuinely-new item -- surfaced to the human at approval time (2026-07-31)
|
|
321
|
+
instead of discarded, so "the AI found more edge cases" is reviewable
|
|
322
|
+
(which lens, which item) rather than a bare trust-me count. Overconfident
|
|
323
|
+
verbalized-completeness is a known LLM failure mode; a reason attached
|
|
324
|
+
to each item is cheaper than re-deriving one after the fact."""
|
|
325
|
+
enriched = list(breakdown)
|
|
326
|
+
additions: list[dict] = []
|
|
327
|
+
consecutive_dry = 0
|
|
328
|
+
round_num = 0
|
|
329
|
+
while consecutive_dry < 2 and round_num < max_rounds:
|
|
330
|
+
lens = _COMPLETENESS_LENSES[round_num % len(_COMPLETENESS_LENSES)]
|
|
331
|
+
round_num += 1
|
|
332
|
+
prompt = (
|
|
333
|
+
f"Module: {module_name}\nDescription: {description}\n"
|
|
334
|
+
f"Current module_logic_breakdown: {json.dumps(enriched)}"
|
|
335
|
+
)
|
|
336
|
+
try:
|
|
337
|
+
res = llm.call_json(
|
|
338
|
+
_COMPLETENESS_SYSTEM_PROMPT.format(lens=lens), prompt,
|
|
339
|
+
model=llm.JUDGE_MODEL, pcp_dir=pcp_dir, command="kickoff-completeness",
|
|
340
|
+
)
|
|
341
|
+
except Exception:
|
|
342
|
+
break
|
|
343
|
+
candidates = res.get("new_items", []) if isinstance(res, dict) else []
|
|
344
|
+
genuinely_new = [c for c in candidates if _is_genuinely_new(c, enriched)]
|
|
345
|
+
if genuinely_new:
|
|
346
|
+
enriched.extend(genuinely_new)
|
|
347
|
+
additions.extend({"item": item, "lens": lens} for item in genuinely_new)
|
|
348
|
+
consecutive_dry = 0
|
|
349
|
+
else:
|
|
350
|
+
consecutive_dry += 1
|
|
351
|
+
return enriched, additions
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def check_capability_coverage(capabilities: list[str], module_specs: dict) -> list[str]:
|
|
355
|
+
"""Deterministic, no LLM: keyword-overlap check between each enumerated
|
|
356
|
+
capability (see DECOMPOSE FIRST in SYSTEM_PROMPT) and the combined
|
|
357
|
+
objective_coverage text of all modules -- a cheap complementary signal
|
|
358
|
+
alongside validate-strategy's LLM-judged coverage_score. Shared by
|
|
359
|
+
kickoff.py and pm.py -- same check, same reasoning, whether this is a
|
|
360
|
+
fresh strategy or an incremental intent."""
|
|
361
|
+
combined_coverage = " ".join(
|
|
362
|
+
" ".join(spec.get("objective_coverage", []) or []) for spec in module_specs.values()
|
|
363
|
+
)
|
|
364
|
+
return _keyword_miss_check(capabilities, combined_coverage, "Capability", "any module's objective_coverage")
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
_NON_TRIVIAL_KEYWORDS = (
|
|
368
|
+
"auth", "login", "oauth", "payment", "billing", "checkout", "queue", "scheduler",
|
|
369
|
+
"cron", "state machine", "state-machine", "parser", "parsing", "embedding",
|
|
370
|
+
"vector search", "rate limit", "rate-limit", "retry", "backoff", "webhook",
|
|
371
|
+
"canvas", "diagram editor", "rich text editor", "rich-text editor",
|
|
372
|
+
"spreadsheet grid", "drag-drop", "drag and drop", "pdf", "ocr",
|
|
373
|
+
"file conversion", "format extraction",
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def check_prior_art_evidence(module_specs: dict) -> list[str]:
|
|
378
|
+
"""Deterministic, no LLM -- same tier and posture as check_capability_coverage.
|
|
379
|
+
Closes a real gap: CLAUDE.md's global Prior-Art Check rule ("run /priorart
|
|
380
|
+
before building a non-trivial module") lived only in doctrine an agent had
|
|
381
|
+
to remember to follow -- nothing in kickoff/pm/build ever checked it
|
|
382
|
+
happened, so it fired only when a human-in-loop session happened to read
|
|
383
|
+
CLAUDE.md, never in a headless run.
|
|
384
|
+
|
|
385
|
+
build_vs_buy is already schema-required per non-`not_applicable` module,
|
|
386
|
+
but its rationale can be written with zero real search behind it.
|
|
387
|
+
`candidates_considered` staying empty is the honest tell: a genuine
|
|
388
|
+
/priorart run always names 2-4 candidates even when the verdict is
|
|
389
|
+
build_fresh ("nothing comparable found, considered X, Y") -- so a
|
|
390
|
+
non-trivial-category module with candidates_considered=[] is a module
|
|
391
|
+
whose build_vs_buy decision was asserted, not searched for.
|
|
392
|
+
|
|
393
|
+
Advisory, matching check_capability_coverage's own posture -- flags,
|
|
394
|
+
never blocks. A keyword match on module name/description is a blunt
|
|
395
|
+
instrument, not proof a module is genuinely non-trivial; a human reads
|
|
396
|
+
the flag and judges."""
|
|
397
|
+
warnings = []
|
|
398
|
+
for name, spec in module_specs.items():
|
|
399
|
+
text = f"{name} {spec.get('description', '')}".lower()
|
|
400
|
+
if not any(kw in text for kw in _NON_TRIVIAL_KEYWORDS):
|
|
401
|
+
continue
|
|
402
|
+
bvb = spec.get("build_vs_buy") or {}
|
|
403
|
+
decision = bvb.get("decision")
|
|
404
|
+
if decision and decision != "not_applicable" and not bvb.get("candidates_considered"):
|
|
405
|
+
warnings.append(
|
|
406
|
+
f"{name}: non-trivial-category module (keyword match) declares "
|
|
407
|
+
f"build_vs_buy={decision} with empty candidates_considered -- no "
|
|
408
|
+
"prior-art search evidence. Run `/priorart` on this module's "
|
|
409
|
+
"description before treating the decision as final."
|
|
410
|
+
)
|
|
411
|
+
return warnings
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def check_module_logic_breakdown_coverage(module_specs: dict, module_acceptances: dict) -> list[str]:
|
|
415
|
+
"""One layer deeper than check_capability_coverage: does each module's
|
|
416
|
+
own declared module_logic_breakdown (internal components/sub-flows/
|
|
417
|
+
edge-cases, see the module_spec schema field) keyword-match at least
|
|
418
|
+
one of THAT module's own criteria descriptions? Only checks modules
|
|
419
|
+
that actually declared a breakdown -- the field is optional, absence is
|
|
420
|
+
not itself a finding (see CLAUDE.md's PCP Design lifecycle for the same
|
|
421
|
+
"declared, then audited" posture design_justification already has)."""
|
|
422
|
+
warnings = []
|
|
423
|
+
for mod_name, spec in module_specs.items():
|
|
424
|
+
breakdown = spec.get("module_logic_breakdown") or []
|
|
425
|
+
if not breakdown:
|
|
426
|
+
continue
|
|
427
|
+
acc = module_acceptances.get(mod_name) or {}
|
|
428
|
+
own_criteria_text = " ".join(c.get("description", "") for c in acc.get("criteria", []) or [])
|
|
429
|
+
for f in _keyword_miss_check(breakdown, own_criteria_text, "Logic-breakdown item", "this module's own criteria"):
|
|
430
|
+
warnings.append(f"{mod_name}: {f}")
|
|
431
|
+
return warnings
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _normalize_ci_rules(ci_rules: dict) -> list[str]:
|
|
435
|
+
"""Coerces ci_rules.yaml's check/severity/id values outside the schema's
|
|
436
|
+
closed enums to a safe default in place, same posture as
|
|
437
|
+
_normalize_acceptance: a placeholder-mismatch gets fixed and flagged, not
|
|
438
|
+
silently written to disk to hard-block every future commit via pcp
|
|
439
|
+
check's schema validation the first time someone tries to ship."""
|
|
440
|
+
warnings = []
|
|
441
|
+
seen_ids: set[str] = set()
|
|
442
|
+
for i, r in enumerate(ci_rules.get("rules", [])):
|
|
443
|
+
rid_display = r.get("id", f"rule#{i}")
|
|
444
|
+
|
|
445
|
+
check = r.get("check")
|
|
446
|
+
if check not in VALID_CI_CHECKS:
|
|
447
|
+
fixed = _CI_CHECK_ALIASES.get(check)
|
|
448
|
+
if fixed is None:
|
|
449
|
+
fixed = "llm_semantic"
|
|
450
|
+
warnings.append(f"ci_rules/{rid_display}: check '{check}' is not valid, coerced to '{fixed}'")
|
|
451
|
+
r["check"] = fixed
|
|
452
|
+
|
|
453
|
+
# Whichever check type it ends up as, make sure the field that type
|
|
454
|
+
# actually requires exists -- coercing the type alone without this
|
|
455
|
+
# would just trade one schema error for another.
|
|
456
|
+
if r["check"] == "llm_semantic" and not r.get("description"):
|
|
457
|
+
# Prefer `pattern` over `name`: when a check type gets coerced
|
|
458
|
+
# away from ast_pattern/grep/file_pair_diff, `pattern` often
|
|
459
|
+
# held free-text semantic explanation (not a real regex) --
|
|
460
|
+
# that's more useful as `description` than the terse rule name.
|
|
461
|
+
r["description"] = r.get("pattern") or r.get("name") or "no description provided"
|
|
462
|
+
elif r["check"] == "ast_pattern" and not r.get("pattern"):
|
|
463
|
+
# Can't safely claim ast_pattern with no pattern to match -- fall
|
|
464
|
+
# back to the one type that only needs a description.
|
|
465
|
+
r["check"] = "llm_semantic"
|
|
466
|
+
r.setdefault("description", r.get("name") or "no description provided")
|
|
467
|
+
warnings.append(f"ci_rules/{rid_display}: ast_pattern with no pattern field, coerced to llm_semantic instead")
|
|
468
|
+
elif r["check"] == "file_exists" and not r.get("target"):
|
|
469
|
+
r["check"] = "llm_semantic"
|
|
470
|
+
r.setdefault("description", r.get("name") or "no description provided")
|
|
471
|
+
warnings.append(f"ci_rules/{rid_display}: file_exists with no target field, coerced to llm_semantic instead")
|
|
472
|
+
elif r["check"] == "protected_path" and not r.get("scope"):
|
|
473
|
+
r["check"] = "llm_semantic"
|
|
474
|
+
r.setdefault("description", r.get("name") or "no description provided")
|
|
475
|
+
warnings.append(f"ci_rules/{rid_display}: protected_path with no scope field, coerced to llm_semantic instead")
|
|
476
|
+
|
|
477
|
+
severity = r.get("severity")
|
|
478
|
+
if severity not in VALID_CI_SEVERITIES:
|
|
479
|
+
fixed = _CI_SEVERITY_ALIASES.get(severity, "advisory")
|
|
480
|
+
warnings.append(f"ci_rules/{rid_display}: severity '{severity}' is not valid, coerced to '{fixed}'")
|
|
481
|
+
r["severity"] = fixed
|
|
482
|
+
|
|
483
|
+
rid = r.get("id", "")
|
|
484
|
+
if not rid or not _CI_ID_PATTERN.match(rid) or rid in seen_ids:
|
|
485
|
+
fixed_id = f"GEN_{i + 1:03d}"
|
|
486
|
+
warnings.append(f"ci_rules: id '{rid or '(missing)'}' is not valid or duplicate, coerced to '{fixed_id}'")
|
|
487
|
+
r["id"] = fixed_id
|
|
488
|
+
rid = fixed_id
|
|
489
|
+
seen_ids.add(rid)
|
|
490
|
+
|
|
491
|
+
if not r.get("name"):
|
|
492
|
+
r["name"] = rid_display
|
|
493
|
+
|
|
494
|
+
return warnings
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _report_orphaned_modules(pcp_dir: Path, generated: set[str]) -> list[str]:
|
|
498
|
+
"""Surface module directories left behind by a PREVIOUS kickoff.
|
|
499
|
+
|
|
500
|
+
`--force` only suppresses the overwrite confirm (see the `if pcp_dir.exists()
|
|
501
|
+
and not force` check below); it has never removed prior module directories,
|
|
502
|
+
while its help text says "Force overwrite existing .pcp/ directory". So a
|
|
503
|
+
second kickoff against a re-scoped vision writes its new modules ALONGSIDE
|
|
504
|
+
the old ones. Found live 2026-07-27 in the Project S dogfood: two kickoffs
|
|
505
|
+
produced 14 module directories from two incompatible decompositions, while
|
|
506
|
+
decomposition.md described only the 6 newest.
|
|
507
|
+
|
|
508
|
+
That is the exact drift PCP exists to prevent, and it is worse than a merely
|
|
509
|
+
stale file, because `pcp build` gathers modules from DISK, not from
|
|
510
|
+
decomposition.md — so the next build would have built features the current
|
|
511
|
+
objective explicitly rules out, with a wave order computed across a
|
|
512
|
+
dependency graph spanning two generations. It also silently corrupts
|
|
513
|
+
validate-strategy's coverage verdict, which judges the new decomposition
|
|
514
|
+
while the orphans sit there covering things it reports as gaps.
|
|
515
|
+
|
|
516
|
+
Deliberately reports rather than deletes: these are protected-path spec
|
|
517
|
+
files that a human may have authored or amended, and destroying them to
|
|
518
|
+
tidy up a scaffold would be a far worse failure than leaving them. Returns
|
|
519
|
+
the orphan names so callers/tests can assert on them."""
|
|
520
|
+
modules_dir = pcp_dir / "strategy" / "modules"
|
|
521
|
+
if not modules_dir.exists():
|
|
522
|
+
return []
|
|
523
|
+
on_disk = {d.name for d in modules_dir.iterdir() if d.is_dir() and (d / "spec.yaml").exists()}
|
|
524
|
+
orphans = sorted(on_disk - generated)
|
|
525
|
+
if not orphans:
|
|
526
|
+
return []
|
|
527
|
+
|
|
528
|
+
console.print(
|
|
529
|
+
f"\n[bold yellow]⚠ {len(orphans)} module director(ies) on disk are NOT in this "
|
|
530
|
+
f"decomposition[/bold yellow] — left over from an earlier kickoff:"
|
|
531
|
+
)
|
|
532
|
+
for name in orphans:
|
|
533
|
+
console.print(f" [yellow]{name}[/yellow]")
|
|
534
|
+
console.print(
|
|
535
|
+
"[dim]`pcp build` gathers modules from disk, not from decomposition.md, so these "
|
|
536
|
+
"WILL be built unless you remove them. They also distort `pcp validate-strategy`'s "
|
|
537
|
+
"coverage verdict. Review and delete the directories you no longer want:[/dim]"
|
|
538
|
+
)
|
|
539
|
+
console.print(f"[dim] rm -rf {modules_dir}/<name>[/dim]")
|
|
540
|
+
console.print(
|
|
541
|
+
"[dim]Not removed automatically — module spec.yaml/acceptance.yaml are "
|
|
542
|
+
"protected-path files that may carry human-authored changes.[/dim]\n"
|
|
543
|
+
)
|
|
544
|
+
return orphans
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
@click.command()
|
|
548
|
+
@click.argument("vision_file", type=click.Path(exists=True))
|
|
549
|
+
@click.option("--path", "project_path", type=click.Path(), default=".",
|
|
550
|
+
help="Project root (default: current directory).")
|
|
551
|
+
@click.option("--force", is_flag=True, help="Force overwrite existing .pcp/ directory.")
|
|
552
|
+
def kickoff(vision_file: str, project_path: str, force: bool):
|
|
553
|
+
"""Kick off a new project from a product vision document."""
|
|
554
|
+
root = Path(project_path).resolve()
|
|
555
|
+
pcp_dir = root / ".pcp"
|
|
556
|
+
|
|
557
|
+
if pcp_dir.exists() and not force:
|
|
558
|
+
if not click.confirm("An existing .pcp/ directory was found. This kickoff will overwrite it. Proceed?"):
|
|
559
|
+
console.print("[yellow]Kickoff aborted.[/yellow]")
|
|
560
|
+
sys.exit(0)
|
|
561
|
+
|
|
562
|
+
vision_path = Path(vision_file).resolve()
|
|
563
|
+
try:
|
|
564
|
+
vision_content = vision_path.read_text()
|
|
565
|
+
except Exception as e:
|
|
566
|
+
console.print(f"[red]Error reading vision file:[/red] {e}")
|
|
567
|
+
sys.exit(2)
|
|
568
|
+
|
|
569
|
+
max_vision_chars = _max_vision_chars()
|
|
570
|
+
if len(vision_content) > max_vision_chars:
|
|
571
|
+
console.print(
|
|
572
|
+
f"[red]Error:[/red] {vision_file} is {len(vision_content):,} chars, "
|
|
573
|
+
f"over the {max_vision_chars:,}-char kickoff limit."
|
|
574
|
+
)
|
|
575
|
+
console.print(
|
|
576
|
+
"[dim]Split it into a shorter vision doc, or scope this kickoff to one phase at a time — "
|
|
577
|
+
"not truncated automatically, since a vision doc has no 'most recent = most relevant' "
|
|
578
|
+
"structure the way a session transcript does, so a silent cut could quietly drop the "
|
|
579
|
+
"important part and generate a wrong strategy with no visible sign why.[/dim]"
|
|
580
|
+
)
|
|
581
|
+
sys.exit(2)
|
|
582
|
+
|
|
583
|
+
console.print("[dim]Analyzing vision and generating Strategy decomposition...[/dim]")
|
|
584
|
+
|
|
585
|
+
try:
|
|
586
|
+
# Sonnet is the reviewed default for generation calls (see
|
|
587
|
+
# llm/client.py's model-selection strategy) -- replaces the prior
|
|
588
|
+
# ambiguous "inherited/default" (whatever the CLI's own default
|
|
589
|
+
# happened to be). PCP_MODEL still overrides for a human debugging.
|
|
590
|
+
result = llm.call_json(SYSTEM_PROMPT, vision_content, model=llm.BUILD_MODEL, pcp_dir=pcp_dir, command="kickoff")
|
|
591
|
+
except RuntimeError as e:
|
|
592
|
+
console.print(f"[red]Error calling LLM:[/red] {e}")
|
|
593
|
+
sys.exit(2)
|
|
594
|
+
except ValueError as e:
|
|
595
|
+
console.print(f"[red]LLM returned invalid JSON:[/red] {e}")
|
|
596
|
+
sys.exit(2)
|
|
597
|
+
|
|
598
|
+
# Write files
|
|
599
|
+
_write_file(pcp_dir / "objective.md", result["objective"])
|
|
600
|
+
_write_file(pcp_dir / "target_state.md", result["target_state"])
|
|
601
|
+
_write_file(pcp_dir / "architecture.md", result["architecture"])
|
|
602
|
+
_write_file(pcp_dir / "strategy" / "decomposition.md", result["decomposition"])
|
|
603
|
+
|
|
604
|
+
sdlc_yaml = yaml.dump(result["sdlc_phase"], default_flow_style=False)
|
|
605
|
+
_write_file(pcp_dir / "SDLC_phase.yaml", sdlc_yaml)
|
|
606
|
+
|
|
607
|
+
# Force the one valid literal version regardless of what the LLM returned
|
|
608
|
+
# -- there's no v1/v2 duality for ci_rules.yaml the way there is for
|
|
609
|
+
# module specs, so this is zero-ambiguity, same defensive posture as
|
|
610
|
+
# forcing "2.0" on acceptance.yaml/spec.yaml below.
|
|
611
|
+
result["ci_rules"]["version"] = "1.0"
|
|
612
|
+
coercion_warnings = _normalize_ci_rules(result["ci_rules"])
|
|
613
|
+
ci_yaml = yaml.dump(result["ci_rules"], default_flow_style=False)
|
|
614
|
+
_write_file(pcp_dir / "ci_rules.yaml", ci_yaml)
|
|
615
|
+
|
|
616
|
+
_write_file(pcp_dir / "architect_persona.md", result["architect_persona"])
|
|
617
|
+
_write_file(pcp_dir / "kb" / "adr" / "ADR-001-example.md", ADR_EXAMPLE)
|
|
618
|
+
_write_file(pcp_dir / "kb" / "domain" / "general.md", DOMAIN_KB_TEMPLATE)
|
|
619
|
+
|
|
620
|
+
# Write module specs and acceptance criteria
|
|
621
|
+
breakdown_additions: dict[str, list[dict]] = {}
|
|
622
|
+
for m in result.get("modules", []):
|
|
623
|
+
mod_dir = pcp_dir / "strategy" / "modules" / m["name"]
|
|
624
|
+
# Force version 2.0 regardless of what the LLM returned -- a fresh
|
|
625
|
+
# kickoff must always get logic_tier/build_vs_buy enforcement, not
|
|
626
|
+
# silently downgrade to the ungated 1.0 shape on a generation slip.
|
|
627
|
+
m["spec"]["version"] = "2.0"
|
|
628
|
+
m["acceptance"]["version"] = "2.0"
|
|
629
|
+
coercion_warnings += _normalize_spec(m["spec"], m["name"])
|
|
630
|
+
coercion_warnings += _normalize_acceptance(m["acceptance"], m["name"])
|
|
631
|
+
|
|
632
|
+
# Opt-in multi-lens completeness pass (lazy-agent backlog item 10) --
|
|
633
|
+
# real extra LLM calls, so this stays behind an explicit flag rather
|
|
634
|
+
# than running on every kickoff (Token Discipline).
|
|
635
|
+
if os.environ.get("PCP_KICKOFF_DEEP_BREAKDOWN") == "1" and m["spec"].get("module_logic_breakdown"):
|
|
636
|
+
enriched, additions = loop_until_dry_breakdown(
|
|
637
|
+
pcp_dir, m["name"], m["spec"].get("description", ""), m["spec"]["module_logic_breakdown"],
|
|
638
|
+
)
|
|
639
|
+
if additions:
|
|
640
|
+
console.print(f"[dim]Completeness pass: +{len(additions)} logic-breakdown item(s) for '{m['name']}'[/dim]")
|
|
641
|
+
breakdown_additions.setdefault(m["name"], []).extend(additions)
|
|
642
|
+
m["spec"]["module_logic_breakdown"] = enriched
|
|
643
|
+
|
|
644
|
+
_write_file(mod_dir / "spec.yaml", yaml.dump(m["spec"], default_flow_style=False))
|
|
645
|
+
_write_file(mod_dir / "acceptance.yaml", yaml.dump(m["acceptance"], default_flow_style=False))
|
|
646
|
+
|
|
647
|
+
_report_orphaned_modules(pcp_dir, {m["name"] for m in result.get("modules", [])})
|
|
648
|
+
|
|
649
|
+
console.print("[green]✓[/green] Generated PCP files under [cyan].pcp/[/cyan]")
|
|
650
|
+
|
|
651
|
+
if coercion_warnings:
|
|
652
|
+
console.print(f"[yellow]⚠ {len(coercion_warnings)} criterion field(s) didn't match the schema, coerced to a safe default:[/yellow]")
|
|
653
|
+
for w in coercion_warnings:
|
|
654
|
+
console.print(f" {w}")
|
|
655
|
+
|
|
656
|
+
# Schema-validate what actually landed on disk -- advisory, matches
|
|
657
|
+
# scan.py's own posture (warn, don't block), but at least surfaces any
|
|
658
|
+
# remaining issue right here instead of only the first time someone
|
|
659
|
+
# tries to commit and pcp check's Layer 1 schema validation hard-blocks
|
|
660
|
+
# on it -- confirmed live in a real kicked-off project (Project A) before
|
|
661
|
+
# this check existed.
|
|
662
|
+
ci_rules_errors = validate_file(pcp_dir / "ci_rules.yaml", "ci_rules")
|
|
663
|
+
if ci_rules_errors:
|
|
664
|
+
console.print("[yellow]⚠ ci_rules.yaml still has schema issues after coercion:[/yellow]")
|
|
665
|
+
for e in ci_rules_errors:
|
|
666
|
+
console.print(f" {e}")
|
|
667
|
+
|
|
668
|
+
for m in result.get("modules", []):
|
|
669
|
+
mod_dir = pcp_dir / "strategy" / "modules" / m["name"]
|
|
670
|
+
errors = validate_file(mod_dir / "acceptance.yaml", "module_acceptance")
|
|
671
|
+
if errors:
|
|
672
|
+
console.print(f"[yellow]⚠ {m['name']}/acceptance.yaml still has schema issues after coercion:[/yellow]")
|
|
673
|
+
for e in errors:
|
|
674
|
+
console.print(f" {e}")
|
|
675
|
+
|
|
676
|
+
# Deterministic, zero-cost capability coverage cross-check (see
|
|
677
|
+
# DECOMPOSE FIRST in SYSTEM_PROMPT) -- runs before the LLM-judged
|
|
678
|
+
# validate-strategy call, not instead of it.
|
|
679
|
+
modules = {m["name"]: m["spec"] for m in result.get("modules", [])}
|
|
680
|
+
capability_warnings = check_capability_coverage(result.get("capabilities_enumerated", []), modules)
|
|
681
|
+
if capability_warnings:
|
|
682
|
+
console.print(f"[yellow]⚠ {len(capability_warnings)} enumerated capability(ies) may not be covered by any module:[/yellow]")
|
|
683
|
+
for w in capability_warnings:
|
|
684
|
+
console.print(f" {w}")
|
|
685
|
+
|
|
686
|
+
# Same check, one layer deeper (module_logic_breakdown vs. each
|
|
687
|
+
# module's OWN criteria) -- see check_module_logic_breakdown_coverage.
|
|
688
|
+
acceptances = {m["name"]: m["acceptance"] for m in result.get("modules", [])}
|
|
689
|
+
breakdown_warnings = check_module_logic_breakdown_coverage(modules, acceptances)
|
|
690
|
+
if breakdown_warnings:
|
|
691
|
+
console.print(f"[yellow]⚠ {len(breakdown_warnings)} logic-breakdown item(s) may not be covered by their own module's criteria:[/yellow]")
|
|
692
|
+
for w in breakdown_warnings:
|
|
693
|
+
console.print(f" {w}")
|
|
694
|
+
|
|
695
|
+
# Prior-art evidence cross-check -- see check_prior_art_evidence's
|
|
696
|
+
# docstring. Previously doctrine-only (CLAUDE.md's Prior-Art Check rule);
|
|
697
|
+
# this is the first code path that actually checks it happened.
|
|
698
|
+
priorart_warnings = check_prior_art_evidence(modules)
|
|
699
|
+
if priorart_warnings:
|
|
700
|
+
console.print(f"[yellow]⚠ {len(priorart_warnings)} module(s) may be missing prior-art search evidence:[/yellow]")
|
|
701
|
+
for w in priorart_warnings:
|
|
702
|
+
console.print(f" {w}")
|
|
703
|
+
|
|
704
|
+
# Run validate-strategy automatically
|
|
705
|
+
console.print("\n[bold]Running validate-strategy...[/bold]")
|
|
706
|
+
objective = result["objective"]
|
|
707
|
+
decomposition = result["decomposition"]
|
|
708
|
+
|
|
709
|
+
val_user_prompt = build_val_prompt(objective, decomposition, modules)
|
|
710
|
+
try:
|
|
711
|
+
val_result = llm.call_json(
|
|
712
|
+
VAL_SYSTEM_PROMPT, val_user_prompt,
|
|
713
|
+
model=llm.JUDGE_MODEL, pcp_dir=pcp_dir, command="kickoff-validate",
|
|
714
|
+
)
|
|
715
|
+
except Exception as e:
|
|
716
|
+
console.print(f"[yellow]Warning: Could not run validate-strategy automatically: {e}[/yellow]")
|
|
717
|
+
val_result = None
|
|
718
|
+
|
|
719
|
+
if val_result:
|
|
720
|
+
render_val_results(pcp_dir, val_result, output_json=False)
|
|
721
|
+
|
|
722
|
+
# Surface completeness-pass additions with their lens before approval --
|
|
723
|
+
# a human seeing a bare "+3 items" count has nothing to actually judge;
|
|
724
|
+
# naming which lens (data-model/edge-case/integration-dependency)
|
|
725
|
+
# produced each item gives a real basis to accept or push back on rather
|
|
726
|
+
# than rubber-stamping "the AI found more edge cases."
|
|
727
|
+
if breakdown_additions:
|
|
728
|
+
console.print("\n[bold]Completeness-pass additions (review before approving):[/bold]")
|
|
729
|
+
for mod_name, items in breakdown_additions.items():
|
|
730
|
+
console.print(f" [cyan]{mod_name}[/cyan]:")
|
|
731
|
+
for a in items:
|
|
732
|
+
console.print(f" + ({a['lens']}) {a['item']}")
|
|
733
|
+
|
|
734
|
+
# PM approval step
|
|
735
|
+
if click.confirm("\nApprove this strategy and proceed to alpha phase?"):
|
|
736
|
+
# Update SDLC phase to alpha
|
|
737
|
+
sdlc_data = result["sdlc_phase"]
|
|
738
|
+
sdlc_data["current_phase"] = "alpha"
|
|
739
|
+
for p in sdlc_data.get("phases", []):
|
|
740
|
+
if p["name"] == "planning":
|
|
741
|
+
for c in p.get("exit_criteria", []):
|
|
742
|
+
if c["id"] == "E001":
|
|
743
|
+
c["status"] = "complete"
|
|
744
|
+
|
|
745
|
+
_write_file(pcp_dir / "SDLC_phase.yaml", yaml.dump(sdlc_data, default_flow_style=False))
|
|
746
|
+
|
|
747
|
+
# Reconstruct modules results format for write_pcp_md
|
|
748
|
+
modules_results = []
|
|
749
|
+
for m in result.get("modules", []):
|
|
750
|
+
criteria = []
|
|
751
|
+
for c in m["acceptance"].get("criteria", []):
|
|
752
|
+
criteria.append({
|
|
753
|
+
"id": c["id"],
|
|
754
|
+
"description": c["description"],
|
|
755
|
+
"check": c.get("check", "manual"),
|
|
756
|
+
"status": c.get("status", "pending")
|
|
757
|
+
})
|
|
758
|
+
modules_results.append({
|
|
759
|
+
"module": m["name"],
|
|
760
|
+
"criteria": criteria
|
|
761
|
+
})
|
|
762
|
+
|
|
763
|
+
from datetime import datetime, timezone
|
|
764
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
765
|
+
total = sum(len(m["criteria"]) for m in modules_results)
|
|
766
|
+
complete = sum(1 for m in modules_results for c in m["criteria"] if c["status"] == "complete")
|
|
767
|
+
|
|
768
|
+
pcp_md_path = write_pcp_md(pcp_dir, modules_results, timestamp, total, complete)
|
|
769
|
+
console.print(f"\n[green]Strategy approved! Transitioned current phase to: alpha.[/green]")
|
|
770
|
+
console.print(f"Governance snapshot written to [cyan]{pcp_md_path.name}[/cyan]")
|
|
771
|
+
else:
|
|
772
|
+
console.print("\n[yellow]Strategy rejected. Spec files kept in .pcp/ for your manual modification.[/yellow]")
|