@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,71 @@
1
+ import { parse } from 'csv-parse/sync'
2
+ import { run_board_render_title } from './run-board-render-title'
3
+
4
+ // The reviewers who helped kit, read off the published roster's CSV. The roster is a spreadsheet anyone
5
+ // with edit access changes, so nothing in it is trusted as drawn: a sheet whose header is not `name,url`
6
+ // is not the roster, a row without a name or with a link that is not `https://` is dropped, and a name
7
+ // loses every control character — an escape in a cell would otherwise be written to the terminal as one.
8
+
9
+ const HEADER = ['name', 'url'] as const
10
+ // Enough for any person's or project's name, short enough that one cell cannot fill the line; a
11
+ // longer one is cut to an ellipsis, as a row's title is.
12
+ const NAME_LIMIT = 40
13
+ const CONTROL = /\p{Cc}/gu
14
+ const SAFE_URL = /^https:\/\/[^\s\p{Cc}]+$/u
15
+
16
+ interface Thanker {
17
+ name: string
18
+ // The page the name opens; `undefined` draws the name unlinked.
19
+ url: string | undefined
20
+ }
21
+
22
+ type Thanks = ReadonlyArray<Thanker>
23
+
24
+ function name_of(cell: string): string {
25
+ return run_board_render_title.title_of(cell.replaceAll(CONTROL, '').trim(), NAME_LIMIT)
26
+ }
27
+
28
+ // An empty cell is a name without a link; any other value has to be a whole `https://` URL.
29
+ function url_of(cell: string): string | undefined | false {
30
+ const url = cell.trim()
31
+
32
+ if (url === '') return undefined
33
+
34
+ return SAFE_URL.test(url) && URL.canParse(url) ? url : false
35
+ }
36
+
37
+ function thanker_of(row: ReadonlyArray<string>): Array<Thanker> {
38
+ const [name_cell = '', url_cell = ''] = row
39
+ const name = name_of(name_cell)
40
+ const url = url_of(url_cell)
41
+
42
+ if (name === '' || url === false) return []
43
+
44
+ return [{ name, url }]
45
+ }
46
+
47
+ function is_roster(header: ReadonlyArray<string> | undefined): boolean {
48
+ return header?.length === HEADER.length && HEADER.every((cell, index) => header[index] === cell)
49
+ }
50
+
51
+ function rows_of(csv: string): Array<Array<string>> | undefined {
52
+ try {
53
+ return parse(csv, { relax_column_count: true, skip_empty_lines: true, bom: true })
54
+ } catch {
55
+ return undefined
56
+ }
57
+ }
58
+
59
+ // `undefined` for a text that is not the roster, so a failed read is told apart from an empty roster.
60
+ function thanks_of(csv: string): Thanks | undefined {
61
+ const [header, ...rows] = rows_of(csv) ?? []
62
+
63
+ if (!is_roster(header)) return undefined
64
+
65
+ return rows.flatMap((row) => thanker_of(row))
66
+ }
67
+
68
+ const run_board_thanks_csv = { NAME_LIMIT, thanks_of }
69
+
70
+ export { run_board_thanks_csv }
71
+ export type { Thanker, Thanks }
@@ -0,0 +1,112 @@
1
+ import { PROJECT_ROOT } from '#scripts/init/init-paths'
2
+ import { stamp_file } from '#scripts/josh/stamp-file'
3
+ import { json_value } from '#scripts/lib/json-value'
4
+ import { timed_fetch } from '#scripts/lib/timed-fetch'
5
+ import { run_progress } from '#scripts/run/progress/run-progress'
6
+ import { z } from 'zod'
7
+ import { run_board_thanks_csv, type Thanks } from './run-board-thanks-csv'
8
+
9
+ // The roster `run:board` thanks its reviewers from: a Google spreadsheet published as CSV, changed
10
+ // apart from any build or release, so it is read at run time. Its answer is kept in the project's
11
+ // record beside the board's other state and reused for five minutes — Google's own cache holds it as
12
+ // long — so neither a redraw nor a fresh `--once` asks again inside that window. A read that fails
13
+ // keeps the last answer, and with none draws no line; nothing about the failure reaches the screen.
14
+
15
+ const ROSTER_URL =
16
+ 'https://docs.google.com/spreadsheets/d/e/2PACX-1vR8Ia38Wiw6Kfyk9Or594J8ZRS8okynV30CpOtq5B6_GyJG7A1-t8Au64jTYvmzgIOlbafcKKgrNFVJ/pub?gid=0&single=true&output=csv'
17
+ const RECORD_PREFIX = 'josh-run-board-thanks-'
18
+ const TTL_MINUTES = 5
19
+ const TTL_MS = TTL_MINUTES * run_progress.MS_PER_MINUTE
20
+
21
+ const thanker_schema = z.object({ name: z.string(), url: z.string().optional() })
22
+ const record_schema = z.object({ checked_ms: z.number(), thanks: z.array(thanker_schema) })
23
+
24
+ interface ThanksRecord {
25
+ checked_ms: number
26
+ thanks: Thanks
27
+ }
28
+
29
+ // What the read works through, injected in tests.
30
+ interface ThanksDependencies {
31
+ fetch_text: () => Promise<string>
32
+ record_path: string
33
+ now: () => number
34
+ }
35
+
36
+ function read_record(record_path: string): ThanksRecord | undefined {
37
+ const raw = stamp_file.read_stamp_text(record_path)
38
+ const parsed = raw === undefined ? undefined : json_value.parse_with(raw, record_schema)
39
+
40
+ if (parsed === undefined) return undefined
41
+
42
+ return {
43
+ checked_ms: parsed.checked_ms,
44
+ thanks: parsed.thanks.map(({ name, url }) => ({ name, url })),
45
+ }
46
+ }
47
+
48
+ // A record that cannot be written leaves the next read to ask again — never a reason to fail the board.
49
+ function write_record(record_path: string, record: ThanksRecord): void {
50
+ try {
51
+ stamp_file.replace_stamp(record_path, record)
52
+ } catch {
53
+ // The board draws what it read either way.
54
+ }
55
+ }
56
+
57
+ async function fetch_roster(read: () => Promise<string>): Promise<Thanks | undefined> {
58
+ try {
59
+ return run_board_thanks_csv.thanks_of(await read())
60
+ } catch {
61
+ return undefined
62
+ }
63
+ }
64
+
65
+ // The recorded roster while it is younger than the record's life; `undefined` once it is stale. A
66
+ // record dated after now — the clock stepped back — is stale too, or it would hold until the clock
67
+ // caught up with it.
68
+ function fresh_thanks(record: ThanksRecord | undefined, now_ms: number): Thanks | undefined {
69
+ if (record === undefined) return undefined
70
+
71
+ const age_ms = now_ms - record.checked_ms
72
+
73
+ return age_ms >= 0 && age_ms < TTL_MS ? record.thanks : undefined
74
+ }
75
+
76
+ // A response that is not a success is a failed read, so its body is never taken for the roster.
77
+ async function fetch_text(): Promise<string> {
78
+ const response = await timed_fetch(ROSTER_URL)
79
+
80
+ if (!response.ok) throw new Error(`roster read answered ${String(response.status)}`)
81
+
82
+ return await response.text()
83
+ }
84
+
85
+ const LIVE_DEPS: ThanksDependencies = {
86
+ fetch_text,
87
+ record_path: stamp_file.stamp_path(RECORD_PREFIX, PROJECT_ROOT),
88
+ now: () => Date.now(),
89
+ }
90
+
91
+ async function read_with(deps: ThanksDependencies): Promise<Thanks> {
92
+ const now_ms = deps.now()
93
+ const record = read_record(deps.record_path)
94
+ const fresh = fresh_thanks(record, now_ms)
95
+
96
+ if (fresh !== undefined) return fresh
97
+
98
+ const thanks = (await fetch_roster(deps.fetch_text)) ?? record?.thanks ?? []
99
+
100
+ write_record(deps.record_path, { checked_ms: now_ms, thanks })
101
+
102
+ return thanks
103
+ }
104
+
105
+ async function read_thanks(deps: ThanksDependencies = LIVE_DEPS): Promise<Thanks> {
106
+ return await read_with(deps)
107
+ }
108
+
109
+ const run_board_thanks_read = { TTL_MS, read_thanks }
110
+
111
+ export { run_board_thanks_read }
112
+ export type { ThanksDependencies }
@@ -0,0 +1,53 @@
1
+ import { run_board_fetch, type Fetch } from './run-board-fetch'
2
+ import { run_board_state, type BoardPorts, type BoardState, type Pace } from './run-board-state'
3
+ import type { Thanks } from './run-board-thanks-csv'
4
+ import { run_board_thanks_read } from './run-board-thanks-read'
5
+
6
+ // The reviewers' roster a `run:board` redraw draws above its legend. Like the GitHub reads
7
+ // (`run-board-github.ts`), **a live board never waits on it**: the read is launched and folded in on
8
+ // the redraw after it lands; one plain frame waits for it. It is launched again no sooner than the
9
+ // roster's own record goes stale. A board with no `read_thanks` port — the chat frame — reads none and
10
+ // draws none.
11
+
12
+ const { TTL_MS } = run_board_thanks_read
13
+ const { is_due } = run_board_state
14
+
15
+ type ThanksState = Pick<BoardState, 'thanks' | 'thanks_fetch' | 'thanks_attempted_ms'>
16
+
17
+ function launch_thanks(state: BoardState, ports: BoardPorts, now_ms: number): BoardState {
18
+ const { read_thanks } = ports
19
+
20
+ if (read_thanks === undefined || state.thanks_fetch !== undefined) return state
21
+ if (!is_due(state.thanks_attempted_ms, TTL_MS, now_ms)) return state
22
+
23
+ const thanks_fetch: Fetch<Thanks> = run_board_fetch.launch(read_thanks, state.thanks)
24
+
25
+ return { ...state, thanks_fetch, thanks_attempted_ms: now_ms }
26
+ }
27
+
28
+ function fold_thanks(state: BoardState): BoardState {
29
+ const answer = state.thanks_fetch?.answer()
30
+
31
+ if (answer === undefined) return state
32
+
33
+ return { ...state, thanks: answer.value, thanks_fetch: undefined }
34
+ }
35
+
36
+ // Folds what landed and launches what is due; at the `settled` pace, waits for it to land.
37
+ async function gather_thanks(
38
+ state: BoardState,
39
+ ports: BoardPorts,
40
+ now_ms: number,
41
+ pace: Pace,
42
+ ): Promise<BoardState> {
43
+ const launched = launch_thanks(fold_thanks(state), ports, now_ms)
44
+
45
+ if (pace === 'settled' && launched.thanks_fetch !== undefined) await launched.thanks_fetch.landed
46
+
47
+ return fold_thanks(launched)
48
+ }
49
+
50
+ const run_board_thanks = { gather_thanks }
51
+
52
+ export { run_board_thanks }
53
+ export type { ThanksState }
@@ -8,10 +8,12 @@ import { run_board_link } from './run-board-link'
8
8
  import { run_board_machine } from './run-board-machine'
9
9
  import { run_board_notes, type Titles } from './run-board-notes'
10
10
  import type { LocalRead } from './run-board-read'
11
+ import { run_board_reason } from './run-board-reason'
11
12
  import { run_board_render, type FrameBounds } from './run-board-render'
12
13
  import { run_board_spin, type Spun } from './run-board-spin'
13
14
  import { run_board_state, type BoardPorts, type BoardState, type Pace } from './run-board-state'
14
15
  import { run_board_status, type RunActivity } from './run-board-status'
16
+ import { run_board_thanks } from './run-board-thanks'
15
17
  import { run_board_usage } from './run-board-usage'
16
18
 
17
19
  // One `run:board` redraw. **Three speeds**: the frame is redrawn once a second, on the second, so
@@ -30,6 +32,7 @@ const LOCAL_READ_SECONDS = 5
30
32
  const LOCAL_READ_MS = LOCAL_READ_SECONDS * run_progress.MS_PER_SECOND
31
33
  const { FRESH_STATE, is_due } = run_board_state
32
34
  const { PLAN_RETRY_MS, gather_github, statuses_for } = run_board_github
35
+ const { gather_thanks } = run_board_thanks
33
36
 
34
37
  async function reread(state: BoardState, ports: BoardPorts, now_ms: number): Promise<BoardState> {
35
38
  if (!is_due(state.read_ms, LOCAL_READ_MS, now_ms)) return state
@@ -88,17 +91,17 @@ function titles_of(state: BoardState): Titles {
88
91
  return with_closed(state.plan, state.closed).context.titles
89
92
  }
90
93
 
91
- // The statuses scoped to the run's plan, a running child the plan's listing no longer holds settled.
92
- // Before the plan is read, the run's own children alone.
93
- function layout_for(state: BoardState, local: LocalRead): BoardLayout {
94
- const { plan, closed } = state
95
-
96
- if (plan === undefined) {
97
- return run_board_layout.local_layout_of(
98
- run_board_status.statuses_of(local.events, local.lanes, new Set()),
99
- )
100
- }
94
+ // A run `run:carry --end` closed and one nobody drives any more are both drawn as stopped.
95
+ function activity_here(local: LocalRead): RunActivity {
96
+ return run_board_status.activity_of(
97
+ local.events,
98
+ local.ended_ms !== undefined || local.is_stranded,
99
+ )
100
+ }
101
101
 
102
+ // The plan laid under its statuses, each waiting row saying why it waits.
103
+ function planned_layout(state: BoardState, plan: BoardPlan, local: LocalRead): BoardLayout {
104
+ const { closed } = state
102
105
  const read = {
103
106
  open_numbers: plan.context.open_numbers,
104
107
  read_ms: state.fetched_ms,
@@ -106,8 +109,25 @@ function layout_for(state: BoardState, local: LocalRead): BoardLayout {
106
109
  labels: plan.labels,
107
110
  }
108
111
  const statuses = run_board_status.settle_closed(statuses_for(plan, local), read)
112
+ const reason_of = run_board_reason.reader({
113
+ plan,
114
+ statuses,
115
+ events: local.events,
116
+ free_lanes: local.free_lanes,
117
+ is_stopped: activity_here(local).is_stopped,
118
+ })
109
119
 
110
- return run_board_layout.layout_of(with_closed(plan, closed), statuses)
120
+ return run_board_layout.layout_of(with_closed(plan, closed), statuses, reason_of)
121
+ }
122
+
123
+ // The statuses scoped to the run's plan, a running child the plan's listing no longer holds settled.
124
+ // Before the plan is read, the run's own children alone.
125
+ function layout_for(state: BoardState, local: LocalRead): BoardLayout {
126
+ if (state.plan !== undefined) return planned_layout(state, state.plan, local)
127
+
128
+ return run_board_layout.local_layout_of(
129
+ run_board_status.statuses_of(local.events, local.lanes, new Set()),
130
+ )
111
131
  }
112
132
 
113
133
  // What one redraw draws with.
@@ -116,12 +136,20 @@ interface Redraw {
116
136
  now_ms: number
117
137
  }
118
138
 
119
- // A run `run:carry --end` closed and one nobody drives any more are both drawn as stopped.
120
- function activity_here(local: LocalRead): RunActivity {
121
- return run_board_status.activity_of(
122
- local.events,
123
- local.ended_ms !== undefined || local.is_stranded,
124
- )
139
+ // What the header reads off the local read.
140
+ type LocalHeader = Pick<
141
+ BoardHeader,
142
+ 'started_ms' | 'ended_ms' | 'activity' | 'operations' | 'usual'
143
+ >
144
+
145
+ function local_header(local: LocalRead): LocalHeader {
146
+ return {
147
+ started_ms: local.started_ms,
148
+ ended_ms: local.ended_ms,
149
+ activity: activity_here(local),
150
+ operations: local.operations,
151
+ usual: local.usual,
152
+ }
125
153
  }
126
154
 
127
155
  function header_of(
@@ -132,9 +160,7 @@ function header_of(
132
160
  ): BoardHeader {
133
161
  return {
134
162
  now_ms: redraw.now_ms,
135
- started_ms: local.started_ms,
136
- ended_ms: local.ended_ms,
137
- activity: activity_here(local),
163
+ ...local_header(local),
138
164
  layout,
139
165
  baseline_total: state.baseline_total,
140
166
  plan_fetched_ms: state.fetched_ms,
@@ -161,6 +187,8 @@ function frame_of(state: BoardState, local: LocalRead, layout: BoardLayout, redr
161
187
  header: header_of(state, local, layout, redraw),
162
188
  notes: run_board_notes.notes_of(local.events, titles_of(state)),
163
189
  resume: local.resume,
190
+ thanks: state.thanks,
191
+ link: redraw.ports.link,
164
192
  ...bounds,
165
193
  })
166
194
 
@@ -203,8 +231,9 @@ async function tick(
203
231
  }
204
232
 
205
233
  const gathered = await gather_github(state_for(read, local), { ports, local, now_ms, pace })
234
+ const thanked = await gather_thanks(gathered, ports, now_ms, pace)
206
235
 
207
- return draw_run(await resample(gathered, ports, now_ms), local, { ports, now_ms })
236
+ return draw_run(await resample(thanked, ports, now_ms), local, { ports, now_ms })
208
237
  }
209
238
 
210
239
  const run_board_tick = {
@@ -0,0 +1,44 @@
1
+ import { lane_ledger, type LedgerEntry } from '#scripts/lane/lane-ledger'
2
+ import { lane_stage_stats } from '#scripts/lane/lane-stage-stats'
3
+ import { run_board_phase, type Phase } from './run-board-phase'
4
+
5
+ // How long each phase usually takes: the lane ledger's median of the stage the phase is
6
+ // (`lane-stage-stats.ts`), so a running child that has stood at its phase far longer stands out
7
+ // (joshuafolkken/kit#3769). A phase is a `josh ship` stage under the names `run-board-phase.ts` gives
8
+ // them, and `implement` is the ledger's derived one. A phase no stage records — `investigate`, `plan`,
9
+ // `failed` — has no usual time, and is never drawn as long.
10
+
11
+ type UsualTimes = ReadonlyMap<Phase, number>
12
+
13
+ const IMPLEMENT_PHASE: Phase = 'implement'
14
+
15
+ // Each phase's ledger stage, from the stage-to-phase names a row's phase is drawn with. The ledger's
16
+ // `implement` runs from the dispatch, so it also holds the investigation and the plan the row draws
17
+ // before its `implement` phase: the usual time is the longer, which can only draw a phase as long
18
+ // later, never sooner.
19
+ const PHASE_STAGES: ReadonlyArray<readonly [string, Phase | undefined]> = [
20
+ [lane_stage_stats.IMPLEMENT, IMPLEMENT_PHASE],
21
+ ...Object.entries(run_board_phase.SHIP_PHASES),
22
+ ]
23
+
24
+ function usual_of(entries: ReadonlyArray<LedgerEntry>): UsualTimes {
25
+ const medians = lane_stage_stats.medians(entries)
26
+
27
+ return new Map(
28
+ PHASE_STAGES.flatMap(([stage, phase]) => {
29
+ const median = medians.get(stage)
30
+
31
+ return phase === undefined || median === undefined ? [] : [[phase, median] as const]
32
+ }),
33
+ )
34
+ }
35
+
36
+ // The repository's whole ledger, read where the board reads its stream; an absent one has no usual time.
37
+ function read(repository: string): UsualTimes {
38
+ return usual_of(lane_ledger.read_entries(lane_ledger.target_of(repository)))
39
+ }
40
+
41
+ const run_board_usual = { read, usual_of }
42
+
43
+ export { run_board_usual }
44
+ export type { UsualTimes }
@@ -1,5 +1,6 @@
1
1
  import { issue_number_shape } from '#scripts/issue/issue-number-shape'
2
2
  import { cli_flags } from '#scripts/lib/cli-flags'
3
+ import { run_headless } from '#scripts/run/run-headless'
3
4
  import { run_invocation } from '#scripts/run/run-invocation'
4
5
  import { run_carry, type CarryChange, type CarryClaimRequest, type CarryOwner } from './run-carry'
5
6
 
@@ -178,6 +179,13 @@ function group_count(values: ParsedValues): number {
178
179
  return groups.filter(Boolean).length
179
180
  }
180
181
 
182
+ // The mark rides the environment the run launched its session with; the record keeps it beside the
183
+ // owner, so `josh backlogrun --stop` knows that owner for the run's own. An attached owner is left as
184
+ // it is, so its record is the one it always was.
185
+ function marked(owner: CarryOwner): CarryOwner {
186
+ return run_headless.is_headless_parent(process.env) ? { ...owner, is_headless: true } : owner
187
+ }
188
+
181
189
  // **`--owner` is what makes liveness answerable at all.** The process that runs this command is a
182
190
  // short-lived `tsx`, so its own pid says nothing about whether the run is still going; the caller
183
191
  // names the process that is — its own session — and a `backlogrun` passes `--owner "$PPID"`. Absent,
@@ -192,7 +200,7 @@ function to_owner(value: OptionValue): CarryOwner | undefined {
192
200
 
193
201
  const pid = Number(text)
194
202
 
195
- return pid < MIN_PID ? undefined : run_carry.owner_of(pid)
203
+ return pid < MIN_PID ? undefined : marked(run_carry.owner_of(pid))
196
204
  }
197
205
 
198
206
  // A non-empty `--summary`, or nothing. An empty string is nothing here, so it reads as absent and the
@@ -6,10 +6,8 @@ import { cost_verdict } from '#scripts/cost-runtime/cost-verdict'
6
6
  import { run_event_setup } from '#scripts/run/event/run-event-setup'
7
7
  import { run_event_stream } from '#scripts/run/event/run-event-stream'
8
8
  import { run_event_stream_emit } from '#scripts/run/event/run-event-stream-emit'
9
- import { run_merge_collect } from '#scripts/run/merge/run-merge-collect'
10
9
  import { run_cli_fault } from '#scripts/run/run-cli-fault'
11
10
  import { run_headless } from '#scripts/run/run-headless'
12
- import { run_stop_notify } from '#scripts/run/run-stop-notify'
13
11
  import {
14
12
  run_carry,
15
13
  type CarryClaim,
@@ -19,8 +17,7 @@ import {
19
17
  } from './run-carry'
20
18
  import { run_carry_args, type CountRequest, type Request } from './run-carry-args'
21
19
  import { run_carry_conversation } from './run-carry-conversation'
22
- import { run_carry_ended } from './run-carry-ended'
23
- import { run_carry_stash } from './run-carry-stash'
20
+ import { run_carry_end } from './run-carry-end'
24
21
 
25
22
  // `josh run:carry` — the record that carries one invocation's budget across its own session cuts.
26
23
  // A `backlogrun` begins it, counts a merge and a filing into it, marks each
@@ -316,52 +313,12 @@ async function count(
316
313
  return report_carry(COUNTED_VERDICT, carry, is_json)
317
314
  }
318
315
 
319
- // **A stop over a record that is still there pushes one ⏸️ confirmation before the record goes**.
320
- // `plan` is read from the record `--end` is about to remove, so the second `--end` reads `none` and
321
- // plans nothing — one stop never notifies twice. A clean `--end` passes no reason and stays silent,
322
- // because a completed run has its own notification. The closed-issue stash report
323
- // (`run-carry-stash.ts`) is once per invocation on the same read.
324
- async function report_end(read: CarryRead): Promise<void> {
325
- if (read.kind === 'none') return
326
-
327
- await run_carry_stash.report_orphans()
328
- }
329
-
330
- // Best-effort: the board's record of the ended run never stands between `--end` and the carry record it
331
- // clears, so a failed write goes to stderr rather than leaving the run carried.
332
- function keep_ended(directory: string, read: CarryRead, stopped: string | undefined): void {
333
- try {
334
- run_carry_ended.record_ended(run_carry_ended.ended_path(directory), read, new Date(), stopped)
335
- } catch (error) {
336
- console.error(`run:carry: the ended run was not recorded for run:board: ${String(error)}`)
337
- }
338
- }
339
-
340
- // The record goes, and the run it held stays readable as the last ended run, so `run:board` keeps the
341
- // finished run on screen until the next one begins — a stopped one with the session to resume it from.
342
- function close_record(directory: string, stopped: string | undefined): CarryRead {
343
- const target = run_carry.carry_path(directory)
344
- const read = run_carry.read_carry(target)
345
-
346
- keep_ended(directory, read, stopped)
347
- run_carry.end_carry(target)
348
-
349
- return read
350
- }
351
-
352
- // The merges the run ended with are recorded while its record can still count them.
353
316
  async function finish(
354
317
  directory: string,
355
318
  stopped: string | undefined,
356
319
  is_json: boolean,
357
320
  ): Promise<number> {
358
- await run_merge_collect.collect_merged(directory)
359
- const read = close_record(directory, stopped)
360
- const notice = run_stop_notify.plan(read, stopped)
361
-
362
- if (notice !== undefined) await run_stop_notify.announce(notice)
363
-
364
- await report_end(read)
321
+ const read = await run_carry_end.end_run(directory, { stopped, is_announced: true })
365
322
 
366
323
  if (read.kind === 'none') return report(NONE_VERDICT, undefined, is_json, SUCCESS_EXIT_CODE)
367
324
 
@@ -0,0 +1,73 @@
1
+ import { run_merge_collect } from '#scripts/run/merge/run-merge-collect'
2
+ import { run_stop_notify } from '#scripts/run/run-stop-notify'
3
+ import { run_carry, type CarryRead } from './run-carry'
4
+ import { run_carry_ended } from './run-carry-ended'
5
+ import { run_carry_stash } from './run-carry-stash'
6
+
7
+ // Ending a run's record, apart from the command that prints the verdict: `run:carry --end` and
8
+ // `josh backlogrun --stop` both end it here, so the one end is never written twice.
9
+
10
+ interface EndRequest {
11
+ // Why the run stopped; `undefined` is a clean finish.
12
+ stopped: string | undefined
13
+ // Whether a stop pushes its ⏸️ confirmation. A person who typed the stop is already at the terminal.
14
+ is_announced: boolean
15
+ }
16
+
17
+ // **A stop over a record that is still there pushes one ⏸️ confirmation before the record goes**.
18
+ // `plan` is read from the record the end is about to remove, so the second end reads `none` and
19
+ // plans nothing — one stop never notifies twice. A clean end passes no reason and stays silent,
20
+ // because a completed run has its own notification.
21
+ async function announce_stop(read: CarryRead, request: EndRequest): Promise<void> {
22
+ if (!request.is_announced) return
23
+
24
+ const notice = run_stop_notify.plan(read, request.stopped)
25
+
26
+ if (notice !== undefined) await run_stop_notify.announce(notice)
27
+ }
28
+
29
+ // The closed-issue stash report (`run-carry-stash.ts`) is once per invocation on the same read.
30
+ async function report_end(read: CarryRead): Promise<void> {
31
+ if (read.kind === 'none') return
32
+
33
+ await run_carry_stash.report_orphans()
34
+ }
35
+
36
+ // Best-effort: the board's record of the ended run never stands between the end and the carry record it
37
+ // clears, so a failed write goes to stderr rather than leaving the run carried.
38
+ function keep_ended(directory: string, read: CarryRead, stopped: string | undefined): void {
39
+ try {
40
+ run_carry_ended.record_ended(run_carry_ended.ended_path(directory), read, new Date(), stopped)
41
+ } catch (error) {
42
+ console.error(`run:carry: the ended run was not recorded for run:board: ${String(error)}`)
43
+ }
44
+ }
45
+
46
+ // The record goes, and the run it held stays readable as the last ended run, so `run:board` keeps the
47
+ // finished run on screen until the next one begins — a stopped one with the session to resume it from.
48
+ function close_record(directory: string, stopped: string | undefined): CarryRead {
49
+ const target = run_carry.carry_path(directory)
50
+ const read = run_carry.read_carry(target)
51
+
52
+ keep_ended(directory, read, stopped)
53
+ run_carry.end_carry(target)
54
+
55
+ return read
56
+ }
57
+
58
+ // The merges the run ended with are recorded while its record can still count them. The read handed
59
+ // back is the record as it stood before it was removed.
60
+ async function end_run(directory: string, request: EndRequest): Promise<CarryRead> {
61
+ await run_merge_collect.collect_merged(directory)
62
+ const read = close_record(directory, request.stopped)
63
+
64
+ await announce_stop(read, request)
65
+ await report_end(read)
66
+
67
+ return read
68
+ }
69
+
70
+ const run_carry_end = { end_run }
71
+
72
+ export type { EndRequest }
73
+ export { run_carry_end }
@@ -102,6 +102,9 @@ interface RunCarry {
102
102
  // The owner conversation's transcript, which outlives a restart of its process
103
103
  // (`run-carry-conversation.ts`). Optional for the same reason as the pid.
104
104
  owner_transcript?: string | undefined
105
+ // The owner is a session the run launched headless (`run-headless.ts`), so nobody can end the run
106
+ // from it. Kept here, beside the owner it describes, so it outlives the supervisor's own record.
107
+ is_owner_headless?: boolean | undefined
105
108
  // Set by `--cut`, and by nothing else. A crash never reaches `--cut`, which is what makes a
106
109
  // declared cut the only standing record carried without a person deciding.
107
110
  is_handed_off?: boolean | undefined
@@ -138,6 +141,7 @@ interface CarryOwner {
138
141
  pid?: number | undefined
139
142
  start?: string | undefined
140
143
  transcript?: string | undefined
144
+ is_headless?: boolean | undefined
141
145
  }
142
146
 
143
147
  // **`resume` is the only one of the four that establishes anything.** The other three are refusals,
@@ -199,6 +203,7 @@ const run_carry_schema = z.object({
199
203
  owner_pid: z.number().optional(),
200
204
  owner_start: z.string().optional(),
201
205
  owner_transcript: z.string().optional(),
206
+ is_owner_headless: z.boolean().optional(),
202
207
  is_handed_off: z.boolean().optional(),
203
208
  done: z.array(z.number()).optional(),
204
209
  retrospective: z.boolean().optional(),
@@ -265,6 +270,7 @@ function fresh_carry(invocation: string, owner: CarryOwner, now: Date): RunCarry
265
270
  owner_pid: owner.pid,
266
271
  owner_start: owner.start,
267
272
  owner_transcript: owner.transcript,
273
+ is_owner_headless: owner.is_headless,
268
274
  }
269
275
  }
270
276
 
@@ -297,7 +303,7 @@ function adopt_carry(
297
303
  owner: CarryOwner = NO_OWNER,
298
304
  ): RunCarry | undefined {
299
305
  // The run's own fields carry across untouched — the counters, the outage fold timestamp, `done` so the successor does not re-run what the cut session finished,
300
- // and `retrospective` so a run resumed after a cut does not repeat its retrospective. Only the three
306
+ // and `retrospective` so a run resumed after a cut does not repeat its retrospective. Only the
301
307
  // ownership fields are overridden: the new owner replaces the old — a caller that declared none
302
308
  // writes `undefined`, so the previous owner cannot survive — and the declared hand-off is spent.
303
309
  const next: RunCarry = {
@@ -305,6 +311,7 @@ function adopt_carry(
305
311
  owner_pid: owner.pid,
306
312
  owner_start: owner.start,
307
313
  owner_transcript: owner.transcript,
314
+ is_owner_headless: owner.is_headless,
308
315
  is_handed_off: false,
309
316
  }
310
317
 
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { fileURLToPath } from 'node:url'
3
3
  import { issue_number_shape } from '#scripts/issue/issue-number-shape'
4
+ import { issue_watch } from '#scripts/issue/issue-watch'
4
5
  import { session_cite } from '#scripts/issue/session-cite'
5
6
  import { josh_command } from '#scripts/josh/josh-run'
6
7
  import { lane_child_marker } from '#scripts/lane/lane-child-marker'
@@ -254,6 +255,10 @@ async function proceed(issue_number: string, hold: string, cost: string): Promis
254
255
  await run_label.mark(issue_number)
255
256
  const reads = await gather_reads(issue_number)
256
257
 
258
+ // The issue the report prints is the one the run has read, so it is the baseline a person's later
259
+ // update is measured against (`issue-watch.ts`).
260
+ await issue_watch.record_baseline_here(issue_number)
261
+
257
262
  return emit({ issue_number, hold, cost, verdict: reads.verdict, report: reads.report })
258
263
  }
259
264