@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
@@ -1,3 +1,4 @@
1
+ import type { Declared } from '#scripts/backlog/backlog-overlap'
1
2
  import { backlog_plan, type PlanContext } from '#scripts/backlog/backlog-plan'
2
3
  import type { WavePlan } from '#scripts/backlog/backlog-waves'
3
4
  import type { EpicChild } from '#scripts/epic/epic-graph'
@@ -25,8 +26,22 @@ interface BoardPlan {
25
26
  // Each issue's label names as the open listing read them, so a running row is drawn as its labels
26
27
  // say it stopped.
27
28
  labels: ReadonlyMap<number, ReadonlyArray<string>>
29
+ // The paths each issue's body declares (`Plan.scope.declared`), which a waiting row is checked
30
+ // against a running one by; a plan read without them checks none.
31
+ declared?: Declared | undefined
28
32
  }
29
33
 
34
+ // Why a waiting row waits, or the stage its lane's launch is at, in the board's own words.
35
+ interface RowReason {
36
+ text: string
37
+ // When the row entered a stage, where the reason is one — how long it has stood there is counted
38
+ // from it.
39
+ since_ms?: number | undefined
40
+ }
41
+
42
+ // What a waiting child's row says after its blockers; `undefined` says nothing.
43
+ type ReasonOf = (child: EpicChild) => RowReason | undefined
44
+
30
45
  interface BoardRow {
31
46
  number: number
32
47
  title: string | undefined
@@ -36,6 +51,8 @@ interface BoardRow {
36
51
  status: ItemStatus | undefined
37
52
  // The standing blockers this row still waits on, as the plan names them.
38
53
  waits: ReadonlyArray<string>
54
+ // Why the row waits beyond its blockers (`run-board-reason.ts`); a row the run has touched has none.
55
+ reason?: RowReason | undefined
39
56
  }
40
57
 
41
58
  type WaveEntry =
@@ -60,6 +77,11 @@ interface Overlay {
60
77
  statuses: ReadonlyMap<number, ItemStatus>
61
78
  }
62
79
 
80
+ // The overlay a layout is drawn from: the plan under the statuses, and what each waiting row says.
81
+ interface Drawn extends Overlay {
82
+ reason_of: ReasonOf | undefined
83
+ }
84
+
63
85
  const SETTLED_STATES: ReadonlySet<ItemState> = new Set(['merged', 'parked', 'done'])
64
86
 
65
87
  function is_settled(state: ItemState): boolean {
@@ -80,13 +102,20 @@ function reference_of(edge: IssueReference, repo: string): string {
80
102
  return edge.repo === repo ? String(edge.number) : issue_cite.plain(edge.number, edge.repo)
81
103
  }
82
104
 
83
- function plan_row(child: EpicChild, state: ItemState, overlay: Overlay): BoardRow {
105
+ // Whether a blocker the plan names still stands for `child` — the wait its row draws as 🔗.
106
+ function is_blocked(child: EpicChild, overlay: Overlay): boolean {
107
+ return child.blocked_by.some((edge) => is_waiting_on(edge, overlay))
108
+ }
109
+
110
+ function plan_row(child: EpicChild, state: ItemState, overlay: Drawn): BoardRow {
84
111
  const { context } = overlay.plan
85
112
  const waits = child.blocked_by
86
113
  .filter((edge) => is_waiting_on(edge, overlay))
87
114
  .map((edge) => reference_of(edge, context.repo))
115
+ const reason = state === 'waiting' ? overlay.reason_of?.(child) : undefined
88
116
 
89
117
  return {
118
+ ...(reason !== undefined && { reason }),
90
119
  number: child.number,
91
120
  title: context.titles.get(child.number),
92
121
  kind: run_board_kind.kind_of(child.labels),
@@ -119,7 +148,7 @@ function active_rows(statuses: ReadonlyMap<number, ItemStatus>, read: RowRead):
119
148
  function place_in_epic(
120
149
  entries: Array<WaveEntry>,
121
150
  epic: number,
122
- overlay: Overlay,
151
+ overlay: Drawn,
123
152
  row: BoardRow,
124
153
  ): void {
125
154
  const group = entries.find((entry) => entry.kind === 'epic' && entry.epic === epic)
@@ -144,7 +173,7 @@ function wave_state(child: EpicChild): ItemState {
144
173
 
145
174
  // One wave, its rows in the plan's order, an epic's children gathered under the epic where the first
146
175
  // of them falls.
147
- function wave_entries(wave: ReadonlyArray<EpicChild>, overlay: Overlay): Array<WaveEntry> {
176
+ function wave_entries(wave: ReadonlyArray<EpicChild>, overlay: Drawn): Array<WaveEntry> {
148
177
  const entries: Array<WaveEntry> = []
149
178
 
150
179
  for (const child of wave) {
@@ -158,7 +187,7 @@ function wave_entries(wave: ReadonlyArray<EpicChild>, overlay: Overlay): Array<W
158
187
  return entries
159
188
  }
160
189
 
161
- function unreached_rows(overlay: Overlay, is_people: boolean): Array<BoardRow> {
190
+ function unreached_rows(overlay: Drawn, is_people: boolean): Array<BoardRow> {
162
191
  const state: ItemState = is_people ? 'human' : 'waiting'
163
192
 
164
193
  return overlay.plan.waves.unreached
@@ -166,8 +195,25 @@ function unreached_rows(overlay: Overlay, is_people: boolean): Array<BoardRow> {
166
195
  .map((child) => plan_row(child, state, overlay))
167
196
  }
168
197
 
169
- function layout_of(plan: BoardPlan, statuses: ReadonlyMap<number, ItemStatus>): BoardLayout {
170
- const overlay = { plan, statuses }
198
+ // Every issue the plan holds, wave or unreached.
199
+ function children_of(plan: BoardPlan): Array<EpicChild> {
200
+ return [...plan.waves.waves.flat(), ...plan.waves.unreached]
201
+ }
202
+
203
+ // The children whose rows wait on the run rather than on a person: the run has not touched them, and
204
+ // no decision is asked of anyone.
205
+ function waiting_children(overlay: Overlay): Array<EpicChild> {
206
+ return children_of(overlay.plan).filter(
207
+ (child) => is_untouched(child, overlay) && !is_human(child),
208
+ )
209
+ }
210
+
211
+ function layout_of(
212
+ plan: BoardPlan,
213
+ statuses: ReadonlyMap<number, ItemStatus>,
214
+ reason_of?: ReasonOf,
215
+ ): BoardLayout {
216
+ const overlay = { plan, statuses, reason_of }
171
217
  const waves = plan.waves.waves.map((wave) =>
172
218
  wave_entries(
173
219
  wave.filter((child) => is_untouched(child, overlay)),
@@ -195,9 +241,9 @@ function local_layout_of(statuses: ReadonlyMap<number, ItemStatus>): BoardLayout
195
241
  }
196
242
  }
197
243
 
198
- // Every issue the plan holds, wave or unreached — the run's scope a lane is checked against.
244
+ // The run's scope a lane is checked against.
199
245
  function numbers_of(plan: BoardPlan): ReadonlySet<number> {
200
- return new Set([...plan.waves.waves.flat(), ...plan.waves.unreached].map((child) => child.number))
246
+ return new Set(children_of(plan).map((child) => child.number))
201
247
  }
202
248
 
203
249
  // Every row the layout draws, an epic's children among them — what the header counts and the legend reads.
@@ -211,12 +257,14 @@ function rows_of(layout: BoardLayout): Array<BoardRow> {
211
257
 
212
258
  const run_board_layout = {
213
259
  EMPTY_LAYOUT,
260
+ is_blocked,
214
261
  is_settled,
215
262
  layout_of,
216
263
  local_layout_of,
217
264
  numbers_of,
218
265
  rows_of,
266
+ waiting_children,
219
267
  }
220
268
 
221
269
  export { run_board_layout }
222
- export type { BoardLayout, BoardPlan, BoardRow, WaveEntry }
270
+ export type { BoardLayout, BoardPlan, BoardRow, Overlay, ReasonOf, RowReason, WaveEntry }
@@ -0,0 +1,234 @@
1
+ import { issue_cite } from '#scripts/issue/issue-cite'
2
+ import { run_event_setup } from '#scripts/run/event/run-event-setup'
3
+ import { run_board_labels } from './run-board-labels'
4
+ import type { LaneEvent } from './run-board-lane-event'
5
+ import { run_board_operation, type Operation } from './run-board-operation'
6
+ import type { SessionOperations } from './run-board-operation-read'
7
+ import type { RunActivity } from './run-board-status'
8
+
9
+ // The header's third line: what the run is doing now, as its kind, when it happened and its words.
10
+ // Always one line while the stream holds an event — the newer of a running child's newest operation and
11
+ // its newest event, or the newest operation of the session a stage is the work of, else the stage the
12
+ // run is at, else how many children run, else how long it has watched, each of the last three with why;
13
+ // an ended run keeps the newest thing it did. **An operation changes with every call a session makes**,
14
+ // where a stage or a count stands for minutes. Every time is an event's or a call's own, so the line
15
+ // never guesses one; the header ages it and draws it in its time column (joshuafolkken/kit#3767).
16
+
17
+ const { KIND_BLANK, NOW_ICONS, PHASE_ICONS, STAGE_ICONS, STATE_ICONS, WORDS } = run_board_labels
18
+ const { STAGE } = run_event_setup
19
+ const REASON_SEPARATOR = ' — '
20
+ const OPERATION_SEPARATOR = ' · '
21
+ const WORD_SEPARATOR = ' '
22
+ // The stages a session is at work on, and whose log says what it does. Every other stage is the
23
+ // driver's or the supervisor's own step, which writes no log of calls.
24
+ const STAGE_SESSIONS: Readonly<Partial<Record<string, 'owner' | 'judgment'>>> = {
25
+ [STAGE.PLANNING]: 'owner',
26
+ [STAGE.SESSION]: 'judgment',
27
+ }
28
+
29
+ interface NowRead {
30
+ now_ms: number
31
+ // Set once the run has ended: the newest event's age stops there.
32
+ ended_ms: number | undefined
33
+ activity: RunActivity
34
+ // The newest operation of each session of the run, `undefined` where none was read.
35
+ operations?: SessionOperations | undefined
36
+ // The issues of the children running now: a lane left by an earlier run is not this run's work.
37
+ children?: ReadonlySet<number> | undefined
38
+ running: number
39
+ // What the plan has left for the run, `undefined` until the plan is read.
40
+ waiting: number | undefined
41
+ // How many of those wait on a person.
42
+ people: number
43
+ }
44
+
45
+ interface NowLine {
46
+ // The kind's icon, drawn under the `⚡`.
47
+ icon: string
48
+ // The icon's word in the legend, `undefined` where the line's own words or the phase legend name it.
49
+ legend?: string | undefined
50
+ // How long ago what the line says happened — up to an ended run's end — `undefined` where no event
51
+ // gave it a time.
52
+ age_ms: number | undefined
53
+ text: string
54
+ }
55
+
56
+ // A line before it is aged: when what it says happened.
57
+ type TimedLine = Omit<NowLine, 'age_ms'> & { since_ms: number | undefined }
58
+
59
+ // What stands behind the pool once nothing runs: nothing, only what a person has to decide, or issues
60
+ // the run cannot take yet.
61
+ function pool_reason(waiting: number, people: number): string {
62
+ if (waiting === 0) return WORDS.pool_empty
63
+
64
+ return waiting === people
65
+ ? `${String(waiting)} ${WORDS.on_person}`
66
+ : `${String(waiting)} ${WORDS.waiting}`
67
+ }
68
+
69
+ function running_of(running: number): string {
70
+ return `${String(running)} ${WORDS.in_progress}`
71
+ }
72
+
73
+ // Why the run is where it is: the children it runs, else what its plan has left. Nothing before the
74
+ // plan is read.
75
+ function reason_of(read: NowRead): string | undefined {
76
+ if (read.running > 0) return running_of(read.running)
77
+
78
+ return read.waiting === undefined ? undefined : pool_reason(read.waiting, read.people)
79
+ }
80
+
81
+ function with_reason(line: string, read: NowRead): string {
82
+ const reason = reason_of(read)
83
+
84
+ return reason === undefined ? line : `${line}${REASON_SEPARATOR}${reason}`
85
+ }
86
+
87
+ // The newest thing an ended run did.
88
+ function latest_line(read: NowRead): TimedLine | undefined {
89
+ const { latest } = read.activity
90
+
91
+ if (latest === undefined) return undefined
92
+
93
+ return { icon: NOW_ICONS.ended, legend: WORDS.ended, since_ms: latest.at_ms, text: latest.text }
94
+ }
95
+
96
+ // A run with nothing running and at no stage is watching its pool, since the newest thing it did — a
97
+ // stage it has left included, so a judgment session's minutes are not counted as watching.
98
+ function watch_line(read: NowRead): TimedLine | undefined {
99
+ const { idle_ms } = read.activity
100
+
101
+ if (idle_ms === undefined) return undefined
102
+
103
+ return { icon: NOW_ICONS.watching, since_ms: idle_ms, text: with_reason(WORDS.watching, read) }
104
+ }
105
+
106
+ // `words`, then what the session did — words that end on a command or a path.
107
+ function operated(words: string, operation: Operation): string {
108
+ return `${words}${OPERATION_SEPARATOR}${run_board_operation.words_of(operation)}`
109
+ }
110
+
111
+ function session_operation(read: NowRead, setup: string): Operation | undefined {
112
+ const session = STAGE_SESSIONS[setup]
113
+
114
+ return session === undefined ? undefined : read.operations?.[session]
115
+ }
116
+
117
+ // The newest operation of the session a stage is the work of. One older than the stage is an earlier
118
+ // session's, or what the parent did before it — the watcher's log runs on across runs.
119
+ function stage_operation(read: NowRead, setup: string): Operation | undefined {
120
+ const operation = session_operation(read, setup)
121
+
122
+ if (operation === undefined) return undefined
123
+
124
+ return operation.at_ms < (read.activity.setup_ms ?? 0) ? undefined : operation
125
+ }
126
+
127
+ // A stage's icon, keyed by its words before the issue a lane's stage names; a stage the board does not
128
+ // know keeps the icon's columns blank, so its words start where every other line's do.
129
+ function stage_icon(setup: string): string {
130
+ const known = Object.keys(STAGE_ICONS).find(
131
+ (words) => setup === words || setup.startsWith(`${words}${WORD_SEPARATOR}`),
132
+ )
133
+
134
+ return (known === undefined ? undefined : STAGE_ICONS[known]) ?? KIND_BLANK
135
+ }
136
+
137
+ // The stage, then what its session last did and when; the stage, since when the run has stood there
138
+ // and why where no session is at work on it, or none could be read.
139
+ function stage_line(read: NowRead, setup: string): TimedLine {
140
+ const operation = stage_operation(read, setup)
141
+ const icon = stage_icon(setup)
142
+
143
+ return operation === undefined
144
+ ? { icon, since_ms: read.activity.setup_ms, text: with_reason(setup, read) }
145
+ : { icon, since_ms: operation.at_ms, text: operated(setup, operation) }
146
+ }
147
+
148
+ // A running child's words, named by its issue.
149
+ function child_said(issue: number, words: string): string {
150
+ return `${issue_cite.plain(issue)}${OPERATION_SEPARATOR}${words}`
151
+ }
152
+
153
+ // What the running children's newest activity is read from: each session's newest operation and each
154
+ // child's newest event, kept to the children named.
155
+ interface ChildSources {
156
+ operations?: SessionOperations | undefined
157
+ lane_events?: ReadonlyMap<number, LaneEvent> | undefined
158
+ children?: ReadonlySet<number> | undefined
159
+ }
160
+
161
+ // The running children's newest operations, each named by its issue.
162
+ function operation_lines(read: ChildSources): Array<TimedLine> {
163
+ const lanes = read.operations?.lanes.filter((lane) => read.children?.has(lane.issue)) ?? []
164
+
165
+ return lanes.map(({ issue, operation }) => ({
166
+ icon: NOW_ICONS.operation,
167
+ legend: WORDS.operation,
168
+ since_ms: operation.at_ms,
169
+ text: child_said(issue, run_board_operation.words_of(operation)),
170
+ }))
171
+ }
172
+
173
+ // The running children's newest events, each in its row's phase icon and its own words.
174
+ function event_lines(read: ChildSources): Array<TimedLine> {
175
+ const events = [...(read.lane_events ?? [])]
176
+
177
+ return events
178
+ .filter(([issue]) => read.children?.has(issue))
179
+ .map(([issue, event]) => ({
180
+ icon: PHASE_ICONS[event.phase],
181
+ since_ms: event.at_ms,
182
+ text: child_said(issue, event.words),
183
+ }))
184
+ }
185
+
186
+ function newest_first(left: TimedLine, right: TimedLine): number {
187
+ return (right.since_ms ?? 0) - (left.since_ms ?? 0)
188
+ }
189
+
190
+ // The newest of the children's operations and events — an operation where the two are as new.
191
+ // `undefined` where neither could be read. A row reads its own child's the same way
192
+ // (joshuafolkken/kit#3769), so the header and the row never disagree on what is newest.
193
+ function newest_of(sources: ChildSources): TimedLine | undefined {
194
+ return [...operation_lines(sources), ...event_lines(sources)].toSorted(newest_first).at(0)
195
+ }
196
+
197
+ // The running children's newest, or `undefined`, and the line counts the children instead.
198
+ function child_line(read: NowRead): TimedLine | undefined {
199
+ const { operations, children, activity } = read
200
+
201
+ return newest_of({ operations, children, lane_events: activity.lane_events })
202
+ }
203
+
204
+ function quiet_line(read: NowRead): TimedLine | undefined {
205
+ if (read.running === 0) return watch_line(read)
206
+
207
+ return { icon: STATE_ICONS.running, since_ms: undefined, text: running_of(read.running) }
208
+ }
209
+
210
+ function timed_line(read: NowRead): TimedLine | undefined {
211
+ const { is_stopped, setup } = read.activity
212
+
213
+ if (is_stopped) return latest_line(read)
214
+ if (setup !== undefined) return stage_line(read, setup)
215
+
216
+ return child_line(read) ?? quiet_line(read)
217
+ }
218
+
219
+ // The line aged to now, or to an ended run's end: an age counting on after it would read as a hang.
220
+ function line_of(read: NowRead): NowLine | undefined {
221
+ const line = timed_line(read)
222
+
223
+ if (line === undefined) return undefined
224
+
225
+ const { since_ms, ...rest } = line
226
+ const until_ms = read.ended_ms ?? read.now_ms
227
+
228
+ return { ...rest, age_ms: since_ms === undefined ? undefined : until_ms - since_ms }
229
+ }
230
+
231
+ const run_board_now = { line_of, newest_of }
232
+
233
+ export { run_board_now }
234
+ export type { ChildSources, NowLine, NowRead, TimedLine }
@@ -0,0 +1,75 @@
1
+ import { time_density_hook } from '#scripts/time-runtime/time-density-hook'
2
+ import { run_board_operation, type Operation } from './run-board-operation'
3
+
4
+ // The disk half of the third line's operation: the end of each session's log, read every time the
5
+ // board re-reads its run. **A fixed, small read**: a dispatch log grows to tens of megabytes and the
6
+ // watcher's log across runs, while the board re-reads every five seconds. The newest call is nearly
7
+ // always inside it; where one line is longer than the read, there is no operation and the line says
8
+ // what it said before. Nothing here may fail — a log that is missing or unreadable is no operation.
9
+
10
+ const TAIL_BYTES = 65_536
11
+
12
+ // A session's log and the checkout its paths are drawn from.
13
+ interface OperationSource {
14
+ log: string | undefined
15
+ root: string | undefined
16
+ }
17
+
18
+ interface LaneSource extends OperationSource {
19
+ issue: string
20
+ }
21
+
22
+ interface OperationSources {
23
+ lanes: ReadonlyArray<LaneSource>
24
+ // The parent session's transcript.
25
+ owner: OperationSource
26
+ // The watcher's log, which a judgment session appends to.
27
+ judgment: OperationSource
28
+ }
29
+
30
+ interface LaneOperation {
31
+ issue: number
32
+ operation: Operation
33
+ }
34
+
35
+ // The newest operation of every session the run may have at work; which of them is at work now is the
36
+ // reader's to say.
37
+ interface SessionOperations {
38
+ lanes: ReadonlyArray<LaneOperation>
39
+ owner: Operation | undefined
40
+ judgment: Operation | undefined
41
+ }
42
+
43
+ type ReadTail = (file_path: string, max_bytes: number) => string
44
+
45
+ function operation_of(source: OperationSource, read_tail: ReadTail): Operation | undefined {
46
+ if (source.log === undefined) return undefined
47
+
48
+ try {
49
+ return run_board_operation.latest_of(read_tail(source.log, TAIL_BYTES), source.root)
50
+ } catch {
51
+ return undefined
52
+ }
53
+ }
54
+
55
+ function lane_operation(source: LaneSource, read_tail: ReadTail): Array<LaneOperation> {
56
+ const operation = operation_of(source, read_tail)
57
+
58
+ return operation === undefined ? [] : [{ issue: Number(source.issue), operation }]
59
+ }
60
+
61
+ function read(
62
+ sources: OperationSources,
63
+ read_tail: ReadTail = time_density_hook.read_tail,
64
+ ): SessionOperations {
65
+ return {
66
+ lanes: sources.lanes.flatMap((lane) => lane_operation(lane, read_tail)),
67
+ owner: operation_of(sources.owner, read_tail),
68
+ judgment: operation_of(sources.judgment, read_tail),
69
+ }
70
+ }
71
+
72
+ const run_board_operation_read = { TAIL_BYTES, read }
73
+
74
+ export { run_board_operation_read }
75
+ export type { LaneOperation, OperationSources, SessionOperations }
@@ -0,0 +1,150 @@
1
+ import path from 'node:path'
2
+ import { json_value } from '#scripts/lib/json-value'
3
+ import { z } from 'zod'
4
+
5
+ // The newest thing a session did, read off the end of its log — a lane child's dispatch log, the
6
+ // parent's transcript or the watcher's log, all one JSON object a line with the tool calls of an
7
+ // `assistant` line under `message.content`. **Only what a machine decided is kept**: the tool's name
8
+ // and what it was pointed at. The call's `description` is the session's own prose, in the session's
9
+ // language, and the board is written in English.
10
+
11
+ interface Operation {
12
+ // The tool's name: `Bash`, `Edit`, `Read`, …
13
+ kind: string
14
+ // The head of the command, or the file's path; `undefined` for a tool pointed at neither.
15
+ target: string | undefined
16
+ at_ms: number
17
+ }
18
+
19
+ // A command is cut here, so a long argument — or a value passed in one — is not drawn.
20
+ const TARGET_LIMIT = 40
21
+ const CUT_MARK = '…'
22
+ const TOOL_USE = 'tool_use'
23
+ // A line that holds no call is never parsed: one can be tens of thousands of characters long.
24
+ const TOOL_USE_MARK = `"${TOOL_USE}"`
25
+ const NEWLINE = '\n'
26
+ // One variable set ahead of the command, with what parts it from the command: it carries a value,
27
+ // never what the command does. The value is read as the shell reads one word — a quoted stretch with
28
+ // its escapes and line breaks, a command run for its output, an escaped character, a plain one.
29
+ const QUOTED = /"(?:[^"\\]|\\.)*"|'[^']*'/su
30
+ const SUBSTITUTED = /\$\((?:[^()]|\([^()]*\))*\)|`[^`]*`/su
31
+ const PLAIN = /\\.|[^\s"'`;&|()\\]/su
32
+ const VALUE = `(?:${QUOTED.source}|${SUBSTITUTED.source}|${PLAIN.source})*`
33
+ const ASSIGNMENT = new RegExp(String.raw`^\w+=${VALUE}(?:\s*(?:&&|;)\s*|\s+|$)`, 'su')
34
+ // A variable still at the head once every readable one is gone: its value could not be read as one.
35
+ const UNREAD_ASSIGNMENT = /^\w+=/u
36
+ const SPACES = /\s+/gu
37
+ // The printable ASCII a target begins with: what follows may be the session's own language.
38
+ const ASCII_HEAD = /^[ -~]*/u
39
+
40
+ // A call names its tool; every other block of a line — its text, its thinking — is not one.
41
+ const CALL_SCHEMA = z.looseObject({
42
+ type: z.literal(TOOL_USE),
43
+ name: z.string(),
44
+ input: z.unknown(),
45
+ })
46
+
47
+ function is_dated(text: string): boolean {
48
+ return !Number.isNaN(Date.parse(text))
49
+ }
50
+
51
+ const MESSAGE_SCHEMA = z.looseObject({ content: z.array(z.unknown()) })
52
+ const LINE_SCHEMA = z.looseObject({
53
+ timestamp: z.string().refine(is_dated),
54
+ message: MESSAGE_SCHEMA,
55
+ })
56
+ // An input that is no object reads as one pointed at nothing.
57
+ const INPUT_SCHEMA = z
58
+ .looseObject({ command: z.string().optional(), file_path: z.string().optional() })
59
+ .catch({})
60
+
61
+ type Call = z.infer<typeof CALL_SCHEMA>
62
+ type Input = z.infer<typeof INPUT_SCHEMA>
63
+
64
+ function ascii_head(text: string): string {
65
+ return (ASCII_HEAD.exec(text)?.[0] ?? '').trim()
66
+ }
67
+
68
+ function cut(text: string): string {
69
+ return text.length > TARGET_LIMIT ? `${text.slice(0, TARGET_LIMIT)}${CUT_MARK}` : text
70
+ }
71
+
72
+ // The command without every variable set ahead of it, one at a time.
73
+ function without_assignments(command: string): string {
74
+ const rest = command.replace(ASSIGNMENT, '')
75
+
76
+ return rest === command ? command : without_assignments(rest)
77
+ }
78
+
79
+ // The first line of what the command runs, cut to the limit. A command whose variable cannot be read
80
+ // to its end is drawn as nothing: a part of the value would stand where the command should.
81
+ function command_target(command: string): string {
82
+ const [first = ''] = without_assignments(command.trim()).split(NEWLINE)
83
+ if (UNREAD_ASSIGNMENT.test(first)) return ''
84
+
85
+ return cut(ascii_head(first.replaceAll(SPACES, ' ')))
86
+ }
87
+
88
+ // The path from `root` down; a file outside it is drawn by its name alone, never by where it is.
89
+ function path_target(file_path: string, root: string | undefined): string {
90
+ const relative = root === undefined ? undefined : path.relative(root, file_path)
91
+ const is_inside = relative !== undefined && !relative.startsWith('..') && relative !== ''
92
+
93
+ return ascii_head(is_inside ? relative : path.basename(file_path))
94
+ }
95
+
96
+ function shown(target: string): string | undefined {
97
+ return target === '' ? undefined : target
98
+ }
99
+
100
+ function target_of(input: Input, root: string | undefined): string | undefined {
101
+ if (input.command !== undefined) return shown(command_target(input.command))
102
+
103
+ return input.file_path === undefined ? undefined : shown(path_target(input.file_path, root))
104
+ }
105
+
106
+ function is_call(block: unknown): block is Call {
107
+ return CALL_SCHEMA.safeParse(block).success
108
+ }
109
+
110
+ function last_call(content: ReadonlyArray<unknown>): Call | undefined {
111
+ return content.findLast((block) => is_call(block))
112
+ }
113
+
114
+ // The last call a line holds, `undefined` for a line that is cut, is not JSON, or holds none.
115
+ function line_operation(line: string, root: string | undefined): Operation | undefined {
116
+ const parsed = json_value.parse_with(line, LINE_SCHEMA)
117
+ const call = last_call(parsed?.message.content ?? [])
118
+
119
+ if (parsed === undefined || call === undefined) return undefined
120
+
121
+ return {
122
+ kind: call.name,
123
+ target: target_of(INPUT_SCHEMA.parse(call.input), root),
124
+ at_ms: Date.parse(parsed.timestamp),
125
+ }
126
+ }
127
+
128
+ // The newest operation in `tail` — the end of a log, whose first line is usually cut mid-way and so
129
+ // reads as no call. `root` is the checkout the session works in, which its paths are drawn from.
130
+ function latest_of(tail: string, root?: string): Operation | undefined {
131
+ const lines = tail.split(NEWLINE).filter((line) => line.includes(TOOL_USE_MARK))
132
+
133
+ for (const line of lines.toReversed()) {
134
+ const operation = line_operation(line, root)
135
+
136
+ if (operation !== undefined) return operation
137
+ }
138
+
139
+ return undefined
140
+ }
141
+
142
+ // The operation as the board writes it: `Bash pnpm josh test:related`, or the tool alone.
143
+ function words_of(operation: Operation): string {
144
+ return operation.target === undefined ? operation.kind : `${operation.kind} ${operation.target}`
145
+ }
146
+
147
+ const run_board_operation = { TARGET_LIMIT, latest_of, words_of }
148
+
149
+ export { run_board_operation }
150
+ export type { Operation }
@@ -37,6 +37,7 @@ const { STAGE } = run_ship_stage
37
37
  const LAUNCHED_PHASE: Phase = 'investigate'
38
38
  const WORD_SEPARATOR = ' '
39
39
  const STAGE_INDEX = 1
40
+ const PHASE_INDEX = 2
40
41
 
41
42
  // `josh ship`'s stages, each its own phase; the preflight is the ship's own start.
42
43
  const SHIP_PHASES: Readonly<Partial<Record<string, Phase>>> = {
@@ -64,6 +65,11 @@ function stage_word_of(event: RunEvent): string {
64
65
  return event.text.split(WORD_SEPARATOR)[STAGE_INDEX] ?? ''
65
66
  }
66
67
 
68
+ // The word after the stage — whether it started, succeeded, failed or was passed over.
69
+ function phase_word_of(event: RunEvent): string {
70
+ return event.text.split(WORD_SEPARATOR)[PHASE_INDEX] ?? ''
71
+ }
72
+
67
73
  // A stage marks its phase when it starts; its `done`, and one a resumed ship passed over, mark none.
68
74
  function ship_phase_of(event: RunEvent): Phase | undefined {
69
75
  if (!run_event_stream.is_stage_start(event)) return undefined
@@ -79,6 +85,16 @@ function phase_of(event: RunEvent): Phase | undefined {
79
85
  return KIND_PHASES[event.kind]
80
86
  }
81
87
 
88
+ // The phase a `lane-phase` or `ship-stage` event's stage belongs to, started or not, so a line written
89
+ // from it draws the icon its row's track does — a stage that failed is the ship's failure.
90
+ function stage_phase_of(event: RunEvent): Phase | undefined {
91
+ if (event.kind === KIND.LANE_PHASE) return LANE_PHASES[stage_word_of(event)]
92
+
93
+ if (phase_word_of(event) === run_ship_stage.PHASE.FAILED) return 'failed'
94
+
95
+ return SHIP_PHASES[stage_word_of(event)]
96
+ }
97
+
82
98
  // A failed ship keeps only its own start: everything after the newest `ship` folds into `failed`.
83
99
  function failed_after(track: ReadonlyArray<Phase>): Array<Phase> {
84
100
  const ship = track.lastIndexOf('ship')
@@ -130,7 +146,16 @@ function replayed(track: ReadonlyArray<Phase>, attempt: ReadonlyArray<Phase>): A
130
146
  return [...track.slice(0, start), ...(is_covered ? attempt : new Set([...current, ...attempt]))]
131
147
  }
132
148
 
133
- const run_board_phase = { LAUNCHED_PHASE, PHASES, attempt_of, history_after, phase_of, replayed }
149
+ const run_board_phase = {
150
+ LAUNCHED_PHASE,
151
+ PHASES,
152
+ SHIP_PHASES,
153
+ attempt_of,
154
+ history_after,
155
+ phase_of,
156
+ replayed,
157
+ stage_phase_of,
158
+ }
134
159
 
135
160
  export { run_board_phase }
136
161
  export type { Phase }