pan-wizard 3.36.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 (314) hide show
  1. package/CHANGELOG.md +3645 -0
  2. package/README.md +36 -48
  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 +1 -2
  8. package/agents/pan-document_code.md +1 -2
  9. package/agents/pan-hardener.md +2 -3
  10. package/agents/pan-integration-checker.md +1 -2
  11. package/agents/pan-knowledge.md +1 -2
  12. package/agents/pan-meta-reviewer.md +3 -3
  13. package/agents/pan-optimizer.md +4 -4
  14. package/agents/pan-phase-researcher.md +79 -488
  15. package/agents/pan-plan-checker.md +12 -2
  16. package/agents/pan-planner.md +10 -6
  17. package/agents/pan-previewer.md +1 -2
  18. package/agents/pan-project-researcher.md +70 -499
  19. package/agents/pan-release.md +1 -1
  20. package/agents/pan-reviewer.md +9 -3
  21. package/agents/pan-roadmapper.md +12 -7
  22. package/agents/pan-verifier.md +7 -2
  23. package/bin/install-lib.cjs +134 -14
  24. package/bin/install.js +59 -12
  25. package/commands/pan/army.md +1 -1
  26. package/commands/pan/autodev.md +76 -0
  27. package/commands/pan/exec-phase.md +3 -4
  28. package/commands/pan/focus-auto.md +14 -7
  29. package/commands/pan/focus-design.md +4 -5
  30. package/commands/pan/focus-docs.md +24 -0
  31. package/commands/pan/learn.md +13 -6
  32. package/commands/pan/milestone-audit.md +8 -1
  33. package/commands/pan/milestone-done.md +10 -5
  34. package/commands/pan/milestone-new.md +2 -2
  35. package/commands/pan/new-project.md +15 -12
  36. package/commands/pan/plan-phase.md +15 -9
  37. package/commands/pan/quick.md +1 -1
  38. package/commands/pan/review-deep.md +11 -5
  39. package/commands/pan/settings.md +6 -0
  40. package/commands/pan/verify-phase.md +6 -2
  41. package/docs/AGENTS.md +917 -0
  42. package/docs/ARCHITECTURE.md +1054 -0
  43. package/docs/CLI-REFERENCE.md +4582 -0
  44. package/docs/COMPARISON.md +116 -0
  45. package/docs/DEVELOPMENT.md +490 -0
  46. package/docs/ECOSYSTEM-REVIEW-2026-06.md +107 -0
  47. package/docs/ECOSYSTEM-REVIEW-2026-08.md +349 -0
  48. package/docs/EXAMPLES.md +584 -0
  49. package/docs/FAQ.md +254 -0
  50. package/docs/FIELD-HARVEST-2026-07.md +88 -0
  51. package/docs/FIELD-REPORT-army-2026-06.md +121 -0
  52. package/docs/HOOKS.md +638 -0
  53. package/docs/IMPROVEMENT-TODO.md +246 -0
  54. package/docs/INTERNALS.md +485 -0
  55. package/docs/MIGRATION-v2-to-v3.md +258 -0
  56. package/docs/SECURITY-HARDENING.md +230 -0
  57. package/docs/SKILLS-ENHANCEMENT-PLAN-V2.md +122 -0
  58. package/docs/SKILLS-FULL-TEXT.md +9685 -0
  59. package/docs/SKILLS-REFERENCE.md +1037 -0
  60. package/docs/TROUBLESHOOTING.md +1084 -0
  61. package/docs/USER-GUIDE.md +1437 -0
  62. package/docs/archive/SKILLS-ENHANCEMENT-PLAN.md +230 -0
  63. package/docs/audits/AUDIT-2026-08-e2e-ROADMAP.md +117 -0
  64. package/docs/audits/AUDIT-2026-08-e2e-VERIFICATION.md +693 -0
  65. package/docs/audits/AUDIT-2026-08-e2e.md +1546 -0
  66. package/docs/audits/OUTSTANDING-2026-08.md +275 -0
  67. package/docs/audits/STABILITY-TEST-2026-08.md +640 -0
  68. package/docs/branding/README-brand-draft.md +119 -0
  69. package/docs/branding/handoff/IMPLEMENTATION.md +205 -0
  70. package/docs/branding/handoff/README-brand-draft.md +119 -0
  71. package/docs/branding/handoff/image-prompts.md +46 -0
  72. package/docs/branding/image-prompts.md +46 -0
  73. package/docs/branding/nano-banana-prompts.txt +60 -0
  74. package/docs/context-monitor.md +13 -0
  75. package/docs/decisions/ADR-0001-strategic-feature-prioritization.md +83 -0
  76. package/docs/decisions/ADR-0002-copilot-cli-runtime.md +71 -0
  77. package/docs/decisions/ADR-0003-smart-execution.md +84 -0
  78. package/docs/decisions/ADR-0004-copilot-cli-interaction.md +89 -0
  79. package/docs/decisions/ADR-0005-command-naming.md +80 -0
  80. package/docs/decisions/ADR-0006-focus-commands.md +101 -0
  81. package/docs/decisions/ADR-0007-ship-v1.md +73 -0
  82. package/docs/decisions/ADR-0008-map-codebase-mermaid-togaf.md +80 -0
  83. package/docs/decisions/ADR-0009-production-deployment-checklist.md +68 -0
  84. package/docs/decisions/ADR-0010-standards-integration.md +79 -0
  85. package/docs/decisions/ADR-0011-help-commands-rewrite.md +64 -0
  86. package/docs/decisions/ADR-0012-contributors-attribution-index.md +42 -0
  87. package/docs/decisions/ADR-0013-strategic-feature-additions.md +53 -0
  88. package/docs/decisions/ADR-0014-internal-cleanup-code-quality.md +53 -0
  89. package/docs/decisions/ADR-0015-focus-auto-runner.md +296 -0
  90. package/docs/decisions/ADR-0016-enhanced-doc-sync.md +54 -0
  91. package/docs/decisions/ADR-0017-enhanced-focus-design.md +69 -0
  92. package/docs/decisions/ADR-0018-user-reality-testing.md +84 -0
  93. package/docs/decisions/ADR-0019-end-to-end-user-acceptance-testing.md +113 -0
  94. package/docs/decisions/ADR-0020-ai-drift-prevention.md +55 -0
  95. package/docs/decisions/ADR-0021-codebase-mapper-v2.md +76 -0
  96. package/docs/decisions/ADR-0022-lifecycle-completeness.md +63 -0
  97. package/docs/decisions/ADR-0023-opus-4-7-adoption.md +89 -0
  98. package/docs/decisions/ADR-0024-spec-b-v2-completion.md +137 -0
  99. package/docs/decisions/ADR-0025-behavioral-guardrails-layer.md +129 -0
  100. package/docs/decisions/ADR-0026-self-improvement-loop.md +171 -0
  101. package/docs/decisions/ADR-0027-doc-code-link-graph.md +164 -0
  102. package/docs/decisions/ADR-0028-unified-agents-skills-target.md +46 -0
  103. package/docs/decisions/ADR-0029-experiment-evaluator.md +76 -0
  104. package/docs/decisions/ADR-0030-planning-concurrency.md +37 -0
  105. package/docs/decisions/ADR-0031-focus-auto-backlog-source.md +47 -0
  106. package/docs/decisions/ADR-0032-squad-model.md +64 -0
  107. package/docs/decisions/ADR-0033-army-campaign.md +40 -0
  108. package/docs/decisions/ADR-0034-scheduled-campaigns.md +57 -0
  109. package/docs/decisions/ADR-0035-army-hud-dashboard.md +40 -0
  110. package/docs/decisions/ADR-0036-agentic-memory-architecture.md +106 -0
  111. package/docs/decisions/ADR-0037-pin-security-agents-to-opus.md +51 -0
  112. package/docs/decisions/ADR-0038-skill-aligned-decomposition.md +41 -0
  113. package/docs/decisions/ADR-0039-worktree-as-project-variant.md +37 -0
  114. package/docs/decisions/ADR-0040-memory-injection-defense.md +55 -0
  115. package/docs/decisions/ADR-0041-mcp-dual-era-bridge.md +47 -0
  116. package/docs/decisions/ADR-0042-design-parity-both-flows.md +45 -0
  117. package/docs/decisions/ADR-0043-addressable-planning-roots.md +56 -0
  118. package/docs/decisions/ADR-0044-bound-the-cached-context.md +104 -0
  119. package/docs/decisions/ADR-0045-agent-plugins-bundle.md +76 -0
  120. package/docs/decisions/ADR-0046-subagent-frontmatter-decisions.md +39 -0
  121. package/docs/decisions/ADR-0047-pan-harness.md +76 -0
  122. package/docs/decisions/ADR-0048-one-core-per-project.md +43 -0
  123. package/docs/decisions/ADR-0049-machine-verdicts-and-findings-ledger.md +116 -0
  124. package/docs/specs/ai_drift_prevention_featureai.md +286 -0
  125. package/docs/specs/codebase_mapper_v2_featureai.md +930 -0
  126. package/docs/specs/command_naming_optimization_featureai.md +930 -0
  127. package/docs/specs/comp_analysis_markdown_v1.md +149 -0
  128. package/docs/specs/continuous_autonomous_execution_featureai.md +304 -0
  129. package/docs/specs/contributors_attribution_index_featureai.md +476 -0
  130. package/docs/specs/copilot_cli_interaction_featureai.md +868 -0
  131. package/docs/specs/copilot_cli_runtime_featureai.md +647 -0
  132. package/docs/specs/custom-skills-assessment.md +461 -0
  133. package/docs/specs/deployment_audit_findings_featureai.md +243 -0
  134. package/docs/specs/doc_code_link_graph_featureai.md +253 -0
  135. package/docs/specs/documentation_alignment_cleanup_featureai.md +69 -0
  136. package/docs/specs/e2e_visual_testing_featureai.md +615 -0
  137. package/docs/specs/end_to_end_user_acceptance_testing_featureai.md +810 -0
  138. package/docs/specs/evidence-loop-plan.md +76 -0
  139. package/docs/specs/evidence_loop_featureai.md +497 -0
  140. package/docs/specs/focus-auto-optimize-category.md +449 -0
  141. package/docs/specs/focus-auto-optimize-superplan.md +188 -0
  142. package/docs/specs/focus_auto_runner_featureai.md +1102 -0
  143. package/docs/specs/focus_design_v2_featureai.md +197 -0
  144. package/docs/specs/focus_sync_v2_featureai.md +202 -0
  145. package/docs/specs/git_integration_overhaul_featureai.md +326 -0
  146. package/docs/specs/glm_zai_provider_featureai.md +200 -0
  147. package/docs/specs/googlecli_adoption_featureai.md +661 -0
  148. package/docs/specs/help_commands_rewrite_featureai.md +860 -0
  149. package/docs/specs/industry_analysis_strategic_additions_featureai.md +231 -0
  150. package/docs/specs/industry_comparison_and_feature_gaps_featureai.md +674 -0
  151. package/docs/specs/internal_cleanup_code_quality_featureai.md +486 -0
  152. package/docs/specs/investigations/README.md +31 -0
  153. package/docs/specs/investigations/digests/2026-09-28.md +47 -0
  154. package/docs/specs/investigations/digests/2026-10-08.md +62 -0
  155. package/docs/specs/investigations/digests/2026-10-10.md +61 -0
  156. package/docs/specs/investigations/features/debugger-red-loop-first.md +55 -0
  157. package/docs/specs/investigations/features/harness-cross-host-behaviour.md +93 -0
  158. package/docs/specs/investigations/features/optimize-apply-regression-gate.md +66 -0
  159. package/docs/specs/investigations/features/reviewer-smell-baseline.md +57 -0
  160. package/docs/specs/investigations/features/trace-failure-capture.md +69 -0
  161. package/docs/specs/lifecycle_completeness_featureai.md +754 -0
  162. package/docs/specs/local-first-ci.md +42 -0
  163. package/docs/specs/map_codebase_mermaid_togaf_featureai.md +805 -0
  164. package/docs/specs/market-delta-2026-09-superplan.md +310 -0
  165. package/docs/specs/market-ideas-2026-09.md +131 -0
  166. package/docs/specs/market-ideas-2026-10.md +428 -0
  167. package/docs/specs/market-ideas-ledger.md +177 -0
  168. package/docs/specs/memory-optimization-2026-10.md +285 -0
  169. package/docs/specs/multi-track-planning-roots.md +143 -0
  170. package/docs/specs/multi_model_routing_featureai.md +687 -0
  171. package/docs/specs/multi_model_routing_superplan.md +197 -0
  172. package/docs/specs/opus_47_existing_enhancements_featureai.md +247 -0
  173. package/docs/specs/opus_47_extended_features_featureai.md +363 -0
  174. package/docs/specs/opus_47_extended_features_v2_featureai.md +397 -0
  175. package/docs/specs/pan_focus_commands_featureai.md +2336 -0
  176. package/docs/specs/pan_zcode_mcp_bridge_featureai.md +120 -0
  177. package/docs/specs/product-excellence-superplan.md +315 -0
  178. package/docs/specs/product_excellence_program_2026_q4_featureai.md +365 -0
  179. package/docs/specs/production_deployment_readiness_featureai.md +433 -0
  180. package/docs/specs/production_readiness_ship_v1_featureai.md +698 -0
  181. package/docs/specs/real_test_enforcement_featureai.md +213 -0
  182. package/docs/specs/reality-check-2026-09-superplan.md +362 -0
  183. package/docs/specs/right-sized-ceremony.md +74 -0
  184. package/docs/specs/roadmap_and_competition_2026_q4_featureai.md +390 -0
  185. package/docs/specs/self_improvement_loop_featureai.md +875 -0
  186. package/docs/specs/skill-aligned-decomposition.md +155 -0
  187. package/docs/specs/standards_integration_featureai.md +833 -0
  188. package/docs/specs/testing-system-redesign-2026-09.md +267 -0
  189. package/docs/specs/user_reality_testing_system_featureai.md +788 -0
  190. package/docs/specs/watch-triggers.json +22 -0
  191. package/docs/specs/workflow-integration-redesign.md +742 -0
  192. package/docs/specs/workflow_state_consistency_featureai.md +183 -0
  193. package/hooks/dist/pan-context-monitor.js +9 -2
  194. package/hooks/dist/pan-cost-logger.js +5 -0
  195. package/hooks/dist/pan-guard.js +28 -5
  196. package/hooks/dist/pan-stop-guard.js +9 -9
  197. package/hooks/dist/pan-trace-logger.js +5 -0
  198. package/package.json +10 -2
  199. package/pan-wizard-core/bin/lib/agents-md.cjs +57 -8
  200. package/pan-wizard-core/bin/lib/autodev-report.cjs +180 -0
  201. package/pan-wizard-core/bin/lib/autodev.cjs +642 -0
  202. package/pan-wizard-core/bin/lib/ci.cjs +143 -0
  203. package/pan-wizard-core/bin/lib/commands.cjs +1 -1
  204. package/pan-wizard-core/bin/lib/config.cjs +31 -2
  205. package/pan-wizard-core/bin/lib/constants.cjs +27 -1
  206. package/pan-wizard-core/bin/lib/core.cjs +22 -10
  207. package/pan-wizard-core/bin/lib/cost.cjs +14 -4
  208. package/pan-wizard-core/bin/lib/cross-family.cjs +164 -0
  209. package/pan-wizard-core/bin/lib/debug-cases.cjs +104 -0
  210. package/pan-wizard-core/bin/lib/failure-class.cjs +81 -0
  211. package/pan-wizard-core/bin/lib/findings.cjs +34 -4
  212. package/pan-wizard-core/bin/lib/focus.cjs +1 -1
  213. package/pan-wizard-core/bin/lib/hosts.cjs +88 -0
  214. package/pan-wizard-core/bin/lib/init.cjs +7 -2
  215. package/pan-wizard-core/bin/lib/knowledge.cjs +2 -0
  216. package/pan-wizard-core/bin/lib/merge.cjs +50 -0
  217. package/pan-wizard-core/bin/lib/mutate.cjs +16 -5
  218. package/pan-wizard-core/bin/lib/next-action.cjs +112 -0
  219. package/pan-wizard-core/bin/lib/optimize.cjs +2 -2
  220. package/pan-wizard-core/bin/lib/phase.cjs +48 -4
  221. package/pan-wizard-core/bin/lib/plan-lint.cjs +150 -1
  222. package/pan-wizard-core/bin/lib/preview.cjs +23 -4
  223. package/pan-wizard-core/bin/lib/requirements-lint.cjs +130 -0
  224. package/pan-wizard-core/bin/lib/research-lint.cjs +150 -3
  225. package/pan-wizard-core/bin/lib/review-deep.cjs +14 -22
  226. package/pan-wizard-core/bin/lib/review-vote.cjs +105 -0
  227. package/pan-wizard-core/bin/lib/runner.cjs +2 -16
  228. package/pan-wizard-core/bin/lib/sizing.cjs +356 -0
  229. package/pan-wizard-core/bin/lib/squads.cjs +1 -1
  230. package/pan-wizard-core/bin/lib/state-write.cjs +247 -0
  231. package/pan-wizard-core/bin/lib/uat.cjs +149 -0
  232. package/pan-wizard-core/bin/lib/verdict.cjs +26 -3
  233. package/pan-wizard-core/bin/lib/verify-plan-set.cjs +30 -1
  234. package/pan-wizard-core/bin/lib/verify-preflight.cjs +2 -2
  235. package/pan-wizard-core/bin/lib/verify-red.cjs +37 -1
  236. package/pan-wizard-core/bin/lib/verify-retro.cjs +3 -2
  237. package/pan-wizard-core/bin/lib/verify.cjs +59 -4
  238. package/pan-wizard-core/bin/lib/worktree.cjs +70 -0
  239. package/pan-wizard-core/bin/pan-tools.cjs +168 -27
  240. package/pan-wizard-core/learnings/universal/autonomous-loop.md +2 -2
  241. package/pan-wizard-core/references/continuation-format.md +3 -3
  242. package/pan-wizard-core/references/guardrails.md +1 -1
  243. package/pan-wizard-core/references/model-profiles.md +2 -3
  244. package/pan-wizard-core/references/questioning.md +24 -0
  245. package/pan-wizard-core/references/ui-brand.md +4 -6
  246. package/pan-wizard-core/references/verdict-contract.md +2 -1
  247. package/pan-wizard-core/templates/config.json +5 -1
  248. package/pan-wizard-core/templates/debug.md +1 -0
  249. package/pan-wizard-core/templates/discovery.md +2 -2
  250. package/pan-wizard-core/templates/requirements.md +9 -0
  251. package/pan-wizard-core/workflows/add-phase.md +4 -6
  252. package/pan-wizard-core/workflows/assumptions.md +19 -1
  253. package/pan-wizard-core/workflows/diagnose-issues.md +2 -0
  254. package/pan-wizard-core/workflows/discuss-phase.md +4 -2
  255. package/pan-wizard-core/workflows/exec-lean.md +69 -0
  256. package/pan-wizard-core/workflows/exec-phase.md +36 -8
  257. package/{commands/pan → pan-wizard-core/workflows}/focus-doc-audit.md +13 -29
  258. package/{commands/pan → pan-wizard-core/workflows}/focus-drift-walking.md +3 -18
  259. package/{commands/pan → pan-wizard-core/workflows}/focus-exec.md +8 -23
  260. package/{commands/pan → pan-wizard-core/workflows}/focus-plan.md +6 -20
  261. package/{commands/pan → pan-wizard-core/workflows}/focus-scan.md +3 -16
  262. package/{commands/pan → pan-wizard-core/workflows}/focus-sync.md +7 -21
  263. package/pan-wizard-core/workflows/health.md +6 -0
  264. package/pan-wizard-core/workflows/help.md +31 -53
  265. package/pan-wizard-core/workflows/insert-phase.md +4 -6
  266. package/pan-wizard-core/workflows/learn.md +3 -3
  267. package/pan-wizard-core/workflows/milestone-audit.md +2 -2
  268. package/pan-wizard-core/workflows/milestone-new.md +38 -95
  269. package/pan-wizard-core/workflows/new-project.md +96 -258
  270. package/pan-wizard-core/workflows/optimize.md +107 -5
  271. package/pan-wizard-core/workflows/phase-tests.md +10 -0
  272. package/pan-wizard-core/workflows/plan-phase.md +86 -43
  273. package/pan-wizard-core/workflows/profile.md +50 -0
  274. package/pan-wizard-core/workflows/progress.md +10 -0
  275. package/pan-wizard-core/workflows/quick.md +34 -43
  276. package/pan-wizard-core/workflows/research-phase.md +118 -40
  277. package/pan-wizard-core/workflows/resume-project.md +18 -2
  278. package/pan-wizard-core/workflows/settings.md +21 -24
  279. package/pan-wizard-core/workflows/todo-check.md +6 -2
  280. package/pan-wizard-core/workflows/transition.md +22 -4
  281. package/pan-wizard-core/workflows/uat-walkthrough.md +90 -0
  282. package/pan-wizard-core/workflows/update.md +1 -1
  283. package/pan-wizard-core/workflows/verify-phase.md +2 -2
  284. package/scripts/build-agent-plugin.js +35 -15
  285. package/scripts/build-plugin.js +10 -1
  286. package/scripts/deprecate-old-versions.js +70 -3
  287. package/scripts/generate-skills-docs.py +1 -1
  288. package/scripts/git-hooks/pre-push +27 -0
  289. package/scripts/install-git-hooks.js +7 -5
  290. package/scripts/install-pre-push.cjs +38 -0
  291. package/scripts/land.cjs +151 -0
  292. package/scripts/local-ci.cjs +501 -0
  293. package/scripts/local-schedule.cjs +277 -0
  294. package/scripts/prompt-lint.cjs +32 -0
  295. package/scripts/prompt-rules.cjs +156 -0
  296. package/scripts/run-tests.cjs +7 -1
  297. package/scripts/test-quality-lint.cjs +3 -3
  298. package/scripts/test-surface.cjs +9 -2
  299. package/agents/pan-research-synthesizer.md +0 -246
  300. package/commands/pan/audit-deployment.md +0 -386
  301. package/commands/pan/dashboard.md +0 -22
  302. package/commands/pan/discord.md +0 -19
  303. package/commands/pan/mcp-bridge.md +0 -143
  304. package/commands/pan/milestone-cleanup.md +0 -19
  305. package/commands/pan/milestone-gaps.md +0 -35
  306. package/commands/pan/optimize.md +0 -115
  307. package/commands/pan/profile.md +0 -76
  308. package/commands/pan/research-phase.md +0 -189
  309. package/pan-wizard-core/bin/lib/bridge.cjs +0 -269
  310. package/pan-wizard-core/templates/research-project/architecture.md +0 -210
  311. package/pan-wizard-core/templates/research-project/features.md +0 -153
  312. package/pan-wizard-core/templates/research-project/pitfalls.md +0 -206
  313. package/pan-wizard-core/templates/research-project/stack.md +0 -126
  314. package/pan-wizard-core/templates/research-project/summary.md +0 -170
@@ -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));