@joshuafolkken/kit 1.1387.0 → 1.1435.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 (236) hide show
  1. package/.claude/skills/epic-commands/SKILL.md +1 -1
  2. package/.claude/skills/epic-commands/epic-bundle.md +5 -0
  3. package/.claude/skills/workflow-commands/backlogrun-lanes.md +5 -3
  4. package/.claude/skills/workflow-commands/backlogrun-steps.md +4 -3
  5. package/.claude/skills/workflow-commands/backlogrun.md +1 -0
  6. package/.claude/skills/workflow-commands/chain-rule.md +4 -2
  7. package/.claude/skills/workflow-commands/issue-comments.md +6 -0
  8. package/.claude/skills/workflow-commands/observation-ledger.md +3 -2
  9. package/.claude/skills/workflow-commands/pre-gate-cut.md +5 -3
  10. package/README.md +25 -0
  11. package/dist/hooks/audit-provision.js +2 -2
  12. package/dist/hooks/batch-guard.js +7 -7
  13. package/dist/hooks/{chunk-IYVOLN6A.js → chunk-2G5F2YCD.js} +3 -3
  14. package/dist/hooks/{chunk-2YSW3CXH.js → chunk-3LE6VLSR.js} +17 -6
  15. package/dist/hooks/{chunk-J3KSS7II.js → chunk-4BHUPFKF.js} +1 -1
  16. package/dist/hooks/{chunk-EAQEGME7.js → chunk-5SJSSCJG.js} +1 -1
  17. package/dist/hooks/{chunk-FZ6FUSBM.js → chunk-723HKZO5.js} +1 -1
  18. package/dist/hooks/{chunk-LMWBRJIP.js → chunk-7KMN5QB5.js} +3 -3
  19. package/dist/hooks/{chunk-NEC4AT4Z.js → chunk-DGT54IH5.js} +4 -0
  20. package/dist/hooks/{chunk-STDSEPWU.js → chunk-EMB55BDF.js} +4 -4
  21. package/dist/hooks/{chunk-JBBMAPC4.js → chunk-IX6TTTI6.js} +4 -0
  22. package/dist/hooks/{chunk-GJ7JPIHN.js → chunk-JXQBK323.js} +2674 -187
  23. package/dist/hooks/{chunk-DCY2GWHC.js → chunk-MSYSUSY6.js} +5 -5
  24. package/dist/hooks/{chunk-EBSHN25W.js → chunk-MU4LJFIV.js} +4 -4
  25. package/dist/hooks/{chunk-WNFJLBXI.js → chunk-QL6QTPWN.js} +3 -3
  26. package/dist/hooks/{chunk-SL4GQ2XP.js → chunk-XER6SSIB.js} +91 -18
  27. package/dist/hooks/{chunk-U32VSRNG.js → chunk-YW452ZAX.js} +89 -34
  28. package/dist/hooks/{chunk-GNDJ7NJ7.js → chunk-YZXYOAZ3.js} +202 -55
  29. package/dist/hooks/codex-hook-adapter.js +15 -15
  30. package/dist/hooks/duplicate-read-guard.js +8 -8
  31. package/dist/hooks/format-edited.js +5 -5
  32. package/dist/hooks/inputs.json +288 -229
  33. package/dist/hooks/investigation-guard.js +8 -8
  34. package/dist/hooks/pretool-guard.js +14 -14
  35. package/dist/hooks/session-lang.js +69 -16
  36. package/dist/hooks/stop-guard.js +13 -38
  37. package/dist/josh.js +62 -9
  38. package/package.json +4 -3
  39. package/prompts/review.md +22 -0
  40. package/scripts/agent/claude-agent-argv.ts +13 -1
  41. package/scripts/auth/auth-setup-cli.ts +101 -0
  42. package/scripts/auth/claude-token-output.ts +78 -0
  43. package/scripts/auth/claude-token.ts +68 -0
  44. package/scripts/backlog/backlog-drive-epic.ts +24 -8
  45. package/scripts/backlog/backlog-drive-launch.ts +3 -0
  46. package/scripts/backlog/backlog-drive-named-offer.ts +7 -2
  47. package/scripts/backlog/backlog-drive-named.ts +4 -2
  48. package/scripts/backlog/backlog-drive-unread.ts +56 -0
  49. package/scripts/backlog/backlog-drive.ts +16 -8
  50. package/scripts/backlog/backlog-overlap.ts +22 -5
  51. package/scripts/backlog/backlog-ready.ts +1 -3
  52. package/scripts/backlog/backlogrun-args.ts +13 -2
  53. package/scripts/backlog/backlogrun-cli.ts +91 -19
  54. package/scripts/backlog/backlogrun-launch.ts +24 -0
  55. package/scripts/backlog/backlogrun-replace.ts +166 -0
  56. package/scripts/backlog/backlogrun-stop.ts +178 -0
  57. package/scripts/behavior/behavior-cli.ts +6 -1
  58. package/scripts/doctor/doctor-io.ts +60 -9
  59. package/scripts/doctor/doctor.ts +20 -1
  60. package/scripts/document/document-byte-budget.ts +3 -3
  61. package/scripts/epic/epic-bundle-cli.ts +8 -8
  62. package/scripts/epic/epic-bundle-referenced.ts +2 -5
  63. package/scripts/epic/epic-bundle.ts +39 -4
  64. package/scripts/exports/unused-members-cli.ts +2 -1
  65. package/scripts/followup/followup-checks-event.ts +82 -0
  66. package/scripts/followup/git-followup-finish.ts +16 -2
  67. package/scripts/followup/git-followup-workflow.ts +5 -2
  68. package/scripts/followup/git-pr-followup-checks.ts +18 -4
  69. package/scripts/followup/git-pr-followup-wrapup.ts +5 -0
  70. package/scripts/followup/git-pr-followup.ts +4 -1
  71. package/scripts/fresh-machine/fresh-machine-cli.ts +204 -0
  72. package/scripts/fresh-machine/fresh-machine-docker.ts +65 -0
  73. package/scripts/fresh-machine/fresh-machine-image.ts +62 -0
  74. package/scripts/fresh-machine/fresh-machine-log.ts +61 -0
  75. package/scripts/fresh-machine/fresh-machine-patterns.ts +130 -0
  76. package/scripts/fresh-machine/fresh-machine-run.ts +215 -0
  77. package/scripts/fresh-machine/fresh-machine-script.ts +45 -0
  78. package/scripts/fresh-machine/fresh-machine-spec.ts +57 -0
  79. package/scripts/fresh-machine/specs/kit-test-backlogrun-python.json +34 -0
  80. package/scripts/fresh-machine/specs/kit-test-backlogrun.json +31 -0
  81. package/scripts/gate/gate-report.ts +70 -15
  82. package/scripts/gate/project-checks.ts +13 -2
  83. package/scripts/gate/scoped-green.ts +2 -1
  84. package/scripts/gate/verification-gate.ts +4 -3
  85. package/scripts/gh/gh-author-association.ts +14 -0
  86. package/scripts/gh/git-gh-conditional.ts +66 -0
  87. package/scripts/gh/git-gh-exec.ts +1 -1
  88. package/scripts/gh/git-gh-issue-read.ts +6 -3
  89. package/scripts/gh/git-gh-issue-write.ts +5 -1
  90. package/scripts/gh/git-gh-issue.ts +4 -1
  91. package/scripts/gh/git-pr-checks-eval.ts +13 -0
  92. package/scripts/gh/git-pr-checks-parse.ts +8 -1
  93. package/scripts/gh/git-pr-checks-watch.ts +11 -4
  94. package/scripts/gh/git-pr-checks.ts +36 -2
  95. package/scripts/git/git-command.ts +16 -4
  96. package/scripts/git/git-schemas.ts +1 -0
  97. package/scripts/git/git-spawn-sync.ts +1 -1
  98. package/scripts/hooks/pretool-guard.ts +3 -1
  99. package/scripts/init/hook-command-rewrite.ts +31 -0
  100. package/scripts/init/init-actions.ts +4 -1
  101. package/scripts/init/init-ai-copy.ts +33 -10
  102. package/scripts/init/init-basic.ts +8 -3
  103. package/scripts/init/init-logic-toolchain.ts +76 -0
  104. package/scripts/init/init-logic.ts +2 -0
  105. package/scripts/init/init.ts +5 -23
  106. package/scripts/init/project-profile.ts +36 -7
  107. package/scripts/init/start-exec.ts +27 -1
  108. package/scripts/init/start-plan.ts +15 -0
  109. package/scripts/init/start-steps.ts +46 -0
  110. package/scripts/init/start.ts +1 -0
  111. package/scripts/init/workflow-mark.ts +35 -0
  112. package/scripts/issue/issue-ack-cli.ts +84 -0
  113. package/scripts/issue/issue-labels.ts +40 -8
  114. package/scripts/issue/issue-state-cli.ts +14 -4
  115. package/scripts/issue/issue-watch-diff.ts +152 -0
  116. package/scripts/issue/issue-watch-gate.ts +81 -0
  117. package/scripts/issue/issue-watch-hook.ts +54 -0
  118. package/scripts/issue/issue-watch.ts +240 -0
  119. package/scripts/issue/measure-rerun-cli.ts +3 -4
  120. package/scripts/josh/josh-command-map.ts +4 -0
  121. package/scripts/josh/josh-commands-backlog.ts +2 -2
  122. package/scripts/josh/josh-commands-development.ts +1 -1
  123. package/scripts/josh/josh-commands-issue.ts +7 -0
  124. package/scripts/josh/josh-commands-lane.ts +11 -2
  125. package/scripts/josh/josh-commands-project.ts +24 -0
  126. package/scripts/josh/josh-verdict.ts +33 -4
  127. package/scripts/lane/claude-lane-broker.ts +142 -0
  128. package/scripts/lane/lane-await.ts +6 -2
  129. package/scripts/lane/lane-cache.ts +37 -7
  130. package/scripts/lane/lane-capacity.ts +8 -6
  131. package/scripts/lane/lane-child-invocation.ts +1 -0
  132. package/scripts/lane/lane-cli.ts +15 -1
  133. package/scripts/lane/lane-close.ts +15 -6
  134. package/scripts/lane/lane-dispatch.ts +36 -10
  135. package/scripts/lane/lane-environment.ts +1 -1
  136. package/scripts/lane/lane-launch-cli.ts +13 -4
  137. package/scripts/lane/lane-ledger.ts +21 -3
  138. package/scripts/lane/lane-measure-cli.ts +10 -2
  139. package/scripts/lane/lane-open.ts +9 -1
  140. package/scripts/lane/lane-preserve.ts +49 -0
  141. package/scripts/lane/lane-reap.ts +20 -5
  142. package/scripts/lane/lane-registry.ts +6 -0
  143. package/scripts/lane/lane-restart-cli.ts +51 -0
  144. package/scripts/lane/lane-restart.ts +175 -0
  145. package/scripts/lane/{openai-review-broker.ts → lane-session-broker.ts} +106 -28
  146. package/scripts/lane/lane-spent.ts +195 -0
  147. package/scripts/lane/lane-stage-stats.ts +27 -3
  148. package/scripts/lane/lane-stop-stats.ts +96 -0
  149. package/scripts/lane/openai-lane-supervisor.ts +2 -2
  150. package/scripts/lane/process-tools.ts +31 -0
  151. package/scripts/lib/buffered-process.ts +36 -2
  152. package/scripts/lib/pnpm-reach.ts +27 -0
  153. package/scripts/lib/status-icons.ts +5 -1
  154. package/scripts/lint/lint-parallel.ts +19 -2
  155. package/scripts/observations/observation-ledger-home.ts +1 -0
  156. package/scripts/review/review-attest-cli.ts +21 -6
  157. package/scripts/review/review-brief-cli.ts +2 -2
  158. package/scripts/review/review-comment-round.ts +114 -0
  159. package/scripts/review/review-record-cli.ts +39 -17
  160. package/scripts/rules/implementation-cut.ts +3 -3
  161. package/scripts/rules/pre-gate-cut.ts +1 -1
  162. package/scripts/run/board/run-board-checks.ts +42 -0
  163. package/scripts/run/board/run-board-cli.ts +2 -0
  164. package/scripts/run/board/run-board-every.ts +3 -1
  165. package/scripts/run/board/run-board-fit.ts +16 -5
  166. package/scripts/run/board/run-board-fresh.ts +64 -0
  167. package/scripts/run/board/run-board-header.ts +129 -53
  168. package/scripts/run/board/run-board-labels.ts +47 -4
  169. package/scripts/run/board/run-board-lane-event.ts +66 -0
  170. package/scripts/run/board/run-board-layout.ts +57 -9
  171. package/scripts/run/board/run-board-now.ts +234 -0
  172. package/scripts/run/board/run-board-operation-read.ts +75 -0
  173. package/scripts/run/board/run-board-operation.ts +150 -0
  174. package/scripts/run/board/run-board-phase.ts +26 -1
  175. package/scripts/run/board/run-board-plan.ts +4 -6
  176. package/scripts/run/board/run-board-read.ts +49 -3
  177. package/scripts/run/board/run-board-reason.ts +124 -0
  178. package/scripts/run/board/run-board-render-checks.ts +55 -0
  179. package/scripts/run/board/run-board-render-legend.ts +12 -3
  180. package/scripts/run/board/run-board-render-slot.ts +2 -2
  181. package/scripts/run/board/run-board-render-tail.ts +128 -0
  182. package/scripts/run/board/run-board-render-thanks.ts +50 -0
  183. package/scripts/run/board/run-board-render-title.ts +58 -0
  184. package/scripts/run/board/run-board-render.ts +96 -80
  185. package/scripts/run/board/run-board-row-yield.ts +32 -0
  186. package/scripts/run/board/run-board-setup.ts +61 -13
  187. package/scripts/run/board/run-board-state.ts +10 -0
  188. package/scripts/run/board/run-board-status.ts +66 -7
  189. package/scripts/run/board/run-board-thanks-csv.ts +71 -0
  190. package/scripts/run/board/run-board-thanks-read.ts +112 -0
  191. package/scripts/run/board/run-board-thanks.ts +53 -0
  192. package/scripts/run/board/run-board-tick.ts +50 -21
  193. package/scripts/run/board/run-board-usual.ts +44 -0
  194. package/scripts/run/carry/run-carry-args.ts +9 -1
  195. package/scripts/run/carry/run-carry-cli.ts +2 -45
  196. package/scripts/run/carry/run-carry-end.ts +73 -0
  197. package/scripts/run/carry/run-carry.ts +8 -1
  198. package/scripts/run/entry/run-entry-cli.ts +5 -0
  199. package/scripts/run/event/run-event-checks.ts +54 -0
  200. package/scripts/run/event/run-event-render.ts +2 -0
  201. package/scripts/run/event/run-event-scope.ts +1 -0
  202. package/scripts/run/event/run-event-setup.ts +55 -11
  203. package/scripts/run/event/run-event-stream.ts +27 -11
  204. package/scripts/run/merge/run-merge-cli.ts +7 -4
  205. package/scripts/run/merge/run-merge-ship.ts +61 -0
  206. package/scripts/run/merge/run-merge-steps.ts +7 -14
  207. package/scripts/run/merge/run-merge-token.ts +16 -2
  208. package/scripts/run/run-headless.ts +1 -0
  209. package/scripts/run/run-step.ts +2 -0
  210. package/scripts/run/ship/run-ship-cli.ts +22 -2
  211. package/scripts/run/ship/run-ship-return.ts +31 -13
  212. package/scripts/run/ship/run-ship-review-steps.ts +17 -4
  213. package/scripts/run/ship/run-ship-stage.ts +1 -0
  214. package/scripts/run/wake/run-wake-await.ts +130 -0
  215. package/scripts/run/wake/run-wake-cli.ts +60 -71
  216. package/scripts/run/wake/run-wake-describe.ts +34 -2
  217. package/scripts/run/wake/run-wake-driver.ts +11 -4
  218. package/scripts/run/wake/run-wake-session.ts +20 -3
  219. package/scripts/run/wake/run-wake-stop.ts +37 -0
  220. package/scripts/run/wake/run-wake-supervise.ts +44 -0
  221. package/scripts/sync/claude-settings-merge.ts +144 -0
  222. package/scripts/sync/sync-ai-files.ts +28 -3
  223. package/scripts/sync/sync.ts +15 -0
  224. package/scripts/test/project-test-script.ts +73 -0
  225. package/scripts/test/skip-marker.ts +14 -1
  226. package/scripts/test/test-related.ts +3 -2
  227. package/scripts/test/test-unit-guard.ts +78 -14
  228. package/scripts/time-runtime/time-reported-failure.ts +11 -3
  229. package/scripts/uninstall/distributed-files.ts +92 -0
  230. package/scripts/uninstall/uninstall-plan.ts +119 -0
  231. package/scripts/uninstall/uninstall-report.ts +61 -0
  232. package/scripts/uninstall/uninstall.ts +206 -0
  233. package/templates/AGENTS.basic.md +4 -0
  234. package/templates/GEMINI.basic.md +6 -0
  235. package/dist/hooks/chunk-6V5OPIUU.js +0 -13
  236. package/scripts/run/board/run-board-track.ts +0 -22
@@ -0,0 +1,78 @@
1
+ import { stripVTControlCharacters } from 'node:util'
2
+
3
+ // What `claude setup-token` printed, split into the token and the text that may be shown: the token
4
+ // is replaced, so it reaches neither the screen nor a log.
5
+
6
+ const REDACTED = '[REDACTED]'
7
+ const TOKEN_START = 'sk-ant-oat'
8
+ const TOKEN_PATTERN = /sk-ant-oat\d+-[\w-]+/u
9
+ const EVERY_TOKEN_PATTERN = new RegExp(TOKEN_PATTERN.source, 'gu')
10
+ // A narrow terminal wraps the token onto lines of its own characters alone, so a continuation is a
11
+ // whole such line and the prose after the token never joins it.
12
+ const CONTINUATION_PATTERN = /^[\w-]+$/u
13
+ const LINE_BREAK_PATTERN = /\r?\n/u
14
+
15
+ interface Captured {
16
+ token: string | undefined
17
+ shown: string
18
+ }
19
+
20
+ // A line that starts a token is another copy of it, never the tail of the one before.
21
+ function is_continuation(line: string): boolean {
22
+ return CONTINUATION_PATTERN.test(line) && !line.startsWith(TOKEN_START)
23
+ }
24
+
25
+ function continuation(lines: ReadonlyArray<string>): Array<string> {
26
+ const length = lines.findIndex((line) => !is_continuation(line.trim()))
27
+
28
+ return (length === -1 ? lines : lines.slice(0, length)).map((line) => line.trim())
29
+ }
30
+
31
+ // Every copy of the token is replaced, its wrapped tail with it: Ink may draw it in more than one frame.
32
+ function redact(lines: ReadonlyArray<string>): Array<string> {
33
+ const start = lines.findIndex((line) => TOKEN_PATTERN.test(line))
34
+
35
+ if (start === -1) return [...lines]
36
+
37
+ const [head = '', ...rest] = lines.slice(start)
38
+ const remaining = rest.slice(continuation(rest).length)
39
+
40
+ return [
41
+ ...lines.slice(0, start),
42
+ head.replaceAll(EVERY_TOKEN_PATTERN, () => REDACTED),
43
+ ...redact(remaining),
44
+ ]
45
+ }
46
+
47
+ function capture(output: string): Captured {
48
+ const lines = stripVTControlCharacters(output).split(LINE_BREAK_PATTERN)
49
+ const start = lines.findIndex((line) => TOKEN_PATTERN.test(line))
50
+ const head = lines[start]
51
+ const shown = redact(lines).join('\n')
52
+
53
+ if (head === undefined) return { token: undefined, shown }
54
+
55
+ const token = [TOKEN_PATTERN.exec(head)?.[0], ...continuation(lines.slice(start + 1))].join('')
56
+
57
+ return { token, shown }
58
+ }
59
+
60
+ // How much of the output so far may be shown as it arrives — the sign-in URL and any prompt come
61
+ // before the token: everything before the token, less a tail that could be the start of one.
62
+ function live_length(output: string): number {
63
+ const start = output.indexOf(TOKEN_START)
64
+
65
+ if (start !== -1) return start
66
+
67
+ const prefixes = Array.from({ length: TOKEN_START.length }, (_, index) =>
68
+ TOKEN_START.slice(0, index + 1),
69
+ )
70
+ const held = prefixes.findLast((prefix) => output.endsWith(prefix))
71
+
72
+ return output.length - (held?.length ?? 0)
73
+ }
74
+
75
+ const claude_token_output = { capture, live_length }
76
+
77
+ export { claude_token_output }
78
+ export type { Captured }
@@ -0,0 +1,68 @@
1
+ import { execaSync } from 'execa'
2
+
3
+ // Where the Claude Code token of `josh fresh:run` lives on the host: the macOS keychain item
4
+ // `josh auth:setup` writes, or — on a machine without a keychain — `CLAUDE_CODE_OAUTH_TOKEN` in the
5
+ // environment. No shell profile is read or written, so no other Claude Code on the host changes how
6
+ // it signs in.
7
+
8
+ const VARIABLE = 'CLAUDE_CODE_OAUTH_TOKEN'
9
+ const SERVICE = 'josh-claude-code-oauth-token'
10
+ const ACCOUNT = 'josh'
11
+ const SECURITY = 'security'
12
+ const SECURITY_TIMEOUT_MS = 10_000
13
+ const FIND = 'find-generic-password'
14
+ const ITEM_ARGUMENTS: ReadonlyArray<string> = ['-s', SERVICE, '-a', ACCOUNT]
15
+ // The modification date of the item, `"mdat"<timedate>=0x… "20261014071518Z\000"`, in UTC.
16
+ const SAVED_DATE_PATTERN = /"mdat"<timedate>=\S+\s+"(\d{4})(\d{2})(\d{2})/u
17
+
18
+ function security(arguments_: ReadonlyArray<string>, input = ''): string | undefined {
19
+ try {
20
+ const { stdout } = execaSync(SECURITY, [...arguments_], { input, timeout: SECURITY_TIMEOUT_MS })
21
+
22
+ return stdout
23
+ } catch {
24
+ return undefined
25
+ }
26
+ }
27
+
28
+ function non_empty(value: string | undefined): string | undefined {
29
+ const trimmed = value?.trim()
30
+
31
+ return trimmed === undefined || trimmed === '' ? undefined : trimmed
32
+ }
33
+
34
+ function from_keychain(): string | undefined {
35
+ return non_empty(security([FIND, ...ITEM_ARGUMENTS, '-w']))
36
+ }
37
+
38
+ // The keychain first, then the environment; `undefined` when neither holds a token.
39
+ function read(): string | undefined {
40
+ return from_keychain() ?? non_empty(process.env[VARIABLE])
41
+ }
42
+
43
+ // `security -i` reads its command from stdin, so the token is in no argument list `ps` could show.
44
+ // The item is read back, because the interactive mode exits 0 whether or not the command succeeded.
45
+ function save(token: string): boolean {
46
+ security(['-i'], `add-generic-password -U ${ITEM_ARGUMENTS.join(' ')} -w "${token}"\n`)
47
+
48
+ return from_keychain() === token
49
+ }
50
+
51
+ // The day the item was last written, `YYYY-MM-DD`; `undefined` when there is none.
52
+ function saved_date(): string | undefined {
53
+ const attributes = security([FIND, ...ITEM_ARGUMENTS])
54
+ const match = attributes === undefined ? undefined : SAVED_DATE_PATTERN.exec(attributes)
55
+
56
+ return match?.slice(1).join('-')
57
+ }
58
+
59
+ // Whether `security` answers at all, asked before a sign-in whose token would have nowhere to go.
60
+ function has_keychain(): boolean {
61
+ return security(['list-keychains']) !== undefined
62
+ }
63
+
64
+ const SETUP_HINT = 'Run `pnpm josh auth:setup` to save one.'
65
+
66
+ const claude_token = { read, save, saved_date, has_keychain, VARIABLE, SETUP_HINT }
67
+
68
+ export { claude_token }
@@ -1,3 +1,4 @@
1
+ import { epic_triage } from '#scripts/epic/epic-triage'
1
2
  import { git_gh_repo } from '#scripts/gh/git-gh-repo'
2
3
  import { issue_cite } from '#scripts/issue/issue-cite'
3
4
  import { issue_number_shape } from '#scripts/issue/issue-number-shape'
@@ -12,7 +13,12 @@ import { backlog_offer } from './backlog-offer'
12
13
  // is runnable, without `run:add` and without waking a parent session.
13
14
  //
14
15
  // **Only what needs a person is handed back.** `stop` means every child left waits on a person, which a
15
- // judgment session reads. A failed `epic:next` is either a network blip or a broken graph (a cycle, an
16
+ // judgment session reads. `triage` means a child carries neither `run:solo` nor `run:lane`, so
17
+ // `epic:next --lanes` offers nothing until a session judges it — it exits 0 and repeats on every pass,
18
+ // so waiting on it would never end. It is handed back under its own word, as the pool path's is, never
19
+ // as `epic #N`: that verdict sends the session to run the epic's children, and the single-child
20
+ // `epic:next` it asks there is not triage-gated, so it would launch the untriaged child instead of
21
+ // labelling it. A failed `epic:next` is either a network blip or a broken graph (a cycle, an
16
22
  // undeclared order, an unreadable child), and its exit code does not tell them apart — so a failure is
17
23
  // a `wait` that retries on the next pass, and `backlog:offer`'s consecutive-retry limit of them hands the
18
24
  // epic back too, rather than waiting out the idle budget on an anomaly nobody is shown.
@@ -21,6 +27,7 @@ const NO_RETRIES = 0
21
27
  const SUCCESS_EXIT_CODE = 0
22
28
  const FIRST_LINE = 0
23
29
  const WAIT: OfferRead = { verdict: 'wait', issues: [], retries: NO_RETRIES }
30
+ const TRIAGE: OfferRead = { ...WAIT, verdict: epic_triage.TRIAGE_VERDICT }
24
31
 
25
32
  // `--lanes` lists every child a free lane could take, so one the loop already knows about is passed
26
33
  // over rather than standing in front of the rest. `undefined` is an answer that could not be read.
@@ -53,22 +60,31 @@ function child_offer(children: ReadonlyArray<string>, state: DriveState): OfferR
53
60
  return child === undefined ? WAIT : { verdict: 'run', issues: [child], retries: NO_RETRIES }
54
61
  }
55
62
 
63
+ // The answers only a judgment session can move.
64
+ function judgment_offer(epic: string, token: string): OfferRead | undefined {
65
+ if (token === 'stop') return handed_back(epic)
66
+
67
+ return token === epic_triage.TRIAGE_VERDICT ? TRIAGE : undefined
68
+ }
69
+
56
70
  async function answered_offer(
57
71
  epic: string,
58
72
  answers: ReadonlyArray<string>,
59
73
  state: DriveState,
60
74
  owner: string,
61
75
  ): Promise<OfferRead> {
62
- const token = answers[FIRST_LINE]
76
+ const token = answers[FIRST_LINE] ?? ''
63
77
 
64
- if (token !== undefined && issue_number_shape.ISSUE_NUMBER_PATTERN.test(token)) {
65
- return child_offer(answers, state)
66
- }
67
-
68
- if (token === 'stop') return handed_back(epic)
78
+ if (issue_number_shape.ISSUE_NUMBER_PATTERN.test(token)) return child_offer(answers, state)
79
+ const judgment = judgment_offer(epic, token)
80
+ if (judgment !== undefined) return judgment
69
81
 
70
82
  if (token === 'complete') {
71
- await backlog_drive_named.mark_done(epic, { outcome: 'merged', code: 0, token: 'none' }, owner)
83
+ await backlog_drive_named.mark_done(
84
+ epic,
85
+ { outcome: 'merged', code: 0, token: 'none', is_offer: true },
86
+ owner,
87
+ )
72
88
  }
73
89
 
74
90
  return WAIT
@@ -33,6 +33,9 @@ async function launch(issue: string, owner: string): Promise<LaunchOutcome['kind
33
33
 
34
34
  const outcome = await lane_launch_cli.launch_lane({ issue, stash: await latest_stash() })
35
35
 
36
+ // A refusal the driver waits out writes nothing else, so the lane's stage is ended here.
37
+ if (outcome.kind !== 'launched') await run_event_setup.emit(run_event_setup.STAGE.NO_CHILD)
38
+
36
39
  return outcome.kind
37
40
  }
38
41
 
@@ -10,6 +10,7 @@ import { backlog_budget } from './backlog-budget'
10
10
  import type { DriveState, OfferRead } from './backlog-drive'
11
11
  import { backlog_drive_epic } from './backlog-drive-epic'
12
12
  import { backlog_drive_named } from './backlog-drive-named'
13
+ import { backlog_drive_unread } from './backlog-drive-unread'
13
14
 
14
15
  interface NamedContext {
15
16
  is_only: boolean
@@ -67,12 +68,16 @@ async function classify(
67
68
  ): Promise<OfferRead> {
68
69
  const result = await issue_state_cli.read_issue(issue)
69
70
 
70
- if (result.kind !== 'state') return { verdict: 'issue-state', issues: [], retries: NO_RETRIES }
71
+ if (result.kind !== 'state') return backlog_drive_unread.offer(issue, result, state.retries)
71
72
 
72
73
  const outcome = settled_outcome(result.state)
73
74
 
74
75
  if (outcome !== undefined) {
75
- await backlog_drive_named.mark_done(issue, { outcome, code: 0, token: 'none' }, owner)
76
+ await backlog_drive_named.mark_done(
77
+ issue,
78
+ { outcome, code: 0, token: 'none', is_offer: true },
79
+ owner,
80
+ )
76
81
 
77
82
  return { verdict: 'wait', issues: [], retries: NO_RETRIES }
78
83
  }
@@ -55,7 +55,9 @@ function is_done(result: MergeResult): boolean {
55
55
  return result.code === 0 && result.token !== MERGE_TOKEN.BUSY
56
56
  }
57
57
 
58
- return result.outcome === 'failed' && result.code === 0 && result.token !== MERGE_TOKEN.STOP
58
+ return (
59
+ result.outcome === 'failed' && result.code === 0 && !run_merge_token.is_control_stop(result)
60
+ )
59
61
  }
60
62
 
61
63
  function can_mark(carry: RunCarry, issue: string, owner: string): boolean {
@@ -125,7 +127,7 @@ async function reconcile(
125
127
  const outcome = parked.has(issue) ? 'parked' : 'merged'
126
128
 
127
129
  // eslint-disable-next-line no-await-in-loop -- each mark rewrites the same carry file
128
- await mark_done(String(issue), { outcome, code: 0, token: 'none' }, owner)
130
+ await mark_done(String(issue), { outcome, code: 0, token: 'none', is_offer: true }, owner)
129
131
  }
130
132
  }
131
133
 
@@ -0,0 +1,56 @@
1
+ import { issue_cite } from '#scripts/issue/issue-cite'
2
+ import type { StateRead } from '#scripts/issue/issue-state-cli'
3
+ import type { OfferRead } from './backlog-drive'
4
+ import { backlog_offer } from './backlog-offer'
5
+
6
+ // **A named issue whose state did not read is asked again before a session hears of it.** The read is
7
+ // one `gh` request, and a dropped connection fails it for a moment and no longer: handing that to a
8
+ // session costs a wake that can only cut and hand the loop back. So the failure is a `wait` that reads
9
+ // again on the next pass — the driver keeps collecting children meanwhile — and `backlog:offer`'s
10
+ // consecutive-retry limit bounds it, the count `backlog-drive-epic.ts` advances for a failed `epic:next`.
11
+ //
12
+ // **What is handed back is `offer`, with the issue and the reason on stderr.** That is the row a session
13
+ // already reads for an offer that produced nothing to start: report what stderr printed, pick no issue
14
+ // by hand. A number that resolves to nothing is an answer rather than a gap, so it is not asked again.
15
+
16
+ type UnreadState = Exclude<StateRead, { kind: 'state' }>
17
+
18
+ const NO_RETRIES = 0
19
+ // The loop's own word for an offer it could not use (`backlog-drive.ts` → `ask_offer`).
20
+ const HANDED_BACK = 'offer'
21
+ const UNKNOWN_REASON = 'unknown reason'
22
+
23
+ function cause_of(read: UnreadState): string {
24
+ if (read.kind === 'missing') return 'the number does not resolve'
25
+ const status = read.status === undefined ? [] : [`status ${String(read.status)}`]
26
+
27
+ return [read.reason ?? UNKNOWN_REASON, ...status].join(', ')
28
+ }
29
+
30
+ function note_of(issue: string, read: UnreadState): string {
31
+ return `The state of named issue ${issue_cite.plain(issue)} could not be read (${cause_of(read)})`
32
+ }
33
+
34
+ function handed_back(note: string): OfferRead {
35
+ console.error(note)
36
+
37
+ return { verdict: HANDED_BACK, issues: [], retries: NO_RETRIES }
38
+ }
39
+
40
+ // `retries` is the count of consecutive failed offers before this one. A named epic's failed
41
+ // `epic:next` advances it too, so the note counts offers, never state reads.
42
+ function offer(issue: string, read: UnreadState, retries: number): OfferRead {
43
+ const note = note_of(issue, read)
44
+ if (read.kind === 'missing') return handed_back(`${note}; check the number and the repository.`)
45
+ const failures = retries + 1
46
+
47
+ if (failures >= backlog_offer.RETRY_LIMIT) {
48
+ return handed_back(`${note}; the offer failed ${String(failures)} times in a row.`)
49
+ }
50
+
51
+ console.error(`${note}; reading it again.`)
52
+
53
+ return { verdict: 'wait', issues: [], retries: failures }
54
+ }
55
+
56
+ export const backlog_drive_unread = { HANDED_BACK, offer }
@@ -26,10 +26,14 @@ const RUN_VERDICT = 'run'
26
26
  const STOP_VERDICT = 'stop'
27
27
  // How the loop reads each `run:merge` token — keyed by the producer's own type, so a token added there
28
28
  // is a type error here until it is classified. `hand-back` ends the loop at once: a hand-off, a person's
29
- // stop, the environment down, a foreign owner, an unreadable state. `stop` — a parked backlog or the
30
- // failure guard — is the offer's `stop` reached through a merge: the child is collected, nothing more
31
- // starts, and the children still in flight are collected before the loop ends. `await` is a cut resumed
32
- // in place: the same lane is awaited again.
29
+ // stop, the environment down, a foreign owner, an unreadable state. `stop` — the failure guard, or a
30
+ // failed child left unparked — collects the child, starts nothing more, and collects the children still
31
+ // in flight before the loop ends. `await` is a cut resumed in place: the same lane is awaited again.
32
+ //
33
+ // **The offer `run:merge` passes through is never read here** (joshuafolkken/kit#3781): its `stop` —
34
+ // only person-waiting issues left — is spelled like the control one, and acting on it ended the run
35
+ // without the idle watch. The loop asks `backlog:offer` on the next pass instead, so whether a parked
36
+ // backlog waits or ends is `backlog:budget`'s alone, after a merge as after any other pass.
33
37
  type TokenHandling = 'hand-back' | 'stop' | 'await'
34
38
  const TOKEN_HANDLING: Readonly<Record<MergeToken, TokenHandling>> = {
35
39
  [MERGE_TOKEN.OVER]: 'hand-back',
@@ -103,7 +107,7 @@ interface DriveEnd {
103
107
  is_finish?: boolean | undefined
104
108
  }
105
109
 
106
- type Collected = Pick<MergeResult, 'token' | 'outcome'>
110
+ type Collected = Pick<MergeResult, 'token' | 'outcome' | 'is_offer'>
107
111
 
108
112
  interface DrivePorts {
109
113
  // Whether a child's process has confirmed-ended — `lane:await`'s re-confirmed check in production.
@@ -209,15 +213,19 @@ function collected_state(
209
213
  // One finished child: handed back on an immediate token, otherwise collected.
210
214
  async function collect(issue: string, state: DriveState, ports: DrivePorts): Promise<PassResult> {
211
215
  const collected = await ports.merge(issue)
212
- const { token } = collected
216
+ const { token, is_offer } = collected
213
217
 
214
218
  // An empty first line is a `run:merge` that failed before printing one: never read as a collection.
219
+ // An offer's `retry` is `backlog:next`'s transport answer, left to the next `backlog:offer`.
215
220
  if (token === '') return ended('merge', state, undefined, issue)
216
- if (IMMEDIATE_TOKENS.has(token)) return ended('merge', state, token, issue)
221
+ if (!is_offer && IMMEDIATE_TOKENS.has(token)) return ended('merge', state, token, issue)
217
222
 
218
223
  const next = collected_state(issue, collected, state, ports.now().toISOString())
219
224
 
220
- return { kind: 'continue', state: token === MERGE_TOKEN.STOP ? merge_stopped(issue, next) : next }
225
+ return {
226
+ kind: 'continue',
227
+ state: run_merge_token.is_control_stop(collected) ? merge_stopped(issue, next) : next,
228
+ }
221
229
  }
222
230
 
223
231
  async function collect_finished(state: DriveState, ports: DrivePorts): Promise<PassResult> {
@@ -78,9 +78,8 @@ interface SeparateContext {
78
78
  repo: string
79
79
  }
80
80
 
81
- // The path each running lane declares, mapped to the issue that holds it.
82
- function running_claims(declared: Declared, read: BusyRead): Map<string, number> {
83
- const holders = read.kind === 'busy' ? read.issues.map((issue) => issue.number) : []
81
+ // The path each of `holders` declares, mapped to the issue that holds it.
82
+ function claims_of(declared: Declared, holders: ReadonlyArray<number>): Map<string, number> {
84
83
  const pairs = holders.flatMap((number) =>
85
84
  [...(declared.get(number) ?? [])].map((path): [string, number] => [path, number]),
86
85
  )
@@ -88,6 +87,10 @@ function running_claims(declared: Declared, read: BusyRead): Map<string, number>
88
87
  return new Map(pairs)
89
88
  }
90
89
 
90
+ function running_claims(declared: Declared, read: BusyRead): Map<string, number> {
91
+ return claims_of(declared, read.kind === 'busy' ? read.issues.map((issue) => issue.number) : [])
92
+ }
93
+
91
94
  function paths_of(child: EpicChild, declared: Declared, repo: string): ReadonlySet<string> {
92
95
  if (child.repo !== repo) return new Set()
93
96
 
@@ -113,6 +116,20 @@ function clash_of(
113
116
  return clashes[0]
114
117
  }
115
118
 
119
+ // The running issues a candidate is weighed against, and the repository they are in.
120
+ interface Holding {
121
+ holders: ReadonlyArray<number>
122
+ repo: string
123
+ }
124
+
125
+ // The path `child` declares that one of the running `holders` already declares — the clash `separate`
126
+ // withholds a candidate on, for a reader that names the running issues itself (`run:board`).
127
+ function running_clash(child: EpicChild, declared: Declared, holding: Holding): Clash | undefined {
128
+ const claims = claims_of(declared, holding.holders)
129
+
130
+ return clash_of(claims, paths_of(child, declared, holding.repo))
131
+ }
132
+
116
133
  function clash_note(child: EpicChild, clash: Clash): string {
117
134
  const waiting = session_cite.issue(child.number, undefined, child.repo)
118
135
  const holder = session_cite.issue(clash.holder, undefined, child.repo)
@@ -172,7 +189,7 @@ function separate(
172
189
  return { offered: walk.offered, withheld, ...(notice !== undefined && { notice }) }
173
190
  }
174
191
 
175
- const backlog_overlap = { declared_of, declared_paths, separate }
192
+ const backlog_overlap = { declared_of, declared_paths, running_clash, separate }
176
193
 
177
194
  export { backlog_overlap }
178
- export type { Declared }
195
+ export type { Clash, Declared }
@@ -25,9 +25,7 @@ interface ReadyPorts {
25
25
 
26
26
  // The lanes in use: the open worktrees an interruption has not stranded. `josh lane:limit` prints it.
27
27
  async function live_lane_count(): Promise<number> {
28
- const lanes = await lane_registry.list_lanes()
29
-
30
- return lanes.filter((lane) => !lane.is_stranded).length
28
+ return lane_registry.live_count(await lane_registry.list_lanes())
31
29
  }
32
30
 
33
31
  async function free_under(limit: number): Promise<number> {
@@ -9,8 +9,12 @@ import { run_invocation } from '#scripts/run/run-invocation'
9
9
  //
10
10
  // **A bare number is an issue.** A shell reads an unquoted `#3437` as a comment, so `3437` in the named
11
11
  // list is taken as `#3437`; a number after the first flag is that flag's value and is left alone.
12
+ //
13
+ // **`--stop` stands alone.** It stops the run rather than starting one, so beside an issue or any other
14
+ // flag it is refused before anything is stopped — a mistyped start must never end a run.
12
15
 
13
16
  const AGENT_FLAG = '--agent'
17
+ const STOP_FLAG = '--stop'
14
18
  const BACKLOG_COMMAND = 'backlogrun'
15
19
  const ISSUE_PREFIX = '#'
16
20
  const FLAG_PREFIX = '-'
@@ -26,7 +30,8 @@ interface BacklogrunArguments {
26
30
  issues: ReadonlyArray<number>
27
31
  }
28
32
 
29
- type ArgumentsResult = { kind: 'args'; args: BacklogrunArguments } | { kind: 'refused' }
33
+ type ArgumentsResult =
34
+ { kind: 'args'; args: BacklogrunArguments } | { kind: 'stop' } | { kind: 'refused' }
30
35
 
31
36
  interface AgentSplit {
32
37
  agent: string | undefined
@@ -56,7 +61,7 @@ function normalized(rest: ReadonlyArray<string>): Array<string> {
56
61
  return [...rest.slice(0, split_at).map((token) => issue_token(token)), ...rest.slice(split_at)]
57
62
  }
58
63
 
59
- function parse(argv: ReadonlyArray<string>): ArgumentsResult {
64
+ function parse_start(argv: ReadonlyArray<string>): ArgumentsResult {
60
65
  const { agent, rest } = split_agent(argv)
61
66
  const provider = agent === undefined ? undefined : AGENTS[agent]
62
67
  const invocation = run_invocation.rebuild([BACKLOG_COMMAND, ...normalized(rest)].join(' '))
@@ -68,6 +73,12 @@ function parse(argv: ReadonlyArray<string>): ArgumentsResult {
68
73
  return { kind: 'args', args: { provider, invocation, issues } }
69
74
  }
70
75
 
76
+ function parse(argv: ReadonlyArray<string>): ArgumentsResult {
77
+ if (!argv.includes(STOP_FLAG)) return parse_start(argv)
78
+
79
+ return { kind: argv.length === 1 ? 'stop' : 'refused' }
80
+ }
81
+
71
82
  const backlogrun_args = { parse }
72
83
 
73
84
  export type { BacklogrunArguments }