@pareto4/pp-core 1.11.0 → 1.12.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 (256) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +31 -0
  3. package/agents/pp-code-fixer.md +1 -1
  4. package/agents/pp-debug-session-manager.md +1 -1
  5. package/agents/pp-debugger.md +1 -1
  6. package/agents/pp-executor.md +20 -10
  7. package/agents/pp-intel-updater.md +1 -1
  8. package/agents/pp-phase-researcher.md +1 -1
  9. package/agents/pp-plan-checker.md +5 -5
  10. package/agents/pp-planner.md +10 -24
  11. package/agents/pp-project-researcher.md +1 -1
  12. package/agents/pp-research-synthesizer.md +1 -1
  13. package/agents/pp-ui-researcher.md +1 -1
  14. package/agents/pp-verifier.md +8 -4
  15. package/bin/install.js +429 -82
  16. package/commands/pp-autonomous.md +8 -6
  17. package/commands/pp-discovery-to-build.md +1 -1
  18. package/commands/pp-discovery.md +3 -2
  19. package/commands/pp-discuss-phase.md +2 -2
  20. package/commands/pp-execute-phase.md +3 -1
  21. package/commands/pp-graphify.md +5 -5
  22. package/commands/pp-import.md +1 -1
  23. package/commands/pp-map-codebase.md +1 -1
  24. package/commands/pp-ns-project.md +2 -3
  25. package/commands/pp-ns-workflow.md +1 -1
  26. package/commands/pp-plan-phase.md +2 -1
  27. package/commands/pp-verify-work.md +2 -1
  28. package/gemini-extension.json +1 -1
  29. package/hooks/dist/pp-cursor-session-start.js +2 -2
  30. package/hooks/dist/pp-session-state.sh +1 -1
  31. package/hooks/dist/pp-statusline.js +21 -2
  32. package/hooks/pp-cursor-session-start.js +2 -2
  33. package/hooks/pp-session-state.sh +1 -1
  34. package/hooks/pp-statusline.js +21 -2
  35. package/package.json +18 -5
  36. package/pareto-skills/pp-project-setup/SKILL.md +5 -63
  37. package/pp-core/bin/detect-build-commands.sh +182 -0
  38. package/pp-core/bin/lib/capability-registry.cjs +65 -7
  39. package/pp-core/bin/lib/check-command-router.cjs +79 -38
  40. package/pp-core/bin/lib/clusters.cjs +2 -3
  41. package/pp-core/bin/lib/command-aliases.cjs +36 -0
  42. package/pp-core/bin/lib/command-arg-projection.cjs +99 -1
  43. package/pp-core/bin/lib/commands.cjs +21 -6
  44. package/pp-core/bin/lib/compose-bindings.cjs +128 -15
  45. package/pp-core/bin/lib/config-schema.cjs +16 -0
  46. package/pp-core/bin/lib/config.cjs +142 -27
  47. package/pp-core/bin/lib/core-utils.cjs +43 -7
  48. package/pp-core/bin/lib/discovery-input-check.cjs +218 -0
  49. package/pp-core/bin/lib/discovery-rebaseline.cjs +1372 -0
  50. package/pp-core/bin/lib/discovery-research-track.cjs +979 -0
  51. package/pp-core/bin/lib/discovery-rollup.cjs +476 -0
  52. package/pp-core/bin/lib/discovery-screens.cjs +1995 -0
  53. package/pp-core/bin/lib/discovery-sweep.cjs +390 -0
  54. package/pp-core/bin/lib/discovery-table.cjs +98 -0
  55. package/pp-core/bin/lib/dispatch-plan.cjs +472 -0
  56. package/pp-core/bin/lib/frontmatter.cjs +17 -2
  57. package/pp-core/bin/lib/init-command-router.cjs +8 -5
  58. package/pp-core/bin/lib/init-contract.cjs +213 -33
  59. package/pp-core/bin/lib/init-interactive.cjs +20 -1
  60. package/pp-core/bin/lib/init.cjs +124 -134
  61. package/pp-core/bin/lib/install-profiles.cjs +5 -0
  62. package/pp-core/bin/lib/install-run.cjs +536 -0
  63. package/pp-core/bin/lib/io.cjs +15 -0
  64. package/pp-core/bin/lib/loop-resolver.cjs +24 -0
  65. package/pp-core/bin/lib/milestone.cjs +345 -48
  66. package/pp-core/bin/lib/phase-command-router.cjs +23 -1
  67. package/pp-core/bin/lib/phase-locator.cjs +15 -6
  68. package/pp-core/bin/lib/phase-scope.cjs +91 -0
  69. package/pp-core/bin/lib/phase-tier.cjs +467 -0
  70. package/pp-core/bin/lib/phase.cjs +247 -21
  71. package/pp-core/bin/lib/phases-command-router.cjs +2 -0
  72. package/pp-core/bin/lib/plan-graph.cjs +368 -0
  73. package/pp-core/bin/lib/planning-workspace.cjs +53 -0
  74. package/pp-core/bin/lib/platform-bindings.cjs +32 -2
  75. package/pp-core/bin/lib/profile-output.cjs +11 -3
  76. package/pp-core/bin/lib/prohibition-enforcement.cjs +112 -0
  77. package/pp-core/bin/lib/roadmap-parser.cjs +366 -99
  78. package/pp-core/bin/lib/roadmap.cjs +141 -39
  79. package/pp-core/bin/lib/runtime-hooks-surface.cjs +1 -1
  80. package/pp-core/bin/lib/shell-command-projection.cjs +24 -0
  81. package/pp-core/bin/lib/state-command-router.cjs +82 -13
  82. package/pp-core/bin/lib/state-document.cjs +124 -7
  83. package/pp-core/bin/lib/state.cjs +784 -257
  84. package/pp-core/bin/lib/template.cjs +22 -6
  85. package/pp-core/bin/lib/vault-command-router.cjs +57 -6
  86. package/pp-core/bin/lib/vault-practices.cjs +6 -36
  87. package/pp-core/bin/lib/vault-write-back.cjs +20 -4
  88. package/pp-core/bin/lib/vault.cjs +555 -23
  89. package/pp-core/bin/lib/verify.cjs +53 -31
  90. package/pp-core/bin/lib/workstream.cjs +1 -1
  91. package/pp-core/bin/lib/worktree-safety.cjs +26 -15
  92. package/pp-core/bin/portable-timeout.sh +89 -0
  93. package/pp-core/bin/pp-tools.cjs +158 -8
  94. package/pp-core/bin/shared/config-defaults.manifest.json +7 -0
  95. package/pp-core/bin/shared/config-schema.manifest.json +3 -0
  96. package/pp-core/references/autonomous-best-effort.md +461 -0
  97. package/pp-core/references/autonomous-lifecycle.md +146 -0
  98. package/pp-core/references/autonomous-smart-discuss.md +16 -12
  99. package/pp-core/references/checkpoints.md +1 -1
  100. package/pp-core/references/discovery-id-conventions.md +202 -0
  101. package/pp-core/references/discovery-rebaseline.md +254 -0
  102. package/pp-core/references/discovery-research-track.md +283 -0
  103. package/pp-core/references/discovery-traceability.md +400 -0
  104. package/pp-core/references/loop-hook-dispatch.md +7 -0
  105. package/pp-core/references/mandatory-initial-read.md +1 -1
  106. package/pp-core/references/planner-gap-closure.md +1 -0
  107. package/pp-core/references/planner-human-verify-mode.md +1 -1
  108. package/pp-core/references/planner-load-graph-context.md +1 -1
  109. package/pp-core/references/planner-plan-graph.md +66 -0
  110. package/pp-core/references/planner-tdd.md +1 -0
  111. package/pp-core/references/planning-config.md +8 -0
  112. package/pp-core/references/sketch-tooling.md +4 -0
  113. package/pp-core/references/tdd.md +1 -0
  114. package/pp-core/templates/claude-md.md +1 -1
  115. package/pp-core/templates/phase-prompt.md +6 -2
  116. package/pp-core/templates/prototype-review/review.js +303 -0
  117. package/pp-core/templates/state.md +6 -0
  118. package/pp-core/workflows/_runtime-launcher.snippet.sh +1 -1
  119. package/pp-core/workflows/add-backlog.md +1 -1
  120. package/pp-core/workflows/add-phase.md +1 -1
  121. package/pp-core/workflows/add-tests.md +1 -1
  122. package/pp-core/workflows/add-todo.md +1 -1
  123. package/pp-core/workflows/ai-integration-phase.md +2 -2
  124. package/pp-core/workflows/audit-fix.md +12 -16
  125. package/pp-core/workflows/audit-milestone.md +1 -1
  126. package/pp-core/workflows/audit-uat.md +1 -1
  127. package/pp-core/workflows/autonomous.md +68 -306
  128. package/pp-core/workflows/check-todos.md +1 -1
  129. package/pp-core/workflows/cleanup.md +1 -1
  130. package/pp-core/workflows/code-review-fix.md +1 -1
  131. package/pp-core/workflows/code-review.md +19 -7
  132. package/pp-core/workflows/complete-milestone.md +41 -60
  133. package/pp-core/workflows/debug.md +1 -1
  134. package/pp-core/workflows/diagnose-issues.md +1 -1
  135. package/pp-core/workflows/discovery-sprint/gap-analysis.md +140 -0
  136. package/pp-core/workflows/discovery-sprint/input-package.md +140 -0
  137. package/pp-core/workflows/discovery-sprint/prefill.md +92 -0
  138. package/pp-core/workflows/discovery-sprint/prototype.md +257 -0
  139. package/pp-core/workflows/discovery-sprint/rebaseline.md +276 -0
  140. package/pp-core/workflows/discovery-sprint/session-13-gates.md +24 -0
  141. package/pp-core/workflows/discovery-sprint/sessions/01-kick-off.md +92 -0
  142. package/pp-core/workflows/discovery-sprint/sessions/02-as-is.md +98 -0
  143. package/pp-core/workflows/discovery-sprint/sessions/03-to-be.md +215 -0
  144. package/pp-core/workflows/discovery-sprint/sessions/04-ux-design.md +146 -0
  145. package/pp-core/workflows/discovery-sprint/sessions/05-architecture.md +98 -0
  146. package/pp-core/workflows/discovery-sprint/sessions/06-security.md +107 -0
  147. package/pp-core/workflows/discovery-sprint/sessions/07-identity.md +98 -0
  148. package/pp-core/workflows/discovery-sprint/sessions/08-integration.md +85 -0
  149. package/pp-core/workflows/discovery-sprint/sessions/09-qa.md +109 -0
  150. package/pp-core/workflows/discovery-sprint/sessions/10-infrastructure.md +119 -0
  151. package/pp-core/workflows/discovery-sprint/sessions/11-roadmap.md +92 -0
  152. package/pp-core/workflows/discovery-sprint/sessions/12-delivery-model.md +98 -0
  153. package/pp-core/workflows/discovery-sprint/sessions/13-wrap-up.md +205 -0
  154. package/pp-core/workflows/discovery-sprint/templates/context-template.md +266 -0
  155. package/pp-core/workflows/discovery-sprint.md +79 -1714
  156. package/pp-core/workflows/discovery-to-build/steps/architecture-deliverables.md +5 -5
  157. package/pp-core/workflows/discovery-to-build/steps/screen-inventory-gate.md +70 -0
  158. package/pp-core/workflows/discovery-to-build/templates/discovery-references.md +54 -0
  159. package/pp-core/workflows/discovery-to-build/templates/pp-handoff.md +184 -0
  160. package/pp-core/workflows/discovery-to-build.md +21 -238
  161. package/pp-core/workflows/discuss-phase/modes/advisor.md +1 -1
  162. package/pp-core/workflows/discuss-phase/modes/auto.md +1 -1
  163. package/pp-core/workflows/discuss-phase/modes/chain.md +1 -1
  164. package/pp-core/workflows/discuss-phase-assumptions.md +1 -1
  165. package/pp-core/workflows/discuss-phase.md +9 -2
  166. package/pp-core/workflows/do.md +4 -4
  167. package/pp-core/workflows/docs-update.md +1 -1
  168. package/pp-core/workflows/edit-phase.md +1 -1
  169. package/pp-core/workflows/eval-review.md +1 -1
  170. package/pp-core/workflows/execute-phase/steps/checkpoint-handling.md +45 -0
  171. package/pp-core/workflows/execute-phase/steps/client-context-gate.md +1 -1
  172. package/pp-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  173. package/pp-core/workflows/execute-phase/steps/cross-ai-delegation.md +90 -0
  174. package/pp-core/workflows/execute-phase/steps/install-run.md +29 -0
  175. package/pp-core/workflows/execute-phase/steps/phase-close-rebaseline.md +23 -0
  176. package/pp-core/workflows/execute-phase/steps/post-merge-gate.md +76 -77
  177. package/pp-core/workflows/execute-phase/steps/serial-group-dispatch.md +72 -0
  178. package/pp-core/workflows/execute-phase/steps/vault-fetch.md +5 -5
  179. package/pp-core/workflows/execute-phase/steps/vault-load-gate.md +19 -112
  180. package/pp-core/workflows/execute-phase.md +102 -216
  181. package/pp-core/workflows/execute-plan.md +6 -2
  182. package/pp-core/workflows/explore.md +1 -1
  183. package/pp-core/workflows/extract-learnings.md +1 -1
  184. package/pp-core/workflows/forensics.md +4 -4
  185. package/pp-core/workflows/graduation.md +1 -1
  186. package/pp-core/workflows/health.md +1 -1
  187. package/pp-core/workflows/help/modes/brief.md +1 -0
  188. package/pp-core/workflows/help/modes/default.md +2 -1
  189. package/pp-core/workflows/help/modes/full.md +12 -5
  190. package/pp-core/workflows/import.md +1 -1
  191. package/pp-core/workflows/ingest-docs.md +1 -1
  192. package/pp-core/workflows/init.md +18 -2
  193. package/pp-core/workflows/insert-phase.md +1 -1
  194. package/pp-core/workflows/list-workspaces.md +1 -1
  195. package/pp-core/workflows/manager.md +3 -3
  196. package/pp-core/workflows/map-codebase.md +2 -2
  197. package/pp-core/workflows/milestone-summary.md +4 -4
  198. package/pp-core/workflows/mvp-phase.md +1 -1
  199. package/pp-core/workflows/new-milestone.md +7 -8
  200. package/pp-core/workflows/new-project.md +6 -1
  201. package/pp-core/workflows/new-workspace.md +1 -1
  202. package/pp-core/workflows/next.md +2 -2
  203. package/pp-core/workflows/note.md +2 -2
  204. package/pp-core/workflows/pause-work.md +1 -1
  205. package/pp-core/workflows/plan-milestone-gaps.md +1 -1
  206. package/pp-core/workflows/plan-phase/steps/chunked-planning.md +111 -0
  207. package/pp-core/workflows/plan-phase/steps/phase-tier.md +34 -0
  208. package/pp-core/workflows/plan-phase.md +43 -167
  209. package/pp-core/workflows/plan-review-convergence.md +2 -2
  210. package/pp-core/workflows/plant-seed.md +1 -1
  211. package/pp-core/workflows/pr-branch.md +1 -1
  212. package/pp-core/workflows/profile-user.md +1 -1
  213. package/pp-core/workflows/progress.md +3 -3
  214. package/pp-core/workflows/quick.md +3 -1
  215. package/pp-core/workflows/remove-phase.md +1 -1
  216. package/pp-core/workflows/remove-workspace.md +1 -1
  217. package/pp-core/workflows/resume-project.md +6 -12
  218. package/pp-core/workflows/review.md +1 -1
  219. package/pp-core/workflows/scan.md +1 -1
  220. package/pp-core/workflows/secure-phase.md +1 -1
  221. package/pp-core/workflows/settings-advanced.md +1 -1
  222. package/pp-core/workflows/settings-integrations.md +1 -1
  223. package/pp-core/workflows/settings.md +1 -1
  224. package/pp-core/workflows/ship.md +25 -8
  225. package/pp-core/workflows/sketch-wrap-up.md +1 -1
  226. package/pp-core/workflows/sketch.md +2 -1
  227. package/pp-core/workflows/spec-phase.md +1 -1
  228. package/pp-core/workflows/spike-wrap-up.md +1 -1
  229. package/pp-core/workflows/spike.md +1 -1
  230. package/pp-core/workflows/stats.md +2 -2
  231. package/pp-core/workflows/thread.md +1 -1
  232. package/pp-core/workflows/transition.md +5 -9
  233. package/pp-core/workflows/ui-phase.md +23 -2
  234. package/pp-core/workflows/ui-review.md +1 -1
  235. package/pp-core/workflows/ultraplan-phase.md +2 -2
  236. package/pp-core/workflows/validate-phase.md +1 -1
  237. package/pp-core/workflows/vault-write-back.md +1 -1
  238. package/pp-core/workflows/verify-phase.md +29 -26
  239. package/pp-core/workflows/verify-work.md +14 -10
  240. package/scripts/base64-scan.sh +7 -41
  241. package/scripts/changeset/cli.cjs +25 -5
  242. package/scripts/dev-env.cjs +462 -0
  243. package/scripts/lib/allowlist-ratchet.cjs +103 -1
  244. package/scripts/lib/size-budget.cjs +311 -0
  245. package/scripts/lint-neutrality-names.cjs +694 -0
  246. package/scripts/lint-org-coordinates.allowlist.json +35 -0
  247. package/scripts/lint-org-coordinates.cjs +426 -0
  248. package/scripts/lint-skill-deps.cjs +48 -9
  249. package/scripts/lint-test-file-count.cjs +17 -2
  250. package/scripts/prompt-injection-scan.sh +41 -20
  251. package/scripts/sync-manifest-versions.cjs +41 -7
  252. package/scripts/sync-runtime-launcher.cjs +106 -1
  253. package/scripts/update-size-baseline.cjs +274 -30
  254. package/scripts/workflow-size.cjs +61 -9
  255. package/commands/pp-build-handoff.md +0 -46
  256. package/pp-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +0 -94
@@ -64,31 +64,46 @@ PATTERNS=(
64
64
  )
65
65
 
66
66
  # ─── Allowlist ───────────────────────────────────────────────────────────────
67
- # Files that legitimately discuss injection patterns (security docs, tests, this script)
67
+ # Files that legitimately contain injection-shaped text (pattern sources, negative
68
+ # test fixtures, adversarial corpora, security documentation).
69
+ #
70
+ # FORMAT (HINST-03, D-09-09): every entry is 'path::reason'.
71
+ # - `path` is matched as a SUBSTRING of the scanned file path, not as a prefix
72
+ # and not as a glob: entry `SECURITY.md` exempts every path ending in it.
73
+ # - `reason` states WHY that path legitimately carries injection-shaped text,
74
+ # and the hit count measured when it was written. It must be at least 80
75
+ # characters, and it must contain neither a single quote (the entry is a
76
+ # single-quoted bash literal) nor the two-character delimiter that opens it.
77
+ # - `is_allowlisted` strips the reason before matching, so the reason never
78
+ # participates in the substring rule.
79
+ #
80
+ # Membership is PINNED EXACTLY by tests/security-scan.security.test.cjs
81
+ # (describe 'prompt-injection-scan.sh ALLOWLIST is pinned'). That test sources
82
+ # THIS file and reads the array bash actually expands — adding, removing or
83
+ # un-reasoning an entry here without editing the test turns the suite red.
68
84
  ALLOWLIST=(
69
- 'scripts/prompt-injection-scan.sh'
70
- 'scripts/base64-scan.sh'
71
- 'scripts/secret-scan.sh'
72
- 'tests/security-scan.security.test.cjs'
73
- 'tests/security.test.cjs'
74
- 'tests/prompt-injection-scan.security.test.cjs'
75
- 'tests/verify.test.cjs'
76
- 'pp-core/bin/lib/security.cjs'
77
- 'hooks/gsd-prompt-guard.js'
78
- 'hooks/gsd-read-injection-scanner.js'
79
- 'tests/read-injection-scanner.security.test.cjs'
80
- 'tests/security-prompt-injection.security.test.cjs'
81
- 'tests/fixtures/adversarial/security/'
82
- 'SECURITY.md'
83
- # These files contain intentional injection examples / security-model prose
84
- # and are not attack vectors — they explain/demonstrate injection patterns.
85
- 'TEST-EXAMPLES.md'
86
- 'explanation/security-model.md'
85
+ 'scripts/prompt-injection-scan.sh::the PATTERNS array above is the literal source of every phrase this scanner looks for, so the file matches itself by construction; measured 4 pattern hits at HEAD on 2026-10-11'
86
+ 'scripts/base64-scan.sh::decodes base64 blobs and re-scans the recovered plaintext against the same injection vocabulary, so it names those phrases inline; measured 2 pattern hits at HEAD on 2026-10-11'
87
+ 'scripts/secret-scan.sh::third of the three diff-mode scan scripts CI runs together in the security-scan job of .gitlab-ci.yml; measured 0 pattern hits at HEAD on 2026-10-11, retained because its own exemption list at line 223 already names security-prompt-injection.security.test.cjs and grows with that vocabulary'
88
+ 'tests/security-scan.security.test.cjs::the behavioural suite for this very scanner, so every positive case embeds a real injection phrase as fixture input; measured 28 pattern hits at HEAD on 2026-10-11'
89
+ 'tests/security.test.cjs::the adversarial suite for the library sanitisers, whose fixtures are real override and role-manipulation strings asserted to be neutralised; measured 54 pattern hits at HEAD on 2026-10-11'
90
+ 'tests/prompt-injection-scan.security.test.cjs::drives scanForInjection in the compiled security library with crafted payloads, and carries a second allowlist of its own for that separate scanner; measured 11 pattern hits at HEAD on 2026-10-11'
91
+ 'tests/vault-security.test.cjs::the vault fencing suite plants an instruction-override heading in a fixture vault file and asserts SUMMARY.md fences and defangs it rather than passing it to the agent as instructions; measured 1 pattern hit at HEAD on 2026-10-11'
92
+ 'tests/verify.test.cjs::the verification suite plants an instruction-override line in a phase artifact and asserts it is rejected, so the line is present as a negative fixture; measured 1 pattern hit at HEAD on 2026-10-11'
93
+ 'pp-core/bin/lib/security.cjs::gitignored build output compiled from src/security.cts per .gitignore line 117, so it is unreachable in diff mode and matters only to a local dir-mode run; it carries the library injection patterns inline; measured 8 pattern hits at HEAD on 2026-10-11'
94
+ 'tests/read-injection-scanner.security.test.cjs::exercises the read-time injection scanner hook with the payloads a hostile file would carry, each asserted to be blocked rather than obeyed; measured 14 pattern hits at HEAD on 2026-10-11'
95
+ 'tests/security-prompt-injection.security.test.cjs::carries two deliberate NUL bytes at lines 631 and 881 so grep classifies it as binary and reports whole-file matches with no line numbers; 10 binary pattern matches measured at HEAD on 2026-10-11'
96
+ 'tests/fixtures/adversarial/security/::a directory of adversarial corpus files whose entire purpose is to BE injection payloads for the suites above; measured 20 pattern hits across its 7 tracked files at HEAD on 2026-10-11'
97
+ 'SECURITY.md::the substring rule makes this exempt every path ending in SECURITY.md, including pp-core/templates/SECURITY.md and every per-phase threat register generated from that template; the breadth is accepted because a security policy is exactly where injection phrasing is legitimately quoted; measured 1 pattern hit across all 13 such files on disk at HEAD on 2026-10-11, a quoted attack shape in a phase threat register'
98
+ 'TEST-EXAMPLES.md::documentation that demonstrates injection examples in order to explain what these scanners catch, so the example text is the point of the file rather than an attack; measured 1 pattern hit at HEAD on 2026-10-11'
99
+ 'explanation/security-model.md::matches docs/explanation/security-model.md through the substring rule; it is the prose that explains the injection threat model and quotes the attack shapes pp-core defends against; measured 2 pattern hits at HEAD on 2026-10-11'
87
100
  )
88
101
 
89
102
  is_allowlisted() {
90
103
  local file="$1"
91
104
  for allowed in "${ALLOWLIST[@]}"; do
105
+ # Strip the 'path::reason' suffix — the reason never takes part in matching.
106
+ allowed="${allowed%%::*}"
92
107
  if [[ "$file" == *"$allowed"* ]]; then
93
108
  return 0
94
109
  fi
@@ -204,4 +219,10 @@ main() {
204
219
  exit 0
205
220
  }
206
221
 
207
- main "$@"
222
+ # Sourceable for the test that pins the ALLOWLIST (D-09-10): sourcing this
223
+ # script must expand the array WITHOUT running the scanner. Direct execution
224
+ # (bash scripts/prompt-injection-scan.sh ... , or ./scripts/...) is unchanged —
225
+ # there BASH_SOURCE[0] and $0 are the same string. Verified on bash 3.2.57.
226
+ if [[ "${BASH_SOURCE[0]:-}" == "${0}" ]]; then
227
+ main "$@"
228
+ fi
@@ -4,10 +4,17 @@
4
4
  /**
5
5
  * sync-manifest-versions.cjs
6
6
  *
7
- * Stamps the package.json version into every runtime-integration manifest whose
8
- * top-level `version` field MUST track the package version. Called automatically
9
- * by the `version` npm lifecycle script so that `npm version X.Y.Z` keeps all
10
- * registered manifests in sync.
7
+ * Stamps the package.json version into every versioned manifest. Called
8
+ * automatically by the `version` npm lifecycle script so that `npm version X.Y.Z`
9
+ * keeps all registered manifests in sync. Two kinds:
10
+ *
11
+ * - VERSIONED_MANIFESTS — JSON runtime-integration manifests whose top-level
12
+ * `version` field MUST track the package version.
13
+ * - VERSIONED_TEXT_FILES — plain-text manifests whose ENTIRE content is the
14
+ * version followed by one newline. Root `VERSION` is the repo layout's
15
+ * version source for `resolveCreatedWith` (src/compose-bindings.cts), so a
16
+ * repo build stamps `createdWith` from it. As a plain-text file it could not
17
+ * join the JSON loop, so nothing kept it in step with package.json.
11
18
  *
12
19
  * Usage:
13
20
  * node scripts/sync-manifest-versions.cjs # stamp + report
@@ -29,6 +36,22 @@ const VERSIONED_MANIFESTS = [
29
36
  'gemini-extension.json',
30
37
  ];
31
38
 
39
+ // Plain-text manifests: the whole file is the version plus one newline. Stamped,
40
+ // drift-checked, staged and counted exactly like VERSIONED_MANIFESTS.
41
+ const VERSIONED_TEXT_FILES = [
42
+ 'VERSION',
43
+ ];
44
+
45
+ // A text manifest's trimmed content, or null when the file is missing.
46
+ function readTextVersion(abs) {
47
+ try {
48
+ return fs.readFileSync(abs, 'utf8').trim();
49
+ } catch (err) {
50
+ if (err && err.code === 'ENOENT') return null;
51
+ throw err;
52
+ }
53
+ }
54
+
32
55
  function readJson(p) {
33
56
  return JSON.parse(fs.readFileSync(p, 'utf8'));
34
57
  }
@@ -53,6 +76,13 @@ function syncManifestVersions(opts) {
53
76
  changed.push(rel);
54
77
  }
55
78
  }
79
+ for (const rel of VERSIONED_TEXT_FILES) {
80
+ const abs = path.join(root, rel);
81
+ if ((readTextVersion(abs) || '') !== v) {
82
+ fs.writeFileSync(abs, v + '\n');
83
+ changed.push(rel);
84
+ }
85
+ }
56
86
  return changed;
57
87
  }
58
88
 
@@ -65,6 +95,10 @@ function findDrift(opts) {
65
95
  const found = readJson(path.join(root, rel)).version;
66
96
  if (found !== v) drift.push({ manifest: rel, found, expected: v });
67
97
  }
98
+ for (const rel of VERSIONED_TEXT_FILES) {
99
+ const found = readTextVersion(path.join(root, rel));
100
+ if (found !== v) drift.push({ manifest: rel, found, expected: v });
101
+ }
68
102
  return drift;
69
103
  }
70
104
 
@@ -84,14 +118,14 @@ function stageManifests(opts) {
84
118
  return;
85
119
  }
86
120
  try {
87
- execFileSync('git', ['add', '--', ...VERSIONED_MANIFESTS], { cwd: root, stdio: ['ignore', 'ignore', 'pipe'] });
121
+ execFileSync('git', ['add', '--', ...VERSIONED_MANIFESTS, ...VERSIONED_TEXT_FILES], { cwd: root, stdio: ['ignore', 'ignore', 'pipe'] });
88
122
  } catch (err) {
89
123
  const detail = err && err.stderr ? err.stderr.toString().trim() : (err && err.message) || 'unknown error';
90
124
  throw new Error(`sync-manifest-versions: failed to git-add manifests inside a work tree: ${detail}`);
91
125
  }
92
126
  }
93
127
 
94
- module.exports = { VERSIONED_MANIFESTS, syncManifestVersions, findDrift, getPackageVersion, stageManifests };
128
+ module.exports = { VERSIONED_MANIFESTS, VERSIONED_TEXT_FILES, syncManifestVersions, findDrift, getPackageVersion, stageManifests };
95
129
 
96
130
  if (require.main === module) {
97
131
  const args = process.argv.slice(2);
@@ -105,7 +139,7 @@ if (require.main === module) {
105
139
  console.error('Run `node scripts/sync-manifest-versions.cjs` to fix.');
106
140
  process.exitCode = 1;
107
141
  } else {
108
- console.log('All ' + VERSIONED_MANIFESTS.length + ' versioned manifests in sync at ' + version + '.');
142
+ console.log('All ' + (VERSIONED_MANIFESTS.length + VERSIONED_TEXT_FILES.length) + ' versioned manifests in sync at ' + version + '.');
109
143
  }
110
144
  } else {
111
145
  const changed = syncManifestVersions({ version });
@@ -23,6 +23,75 @@ const WORKFLOWS_DIR = path.join(__dirname, '..', 'pp-core', 'workflows');
23
23
  const AGENTS_DIR = path.join(__dirname, '..', 'agents');
24
24
  const SNIPPET_FILE = path.join(WORKFLOWS_DIR, '_runtime-launcher.snippet.sh');
25
25
 
26
+ /**
27
+ * The prefix every launcher line starts with (after trimming leading indentation).
28
+ */
29
+ const LAUNCHER_HEAD = '_PP_SHIM_NAME="pp-tools.cjs"; ';
30
+
31
+ /**
32
+ * Launcher carriers that live OUTSIDE this script's two roots
33
+ * (pp-core/workflows/ and agents/).
34
+ *
35
+ * Why a list rather than another walked root: these are commands, docs and
36
+ * references whose block structure must survive untouched. transformFile()
37
+ * strips every resolver line from every shell block and re-inserts ONE preamble
38
+ * into the first pp_run block — correct for a workflow, wrong here, because
39
+ * commands/pp-graphify.md legitimately carries five copies (one per Bash-tool
40
+ * block a reader may run in isolation) and docs/DEBUGGING.md carries one inside
41
+ * an explanatory fence. They get line-level in-place replacement instead:
42
+ * same count, same positions, same indentation.
43
+ *
44
+ * Adding a launcher line to a file outside the roots without listing it here is
45
+ * caught by the repo-wide sweep in tests/runtime-launcher-parity.test.cjs (W6),
46
+ * which reads this array — the sweep is deliberately wider than the generator.
47
+ */
48
+ const EXTRA_CARRIERS = Object.freeze([
49
+ 'commands/pp-discuss-phase.md',
50
+ 'commands/pp-graphify.md',
51
+ 'commands/pp-import.md',
52
+ 'docs/DEBUGGING.md',
53
+ 'pp-core/references/planner-load-graph-context.md',
54
+ ]);
55
+
56
+ /**
57
+ * True when `line` is a launcher line: it starts with the launcher head
58
+ * (ignoring indentation) and closes the one-line if/elif/else chain with `fi`.
59
+ *
60
+ * A prose line that merely mentions the head in backticks does not close with
61
+ * `fi`, so it is rejected. This is the SINGLE detector shared by the generator
62
+ * and the parity sweep.
63
+ *
64
+ * @param {string} line
65
+ * @returns {boolean}
66
+ */
67
+ function isLauncherLine(line) {
68
+ if (typeof line !== 'string') return false;
69
+ const trimmed = line.trim();
70
+ return trimmed.startsWith(LAUNCHER_HEAD) && /\bfi$/.test(trimmed);
71
+ }
72
+
73
+ /**
74
+ * Replace every launcher line in `content` with `canonicalLine`, in place:
75
+ * the line count, each line's position and each line's leading whitespace are
76
+ * preserved. Nothing else in the document is read or written.
77
+ *
78
+ * @param {string} content
79
+ * @param {string} canonicalLine
80
+ * @returns {string|null} the new content, or null when nothing changed
81
+ */
82
+ function replaceCanonicalLines(content, canonicalLine) {
83
+ const lines = content.split('\n');
84
+ let changed = false;
85
+ const out = lines.map((line) => {
86
+ if (!isLauncherLine(line)) return line;
87
+ const indent = line.slice(0, line.length - line.trimStart().length);
88
+ const replacement = indent + canonicalLine;
89
+ if (replacement !== line) changed = true;
90
+ return replacement;
91
+ });
92
+ return changed ? out.join('\n') : null;
93
+ }
94
+
26
95
  // Read canonical preamble (full content of snippet file)
27
96
  function loadPreamble() {
28
97
  const raw = fs.readFileSync(SNIPPET_FILE, 'utf8');
@@ -413,12 +482,48 @@ function main() {
413
482
  }
414
483
  }
415
484
 
485
+ // Process the launcher carriers outside the two roots (in-place line mode).
486
+ if (preamble.length !== 1) {
487
+ throw new Error(
488
+ `_runtime-launcher.snippet.sh must be exactly ONE line (found ${preamble.length}). ` +
489
+ 'The in-place carrier mode, the repo-wide parity sweep and the one-line extractor in ' +
490
+ 'tests/bug-3668-workflow-runtime-resolution.test.cjs all depend on that contract.',
491
+ );
492
+ }
493
+ const repoRoot = path.join(__dirname, '..');
494
+ for (const rel of EXTRA_CARRIERS) {
495
+ const full = path.join(repoRoot, rel);
496
+ const content = fs.readFileSync(full, 'utf8');
497
+ if (!content.split('\n').some((line) => isLauncherLine(line))) {
498
+ throw new Error(
499
+ `EXTRA_CARRIERS lists ${rel} but that file carries no launcher line. ` +
500
+ 'A stale list must fail loudly rather than silently skip a carrier — ' +
501
+ 'remove the entry or restore the launcher line.',
502
+ );
503
+ }
504
+ const result = replaceCanonicalLines(content, preamble[0]);
505
+ if (result !== null) {
506
+ fs.writeFileSync(full, result, 'utf8');
507
+ transformedCount++;
508
+ console.log(`transformed (carrier): ${rel}`);
509
+ } else {
510
+ unchangedCount++;
511
+ }
512
+ }
513
+
416
514
  console.log(`\nDone. ${transformedCount} files transformed, ${unchangedCount} unchanged.`);
417
515
  }
418
516
 
419
517
  // Export the pure transform for tests; only run the filesystem walk when invoked
420
518
  // directly (`node scripts/sync-runtime-launcher.cjs`), never on require().
421
- module.exports = { transformFile, loadPreamble };
519
+ module.exports = {
520
+ transformFile,
521
+ loadPreamble,
522
+ isLauncherLine,
523
+ replaceCanonicalLines,
524
+ EXTRA_CARRIERS,
525
+ LAUNCHER_HEAD,
526
+ };
422
527
 
423
528
  if (require.main === module) {
424
529
  main();
@@ -4,65 +4,309 @@
4
4
  /**
5
5
  * @file update-size-baseline.cjs
6
6
  *
7
- * Regenerates the committed per-file workflow size baseline
8
- * (`tests/workflow-size-baseline.json`) from the current workflow files.
7
+ * Regenerates the committed per-file size baselines
8
+ * (`tests/workflow-size-baseline.json`, `tests/agent-size-baseline.json`) from
9
+ * the current files.
9
10
  *
10
- * Run via `npm run size:baseline` whenever a workflow file legitimately grows
11
- * or shrinks. Growth must still be justified in the PR; this script only
12
- * records the new reality so the CI guard (issue #1074) can diff against it.
11
+ * Workflow size budget policy (2026-10-11; per commit since pack 9): the guards
12
+ * allow each commit a small DELTA BUDGET (config size_budget.*, see
13
+ * scripts/lib/size-budget.cjs), so plans never run this script. It runs once per
14
+ * phase, in the phase's closing commit (`--reason "phase NN close"`, from
15
+ * execute-phase), and otherwise only when one commit must grow past the budget.
16
+ * Because a raise is the review record of growth, RAISING any entry requires a
17
+ * reason:
13
18
  *
14
- * Idempotent: running it twice with no file changes produces no diff.
19
+ * npm run size:baseline # lower / add / remove only
20
+ * npm run size:baseline -- --reason "<why>" # also allowed to raise
21
+ *
22
+ * Without `--reason`, a run that would raise any entry is REFUSED: it exits
23
+ * non-zero and writes nothing (neither baseline). With a reason, every raise is
24
+ * recorded in the baseline file's `_rebaselines` array (newest last) as
25
+ * `{ date, reason, deltas: [{ file, from, to, delta }] }` — the committed
26
+ * review record of a deliberate growth.
27
+ *
28
+ * Idempotent: running it twice with no file changes produces no diff (a run
29
+ * with nothing to raise never appends a `_rebaselines` entry).
15
30
  */
16
31
 
17
32
  const fs = require('fs');
18
33
  const path = require('path');
19
- const { measureMdFiles, WORKFLOWS_DIR } = require('./workflow-size.cjs');
34
+ const { measureMdFiles, measureMdTree, WORKFLOWS_DIR } = require('./workflow-size.cjs');
20
35
 
21
36
  const BASELINE_PATH = path.join(__dirname, '..', 'tests', 'workflow-size-baseline.json');
22
37
  const AGENTS_DIR = path.join(__dirname, '..', 'agents');
23
38
  const AGENT_BASELINE_PATH = path.join(__dirname, '..', 'tests', 'agent-size-baseline.json');
24
- const isGsdAgent = (f) => f.startsWith('pp-');
39
+ const isPpAgent = (f) => f.startsWith('pp-');
40
+
41
+ /** Key of the raise log inside a baseline file. Never a measured filename. */
42
+ const REBASELINES_KEY = '_rebaselines';
43
+
44
+ /**
45
+ * Split a parsed baseline file into its size entries and its raise log.
46
+ * Size entries are the numeric values; metadata keys (`_rebaselines`) are not
47
+ * sizes and must never reach a guard as one.
48
+ *
49
+ * @param {object|null} raw - Parsed baseline JSON (null when absent).
50
+ * @returns {{ sizes: Object<string, number>, rebaselines: Array<object> }}
51
+ */
52
+ function splitBaseline(raw) {
53
+ const sizes = {};
54
+ let rebaselines = [];
55
+ if (raw && typeof raw === 'object') {
56
+ for (const [key, value] of Object.entries(raw)) {
57
+ if (key === REBASELINES_KEY) rebaselines = value;
58
+ else sizes[key] = value;
59
+ }
60
+ }
61
+ return { sizes, rebaselines };
62
+ }
63
+
64
+ /**
65
+ * Read and parse a baseline file; `null` when it does not exist.
66
+ *
67
+ * @param {string} filePath
68
+ * @returns {object|null}
69
+ */
70
+ function readBaseline(filePath) {
71
+ let text;
72
+ try {
73
+ text = fs.readFileSync(filePath, 'utf-8');
74
+ } catch (err) {
75
+ if (err.code === 'ENOENT') return null;
76
+ throw err;
77
+ }
78
+ return JSON.parse(text);
79
+ }
25
80
 
26
81
  /**
27
- * Serialize a size map to the on-disk baseline format: keys sorted, 2-space
28
- * indent, trailing newline (so the file is a stable, minimal-diff artifact).
82
+ * Serialize to the on-disk baseline format: size keys sorted, then the
83
+ * `_rebaselines` log last; 2-space indent, trailing newline (a stable,
84
+ * minimal-diff artifact).
29
85
  *
30
86
  * @param {Object<string, number>} sizes
87
+ * @param {Array<object>} [rebaselines]
31
88
  * @returns {string}
32
89
  */
33
- function serializeBaseline(sizes) {
90
+ function serializeBaseline(sizes, rebaselines = []) {
34
91
  const sorted = {};
35
92
  for (const key of Object.keys(sizes).sort()) sorted[key] = sizes[key];
93
+ sorted[REBASELINES_KEY] = rebaselines;
36
94
  return JSON.stringify(sorted, null, 2) + '\n';
37
95
  }
38
96
 
39
97
  /**
40
- * Write a baseline file from the measured `.md` sizes in a directory.
98
+ * Classify the difference between an old and a new size map.
99
+ *
100
+ * @param {Object<string, number>} oldSizes
101
+ * @param {Object<string, number>} newSizes
102
+ * @returns {{ raised: Array<{file:string,from:number,to:number,delta:number}>,
103
+ * lowered: Array<{file:string,from:number,to:number,delta:number}>,
104
+ * added: string[], removed: string[] }}
105
+ */
106
+ function diffSizes(oldSizes, newSizes) {
107
+ const raised = [];
108
+ const lowered = [];
109
+ const added = [];
110
+ for (const file of Object.keys(newSizes).sort()) {
111
+ if (!(file in oldSizes)) {
112
+ added.push(file);
113
+ continue;
114
+ }
115
+ const from = oldSizes[file];
116
+ const to = newSizes[file];
117
+ if (to > from) raised.push({ file, from, to, delta: to - from });
118
+ else if (to < from) lowered.push({ file, from, to, delta: from - to });
119
+ }
120
+ const removed = Object.keys(oldSizes).filter((f) => !(f in newSizes)).sort();
121
+ return { raised, lowered, added, removed };
122
+ }
123
+
124
+ /**
125
+ * Validate a `_rebaselines` log. Returns a list of problems (empty = valid).
126
+ * Every entry must carry an ISO date, a non-empty reason and a non-empty list
127
+ * of positive deltas whose arithmetic holds; dates must be non-decreasing
128
+ * (newest last).
129
+ *
130
+ * @param {unknown} log
131
+ * @returns {string[]}
132
+ */
133
+ function validateRebaselineLog(log) {
134
+ if (!Array.isArray(log)) return [`${REBASELINES_KEY} must be an array, got ${JSON.stringify(log)}`];
135
+ const problems = [];
136
+ const isSize = (n) => Number.isInteger(n) && n >= 0;
137
+ let prevDate = '';
138
+ log.forEach((entry, i) => {
139
+ const at = `${REBASELINES_KEY}[${i}]`;
140
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
141
+ problems.push(`${at} must be an object`);
142
+ return;
143
+ }
144
+ if (typeof entry.date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(entry.date)) {
145
+ problems.push(`${at}.date must be YYYY-MM-DD, got ${JSON.stringify(entry.date)}`);
146
+ } else {
147
+ if (entry.date < prevDate) problems.push(`${at}.date ${entry.date} is older than the entry before it (newest must be last)`);
148
+ prevDate = entry.date;
149
+ }
150
+ if (typeof entry.reason !== 'string' || entry.reason.trim() === '') {
151
+ problems.push(`${at}.reason must be a non-empty string`);
152
+ }
153
+ if (!Array.isArray(entry.deltas) || entry.deltas.length === 0) {
154
+ problems.push(`${at}.deltas must be a non-empty array`);
155
+ return;
156
+ }
157
+ entry.deltas.forEach((d, j) => {
158
+ const dat = `${at}.deltas[${j}]`;
159
+ if (!d || typeof d.file !== 'string' || d.file === '') problems.push(`${dat}.file must be a non-empty string`);
160
+ if (!d || !isSize(d.from) || !isSize(d.to)) problems.push(`${dat}.from/.to must be non-negative integers`);
161
+ else if (!Number.isInteger(d.delta) || d.delta <= 0 || d.delta !== d.to - d.from) {
162
+ problems.push(`${dat}.delta must be the positive difference to - from (${d.to - d.from}), got ${JSON.stringify(d.delta)}`);
163
+ }
164
+ });
165
+ });
166
+ return problems;
167
+ }
168
+
169
+ /**
170
+ * Compute (but do not write) a regenerated baseline.
41
171
  *
42
172
  * @param {object} [opts]
43
173
  * @param {string} [opts.dir] - Directory to measure (default: workflows).
44
- * @param {string} [opts.outPath] - Baseline file to write (default: workflow).
45
- * @param {function(string): boolean} [opts.predicate] - Filename filter.
46
- * @returns {{ outPath: string, count: number, content: string }}
174
+ * @param {string} [opts.outPath] - Baseline file (default: workflow baseline).
175
+ * @param {function(string): boolean} [opts.predicate] - File filter.
176
+ * @param {function(string, function): Object<string, number>} [opts.measure] -
177
+ * Measurer (default: recursive `measureMdTree`; agents pass `measureMdFiles`).
178
+ * @param {string} [opts.reason] - Justification; required to raise any entry.
179
+ * @param {string} [opts.today] - Date stamp for a raise (default: today, UTC).
180
+ * @returns {{ outPath: string, refused: boolean, count: number, content: string|null,
181
+ * change: ReturnType<typeof diffSizes> }}
47
182
  */
48
- function generateBaseline({ dir = WORKFLOWS_DIR, outPath = BASELINE_PATH, predicate } = {}) {
49
- const sizes = measureMdFiles(dir, predicate);
50
- const content = serializeBaseline(sizes);
51
- fs.writeFileSync(outPath, content);
52
- return { outPath, count: Object.keys(sizes).length, content };
183
+ function planBaseline({
184
+ dir = WORKFLOWS_DIR,
185
+ outPath = BASELINE_PATH,
186
+ predicate,
187
+ measure = measureMdTree,
188
+ reason,
189
+ today = new Date().toISOString().slice(0, 10),
190
+ } = {}) {
191
+ const sizes = measure(dir, predicate);
192
+ const { sizes: oldSizes, rebaselines: oldLog } = splitBaseline(readBaseline(outPath));
193
+ const change = diffSizes(oldSizes, sizes);
194
+ const count = Object.keys(sizes).length;
195
+ const hasReason = typeof reason === 'string' && reason.trim() !== '';
196
+ if (change.raised.length > 0 && !hasReason) {
197
+ return { outPath, refused: true, count, content: null, change };
198
+ }
199
+ const log = Array.isArray(oldLog) ? [...oldLog] : [];
200
+ if (change.raised.length > 0) {
201
+ log.push({ date: today, reason: reason.trim(), deltas: change.raised });
202
+ }
203
+ return { outPath, refused: false, count, content: serializeBaseline(sizes, log), change };
53
204
  }
54
205
 
55
- if (require.main === module) {
56
- const targets = [
57
- { label: 'workflow', dir: WORKFLOWS_DIR, outPath: BASELINE_PATH },
58
- { label: 'agent', dir: AGENTS_DIR, outPath: AGENT_BASELINE_PATH, predicate: isGsdAgent },
59
- ];
60
- for (const t of targets) {
61
- const { outPath, count } = generateBaseline(t);
62
- process.stdout.write(
63
- `Wrote ${count} ${t.label} sizes to ${path.relative(process.cwd(), outPath)}\n`
206
+ /**
207
+ * Regenerate one baseline file. A refused plan (a raise without a reason)
208
+ * writes nothing.
209
+ *
210
+ * @param {Parameters<typeof planBaseline>[0]} [opts]
211
+ * @returns {ReturnType<typeof planBaseline>}
212
+ */
213
+ function generateBaseline(opts = {}) {
214
+ const plan = planBaseline(opts);
215
+ if (!plan.refused) fs.writeFileSync(plan.outPath, plan.content);
216
+ return plan;
217
+ }
218
+
219
+ /**
220
+ * Parse CLI arguments: only `--reason <text>` / `--reason=<text>`.
221
+ *
222
+ * @param {string[]} argv
223
+ * @returns {{ reason?: string }}
224
+ */
225
+ function parseArgs(argv) {
226
+ const out = {};
227
+ for (let i = 0; i < argv.length; i++) {
228
+ const arg = argv[i];
229
+ if (arg === '--reason') {
230
+ out.reason = argv[++i];
231
+ if (out.reason === undefined) throw new Error('--reason needs a value');
232
+ } else if (arg.startsWith('--reason=')) {
233
+ out.reason = arg.slice('--reason='.length);
234
+ } else {
235
+ throw new Error(`unknown argument: ${arg} (only --reason "<text>" is accepted)`);
236
+ }
237
+ }
238
+ if (out.reason !== undefined && out.reason.trim() === '') throw new Error('--reason must not be empty');
239
+ return out;
240
+ }
241
+
242
+ const TARGETS = [
243
+ { label: 'workflow', dir: WORKFLOWS_DIR, outPath: BASELINE_PATH, measure: measureMdTree },
244
+ { label: 'agent', dir: AGENTS_DIR, outPath: AGENT_BASELINE_PATH, predicate: isPpAgent, measure: measureMdFiles },
245
+ ];
246
+
247
+ /**
248
+ * CLI entry. Plans every target first and writes only when none is refused,
249
+ * so a refusal leaves BOTH baselines untouched.
250
+ *
251
+ * @param {string[]} argv
252
+ * @param {object} [io]
253
+ * @param {Array<object>} [io.targets]
254
+ * @param {string} [io.today]
255
+ * @param {NodeJS.WritableStream} [io.stdout]
256
+ * @param {NodeJS.WritableStream} [io.stderr]
257
+ * @returns {number} exit code
258
+ */
259
+ function main(argv, { targets = TARGETS, today, stdout = process.stdout, stderr = process.stderr } = {}) {
260
+ let args;
261
+ try {
262
+ args = parseArgs(argv);
263
+ } catch (err) {
264
+ stderr.write(`size:baseline: ${err.message}\n`);
265
+ return 2;
266
+ }
267
+ const plans = targets.map((t) => ({ t, plan: planBaseline({ ...t, reason: args.reason, today }) }));
268
+ const refused = plans.filter((p) => p.plan.refused);
269
+ if (refused.length > 0) {
270
+ const lines = refused.flatMap(({ t, plan }) =>
271
+ plan.change.raised.map((r) => ` - ${t.label}: ${r.file}: ${r.from} → ${r.to} (+${r.delta})`)
272
+ );
273
+ stderr.write(
274
+ 'size:baseline: REFUSED — this run would RAISE baseline entries, which needs a reason:\n' +
275
+ `${lines.join('\n')}\n` +
276
+ 'Shrink the file(s), or re-run with: npm run size:baseline -- --reason "<why this growth is needed>"\n' +
277
+ 'Nothing was written.\n'
64
278
  );
279
+ return 1;
65
280
  }
281
+ for (const { t, plan } of plans) {
282
+ fs.writeFileSync(plan.outPath, plan.content);
283
+ const c = plan.change;
284
+ stdout.write(
285
+ `Wrote ${plan.count} ${t.label} sizes to ${path.relative(process.cwd(), plan.outPath)} ` +
286
+ `(raised ${c.raised.length}, lowered ${c.lowered.length}, added ${c.added.length}, removed ${c.removed.length})\n`
287
+ );
288
+ }
289
+ if (args.reason !== undefined && plans.every((p) => p.plan.change.raised.length === 0)) {
290
+ stdout.write('Note: nothing was raised, so --reason was not recorded.\n');
291
+ }
292
+ return 0;
293
+ }
294
+
295
+ if (require.main === module) {
296
+ process.exitCode = main(process.argv.slice(2));
66
297
  }
67
298
 
68
- module.exports = { generateBaseline, serializeBaseline, BASELINE_PATH, AGENT_BASELINE_PATH };
299
+ module.exports = {
300
+ generateBaseline,
301
+ planBaseline,
302
+ serializeBaseline,
303
+ splitBaseline,
304
+ readBaseline,
305
+ diffSizes,
306
+ validateRebaselineLog,
307
+ parseArgs,
308
+ main,
309
+ REBASELINES_KEY,
310
+ BASELINE_PATH,
311
+ AGENT_BASELINE_PATH,
312
+ };