program-context-protocol 0.12.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. pcp/__init__.py +3 -0
  2. pcp/assertions.py +152 -0
  3. pcp/attest.py +111 -0
  4. pcp/build_loop_bypass.py +76 -0
  5. pcp/build_report.py +54 -0
  6. pcp/capture.py +339 -0
  7. pcp/cli.py +104 -0
  8. pcp/commands/__init__.py +0 -0
  9. pcp/commands/amend.py +283 -0
  10. pcp/commands/architect_review.py +291 -0
  11. pcp/commands/architecture_justification.py +164 -0
  12. pcp/commands/audit.py +371 -0
  13. pcp/commands/build.py +4523 -0
  14. pcp/commands/build_plan.py +153 -0
  15. pcp/commands/build_status.py +83 -0
  16. pcp/commands/capture.py +72 -0
  17. pcp/commands/check.py +584 -0
  18. pcp/commands/context.py +151 -0
  19. pcp/commands/control_audit_cmd.py +54 -0
  20. pcp/commands/correct_objective.py +160 -0
  21. pcp/commands/dashboard.py +732 -0
  22. pcp/commands/deploy.py +199 -0
  23. pcp/commands/deploy_check.py +134 -0
  24. pcp/commands/design_audit.py +323 -0
  25. pcp/commands/diff.py +153 -0
  26. pcp/commands/diff_reduce.py +355 -0
  27. pcp/commands/docs.py +538 -0
  28. pcp/commands/doctor.py +820 -0
  29. pcp/commands/escalations_cmd.py +64 -0
  30. pcp/commands/gate.py +209 -0
  31. pcp/commands/import_project.py +404 -0
  32. pcp/commands/init.py +1634 -0
  33. pcp/commands/install_hook.py +283 -0
  34. pcp/commands/install_skill.py +48 -0
  35. pcp/commands/kickoff.py +772 -0
  36. pcp/commands/narrative_lint.py +54 -0
  37. pcp/commands/objective_conflicts_cmd.py +68 -0
  38. pcp/commands/pm.py +504 -0
  39. pcp/commands/pressure_test_cmd.py +72 -0
  40. pcp/commands/provenance.py +313 -0
  41. pcp/commands/prune.py +179 -0
  42. pcp/commands/report.py +49 -0
  43. pcp/commands/run_log_cmd.py +122 -0
  44. pcp/commands/scan.py +346 -0
  45. pcp/commands/self_update.py +125 -0
  46. pcp/commands/status.py +180 -0
  47. pcp/commands/takeover.py +55 -0
  48. pcp/commands/telemetry_cmd.py +167 -0
  49. pcp/commands/validate_module.py +153 -0
  50. pcp/commands/validate_strategy.py +413 -0
  51. pcp/commands/verify.py +166 -0
  52. pcp/commands/verify_syntax_fix.py +74 -0
  53. pcp/commands/watch.py +372 -0
  54. pcp/config_audit.py +141 -0
  55. pcp/context_map.py +124 -0
  56. pcp/control_audit.py +159 -0
  57. pcp/coupling.py +178 -0
  58. pcp/coverage_audit.py +77 -0
  59. pcp/decision_log.py +134 -0
  60. pcp/discovery/__init__.py +0 -0
  61. pcp/discovery/clusters.py +124 -0
  62. pcp/discovery/graph.py +110 -0
  63. pcp/discovery/scanner.py +109 -0
  64. pcp/escalations.py +193 -0
  65. pcp/evidence.py +30 -0
  66. pcp/evidence_chain.py +56 -0
  67. pcp/impact.py +164 -0
  68. pcp/install_approvals.py +44 -0
  69. pcp/integrity_audit.py +176 -0
  70. pcp/librarian.py +89 -0
  71. pcp/llm/__init__.py +0 -0
  72. pcp/llm/client.py +183 -0
  73. pcp/llm/coding_agent_contract.py +104 -0
  74. pcp/llm/harness/__init__.py +12 -0
  75. pcp/llm/harness/agy.py +121 -0
  76. pcp/llm/harness/agy_coding_loop.py +180 -0
  77. pcp/llm/harness/claude.py +241 -0
  78. pcp/llm/ledger.py +47 -0
  79. pcp/narrative_lint.py +229 -0
  80. pcp/nav_graph.py +226 -0
  81. pcp/objective_conflicts.py +129 -0
  82. pcp/operational.py +70 -0
  83. pcp/orphaned_work.py +262 -0
  84. pcp/pcp_dir.py +35 -0
  85. pcp/pcp_status.py +313 -0
  86. pcp/policy.py +81 -0
  87. pcp/pressure_test.py +196 -0
  88. pcp/qa.py +445 -0
  89. pcp/run_log.py +225 -0
  90. pcp/schema/__init__.py +0 -0
  91. pcp/schema/ci_rules.schema.json +106 -0
  92. pcp/schema/controls.schema.json +39 -0
  93. pcp/schema/module_acceptance.schema.json +144 -0
  94. pcp/schema/module_spec.schema.json +78 -0
  95. pcp/schema/sdlc_phase.schema.json +52 -0
  96. pcp/schema/validator.py +77 -0
  97. pcp/skill_data/pcp/SKILL.md +1897 -0
  98. pcp/spec_write.py +269 -0
  99. pcp/spend.py +77 -0
  100. pcp/symbols.py +86 -0
  101. pcp/telemetry.py +308 -0
  102. pcp/uat.py +271 -0
  103. pcp/version_drift.py +222 -0
  104. program_context_protocol-0.12.4.dist-info/METADATA +123 -0
  105. program_context_protocol-0.12.4.dist-info/RECORD +109 -0
  106. program_context_protocol-0.12.4.dist-info/WHEEL +4 -0
  107. program_context_protocol-0.12.4.dist-info/entry_points.txt +2 -0
  108. program_context_protocol-0.12.4.dist-info/licenses/LICENSE-APACHE +202 -0
  109. program_context_protocol-0.12.4.dist-info/licenses/LICENSE-MIT +21 -0
@@ -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]")