@joshuafolkken/kit 1.1453.0 → 1.1471.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 (224) hide show
  1. package/.claude/skills/dependency-update/SKILL.md +26 -55
  2. package/.claude/skills/epic-commands/SKILL.md +87 -148
  3. package/.claude/skills/epic-commands/epic-bundle.md +45 -77
  4. package/.claude/skills/epic-commands/execution-waves.md +44 -81
  5. package/.claude/skills/verify-ui/SKILL.md +25 -50
  6. package/.claude/skills/workflow-commands/background-commands.md +36 -47
  7. package/.claude/skills/workflow-commands/backlogrun-child.md +72 -85
  8. package/.claude/skills/workflow-commands/backlogrun-handoff-report.md +1 -1
  9. package/.claude/skills/workflow-commands/backlogrun-lanes.md +91 -121
  10. package/.claude/skills/workflow-commands/backlogrun-park.md +73 -117
  11. package/.claude/skills/workflow-commands/backlogrun-progress.md +7 -9
  12. package/.claude/skills/workflow-commands/backlogrun-recovery.md +11 -20
  13. package/.claude/skills/workflow-commands/backlogrun-steps.md +43 -52
  14. package/.claude/skills/workflow-commands/chain-rule.md +24 -34
  15. package/.claude/skills/workflow-commands/followup.md +57 -76
  16. package/.claude/skills/workflow-commands/halfrun.md +2 -1
  17. package/.claude/skills/workflow-commands/issue-comments.md +43 -60
  18. package/.claude/skills/workflow-commands/kickoff.md +65 -74
  19. package/.claude/skills/workflow-commands/observation-filing.md +62 -107
  20. package/.claude/skills/workflow-commands/observation-ledger.md +56 -111
  21. package/.claude/skills/workflow-commands/pre-gate-cut.md +89 -138
  22. package/.claude/skills/workflow-commands/prerequisite.md +10 -15
  23. package/.claude/skills/workflow-commands/progress-watcher.md +21 -26
  24. package/.claude/skills/workflow-commands/prrun.md +2 -1
  25. package/.claude/skills/workflow-commands/retrospective.md +31 -50
  26. package/.claude/skills/workflow-commands/split-assessment.md +34 -58
  27. package/.claude/skills/workflow-commands/working-tree-hold.md +54 -71
  28. package/CLAUDE.md +7 -6
  29. package/README.md +2 -9
  30. package/dist/CLAUDE.md +7 -6
  31. package/dist/hooks/batch-guard.js +6 -6
  32. package/dist/hooks/{chunk-Y3GKIKGW.js → chunk-43ZHT22P.js} +31 -15
  33. package/dist/hooks/{chunk-MBJYQPEK.js → chunk-AS74OU3D.js} +24 -8
  34. package/dist/hooks/{chunk-3C4XRYG6.js → chunk-E7OIKX5Z.js} +17 -12
  35. package/dist/hooks/{chunk-Z4XI7CZS.js → chunk-EUMACUK2.js} +18 -12
  36. package/dist/hooks/{chunk-NO4AALP5.js → chunk-H5HVWYXK.js} +4 -4
  37. package/dist/hooks/{chunk-5C7L6FRC.js → chunk-IIGFYXKC.js} +346 -241
  38. package/dist/hooks/{chunk-W5ZKGTSY.js → chunk-J47HU5H5.js} +14 -3
  39. package/dist/hooks/{chunk-OLMO72ON.js → chunk-K4EOONYM.js} +1 -1
  40. package/dist/hooks/{chunk-LBFYEOXJ.js → chunk-K4LMBQC2.js} +4 -4
  41. package/dist/hooks/{chunk-D3NJXN5M.js → chunk-KC5AK2GF.js} +8 -6
  42. package/dist/hooks/chunk-SHGPANFB.js +56 -0
  43. package/dist/hooks/{chunk-EF5GQ6WS.js → chunk-YHBZAJSV.js} +3 -3
  44. package/dist/hooks/{chunk-SHYUQOIS.js → chunk-YYPI7SJZ.js} +5 -5
  45. package/dist/hooks/codex-hook-adapter.js +13 -13
  46. package/dist/hooks/duplicate-read-guard.js +7 -7
  47. package/dist/hooks/format-edited.js +3 -3
  48. package/dist/hooks/inputs.json +251 -247
  49. package/dist/hooks/investigation-guard.js +7 -7
  50. package/dist/hooks/pretool-guard.js +12 -12
  51. package/dist/hooks/session-lang.js +6 -5
  52. package/dist/hooks/stop-guard.js +75 -18
  53. package/dist/josh.js +6 -7
  54. package/dist/managed-marker/index.js +1 -2
  55. package/dist/version/index.js +15 -12
  56. package/eslint/base.js +2 -1
  57. package/eslint/rules/unicorn.js +16 -0
  58. package/package.json +3 -3
  59. package/prompts/coding-standards.md +12 -22
  60. package/prompts/collaboration-workflow/file-edits.md +26 -44
  61. package/prompts/collaboration-workflow/gh-rest.md +20 -36
  62. package/prompts/collaboration-workflow/glossary.md +5 -7
  63. package/prompts/collaboration-workflow/issue-citation.md +7 -13
  64. package/prompts/collaboration-workflow/issue-template.md +29 -29
  65. package/prompts/collaboration-workflow/operating-rules.md +35 -44
  66. package/prompts/collaboration-workflow/output-bounds.md +15 -15
  67. package/prompts/collaboration-workflow/overview.md +14 -19
  68. package/prompts/collaboration-workflow/plan-comment.md +13 -13
  69. package/prompts/collaboration-workflow/principles.md +50 -53
  70. package/prompts/collaboration-workflow/proposal-request.md +1 -9
  71. package/prompts/collaboration-workflow/report-format.md +32 -49
  72. package/prompts/collaboration-workflow/residency.md +10 -10
  73. package/prompts/collaboration-workflow/rule-delivery.md +9 -9
  74. package/prompts/collaboration-workflow/shell-body.md +11 -21
  75. package/prompts/collaboration-workflow/turn-batching.md +8 -14
  76. package/prompts/collaboration-workflow/upstream-interrupt.md +26 -34
  77. package/prompts/collaboration-workflow/wip-cap.md +24 -30
  78. package/prompts/collaboration-workflow.md +24 -25
  79. package/prompts/refactoring.md +33 -43
  80. package/prompts/review-rubric.md +84 -129
  81. package/prompts/review.md +111 -141
  82. package/prompts/sonar-hotspot-handling.md +22 -50
  83. package/prompts/testing-guide.md +48 -99
  84. package/scripts/adopt/adopt-toolkits.ts +2 -4
  85. package/scripts/agent/agent-exit-record.ts +23 -1
  86. package/scripts/agent/codex-agent-argv.ts +7 -2
  87. package/scripts/backlog/backlog-budget-cli.ts +1 -2
  88. package/scripts/backlog/backlog-drive-carried.ts +26 -0
  89. package/scripts/backlog/backlog-drive-cli.ts +10 -5
  90. package/scripts/backlog/backlog-drive-named.ts +2 -4
  91. package/scripts/backlog/backlog-drive-restore.ts +19 -1
  92. package/scripts/backlog/backlog-drive-seats.ts +27 -0
  93. package/scripts/backlog/backlog-drive.ts +11 -6
  94. package/scripts/backlog/backlog-ready.ts +13 -6
  95. package/scripts/behavior/behavior-rules.ts +3 -2
  96. package/scripts/cost-runtime/codex-usage.ts +4 -2
  97. package/scripts/cost-runtime/cost-cli.ts +1 -2
  98. package/scripts/delegation/investigation-guard.ts +1 -1
  99. package/scripts/delegation/investigation-reads.ts +1 -1
  100. package/scripts/discovery/repo-discovery.ts +4 -2
  101. package/scripts/discovery/repo-origin.ts +2 -4
  102. package/scripts/doctor/doctor-logic.ts +6 -3
  103. package/scripts/document/document-byte-budget.ts +27 -27
  104. package/scripts/document/document-byte-check.ts +1 -2
  105. package/scripts/document/entry-read-budget.ts +2 -2
  106. package/scripts/epic/epic-audit-rationale.ts +8 -2
  107. package/scripts/epic/epic-bundle-cli.ts +1 -2
  108. package/scripts/epic/epic-chains.ts +6 -2
  109. package/scripts/epic/epic-close-comment.ts +1 -2
  110. package/scripts/gate/verification-gate.ts +7 -2
  111. package/scripts/gh/git-pr-ai-review.ts +12 -1
  112. package/scripts/gh/git-pr-confirmation.ts +11 -7
  113. package/scripts/gh/git-pr.ts +6 -2
  114. package/scripts/git/git-common-directory.ts +3 -3
  115. package/scripts/git/git-diff-reads.ts +16 -9
  116. package/scripts/git/git-execa-error.ts +1 -2
  117. package/scripts/hooks/format-edited-file.ts +10 -4
  118. package/scripts/hooks/pre-push-unit.ts +15 -4
  119. package/scripts/hooks/pretool-guard.ts +23 -29
  120. package/scripts/hooks/step-zero-notice.ts +3 -2
  121. package/scripts/init/hook-command-rewrite.ts +6 -2
  122. package/scripts/init/init-logic-deploy-vps.ts +4 -2
  123. package/scripts/issue/behavior-change-lint.ts +12 -6
  124. package/scripts/issue/issue-scout-cli.ts +3 -2
  125. package/scripts/josh/file-map-stamp.ts +1 -3
  126. package/scripts/josh/hook-decision.ts +17 -2
  127. package/scripts/josh/josh-commands-workflow.ts +1 -1
  128. package/scripts/lane/lane-await.ts +3 -2
  129. package/scripts/lane/lane-child-invocation.ts +14 -1
  130. package/scripts/lane/lane-guard-policy.ts +4 -4
  131. package/scripts/lane/lane-ledger.ts +9 -0
  132. package/scripts/lane/lane-load.ts +9 -2
  133. package/scripts/lane/lane-measure-cli.ts +2 -0
  134. package/scripts/lane/lane-parked.ts +18 -0
  135. package/scripts/lane/lane-process-load.ts +125 -0
  136. package/scripts/lane/lane-process-stats.ts +93 -0
  137. package/scripts/lane/lane-registry.ts +8 -3
  138. package/scripts/lane/lane-session-broker.ts +29 -5
  139. package/scripts/lane/lane-spent.ts +14 -3
  140. package/scripts/lane/lane-stats.ts +1 -0
  141. package/scripts/managed-marker/managed-marker-logic.ts +1 -2
  142. package/scripts/notify/confirmation-options.ts +59 -0
  143. package/scripts/notify/notify-opt-in.ts +33 -0
  144. package/scripts/notify/telegram-notify.ts +25 -8
  145. package/scripts/notify/telegram-test-logic.ts +33 -1
  146. package/scripts/notify/telegram-test.ts +4 -2
  147. package/scripts/overrides/lockfile-overrides.ts +1 -3
  148. package/scripts/propagate/propagate.ts +1 -2
  149. package/scripts/release/release-progress.ts +3 -2
  150. package/scripts/report/report-lint.ts +7 -4
  151. package/scripts/review/review-record-cli.ts +6 -2
  152. package/scripts/rules/destructive-command.ts +6 -9
  153. package/scripts/rules/direct-filing.ts +3 -7
  154. package/scripts/rules/early-heartbeat.ts +7 -18
  155. package/scripts/rules/file-body.ts +6 -13
  156. package/scripts/rules/filing-cap.ts +3 -6
  157. package/scripts/rules/git-force.ts +5 -9
  158. package/scripts/rules/implementation-cut.ts +37 -35
  159. package/scripts/rules/index-guard.ts +4 -7
  160. package/scripts/rules/issue-comments.ts +5 -15
  161. package/scripts/rules/josh-git-bare.ts +3 -6
  162. package/scripts/rules/lane-background.ts +10 -21
  163. package/scripts/rules/lane-carry-conflict.ts +7 -17
  164. package/scripts/rules/lane-interactive-ask.ts +6 -11
  165. package/scripts/rules/lane-park.ts +5 -13
  166. package/scripts/rules/lane-split-park.ts +2 -7
  167. package/scripts/rules/lane-switch-main.ts +9 -12
  168. package/scripts/rules/oracle-consulted.ts +4 -5
  169. package/scripts/rules/piped-verification.ts +81 -31
  170. package/scripts/rules/poll-loop.ts +7 -17
  171. package/scripts/rules/pre-gate-cut.ts +7 -21
  172. package/scripts/rules/prior-comment-read.ts +7 -5
  173. package/scripts/rules/protected-files.ts +5 -9
  174. package/scripts/rules/raw-field-body.ts +3 -7
  175. package/scripts/rules/rule-body-guard.ts +4 -11
  176. package/scripts/rules/run-tail-rule.ts +6 -22
  177. package/scripts/rules/shell-body-trigger.ts +7 -8
  178. package/scripts/rules/shell-segments.ts +7 -0
  179. package/scripts/rules/stop-rules.ts +17 -29
  180. package/scripts/rules/test-declared-commit.ts +4 -10
  181. package/scripts/rules/third-party-write.ts +5 -10
  182. package/scripts/rules/worktree-guard.ts +5 -12
  183. package/scripts/run/board/run-board-github.ts +11 -3
  184. package/scripts/run/board/run-board-idle.ts +31 -0
  185. package/scripts/run/board/run-board-read.ts +9 -12
  186. package/scripts/run/board/run-board-reason.ts +7 -3
  187. package/scripts/run/board/run-board-state.ts +4 -0
  188. package/scripts/run/board/run-board-status.ts +18 -3
  189. package/scripts/run/board/run-board-thanks.ts +7 -2
  190. package/scripts/run/board/run-board-tick.ts +32 -7
  191. package/scripts/run/carry/run-carry-args.ts +1 -3
  192. package/scripts/run/carry/run-carry-conversation.ts +7 -2
  193. package/scripts/run/cut/run-cut-cli.ts +15 -20
  194. package/scripts/run/cut/run-cut-decline.ts +72 -0
  195. package/scripts/run/cut/run-cut-ready.ts +74 -0
  196. package/scripts/run/cut/run-cut-report.ts +13 -0
  197. package/scripts/run/cut/run-cut.ts +10 -1
  198. package/scripts/run/entry/run-entry-stop.ts +6 -3
  199. package/scripts/run/hold/run-hold-cli.ts +14 -3
  200. package/scripts/run/hold/run-hold.ts +6 -3
  201. package/scripts/run/merge/run-merge-cli.ts +30 -7
  202. package/scripts/run/merge/run-merge-ship.ts +59 -1
  203. package/scripts/run/merge/run-merge.ts +3 -2
  204. package/scripts/run/run-ending.ts +31 -3
  205. package/scripts/run/run-parent-cut-hook.ts +5 -10
  206. package/scripts/run/run-preflight.ts +1 -2
  207. package/scripts/run/run-stop-notify.ts +3 -2
  208. package/scripts/run/run-watcher-guard.ts +6 -3
  209. package/scripts/run/ship/run-ship-review-steps.ts +13 -4
  210. package/scripts/run/ship/run-ship-review.ts +15 -2
  211. package/scripts/run/tidy/run-tidy-cli.ts +3 -3
  212. package/scripts/run/tidy/run-tidy-lanes.ts +3 -2
  213. package/scripts/safe-chain/project-config.ts +7 -2
  214. package/scripts/sync/sync-hook-safety.ts +8 -2
  215. package/scripts/test/unit-worker-share.ts +3 -2
  216. package/scripts/time-runtime/time-batch-guard.ts +3 -3
  217. package/scripts/time-runtime/time-hook-transcript.ts +3 -2
  218. package/scripts/time-runtime/time-shell.ts +10 -0
  219. package/scripts/version/effective-upstream.ts +3 -4
  220. package/scripts/version/kit-version-config.ts +8 -2
  221. package/scripts/version/latest-corepack.ts +1 -2
  222. package/scripts/version/release-hold.ts +11 -4
  223. package/scripts/version/version-commands.ts +12 -4
  224. package/dist/hooks/chunk-UT7YC3N2.js +0 -56
@@ -2,12 +2,10 @@
2
2
 
3
3
  ## 0. Per-Requirement Test Coverage (MANDATORY)
4
4
 
5
- Before writing any implementation code, **list every requirement from the user's request and assign a test to each one**. This step is non-negotiable.
5
+ Before any implementation code, **list every requirement from the user's request and assign a test to each one**.
6
6
 
7
7
  ### Planning template
8
8
 
9
- For a user request with N requirements, enumerate each one before touching any source files:
10
-
11
9
  ```text
12
10
  Req 1: "Label chip shown immediately"
13
11
  → E2E: open editor, type new label, press Enter → chip visible without blurring
@@ -21,19 +19,17 @@ Req N: ...
21
19
 
22
20
  ### Rules
23
21
 
24
- - **Every user-facing behavior change** (UI interaction, keyboard shortcut, visible state change) must have a corresponding E2E test.
25
- - **Every logic/utility change** (pure functions, filters, transforms) must have a unit test. A bug fix takes a regression test; a refactor takes tests pinning the existing behavior BEFORE the change (`prompts/refactoring.md`).
22
+ - User-facing behavior change (UI interaction, keyboard shortcut, visible state) → an E2E test.
23
+ - Logic/utility change (pure functions, filters, transforms) → a unit test. Bug fix → a regression test; refactor → tests pinning the existing behavior BEFORE the change (`prompts/refactoring.md`).
26
24
  - **Non-runtime updates (pre-approved manual-only exception):** a change touching no executable runtime code path — docs, non-executable config, editor/IDE files, cosmetic asset swaps with no selector/path change — may be verified by hand; declare it in Step 0 with the reason. The mechanically exempt (`*.md`, `.editorconfig`, `.idea/**`, `.vscode/**`, `prompts/**`) need no declaration. A runtime change whose test is infeasible needs the reason and user approval.
27
- - **basic で kit がテストできない言語だけの変更:** `package.json` に `josh.profile: basic`(改名前の `static` も同じ)が記録され、変更が HTML/CSS や Lua などのソースだけなら自動テストは不要。HTML/CSS はブラウザーで対象ページを開き、想定する画面幅でレイアウト・リンク・HTML 内の操作を確認する。そのほかのソースは変更した処理を実際に動かす。どちらも結果を記録する。同じ変更に JavaScript または TypeScript のファイルが含まれれば自動テストを行う。
28
- - If a test is genuinely infeasible (e.g., native OS date-picker popup cannot be driven by Playwright), write a comment in the test file explaining why and test the closest observable behavior instead (e.g., that the editor remains open after the `change` event fires).
29
- - Do **not** report a requirement as done if its test is missing.
25
+ - **basic で kit がテストできない言語だけの変更:** `package.json` に `josh.profile: basic`(改名前の `static` も同じ)があり、変更が HTML/CSS や Lua などのソースだけなら自動テストは不要。HTML/CSS はブラウザーで対象ページを開き、想定する画面幅でレイアウト・リンク・HTML 内の操作を確認する。そのほかのソースは変更した処理を実際に動かす。どちらも結果を記録する。JavaScript または TypeScript のファイルを含む変更は自動テストを行う。
26
+ - Test infeasible (e.g., a native OS date-picker Playwright cannot drive) → comment why in the test file and test the closest observable behavior (e.g., the editor stays open after `change`).
27
+ - A requirement without its test is not done.
30
28
 
31
29
  ### Verification checklist (add to the completion gate)
32
30
 
33
- - [ ] Count requirements in the user's request.
34
- - [ ] Count tests added/updated for those requirements.
35
- - [ ] The two counts match (or each gap is documented as infeasible with a comment).
36
- - [ ] **UI changes only:** a screenshot of the affected screen was captured and visually confirmed before reporting completion (passing tests are not proof the UI looks correct — see the **UI verification (screenshot)** rule in the completion gate). If a screenshot is impossible in this environment, that is stated and the user is asked to verify visually.
31
+ - [ ] Requirement count equals the count of tests added/updated (each gap documented as infeasible).
32
+ - [ ] UI changes: a screenshot of the affected screen was checked (`CLAUDE.md` → UI verification); if impossible here, say so and ask the user to check.
37
33
 
38
34
  ---
39
35
 
@@ -44,21 +40,19 @@ Req N: ...
44
40
  | `src/routes/` pages, UI components with user interaction | E2E (Playwright) |
45
41
  | `.ts`/`.js` utilities, `src/lib/server/`, display-only components | Unit/Integration (Vitest) |
46
42
 
47
- **The type is decided from the path, not by asking.** A change under `src/routes/` takes E2E; every other runtime path takes Unit — a component with interaction is reached through the route that renders it, and that route is under `src/routes/`. `pnpm josh test:declared` prints the type each untested runtime file calls for on its `required` detail line, so the choice never becomes a stop.
43
+ **The type is decided from the path, not by asking**: under `src/routes/` → E2E; every other runtime path → Unit (an interactive component is reached through its route). `pnpm josh test:declared` prints the type per untested runtime file on its `required` line.
48
44
 
49
45
  ### Test file naming & placement (one unambiguous rule)
50
46
 
51
- There is exactly **one** convention for every test file — never choose between two. The rules below are mandatory; some are config-enforced (a violating file runs under the wrong vitest project or not at all), others are enforced by convention/review — each rule states which.
52
-
53
47
  | Test kind | Required filename | Routed to |
54
48
  | ------------------- | ------------------------ | -------------------------------- |
55
49
  | Unit / integration | `*.test.ts` | node/unit vitest project |
56
50
  | Component / browser | `*.svelte.test.ts` | browser/component vitest project |
57
51
  | E2E | `src/routes/**/*.e2e.ts` | Playwright |
58
52
 
59
- - **Use `*.test.ts` — never `*.spec.ts`.** **Lint-enforced** by `eslint/rules/test-filename.js` (wired into `eslint/base.js`) for every JS/TS extension; how the ban is wired and why: `docs/maintainers/testing-guide-history.md` → "Why the `*.spec.*` ban is a lint rule".
60
- - **The `.svelte.` infix is required for component/browser tests and must be preserved.** `*.svelte.test.ts` routes the file to the **browser/component** vitest project (`include: src/**/*.svelte.{test,spec}.{js,ts}`); plain `*.test.ts` routes to the **node/unit** project (`include: src/**/*.{test,spec}.{js,ts}`). Renaming `Foo.svelte.test.ts` → `Foo.test.ts` silently moves it to the wrong project — do not drop the `.svelte.` infix.
61
- - **Colocate every test next to the code it exercises.** A top-level `tests/` directory is **not used** — place `foo.test.ts` beside `foo.ts`, and E2E specs under the relevant `src/routes/**` path. The top-level `tests/` ban is **lint-enforced** by the same rule (`eslint/rules/test-filename.js`). Note: `playwright.config.ts` discovers E2E via `testMatch: '**/*.e2e.{ts,js}'`, so an `*.e2e.ts` placed outside `src/routes/**` would still run — that specific `src/routes/**` placement is enforced by **convention/review, not config**, and must be upheld manually.
53
+ - **Never `*.spec.ts`** — lint-enforced by `eslint/rules/test-filename.js` (wired into `eslint/base.js`) for every JS/TS extension; rationale: `docs/maintainers/testing-guide-history.md` → "Why the `*.spec.*` ban is a lint rule".
54
+ - **Keep the `.svelte.` infix** on component/browser tests: `*.svelte.test.ts` matches the browser project (`include: src/**/*.svelte.{test,spec}.{js,ts}`), plain `*.test.ts` the node project (`include: src/**/*.{test,spec}.{js,ts}`); dropping it silently moves the file to the wrong project.
55
+ - **Colocate** each test beside its code (`foo.test.ts` next to `foo.ts`); no top-level `tests/` (lint-enforced by the same rule). E2E specs go under `src/routes/**` — enforced by review only, since `playwright.config.ts` `testMatch: '**/*.e2e.{ts,js}'` runs them anywhere.
62
56
 
63
57
  ---
64
58
 
@@ -72,156 +66,111 @@ There is exactly **one** convention for every test file — never choose between
72
66
  ### Test Functions
73
67
 
74
68
  - Outside `describe`: `test`; inside `describe`: `it`
75
- - Use `describe` only when grouping multiple tests is necessary
69
+ - `describe` only when grouping multiple tests
76
70
 
77
71
  ### Parameterized Tests
78
72
 
79
73
  - Playwright: `for` loop; Vitest: `test.each` / `it.each`
80
- - Test cases: define as `cases` or `*_cases` array at file top (outside test functions)
74
+ - Cases: a `cases` or `*_cases` array at file top, outside test functions
81
75
 
82
76
  ### Constants
83
77
 
84
- - All magic numbers/strings except `0`, `1`, `-1` must be `UPPER_SNAKE_CASE` constants
85
- - Place after imports at file top
78
+ - Magic numbers/strings except `0`, `1`, `-1` → `UPPER_SNAKE_CASE` constants after the imports
86
79
 
87
80
  ### Imports
88
81
 
89
- - Always include `.js` extension: `import { foo } from './bar.js'`
82
+ - Always the `.js` extension: `import { foo } from './bar.js'`
90
83
  - Type imports: `import { type Foo } from '...'`
91
84
 
92
85
  ### Playwright
93
86
 
94
- - Element selection: `page.getByTestId('id')` — add `data-testid` to implementation files
87
+ - Select with `page.getByTestId('id')`; add `data-testid` to the implementation
95
88
  - Test functions always `async`
96
- - **Project split:** `playwright.config.ts` defines **`e2e-guest`** (no auth: `src/routes/**/dash-guest.e2e.ts`, `src/routes/**/demo.e2e.ts`), then **`e2e-main`** (depends on `e2e-guest`), then **`e2e-leak-check`** (depends on `e2e-main`). Authenticated projects set `storageState` to `src/routes/.auth/user.json` when that file exists — **there is no UI login on every test**; cookies/session are restored from disk.
97
- - **Workers:** Playwright uses `workers: 1` because authenticated E2E shares one storage state; higher parallelism races on the same DB user.
98
- - **Timeouts:** Guest/main/leak projects use **per-project test timeouts** in `playwright.config.ts` so switching tabs and dash actions are not cut off by the global default.
89
+ - `playwright.config.ts` projects, each depending on the previous: `e2e-guest` (no auth: `src/routes/**/dash-guest.e2e.ts`, `src/routes/**/demo.e2e.ts`) → `e2e-main` → `e2e-leak-check`. Authenticated ones restore `storageState` from `src/routes/.auth/user.json` when it exists — no UI login per test.
90
+ - `workers: 1` — authenticated E2E shares one storage state and DB user.
91
+ - Guest/main/leak projects set per-project test timeouts so tab switches and dash actions are not cut off.
99
92
 
100
93
  ### Regression fix workflow (E2E data / cleanup bugs)
101
94
 
102
- When fixing bugs where tests leave data behind (or similar persistent state):
95
+ When tests leave persistent state behind:
103
96
 
104
- 1. **Add or extend a failing guard** — e.g. `src/routes/dash/dash-leak-check.e2e.ts` exports `dash_leak_guard.scrub_then_assert_clean`: it scrubs tasks whose titles contain the `E2E_` automation prefix, then asserts none remain. Confirm it **fails** while scrub or per-test teardown is broken (red).
105
- 2. **Fix production code or test teardown** — cleanup must be reliable (prefer `data-testid` over locale-specific `aria-label` for automation; avoid swallowing cleanup errors unless explicitly intended).
106
- 3. **Confirm the guard passes** (green) with full `pnpm test:e2e` (or targeted Playwright command above).
107
- 4. **Document** any new invariant in this guide or the relevant E2E helper module.
97
+ 1. Add or extend a guard and confirm it fails (e.g. `dash_leak_guard.scrub_then_assert_clean` in `src/routes/dash/dash-leak-check.e2e.ts` scrubs `E2E_`-prefixed tasks, then asserts none remain).
98
+ 2. Fix production code or teardown — prefer `data-testid` over locale-specific `aria-label`; do not swallow cleanup errors unless intended.
99
+ 3. Confirm green with full `pnpm test:e2e` (or a targeted Playwright run).
100
+ 4. Document any new invariant here or in the E2E helper module.
108
101
 
109
102
  ### Error Messages
110
103
 
111
- - Include expected and actual values explicitly
104
+ - State expected and actual values
112
105
  - Wrap `number` in template literals with `String()`
113
- - Don't add `?? ''` unless the value can genuinely be `undefined`
106
+ - No `?? ''` unless the value can be `undefined`
114
107
 
115
108
  ### Type Safety
116
109
 
117
- - No `any`; always use loop variables (avoids unused-variable lint errors)
118
- - Use `@ts-expect-error`, not `@ts-ignore`
110
+ - No `any`; use every loop variable
111
+ - `@ts-expect-error`, not `@ts-ignore`
119
112
 
120
113
  ### Statement Limit
121
114
 
122
- - Max 10 statements per test function; split complex tests if exceeded
115
+ - Max 10 statements per test function; split beyond that
123
116
 
124
117
  ### No live network
125
118
 
126
- - **A unit test must not reach the network.** Mock every read that leaves the process — a `gh` call, an HTTP request, a CLI subprocess that makes one
127
- - **Mock the whole read, not the first half of it.** A helper that stubs one call and leaves a second one on its default calls through, and the test still **passes** — slower, and against whatever the remote answers. That is how joshuafolkken/kit#1353 reached a 10-second timeout in CI on a change that had nothing to do with it
128
- - In kit's own checkout `vitest.config.ts` arms a guard that fails the run and lists the `gh` invocations it recorded — the commands, not the test that made them, which the command text is usually enough to find (`docs/josh-commands.md` → `josh test:unit`). Elsewhere the rule holds without one
119
+ - **A unit test must not reach the network** — mock every read leaving the process (`gh` call, HTTP request, CLI subprocess that makes one).
120
+ - **Mock the whole read**: a stub of one call leaving a second on its default still passes, slowly and against the remote (joshuafolkken/kit#1353: a 10-second CI timeout).
121
+ - kit's `vitest.config.ts` fails such a run, listing the `gh` invocations (`docs/josh-commands.md` → `josh test:unit`); elsewhere the rule holds unguarded.
129
122
 
130
123
  ### A josh command that writes state runs through the integration harness (kit only)
131
124
 
132
- - **A new or changed state-writing josh command needs a test in all three environments of `scripts/test/josh-harness.ts`** — kit, a consumer without `docs/`, a lane worktree (joshuafolkken/kit#2402). Packed suite: `vitest.harness.config.ts`
125
+ - A new or changed state-writing josh command needs a test in all three environments of `scripts/test/josh-harness.ts` — kit, a consumer without `docs/`, a lane worktree (joshuafolkken/kit#2402). Packed suite: `vitest.harness.config.ts`
133
126
 
134
127
  ---
135
128
 
136
129
  ## 3. Checklist
137
130
 
138
- - [ ] **Before “done”:** **Completion gate** in `CLAUDE.md` — `pnpm josh gate` (lint, type check, spell check and unit tests), then E2E per § 6 below: the CI E2E job where a pull request is open, **`pnpm josh test:e2e`** run by you where there is none. **Do not** stop at `pnpm josh test:unit` when E2E applies, and **never** ask the user to run it. Run it with `CI` unset so Playwright uses **DEV** (`pnpm run dev` on the port `playwright.config.ts` resolves from `PORT_SEED`), matching the VS Code Test plugin. ReadLints **0 errors** on touched files; Playwright **no failures and no flaky** runs in what you report. **If Playwright cannot run in your environment:** do **not** use `pnpm build` or `CI=true` unless the user asked — say the E2E result is missing and that the gate is therefore not closed
139
- - [ ] Magic numbers/strings → constants (except `0`, `1`, `-1`)
140
- - [ ] `.js` on all import paths
141
- - [ ] Playwright: `data-testid` selectors only
142
- - [ ] `async`/`await` used correctly
143
- - [ ] No `any`; all loop variables used
144
- - [ ] Max 10 statements per test function
145
- - [ ] `read_lints` run; 0 errors before reporting done
146
- - [ ] No unnecessary `?? ''`; `String()` for numbers in template literals
131
+ - [ ] `pnpm josh gate`, then E2E per § 6: the CI E2E job where a pull request is open; where there is none, run `pnpm josh test:e2e` with `CI` unset (DEV: `pnpm run dev` on the port `playwright.config.ts` derives from `PORT_SEED`); no `pnpm build` or `CI=true` unless asked. Playwright cannot run here → report the E2E result missing and the gate not closed
132
+ - [ ] `read_lints`: 0 errors on touched files; Playwright: no failures and no flaky runs
133
+ - [ ] § 2 holds: constants, `.js` imports, `data-testid` selectors, correct `async`/`await`, no `any`, ≤10 statements, `String()` / no needless `?? ''`
147
134
 
148
135
  ---
149
136
 
150
137
  ## 4. Reference Files
151
138
 
152
- **E2E:** `src/routes/**/page.e2e.ts`, `src/routes/**/praise.e2e.ts`
153
- **Unit:** `src/lib/data/phrases/phrases.test.ts`, `src/lib/data/praise-audio.test.ts`
154
- **ESLint:** `eslint.config.js`
139
+ E2E: `src/routes/**/page.e2e.ts`, `src/routes/**/praise.e2e.ts` · Unit: `src/lib/data/phrases/phrases.test.ts`, `src/lib/data/praise-audio.test.ts` · ESLint: `eslint.config.js`
155
140
 
156
141
  ---
157
142
 
158
143
  ## 5. Unit Test Example — Stateful Functions
159
144
 
160
- ```typescript
161
- import { expect, test } from 'vitest'
162
- import { get_praise_audio_file, reset_praise_audio_index } from './praise-audio.js'
163
-
164
- test('returns expected values in sequence', () => {
165
- reset_praise_audio_index() // reset state at the start of each test
166
-
167
- const first_result = get_praise_audio_file()
168
- expect(first_result).toBe('expected-value-1')
169
-
170
- const second_result = get_praise_audio_file()
171
- expect(second_result).toBe('expected-value-2')
172
- })
173
- ```
145
+ Reset module state at the start of each test (`reset_praise_audio_index()` in `src/lib/data/praise-audio.test.ts`), then assert each call of the sequence in order.
174
146
 
175
147
  ---
176
148
 
177
149
  ## 6. Closing the E2E gate without a human run
178
150
 
179
- No human E2E run closes the gate, and nothing a red local run stopped is let through
180
- (`docs/maintainers/testing-guide-history.md`).
151
+ No human E2E run closes the gate, and nothing a red local run stopped is let through (`docs/maintainers/testing-guide-history.md`).
181
152
 
182
153
  ### Which result closes the gate
183
154
 
184
- Decided by whether a pull request is open, never by judgement:
155
+ Decided by whether a pull request is open:
185
156
 
186
157
  | Situation | What closes the gate |
187
158
  | -------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
188
159
  | A pull request is open — `fullrun`, `prrun`, `backlogrun` | The CI E2E job. `pnpm josh followup` waits for the checks and refuses to merge while any of them is non-passing. |
189
160
  | No pull request — `halfrun`'s stop before commit, a completion reported outside any workflow | `pnpm josh test:e2e`, run by **you**, output read by you. |
190
161
 
191
- **Never ask the user to run it, in either row.** A completion report is not allowed to depend on
192
- somebody being at the keyboard; that dependency is what made a `fullrun` chain stall at its last
193
- step. And never report completion on an E2E result nobody read — an unread result is not a pass.
162
+ **Never ask the user to run it**, in either row, and never report completion on an unread E2E result — unread is not a pass.
194
163
 
195
- **Where the application layer ships an integrated gate, prefer it** over a bare `pnpm josh test:e2e`
196
- for the second row: `@joshuafolkken/app-kit` ships `pnpm josh-app verify`, which builds once and
197
- boots once to run the E2E suite and the DAST scan against that one server — and which delegates E2E
198
- to `josh test:e2e`, so the skip below still applies. **Decide by the command list the installed
199
- toolkit prints, not by which toolkit is installed and not by a version number written here**: the
200
- same rule the UI gate uses, and the only one that survives a release.
164
+ Second row: prefer an integrated gate when the installed toolkit's printed command list has one (not by toolkit or version) — `@joshuafolkken/app-kit`'s `pnpm josh-app verify` builds and boots once for E2E and the DAST scan, delegating E2E to `josh test:e2e` (so the skip below applies).
201
165
 
202
166
  ### A project with no E2E suite
203
167
 
204
- `pnpm josh test:e2e` runs through `scripts/test/test-e2e-guard.ts`, which **skips and exits 0**, naming
205
- the reason, when `@playwright/test` is not installed or no `*.e2e.{ts,js}` file exists. That skip is
206
- a **defined** outcome, not an accident: a project with no E2E suite has nothing for the gate to
207
- read, and the run says so out loud. **A skip you did not see printed is not one** — it is an unread
208
- result, and it falls under the previous paragraph.
168
+ `pnpm josh test:e2e` runs through `scripts/test/test-e2e-guard.ts`, which skips and exits 0, naming the reason, when `@playwright/test` is not installed or no `*.e2e.{ts,js}` file exists. That is a defined outcome; **a skip you did not see printed is an unread result**.
209
169
 
210
- **CI decides the same question by the same rule.** The `e2e-detect` job in
211
- `templates/workflows/ci.yml` enables the `e2e` job when any `*.e2e.{ts,js}` exists outside
212
- `node_modules` — the guard's own glob — whatever the config's filename;
213
- `scripts/ci/ci-yml-e2e-detect.test.ts` pins the agreement. CI enables the job on the spec alone, even
214
- where the guard skips for a missing `@playwright/test`, so a spec that cannot run ends red rather than
215
- silently green.
170
+ CI uses the same rule: the `e2e-detect` job in `templates/workflows/ci.yml` enables the `e2e` job when any `*.e2e.{ts,js}` exists outside `node_modules` (the guard's glob), whatever the config's filename — on the spec alone, so a spec that cannot run ends red, not silently green. `scripts/ci/ci-yml-e2e-detect.test.ts` pins the agreement.
216
171
 
217
172
  ### The gate is not weakened
218
173
 
219
- - **A non-passing check keeps the merge closed.** `evaluate_pr_state` returns `success` only when
220
- GitHub reports the pull request `CLEAN` **and** every required check passes; the only exception is a
221
- non-passing set made entirely of CodeRabbit checks (the temporary kit#753 policy). A failing check
222
- ends the wait on the poll that sees it, by name (`PR checks failed (failed checks: E2E).`).
223
- - **Filtering, narrowing or reinterpreting E2E output to get past it is a workaround** —
224
- `CLAUDE.md` → "Cross-package problems"; reporting the filtered result honestly does not make it
225
- compliant.
226
- - **`E2E` stays off the default required-check list**; a project opts in through
227
- `JOSH_REQUIRED_CHECKS`. `scripts/gh/git-pr-checks-e2e-gate.test.ts` pins both properties.
174
+ - **A non-passing check keeps the merge closed.** `evaluate_pr_state` returns `success` only when GitHub reports the pull request `CLEAN` and every required check passes; the only exception is a non-passing set made entirely of CodeRabbit checks (the temporary kit#753 policy). A failing check ends the wait on the poll that sees it, by name (`PR checks failed (failed checks: E2E).`).
175
+ - **Filtering, narrowing or reinterpreting E2E output to get past it is a workaround** (`CLAUDE.md` → "Cross-package problems"), however honestly reported.
176
+ - `E2E` stays off the default required-check list; a project opts in through `JOSH_REQUIRED_CHECKS`. `scripts/gh/git-pr-checks-e2e-gate.test.ts` pins both properties.
@@ -65,8 +65,7 @@ function declared_toolkits(project_root: string): ReadonlyArray<string> {
65
65
  // declares one, so a toolkit shipping several CLIs still resolves to one deterministically.
66
66
  function pick_bin_name(package_name: string, bin: string | Record<string, string>): string {
67
67
  const unscoped = path.basename(package_name)
68
- if (typeof bin === 'string') return unscoped
69
- if (bin[unscoped] !== undefined) return unscoped
68
+ if (typeof bin === 'string' || bin[unscoped] !== undefined) return unscoped
70
69
 
71
70
  return Object.keys(bin)[0] ?? unscoped
72
71
  }
@@ -99,8 +98,7 @@ function has_runnable_cli(project_root: string, bin_name: string): boolean {
99
98
  // spawning it would fail the sync step rather than say what is wrong.
100
99
  function resolve_toolkit(project_root: string, package_name: string): Release | undefined {
101
100
  const bin_name = read_bin_name(project_root, package_name)
102
- if (bin_name === undefined) return undefined
103
- if (!has_runnable_cli(project_root, bin_name)) return undefined
101
+ if (bin_name === undefined || !has_runnable_cli(project_root, bin_name)) return undefined
104
102
 
105
103
  return { package_name, version: LATEST_VERSION, bin_name }
106
104
  }
@@ -20,6 +20,28 @@ function read_exit_record(transcript: string): ClaudeResultEvent | undefined {
20
20
  .findLast((record) => record !== undefined)
21
21
  }
22
22
 
23
- const agent_exit_record = { read_exit_record }
23
+ function event_type(line: string): unknown {
24
+ const value = json_value.parse_or_undefined(line)
25
+
26
+ return typeof value === 'object' && value !== null && 'type' in value ? value.type : undefined
27
+ }
28
+
29
+ // The Claude stream events a turn is made of — a Codex transcript's own (`turn.completed`, `error`)
30
+ // are none of these, so one never reads as killed.
31
+ const TURN_TYPES: ReadonlySet<unknown> = new Set(['system', 'assistant', 'user', 'stream_event'])
32
+
33
+ // Whether a Claude session was killed inside a turn: its last event is a turn's, not a `result`. A
34
+ // finished turn ends on a result; a background task can wake the session for another turn, and a
35
+ // process killed before that turn's result leaves other events last.
36
+ function is_mid_turn(transcript: string): boolean {
37
+ const last = transcript
38
+ .split('\n')
39
+ .map((line) => event_type(line))
40
+ .findLast((type) => type !== undefined)
41
+
42
+ return TURN_TYPES.has(last)
43
+ }
44
+
45
+ const agent_exit_record = { is_mid_turn, read_exit_record }
24
46
 
25
47
  export { agent_exit_record }
@@ -47,8 +47,13 @@ function ledger_arguments(
47
47
  common_directory: string | undefined,
48
48
  profile: AgentProfile,
49
49
  ): ReadonlyArray<string> {
50
- if (common_directory === undefined || profile.role !== agent_role_profile.WORKER) return []
51
- if (path.basename(common_directory) !== GIT_DIRECTORY) return []
50
+ if (
51
+ common_directory === undefined ||
52
+ profile.role !== agent_role_profile.WORKER ||
53
+ path.basename(common_directory) !== GIT_DIRECTORY
54
+ ) {
55
+ return []
56
+ }
52
57
 
53
58
  const directory = path.join(path.dirname(common_directory), LEDGER_DIRECTORY)
54
59
 
@@ -157,8 +157,7 @@ function build_input(values: ParsedValues, now_ms: number): BudgetInput | undefi
157
157
 
158
158
  const started_at_ms = to_time_ms(cli_flags.string_of(values.started))
159
159
 
160
- if (started_at_ms === undefined) return undefined
161
- if (!are_values_readable(values)) return undefined
160
+ if (started_at_ms === undefined || !are_values_readable(values)) return undefined
162
161
 
163
162
  return input_of(values, { answer, now_ms, started_at_ms })
164
163
  }
@@ -0,0 +1,26 @@
1
+ import { has_label_name, IN_PROGRESS_LABEL } from '#scripts/issue/issue-labels'
2
+ import { issue_state_cli } from '#scripts/issue/issue-state-cli'
3
+ import type { LaneInfo } from '#scripts/lane/lane-registry'
4
+
5
+ // The open lanes an earlier invocation dispatched that still claim their issue: `in-progress`, and not
6
+ // stopped for a person's review. Their unit may have died across the hand-off, so the drive collects
7
+ // them; a lane parked or held for review is a person's and is left alone.
8
+
9
+ async function is_claimed(issue: string): Promise<boolean> {
10
+ const read = await issue_state_cli.read_issue(issue)
11
+
12
+ if (read.kind !== 'state') return false
13
+
14
+ return has_label_name(read.state.labels, IN_PROGRESS_LABEL) && !read.state.is_human_review
15
+ }
16
+
17
+ async function carried(lanes: ReadonlyArray<LaneInfo>): Promise<ReadonlyArray<string>> {
18
+ const dispatched = lanes.filter((lane) => lane.output !== undefined).map((lane) => lane.issue)
19
+ const claims = await Promise.all(dispatched.map(async (issue) => await is_claimed(issue)))
20
+
21
+ return dispatched.filter((_issue, index) => claims[index] === true)
22
+ }
23
+
24
+ const backlog_drive_carried = { carried }
25
+
26
+ export { backlog_drive_carried }
@@ -6,7 +6,7 @@ import { issue_cite } from '#scripts/issue/issue-cite'
6
6
  import { hook_decision } from '#scripts/josh/hook-decision'
7
7
  import { josh_command } from '#scripts/josh/josh-run'
8
8
  import { lane_await, type AwaitState } from '#scripts/lane/lane-await'
9
- import { lane_registry } from '#scripts/lane/lane-registry'
9
+ import { lane_registry, type LaneInfo } from '#scripts/lane/lane-registry'
10
10
  import { lane_sampler } from '#scripts/lane/lane-sampler'
11
11
  import { error_text } from '#scripts/lib/error-message'
12
12
  import { json_value } from '#scripts/lib/json-value'
@@ -24,6 +24,7 @@ import {
24
24
  type OfferRead,
25
25
  } from './backlog-drive'
26
26
  import { backlog_drive_args, type DriveContext } from './backlog-drive-args'
27
+ import { backlog_drive_carried } from './backlog-drive-carried'
27
28
  import { backlog_drive_finish } from './backlog-drive-finish'
28
29
  import { backlog_drive_launch } from './backlog-drive-launch'
29
30
  import { backlog_drive_named } from './backlog-drive-named'
@@ -32,6 +33,7 @@ import { backlog_drive_offer_argv } from './backlog-drive-offer-argv'
32
33
  import { backlog_drive_owner } from './backlog-drive-owner'
33
34
  import { backlog_drive_restore } from './backlog-drive-restore'
34
35
  import { backlog_drive_retrospective } from './backlog-drive-retrospective'
36
+ import { backlog_drive_seats } from './backlog-drive-seats'
35
37
  import { backlog_offer_cli } from './backlog-offer-cli'
36
38
  import { backlog_ready } from './backlog-ready'
37
39
 
@@ -153,10 +155,10 @@ function finished_checker(seeded: ReadonlyArray<string>): (issue: string) => boo
153
155
  }
154
156
  }
155
157
 
156
- async function open_lanes(): Promise<ReadonlyArray<string>> {
158
+ async function open_lanes(): Promise<ReadonlyArray<LaneInfo>> {
157
159
  const lanes = await lane_registry.list_lanes()
158
160
 
159
- return lanes.filter((lane) => !lane.is_stranded).map((lane) => lane.issue)
161
+ return lanes.filter((lane) => !lane.is_stranded)
160
162
  }
161
163
 
162
164
  function end_line(end: DriveEnd): string {
@@ -190,6 +192,7 @@ function ports_of(
190
192
  offer: async (state) => await read_offer(state, context),
191
193
  launch: async (issue) => await backlog_drive_launch.launch(issue, context.owner),
192
194
  free_lanes: backlog_ready.drive_free_lane_count,
195
+ has_lane: backlog_drive_seats.has_kept_lane,
193
196
  judgement_owed: judgement_reader(),
194
197
  now: () => new Date(),
195
198
  sleep: async (milliseconds) => {
@@ -211,9 +214,11 @@ async function seeded_lanes(context: DriveContext): Promise<ReadonlyArray<string
211
214
 
212
215
  await backlog_drive_named.reconcile(carry, events, context.owner)
213
216
 
214
- const lanes = [...new Set([...(await open_lanes()), ...context.awaited])]
217
+ const open = await open_lanes()
218
+ const lanes = [...new Set([...open.map((lane) => lane.issue), ...context.awaited])]
219
+ const dispatched = await backlog_drive_carried.carried(open)
215
220
 
216
- return backlog_drive_restore.restore(lanes, events, carry.merged_issues ?? [])
221
+ return backlog_drive_restore.restore(lanes, events, carry.merged_issues ?? [], dispatched)
217
222
  }
218
223
 
219
224
  // The interval between two asks is `backlog:budget`'s own, so the driver keeps the one its reason prints.
@@ -1,5 +1,4 @@
1
- import { has_label_name, NEEDS_DECISION_LABEL } from '#scripts/issue/issue-labels'
2
- import { issue_state_cli } from '#scripts/issue/issue-state-cli'
1
+ import { lane_parked } from '#scripts/lane/lane-parked'
3
2
  import { run_carry, type RunCarry } from '#scripts/run/carry/run-carry'
4
3
  import type { RunEvent } from '#scripts/run/event/run-event-stream'
5
4
  import type { MergeResult } from '#scripts/run/merge/run-merge-cli'
@@ -98,9 +97,8 @@ function skip_after_failure(target: string, issue: string, owner: string): void
98
97
  async function should_skip(issue: string, outcome: MergeResult['outcome']): Promise<boolean> {
99
98
  if (outcome === 'failed') return true
100
99
  if (outcome !== 'parked') return false
101
- const read = await issue_state_cli.read_issue(issue)
102
100
 
103
- return read.kind === 'state' && has_label_name(read.state.labels, NEEDS_DECISION_LABEL)
101
+ return await lane_parked.is_parked(issue)
104
102
  }
105
103
 
106
104
  async function mark_done(issue: string, result: MergeResult, owner: string): Promise<void> {
@@ -54,14 +54,32 @@ function parked_issues(events: ReadonlyArray<RunEvent>): ReadonlySet<number> {
54
54
  )
55
55
  }
56
56
 
57
+ // The issues this invocation launched or settled — any other dispatched lane was carried in from an
58
+ // earlier invocation.
59
+ function mentioned_issues(events: ReadonlyArray<RunEvent>): ReadonlySet<string> {
60
+ const tracked = events.filter(
61
+ (event) =>
62
+ event.kind === run_event_stream.EVENT_KIND.CHILD_LAUNCH || SETTLED_KINDS.has(event.kind),
63
+ )
64
+
65
+ return new Set(tracked.map((event) => issue_of(event)).filter((issue) => issue !== undefined))
66
+ }
67
+
68
+ // The lanes still in flight: launched by this invocation and not settled, or dispatched by an earlier
69
+ // one and never seen by this one — a lane nobody collects otherwise waits forever.
57
70
  function restore(
58
71
  lanes: ReadonlyArray<string>,
59
72
  events: ReadonlyArray<RunEvent>,
60
73
  merged_issues: ReadonlyArray<number>,
74
+ dispatched: ReadonlyArray<string> = [],
61
75
  ): ReadonlyArray<string> {
62
76
  const active = active_issues(events)
77
+ const mentioned = mentioned_issues(events)
78
+ const carried = new Set(dispatched.filter((issue) => !mentioned.has(issue)))
63
79
 
64
- return lanes.filter((issue) => active.has(issue) && !merged_issues.includes(Number(issue)))
80
+ return lanes.filter(
81
+ (issue) => (active.has(issue) || carried.has(issue)) && !merged_issues.includes(Number(issue)),
82
+ )
65
83
  }
66
84
 
67
85
  export const backlog_drive_restore = { active_issues, restore, settled_issues, parked_issues }
@@ -0,0 +1,27 @@
1
+ import { lane_registry } from '#scripts/lane/lane-registry'
2
+ import type { DrivePorts } from './backlog-drive'
3
+
4
+ type SeatPorts = Pick<DrivePorts, 'free_lanes' | 'has_lane'>
5
+
6
+ // The issues a pass launches: every one that already holds a kept lane, which it reuses, then as many
7
+ // of the rest as there are free lanes.
8
+ async function seated(issues: ReadonlyArray<string>, ports: SeatPorts): Promise<Array<string>> {
9
+ const kept = await Promise.all(issues.map(async (issue) => await ports.has_lane(issue)))
10
+ const fresh = issues.filter((_issue, index) => kept[index] !== true)
11
+
12
+ return [
13
+ ...issues.filter((_issue, index) => kept[index] === true),
14
+ ...fresh.slice(0, await ports.free_lanes()),
15
+ ]
16
+ }
17
+
18
+ // A kept lane is one a child was dispatched into — `lane:launch` reuses it rather than opening one.
19
+ async function has_kept_lane(issue: string): Promise<boolean> {
20
+ const lane = await lane_registry.find_open_lane(issue)
21
+
22
+ return lane?.output !== undefined
23
+ }
24
+
25
+ const backlog_drive_seats = { has_kept_lane, seated }
26
+
27
+ export { backlog_drive_seats }
@@ -20,6 +20,7 @@ import { issue_cite } from '#scripts/issue/issue-cite'
20
20
  import type { LaunchOutcome } from '#scripts/lane/lane-launch-cli'
21
21
  import type { MergeResult } from '#scripts/run/merge/run-merge-cli'
22
22
  import { run_merge_token, type MergeToken } from '#scripts/run/merge/run-merge-token'
23
+ import { backlog_drive_seats } from './backlog-drive-seats'
23
24
 
24
25
  const { MERGE_TOKEN } = run_merge_token
25
26
  const RUN_VERDICT = 'run'
@@ -124,6 +125,9 @@ interface DrivePorts {
124
125
  // The lanes free right now — `backlog_ready.drive_free_lane_count` in production, the limit less the
125
126
  // open lanes.
126
127
  free_lanes: () => Promise<number>
128
+ // Whether the issue already holds a kept lane (one a child was dispatched into) — it is reused, so it
129
+ // takes no free seat.
130
+ has_lane: (issue: string) => Promise<boolean>
127
131
  // How many open issues lacking `auto-ok` still owe a judgement — `auto_ok_judge_read.owed` in
128
132
  // production, `0` when the read failed.
129
133
  judgement_owed: () => Promise<number>
@@ -265,10 +269,10 @@ async function refused(
265
269
  return ended('launch', state, undefined, issue)
266
270
  }
267
271
 
268
- // **No more launches than free lanes**. `backlog:offer` lists every ready issue
269
- // without reading the lanes, so launching all of them overran `JOSH_LANE_LIMIT` until `lane:open` ran
270
- // out of seats. The free count is read once, before the first launch, because every launch here opens
271
- // a lane the count would otherwise have to be told about.
272
+ // **No more new lanes than free lanes**. `backlog:offer` lists every ready issue without reading the
273
+ // lanes, so launching all of them overran `JOSH_LANE_LIMIT` until `lane:open` ran out of seats. An issue
274
+ // that already holds a kept lane reuses it and takes no seat (`backlog-drive-seats.ts`); the rest launch
275
+ // up to the free count, read once before the first launch.
272
276
  //
273
277
  // **Only a launch is activity**: a pass that started nothing — every lane taken
274
278
  // — keeps `active` where it was, or the once-a-minute offer would restart the idle budget for good.
@@ -284,9 +288,10 @@ async function launch_all(
284
288
  ports: DrivePorts,
285
289
  ): Promise<PassResult> {
286
290
  const in_flight = [...state.in_flight]
287
- const free = await ports.free_lanes()
288
291
 
289
- for (const issue of issues.slice(0, free)) {
292
+ const launching = await backlog_drive_seats.seated(issues, ports)
293
+
294
+ for (const issue of launching) {
290
295
  // eslint-disable-next-line no-await-in-loop -- lanes launch in order and the first refusal ends the pass
291
296
  const outcome = await ports.launch(issue)
292
297
 
@@ -2,7 +2,7 @@ import { git_gh_command } from '#scripts/gh/git-gh-command'
2
2
  import { issue_cite } from '#scripts/issue/issue-cite'
3
3
  import { josh_command, type JoshResult } from '#scripts/josh/josh-run'
4
4
  import { lane_capacity } from '#scripts/lane/lane-capacity'
5
- import { lane_registry } from '#scripts/lane/lane-registry'
5
+ import { lane_registry, type LaneInfo } from '#scripts/lane/lane-registry'
6
6
  import type { RunCarry } from '#scripts/run/carry/run-carry'
7
7
  import { run_headless } from '#scripts/run/run-headless'
8
8
  import { run_invocation } from '#scripts/run/run-invocation'
@@ -28,6 +28,16 @@ async function live_lane_count(): Promise<number> {
28
28
  return lane_registry.live_count(await lane_registry.list_lanes())
29
29
  }
30
30
 
31
+ // Free lanes beside `lanes`, `undefined` where the limit could not be read: the count `lane:limit`
32
+ // prints, and the one `run:board` draws, so the two never answer apart.
33
+ async function free_lanes_beside(lanes: ReadonlyArray<LaneInfo>): Promise<number | undefined> {
34
+ const limit = await lane_capacity.lane_limit()
35
+
36
+ if (limit.kind !== 'limit') return undefined
37
+
38
+ return lane_capacity.free_lanes(limit.limit, lane_registry.live_count(lanes))
39
+ }
40
+
31
41
  async function free_under(limit: number): Promise<number> {
32
42
  return lane_capacity.free_lanes(limit, await live_lane_count())
33
43
  }
@@ -42,11 +52,7 @@ async function free_under(limit: number): Promise<number> {
42
52
  // (`backlog:next`) is gated behind a free lane, so a network read here would defeat that design. The
43
53
  // two can diverge, but only toward over-counting live lanes → fewer free lanes, the safe direction.
44
54
  async function free_lane_count(): Promise<number> {
45
- const limit = await lane_capacity.lane_limit()
46
-
47
- if (limit.kind !== 'limit') return NO_FREE
48
-
49
- return await free_under(limit.limit)
55
+ return (await free_lanes_beside(await lane_registry.list_lanes())) ?? NO_FREE
50
56
  }
51
57
 
52
58
  // **The drive's count throws on an unreadable limit**. The drive launches no
@@ -165,6 +171,7 @@ const backlog_ready = {
165
171
  read_backlog_next,
166
172
  print_offer_hint,
167
173
  free_lane_count,
174
+ free_lanes_beside,
168
175
  live_lane_count,
169
176
  drive_free_lane_count,
170
177
  drains_pool,