@joshuafolkken/kit 1.1373.0 → 1.1383.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 (92) hide show
  1. package/.claude/skills/dependency-update/SKILL.md +6 -3
  2. package/.claude/skills/workflow-commands/backlogrun-progress.md +2 -3
  3. package/.claude/skills/workflow-commands/backlogrun-steps.md +2 -4
  4. package/.claude/skills/workflow-commands/retrospective.md +1 -2
  5. package/dist/hooks/batch-guard.js +6 -6
  6. package/dist/hooks/{chunk-UTHMB4O2.js → chunk-2X2ART5G.js} +4 -33
  7. package/dist/hooks/{chunk-NZN2UFI6.js → chunk-4MGHBTOM.js} +162 -233
  8. package/dist/hooks/{chunk-YLBBSSTF.js → chunk-6AC4C2IO.js} +91 -67
  9. package/dist/hooks/{chunk-AKGCP4LX.js → chunk-6MAW7BCM.js} +12 -12
  10. package/dist/hooks/{chunk-JCRDBQXB.js → chunk-7VSZ2N4B.js} +3 -3
  11. package/dist/hooks/{chunk-GG72ANKI.js → chunk-KC6LUZZ2.js} +1 -1
  12. package/dist/hooks/{chunk-BZMPVFDY.js → chunk-KCF2YE7O.js} +5 -5
  13. package/dist/hooks/{chunk-7WKYHEGU.js → chunk-LC56R53A.js} +3 -3
  14. package/dist/hooks/{chunk-V4TKZHER.js → chunk-LOJTBU5V.js} +3 -3
  15. package/dist/hooks/{chunk-JGW24KKM.js → chunk-RYP3TSUJ.js} +3 -3
  16. package/dist/hooks/{chunk-SLSFIWTX.js → chunk-TT742SIO.js} +8 -5
  17. package/dist/hooks/{chunk-Z5TMJIPW.js → chunk-Y5R47QHU.js} +1 -1
  18. package/dist/hooks/{chunk-7UBFU3CL.js → chunk-YKUITFT7.js} +2 -2
  19. package/dist/hooks/codex-hook-adapter.js +13 -13
  20. package/dist/hooks/duplicate-read-guard.js +7 -7
  21. package/dist/hooks/format-edited.js +3 -3
  22. package/dist/hooks/inputs.json +190 -191
  23. package/dist/hooks/investigation-guard.js +7 -7
  24. package/dist/hooks/pretool-guard.js +12 -12
  25. package/dist/hooks/session-lang.js +3 -3
  26. package/dist/hooks/stop-guard.js +19 -15
  27. package/dist/josh.js +40 -5
  28. package/package.json +4 -4
  29. package/prompts/collaboration-workflow/upstream-interrupt.md +1 -1
  30. package/scripts/adopt/adopt-logic.ts +4 -1
  31. package/scripts/adopt/adopt.ts +7 -4
  32. package/scripts/backlog/backlog-budget-cli.ts +2 -20
  33. package/scripts/backlog/backlog-budget.ts +72 -109
  34. package/scripts/backlog/backlog-drive-cli.ts +10 -3
  35. package/scripts/backlog/backlog-drive.ts +5 -2
  36. package/scripts/backlog/backlog-offer-cli.ts +1 -15
  37. package/scripts/cost-runtime/context-cut-payback.ts +9 -4
  38. package/scripts/cost-runtime/cost-pricing.ts +94 -33
  39. package/scripts/document/document-byte-budget.ts +1 -1
  40. package/scripts/document/read-set-pricing.ts +7 -5
  41. package/scripts/epic/epic-candidate-confirm.ts +1 -1
  42. package/scripts/epic/epic-cross-repo.ts +4 -4
  43. package/scripts/epic/epic-next.ts +1 -1
  44. package/scripts/git/git-spawn-sync.ts +9 -1
  45. package/scripts/hooks/stop-guard.ts +20 -9
  46. package/scripts/init/init-ai-copy.ts +10 -3
  47. package/scripts/init/init-copy-content.ts +8 -1
  48. package/scripts/init/security-policy.ts +79 -0
  49. package/scripts/josh/josh-commands-ai.ts +1 -1
  50. package/scripts/josh/josh-commands-maintenance.ts +3 -0
  51. package/scripts/josh/josh-commands-project.ts +1 -1
  52. package/scripts/josh/josh-commands-versioning.ts +2 -1
  53. package/scripts/lane/lane-cli.ts +1 -3
  54. package/scripts/propagate/propagate-run.ts +17 -1
  55. package/scripts/propagate/propagate-steps.ts +9 -1
  56. package/scripts/run/board/run-board-header.ts +107 -84
  57. package/scripts/run/board/run-board-labels.ts +18 -17
  58. package/scripts/run/board/run-board-machine.ts +21 -8
  59. package/scripts/run/board/run-board-render.ts +1 -5
  60. package/scripts/run/board/run-board-span.ts +0 -1
  61. package/scripts/run/board/run-board-status.ts +0 -17
  62. package/scripts/run/board/run-board-tick.ts +1 -0
  63. package/scripts/run/carry/run-carry-cli.ts +8 -62
  64. package/scripts/run/carry/run-carry-ended.ts +1 -1
  65. package/scripts/run/carry/run-carry.ts +22 -68
  66. package/scripts/run/cut/run-cut.ts +4 -4
  67. package/scripts/run/event/run-event-render.ts +0 -1
  68. package/scripts/run/event/run-event-scope.ts +4 -8
  69. package/scripts/run/event/run-event-stream-emit.ts +0 -25
  70. package/scripts/run/event/run-event-stream.ts +23 -31
  71. package/scripts/run/merge/run-merge-collect.ts +1 -1
  72. package/scripts/run/merge/run-merge-steps.ts +1 -1
  73. package/scripts/run/progress/run-progress-read.ts +1 -1
  74. package/scripts/run/run-headless.ts +2 -2
  75. package/scripts/run/run-status-cli.ts +1 -5
  76. package/scripts/run/run-step.ts +2 -9
  77. package/scripts/run/run-stop-notify.ts +1 -1
  78. package/scripts/run/run-stranded-detect.ts +3 -3
  79. package/scripts/run/run-stranded.ts +25 -17
  80. package/scripts/run/wake/run-wake-cli.ts +7 -9
  81. package/scripts/run/wake/run-wake-describe.ts +3 -5
  82. package/scripts/run/wake/run-wake-driver.ts +3 -3
  83. package/scripts/run/wake/run-wake-loop.ts +4 -21
  84. package/scripts/run/wake/run-wake-restart.ts +1 -1
  85. package/scripts/run/wake/run-wake.ts +6 -7
  86. package/scripts/sync/sync-ai-files.ts +2 -0
  87. package/scripts/version/latest-update.ts +20 -6
  88. package/scripts/version/peer-floor.ts +99 -0
  89. package/scripts/version/playwright-browsers.ts +109 -0
  90. package/scripts/version/publishable-range-check.ts +41 -4
  91. package/templates/SECURITY.md +23 -0
  92. package/scripts/backlog/backlog-idle.ts +0 -88
@@ -0,0 +1,79 @@
1
+ import path from 'node:path'
2
+ import { repo_origin } from '#scripts/discovery/repo-origin'
3
+ import { git_spawn_sync } from '#scripts/git/git-spawn-sync'
4
+ import { file_reader } from '#scripts/lib/read-file'
5
+
6
+ // A consumer's `SECURITY.md` is its own policy, not kit's: the advisory and release links have to
7
+ // name the repository the file lands in, and kit's account of its own published package is not a
8
+ // fact about that repository. So the consumer copy is written from a template of its own, with the
9
+ // repository filled in, and kit's root `SECURITY.md` stays kit's.
10
+ const SECURITY_MD = 'SECURITY.md'
11
+ const SECURITY_TEMPLATE = 'templates/SECURITY.md'
12
+ const REPOSITORY_PLACEHOLDER = '{{REPOSITORY}}'
13
+ const KIT_ADVISORY_URL = 'https://github.com/joshuafolkken/kit/security/advisories/new'
14
+ const UNRESOLVED_REASON = '`origin` is not a GitHub repository `owner/repo` can be read from'
15
+ const LINK_DELIMITER = /[\s()<>[\]]+/u
16
+
17
+ // Read from the local `origin` rather than asked of GitHub: the basic profile decides whether to
18
+ // write the file from the same remote, and a policy file must not depend on `gh` being signed in.
19
+ function repository_at(root: string): string | undefined {
20
+ const url = git_spawn_sync.origin_url(root)
21
+ const identity = url === undefined ? undefined : repo_origin.parse_origin_url(url)
22
+
23
+ return identity === undefined ? undefined : `${identity.owner}/${identity.repo}`
24
+ }
25
+
26
+ function apply_repository(content: string, repository: string): string {
27
+ return content.replaceAll(REPOSITORY_PLACEHOLDER, () => repository)
28
+ }
29
+
30
+ // Throws rather than writing the placeholder through: both copy paths ask `did_skip_unresolved`
31
+ // first, so reaching here without a repository is a caller that skipped the question.
32
+ function apply_repository_for_destination(destination_path: string, content: string): string {
33
+ if (path.basename(destination_path) !== SECURITY_MD) return content
34
+ const repository = repository_at(path.dirname(destination_path))
35
+
36
+ if (repository === undefined) {
37
+ throw new Error(`${SECURITY_MD} needs a GitHub origin to name the repository it reports to`)
38
+ }
39
+
40
+ return apply_repository(content, repository)
41
+ }
42
+
43
+ // A full project synced before the template existed holds kit's own policy under this name, and the
44
+ // skip leaves that file on disk — still sending its readers to kit's advisory form. The form is
45
+ // matched as a whole link target, not as a substring: a longer address that merely starts with it
46
+ // is another address.
47
+ function does_report_to_kit(root: string): boolean {
48
+ const existing = file_reader.read_if_readable(path.join(root, SECURITY_MD)) ?? ''
49
+
50
+ return new Set(existing.split(LINK_DELIMITER)).has(KIT_ADVISORY_URL)
51
+ }
52
+
53
+ function unresolved_warning(root: string): string {
54
+ if (!does_report_to_kit(root)) return ` ⚠ skipped ${SECURITY_MD} (${UNRESOLVED_REASON})`
55
+
56
+ return ` ⚠ stale ${SECURITY_MD} still reports vulnerabilities to joshuafolkken/kit — replace it with your own policy or delete it (${UNRESOLVED_REASON})`
57
+ }
58
+
59
+ // Without a GitHub `origin` there is no repository for the advisory link to name, so the file is
60
+ // left unwritten — the next `josh sync` after the remote is added writes it.
61
+ function did_skip_unresolved(filename: string, root: string): boolean {
62
+ if (filename !== SECURITY_MD || repository_at(root) !== undefined) return false
63
+
64
+ console.warn(unresolved_warning(root))
65
+
66
+ return true
67
+ }
68
+
69
+ const security_policy = {
70
+ SECURITY_MD,
71
+ SECURITY_TEMPLATE,
72
+ REPOSITORY_PLACEHOLDER,
73
+ KIT_ADVISORY_URL,
74
+ repository_at,
75
+ apply_repository_for_destination,
76
+ did_skip_unresolved,
77
+ }
78
+
79
+ export { security_policy }
@@ -214,7 +214,7 @@ const AI_COMMANDS: Record<string, CommandEntry> = {
214
214
  // credentials, the same reason `run:wake` carries these flags.
215
215
  tsx_arguments: OPTIONAL_ENV_FILE_FLAGS,
216
216
  description:
217
- 'Report whether the run is stranded — budget handed off, owner gone, and no supervisor watching',
217
+ 'Report whether the run is stranded — owner gone, no successor holding the budget, and no supervisor watching',
218
218
  category: 'AI tools',
219
219
  reference: ['', 'automation', ['processes', 'notifications']],
220
220
  },
@@ -80,6 +80,9 @@ const MAINTENANCE_COMMANDS: Record<string, CommandEntry> = {
80
80
  description: 'Update all dependencies to latest',
81
81
  category: 'Maintenance',
82
82
  reference: ['', 'developer', ['files', 'network']],
83
+ // `JOSH_PLAYWRIGHT_BROWSERS` widens the browsers a Playwright bump installs, and a project
84
+ // keeps that setting in its `.env`.
85
+ tsx_arguments: OPTIONAL_ENV_FILE_FLAGS,
83
86
  },
84
87
  }
85
88
  /* eslint-enable @typescript-eslint/naming-convention */
@@ -88,7 +88,7 @@ const PROJECT_COMMANDS: Record<string, CommandEntry> = {
88
88
  },
89
89
  adopt: {
90
90
  script: 'scripts/adopt/adopt.ts',
91
- description: 'Upgrade every installed @joshuafolkken toolkit here and open the pull request',
91
+ description: 'Upgrade the @joshuafolkken toolkits and other dependencies here, open the PR',
92
92
  category: 'Project',
93
93
  reference: ['[--dry-run]', 'developer', ['files', 'git', 'network', 'processes']],
94
94
  },
@@ -20,7 +20,8 @@ const VERSIONING_COMMANDS: Record<string, CommandEntry> = {
20
20
  // before chaining here, exactly as it already does for `latest:update`.
21
21
  ranges: {
22
22
  script: 'scripts/version/publishable-range-check.ts',
23
- description: 'Check that every published dependency range still resolves for a consumer',
23
+ description:
24
+ 'Check that every published dependency range still resolves for a consumer and no peer range falls below an installed toolkit',
24
25
  category: 'Versioning',
25
26
  reference: ['', 'developer', ['network']],
26
27
  },
@@ -62,9 +62,7 @@ async function guard_cut_session(): Promise<string | undefined> {
62
62
 
63
63
  const read = run_carry.read_carry(run_carry.carry_path(directory))
64
64
 
65
- if ((read.kind !== 'carried' && read.kind !== 'expired') || read.carry.is_handed_off !== true) {
66
- return undefined
67
- }
65
+ if (read.kind !== 'carried' || read.carry.is_handed_off !== true) return undefined
68
66
 
69
67
  return run_carry.count_refused_message(read.carry)
70
68
  }
@@ -13,6 +13,7 @@ import type { PropagateTarget } from './propagate-targets'
13
13
  const STEP_PRECHECK = 'working tree check'
14
14
  const STEP_UPGRADE = 'josh vu'
15
15
  const STEP_SYNC = 'josh sync'
16
+ const STEP_LATEST = 'josh latest'
16
17
  const STEP_LINT_RELATED = 'related lint check'
17
18
  const STEP_TEST_RELATED = 'related test check'
18
19
  const STEP_VERIFY = 'verification gate'
@@ -32,6 +33,18 @@ const STEP_ORDER: ReadonlyArray<string> = [
32
33
  STEP_PR,
33
34
  STEP_RETURN,
34
35
  ]
36
+ // The same sequence with the consumer's other dependencies, its audit and its pnpm pin brought up
37
+ // to date after the sync. Only `josh adopt` runs it: `josh latest` moves every dependency to the
38
+ // registry's newest, which is what adoption is for and exactly what propagation's exact-version wait
39
+ // exists to prevent — and an unrelated dependency's breakage would stop a release reaching a
40
+ // consumer. Derived from `STEP_ORDER` rather than listed, so the two cannot drift apart; behind the
41
+ // sync, because the sync can add devDependencies to `package.json` without installing them, and a
42
+ // pull request opened over that fails CI's frozen install — the step installs before it updates
43
+ // (`propagate_steps`), so the lockfile is re-resolved even when `josh latest` rolls its update back.
44
+ // The related checks and the gate that follow then verify the tree as it will be committed.
45
+ const ADOPT_STEP_ORDER: ReadonlyArray<string> = STEP_ORDER.flatMap((step) =>
46
+ step === STEP_SYNC ? [step, STEP_LATEST] : step,
47
+ )
35
48
  const PROPAGATED_REASON = 'opened a pull request'
36
49
 
37
50
  type RunOutcome = 'propagated' | 'failed' | 'skipped'
@@ -130,10 +143,11 @@ function run_target(
130
143
  target: PropagateTarget,
131
144
  run_step: RunStep,
132
145
  success_reason: string = PROPAGATED_REASON,
146
+ order: ReadonlyArray<string> = STEP_ORDER,
133
147
  ): TargetResult {
134
148
  const steps: Array<StepResult> = []
135
149
 
136
- for (const step of STEP_ORDER) {
150
+ for (const step of order) {
137
151
  const result = run_step(target, step)
138
152
 
139
153
  steps.push(result)
@@ -192,11 +206,13 @@ const propagate_run = {
192
206
  STEP_ISSUE,
193
207
  STEP_UPGRADE,
194
208
  STEP_SYNC,
209
+ STEP_LATEST,
195
210
  STEP_LINT_RELATED,
196
211
  STEP_TEST_RELATED,
197
212
  STEP_VERIFY,
198
213
  STEP_PR,
199
214
  STEP_ORDER,
215
+ ADOPT_STEP_ORDER,
200
216
  skip_reason,
201
217
  run_target,
202
218
  run_targets,
@@ -27,10 +27,17 @@ const SUCCESS_EXIT_CODE = 0
27
27
  // pass — so the chain is not repeated here. It resolves in the consumer's directory, which by this
28
28
  // point has already been upgraded to the version being propagated, so the command is present.
29
29
  const VERIFY_SCRIPT = `pnpm josh ${GATE_COMMAND}`
30
+ // `josh latest` behind an install of its own. The sync can add devDependencies to `package.json`
31
+ // without installing them, and `josh latest` does not always re-resolve the lockfile: when the age
32
+ // gate would downgrade a dependency it restores the files it found and exits zero. Installing first
33
+ // makes the tree it finds a consistent one, so the lockfile matches `package.json` on either path.
34
+ // `--no-frozen-lockfile` because the install exists to write the lockfile, which pnpm refuses under CI.
35
+ const LATEST_SCRIPT = 'pnpm install --no-frozen-lockfile && pnpm josh latest'
30
36
 
31
37
  // The commands the release-independent spawning steps run inside the target's own directory. The
32
38
  // upgrade and the sync are not here: both are per toolkit, so they are built from the plan instead.
33
39
  const STEP_COMMANDS: Readonly<Record<string, ReadonlyArray<string>>> = {
40
+ [propagate_run.STEP_LATEST]: ['sh', '-c', LATEST_SCRIPT],
34
41
  [propagate_run.STEP_LINT_RELATED]: ['pnpm', 'josh', 'lint:related'],
35
42
  [propagate_run.STEP_TEST_RELATED]: ['pnpm', 'josh', 'test:related'],
36
43
  [propagate_run.STEP_VERIFY]: ['sh', '-c', VERIFY_SCRIPT],
@@ -353,7 +360,8 @@ interface Release {
353
360
  // `josh propagate` always passes exactly one release; `josh adopt` passes every toolkit installed in
354
361
  // the repository it runs in. Only the upgrade and the sync repeat per
355
362
  // release — the pre-check, the verification gate, the issue, the pull request and the return to the
356
- // default branch happen once, which is why the step order itself is unchanged.
363
+ // default branch happen once, which is why the step order does not depend on the plan — `josh adopt`
364
+ // runs `propagate_run.ADOPT_STEP_ORDER`, the same order with `josh latest` added.
357
365
  interface ReleasePlan {
358
366
  releases: ReadonlyArray<Release>
359
367
  origin: string
@@ -1,33 +1,36 @@
1
1
  import { styleText } from 'node:util'
2
2
  import { backlog_budget } from '#scripts/backlog/backlog-budget'
3
- import { backlog_idle, type IdleWindow } from '#scripts/backlog/backlog-idle'
3
+ import { run_board_fit } from './run-board-fit'
4
4
  import { run_board_labels } from './run-board-labels'
5
5
  import { run_board_layout, type BoardLayout, type BoardRow } from './run-board-layout'
6
6
  import { run_board_machine, type MachineGauges } from './run-board-machine'
7
+ import { run_board_render_slot } from './run-board-render-slot'
7
8
  import type { RunActivity } from './run-board-status'
8
9
  import type { LaneUsages } from './run-board-usage'
9
10
 
10
- // The top of `run:board`: whether the run is moving, how long it has run, how long it has left, how
11
- // fresh its last event is, and how far through the plan it is — and, while it waits on an empty
12
- // backlog, until when it waits and what ends the wait. Every time comes from a record (the carry's
13
- // start, the stream's events, the `idle` window); nothing here guesses one. Lines of symbols rather
14
- // than sentences: the `⏱` that moves every second is the proof the board is live, so no `updated`
15
- // line is drawn. Beside the time, the machine the run is on, so a slow run reads apart from a stuck
16
- // one. **Always two lines**: the run's state with its progress, then the time with the machine. A
17
- // value not read yet is drawn `-`, never `0`, so a header still loading reads as unknown rather than
18
- // as an empty run.
19
-
20
- const { HEADER_ICONS, STATE_ICONS, WORDS, bar_of, clock_of, elapsed_of, left_of, minute_of } =
21
- run_board_labels
11
+ // The top of `run:board`: whether the run is moving, how long it has run, how fresh its last event is,
12
+ // and how far through the plan it is. Every time comes from a record (the carry's start, the stream's
13
+ // events); nothing here guesses one. Lines of symbols rather than sentences: the `⏰` that moves every
14
+ // second is the proof the board is live, so no `updated` line is drawn. **Always two lines, in three
15
+ // columns**: the run's mark and name over its repository's, the run's age over the `💓`, then the
16
+ // progress over the machine the run is on — so the first line reads as how far the run is and the
17
+ // second as what it runs on, a slow run apart from a stuck one. Both are written the same while the
18
+ // run works and while it waits, so a wait is read off the mark and the `💓` rather than off a line
19
+ // that changes shape. A value not read yet is drawn `-`, never `0`, so a header still loading reads
20
+ // as unknown rather than as an empty run.
21
+
22
+ const { HEADER_ICONS, STATE_ICONS, WORDS, bar_of, minute_of, span_of } = run_board_labels
23
+ const { cell_of } = run_board_render_slot
22
24
  const UNKNOWN = '-'
23
- const { GAP } = run_board_machine
25
+ const BLANK = ' '
26
+ const REPO_SEPARATOR = '/'
27
+ const { GAP, PERCENT_WIDTH } = run_board_machine
24
28
  // A running run whose newest event is older than this is drawn as stale, so a hang stands out; past
25
29
  // twice that it is drawn as alarming.
26
30
  const STALE_MINUTES = 15
27
31
  const STALE_MS = STALE_MINUTES * backlog_budget.MS_PER_MINUTE
28
32
  const ALARM_FACTOR = 2
29
33
  const ALARM_MS = STALE_MS * ALARM_FACTOR
30
- const INDENT = ' '
31
34
  const RUN_NAME = 'backlogrun'
32
35
 
33
36
  // Who reads the frame: a person on a screen, or a chat a session answers a progress question in, which
@@ -56,6 +59,8 @@ interface BoardHeader {
56
59
  // terminal and they draw their still icons.
57
60
  spinner: string | undefined
58
61
  form: BoardForm
62
+ // The repository the plan was read for, as `owner/repo`; `undefined` before a plan is read.
63
+ repo: string | undefined
59
64
  // An issue reference as the board draws it — `3450` or `owner/repo#12` — made a link where the
60
65
  // terminal opens one.
61
66
  link: (reference: string) => string
@@ -103,9 +108,8 @@ function quiet_mark(layout: BoardLayout | undefined): RunMark {
103
108
  // The run's state, each state overriding the ones below it.
104
109
  function mark_of(header: BoardHeader, running: number): RunMark {
105
110
  if (header.activity.is_stopped) return 'stopped'
106
- if (running > 0) return 'running'
107
111
 
108
- return header.activity.idle === undefined ? quiet_mark(header.layout) : 'idle'
112
+ return running > 0 ? 'running' : quiet_mark(header.layout)
109
113
  }
110
114
 
111
115
  // A running run turns the spinner in place of ▶, both one column wide; every other mark stands still.
@@ -116,25 +120,61 @@ function run_part(mark: RunMark, spinner: string | undefined): string {
116
120
  return styleText(color, `${shown} ${RUN_NAME}`)
117
121
  }
118
122
 
123
+ // The repository's own name — `kit` of `owner/kit` — under the run's, past the mark's columns, so a
124
+ // board open beside another repository's is told apart. Blank before a plan names one.
125
+ function repo_part(header: BoardHeader, mark: RunMark): string {
126
+ const name = header.repo?.split(REPO_SEPARATOR).at(-1) ?? ''
127
+ const indent = run_board_fit.width_of(RUN_MARKS[mark].glyph) + BLANK.length
128
+
129
+ return `${BLANK.repeat(indent)}${name}`
130
+ }
131
+
119
132
  function aged(part: string, age_ms: number): string {
120
133
  if (age_ms > ALARM_MS) return styleText('red', part)
121
134
 
122
135
  return age_ms > STALE_MS ? styleText('yellow', part) : part
123
136
  }
124
137
 
125
- // How long ago the stream last moved; only a running run can be stale, so only it is colored. An ended
126
- // run has no heartbeat to keep: one counting on would read as a hang.
127
- function heartbeat_part(header: BoardHeader, mark: RunMark): string | undefined {
138
+ // How long ago the stream last moved. An ended run has no heartbeat to keep: one counting on would
139
+ // read as a hang.
140
+ function age_of(header: BoardHeader): number | undefined {
128
141
  const last = header.activity.last_event_ms
129
142
 
130
143
  if (last === undefined || header.ended_ms !== undefined) return undefined
131
144
 
132
- const age = header.now_ms - last
133
- const part = `💓 ${elapsed_of(age)}`
145
+ return header.now_ms - last
146
+ }
147
+
148
+ // The minute an ended run ended, standing where its heartbeat did. Blank while the run has none to draw.
149
+ function ended_part(header: BoardHeader): string {
150
+ const { ended_ms } = header
151
+
152
+ return ended_ms === undefined ? '' : `${HEADER_ICONS.ended} ${minute_of(ended_ms)}`
153
+ }
154
+
155
+ // The `💓` under the run's age, in the `⏰`'s own format with its digits right-aligned to `width` —
156
+ // only a running run can be stale, so only it is colored. Only an ended run draws anything else there:
157
+ // the minute it ended.
158
+ function beat_part(header: BoardHeader, mark: RunMark, width: number): string {
159
+ const age = age_of(header)
160
+
161
+ if (age === undefined) return ended_part(header)
162
+
163
+ const part = `${HEADER_ICONS.heartbeat} ${span_of(age).padStart(width)}`
134
164
 
135
165
  return mark === 'running' ? aged(part, age) : part
136
166
  }
137
167
 
168
+ // A run's age over the `💓`, or over the minute an ended run ended: the shorter of the two lengths is
169
+ // right-aligned to the longer, so the digits stand in one column when only one has passed ten hours.
170
+ function time_parts(header: BoardHeader, mark: RunMark): Array<string> {
171
+ const elapsed = span_of((header.ended_ms ?? header.now_ms) - header.started_ms)
172
+ const age = age_of(header)
173
+ const width = Math.max(elapsed.length, age === undefined ? 0 : span_of(age).length)
174
+
175
+ return [`${HEADER_ICONS.elapsed} ${elapsed.padStart(width)}`, beat_part(header, mark, width)]
176
+ }
177
+
138
178
  // A plan fetch is said only when it failed — the board otherwise keeps quiet about a routine read.
139
179
  function plan_warning(header: BoardHeader): string | undefined {
140
180
  const failed = header.plan_failed_ms
@@ -152,23 +192,6 @@ function loading_part(header: BoardHeader): string | undefined {
152
192
  return is_plan_loading && spinner !== undefined ? `${HEADER_ICONS.loading} ${spinner}` : undefined
153
193
  }
154
194
 
155
- // A running run's age and time left before the cut-off, or an ended run's frozen duration and the
156
- // minute it ended — an ended run has no cut-off left.
157
- function time_parts(header: BoardHeader): Array<string> {
158
- const { now_ms, started_ms, ended_ms } = header
159
-
160
- if (ended_ms !== undefined) {
161
- return [
162
- `⏱ ${elapsed_of(ended_ms - started_ms)}`,
163
- `${HEADER_ICONS.ended} ${minute_of(ended_ms)}`,
164
- ]
165
- }
166
-
167
- const cutoff = started_ms + backlog_budget.WHOLE_RUN_BUDGET_MS
168
-
169
- return [`⏱ ${elapsed_of(now_ms - started_ms)}`, `⌛ ${left_of(cutoff - now_ms)}`]
170
- }
171
-
172
195
  function layout_of(header: BoardHeader): BoardLayout {
173
196
  return header.layout ?? run_board_layout.EMPTY_LAYOUT
174
197
  }
@@ -186,14 +209,25 @@ function plus_of(counts: BoardCounts, header: BoardHeader): string {
186
209
  return added > 0 ? ` (+${String(added)})` : ''
187
210
  }
188
211
 
189
- // Icon, figure, then bar, as the machine gauges: the settled count is right-aligned to the total's
190
- // width, so the bar starts in one column as the count grows. Arrivals since the board first looked
191
- // follow the bar, where they cannot move it.
192
- function tally_part(counts: BoardCounts, is_read: boolean): string {
212
+ // The plan's total as the tally draws it, unknown until the plan is read.
213
+ function total_of(counts: BoardCounts, header: BoardHeader): string {
214
+ return is_plan_read(header) ? String(counts.total) : UNKNOWN
215
+ }
216
+
217
+ // The width the tally and the machine's first figure are both right-aligned to — the wider of a full
218
+ // tally and a full percent — so the progress bar and the gauge under it start in one column, and
219
+ // neither moves as a count or a percent gains a digit.
220
+ function figure_width(total: string): number {
221
+ return Math.max(PERCENT_WIDTH, `${total}/${total}`.length)
222
+ }
223
+
224
+ // Icon, figure, then bar, as the machine gauges. Arrivals since the board first looked follow the
225
+ // bar, where they cannot move it.
226
+ function tally_part(counts: BoardCounts, header: BoardHeader): string {
193
227
  const { settled } = counts
194
- const total = is_read ? String(counts.total) : UNKNOWN
195
- const tally = `${String(settled)}/${total}`.padStart(`${total}/${total}`.length)
196
- const bar = is_read ? bar_of(settled, counts.total) : bar_of(0, 0)
228
+ const total = total_of(counts, header)
229
+ const tally = `${String(settled)}/${total}`.padStart(figure_width(total))
230
+ const bar = is_plan_read(header) ? bar_of(settled, counts.total) : bar_of(0, 0)
197
231
 
198
232
  return `${STATE_ICONS.merged} ${tally} ${bar}`
199
233
  }
@@ -202,47 +236,20 @@ function progress_parts(counts: BoardCounts, header: BoardHeader): Array<string>
202
236
  const is_read = is_plan_read(header)
203
237
 
204
238
  return [
205
- `${tally_part(counts, is_read)}${is_read ? plus_of(counts, header) : ''}`,
239
+ `${tally_part(counts, header)}${is_read ? plus_of(counts, header) : ''}`,
206
240
  `${STATE_ICONS.running} ${String(counts.running)}`,
207
241
  `${STATE_ICONS.waiting} ${is_read ? String(counts.remaining) : UNKNOWN}`,
208
242
  `${STATE_ICONS.parked} ${String(counts.parked)}`,
209
243
  ]
210
244
  }
211
245
 
212
- // The run's mark and its progress, then how fresh its stream is and what its plan read is doing.
213
- function state_line(counts: BoardCounts, header: BoardHeader): string {
214
- const mark = mark_of(header, counts.running)
215
- const parts = [
216
- run_part(mark, header.spinner),
217
- ...progress_parts(counts, header),
218
- heartbeat_part(header, mark),
219
- loading_part(header),
220
- plan_warning(header),
221
- ]
246
+ // The run's progress, then what its plan read is doing.
247
+ function progress_part(counts: BoardCounts, header: BoardHeader): string {
248
+ const parts = [...progress_parts(counts, header), loading_part(header), plan_warning(header)]
222
249
 
223
250
  return parts.filter((part) => part !== undefined).join(GAP)
224
251
  }
225
252
 
226
- function idle_line(idle: IdleWindow, now_ms: number): string {
227
- const ending = idle.bound === 'idle' ? WORDS.idle_end_idle : WORDS.idle_end_run
228
- const left = `${elapsed_of(idle.until_ms - now_ms)} ${WORDS.idle_left}`
229
- const until = `${WORDS.idle_until} ${clock_of(idle.until_ms)} (${left}) → ${ending}`
230
- const next_check = `${WORDS.next_check} ${clock_of(backlog_idle.next_check_ms(idle))}`
231
-
232
- return `${INDENT}${RUN_MARKS.idle.glyph} ${until} · ${next_check}`
233
- }
234
-
235
- // The wait, under the header's blank line, drawn only while it is what the run is doing: no child
236
- // running and the run not ended. The children running are the run's own, read
237
- // locally, so a board still waiting on GitHub never draws a running run as waiting.
238
- function idle_lines(header: BoardHeader): Array<string> {
239
- const { idle, is_stopped } = header.activity
240
-
241
- if (is_stopped || idle === undefined || counts_of(layout_of(header)).running > 0) return []
242
-
243
- return ['', idle_line(idle, header.now_ms)]
244
- }
245
-
246
253
  // The gauges the machine line draws: every one on a screen, the memory alone in a chat.
247
254
  function machine_of(header: BoardHeader): MachineGauges | undefined {
248
255
  const { machine } = header
@@ -257,19 +264,35 @@ const MACHINE_UNKNOWN = [HEADER_ICONS.cpu, HEADER_ICONS.memory, HEADER_ICONS.swa
257
264
  .map((icon) => `${icon} ${UNKNOWN}`)
258
265
  .join(GAP)
259
266
 
260
- // The time, then the machine the run is on.
261
- function clock_line(header: BoardHeader): string {
262
- const machine = run_board_machine.line_of(machine_of(header)) ?? MACHINE_UNKNOWN
267
+ // The machine the run is on, its first figure as wide as the tally over it.
268
+ function machine_part(counts: BoardCounts, header: BoardHeader): string {
269
+ const width = figure_width(total_of(counts, header))
263
270
 
264
- return [...time_parts(header), machine].join(GAP)
271
+ return run_board_machine.line_of(machine_of(header), width) ?? MACHINE_UNKNOWN
265
272
  }
266
273
 
267
- // The run's state with its progress, then the time with the machine, in every state the board can be in.
274
+ // Both lines' cells of one column, the narrower padded to the wider as the terminal counts them.
275
+ function column_of(cells: ReadonlyArray<string>): Array<string> {
276
+ const width = Math.max(...cells.map((cell) => run_board_fit.width_of(cell)))
277
+
278
+ return cells.map((cell) => cell_of(cell, width))
279
+ }
280
+
281
+ // The run's name, age and progress, then its repository, heartbeat and machine under them, in every
282
+ // state the board can be in.
268
283
  function header_lines(header: BoardHeader): Array<string> {
269
- return [state_line(counts_of(layout_of(header)), header), clock_line(header)]
284
+ const counts = counts_of(layout_of(header))
285
+ const mark = mark_of(header, counts.running)
286
+ const columns = [
287
+ column_of([run_part(mark, header.spinner), repo_part(header, mark)]),
288
+ column_of(time_parts(header, mark)),
289
+ [progress_part(counts, header), machine_part(counts, header)],
290
+ ]
291
+
292
+ return [0, 1].map((line) => columns.map((column) => column[line]).join(GAP))
270
293
  }
271
294
 
272
- const run_board_header = { counts_of, header_lines, idle_lines, is_plan_read }
295
+ const run_board_header = { counts_of, header_lines, is_plan_read }
273
296
 
274
297
  export { run_board_header }
275
298
  export type { BoardCounts, BoardForm, BoardHeader }
@@ -6,16 +6,16 @@ import type { BoardNote } from './run-board-notes'
6
6
  import type { Phase } from './run-board-phase'
7
7
  import type { ItemState } from './run-board-status'
8
8
 
9
- // The words and the clock `run:board` draws with. A moment on the board is a local `HH:MM:SS` clock;
10
- // how long something has run is a short `MM:SS`, so a person reads the board against the clock on
11
- // their own screen.
9
+ // The words and the clock `run:board` draws with. A moment on the board is a local clock, `HH:MM`
10
+ // where a person reads it to the minute; how long a row or the heartbeat has run is a short `MM:SS`,
11
+ // and how long the run has run or its wait has left is `H:MM:SS` — two separators are a length and one
12
+ // is a time of day, so the header's time column never reads a length as a moment.
12
13
 
13
14
  const CLOCK_WIDTH = 2
14
15
  const CLOCK_PAD = '0'
15
16
  const MS_PER_SECOND = 1000
16
17
  const SECONDS_PER_MINUTE = 60
17
18
  const MINUTES_PER_HOUR = 60
18
- const MS_PER_MINUTE = MS_PER_SECOND * SECONDS_PER_MINUTE
19
19
  // Every gauge is this wide — the plan, the machine and a row's phase.
20
20
  const BAR_WIDTH = 10
21
21
  // A centered square done and a thin line left: the shapes alone tell the two apart where no color is
@@ -58,11 +58,6 @@ const WORDS = {
58
58
  followup: 'followup',
59
59
  report: 'report',
60
60
  failed: 'failed',
61
- idle_until: 'wait ends',
62
- idle_left: 'left',
63
- idle_end_idle: 'ends and reports if nothing new',
64
- idle_end_run: 'ends at the whole-run cut-off and reports',
65
- next_check: 'next check',
66
61
  notes: 'findings and decisions',
67
62
  more: 'more',
68
63
  filed: 'filed',
@@ -168,13 +163,16 @@ const KIND_WORDS: Readonly<Record<FiledKind, keyof Words>> = {
168
163
  }
169
164
 
170
165
  // The header's gauges and marks. ⚡ rather than 🔥: a fire beside a gauge drawn green reads as an
171
- // alarm.
166
+ // alarm. ⏰ rather than ⏱, a text symbol some terminals draw one column wide: under it the 💓's digits
167
+ // would stand a column off.
172
168
  const HEADER_ICONS = {
173
169
  cpu: '⚡',
174
170
  memory: '🧠',
175
171
  swap: '💾',
176
172
  ended: '🔚',
177
173
  loading: '⏳',
174
+ elapsed: '⏰',
175
+ heartbeat: '💓',
178
176
  } as const
179
177
 
180
178
  // **The machine gauges' three colors, decided here alone**: Activity Monitor's green, yellow and red
@@ -207,8 +205,8 @@ function minute_of(ms: number): string {
207
205
  return [date.getHours(), date.getMinutes()].map((value) => two_digits(value)).join(':')
208
206
  }
209
207
 
210
- // How long something has run, as `MM:SS` whose minutes never carry into hours (`61:05`), so the header
211
- // and every row read one short column; a negative span reads as zero.
208
+ // How long a row's child has run, as `MM:SS` whose minutes never carry into hours (`61:05`), so every
209
+ // row reads one short column; a negative span reads as zero.
212
210
  function elapsed_of(ms: number): string {
213
211
  const seconds = Math.floor(Math.max(0, ms) / MS_PER_SECOND)
214
212
  const minutes = Math.floor(seconds / SECONDS_PER_MINUTE)
@@ -216,12 +214,15 @@ function elapsed_of(ms: number): string {
216
214
  return `${two_digits(minutes)}:${two_digits(seconds % SECONDS_PER_MINUTE)}`
217
215
  }
218
216
 
219
- // How long is left, as `7h38m`; a deadline already passed reads as zero.
220
- function left_of(ms: number): string {
221
- const minutes = Math.floor(Math.max(0, ms) / MS_PER_MINUTE)
217
+ // How long the run has run or its stream has been silent, as `H:MM:SS` with the hours not padded; a negative
218
+ // span reads as zero. Always three fields, so a run under an hour (`0:03:25`) never reads as a time.
219
+ function span_of(ms: number): string {
220
+ const seconds = Math.floor(Math.max(0, ms) / MS_PER_SECOND)
221
+ const minutes = Math.floor(seconds / SECONDS_PER_MINUTE)
222
222
  const hours = Math.floor(minutes / MINUTES_PER_HOUR)
223
+ const tail = [minutes % MINUTES_PER_HOUR, seconds % SECONDS_PER_MINUTE]
223
224
 
224
- return `${String(hours)}h${two_digits(minutes % MINUTES_PER_HOUR)}m`
225
+ return [String(hours), ...tail.map((value) => two_digits(value))].join(':')
225
226
  }
226
227
 
227
228
  function is_shade(color: Paint): color is Shade {
@@ -289,9 +290,9 @@ const run_board_labels = {
289
290
  bar_of,
290
291
  clock_of,
291
292
  elapsed_of,
292
- left_of,
293
293
  minute_of,
294
294
  painted,
295
+ span_of,
295
296
  spinner_of,
296
297
  }
297
298