pan-wizard 3.35.0 → 3.37.0

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 (339) hide show
  1. package/CHANGELOG.md +3645 -0
  2. package/README.md +46 -52
  3. package/agents/pan-conductor.md +2 -3
  4. package/agents/pan-counterfactual.md +1 -2
  5. package/agents/pan-debugger.md +14 -3
  6. package/agents/pan-design-checker.md +1 -2
  7. package/agents/pan-designer.md +2 -3
  8. package/agents/pan-document_code.md +1 -2
  9. package/agents/pan-executor.md +25 -0
  10. package/agents/pan-hardener.md +4 -3
  11. package/agents/pan-integration-checker.md +1 -2
  12. package/agents/pan-knowledge.md +1 -2
  13. package/agents/pan-meta-reviewer.md +3 -3
  14. package/agents/pan-optimizer.md +4 -4
  15. package/agents/pan-phase-researcher.md +80 -475
  16. package/agents/pan-plan-checker.md +40 -3
  17. package/agents/pan-planner.md +21 -7
  18. package/agents/pan-previewer.md +3 -2
  19. package/agents/pan-project-researcher.md +72 -493
  20. package/agents/pan-release.md +1 -1
  21. package/agents/pan-reviewer.md +14 -3
  22. package/agents/pan-roadmapper.md +12 -7
  23. package/agents/pan-verifier.md +9 -4
  24. package/bin/install-lib.cjs +134 -14
  25. package/bin/install.js +59 -12
  26. package/commands/pan/army.md +1 -1
  27. package/commands/pan/autodev.md +76 -0
  28. package/commands/pan/cost.md +21 -1
  29. package/commands/pan/exec-phase.md +3 -4
  30. package/commands/pan/focus-auto.md +14 -7
  31. package/commands/pan/focus-design.md +4 -5
  32. package/commands/pan/focus-docs.md +24 -0
  33. package/commands/pan/learn.md +13 -6
  34. package/commands/pan/milestone-audit.md +8 -1
  35. package/commands/pan/milestone-done.md +10 -5
  36. package/commands/pan/milestone-new.md +2 -2
  37. package/commands/pan/new-project.md +15 -12
  38. package/commands/pan/plan-phase.md +15 -9
  39. package/commands/pan/preview.md +1 -1
  40. package/commands/pan/quick.md +1 -1
  41. package/commands/pan/review-deep.md +11 -5
  42. package/commands/pan/settings.md +6 -0
  43. package/commands/pan/verify-phase.md +6 -2
  44. package/docs/AGENTS.md +917 -0
  45. package/docs/ARCHITECTURE.md +1054 -0
  46. package/docs/CLI-REFERENCE.md +4582 -0
  47. package/docs/COMPARISON.md +116 -0
  48. package/docs/DEVELOPMENT.md +490 -0
  49. package/docs/ECOSYSTEM-REVIEW-2026-06.md +107 -0
  50. package/docs/ECOSYSTEM-REVIEW-2026-08.md +349 -0
  51. package/docs/EXAMPLES.md +584 -0
  52. package/docs/FAQ.md +254 -0
  53. package/docs/FIELD-HARVEST-2026-07.md +88 -0
  54. package/docs/FIELD-REPORT-army-2026-06.md +121 -0
  55. package/docs/HOOKS.md +638 -0
  56. package/docs/IMPROVEMENT-TODO.md +246 -0
  57. package/docs/INTERNALS.md +485 -0
  58. package/docs/MIGRATION-v2-to-v3.md +258 -0
  59. package/docs/SECURITY-HARDENING.md +230 -0
  60. package/docs/SKILLS-ENHANCEMENT-PLAN-V2.md +122 -0
  61. package/docs/SKILLS-FULL-TEXT.md +9685 -0
  62. package/docs/SKILLS-REFERENCE.md +1037 -0
  63. package/docs/TROUBLESHOOTING.md +1084 -0
  64. package/docs/USER-GUIDE.md +1437 -0
  65. package/docs/archive/SKILLS-ENHANCEMENT-PLAN.md +230 -0
  66. package/docs/audits/AUDIT-2026-08-e2e-ROADMAP.md +117 -0
  67. package/docs/audits/AUDIT-2026-08-e2e-VERIFICATION.md +693 -0
  68. package/docs/audits/AUDIT-2026-08-e2e.md +1546 -0
  69. package/docs/audits/OUTSTANDING-2026-08.md +275 -0
  70. package/docs/audits/STABILITY-TEST-2026-08.md +640 -0
  71. package/docs/branding/README-brand-draft.md +119 -0
  72. package/docs/branding/handoff/IMPLEMENTATION.md +205 -0
  73. package/docs/branding/handoff/README-brand-draft.md +119 -0
  74. package/docs/branding/handoff/image-prompts.md +46 -0
  75. package/docs/branding/image-prompts.md +46 -0
  76. package/docs/branding/nano-banana-prompts.txt +60 -0
  77. package/docs/context-monitor.md +13 -0
  78. package/docs/decisions/ADR-0001-strategic-feature-prioritization.md +83 -0
  79. package/docs/decisions/ADR-0002-copilot-cli-runtime.md +71 -0
  80. package/docs/decisions/ADR-0003-smart-execution.md +84 -0
  81. package/docs/decisions/ADR-0004-copilot-cli-interaction.md +89 -0
  82. package/docs/decisions/ADR-0005-command-naming.md +80 -0
  83. package/docs/decisions/ADR-0006-focus-commands.md +101 -0
  84. package/docs/decisions/ADR-0007-ship-v1.md +73 -0
  85. package/docs/decisions/ADR-0008-map-codebase-mermaid-togaf.md +80 -0
  86. package/docs/decisions/ADR-0009-production-deployment-checklist.md +68 -0
  87. package/docs/decisions/ADR-0010-standards-integration.md +79 -0
  88. package/docs/decisions/ADR-0011-help-commands-rewrite.md +64 -0
  89. package/docs/decisions/ADR-0012-contributors-attribution-index.md +42 -0
  90. package/docs/decisions/ADR-0013-strategic-feature-additions.md +53 -0
  91. package/docs/decisions/ADR-0014-internal-cleanup-code-quality.md +53 -0
  92. package/docs/decisions/ADR-0015-focus-auto-runner.md +296 -0
  93. package/docs/decisions/ADR-0016-enhanced-doc-sync.md +54 -0
  94. package/docs/decisions/ADR-0017-enhanced-focus-design.md +69 -0
  95. package/docs/decisions/ADR-0018-user-reality-testing.md +84 -0
  96. package/docs/decisions/ADR-0019-end-to-end-user-acceptance-testing.md +113 -0
  97. package/docs/decisions/ADR-0020-ai-drift-prevention.md +55 -0
  98. package/docs/decisions/ADR-0021-codebase-mapper-v2.md +76 -0
  99. package/docs/decisions/ADR-0022-lifecycle-completeness.md +63 -0
  100. package/docs/decisions/ADR-0023-opus-4-7-adoption.md +89 -0
  101. package/docs/decisions/ADR-0024-spec-b-v2-completion.md +137 -0
  102. package/docs/decisions/ADR-0025-behavioral-guardrails-layer.md +129 -0
  103. package/docs/decisions/ADR-0026-self-improvement-loop.md +171 -0
  104. package/docs/decisions/ADR-0027-doc-code-link-graph.md +164 -0
  105. package/docs/decisions/ADR-0028-unified-agents-skills-target.md +46 -0
  106. package/docs/decisions/ADR-0029-experiment-evaluator.md +76 -0
  107. package/docs/decisions/ADR-0030-planning-concurrency.md +37 -0
  108. package/docs/decisions/ADR-0031-focus-auto-backlog-source.md +47 -0
  109. package/docs/decisions/ADR-0032-squad-model.md +64 -0
  110. package/docs/decisions/ADR-0033-army-campaign.md +40 -0
  111. package/docs/decisions/ADR-0034-scheduled-campaigns.md +57 -0
  112. package/docs/decisions/ADR-0035-army-hud-dashboard.md +40 -0
  113. package/docs/decisions/ADR-0036-agentic-memory-architecture.md +106 -0
  114. package/docs/decisions/ADR-0037-pin-security-agents-to-opus.md +51 -0
  115. package/docs/decisions/ADR-0038-skill-aligned-decomposition.md +41 -0
  116. package/docs/decisions/ADR-0039-worktree-as-project-variant.md +37 -0
  117. package/docs/decisions/ADR-0040-memory-injection-defense.md +55 -0
  118. package/docs/decisions/ADR-0041-mcp-dual-era-bridge.md +47 -0
  119. package/docs/decisions/ADR-0042-design-parity-both-flows.md +45 -0
  120. package/docs/decisions/ADR-0043-addressable-planning-roots.md +56 -0
  121. package/docs/decisions/ADR-0044-bound-the-cached-context.md +104 -0
  122. package/docs/decisions/ADR-0045-agent-plugins-bundle.md +76 -0
  123. package/docs/decisions/ADR-0046-subagent-frontmatter-decisions.md +39 -0
  124. package/docs/decisions/ADR-0047-pan-harness.md +76 -0
  125. package/docs/decisions/ADR-0048-one-core-per-project.md +43 -0
  126. package/docs/decisions/ADR-0049-machine-verdicts-and-findings-ledger.md +116 -0
  127. package/docs/specs/ai_drift_prevention_featureai.md +286 -0
  128. package/docs/specs/codebase_mapper_v2_featureai.md +930 -0
  129. package/docs/specs/command_naming_optimization_featureai.md +930 -0
  130. package/docs/specs/comp_analysis_markdown_v1.md +149 -0
  131. package/docs/specs/continuous_autonomous_execution_featureai.md +304 -0
  132. package/docs/specs/contributors_attribution_index_featureai.md +476 -0
  133. package/docs/specs/copilot_cli_interaction_featureai.md +868 -0
  134. package/docs/specs/copilot_cli_runtime_featureai.md +647 -0
  135. package/docs/specs/custom-skills-assessment.md +461 -0
  136. package/docs/specs/deployment_audit_findings_featureai.md +243 -0
  137. package/docs/specs/doc_code_link_graph_featureai.md +253 -0
  138. package/docs/specs/documentation_alignment_cleanup_featureai.md +69 -0
  139. package/docs/specs/e2e_visual_testing_featureai.md +615 -0
  140. package/docs/specs/end_to_end_user_acceptance_testing_featureai.md +810 -0
  141. package/docs/specs/evidence-loop-plan.md +76 -0
  142. package/docs/specs/evidence_loop_featureai.md +497 -0
  143. package/docs/specs/focus-auto-optimize-category.md +449 -0
  144. package/docs/specs/focus-auto-optimize-superplan.md +188 -0
  145. package/docs/specs/focus_auto_runner_featureai.md +1102 -0
  146. package/docs/specs/focus_design_v2_featureai.md +197 -0
  147. package/docs/specs/focus_sync_v2_featureai.md +202 -0
  148. package/docs/specs/git_integration_overhaul_featureai.md +326 -0
  149. package/docs/specs/glm_zai_provider_featureai.md +200 -0
  150. package/docs/specs/googlecli_adoption_featureai.md +661 -0
  151. package/docs/specs/help_commands_rewrite_featureai.md +860 -0
  152. package/docs/specs/industry_analysis_strategic_additions_featureai.md +231 -0
  153. package/docs/specs/industry_comparison_and_feature_gaps_featureai.md +674 -0
  154. package/docs/specs/internal_cleanup_code_quality_featureai.md +486 -0
  155. package/docs/specs/investigations/README.md +31 -0
  156. package/docs/specs/investigations/digests/2026-09-28.md +47 -0
  157. package/docs/specs/investigations/digests/2026-10-08.md +62 -0
  158. package/docs/specs/investigations/digests/2026-10-10.md +61 -0
  159. package/docs/specs/investigations/features/debugger-red-loop-first.md +55 -0
  160. package/docs/specs/investigations/features/harness-cross-host-behaviour.md +93 -0
  161. package/docs/specs/investigations/features/optimize-apply-regression-gate.md +66 -0
  162. package/docs/specs/investigations/features/reviewer-smell-baseline.md +57 -0
  163. package/docs/specs/investigations/features/trace-failure-capture.md +69 -0
  164. package/docs/specs/lifecycle_completeness_featureai.md +754 -0
  165. package/docs/specs/local-first-ci.md +42 -0
  166. package/docs/specs/map_codebase_mermaid_togaf_featureai.md +805 -0
  167. package/docs/specs/market-delta-2026-09-superplan.md +310 -0
  168. package/docs/specs/market-ideas-2026-09.md +131 -0
  169. package/docs/specs/market-ideas-2026-10.md +428 -0
  170. package/docs/specs/market-ideas-ledger.md +177 -0
  171. package/docs/specs/memory-optimization-2026-10.md +285 -0
  172. package/docs/specs/multi-track-planning-roots.md +143 -0
  173. package/docs/specs/multi_model_routing_featureai.md +687 -0
  174. package/docs/specs/multi_model_routing_superplan.md +197 -0
  175. package/docs/specs/opus_47_existing_enhancements_featureai.md +247 -0
  176. package/docs/specs/opus_47_extended_features_featureai.md +363 -0
  177. package/docs/specs/opus_47_extended_features_v2_featureai.md +397 -0
  178. package/docs/specs/pan_focus_commands_featureai.md +2336 -0
  179. package/docs/specs/pan_zcode_mcp_bridge_featureai.md +120 -0
  180. package/docs/specs/product-excellence-superplan.md +315 -0
  181. package/docs/specs/product_excellence_program_2026_q4_featureai.md +365 -0
  182. package/docs/specs/production_deployment_readiness_featureai.md +433 -0
  183. package/docs/specs/production_readiness_ship_v1_featureai.md +698 -0
  184. package/docs/specs/real_test_enforcement_featureai.md +213 -0
  185. package/docs/specs/reality-check-2026-09-superplan.md +362 -0
  186. package/docs/specs/right-sized-ceremony.md +74 -0
  187. package/docs/specs/roadmap_and_competition_2026_q4_featureai.md +390 -0
  188. package/docs/specs/self_improvement_loop_featureai.md +875 -0
  189. package/docs/specs/skill-aligned-decomposition.md +155 -0
  190. package/docs/specs/standards_integration_featureai.md +833 -0
  191. package/docs/specs/testing-system-redesign-2026-09.md +267 -0
  192. package/docs/specs/user_reality_testing_system_featureai.md +788 -0
  193. package/docs/specs/watch-triggers.json +22 -0
  194. package/docs/specs/workflow-integration-redesign.md +742 -0
  195. package/docs/specs/workflow_state_consistency_featureai.md +183 -0
  196. package/hooks/dist/pan-context-monitor.js +9 -2
  197. package/hooks/dist/pan-cost-logger.js +5 -0
  198. package/hooks/dist/pan-guard.js +28 -5
  199. package/hooks/dist/pan-stop-guard.js +9 -9
  200. package/hooks/dist/pan-trace-logger.js +5 -0
  201. package/package.json +10 -2
  202. package/pan-wizard-core/bin/lib/agents-md.cjs +57 -8
  203. package/pan-wizard-core/bin/lib/autodev-report.cjs +180 -0
  204. package/pan-wizard-core/bin/lib/autodev.cjs +642 -0
  205. package/pan-wizard-core/bin/lib/ci.cjs +143 -0
  206. package/pan-wizard-core/bin/lib/commands.cjs +1 -1
  207. package/pan-wizard-core/bin/lib/config.cjs +41 -5
  208. package/pan-wizard-core/bin/lib/constants.cjs +27 -1
  209. package/pan-wizard-core/bin/lib/core.cjs +31 -14
  210. package/pan-wizard-core/bin/lib/cost.cjs +160 -4
  211. package/pan-wizard-core/bin/lib/cross-family.cjs +164 -0
  212. package/pan-wizard-core/bin/lib/debug-cases.cjs +104 -0
  213. package/pan-wizard-core/bin/lib/doc-lint.cjs +5 -3
  214. package/pan-wizard-core/bin/lib/failure-class.cjs +81 -0
  215. package/pan-wizard-core/bin/lib/findings.cjs +148 -9
  216. package/pan-wizard-core/bin/lib/focus.cjs +1 -1
  217. package/pan-wizard-core/bin/lib/git.cjs +1 -1
  218. package/pan-wizard-core/bin/lib/hosts.cjs +88 -0
  219. package/pan-wizard-core/bin/lib/init.cjs +7 -2
  220. package/pan-wizard-core/bin/lib/knowledge.cjs +2 -0
  221. package/pan-wizard-core/bin/lib/merge.cjs +50 -0
  222. package/pan-wizard-core/bin/lib/mutate.cjs +268 -0
  223. package/pan-wizard-core/bin/lib/next-action.cjs +112 -0
  224. package/pan-wizard-core/bin/lib/optimize.cjs +2 -2
  225. package/pan-wizard-core/bin/lib/phase-gate.cjs +15 -9
  226. package/pan-wizard-core/bin/lib/phase-remove.cjs +91 -11
  227. package/pan-wizard-core/bin/lib/phase.cjs +85 -13
  228. package/pan-wizard-core/bin/lib/plan-lint.cjs +310 -0
  229. package/pan-wizard-core/bin/lib/preview.cjs +26 -4
  230. package/pan-wizard-core/bin/lib/requirements-lint.cjs +130 -0
  231. package/pan-wizard-core/bin/lib/research-lint.cjs +268 -0
  232. package/pan-wizard-core/bin/lib/review-deep.cjs +14 -22
  233. package/pan-wizard-core/bin/lib/review-vote.cjs +105 -0
  234. package/pan-wizard-core/bin/lib/roadmap.cjs +1 -0
  235. package/pan-wizard-core/bin/lib/runner.cjs +2 -16
  236. package/pan-wizard-core/bin/lib/sizing.cjs +356 -0
  237. package/pan-wizard-core/bin/lib/squads.cjs +1 -1
  238. package/pan-wizard-core/bin/lib/state-write.cjs +247 -0
  239. package/pan-wizard-core/bin/lib/test-gate.cjs +433 -0
  240. package/pan-wizard-core/bin/lib/uat.cjs +149 -0
  241. package/pan-wizard-core/bin/lib/usage.cjs +104 -0
  242. package/pan-wizard-core/bin/lib/verdict.cjs +26 -3
  243. package/pan-wizard-core/bin/lib/verify-plan-set.cjs +30 -1
  244. package/pan-wizard-core/bin/lib/verify-preflight.cjs +2 -2
  245. package/pan-wizard-core/bin/lib/verify-red.cjs +174 -0
  246. package/pan-wizard-core/bin/lib/verify-retro.cjs +3 -2
  247. package/pan-wizard-core/bin/lib/verify-scope.cjs +143 -2
  248. package/pan-wizard-core/bin/lib/verify.cjs +86 -31
  249. package/pan-wizard-core/bin/lib/worktree.cjs +70 -0
  250. package/pan-wizard-core/bin/pan-tools.cjs +257 -28
  251. package/pan-wizard-core/learnings/universal/autonomous-loop.md +2 -2
  252. package/pan-wizard-core/mcp/orchestrator.cjs +4 -1
  253. package/pan-wizard-core/references/continuation-format.md +3 -3
  254. package/pan-wizard-core/references/design-methodology.md +1 -1
  255. package/pan-wizard-core/references/guardrails.md +1 -1
  256. package/pan-wizard-core/references/handoff-decisions.md +1 -1
  257. package/pan-wizard-core/references/model-profiles.md +2 -3
  258. package/pan-wizard-core/references/questioning.md +24 -0
  259. package/pan-wizard-core/references/ui-brand.md +4 -6
  260. package/pan-wizard-core/references/verdict-contract.md +2 -1
  261. package/pan-wizard-core/templates/config.json +10 -2
  262. package/pan-wizard-core/templates/debug.md +1 -0
  263. package/pan-wizard-core/templates/design.md +5 -4
  264. package/pan-wizard-core/templates/discovery.md +2 -2
  265. package/pan-wizard-core/templates/phase-prompt.md +7 -0
  266. package/pan-wizard-core/templates/requirements.md +9 -0
  267. package/pan-wizard-core/templates/research.md +10 -0
  268. package/pan-wizard-core/templates/summary-complex.md +5 -1
  269. package/pan-wizard-core/templates/summary-minimal.md +4 -0
  270. package/pan-wizard-core/templates/summary-standard.md +5 -1
  271. package/pan-wizard-core/templates/summary.md +8 -1
  272. package/pan-wizard-core/workflows/add-phase.md +4 -6
  273. package/pan-wizard-core/workflows/assumptions.md +19 -1
  274. package/pan-wizard-core/workflows/diagnose-issues.md +2 -0
  275. package/pan-wizard-core/workflows/discuss-phase.md +4 -2
  276. package/pan-wizard-core/workflows/exec-lean.md +69 -0
  277. package/pan-wizard-core/workflows/exec-phase.md +79 -15
  278. package/pan-wizard-core/workflows/execute-plan.md +3 -1
  279. package/{commands/pan → pan-wizard-core/workflows}/focus-doc-audit.md +13 -29
  280. package/{commands/pan → pan-wizard-core/workflows}/focus-drift-walking.md +3 -18
  281. package/{commands/pan → pan-wizard-core/workflows}/focus-exec.md +8 -23
  282. package/{commands/pan → pan-wizard-core/workflows}/focus-plan.md +6 -20
  283. package/{commands/pan → pan-wizard-core/workflows}/focus-scan.md +3 -16
  284. package/{commands/pan → pan-wizard-core/workflows}/focus-sync.md +7 -21
  285. package/pan-wizard-core/workflows/health.md +6 -0
  286. package/pan-wizard-core/workflows/help.md +114 -77
  287. package/pan-wizard-core/workflows/insert-phase.md +4 -6
  288. package/pan-wizard-core/workflows/learn.md +3 -3
  289. package/pan-wizard-core/workflows/milestone-audit.md +2 -2
  290. package/pan-wizard-core/workflows/milestone-new.md +38 -95
  291. package/pan-wizard-core/workflows/new-project.md +96 -258
  292. package/pan-wizard-core/workflows/optimize.md +107 -5
  293. package/pan-wizard-core/workflows/phase-tests.md +10 -0
  294. package/pan-wizard-core/workflows/plan-phase.md +106 -45
  295. package/pan-wizard-core/workflows/profile.md +50 -0
  296. package/pan-wizard-core/workflows/progress.md +10 -0
  297. package/pan-wizard-core/workflows/quick.md +34 -43
  298. package/pan-wizard-core/workflows/remove-phase.md +11 -1
  299. package/pan-wizard-core/workflows/research-phase.md +118 -40
  300. package/pan-wizard-core/workflows/resume-project.md +18 -2
  301. package/pan-wizard-core/workflows/settings.md +33 -19
  302. package/pan-wizard-core/workflows/todo-check.md +6 -2
  303. package/pan-wizard-core/workflows/transition.md +22 -4
  304. package/pan-wizard-core/workflows/uat-walkthrough.md +90 -0
  305. package/pan-wizard-core/workflows/update.md +1 -1
  306. package/pan-wizard-core/workflows/verify-phase.md +39 -46
  307. package/scripts/build-agent-plugin.js +35 -15
  308. package/scripts/build-help.cjs +106 -0
  309. package/scripts/build-plugin.js +10 -1
  310. package/scripts/deprecate-old-versions.js +70 -3
  311. package/scripts/generate-skills-docs.py +1 -1
  312. package/scripts/git-hooks/pre-push +27 -0
  313. package/scripts/install-git-hooks.js +7 -5
  314. package/scripts/install-pre-push.cjs +38 -0
  315. package/scripts/land.cjs +151 -0
  316. package/scripts/local-ci.cjs +501 -0
  317. package/scripts/local-schedule.cjs +277 -0
  318. package/scripts/mutation-probe.cjs +7 -80
  319. package/scripts/prompt-lint.cjs +32 -0
  320. package/scripts/prompt-rules.cjs +156 -0
  321. package/scripts/run-tests.cjs +7 -1
  322. package/scripts/test-quality-lint.cjs +3 -3
  323. package/scripts/test-surface.cjs +175 -6
  324. package/agents/pan-research-synthesizer.md +0 -246
  325. package/commands/pan/audit-deployment.md +0 -386
  326. package/commands/pan/dashboard.md +0 -22
  327. package/commands/pan/discord.md +0 -19
  328. package/commands/pan/mcp-bridge.md +0 -143
  329. package/commands/pan/milestone-cleanup.md +0 -19
  330. package/commands/pan/milestone-gaps.md +0 -35
  331. package/commands/pan/optimize.md +0 -115
  332. package/commands/pan/profile.md +0 -76
  333. package/commands/pan/research-phase.md +0 -189
  334. package/pan-wizard-core/bin/lib/bridge.cjs +0 -269
  335. package/pan-wizard-core/templates/research-project/architecture.md +0 -204
  336. package/pan-wizard-core/templates/research-project/features.md +0 -147
  337. package/pan-wizard-core/templates/research-project/pitfalls.md +0 -200
  338. package/pan-wizard-core/templates/research-project/stack.md +0 -120
  339. package/pan-wizard-core/templates/research-project/summary.md +0 -170
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * land.cjs — land a branch on main without a pull request (`npm run land -- <branch>`;
4
+ * local-first plan, phase 3). GitHub stays the mirror: main is pushed there, never forced.
5
+ *
6
+ * Refuses unless:
7
+ * - a full `npm run ci:local` passed on the branch's current HEAD, from a tree with no
8
+ * uncommitted changes, with no gate skipped (scripts/local-ci.cjs records the verdict in
9
+ * the git directory, pan-ci-local/<sha>.json, shared by every worktree);
10
+ * - the branch contains origin/main, so the squash commit's tree is exactly the tree
11
+ * ci:local passed (merge or rebase main into the branch and run ci:local again if not);
12
+ * - the checkout that has main (or a temporary one) is clean and main can fast-forward
13
+ * to origin/main.
14
+ *
15
+ * Then: fetch origin, fast-forward main to origin/main, `git merge --squash <branch>`, commit
16
+ * with the branch's summary (the title is its first commit's subject, or --title; the body
17
+ * lists every commit), and `git push origin main`. A rejected push leaves the commit on the
18
+ * local main and says so; nothing is forced, and the branch itself is left alone.
19
+ *
20
+ * Usage:
21
+ * node scripts/land.cjs <branch> [--title "..."] [--dry-run] [--no-push] [--allow-skips]
22
+ * [--remote origin] [--main main]
23
+ * Exit 0 landed (or the dry run would land), 1 refused or failed, 2 usage.
24
+ */
25
+
26
+ 'use strict';
27
+
28
+ const { spawnSync } = require('child_process');
29
+ const fs = require('fs');
30
+ const os = require('os');
31
+ const path = require('path');
32
+
33
+ function git(cwd, args, opts = {}) {
34
+ const r = spawnSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, ...opts });
35
+ return { ok: r.status === 0, out: (r.stdout || '').trim(), err: (r.stderr || '').trim() || (r.error ? r.error.message : '') };
36
+ }
37
+
38
+ function parseArgs(argv) {
39
+ const a = { branch: null, title: null, dryRun: false, push: true, allowSkips: false, remote: 'origin', main: 'main' };
40
+ for (let i = 0; i < argv.length; i++) {
41
+ const k = argv[i];
42
+ if (k === '--title') a.title = argv[++i];
43
+ else if (k === '--dry-run') a.dryRun = true;
44
+ else if (k === '--no-push') a.push = false;
45
+ else if (k === '--allow-skips') a.allowSkips = true;
46
+ else if (k === '--remote') a.remote = argv[++i];
47
+ else if (k === '--main') a.main = argv[++i];
48
+ else if (k.startsWith('--')) throw new Error(`unknown option: ${k}`);
49
+ else if (!a.branch) a.branch = k;
50
+ else throw new Error(`one branch at a time (got ${a.branch} and ${k})`);
51
+ }
52
+ if (!a.branch) throw new Error('usage: land.cjs <branch> [--title T] [--dry-run] [--no-push] [--allow-skips]');
53
+ return a;
54
+ }
55
+
56
+ /** Why ci:local's verdict does not clear `sha` for landing, or null when it does. Pure. */
57
+ function verdictProblem(verdict, sha, { allowSkips = false } = {}) {
58
+ if (!verdict) return `no ci:local verdict for ${sha.slice(0, 9)} — run \`npm run ci:local\` on the branch at that commit`;
59
+ if (verdict.headFull !== sha) return `the verdict is for ${String(verdict.headFull).slice(0, 9)}, not ${sha.slice(0, 9)}`;
60
+ if (verdict.options && (verdict.options.only || verdict.options.fast)) return 'the verdict is from a partial run (--only or --fast); run the full ci:local';
61
+ if (verdict.dirtyAtStart !== false) return 'ci:local ran with uncommitted changes, so it did not test this commit alone; commit or stash them and run it again';
62
+ if (verdict.status !== 'pass') return `ci:local failed on ${sha.slice(0, 9)}: ${(verdict.gates || []).filter(g => g.status === 'fail').map(g => g.id).join(', ') || 'see its report'}`;
63
+ const skipped = (verdict.gates || []).filter(g => g.status === 'skip').map(g => g.id);
64
+ if (skipped.length && !allowSkips) return `ci:local skipped ${skipped.join(', ')}; install what they need, or pass --allow-skips`;
65
+ return null;
66
+ }
67
+
68
+ /** The squash commit's message: a title, and the branch's commits as the body. Pure. */
69
+ function commitMessage(branch, subjects, title) {
70
+ const head = title || subjects[0] || `Land ${branch}`;
71
+ const body = subjects.length > 1 || (title && subjects.length) ? `\n\nSquashed from ${branch}:\n${subjects.map(s => `- ${s}`).join('\n')}` : '';
72
+ return `${head}${body}\n`;
73
+ }
74
+
75
+ // The worktree that has `main` checked out, if any.
76
+ function mainCheckout(cwd, main) {
77
+ const list = git(cwd, ['worktree', 'list', '--porcelain']).out;
78
+ for (const block of list.split(/\n\n+/)) {
79
+ const wt = (block.match(/^worktree (.+)$/m) || [])[1];
80
+ if (wt && new RegExp(`^branch refs/heads/${main.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm').test(block)) return path.resolve(wt);
81
+ }
82
+ return null;
83
+ }
84
+
85
+ function land(argv, cwd = process.cwd(), log = console.log) {
86
+ let o;
87
+ try { o = parseArgs(argv); } catch (e) { log(`land: ${e.message}`); return 2; }
88
+ const refuse = (why) => { log(`land: refused — ${why}`); return 1; };
89
+
90
+ const sha = git(cwd, ['rev-parse', '--verify', `refs/heads/${o.branch}^{commit}`]);
91
+ if (!sha.ok) return refuse(`no local branch ${o.branch}`);
92
+ if (o.branch === o.main) return refuse(`${o.branch} is the branch it would land on`);
93
+
94
+ const common = path.resolve(cwd, git(cwd, ['rev-parse', '--git-common-dir']).out);
95
+ let verdict = null;
96
+ try { verdict = JSON.parse(fs.readFileSync(path.join(common, 'pan-ci-local', `${sha.out}.json`), 'utf8')); } catch { /* none */ }
97
+ const problem = verdictProblem(verdict, sha.out, o);
98
+ if (problem) return refuse(problem);
99
+
100
+ const fetched = git(cwd, ['fetch', o.remote, o.main]);
101
+ if (!fetched.ok) return refuse(`git fetch ${o.remote} ${o.main}: ${fetched.err}`);
102
+ const upstream = `refs/remotes/${o.remote}/${o.main}`;
103
+ if (!git(cwd, ['merge-base', '--is-ancestor', upstream, sha.out]).ok) {
104
+ return refuse(`${o.branch} does not contain ${o.remote}/${o.main}; merge or rebase it onto ${o.remote}/${o.main}, then run ci:local again`);
105
+ }
106
+ const subjects = git(cwd, ['log', '--reverse', '--format=%s', `${upstream}..${sha.out}`]).out.split('\n').filter(Boolean);
107
+ if (!subjects.length) return refuse(`${o.branch} has nothing that ${o.remote}/${o.main} lacks`);
108
+ const message = commitMessage(o.branch, subjects, o.title);
109
+
110
+ let where = mainCheckout(cwd, o.main);
111
+ if (where && git(where, ['status', '--porcelain']).out) return refuse(`the checkout with ${o.main} (${where}) has uncommitted changes`);
112
+ if (o.dryRun) {
113
+ log(`land: would squash ${subjects.length} commit(s) of ${o.branch} (${sha.out.slice(0, 9)}) onto ${o.remote}/${o.main} in ${where || 'a temporary worktree'}${o.push ? ` and push ${o.main} to ${o.remote}` : ''}:\n\n${message}`);
114
+ return 0;
115
+ }
116
+
117
+ let temp = null;
118
+ if (!where) {
119
+ temp = fs.mkdtempSync(path.join(os.tmpdir(), 'pan-land-'));
120
+ const hasMain = git(cwd, ['rev-parse', '--verify', `refs/heads/${o.main}`]).ok;
121
+ const add = git(cwd, hasMain ? ['worktree', 'add', temp, o.main] : ['worktree', 'add', '-b', o.main, temp, upstream]);
122
+ if (!add.ok) { fs.rmSync(temp, { recursive: true, force: true }); return refuse(`could not check out ${o.main}: ${add.err}`); }
123
+ where = temp;
124
+ }
125
+ try {
126
+ const ff = git(where, ['merge', '--ff-only', upstream]);
127
+ if (!ff.ok) return refuse(`local ${o.main} cannot fast-forward to ${o.remote}/${o.main} (it has commits ${o.remote} lacks): ${ff.err}`);
128
+ const sq = git(where, ['merge', '--squash', sha.out]);
129
+ if (!sq.ok) { git(where, ['reset', '--merge']); return refuse(`the squash merge failed: ${sq.err}`); }
130
+ // The branch contains main, so the result is the branch's tree: anything else is a bug here.
131
+ const tree = git(where, ['write-tree']).out;
132
+ const want = git(cwd, ['rev-parse', `${sha.out}^{tree}`]).out;
133
+ if (tree !== want) { git(where, ['reset', '--hard', upstream]); return refuse(`the squashed tree ${tree.slice(0, 9)} is not the tree ci:local passed (${want.slice(0, 9)})`); }
134
+ const commit = git(where, ['commit', '--no-verify', '-F', '-'], { input: message });
135
+ if (!commit.ok) { git(where, ['reset', '--merge']); return refuse(`git commit: ${commit.err}`); }
136
+ const landed = git(where, ['rev-parse', 'HEAD']).out;
137
+ log(`land: ${o.branch} squashed onto ${o.main} as ${landed.slice(0, 9)}`);
138
+ if (!o.push) { log(`land: not pushed (--no-push); push with \`git push ${o.remote} ${o.main}\``); return 0; }
139
+ // Never forced: a push the remote rejects (it moved, or branch protection) stops here.
140
+ const push = git(where, ['push', o.remote, `refs/heads/${o.main}:refs/heads/${o.main}`]);
141
+ if (!push.ok) { log(`land: the push to ${o.remote} was rejected; ${landed.slice(0, 9)} is on the local ${o.main} only:\n${push.err}`); return 1; }
142
+ log(`land: pushed ${o.main} to ${o.remote}`);
143
+ return 0;
144
+ } finally {
145
+ if (temp) { git(cwd, ['worktree', 'remove', '--force', temp]); fs.rmSync(temp, { recursive: true, force: true }); }
146
+ }
147
+ }
148
+
149
+ module.exports = { parseArgs, verdictProblem, commitMessage, mainCheckout, land };
150
+
151
+ if (require.main === module) process.exit(land(process.argv.slice(2)));
@@ -0,0 +1,501 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * local-ci.cjs — run the GitHub CI gates on this machine (`npm run ci:local`).
4
+ *
5
+ * Runs in parallel, each gate with its own log and temp directory (whole-suite runs are
6
+ * staged so they do not starve each other: containers first, then the host runs one at a time):
7
+ *
8
+ * host-node<N> the unit + scenario suite on the host, once per Node version fnm
9
+ * has installed (18, 20, 22); without fnm, once on the current Node
10
+ * and the other versions are reported skipped
11
+ * linux-node<N> the suite under Node 18, 20 and 22 in node:<N>-bookworm containers,
12
+ * the repo mounted read-only and copied in (needs Docker)
13
+ * harness the PAN Harness at tier 0 with --no-ledger
14
+ * gitleaks secret scan of the history with .gitleaks.toml
15
+ * audit npm audit --omit=dev --audit-level=high
16
+ * links pan-tools links validate
17
+ * surface scripts/test-surface.cjs --check (the suite checks it too; this
18
+ * names the failure)
19
+ * coverage scripts/coverage-gate.cjs
20
+ * codescan only with --codescan: the CodeQL CLI's security-extended suite when
21
+ * installed (on PATH, or %USERPROFILE%\.codeql-cli\codeql), else Semgrep in
22
+ * Docker; --codescan=codeql or =semgrep picks one
23
+ * clean-tree after every other gate: the working tree is as it was before the
24
+ * run (the local form of CI's `git diff --exit-code`, which tolerates
25
+ * uncommitted work you started with)
26
+ *
27
+ * A tool that is not installed is reported SKIP, never PASS. Any FAIL exits 1;
28
+ * --strict makes a SKIP fail too. The report goes to .ci-local/report.json; a full run
29
+ * (no --only, no --fast) also records its verdict for the commit it ran on in the git
30
+ * directory (pan-ci-local/<sha>.json, shared by every worktree), which scripts/land.cjs
31
+ * requires before it lands a branch.
32
+ *
33
+ * Usage:
34
+ * node scripts/local-ci.cjs every gate
35
+ * node scripts/local-ci.cjs --fast the pre-push subset: host suite on the
36
+ * current Node, harness, gitleaks
37
+ * node scripts/local-ci.cjs --only host,gitleaks gates whose id starts with a listed name
38
+ * node scripts/local-ci.cjs --codescan add code scanning; Semgrep scans the files
39
+ * changed against main, or all with
40
+ * --codescan-all; CodeQL always scans the tree
41
+ * node scripts/local-ci.cjs --out <dir> report, logs and lock there, not .ci-local/
42
+ * node scripts/local-ci.cjs --no-docker skip the container gates
43
+ * node scripts/local-ci.cjs --jobs 4 at most 4 light gates at once (default 6)
44
+ * node scripts/local-ci.cjs --root <dir> run against another checkout
45
+ * node scripts/local-ci.cjs --strict a skipped gate fails the run
46
+ */
47
+
48
+ 'use strict';
49
+
50
+ const { spawn, spawnSync } = require('child_process');
51
+ const fs = require('fs');
52
+ const os = require('os');
53
+ const path = require('path');
54
+ const crypto = require('crypto');
55
+
56
+ const NODE_VERSIONS = ['18', '20', '22'];
57
+ // Pinned like the CI actions are: a moving tag would change the scan under you.
58
+ const SEMGREP_IMAGE = process.env.PAN_CI_SEMGREP_IMAGE || 'semgrep/semgrep:1.90.0';
59
+ const CODE_EXT = /\.(c?js|mjs)$/;
60
+ const GATE_TIMEOUT_MS = 30 * 60 * 1000;
61
+ const FAST_IDS = ['host-current', 'harness', 'gitleaks'];
62
+ // Measured on a 40-core Windows machine: Node's default (a file per core but one) made the
63
+ // Node 22 suite's wall-clock checks miss their bounds even with nothing else running.
64
+ // PAN_TEST_CONCURRENCY overrides it.
65
+ const HOST_TEST_CONCURRENCY = Math.max(2, Math.min(16, Math.floor(os.cpus().length / 2)));
66
+
67
+ function parseArgs(argv) {
68
+ const a = { fast: false, only: null, codescan: null, codescanAll: false, docker: true, jobs: 6, root: path.resolve(__dirname, '..'), out: null, strict: false, json: false, help: false };
69
+ for (let i = 0; i < argv.length; i++) {
70
+ const k = argv[i];
71
+ if (k === '--fast') a.fast = true;
72
+ else if (k === '--only') a.only = String(argv[++i] || '').split(',').map(s => s.trim()).filter(Boolean);
73
+ else if (k === '--codescan') a.codescan = 'auto';
74
+ else if (k.startsWith('--codescan=')) a.codescan = k.slice('--codescan='.length);
75
+ else if (k === '--codescan-all') { a.codescanAll = true; a.codescan = a.codescan || 'auto'; }
76
+ else if (k === '--no-docker') a.docker = false;
77
+ else if (k === '--jobs') a.jobs = Math.max(1, Number(argv[++i]) || 1);
78
+ else if (k === '--root') a.root = path.resolve(argv[++i] || '.');
79
+ else if (k === '--out') a.out = path.resolve(argv[++i] || '.ci-local');
80
+ else if (k === '--strict') a.strict = true;
81
+ else if (k === '--json') a.json = true;
82
+ else if (k === '--help' || k === '-h') a.help = true;
83
+ else throw new Error(`unknown option: ${k}`);
84
+ }
85
+ if (a.codescan && !['auto', 'semgrep', 'codeql'].includes(a.codescan)) throw new Error(`--codescan takes semgrep or codeql, got ${a.codescan}`);
86
+ return a;
87
+ }
88
+
89
+ // PATH lookup without a shell, honouring PATHEXT on Windows. Returns the full path or null.
90
+ function which(cmd, env = process.env) {
91
+ const dirs = String(env.PATH || env.Path || '').split(path.delimiter).filter(Boolean);
92
+ const exts = process.platform === 'win32' ? ['', ...String(env.PATHEXT || '.EXE;.CMD;.BAT').toLowerCase().split(';')] : [''];
93
+ for (const d of dirs) {
94
+ for (const e of exts) {
95
+ const p = path.join(d, cmd + e);
96
+ try { if (fs.statSync(p).isFile()) return p; } catch { /* next */ }
97
+ }
98
+ }
99
+ return null;
100
+ }
101
+
102
+ // The CodeQL CLI: on PATH, else where docs/DEVELOPMENT.md unpacks the codeql-bundle,
103
+ // %USERPROFILE%\.codeql-cli\codeql (not under AppData, which a packaged app such as the
104
+ // Claude desktop app sees through a private, virtualised copy).
105
+ function findCodeql(env = process.env) {
106
+ const onPath = which('codeql', env);
107
+ if (onPath) return onPath;
108
+ const home = env.USERPROFILE || env.HOME;
109
+ const base = home ? path.join(home, '.codeql-cli', 'codeql') : null;
110
+ for (const f of base ? ['codeql.exe', 'codeql'] : []) {
111
+ if (fs.existsSync(path.join(base, f))) return path.join(base, f);
112
+ }
113
+ return null;
114
+ }
115
+
116
+ // What fails a CodeQL run, as GitHub's code-scanning check does by default: a result at
117
+ // error level, or one whose rule has a security severity of high or critical (7.0+), unless
118
+ // it was accepted on review (`accepted`: [{rule, path, reason}], from .codeql-accepted.json —
119
+ // the alerts dismissed on GitHub, matched by rule and file since lines move).
120
+ function judgeSarif(doc, accepted = []) {
121
+ const severity = new Map();
122
+ const results = [];
123
+ for (const run of doc.runs || []) {
124
+ const rules = [...((run.tool && run.tool.driver && run.tool.driver.rules) || []), ...((run.tool && run.tool.extensions) || []).flatMap(e => e.rules || [])];
125
+ for (const r of rules) severity.set(r.id, Number((r.properties || {})['security-severity']) || 0);
126
+ results.push(...(run.results || []));
127
+ }
128
+ const where = (r) => (((r.locations || [])[0] || {}).physicalLocation || {});
129
+ const file = (r) => String((where(r).artifactLocation || {}).uri || '').replace(/\\/g, '/');
130
+ const isAccepted = (r) => accepted.some(a => a.rule === r.ruleId && a.path === file(r));
131
+ const serious = results.filter(r => r.level === 'error' || (severity.get(r.ruleId) || 0) >= 7);
132
+ const blocking = serious.filter(r => !isAccepted(r));
133
+ const stale = accepted.filter(a => !results.some(r => a.rule === r.ruleId && a.path === file(r)));
134
+ const shown = blocking.slice(0, 3).map(r => `${r.ruleId} ${file(r)}:${(where(r).region || {}).startLine || '?'}`);
135
+ return {
136
+ status: blocking.length ? 'fail' : 'pass',
137
+ detail: `${results.length} result(s), ${blocking.length} blocking (error level or security severity high+), ${serious.length - blocking.length} accepted${stale.length ? `, ${stale.length} stale acceptance(s)` : ''}${shown.length ? `: ${shown.join('; ')}` : ''}`,
138
+ blocking: blocking.map(r => ({ rule: r.ruleId, path: file(r), line: (where(r).region || {}).startLine })),
139
+ stale,
140
+ };
141
+ }
142
+
143
+ function readAccepted(root) {
144
+ try {
145
+ return (JSON.parse(fs.readFileSync(path.join(root, '.codeql-accepted.json'), 'utf8')).accepted || []).filter(a => a.rule && a.path && a.reason);
146
+ } catch { return []; }
147
+ }
148
+
149
+ function quick(cmd, args, opts = {}) {
150
+ const r = spawnSync(cmd, args, { encoding: 'utf8', timeout: 60000, windowsHide: true, ...opts });
151
+ return { status: r.error ? null : r.status, stdout: r.stdout || '', stderr: r.stderr || '' };
152
+ }
153
+
154
+ // Majors fnm has installed, e.g. ['18', '22']. `fnm list` prints "* v18.20.4 default".
155
+ function fnmMajors(fnm, env) {
156
+ const r = quick(fnm, ['list'], { env });
157
+ if (r.status !== 0) return [];
158
+ return [...new Set([...r.stdout.matchAll(/v(\d+)\.\d+\.\d+/g)].map(m => m[1]))];
159
+ }
160
+
161
+ function dockerUp(docker, env) {
162
+ return !!docker && quick(docker, ['info', '--format', '{{.ServerVersion}}'], { env }).status === 0;
163
+ }
164
+
165
+ // Fingerprint of the working tree: tracked changes plus untracked, non-ignored files.
166
+ function treeState(root, env) {
167
+ const st = quick('git', ['status', '--porcelain=v1', '--untracked-files=all'], { cwd: root, env, maxBuffer: 64 * 1024 * 1024 });
168
+ if (st.status !== 0) return null;
169
+ const diff = quick('git', ['diff', 'HEAD', '--binary'], { cwd: root, env, maxBuffer: 256 * 1024 * 1024 });
170
+ return { lines: st.stdout.split('\n').filter(Boolean), hash: crypto.createHash('sha256').update(st.stdout + '\0' + diff.stdout).digest('hex') };
171
+ }
172
+
173
+ function changedCodeFiles(root, env) {
174
+ const base = quick('git', ['merge-base', 'HEAD', 'main'], { cwd: root, env }).stdout.trim();
175
+ const tracked = base ? quick('git', ['diff', '--name-only', '--diff-filter=ACMR', base], { cwd: root, env }).stdout : '';
176
+ const untracked = quick('git', ['ls-files', '--others', '--exclude-standard'], { cwd: root, env }).stdout;
177
+ return [...new Set((tracked + '\n' + untracked).split('\n').map(s => s.trim()).filter(f => CODE_EXT.test(f) && fs.existsSync(path.join(root, f))))];
178
+ }
179
+
180
+ // Every gate is { id, title, cmd, args, opts, skip?, interpret? }. A gate with `skip` set is
181
+ // reported SKIP with that reason and never runs. `interpret(result)` may turn an exit-0 run
182
+ // into a skip (coverage on old Node) or add a detail line.
183
+ function planGates(o, env = process.env) {
184
+ const root = o.root;
185
+ const node = process.execPath;
186
+ const cur = process.versions.node.split('.')[0];
187
+ const suite = ['scripts/run-tests.cjs', 'tests', 'tests/scenarios'];
188
+ const gates = [];
189
+ const want = (id) => {
190
+ if (o.fast) return FAST_IDS.some(f => id === f || (f === 'host-current' && id === `host-node${cur}`));
191
+ if (!o.only) return true;
192
+ return o.only.some(p => id.startsWith(p));
193
+ };
194
+ const shell = process.platform === 'win32';
195
+
196
+ // Host suite, per Node version.
197
+ const fnm = which('fnm', env);
198
+ const majors = fnm ? fnmMajors(fnm, env) : [];
199
+ if (o.fast || !fnm) {
200
+ gates.push({ id: `host-node${cur}`, title: `${process.platform} suite · node ${process.versions.node} (current${fnm ? '' : '; fnm not installed'})`, cmd: node, args: suite });
201
+ if (!o.fast) for (const v of NODE_VERSIONS) {
202
+ if (v !== cur) gates.push({ id: `host-node${v}`, title: `${process.platform} suite · node ${v}`, skip: 'fnm not installed — winget install Schniz.fnm, then fnm install ' + v });
203
+ }
204
+ } else {
205
+ for (const v of NODE_VERSIONS) {
206
+ gates.push(majors.includes(v)
207
+ ? { id: `host-node${v}`, title: `${process.platform} suite · node ${v} (fnm)`, cmd: fnm, args: ['exec', `--using=${v}`, 'node', ...suite] }
208
+ : { id: `host-node${v}`, title: `${process.platform} suite · node ${v}`, skip: `fnm has no Node ${v} — fnm install ${v}` });
209
+ }
210
+ }
211
+
212
+ // Linux suite in containers.
213
+ const docker = which('docker', env);
214
+ const haveDocker = o.docker && !o.fast && NODE_VERSIONS.some(v => want(`linux-node${v}`)) && dockerUp(docker, env);
215
+ for (const v of NODE_VERSIONS) {
216
+ const id = `linux-node${v}`;
217
+ if (!o.docker) { gates.push({ id, title: `linux suite · node ${v}`, skip: '--no-docker' }); continue; }
218
+ if (!haveDocker) { gates.push({ id, title: `linux suite · node ${v}`, skip: docker ? 'Docker is not running' : 'Docker not installed' }); continue; }
219
+ // Copy without node_modules and .git (a worktree's .git is a Windows path), give the copy
220
+ // its own one-commit repo so git-reading tests see a checkout, then do what ci.yml does.
221
+ // As the image's unprivileged `node` user, like a CI runner: as root, the tests that make
222
+ // a file read-only to force a failed write see the write succeed.
223
+ const script = [
224
+ 'set -e',
225
+ 'mkdir -p ~/work',
226
+ 'tar -C /src --exclude=./node_modules --exclude=./.git --exclude=./.ci-local --exclude=./hooks/dist -cf - . | tar -C ~/work -xf -',
227
+ 'cd ~/work',
228
+ 'git init -q && git add -A && git -c user.email=ci@local -c user.name=local-ci commit -qm snapshot',
229
+ 'npm ci --no-audit --no-fund',
230
+ 'npm run build:hooks',
231
+ 'npm run test:all',
232
+ ].join(' && ').replace('set -e && ', 'set -e; ');
233
+ gates.push({
234
+ id, title: `linux suite · node ${v} (node:${v}-bookworm)`, cmd: docker,
235
+ args: ['run', '--rm', '-u', 'node', '-e', 'HOME=/home/node', '-v', `${root}:/src:ro`, `node:${v}-bookworm`, 'bash', '-c', script],
236
+ });
237
+ }
238
+
239
+ // The double-backed scenarios drive the real Claude Code CLI against the harness's model
240
+ // double (no key, no spend). --require-run makes a missing CLI a failure, not a skip, as
241
+ // the GitHub harness job did before this gate replaced it.
242
+ gates.push({
243
+ id: 'harness', title: 'PAN Harness · tier 0', cmd: node,
244
+ args: ['harness/src/run.cjs', '--tier', '0', '--no-ledger', '--require-run', 'double-executor-body-claude', '--require-run', 'cassette-drift-claude', '--require-run', 'discuss-phase-scripted-user'],
245
+ });
246
+
247
+ const gitleaks = which('gitleaks', env);
248
+ const leakArgs = ['detect', '--no-banner', '--redact', '--verbose', '--source', '.'];
249
+ if (fs.existsSync(path.join(root, '.gitleaks.toml'))) leakArgs.push('--config', '.gitleaks.toml');
250
+ gates.push(gitleaks
251
+ ? { id: 'gitleaks', title: 'gitleaks · history', cmd: gitleaks, args: leakArgs }
252
+ : { id: 'gitleaks', title: 'gitleaks · history', skip: 'gitleaks not installed — winget install gitleaks.gitleaks' });
253
+
254
+ gates.push({ id: 'audit', title: 'npm audit · production, high+', cmd: shell ? 'npm audit --omit=dev --audit-level=high' : 'npm', args: shell ? [] : ['audit', '--omit=dev', '--audit-level=high'], opts: { shell } });
255
+ gates.push({ id: 'links', title: 'links validate', cmd: node, args: ['pan-wizard-core/bin/pan-tools.cjs', 'links', 'validate', '--raw'] });
256
+ gates.push({ id: 'surface', title: 'surface registry', cmd: node, args: ['scripts/test-surface.cjs', '--check'] });
257
+ gates.push({
258
+ id: 'coverage', title: 'coverage gate', cmd: node, args: ['scripts/coverage-gate.cjs'],
259
+ interpret: (r) => /coverage gate — skipped/.test(r.output) ? { status: 'skip', detail: 'needs Node 22+' } : null,
260
+ });
261
+
262
+ const codeql = o.codescan ? findCodeql(env) : null;
263
+ const tool = o.codescan === 'auto' ? (codeql ? 'codeql' : 'semgrep') : o.codescan;
264
+ if (tool === 'semgrep') {
265
+ const files = o.codescanAll ? [] : changedCodeFiles(root, env);
266
+ const base = { id: 'codescan', title: `semgrep · ${o.codescanAll ? 'whole repo' : 'changed files'}` };
267
+ if (!dockerUp(docker, env)) gates.push({ ...base, skip: docker ? 'Docker is not running' : 'Docker not installed' });
268
+ else if (!o.codescanAll && files.length === 0) gates.push({ ...base, skip: 'no changed JS files against main' });
269
+ else gates.push({
270
+ ...base, cmd: docker,
271
+ args: ['run', '--rm', '-v', `${root}:/src:ro`, '-w', '/src', '-e', 'HOME=/tmp', SEMGREP_IMAGE,
272
+ 'semgrep', 'scan', '--config', 'p/javascript', '--config', 'p/nodejs', '--error', '--metrics=off', '--disable-version-check',
273
+ ...(o.codescanAll ? ['.'] : files.map(f => f.replace(/\\/g, '/')))],
274
+ });
275
+ } else if (tool === 'codeql') {
276
+ const base = { id: 'codescan', title: 'CodeQL · security-extended · whole tree' };
277
+ if (!codeql) gates.push({ ...base, skip: 'codeql CLI not installed — the codeql-bundle from github.com/github/codeql-action/releases, unpacked to %USERPROFILE%\\.codeql-cli' });
278
+ else {
279
+ const work = fs.mkdtempSync(path.join(os.tmpdir(), 'pan-local-ci-codeql-'));
280
+ const db = path.join(work, 'db');
281
+ const config = path.join(work, 'config.yml');
282
+ // Build output and this script's own output are copies of, or reports on, the source.
283
+ fs.writeFileSync(config, 'paths-ignore:\n - node_modules\n - dist\n - hooks/dist\n - .ci-local\n');
284
+ const sarif = path.join(o.out || path.join(root, '.ci-local'), 'codeql.sarif');
285
+ gates.push({
286
+ ...base, cmd: codeql,
287
+ args: ['database', 'create', db, '--language=javascript', `--source-root=${root}`, `--codescanning-config=${config}`, '--overwrite'],
288
+ then: { cmd: codeql, args: ['database', 'analyze', db, 'codeql/javascript-queries:codeql-suites/javascript-security-extended.qls', '--format=sarif-latest', `--output=${sarif}`, '--download'] },
289
+ interpret: () => {
290
+ let doc;
291
+ try { doc = JSON.parse(fs.readFileSync(sarif, 'utf8')); } catch { return { status: 'fail', detail: 'no SARIF written' }; }
292
+ try { fs.rmSync(work, { recursive: true, force: true }); } catch { /* the database is large but disposable */ }
293
+ const v = judgeSarif(doc, readAccepted(root));
294
+ return { status: v.status, detail: `${v.detail} — ${path.relative(root, sarif)}` };
295
+ },
296
+ });
297
+ }
298
+ }
299
+
300
+ return gates.filter(g => want(g.id));
301
+ }
302
+
303
+ function fmtMs(ms) {
304
+ const s = Math.round(ms / 1000);
305
+ return s >= 60 ? `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s` : `${s}s`;
306
+ }
307
+
308
+ function runOne(cmd, args, opts, logStream) {
309
+ return new Promise((resolve) => {
310
+ let child;
311
+ try {
312
+ child = spawn(cmd, args, { ...opts, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
313
+ } catch (e) {
314
+ logStream.write(`spawn failed: ${e.message}\n`);
315
+ return resolve({ code: null, error: e.message });
316
+ }
317
+ let tail = '';
318
+ const keep = (d) => { logStream.write(d); tail = (tail + d.toString()).slice(-16000); };
319
+ child.stdout.on('data', keep);
320
+ child.stderr.on('data', keep);
321
+ const timer = setTimeout(() => { keep(`\n[local-ci] timed out after ${fmtMs(GATE_TIMEOUT_MS)}\n`); child.kill(); }, GATE_TIMEOUT_MS);
322
+ child.on('error', (e) => { clearTimeout(timer); keep(`\n[local-ci] ${e.message}\n`); resolve({ code: null, error: e.message, tail }); });
323
+ child.on('close', (code) => { clearTimeout(timer); resolve({ code, tail }); });
324
+ });
325
+ }
326
+
327
+ // Last line that says something, for the one-line detail of a failed gate.
328
+ function lastSignal(text) {
329
+ const lines = String(text).split(/\r?\n/).map(l => l.trim()).filter(Boolean);
330
+ const hit = [...lines].reverse().find(l => /(^ℹ fail|not ok|error|fail|leaks found|stale|drift|vulnerabilit)/i.test(l));
331
+ return (hit || lines[lines.length - 1] || '').slice(0, 160);
332
+ }
333
+
334
+ async function runGate(g, o, env, logDir) {
335
+ const started = Date.now();
336
+ if (g.skip) return { id: g.id, title: g.title, status: 'skip', detail: g.skip, durationMs: 0 };
337
+ const log = path.join(logDir, `${g.id}.log`);
338
+ const stream = fs.createWriteStream(log);
339
+ // Its own temp directory: tests that use a fixed name under os.tmpdir() must not meet
340
+ // another gate's run of the same test.
341
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), `pan-lci-${g.id}-`));
342
+ // The host suite runs at most HOST_TEST_CONCURRENCY files at once (scripts/run-tests.cjs).
343
+ const cap = /^host-/.test(g.id) ? { PAN_TEST_CONCURRENCY: env.PAN_TEST_CONCURRENCY || String(HOST_TEST_CONCURRENCY) } : {};
344
+ const opts = { cwd: o.root, env: { ...env, ...cap, TEMP: tmp, TMP: tmp, TMPDIR: tmp }, ...(g.opts || {}) };
345
+ stream.write(`$ ${[g.cmd, ...g.args].join(' ')}\n\n`);
346
+ let r = await runOne(g.cmd, g.args, opts, stream);
347
+ if (r.code === 0 && g.then) {
348
+ stream.write(`\n$ ${[g.then.cmd, ...g.then.args].join(' ')}\n\n`);
349
+ r = await runOne(g.then.cmd, g.then.args, opts, stream);
350
+ }
351
+ await new Promise(res => stream.end(res));
352
+ // Kept on failure: a gate's own logs (the coverage gate's spec log) may live there.
353
+ if (r.code === 0) try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* best effort: a child may still hold a file */ }
354
+ let output = r.tail || '';
355
+ try { output = fs.readFileSync(log, 'utf8'); } catch { /* the tail is enough for a verdict */ }
356
+ let status = r.code === 0 ? 'pass' : 'fail';
357
+ let detail = r.code === 0 ? '' : (r.error ? r.error : `exit ${r.code} — ${lastSignal(r.tail || output)}`);
358
+ if (g.interpret && (r.code === 0 || g.then)) {
359
+ const v = g.interpret({ output, code: r.code });
360
+ if (v) { status = v.status; detail = v.detail; }
361
+ }
362
+ return { id: g.id, title: g.title, status, detail, durationMs: Date.now() - started, command: [g.cmd, ...g.args].join(' '), log: path.relative(o.root, log).replace(/\\/g, '/') };
363
+ }
364
+
365
+ async function pool(items, n, fn) {
366
+ const out = new Array(items.length);
367
+ let next = 0;
368
+ await Promise.all(Array.from({ length: Math.min(n, items.length) }, async () => {
369
+ while (next < items.length) { const i = next++; out[i] = await fn(items[i]); }
370
+ }));
371
+ return out;
372
+ }
373
+
374
+ function summary(report, out = '.ci-local') {
375
+ const mark = { pass: 'PASS', fail: 'FAIL', skip: 'SKIP' };
376
+ const w = Math.max(...report.gates.map(g => g.id.length), 8);
377
+ const lines = [`local CI — ${report.head || 'no git'} · node ${report.node} · ${process.platform}`, ''];
378
+ for (const g of report.gates) {
379
+ lines.push(` ${mark[g.status]} ${g.id.padEnd(w)} ${(g.status === 'skip' ? '' : fmtMs(g.durationMs)).padStart(6)} ${g.detail || g.title}`);
380
+ }
381
+ const c = report.counts;
382
+ lines.push('', `${report.status === 'pass' ? 'PASSED' : 'FAILED'} — ${c.pass} passed, ${c.fail} failed, ${c.skip} skipped in ${fmtMs(report.durationMs)}${c.skip && report.status === 'pass' ? ' (skipped gates did not run: they are not a pass)' : ''}`);
383
+ lines.push(`report: ${path.join(out, 'report.json')} logs: ${path.join(out, 'logs')}`);
384
+ return lines.join('\n');
385
+ }
386
+
387
+ async function main(argv, env = process.env) {
388
+ let o;
389
+ try { o = parseArgs(argv); } catch (e) { console.error(`local-ci: ${e.message}`); return 2; }
390
+ if (o.help) { console.log(fs.readFileSync(__filename, 'utf8').split('*/')[0].replace(/^#!.*\n\/\*\*?/, '').replace(/^ \* ?/gm, '')); return 0; }
391
+ // Run from inside a node --test process, a gate's own `node --test` would see the parent's
392
+ // context and report to it instead of running its files: the gates start from a clean slate.
393
+ env = { ...env };
394
+ delete env.NODE_TEST_CONTEXT;
395
+
396
+ const outDir = o.out || path.join(o.root, '.ci-local');
397
+ const logDir = path.join(outDir, 'logs');
398
+ // One run per checkout: a second one would wipe the logs the first is writing.
399
+ const lock = path.join(outDir, 'run.lock');
400
+ fs.mkdirSync(outDir, { recursive: true });
401
+ try {
402
+ fs.writeFileSync(lock, String(process.pid), { flag: 'wx' });
403
+ } catch {
404
+ const pid = Number(fs.readFileSync(lock, 'utf8'));
405
+ let alive = false;
406
+ try { process.kill(pid, 0); alive = true; } catch { /* stale lock */ }
407
+ if (alive && pid !== process.pid) { console.error(`local-ci: another run (pid ${pid}) is using ${path.relative(o.root, outDir)} — wait for it, or delete ${path.relative(o.root, lock)} if it is gone`); return 2; }
408
+ fs.writeFileSync(lock, String(process.pid));
409
+ }
410
+ try {
411
+ return await runAll(o, env, outDir, logDir);
412
+ } finally {
413
+ fs.rmSync(lock, { force: true });
414
+ }
415
+ }
416
+
417
+ async function runAll(o, env, outDir, logDir) {
418
+ fs.rmSync(logDir, { recursive: true, force: true });
419
+ fs.mkdirSync(logDir, { recursive: true });
420
+
421
+ const started = Date.now();
422
+ const before = treeState(o.root, env);
423
+ const gates = planGates(o, env);
424
+ const wantsClean = !o.only || o.only.some(p => 'clean-tree'.startsWith(p));
425
+ if (gates.length === 0 && !wantsClean) { console.error('local-ci: no gate matches --only'); return 2; }
426
+
427
+ // The suite and the harness read hooks/dist (gitignored), as CI's build:hooks step provides.
428
+ const needsHooks = gates.some(g => !g.skip && /^(host-|harness|coverage)/.test(g.id));
429
+ const results = [];
430
+ if (needsHooks && fs.existsSync(path.join(o.root, 'scripts', 'build-hooks.js'))) {
431
+ const b = await runGate({ id: 'build-hooks', title: 'build:hooks', cmd: process.execPath, args: ['scripts/build-hooks.js'] }, o, env, logDir);
432
+ if (b.status === 'fail') results.push(b);
433
+ }
434
+ if (!o.json) process.stderr.write(`local-ci: ${gates.filter(g => !g.skip).length} gate(s), ${o.jobs} at a time — logs in ${path.join(path.relative(o.root, outDir) || '.', 'logs')}\n`);
435
+ // Whole-suite runs are heavy. Measured here: a host suite run beside another host suite, or
436
+ // beside the three containers, times its 30-second setup hooks out; beside the harness alone
437
+ // it is fine. So the containers go first, together, and the host suite runs (one per Node
438
+ // version, then the coverage gate's) follow one at a time. The harness and the light gates
439
+ // run beside all of it.
440
+ const one = async (g) => {
441
+ const r = await runGate(g, o, env, logDir);
442
+ if (!o.json && r.status !== 'skip') process.stderr.write(` ${r.status === 'pass' ? 'pass' : r.status} ${r.id} (${fmtMs(r.durationMs)})\n`);
443
+ return r;
444
+ };
445
+ const byGate = new Map();
446
+ const containers = gates.filter(g => !g.skip && g.id.startsWith('linux-'));
447
+ const lane = gates.filter(g => !g.skip && (g.id.startsWith('host-') || g.id === 'coverage'));
448
+ const heavy = async () => {
449
+ await Promise.all(containers.map(async g => byGate.set(g, await one(g))));
450
+ for (const g of lane) byGate.set(g, await one(g));
451
+ };
452
+ const rest = gates.filter(g => !containers.includes(g) && !lane.includes(g));
453
+ await Promise.all([heavy(), pool(rest, Math.max(1, o.jobs - 1), async g => byGate.set(g, await one(g)))]);
454
+ results.push(...gates.map(g => byGate.get(g)));
455
+
456
+ if (wantsClean) {
457
+ const after = treeState(o.root, env);
458
+ if (!before || !after) results.push({ id: 'clean-tree', title: 'working tree unchanged', status: 'skip', detail: 'not a git checkout', durationMs: 0 });
459
+ else {
460
+ const changed = [...new Set([...after.lines.filter(l => !before.lines.includes(l)), ...before.lines.filter(l => !after.lines.includes(l))])];
461
+ const ok = before.hash === after.hash;
462
+ results.push({ id: 'clean-tree', title: 'working tree unchanged', status: ok ? 'pass' : 'fail', durationMs: 0,
463
+ detail: ok ? (before.lines.length ? `unchanged (${before.lines.length} path(s) were already modified before the run)` : 'clean') : `the run changed the tree: ${changed.slice(0, 5).join('; ') || 'content of already-modified files'}` });
464
+ }
465
+ }
466
+
467
+ const counts = { pass: 0, fail: 0, skip: 0 };
468
+ for (const r of results) counts[r.status]++;
469
+ const failed = counts.fail > 0 || (o.strict && counts.skip > 0);
470
+ const headFull = quick('git', ['rev-parse', 'HEAD'], { cwd: o.root, env }).stdout.trim();
471
+ const report = {
472
+ status: failed ? 'fail' : 'pass', startedAt: new Date(started).toISOString(), durationMs: Date.now() - started,
473
+ root: o.root, head: headFull.slice(0, 7), headFull, node: process.versions.node, platform: process.platform,
474
+ // Uncommitted work at the start means the gates ran on something other than HEAD.
475
+ dirtyAtStart: before ? before.lines.length > 0 : null,
476
+ options: { fast: o.fast, only: o.only, codescan: o.codescan, docker: o.docker, strict: o.strict, jobs: o.jobs },
477
+ counts, gates: results,
478
+ };
479
+ fs.writeFileSync(path.join(outDir, 'report.json'), JSON.stringify(report, null, 2) + '\n');
480
+ const recorded = o.only || o.fast || !headFull ? null : recordVerdict(o.root, report, env);
481
+ console.log(o.json ? JSON.stringify(report, null, 2) : summary(report, path.relative(o.root, outDir) || '.') + (recorded ? `\nverdict for ${report.head} recorded for land: ${recorded}` : ''));
482
+ return failed ? 1 : 0;
483
+ }
484
+
485
+ // Where land.cjs looks for a commit's verdict: the git directory every worktree shares.
486
+ function verdictFile(root, sha, env = process.env) {
487
+ const common = quick('git', ['rev-parse', '--git-common-dir'], { cwd: root, env }).stdout.trim();
488
+ return common ? path.join(path.resolve(root, common), 'pan-ci-local', `${sha}.json`) : null;
489
+ }
490
+
491
+ function recordVerdict(root, report, env) {
492
+ const file = verdictFile(root, report.headFull, env);
493
+ if (!file) return null;
494
+ fs.mkdirSync(path.dirname(file), { recursive: true });
495
+ fs.writeFileSync(file, JSON.stringify(report, null, 2) + '\n');
496
+ return file;
497
+ }
498
+
499
+ module.exports = { parseArgs, planGates, which, findCodeql, judgeSarif, treeState, lastSignal, verdictFile, main };
500
+
501
+ if (require.main === module) main(process.argv.slice(2)).then(code => process.exit(code));