@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
@@ -65,8 +65,9 @@ function notice(raw_payload: string, root: string = process.cwd()): string | und
65
65
 
66
66
  const target = STAMP.path(transcript)
67
67
 
68
- if (STAMP.last_ms(target) !== hook_decision.NEVER_MS) return undefined
69
- if (!STAMP.record(target, Date.now())) return undefined
68
+ if (STAMP.last_ms(target) !== hook_decision.NEVER_MS || !STAMP.record(target, Date.now())) {
69
+ return undefined
70
+ }
70
71
 
71
72
  return NOTICE
72
73
  }
@@ -65,8 +65,12 @@ function rewrite_command_value(value: string): string {
65
65
  .split(PNPM_JOSH_PREFIX)
66
66
  .join(BUNDLE_INVOCATION)
67
67
 
68
- if (!rewritten.includes(claude_plugin_config.MARKETPLACE_PATH)) return rewritten
69
- if (rewritten.includes(MISSING_INSTALL_GUARD)) return rewritten
68
+ if (
69
+ !rewritten.includes(claude_plugin_config.MARKETPLACE_PATH) ||
70
+ rewritten.includes(MISSING_INSTALL_GUARD)
71
+ ) {
72
+ return rewritten
73
+ }
70
74
 
71
75
  return `${MISSING_INSTALL_GUARD}${rewritten}`
72
76
  }
@@ -30,8 +30,10 @@ function patch_deploy_vps_pnpm(content: string): string {
30
30
  const with_pnpm12 = content.replaceAll(PNPM_INSTALL_PATTERN, (command) =>
31
31
  replace_outdated_install(command),
32
32
  )
33
- if (with_pnpm12.includes(VERSION_CHECK_MARKER)) return with_pnpm12
34
- if (!OLD_PNPM_CHECK_PATTERN.test(with_pnpm12)) return with_pnpm12
33
+
34
+ if (with_pnpm12.includes(VERSION_CHECK_MARKER) || !OLD_PNPM_CHECK_PATTERN.test(with_pnpm12)) {
35
+ return with_pnpm12
36
+ }
35
37
 
36
38
  return with_pnpm12
37
39
  .replace(OLD_MAJOR_LINE_PATTERN, '')
@@ -76,9 +76,12 @@ function firing_point_problem(body: string): string | undefined {
76
76
  }
77
77
 
78
78
  function baseline_problem(body: string): string | undefined {
79
- if (!markdown_section.has_line(body, BASELINE_HEADING)) return undefined
80
-
81
- if (baseline_measure.has_command_value(body)) return undefined
79
+ if (
80
+ !markdown_section.has_line(body, BASELINE_HEADING) ||
81
+ baseline_measure.has_command_value(body)
82
+ ) {
83
+ return undefined
84
+ }
82
85
 
83
86
  return 'baseline must be written as `command → value`, not prose (it has to be re-runnable)'
84
87
  }
@@ -86,9 +89,12 @@ function baseline_problem(body: string): string | undefined {
86
89
  // The reproduction section is checked for format only where the heading is present — a missing heading
87
90
  // is already reported by `heading_problems`, so reporting it here too would double the message.
88
91
  function reproduction_problem(body: string): string | undefined {
89
- if (!markdown_section.has_line(body, REPRODUCTION_HEADING)) return undefined
90
-
91
- if (reproduction_measure.has_command_output(body)) return undefined
92
+ if (
93
+ !markdown_section.has_line(body, REPRODUCTION_HEADING) ||
94
+ reproduction_measure.has_command_output(body)
95
+ ) {
96
+ return undefined
97
+ }
92
98
 
93
99
  return 'reproduction must be a command and its actual output (a fenced block), not prose ("確認した" is not re-runnable)'
94
100
  }
@@ -73,8 +73,9 @@ function read_arguments(argv: ReadonlyArray<string>): ScoutArguments | undefined
73
73
  const { values, positionals } = parsed
74
74
  const [title] = positionals
75
75
 
76
- if (has_invalid_arguments(title, values.body, values['body-file'])) return undefined
77
- if (title === undefined) return undefined
76
+ if (title === undefined || has_invalid_arguments(title, values.body, values['body-file'])) {
77
+ return undefined
78
+ }
78
79
 
79
80
  return { title, body: values.body ?? '', body_file: values['body-file'] }
80
81
  }
@@ -211,9 +211,7 @@ function is_own_record(stamp: FileMapStamp): boolean {
211
211
  function complete_at(target: string): string | undefined {
212
212
  const stamp = read_at(target)
213
213
 
214
- if (stamp === undefined) return undefined
215
-
216
- if (!is_own_record(stamp)) return undefined
214
+ if (stamp === undefined || !is_own_record(stamp)) return undefined
217
215
 
218
216
  return stamp_file.write_stamp(target, { ...stamp, completed_at: new Date().toISOString() })
219
217
  }
@@ -87,8 +87,8 @@ function deny_envelope(reason: string): string {
87
87
  }
88
88
 
89
89
  // **A rewrite, not a refusal**: the call proceeds with `updated_input` merged
90
- // over its own. `allow` is the decision the harness applies an `updatedInput` under; a settings `deny`
91
- // rule still wins over it, so this never widens what the call may do.
90
+ // over its own and is allowed past the permission prompt; a settings `deny` rule still wins over it.
91
+ // Where the line may carry commands the hook did not write, `flow_rewrite_envelope` is the one to use.
92
92
  function rewrite_envelope(updated_input: Record<string, unknown>, context: string): string {
93
93
  return JSON.stringify({
94
94
  systemMessage: context,
@@ -101,6 +101,20 @@ function rewrite_envelope(updated_input: Record<string, unknown>, context: strin
101
101
  })
102
102
  }
103
103
 
104
+ // **A rewrite that leaves the permission to the harness.** No `permissionDecision`, so the rewritten
105
+ // call meets the same permission flow the typed one would have — whatever else rides the line beside
106
+ // the rewritten part is prompted for, or not, exactly as before (joshuafolkken/kit#3875).
107
+ function flow_rewrite_envelope(updated_input: Record<string, unknown>, context: string): string {
108
+ return JSON.stringify({
109
+ systemMessage: context,
110
+ hookSpecificOutput: {
111
+ hookEventName: HOOK_EVENT_NAME,
112
+ updatedInput: updated_input,
113
+ additionalContext: context,
114
+ },
115
+ })
116
+ }
117
+
104
118
  // **A notice, not a decision.** It carries no `permissionDecision`, so the call proceeds exactly as it
105
119
  // would have; `additionalContext` is what reaches the model, and `systemMessage` is what the person
106
120
  // watching sees. Both are filled because either channel alone leaves one of the two blind — and the
@@ -476,6 +490,7 @@ const hook_decision = {
476
490
  deny_envelope,
477
491
  emit_outcome,
478
492
  fault_notice,
493
+ flow_rewrite_envelope,
479
494
  is_switch_enabled,
480
495
  is_switch_opt_in,
481
496
  load_environment_file,
@@ -47,7 +47,7 @@ const WORKFLOW_COMMANDS: Record<string, CommandEntry> = {
47
47
  description: 'Send Telegram notification',
48
48
  category: 'Workflow',
49
49
  reference: [
50
- '[--task-type <type>] [--body <text> | --body-file <path>] [--issue-url <url>] [--pr-url <url>] [--issue-title <text>] [--repo-name <name>]',
50
+ '[--task-type <type>] [--body <text> | --body-file <path>] [--issue-url <url>] [--pr-url <url>] [--issue-title <text>] [--repo-name <name>] [--recommended <text>] [--alternative <text>]...',
51
51
  'automation',
52
52
  ['notifications'],
53
53
  ],
@@ -89,8 +89,9 @@ function process_trace_default(issue: string): ProcessTrace {
89
89
 
90
90
  process_tools.throw_if_missing(PGREP_COMMAND, result.error)
91
91
 
92
- if (result.status === PROCESS_FOUND) return PROCESS_ALIVE
93
- if (lane_handoff.is_ship_running(issue, process.cwd())) return PROCESS_ALIVE
92
+ if (result.status === PROCESS_FOUND || lane_handoff.is_ship_running(issue, process.cwd())) {
93
+ return PROCESS_ALIVE
94
+ }
94
95
 
95
96
  return result.status === PROCESS_NOT_FOUND ? PROCESS_NONE : PROCESS_UNKNOWN
96
97
  }
@@ -8,6 +8,8 @@ interface ShipStop extends ShipResume {
8
8
  }
9
9
 
10
10
  const CHILD_INVOCATION = 'fullrun'
11
+ // What a brokered reviewer's prompt ends with, so the one pattern below finds it too.
12
+ const REVIEW_INVOCATION = 'the reviewer of lane'
11
13
  const RESUME_GUIDE = '.claude/skills/workflow-commands/pre-gate-cut.md'
12
14
  // What a detached `josh ship --detach` supervisor's command line carries. It ends
13
15
  // with the `"<title> #<N>"` title on purpose, so the liveness pattern below finds it by the same trailing
@@ -31,10 +33,20 @@ function child_invocation(issue: string): string {
31
33
  // pattern stopped matching as finished — parking an issue that was still shipping. The supervisor's
32
34
  // command line ends with its `#<N>` title, so the one extended pattern covers both, and the wait, the
33
35
  // liveness read and the reaper keep searching with a single pattern.
36
+ //
37
+ // **A brokered reviewer counts as the child too** (joshuafolkken/kit#3878). The lane's broker starts it
38
+ // for a ship and it outlives a ship that stopped waiting: unmatched, the lane read as finished, the
39
+ // run parked the issue under it, and the reap left it running checks in the parked lane.
34
40
  function process_pattern(issue: string): string {
35
41
  issue_number_shape.require_issue_number(issue)
36
42
 
37
- return `(${CHILD_INVOCATION}|${SHIP_PROCESS} .*) ${issue_cite.plain(issue)}$`
43
+ return `(${CHILD_INVOCATION}|${REVIEW_INVOCATION}|${SHIP_PROCESS} .*) ${issue_cite.plain(issue)}$`
44
+ }
45
+
46
+ function review_invocation(issue: string): string {
47
+ issue_number_shape.require_issue_number(issue)
48
+
49
+ return `${REVIEW_INVOCATION} ${issue_cite.plain(issue)}`
38
50
  }
39
51
 
40
52
  function resume_invocation(issue: string): string {
@@ -82,6 +94,7 @@ const lane_child_invocation = {
82
94
  outage_resume_invocation,
83
95
  process_pattern,
84
96
  resume_invocation,
97
+ review_invocation,
85
98
  ship_stop_invocation,
86
99
  }
87
100
 
@@ -47,25 +47,25 @@ const LANE_GUARD_POLICY: ReadonlyArray<LaneGuardEntry> = [
47
47
  id: 'investigation',
48
48
  mode_in_lane_child: 'notice',
49
49
  because:
50
- 'a refusal ends a child turn so it cannot fire as one (kit#2138), which is why it was `off` from #2138 to #2382. The exemption reason — "the child is itself the delegated unit, so it cannot dispatch a sub-unit to read its own edit targets" — did not hold in measurement: kit#2382 re-measured the six lane children after #2347 (#2366 #2370 #2371 #2372 #2374 #2375) and every one dispatched sub-agents (10 launches, 2 of them investigation fan-outs) and read 4–17 files it never edited, all six over the threshold of 3. So the reading the guard exists to send out was there to send. It takes `notice`, not `off`: the child is nudged to delegate rather than killed, on the `additionalContext` path batching and duplicate-read already use, and the notice names the concrete unedited files this run read (the #2276 precedent) rather than the generic "delegate" #2164 → #2178 measured not to move the number. The density is a genuine experiment — re-measured on the next backlogrun, and a notice that still does not move it is redesigned rather than kept',
50
+ 'a refusal ends a child turn, yet lane children were measured reading files they never edited, so the child is nudged to delegate by a notice rather than killed',
51
51
  },
52
52
  {
53
53
  id: 'batching',
54
54
  mode_in_lane_child: 'off',
55
55
  because:
56
- 'a refusal ends a child turn so it cannot fire as one (kit#2138); kit#2164 replaced it with a notice, kit#2178 took that notice off after it did not move the density, and kit#2276 restored it — naming the concrete recent calls and recurring every single-call turn — as a genuine experiment to be redesigned rather than kept if the next backlogrun still missed 1.40. kit#2405 is that re-measurement, and it cuts the notice off (`off`). `josh time:density` read 1.13 calls per round trip over the ten most recent lanes (1.04 in the Issue’s per-message frame), short of the 1.40 target as every advisory attempt before it was (kit#1304/#1329/#1337/#1390/#2164/#2276). The cause is structural rather than wording: a `PreToolUse` hook cannot see the turn it is in — a turn’s later `tool_use` lines arrive 1.4–15s after its first — so it judges only closed turns, and a notice at turn close still asks for the parallel `tool_use` blocks the model resists. The one lever measured to move the density is a composite command the model emits in a single call (`read:files`/`edit:files`, kit#2202/#2311), which needs no notice. So per the issue’s instruction to discontinue advisory means when the target is missed, the notice is cut off rather than rewritten a seventh time; the density stays readable through `josh time:density`',
56
+ 'a refusal ends a child turn and every advisory notice was measured not to move the call density, so the guard is off and the density stays readable through `josh time:density`',
57
57
  },
58
58
  {
59
59
  id: 'duplicate-read',
60
60
  mode_in_lane_child: 'notice',
61
61
  because:
62
- 'a refusal ends a child turn so it cannot fire as one (kit#2138), and the unchanged re-reads the guard catches are measured in lane children above all (kit#2298 baseline: 8.0 per lane run) — so silencing it there (`off`) would neuter it exactly where the count lives. It takes `notice` instead: the child is nudged to scroll up to the earlier read rather than killed. The mode is a genuine experiment, as the batching notice is — a notice that does not reduce the duplicate reads on the next backlogrun is redesigned rather than kept',
62
+ 'a refusal ends a child turn, yet unchanged re-reads are measured in lane children above all, so the child is nudged to scroll up to the earlier read by a notice rather than killed',
63
63
  },
64
64
  {
65
65
  id: 'rule',
66
66
  mode_in_lane_child: 'refuse',
67
67
  because:
68
- "it carries the lane-only rules a child depends on (`pre-gate-cut`, `implementation-cut`, `lane-park`) and the safety rules a child must still obey (`shell-body`, `piped-verification`); a blanket suppression would disarm exactly the guards written for the child. `implementation-cut` (joshuafolkken/kit#2310) needs `refuse` for the same reason the pre-gate cut does — kit#1864 and kit#2310 both measured a cut carried as prose firing 0 times, so a notice, being prose in front of the run, would drop enforcement without a procedural replacement. The pre-gate-cut refusal does collateral a sibling call batched in the same turn — a `PreToolUse` deny cancels the turn's other parallel calls, which is the harness behavior kit#2177 measured on #2160 (gate refused, its batched `review:brief` cancelled) — and that collateral is not avoidable at the guard level. `notice` was considered for the pre-gate-cut point and rejected: kit#1864 measured the cut taken 0 times while it was carried as prose, and a notice is prose in front of the run (kit#2178 measured a lane-child notice not moving the number), so a notice would drop enforcement without a procedural replacement. kit#2177 supplies that replacement in the procedure instead — the chain orders the cut before the gate, so the cut is issued on its own (its `cut` verdict ends the turn, batching with nothing) and the gate runs in the fresh process where the carried cut keeps this guard silent; the refusal therefore stays `refuse` as insurance and the collateral is gone from the default path rather than from the guard",
68
+ 'it carries the lane-only rules a child depends on (`pre-gate-cut`, `implementation-cut`, `lane-park`) and the safety rules it must still obey, and a notice was rejected despite the collateral a refusal costs a batched sibling, because a cut carried as prose was measured never to fire',
69
69
  },
70
70
  ]
71
71
 
@@ -56,6 +56,12 @@ const gate_schema = z.object({
56
56
  // worth its load. `swapped_mb` is `machine_capacity`'s counter, every page swapped since boot.
57
57
  // Both memory fields were renamed with their meaning (joshuafolkken/kit#3593): an older row's
58
58
  // `free_mb` / `swap_mb` are stripped on read, so the two measurements never share a column.
59
+ const process_kind_schema = z.object({
60
+ kind: z.string(),
61
+ cpu_percent: z.number(),
62
+ rss_mb: z.number(),
63
+ count: z.number(),
64
+ })
59
65
  const load_schema = z.object({
60
66
  kind: z.literal(KIND.LOAD),
61
67
  at: z.string(),
@@ -63,6 +69,9 @@ const load_schema = z.object({
63
69
  available_mb: z.number().optional(),
64
70
  swapped_mb: z.number().optional(),
65
71
  lanes: z.number().optional(),
72
+ // The heaviest process kinds at the instant of the sample (`lane-process-load.ts`), so the load the
73
+ // core budget's ledger does not explain can be read back by what made it (joshuafolkken/kit#3874).
74
+ processes: z.array(process_kind_schema).optional(),
66
75
  })
67
76
  // A lane child started on `issue` — the instant its implementation is timed from.
68
77
  const dispatch_schema = z.object({
@@ -2,10 +2,12 @@ import { loadavg } from 'node:os'
2
2
  import { machine_capacity } from '#scripts/gate/machine-capacity'
3
3
  import { lane_await } from './lane-await'
4
4
  import type { LoadEntry } from './lane-ledger'
5
+ import { lane_process_load } from './lane-process-load'
5
6
  import { lane_registry, type LaneInfo } from './lane-registry'
6
7
 
7
8
  // One machine-load sample for the lane-limit measurement: the one-minute load
8
- // average, available memory, the swap counter and how many lanes have a child working, read at one
9
+ // average, available memory, the swap counter, how many lanes have a child working and the heaviest
10
+ // process kinds (`lane-process-load.ts`), read at one
9
11
  // instant so `josh lane:stats` can set the load beside the lane count that produced it.
10
12
  //
11
13
  // **The memory is `machine_capacity`'s reading, the one the gate admits against.** `os.freemem()` reads
@@ -38,7 +40,11 @@ function rounded(megabytes: number | undefined): number | undefined {
38
40
  }
39
41
 
40
42
  async function sample(at: string): Promise<LoadEntry> {
41
- const [memory, lanes] = await Promise.all([machine_capacity.read_memory(), read_lanes()])
43
+ const [memory, lanes, processes] = await Promise.all([
44
+ machine_capacity.read_memory(),
45
+ read_lanes(),
46
+ lane_process_load.read(),
47
+ ])
42
48
  const [load = 0] = loadavg()
43
49
 
44
50
  return {
@@ -48,6 +54,7 @@ async function sample(at: string): Promise<LoadEntry> {
48
54
  available_mb: rounded(memory.available_mb),
49
55
  swapped_mb: rounded(memory.swapped_mb),
50
56
  lanes,
57
+ processes,
51
58
  }
52
59
  }
53
60
 
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url'
4
4
  import { cli_flags } from '#scripts/lib/cli-flags'
5
5
  import { lane_capacity } from './lane-capacity'
6
6
  import { lane_ledger } from './lane-ledger'
7
+ import { lane_process_stats } from './lane-process-stats'
7
8
  import { lane_sampler, type MeasureContext } from './lane-sampler'
8
9
  import { lane_stage_stats } from './lane-stage-stats'
9
10
  import { lane_stats } from './lane-stats'
@@ -119,6 +120,7 @@ async function stats_command(
119
120
  `${lane_stats.header()}\n${row}`,
120
121
  lane_stage_stats.table(entries, window),
121
122
  lane_stop_stats.table(entries, window),
123
+ lane_process_stats.table(entries, window),
122
124
  ].join('\n\n'),
123
125
  )
124
126
 
@@ -0,0 +1,18 @@
1
+ import { has_label_name, NEEDS_DECISION_LABEL } from '#scripts/issue/issue-labels'
2
+ import { issue_state_cli } from '#scripts/issue/issue-state-cli'
3
+
4
+ // Whether an issue is parked for a person — the `needs-decision` label — read live, so a session a
5
+ // lane's broker would start for it is refused once the run has parked it (joshuafolkken/kit#3878).
6
+ //
7
+ // **An issue whose state cannot be read is not parked**: the read gates every brokered review, so
8
+ // reading an outage as a park would fail every review in flight for as long as GitHub is away.
9
+
10
+ async function is_parked(issue: string): Promise<boolean> {
11
+ const read = await issue_state_cli.read_issue(issue)
12
+
13
+ return read.kind === 'state' && has_label_name(read.state.labels, NEEDS_DECISION_LABEL)
14
+ }
15
+
16
+ const lane_parked = { is_parked }
17
+
18
+ export { lane_parked }
@@ -0,0 +1,125 @@
1
+ import { PROBE_TIMEOUT_MS } from '#scripts/lib/timeouts'
2
+ import { execa } from 'execa'
3
+
4
+ // What the machine's load is made of at one instant: every process grouped by the kind of tool it
5
+ // runs, the heaviest kinds kept with their CPU and resident size, for a `load` sample to carry.
6
+ //
7
+ // **Why a load sample needs it.** The gate's static checks and unit suite are on the core budget's
8
+ // ledger, so the load the ledger does not explain comes from somewhere else — review subagents, the
9
+ // sessions themselves, a hook's unit run, an IDE — and a load average alone cannot say which.
10
+ // joshuafolkken/kit#3874 measured load 93 with 1.6 lanes working and nothing on record to explain it.
11
+ //
12
+ // **The kind is read from the command line, not the executable name.** Every tool here is a `node`
13
+ // process, so the name says nothing; the first pattern a command line matches names its kind, in the
14
+ // order below, so `pnpm exec vitest` is `vitest` rather than `josh`. The IDE comes first: its language
15
+ // servers run out of a `typescript` or `eslint` directory, and read as `tsc` or `eslint` they would
16
+ // pass the IDE's load off as the gate's own checks — and a server the IDE starts on a plain `node`
17
+ // runtime is known only by its `.vscode/extensions/` path. `claude` is the executable or its package,
18
+ // never a `.claude/` path a session's Bash-tool shell or hook script runs through. `pnpm` is the
19
+ // command, never the `.pnpm/` store a resolved binary's path runs through.
20
+ //
21
+ // **`%cpu` is the share of one core**, so 300 is three cores busy; it is `ps`'s decaying average,
22
+ // which is what a one-minute sample beside the one-minute load average wants.
23
+
24
+ const PS_ARGUMENTS = ['-A', '-o', 'rss=,%cpu=,args=']
25
+ // `ps` prints `%cpu` with the locale's decimal separator, and a `0,7` would parse as NaN.
26
+ const PS_ENVIRONMENT = { LC_ALL: 'C' }
27
+ const FIELD_SEPARATOR = /\s+/u
28
+ const KB_PER_MB = 1024
29
+ // Enough to name what dominates and the runner-up behind it, short enough for one ledger line a minute.
30
+ const TOP_KINDS = 5
31
+ const OTHER_KIND = 'other'
32
+
33
+ const KIND_PATTERNS: ReadonlyArray<readonly [string, RegExp]> = [
34
+ ['vscode', /Visual Studio Code|Code Helper|\.vscode\/extensions\//u],
35
+ ['vitest', /vitest/u],
36
+ ['eslint', /eslint/u],
37
+ ['tsc', /\btsc\b|svelte-check|typescript/u],
38
+ ['cspell', /cspell/u],
39
+ ['prettier', /prettier/u],
40
+ ['playwright', /playwright/u],
41
+ ['chrome', /Google Chrome/u],
42
+ ['chromium', /chromium|headless_shell/iu],
43
+ ['claude', /(?:^|\/)claude(?:\s|$)|@anthropic-ai\/claude-code\//u],
44
+ ['josh', /josh\.ts|\btsx\b|(?<!\.)\bpnpm\b/u],
45
+ ['node', /\bnode\b/u],
46
+ ]
47
+
48
+ interface ProcessKind {
49
+ kind: string
50
+ cpu_percent: number
51
+ rss_mb: number
52
+ count: number
53
+ }
54
+
55
+ function kind_of(command_line: string): string {
56
+ return KIND_PATTERNS.find(([, pattern]) => pattern.test(command_line))?.[0] ?? OTHER_KIND
57
+ }
58
+
59
+ // One line of `ps`'s listing as its kind and readings; a header or a cut-short line reads as nothing.
60
+ function process_of(line: string): Omit<ProcessKind, 'count'> | undefined {
61
+ const [rss, cpu, ...command] = line.trim().split(FIELD_SEPARATOR)
62
+ const reading = { cpu_percent: Number(cpu), rss_mb: Number(rss) / KB_PER_MB }
63
+
64
+ if (command.length === 0 || Number.isNaN(reading.cpu_percent + reading.rss_mb)) return undefined
65
+
66
+ return { kind: kind_of(command.join(' ')), ...reading }
67
+ }
68
+
69
+ function add_line(totals: Map<string, ProcessKind>, line: string): void {
70
+ const reading = process_of(line)
71
+
72
+ if (reading === undefined) return
73
+
74
+ const { kind } = reading
75
+ const total = totals.get(kind) ?? { kind, cpu_percent: 0, rss_mb: 0, count: 0 }
76
+
77
+ total.cpu_percent += reading.cpu_percent
78
+ total.rss_mb += reading.rss_mb
79
+ total.count += 1
80
+ totals.set(kind, total)
81
+ }
82
+
83
+ function rounded(total: ProcessKind): ProcessKind {
84
+ return { ...total, cpu_percent: Math.round(total.cpu_percent), rss_mb: Math.round(total.rss_mb) }
85
+ }
86
+
87
+ // The heaviest kinds first: by CPU, the load average's own measure, then by memory for idle kinds.
88
+ function by_weight(left: ProcessKind, right: ProcessKind): number {
89
+ return right.cpu_percent - left.cpu_percent || right.rss_mb - left.rss_mb
90
+ }
91
+
92
+ function summarize(listing: string): Array<ProcessKind> {
93
+ const totals = new Map<string, ProcessKind>()
94
+
95
+ for (const line of listing.split('\n')) add_line(totals, line)
96
+
97
+ return [...totals.values()]
98
+ .map((total) => rounded(total))
99
+ .toSorted(by_weight)
100
+ .slice(0, TOP_KINDS)
101
+ }
102
+
103
+ // `undefined` where `ps` did not answer, so the sample records no breakdown rather than an empty one.
104
+ async function read(): Promise<Array<ProcessKind> | undefined> {
105
+ try {
106
+ const { stdout } = await execa('ps', PS_ARGUMENTS, {
107
+ env: PS_ENVIRONMENT,
108
+ timeout: PROBE_TIMEOUT_MS,
109
+ })
110
+
111
+ return summarize(stdout)
112
+ } catch {
113
+ return undefined
114
+ }
115
+ }
116
+
117
+ const lane_process_load = {
118
+ TOP_KINDS,
119
+ kind_of,
120
+ read,
121
+ summarize,
122
+ }
123
+
124
+ export type { ProcessKind }
125
+ export { lane_process_load }
@@ -0,0 +1,93 @@
1
+ import type { LedgerEntry, LoadEntry } from './lane-ledger'
2
+ import type { ProcessKind } from './lane-process-load'
3
+ import { lane_stats, type StatsWindow } from './lane-stats'
4
+
5
+ // What the period's machine load was made of: the process kinds the load samples carried
6
+ // (`lane-process-load.ts`), one row per kind, printed by `josh lane:stats` under the stop table so the
7
+ // load the core budget's ledger does not explain is read back by what made it (joshuafolkken/kit#3874).
8
+ //
9
+ // **A kind is averaged over the samples it appears in**, never over every sample. A sample keeps only
10
+ // its heaviest kinds, so a kind absent from one was below them rather than idle, and a zero there would
11
+ // understate it; `samples` says how often it was among the heaviest, which is the weight to read it by.
12
+ //
13
+ // **CPU is in cores** — `ps`'s share of one core over a hundred — so the column reads against the load
14
+ // average and the machine's core count without a conversion.
15
+
16
+ const COLUMNS = [
17
+ 'process',
18
+ 'cpu mean (cores)',
19
+ 'cpu peak (cores)',
20
+ 'rss peak (GB)',
21
+ 'samples',
22
+ ] as const
23
+ const PERCENT_PER_CORE = 100
24
+ const MB_PER_GB = 1024
25
+ const DECIMALS = 1
26
+
27
+ interface KindRow {
28
+ kind: string
29
+ readings: Array<ProcessKind>
30
+ }
31
+
32
+ function is_load(entry: LedgerEntry): entry is LoadEntry {
33
+ return entry.kind === 'load'
34
+ }
35
+
36
+ function group(samples: ReadonlyArray<LoadEntry>): Array<KindRow> {
37
+ const rows = new Map<string, KindRow>()
38
+ const readings = samples.flatMap((sample) => sample.processes ?? [])
39
+
40
+ for (const reading of readings) {
41
+ const row = rows.get(reading.kind) ?? { kind: reading.kind, readings: [] }
42
+
43
+ row.readings.push(reading)
44
+ rows.set(reading.kind, row)
45
+ }
46
+
47
+ return [...rows.values()]
48
+ }
49
+
50
+ function cores(readings: ReadonlyArray<ProcessKind>): Array<number> {
51
+ return readings.map((reading) => reading.cpu_percent / PERCENT_PER_CORE)
52
+ }
53
+
54
+ // A row has one reading at least, so its mean always exists.
55
+ function mean_cores(row: KindRow): number {
56
+ return lane_stats.mean(cores(row.readings)) ?? 0
57
+ }
58
+
59
+ function cell(value: number | undefined): string {
60
+ return value === undefined ? lane_stats.MISSING : value.toFixed(DECIMALS)
61
+ }
62
+
63
+ function kind_cells(row: KindRow): Array<string> {
64
+ const rss_peak = lane_stats.peak(row.readings.map((reading) => reading.rss_mb))
65
+
66
+ return [
67
+ row.kind,
68
+ cell(mean_cores(row)),
69
+ cell(lane_stats.peak(cores(row.readings))),
70
+ cell(rss_peak === undefined ? undefined : rss_peak / MB_PER_GB),
71
+ String(row.readings.length),
72
+ ]
73
+ }
74
+
75
+ function by_mean_cpu(left: KindRow, right: KindRow): number {
76
+ return mean_cores(right) - mean_cores(left)
77
+ }
78
+
79
+ // The period's table: `entries` is the whole ledger, filtered here to `window`. A period whose samples
80
+ // carried no breakdown prints the header alone.
81
+ function table(entries: ReadonlyArray<LedgerEntry>, window: StatsWindow): string {
82
+ const rows = group(lane_stats.in_window(entries, window).filter(is_load)).toSorted(by_mean_cpu)
83
+
84
+ return [
85
+ lane_stats.to_row(COLUMNS),
86
+ lane_stats.to_row(COLUMNS.map(() => '---')),
87
+ ...rows.map((row) => lane_stats.to_row(kind_cells(row))),
88
+ ].join('\n')
89
+ }
90
+
91
+ const lane_process_stats = { table }
92
+
93
+ export { lane_process_stats }
@@ -175,9 +175,14 @@ function parse_block(block: string, root: string): LaneInfo | undefined {
175
175
  const branch = line_value(lines, BRANCH_PREFIX)
176
176
  const issue = branch_issue(branch)
177
177
 
178
- if (directory === undefined || branch === undefined || issue === undefined) return undefined
179
-
180
- if (!is_lane_directory(root, issue, directory)) return undefined
178
+ if (
179
+ directory === undefined ||
180
+ branch === undefined ||
181
+ issue === undefined ||
182
+ !is_lane_directory(root, issue, directory)
183
+ ) {
184
+ return undefined
185
+ }
181
186
 
182
187
  return build_lane(issue, branch, directory, is_gone(directory))
183
188
  }
@@ -15,6 +15,7 @@ import { z } from 'zod'
15
15
  import { lane_broker_server, type BrokerServer } from './lane-broker-server'
16
16
  import { lane_child_invocation, type ShipStop } from './lane-child-invocation'
17
17
  import { lane_child_marker } from './lane-child-marker'
18
+ import { lane_parked } from './lane-parked'
18
19
  import type { LaneInfo } from './lane-registry'
19
20
  import { lane_relaunch } from './lane-relaunch'
20
21
 
@@ -105,14 +106,22 @@ function provider_of(lane: LaneInfo): AgentProvider {
105
106
 
106
107
  // **The reviewer carries the lane's mark explicitly**: the supervisor serving a Claude lane is no lane child,
107
108
  // so a reviewer inheriting its environment would descend from the carry record's owner unmarked and be
108
- // read as the `backlogrun` parent by every hook.
109
+ // read as the `backlogrun` parent by every hook. **Its prompt ends with the review invocation**, so the
110
+ // lane's liveness read and the park's reap find it as they find the child (joshuafolkken/kit#3878).
109
111
 
110
- async function launch_review(lane: LaneInfo, round: ReviewRound): Promise<boolean> {
112
+ function review_prompt(lane: LaneInfo, round: ReviewRound): string {
111
113
  const target = review_paths(lane.directory)
112
114
  const prompt_of =
113
115
  round === '1' ? run_ship_review.reviewer_prompt : run_ship_review.verification_prompt
116
+ const tail = lane_child_invocation.review_invocation(lane.issue)
117
+
118
+ return `${prompt_of(target.brief, target.findings, lane.issue)} This is ${tail}`
119
+ }
120
+
121
+ async function launch_review(lane: LaneInfo, round: ReviewRound): Promise<boolean> {
122
+ const target = review_paths(lane.directory)
114
123
  const built = agent_argv.resolve_in(
115
- prompt_of(target.brief, target.findings),
124
+ review_prompt(lane, round),
116
125
  agent_role_profile.REVIEWER,
117
126
  lane.directory,
118
127
  )
@@ -143,7 +152,11 @@ function relaunch(lane: LaneInfo, stop: ShipStop): boolean {
143
152
  return result.kind === 'launched'
144
153
  }
145
154
 
155
+ // **A parked issue gets no session**: a request can be read long after it was written, so the park is
156
+ // read when the action runs, never when it was asked for (joshuafolkken/kit#3878).
146
157
  async function perform(lane: LaneInfo, action: BrokerAction): Promise<boolean> {
158
+ if (await lane_parked.is_parked(lane.issue)) return false
159
+
147
160
  if (action.kind === 'relaunch') return relaunch(lane, action.stop)
148
161
 
149
162
  return await launch_review(lane, action.round)
@@ -336,6 +349,13 @@ function is_serving(directory: string, issue: string): boolean {
336
349
  return target !== undefined && live_response(target) !== undefined
337
350
  }
338
351
 
352
+ // **A request nobody waits for any more is taken back**: a server busy with an earlier request reads the
353
+ // file only once it is free, and a relaunch the ship had already given up on then started a session in a
354
+ // lane the run had parked in the meantime (joshuafolkken/kit#3878). A newer request is left alone.
355
+ function withdraw(pending: string, nonce: string): void {
356
+ if (read_record(pending, request_schema)?.nonce === nonce) stamp_file.remove_stamp(pending)
357
+ }
358
+
339
359
  async function request(directory: string, issue: string, action: BrokerAction): Promise<boolean> {
340
360
  const target = response_path(directory, issue)
341
361
  const response = target === undefined ? undefined : live_response(target)
@@ -344,10 +364,14 @@ async function request(directory: string, issue: string, action: BrokerAction):
344
364
 
345
365
  const nonce = randomUUID()
346
366
  const body = { session: response.session, nonce, action }
367
+ const pending = request_path(directory, issue)
368
+
369
+ stamp_file.replace_stamp(pending, body)
370
+ const is_success = await wait_response(target, response.session, nonce)
347
371
 
348
- stamp_file.replace_stamp(request_path(directory, issue), body)
372
+ withdraw(pending, nonce)
349
373
 
350
- return await wait_response(target, response.session, nonce)
374
+ return is_success
351
375
  }
352
376
 
353
377
  const lane_session_broker = {