@joshuafolkken/kit 1.1471.0 → 1.1514.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 (267) hide show
  1. package/.claude/settings.json +6 -6
  2. package/.claude/skills/epic-commands/SKILL.md +2 -14
  3. package/.claude/skills/epic-commands/epic-bundle.md +9 -5
  4. package/.claude/skills/epic-commands/execution-waves.md +1 -2
  5. package/.claude/skills/verify-ui/SKILL.md +1 -2
  6. package/.claude/skills/workflow-commands/SKILL.md +7 -9
  7. package/.claude/skills/workflow-commands/background-commands.md +3 -7
  8. package/.claude/skills/workflow-commands/backlogrun-child.md +3 -10
  9. package/.claude/skills/workflow-commands/backlogrun-lanes.md +6 -15
  10. package/.claude/skills/workflow-commands/backlogrun-park.md +2 -8
  11. package/.claude/skills/workflow-commands/backlogrun-progress.md +10 -19
  12. package/.claude/skills/workflow-commands/backlogrun-recovery.md +8 -8
  13. package/.claude/skills/workflow-commands/backlogrun-steps.md +2 -10
  14. package/.claude/skills/workflow-commands/backlogrun.md +31 -50
  15. package/.claude/skills/workflow-commands/chain-rule.md +7 -6
  16. package/.claude/skills/workflow-commands/delegation.md +12 -8
  17. package/.claude/skills/workflow-commands/entry-sequence.md +1 -3
  18. package/.claude/skills/workflow-commands/followup.md +3 -6
  19. package/.claude/skills/workflow-commands/fullrun-steps.md +3 -5
  20. package/.claude/skills/workflow-commands/fullrun.md +4 -6
  21. package/.claude/skills/workflow-commands/halfrun.md +3 -7
  22. package/.claude/skills/workflow-commands/into-target.md +1 -2
  23. package/.claude/skills/workflow-commands/issue-comments.md +3 -3
  24. package/.claude/skills/workflow-commands/issue-scout.md +3 -2
  25. package/.claude/skills/workflow-commands/latest-gate.md +2 -6
  26. package/.claude/skills/workflow-commands/needs-human-review.md +2 -5
  27. package/.claude/skills/workflow-commands/observation-filing.md +0 -14
  28. package/.claude/skills/workflow-commands/observation-ledger.md +4 -9
  29. package/.claude/skills/workflow-commands/pre-gate-cut.md +11 -16
  30. package/.claude/skills/workflow-commands/prerequisite.md +1 -2
  31. package/.claude/skills/workflow-commands/progress-watcher.md +2 -7
  32. package/.claude/skills/workflow-commands/prrun.md +3 -5
  33. package/.claude/skills/workflow-commands/retrospective.md +4 -4
  34. package/.claude/skills/workflow-commands/split-assessment.md +1 -3
  35. package/.claude/skills/workflow-commands/target-repository.md +1 -2
  36. package/.claude/skills/workflow-commands/working-tree-hold.md +2 -4
  37. package/.codex/hooks.json +6 -6
  38. package/.github/actions/setup-node/action.yml +1 -1
  39. package/.github/workflows/ci.yml +1 -1
  40. package/.github/workflows/publish.yml +2 -2
  41. package/CLAUDE.md +1 -1
  42. package/cspell/index.yaml +7 -0
  43. package/dist/CLAUDE.md +1 -1
  44. package/dist/commands/document-section-cli.js +67 -34
  45. package/dist/hooks/batch-guard.js +6 -6
  46. package/dist/hooks/chunk-4KKD2UTK.js +66 -0
  47. package/dist/hooks/{chunk-J47HU5H5.js → chunk-6TS2JD6D.js} +3 -3
  48. package/dist/hooks/chunk-7DSBJVFH.js +50 -0
  49. package/dist/hooks/{chunk-YHBZAJSV.js → chunk-A65Y4YOR.js} +3 -3
  50. package/dist/hooks/{chunk-E7OIKX5Z.js → chunk-APUO6U4J.js} +85 -7
  51. package/dist/hooks/{chunk-IX6TTTI6.js → chunk-EFXE6C37.js} +8 -22
  52. package/dist/hooks/chunk-GP4IEC4O.js +20 -0
  53. package/dist/hooks/{chunk-SHGPANFB.js → chunk-JP45SOCJ.js} +3 -3
  54. package/dist/hooks/{chunk-K4EOONYM.js → chunk-LAOV7NLM.js} +1 -1
  55. package/dist/hooks/chunk-M4HGZV7U.js +83 -0
  56. package/dist/hooks/{chunk-KC5AK2GF.js → chunk-O6GDRNJB.js} +3 -3
  57. package/dist/hooks/{chunk-YYPI7SJZ.js → chunk-PF2NZL2A.js} +5 -5
  58. package/dist/hooks/{chunk-H5HVWYXK.js → chunk-PTICX4OL.js} +3 -4
  59. package/dist/hooks/{chunk-IIGFYXKC.js → chunk-RT27QRJW.js} +581 -1438
  60. package/dist/hooks/{chunk-43ZHT22P.js → chunk-SYOTDYCI.js} +1127 -1089
  61. package/dist/hooks/{chunk-AS74OU3D.js → chunk-TNUMY6NL.js} +1507 -1336
  62. package/dist/hooks/chunk-UPUZF3AN.js +279 -0
  63. package/dist/hooks/{chunk-EUMACUK2.js → chunk-WK6SMBIC.js} +1948 -806
  64. package/dist/hooks/codex-hook-adapter.js +17 -14
  65. package/dist/hooks/duplicate-read-guard.js +7 -7
  66. package/dist/hooks/format-edited.js +5 -4
  67. package/dist/hooks/inputs.json +317 -264
  68. package/dist/hooks/investigation-guard.js +8 -11
  69. package/dist/hooks/pretool-guard.js +16 -13
  70. package/dist/hooks/session-lang.js +370 -25
  71. package/dist/hooks/stop-guard.js +2576 -176
  72. package/dist/josh.js +2124 -277
  73. package/lefthook/base.yml +5 -3
  74. package/package.json +12 -10
  75. package/prompts/collaboration-workflow/file-edits.md +1 -3
  76. package/prompts/collaboration-workflow/gh-rest.md +1 -2
  77. package/prompts/collaboration-workflow/issue-citation.md +2 -4
  78. package/prompts/collaboration-workflow/issue-template.md +10 -2
  79. package/prompts/collaboration-workflow/lane-instructions.md +35 -0
  80. package/prompts/collaboration-workflow/operating-rules.md +2 -6
  81. package/prompts/collaboration-workflow/output-bounds.md +3 -3
  82. package/prompts/collaboration-workflow/overview.md +1 -1
  83. package/prompts/collaboration-workflow/principles.md +14 -8
  84. package/prompts/collaboration-workflow/report-format.md +9 -0
  85. package/prompts/collaboration-workflow/residency.md +12 -8
  86. package/prompts/collaboration-workflow/rule-delivery.md +3 -11
  87. package/prompts/collaboration-workflow/shell-body.md +6 -6
  88. package/prompts/collaboration-workflow/turn-batching.md +5 -5
  89. package/prompts/collaboration-workflow/upstream-interrupt.md +2 -2
  90. package/prompts/collaboration-workflow/wip-cap.md +6 -6
  91. package/prompts/collaboration-workflow.md +1 -0
  92. package/prompts/review.md +4 -5
  93. package/prompts/testing-guide.md +2 -2
  94. package/scripts/agent/agent-argv.ts +20 -4
  95. package/scripts/agent/agent-role-profile.ts +101 -45
  96. package/scripts/agent/agent-safe-text.ts +60 -0
  97. package/scripts/agent/claude-agent-argv.ts +14 -0
  98. package/scripts/agent/codex-agent-argv.ts +2 -0
  99. package/scripts/agent/lane-instructions.ts +144 -0
  100. package/scripts/backlog/backlog-budget.ts +4 -4
  101. package/scripts/backlog/backlog-drive-named.ts +1 -1
  102. package/scripts/backlog/backlog-drive-restore.ts +9 -1
  103. package/scripts/backlog/backlog-occupied.ts +56 -0
  104. package/scripts/backlog/backlog-ready.ts +47 -10
  105. package/scripts/delegation/duplicate-read-guard.ts +2 -2
  106. package/scripts/delegation/investigation-guard.ts +15 -65
  107. package/scripts/delegation/investigation-reads.ts +0 -1
  108. package/scripts/document/document-byte-budget.ts +6 -5
  109. package/scripts/document/document-section-cli.ts +17 -5
  110. package/scripts/document/entry-read-budget.ts +2 -2
  111. package/scripts/document/read-set-cli.ts +4 -1
  112. package/scripts/epic/epic-audit-orphans.ts +5 -8
  113. package/scripts/epic/epic-audit.ts +52 -1
  114. package/scripts/epic/epic-bundle-evidence.ts +49 -26
  115. package/scripts/epic/epic-bundle-referenced.ts +14 -3
  116. package/scripts/epic/epic-bundle.ts +17 -9
  117. package/scripts/followup/git-followup-workflow.ts +19 -1
  118. package/scripts/gate/check-inputs.ts +129 -0
  119. package/scripts/gate/check-reuse.ts +162 -0
  120. package/scripts/gate/core-budget.ts +93 -44
  121. package/scripts/gate/core-wait.ts +130 -0
  122. package/scripts/gate/free-cores.ts +49 -0
  123. package/scripts/gate/gate-ledger.ts +17 -3
  124. package/scripts/gate/gate-plan.ts +23 -1
  125. package/scripts/gate/gate-report.ts +15 -2
  126. package/scripts/gate/gate-run-steps.ts +150 -0
  127. package/scripts/gate/held-mark.ts +55 -0
  128. package/scripts/gate/machine-capacity.ts +70 -11
  129. package/scripts/gate/pre-commit-type-check.ts +14 -1
  130. package/scripts/gate/verification-gate.ts +58 -125
  131. package/scripts/gh/git-gh-issue-rest.ts +1 -0
  132. package/scripts/gh/git-gh-issue-write.ts +24 -2
  133. package/scripts/gh/git-pr.ts +13 -1
  134. package/scripts/git/main-merge-regenerate.ts +78 -0
  135. package/scripts/git/main-merge.ts +14 -5
  136. package/scripts/hooks/batch-guard.ts +5 -8
  137. package/scripts/hooks/pre-commit-lint.ts +91 -0
  138. package/scripts/hooks/pre-push-unit.ts +1 -1
  139. package/scripts/hooks/pretool-guard.ts +27 -52
  140. package/scripts/hooks/reserved-run.ts +1 -0
  141. package/scripts/hooks/run-hook.sh +10 -12
  142. package/scripts/hooks/stop-actions.ts +204 -0
  143. package/scripts/hooks/stop-guard.ts +62 -21
  144. package/scripts/init/hook-launch.ts +12 -7
  145. package/scripts/issue/expected-effect-lint.ts +59 -0
  146. package/scripts/issue/issue-cite-cli.ts +35 -15
  147. package/scripts/issue/issue-classification.ts +1 -1
  148. package/scripts/issue/issue-lint-cli.ts +5 -3
  149. package/scripts/issue/issue-scout-cli.ts +19 -14
  150. package/scripts/issue/markdown-section.ts +19 -5
  151. package/scripts/josh/command-usage.ts +110 -0
  152. package/scripts/josh/josh-command-map.ts +2 -0
  153. package/scripts/josh/josh-command-types.ts +55 -9
  154. package/scripts/josh/josh-commands-ai.ts +13 -0
  155. package/scripts/josh/josh-commands-development.ts +1 -1
  156. package/scripts/josh/josh-commands-hooks.ts +6 -0
  157. package/scripts/josh/josh-commands-lane.ts +10 -1
  158. package/scripts/josh/josh-commands-workflow.ts +5 -0
  159. package/scripts/josh/josh-logic.ts +6 -15
  160. package/scripts/josh/josh-usage-automation.ts +121 -0
  161. package/scripts/josh/josh-usage-backlog.ts +180 -0
  162. package/scripts/josh/josh-usage-developer.ts +230 -0
  163. package/scripts/josh/josh-usage-hooks.ts +38 -0
  164. package/scripts/josh/josh-usage-issue.ts +205 -0
  165. package/scripts/josh/josh-usage-review.ts +101 -0
  166. package/scripts/josh/josh-usage-run.ts +252 -0
  167. package/scripts/josh/josh-usage-session.ts +238 -0
  168. package/scripts/josh/josh-usage-workflow.ts +212 -0
  169. package/scripts/josh/josh.ts +14 -5
  170. package/scripts/josh/package-directory.ts +20 -0
  171. package/scripts/lane/lane-capacity.ts +8 -2
  172. package/scripts/lane/lane-check-stats.ts +56 -0
  173. package/scripts/lane/lane-child-invocation.ts +35 -4
  174. package/scripts/lane/lane-dispatch.ts +3 -3
  175. package/scripts/lane/lane-guard-policy.ts +2 -2
  176. package/scripts/lane/lane-handoff.ts +8 -1
  177. package/scripts/lane/lane-install.ts +12 -4
  178. package/scripts/lane/lane-ledger.ts +43 -1
  179. package/scripts/lane/lane-limit-cli.ts +8 -9
  180. package/scripts/lane/lane-measure-cli.ts +2 -0
  181. package/scripts/lane/lane-preserve.ts +29 -1
  182. package/scripts/lane/lane-registry.ts +9 -7
  183. package/scripts/lane/lane-restart.ts +5 -26
  184. package/scripts/lane/lane-session-action.ts +146 -0
  185. package/scripts/lane/lane-session-broker.ts +57 -138
  186. package/scripts/lane/lane-withdraw-cli.ts +71 -0
  187. package/scripts/lane/lane-withdraw.ts +182 -0
  188. package/scripts/lint/format.ts +4 -4
  189. package/scripts/lint/lint-parallel.ts +10 -3
  190. package/scripts/lint/lint-related-scope.ts +4 -1
  191. package/scripts/notify/telegram-test.ts +10 -5
  192. package/scripts/report/report-format-reference.ts +2 -0
  193. package/scripts/review/acceptance-map-check.ts +61 -0
  194. package/scripts/review/acceptance-map.ts +205 -0
  195. package/scripts/review/review-brief-cli.ts +1 -1
  196. package/scripts/rules/agent-model.ts +59 -0
  197. package/scripts/rules/delivered-rules.ts +11 -2
  198. package/scripts/rules/filing-cap.ts +1 -0
  199. package/scripts/rules/firing-point.ts +6 -5
  200. package/scripts/rules/guard-remedy.ts +117 -0
  201. package/scripts/rules/issue-comments.ts +67 -6
  202. package/scripts/rules/last-prompt.ts +50 -5
  203. package/scripts/rules/oracle-list-cli.ts +7 -0
  204. package/scripts/rules/piped-verification.ts +3 -1
  205. package/scripts/rules/rule-list.ts +4 -1
  206. package/scripts/rules/rule-registry-rows.ts +10 -0
  207. package/scripts/rules/rule-registry.ts +7 -4
  208. package/scripts/rules/rule-rewrite.ts +114 -0
  209. package/scripts/rules/rule-value.ts +7 -1
  210. package/scripts/rules/shell-segments.ts +15 -0
  211. package/scripts/rules/stop-decision.ts +77 -0
  212. package/scripts/rules/stop-rules.ts +147 -47
  213. package/scripts/rules/tail-commands.ts +15 -1
  214. package/scripts/run/board/run-board-cli.ts +2 -0
  215. package/scripts/run/board/run-board-closed.ts +8 -2
  216. package/scripts/run/board/run-board-github.ts +9 -1
  217. package/scripts/run/board/run-board-header.ts +4 -1
  218. package/scripts/run/board/run-board-labels.ts +2 -0
  219. package/scripts/run/board/run-board-layout.ts +1 -1
  220. package/scripts/run/board/run-board-read.ts +16 -0
  221. package/scripts/run/board/run-board-reason.ts +10 -1
  222. package/scripts/run/board/run-board-render-legend.ts +1 -0
  223. package/scripts/run/board/run-board-render-slot.ts +1 -1
  224. package/scripts/run/board/run-board-render-waits.ts +41 -0
  225. package/scripts/run/board/run-board-render.ts +6 -1
  226. package/scripts/run/board/run-board-row-yield.ts +1 -0
  227. package/scripts/run/board/run-board-state.ts +5 -0
  228. package/scripts/run/board/run-board-status.ts +72 -21
  229. package/scripts/run/board/run-board-tick.ts +5 -1
  230. package/scripts/run/cut/run-cut-cli.ts +3 -2
  231. package/scripts/run/cut/run-cut-report.ts +16 -0
  232. package/scripts/run/detached-launch.ts +23 -4
  233. package/scripts/run/entry/run-entry-cli.ts +8 -1
  234. package/scripts/run/event/run-event-render.ts +1 -0
  235. package/scripts/run/event/run-event-scope.ts +19 -1
  236. package/scripts/run/event/run-event-stream-emit.ts +13 -6
  237. package/scripts/run/event/run-event-stream.ts +4 -0
  238. package/scripts/run/hold/run-hold-session.ts +46 -0
  239. package/scripts/run/hold/run-hold.ts +7 -1
  240. package/scripts/run/launch-priority.ts +32 -0
  241. package/scripts/run/merge/run-merge-cli.ts +16 -65
  242. package/scripts/run/merge/run-merge-signals.ts +99 -0
  243. package/scripts/run/merge/run-merge-steps.ts +11 -1
  244. package/scripts/run/merge/run-merge.ts +21 -9
  245. package/scripts/run/run-liveness.ts +1 -1
  246. package/scripts/run/run-poll-hook.ts +157 -0
  247. package/scripts/run/run-step-cli.ts +22 -15
  248. package/scripts/run/run-step-section.ts +85 -0
  249. package/scripts/run/ship/run-ship-cli.ts +5 -12
  250. package/scripts/run/ship/run-ship-detach.ts +9 -0
  251. package/scripts/run/ship/run-ship-next.ts +10 -2
  252. package/scripts/run/ship/run-ship-overlap.ts +195 -0
  253. package/scripts/run/ship/run-ship-pre-detach.ts +33 -15
  254. package/scripts/run/ship/run-ship-preflight.ts +28 -2
  255. package/scripts/run/ship/run-ship-return.ts +31 -7
  256. package/scripts/run/ship/run-ship-review-steps.ts +12 -10
  257. package/scripts/run/ship/run-ship-steps.ts +1 -1
  258. package/scripts/run/ship/run-ship-stop-context.ts +206 -0
  259. package/scripts/run/ship/run-ship-sync.ts +14 -6
  260. package/scripts/test/test-e2e-guard.ts +30 -5
  261. package/scripts/test/test-unit-guard.ts +16 -1
  262. package/scripts/test/unit-worker-share.ts +26 -4
  263. package/scripts/time-runtime/time-shell.ts +29 -4
  264. package/scripts/time-runtime/time-spans.ts +1 -0
  265. package/scripts/time-runtime/time-tool-call.ts +7 -0
  266. package/templates/gitignore +6 -2
  267. package/dist/hooks/chunk-K4LMBQC2.js +0 -68
@@ -1,19 +1,19 @@
1
1
  #!/usr/bin/env tsx
2
- import { availableParallelism } from 'node:os'
3
2
  import { fileURLToPath } from 'node:url'
4
3
  import type { FileMapStamp } from '#scripts/josh/file-map-stamp'
5
4
  import { GATE_COMMAND } from '#scripts/josh/josh-command-types'
6
5
  import { composite_arguments, USAGE_ERROR_EXIT_CODE } from '#scripts/josh/josh-composite-arguments'
7
6
  import { lane_ledger } from '#scripts/lane/lane-ledger'
8
- import { bounded_pool } from '#scripts/lib/bounded-pool'
9
- import { buffered_process, FAIL_EXIT_CODE } from '#scripts/lib/buffered-process'
7
+ import { FAIL_EXIT_CODE } from '#scripts/lib/buffered-process'
10
8
  import { review_stamps } from '#scripts/review/review-stamps'
11
9
  import { review_tree } from '#scripts/review/review-tree'
12
10
  import { unit_worker_share } from '#scripts/test/unit-worker-share'
13
- import { core_budget } from './core-budget'
11
+ import { check_reuse, type CheckRecords } from './check-reuse'
12
+ import { free_cores } from './free-cores'
14
13
  import { gate_ledger, type GateLedgerStart } from './gate-ledger'
15
14
  import { gate_plan, type GatePlan } from './gate-plan'
16
- import { gate_report, type GateStep, type GateStepResult } from './gate-report'
15
+ import { gate_report, type GateStepResult } from './gate-report'
16
+ import { gate_run_steps, type ReusedChecks } from './gate-run-steps'
17
17
  import { gate_skip } from './gate-skip'
18
18
  import { build_gate_step, build_gate_steps, UNIT_WORKER_FLAG } from './gate-steps'
19
19
  import { gate_tree, type GateTree } from './gate-tree'
@@ -35,25 +35,6 @@ const { GATE_CHECKS } = gate_plan
35
35
 
36
36
  const GATE_TARGETS: ReadonlyArray<string> = GATE_CHECKS.map((check) => check.target)
37
37
 
38
- async function run_gate_step(step: GateStep): Promise<GateStepResult> {
39
- if (step.skip_reason !== undefined) {
40
- return {
41
- label: step.label,
42
- command: step.command_args.join(' '),
43
- output: project_checks.skip_notice('check', step.skip_reason),
44
- exit_code: 0,
45
- elapsed_ms: 0,
46
- }
47
- }
48
-
49
- const result = await buffered_process.run_buffered_process(
50
- step.command_args,
51
- step.cwd === undefined ? {} : { cwd: step.cwd },
52
- )
53
-
54
- return { label: step.label, command: step.command_args.join(' '), ...result }
55
- }
56
-
57
38
  // The record `josh review:brief` reads, written only on a fully green run and only with the tree it
58
39
  // was green on. `/code-review` runs in a forked process that reads none of
59
40
  // this repository's documents, so "the unit tests already passed" reaches it only if the invocation
@@ -65,7 +46,7 @@ async function run_gate_step(step: GateStep): Promise<GateStepResult> {
65
46
  // visible on the console, and a record erasing it would have the brief tell a review agent not to
66
47
  // re-run tests that never ran. A skip of a tool the project does not use (`NOT_USED_MARKER` — eslint
67
48
  // with no ESLint configuration) is not one: nothing went unverified, so it withholds nothing
68
- // (`gate_report.is_unverified_skip`). A step that passed **with a checker's warnings** is withheld for the
49
+ // (`gate_report.has_nothing_to_say`). A step that passed **with a checker's warnings** is withheld for the
69
50
  // same reason one place further on: since the record is reused instead of the checks being re-run, a
70
51
  // warning printed once would never be printed again on that tree, and hiding an eslint or
71
52
  // svelte-check finding is the same failure as hiding a skip. It is `has_checker_warning`, not
@@ -81,17 +62,13 @@ async function run_gate_step(step: GateStep): Promise<GateStepResult> {
81
62
  //
82
63
  // The destination is a parameter so a test can exercise the record without overwriting the one a
83
64
  // real run may be relying on — `josh gate` and `josh review:brief` share one path by design.
84
- function has_nothing_to_say(result: GateStepResult): boolean {
85
- return !gate_report.is_unverified_skip(result) && !gate_report.has_checker_warning(result)
86
- }
87
-
88
65
  async function record_green_gate(
89
66
  results: ReadonlyArray<GateStepResult>,
90
67
  before: Record<string, string>,
91
68
  target?: string,
92
69
  base?: string,
93
70
  ): Promise<void> {
94
- if (results.some((result) => !has_nothing_to_say(result))) return
71
+ if (results.some((result) => !gate_report.has_nothing_to_say(result))) return
95
72
 
96
73
  try {
97
74
  const after = await review_tree.read_changed_tree()
@@ -153,94 +130,6 @@ async function with_gate_marker<T>(
153
130
  }
154
131
  }
155
132
 
156
- // This gate's share of the machine, resolved once at the gate's entry and carried down as one value.
157
- // `available_cores` is the budget every check reserves against; `is_reserved`
158
- // is false for a gate nested inside another gate's unit suite — this repository's own gate tests — so
159
- // the outer gate reserves and every gate beneath it does not. Captured before this gate sets
160
- // `JOSH_UNIT_RUN_MARKED`, which is why it is read at entry rather than re-derived here.
161
- interface BudgetContext {
162
- available_cores: number
163
- is_reserved: boolean
164
- }
165
-
166
- function default_budget(): BudgetContext {
167
- return {
168
- available_cores: availableParallelism(),
169
- is_reserved: !unit_worker_share.is_nested_run() && !core_budget.is_held(),
170
- }
171
- }
172
-
173
- // A gate step paired with the cores it reserves from the machine-wide budget while it runs.
174
- // The weight travels with the step so `run_gate_steps` never has to re-pair a
175
- // step with its check by index.
176
- interface ReservedStep {
177
- step: GateStep
178
- weight: number
179
- memory_mb: number
180
- }
181
-
182
- async function build_reserved_steps(
183
- start_directory: string,
184
- plan: GatePlan,
185
- available_cores: number,
186
- ): Promise<ReadonlyArray<ReservedStep>> {
187
- return await Promise.all(
188
- plan.checks.map(async (check) => ({
189
- step: await build_gate_step(check, start_directory, plan),
190
- weight: gate_plan.check_weight(check, plan, available_cores),
191
- memory_mb: gate_plan.check_memory(check, plan, available_cores),
192
- })),
193
- )
194
- }
195
-
196
- // **A nested gate takes no reservation, and the budget is what makes that necessary rather than
197
- // merely tidy**. The unit step spawns the suite, which runs this repository's
198
- // own gate tests; each of those is a gate inside the outer gate's unit reservation, and a place it
199
- // claimed would wait for cores the outer gate cannot free until the suite — this test included —
200
- // finishes.
201
- async function run_reserved_step(
202
- reserved: ReservedStep,
203
- budget: BudgetContext,
204
- ): Promise<GateStepResult> {
205
- if (!budget.is_reserved) return await run_gate_step(reserved.step)
206
-
207
- return await core_budget.with_core_reservation(
208
- reserved.weight,
209
- async (admission) => ({
210
- ...(await run_gate_step(reserved.step)),
211
- budget_note: core_budget.describe_overflow(admission),
212
- }),
213
- { budget: budget.available_cores, memory_mb: reserved.memory_mb },
214
- )
215
- }
216
-
217
- // `bounded_pool` rather than a bare `Promise.all`, so the plan's `concurrency` is what decides how
218
- // many run at once. **The all-failures-in-one-pass property survives the
219
- // change** because no check ever rejects: `buffered_process` reports a non-zero exit as a value, so
220
- // the pool's first-failure abort — written for callers that spawn real Claude sessions — never
221
- // fires here and every queued check still runs. Results come back in input order however they
222
- // finished, which is what keeps the printed sections in declaration order. Each step first claims its
223
- // place in the machine-wide budget and waits while the budget is full.
224
- async function run_gate_steps(
225
- plan: GatePlan,
226
- budget: BudgetContext = default_budget(),
227
- ): Promise<ReadonlyArray<GateStepResult>> {
228
- const reserved = await build_reserved_steps(process.cwd(), plan, budget.available_cores)
229
-
230
- // **The checks run under the held mark, so a check's own dispatch claims nothing**.
231
- // Each check is a `pnpm josh <target>` child, and a target that declares
232
- // a weight would otherwise claim a second place for the cores reserved for it here. A nested gate
233
- // sets the mark too, since its checks are covered by the outer gate's reservation.
234
- return await core_budget.with_held_mark(
235
- async () =>
236
- await bounded_pool.bounded_map(
237
- reserved,
238
- plan.concurrency,
239
- async (entry) => await run_reserved_step(entry, budget),
240
- ),
241
- )
242
- }
243
-
244
133
  // **Both markers are claims about the unit suite, so a gate that is not running it makes neither**.
245
134
  // The in-flight marker tells the next `josh review:brief` that a gate is
246
135
  // covering this tree right now, which is what stops a review agent re-running the suite; the
@@ -276,15 +165,21 @@ async function run_marked_gate<T>(
276
165
  // The concurrent-run count is read here rather than inside `gate-plan.ts` for the reason the core
277
166
  // count already is: that module stays a pure function of its inputs, and the machine is asked once and
278
167
  // handed to both calls. Counted *before* the unit step writes its own marker, so `+ 1` is this gate.
279
- function announce_gate_plan(is_unit_included: boolean, available_cores: number): GatePlan {
168
+ // `free` bounds the unit cap the run count produced (`gate_plan.bound_by_free`).
169
+ function announce_gate_plan(
170
+ is_unit_included: boolean,
171
+ available_cores: number,
172
+ free: number | undefined,
173
+ ): GatePlan {
280
174
  const concurrent_runs = unit_worker_share.live_run_count() + unit_worker_share.SOLO_RUNS
281
175
  const is_kit_repository = project_checks.is_kit_repository(process.cwd())
282
- const plan = gate_plan.resolve_gate_plan(
176
+ const resolved = gate_plan.resolve_gate_plan(
283
177
  available_cores,
284
178
  concurrent_runs,
285
179
  is_unit_included,
286
180
  is_kit_repository,
287
181
  )
182
+ const plan = gate_plan.bound_by_free(resolved, available_cores, free)
288
183
 
289
184
  process.stdout.write(`${gate_plan.format_gate_plan(plan, available_cores, concurrent_runs)}\n`)
290
185
 
@@ -319,6 +214,35 @@ interface GateOptions {
319
214
  // The lane ledger a finished gate's duration is appended to. Only the CLI
320
215
  // entry resolves it, so a suite driving the gate never writes a measurement of checks that never ran.
321
216
  ledger_path?: string | undefined
217
+ // Where each check's own green record lives (`check-reuse.ts`). Only the CLI entry resolves it, for
218
+ // the ledger's reason: a suite whose checks never ran must not record them green on the live tree.
219
+ check_records?: CheckRecords | undefined
220
+ // The cores free of load from outside the core budget's ledger (`free-cores.ts`). Only the CLI entry
221
+ // resolves it, for the ledger's reason: a suite driving the gate never reads the machine, so its plan
222
+ // does not depend on what else happened to be running.
223
+ read_free_cores?: ((available_cores: number) => Promise<number | undefined>) | undefined
224
+ }
225
+
226
+ // **Per-check records belong to the full local gate alone**: CI's `--no-unit` gate is the safety net
227
+ // under the local reuse and stays as it was, so it neither reads nor writes one.
228
+ function check_records_of(plan: GatePlan, options: GateOptions): CheckRecords | undefined {
229
+ return gate_plan.has_unit_check(plan.checks) ? options.check_records : undefined
230
+ }
231
+
232
+ async function checks_to_reuse(
233
+ plan: GatePlan,
234
+ tree: GateTree,
235
+ options: GateOptions,
236
+ ): Promise<ReusedChecks> {
237
+ const records = check_records_of(plan, options)
238
+
239
+ if (records === undefined || options.is_forced === true) return new Map()
240
+
241
+ return await check_reuse.reusable_checks(
242
+ plan.checks.map((check) => check.label),
243
+ tree,
244
+ records,
245
+ )
322
246
  }
323
247
 
324
248
  // **The gate refuses to start when the scoped pair has not been green on this tree**
@@ -374,6 +298,10 @@ async function settle_gate(
374
298
 
375
299
  await gate_ledger.record(options.ledger, results, { elapsed_ms, is_passed })
376
300
 
301
+ const records = check_records_of(plan, options)
302
+
303
+ if (records !== undefined) await check_reuse.record_green_checks(results, tree, records)
304
+
377
305
  if (!is_passed) return FAIL_EXIT_CODE
378
306
 
379
307
  await record_whole_gate(plan, results, tree, options)
@@ -396,8 +324,11 @@ async function run_checked_gate(
396
324
  const ledger = await gate_ledger.start(options.ledger_path)
397
325
  // Resolved at the gate's entry, before this gate sets `JOSH_UNIT_RUN_MARKED`, so a gate nested in the
398
326
  // unit suite reads the flag as set and reserves nothing.
399
- const budget = default_budget()
400
- const plan = announce_gate_plan(is_unit_included, budget.available_cores)
327
+ const budget = gate_run_steps.default_budget()
328
+ // Read before the count for the ledger's reason: the reading waits out a sample window.
329
+ const free = await options.read_free_cores?.(budget.available_cores)
330
+ const plan = announce_gate_plan(is_unit_included, budget.available_cores, free)
331
+ const reused = await checks_to_reuse(plan, tree, options)
401
332
 
402
333
  // **The gate holds the unit-run marker for its whole run, not just its unit step.** The step that
403
334
  // would write it is a subprocess started a second or so after the count above, so two lanes launched
@@ -406,7 +337,7 @@ async function run_checked_gate(
406
337
  // the next lane asks; the guard inside the spawned `josh test:unit` sees the handoff and adds no
407
338
  // second marker for the same run.
408
339
  async function run_and_settle(): Promise<number> {
409
- const results = await run_gate_steps(plan, budget)
340
+ const results = await gate_run_steps.run_gate_steps(plan, budget, reused)
410
341
 
411
342
  return await settle_gate(plan, results, tree, { ...options, started_at, ledger })
412
343
  }
@@ -506,6 +437,8 @@ async function run_gate_command(
506
437
  if (process.argv[1] === fileURLToPath(import.meta.url)) {
507
438
  process.exitCode = await run_gate_command(process.argv.slice(FIRST_ARGUMENT_INDEX), {
508
439
  ledger_path: await lane_ledger.target(),
440
+ check_records: check_reuse.DEFAULT_RECORDS,
441
+ read_free_cores: async (available_cores) => await free_cores.read({ available_cores }),
509
442
  })
510
443
  }
511
444
 
@@ -519,11 +452,11 @@ const verification_gate = {
519
452
  clear_gate_running,
520
453
  mark_gate_running,
521
454
  record_green_gate,
522
- run_gate_steps,
455
+ run_gate_steps: gate_run_steps.run_gate_steps,
523
456
  scoped_precheck_refusal,
524
457
  with_gate_marker,
525
458
  run_gate_command,
526
- run_gate_step,
459
+ run_gate_step: gate_run_steps.run_gate_step,
527
460
  run_verification_gate,
528
461
  }
529
462
 
@@ -71,6 +71,7 @@ const REST_FIELD_NAMES = new Map<string, string>([
71
71
  ['url', 'html_url'],
72
72
  ['createdAt', 'created_at'],
73
73
  ['closedAt', 'closed_at'],
74
+ ['stateReason', 'state_reason'],
74
75
  ['updatedAt', 'updated_at'],
75
76
  ])
76
77
 
@@ -36,6 +36,7 @@ const LABEL_NAMES_FILTER = '.[].name'
36
36
  const LABELS_SEGMENT = '/labels'
37
37
  const COMMENTS_SEGMENT = '/comments'
38
38
  const CLOSED_STATE = 'closed'
39
+ const NOT_PLANNED_REASON = 'not_planned'
39
40
  const COLOR_HASH = '#'
40
41
  const UNPROCESSABLE_STATUS = 422
41
42
  const ALREADY_EXISTS_CODE = 'already_exists'
@@ -99,17 +100,37 @@ async function issue_try_comment(issue_number: string, body: string): Promise<bo
99
100
  // It is a required parameter that may be `undefined` rather than an optional one: skipping the
100
101
  // announcement is a decision the caller makes from what it read, never something a call site can
101
102
  // fall into by leaving an argument off.
102
- async function issue_close(issue_number: string, comment: string | undefined): Promise<boolean> {
103
+ async function close_with(
104
+ issue_number: string,
105
+ comment: string | undefined,
106
+ state: Readonly<Record<string, string>>,
107
+ ): Promise<boolean> {
103
108
  return await did_write_succeed(async () => {
104
109
  if (comment !== undefined) await issue_comment(issue_number, comment)
105
110
  await git_gh_exec.exec_gh_api({
106
111
  path: git_gh_api_path.issue_api_path(issue_number),
107
112
  method: PATCH_METHOD,
108
- body: JSON.stringify({ state: CLOSED_STATE }),
113
+ body: JSON.stringify(state),
109
114
  })
110
115
  })
111
116
  }
112
117
 
118
+ async function issue_close(issue_number: string, comment: string | undefined): Promise<boolean> {
119
+ return await close_with(issue_number, comment, { state: CLOSED_STATE })
120
+ }
121
+
122
+ // The same close as "not planned" — the reason `issue-merged.ts` reads as no merge, so a closed issue
123
+ // that was withdrawn is never taken for one whose work landed.
124
+ async function issue_close_not_planned(
125
+ issue_number: string,
126
+ comment: string | undefined,
127
+ ): Promise<boolean> {
128
+ return await close_with(issue_number, comment, {
129
+ state: CLOSED_STATE,
130
+ state_reason: NOT_PLANNED_REASON,
131
+ })
132
+ }
133
+
113
134
  // REST answers 422 `{"resource":"Label","code":"invalid","field":"color"}` for a color carrying a
114
135
  // leading `#`. The one caller passes the hash form (`EPIC_LABEL_COLOR`), so dropping it here is what
115
136
  // keeps that call site unchanged.
@@ -336,6 +357,7 @@ const git_gh_issue_write = {
336
357
  issue_comment,
337
358
  issue_try_comment,
338
359
  issue_close,
360
+ issue_close_not_planned,
339
361
  label_ensure,
340
362
  issue_create_request,
341
363
  issue_create_with_label,
@@ -5,6 +5,7 @@ import { issue_cite } from '#scripts/issue/issue-cite'
5
5
  import { session_cite } from '#scripts/issue/session-cite'
6
6
  import { animation_helpers, type AnimationOptions } from '#scripts/lib/animation-helpers'
7
7
  import { json_value } from '#scripts/lib/json-value'
8
+ import { acceptance_map } from '#scripts/review/acceptance-map'
8
9
  import { git_gh_command } from './git-gh-command'
9
10
  import { git_pr_error } from './git-pr-error'
10
11
  import { git_pr_messages } from './git-pr-messages'
@@ -172,6 +173,17 @@ function build_body(issue_info: IssueInfo, extra_body?: string): string {
172
173
  return `${closes}\n\n${extra_body}`
173
174
  }
174
175
 
176
+ // A body without the acceptance-criteria table gets one pending row per criterion, so the author fills
177
+ // in rows that already carry their identifiers — `josh followup` refuses the merge until they do.
178
+ async function body_with_map(issue_info: IssueInfo, extra_body?: string): Promise<string> {
179
+ const body = build_body(issue_info, extra_body)
180
+
181
+ if (acceptance_map.has_map(body)) return body
182
+ const map = acceptance_map.skeleton(await git_gh_command.issue_get_body(issue_info.number))
183
+
184
+ return map === undefined ? body : `${body}\n\n${map}`
185
+ }
186
+
175
187
  async function existing_pr_label(branch_name: string): Promise<ReleaseClassification | undefined> {
176
188
  if (
177
189
  !(await git_gh_command.pr_exists(branch_name)) ||
@@ -190,7 +202,7 @@ async function create_for_issue(
190
202
  ): Promise<void> {
191
203
  await create(
192
204
  build_title(issue_info),
193
- build_body(issue_info, extra_body),
205
+ await body_with_map(issue_info, extra_body),
194
206
  issue_info.branch_name,
195
207
  {
196
208
  label,
@@ -0,0 +1,78 @@
1
+ import { SUITE_TIMEOUT_MS } from '#scripts/lib/timeouts'
2
+ import { execa } from 'execa'
3
+ import { git_command } from './git-command'
4
+ import { git_spawn } from './git-spawn'
5
+
6
+ // A merge of the default branch that stops only on generated files is finished here rather than by a
7
+ // resumed session: each such file is the output of a command, so running that command on the merged
8
+ // tree writes the one correct resolution, with no conflict marker for anyone to read.
9
+ //
10
+ // **This table is the single list of what is resolved that way** — a file a person writes never
11
+ // belongs in it, because regenerating would discard that person's side of the merge. A conflict on any
12
+ // path not listed here still stops as `conflict`. A file belongs here only once its committed content
13
+ // is exactly what its command writes: `scripts/test/pure-files.ts` is not, so regenerating it would
14
+ // rewrite far more than the conflict.
15
+
16
+ interface RegeneratedFile {
17
+ path: string
18
+ command: ReadonlyArray<string>
19
+ }
20
+
21
+ const REGENERATED_FILES: ReadonlyArray<RegeneratedFile> = [
22
+ {
23
+ path: 'docs/josh-command-catalog.md',
24
+ command: ['pnpm', 'exec', 'tsx', 'scripts/document/generate-catalog.ts'],
25
+ },
26
+ ]
27
+
28
+ function regenerated_for(
29
+ files: ReadonlyArray<string>,
30
+ table: ReadonlyArray<RegeneratedFile>,
31
+ ): ReadonlyArray<RegeneratedFile> {
32
+ const entries = files.map((file) => table.find((entry) => entry.path === file))
33
+
34
+ return entries.every((entry) => entry !== undefined) ? entries : []
35
+ }
36
+
37
+ // Each generator writes only its own file, so they run side by side.
38
+ async function regenerate(entries: ReadonlyArray<RegeneratedFile>, cwd: string): Promise<void> {
39
+ await Promise.all(
40
+ entries.map(async (entry) => {
41
+ const [binary = '', ...command_arguments] = entry.command
42
+
43
+ await execa(binary, command_arguments, { cwd })
44
+ }),
45
+ )
46
+ }
47
+
48
+ // `--no-edit` keeps the message the merge was started with — the one carrying the issue number the
49
+ // `commit-msg` hook asks for.
50
+ async function conclude_merge(entries: ReadonlyArray<RegeneratedFile>): Promise<void> {
51
+ await git_spawn.with_output('add', ['--', ...entries.map((entry) => entry.path)])
52
+ await git_spawn.with_output('commit', ['--no-edit'], { timeout_ms: SUITE_TIMEOUT_MS })
53
+ }
54
+
55
+ // Whether the merge that stopped on `files` was finished. A command that fails leaves the merge in
56
+ // progress and answers `false`, so the stop is the `conflict` it would have been without this table.
57
+ async function resolve(
58
+ files: ReadonlyArray<string>,
59
+ table: ReadonlyArray<RegeneratedFile> = REGENERATED_FILES,
60
+ ): Promise<boolean> {
61
+ const entries = regenerated_for(files, table)
62
+
63
+ if (entries.length === 0) return false
64
+
65
+ try {
66
+ await regenerate(entries, await git_command.repository_root())
67
+ await conclude_merge(entries)
68
+ } catch {
69
+ return false
70
+ }
71
+
72
+ return true
73
+ }
74
+
75
+ const main_merge_regenerate = { REGENERATED_FILES, resolve }
76
+
77
+ export type { RegeneratedFile }
78
+ export { main_merge_regenerate }
@@ -6,6 +6,7 @@ import { composite_arguments, USAGE_ERROR_EXIT_CODE } from '#scripts/josh/josh-c
6
6
  import { error_text } from '#scripts/lib/error-message'
7
7
  import { git_command } from './git-command'
8
8
  import { main_merge_guard } from './main-merge-guard'
9
+ import { main_merge_regenerate } from './main-merge-regenerate'
9
10
 
10
11
  // `josh main:merge` (`josh mm`) — bring the default branch into the branch this checkout is on.
11
12
  //
@@ -62,18 +63,26 @@ interface MergeTarget {
62
63
  }
63
64
 
64
65
  // A failure that left no unmerged path — a remote that cannot be reached, a hook that refused — is not
65
- // a conflict, so it is rethrown rather than reported as one with nothing to resolve.
66
+ // a conflict, so it is rethrown rather than reported as one with nothing to resolve. A conflict only on
67
+ // generated files is regenerated and committed (`main-merge-regenerate.ts`), so it answers `undefined`
68
+ // and the merge reads as finished.
69
+ async function unresolved_conflict(error: unknown): Promise<MergeOutcome | undefined> {
70
+ const files = main_merge_guard.unmerged_paths(await git_command.status())
71
+
72
+ if (files.length === 0) throw error
73
+
74
+ return (await main_merge_regenerate.resolve(files)) ? undefined : { kind: 'conflict', files }
75
+ }
76
+
66
77
  async function merge_into(target: MergeTarget): Promise<MergeOutcome> {
67
78
  const { default_branch, current_branch } = target
68
79
 
69
80
  try {
70
81
  await git_command.merge_branch(default_branch, merge_message(default_branch, current_branch))
71
82
  } catch (error) {
72
- const files = main_merge_guard.unmerged_paths(await git_command.status())
73
-
74
- if (files.length === 0) throw error
83
+ const conflict = await unresolved_conflict(error)
75
84
 
76
- return { kind: 'conflict', files }
85
+ if (conflict !== undefined) return conflict
77
86
  }
78
87
 
79
88
  return { kind: target.is_current ? 'current' : 'merged', branch: default_branch }
@@ -54,16 +54,14 @@ function batching_mode(): LaneGuardMode {
54
54
  return lane_guard_policy.mode_here('batching')
55
55
  }
56
56
 
57
- // A guard the enumeration turns fully `off` says nothing at all. Batching is `notice` in a lane child
58
- // since kit#2276, so a candidate call is reached there; only a guard some future enumeration sets to
59
- // `off` is skipped. On the main line the mode is `refuse`.
57
+ // A guard the enumeration turns fully `off` says nothing at all. The lane-child mode and its reason
58
+ // live in `lane-guard-policy.ts`; on the main line the mode is `refuse`.
60
59
  function is_candidate(call: GuardedCall): boolean {
61
60
  return is_batch_candidate(call) && batching_mode() !== 'off'
62
61
  }
63
62
 
64
- // A refusable call is refused only where the mode is `refuse`. A lane child is `notice` (kit#2276), so
65
- // it reaches here and is withheld the refusal — the notice branch below carries the guidance instead,
66
- // because a refusal ends a headless child's turn (kit#2138).
63
+ // A refusable call is refused only where the mode is `refuse`. Under any other mode it is withheld the
64
+ // refusal; why a lane child takes that mode is its `because` in `lane-guard-policy.ts`.
67
65
  function should_block_here(tail: string, call: GuardedCall, refused_at_ms: number): boolean {
68
66
  if (batching_mode() !== 'refuse') return false
69
67
 
@@ -71,8 +69,7 @@ function should_block_here(tail: string, call: GuardedCall, refused_at_ms: numbe
71
69
  }
72
70
 
73
71
  // The whole-file write earns a notice on every run. The second branch turns a
74
- // refusable call into a notice under a `notice`-mode guard, which is the mode batching takes in a lane
75
- // child again since kit#2276. Both read the notice's own record, so a notice never spends the refusal's
72
+ // refusable call into a notice under a `notice`-mode guard. Both read the notice's own record, so a notice never spends the refusal's
76
73
  // stamp and re-fires on the notice's own `NOTICE_REFIRE_EVERY` cadence.
77
74
  function should_notify_here(tail: string, call: GuardedCall, notified_at_ms: number): boolean {
78
75
  if (time_batch_guard.should_notify(tail, call, notified_at_ms)) return true
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env tsx
2
+ import { fileURLToPath } from 'node:url'
3
+ import { core_budget } from '#scripts/gate/core-budget'
4
+ import {
5
+ CSPELL_COMMIT_CACHE_FLAGS,
6
+ ESLINT_COMMIT_CACHE_FLAGS,
7
+ PRETTIER_CACHE_FLAGS,
8
+ } from '#scripts/josh/josh-command-types'
9
+ import { resolve_spawn_exit } from '#scripts/lib/spawn-exit'
10
+ import { execa } from 'execa'
11
+
12
+ // The pre-commit hook's cspell, prettier and eslint runs over the staged files. They go through here
13
+ // rather than a bare `pnpm exec` in `lefthook/base.yml` so each cache location comes from the
14
+ // constant its ignore rules are asserted against (joshuafolkken/kit#3948) — a YAML copy would keep
15
+ // writing the old file the day that constant moves.
16
+ //
17
+ // cspell and eslint write the hook's own files: a staged-files cspell run prunes every entry it did
18
+ // not visit, and `--quiet` stores eslint reports without their warnings. Prettier does neither, so
19
+ // it shares the gate's file and a commit right after a green gate checks nothing twice.
20
+
21
+ const ARGV_OFFSET = 2
22
+ const USAGE_ERROR_EXIT_CODE = 2
23
+ const PNPM = 'pnpm'
24
+
25
+ const TOOL_ARGUMENTS: Readonly<Record<string, ReadonlyArray<string>>> = {
26
+ cspell: [
27
+ 'exec',
28
+ 'cspell',
29
+ 'lint',
30
+ '--no-must-find-files',
31
+ '--no-progress',
32
+ ...CSPELL_COMMIT_CACHE_FLAGS,
33
+ ],
34
+ prettier: ['exec', 'prettier', '--check', '--ignore-unknown', ...PRETTIER_CACHE_FLAGS],
35
+ eslint: ['exec', 'eslint', '--quiet', ...ESLINT_COMMIT_CACHE_FLAGS],
36
+ }
37
+
38
+ // **Each tool claims its place in the core budget** (joshuafolkken/kit#3944): lefthook starts the three
39
+ // in parallel, and off the ledger every other lane's admission saw them only as foreign load. Each is
40
+ // one process over the staged files — eslint over 40 of them took 16.9s of CPU over 14.0s of wall at
41
+ // 1,225 MB, the one-process shape the whole-tree scan's weight already names.
42
+ // The key is the one `CORE_WEIGHTS` and `MEMORY_MB` both declare the tool under.
43
+ const TOOL_BUDGETS: Readonly<Record<string, 'spell_check' | 'format' | 'eslint_scan'>> = {
44
+ cspell: 'spell_check',
45
+ prettier: 'format',
46
+ eslint: 'eslint_scan',
47
+ }
48
+
49
+ const USAGE = `Usage: josh pre-commit-lint <${Object.keys(TOOL_ARGUMENTS).join('|')}> [files...]`
50
+
51
+ function build_arguments(
52
+ tool: string,
53
+ staged_files: ReadonlyArray<string>,
54
+ ): ReadonlyArray<string> | undefined {
55
+ const tool_arguments = TOOL_ARGUMENTS[tool]
56
+ if (tool_arguments === undefined) return undefined
57
+
58
+ return [...tool_arguments, ...staged_files]
59
+ }
60
+
61
+ async function run_tool(command_arguments: ReadonlyArray<string>): Promise<number> {
62
+ const result = await execa(PNPM, command_arguments, { stdio: 'inherit', reject: false })
63
+
64
+ return resolve_spawn_exit(PNPM, result)
65
+ }
66
+
67
+ async function run(argv: ReadonlyArray<string>): Promise<number> {
68
+ const [tool = '', ...staged_files] = argv
69
+ const command_arguments = build_arguments(tool, staged_files)
70
+ const budget = TOOL_BUDGETS[tool]
71
+
72
+ if (command_arguments === undefined || budget === undefined) {
73
+ console.error(USAGE)
74
+
75
+ return USAGE_ERROR_EXIT_CODE
76
+ }
77
+
78
+ return await core_budget.with_command_reservation(
79
+ core_budget.CORE_WEIGHTS[budget],
80
+ async () => await run_tool(command_arguments),
81
+ { memory_mb: core_budget.MEMORY_MB[budget], command: `pre-commit ${tool}` },
82
+ )
83
+ }
84
+
85
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
86
+ process.exitCode = await run(process.argv.slice(ARGV_OFFSET))
87
+ }
88
+
89
+ const pre_commit_lint = { build_arguments, run, USAGE_ERROR_EXIT_CODE }
90
+
91
+ export { pre_commit_lint }
@@ -133,7 +133,7 @@ async function run_reserved_suite(vitest_arguments: ReadonlyArray<string>): Prom
133
133
  return await core_budget.with_command_reservation(
134
134
  gate_plan.direct_unit_weight(),
135
135
  async () => await test_unit_guard.run_guarded_unit(process.cwd(), vitest_arguments),
136
- { memory_mb: gate_plan.direct_unit_memory() },
136
+ { memory_mb: gate_plan.direct_unit_memory(), command: 'pre-push test:unit' },
137
137
  )
138
138
  }
139
139