@joshuafolkken/kit 1.1383.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 (244) 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-LOJTBU5V.js → chunk-2G5F2YCD.js} +3 -3
  14. package/dist/hooks/{chunk-4MGHBTOM.js → chunk-3LE6VLSR.js} +25 -8
  15. package/dist/hooks/{chunk-Y5R47QHU.js → chunk-4BHUPFKF.js} +1 -1
  16. package/dist/hooks/{chunk-KC6LUZZ2.js → chunk-5SJSSCJG.js} +1 -1
  17. package/dist/hooks/{chunk-FZ6FUSBM.js → chunk-723HKZO5.js} +1 -1
  18. package/dist/hooks/{chunk-RYP3TSUJ.js → chunk-7KMN5QB5.js} +3 -3
  19. package/dist/hooks/{chunk-NEC4AT4Z.js → chunk-DGT54IH5.js} +4 -0
  20. package/dist/hooks/{chunk-YKUITFT7.js → chunk-EMB55BDF.js} +4 -4
  21. package/dist/hooks/{chunk-JBBMAPC4.js → chunk-IX6TTTI6.js} +4 -0
  22. package/dist/hooks/{chunk-6MAW7BCM.js → chunk-JXQBK323.js} +2677 -190
  23. package/dist/hooks/{chunk-KCF2YE7O.js → chunk-MSYSUSY6.js} +5 -5
  24. package/dist/hooks/{chunk-LC56R53A.js → chunk-MU4LJFIV.js} +4 -4
  25. package/dist/hooks/{chunk-7VSZ2N4B.js → chunk-QL6QTPWN.js} +3 -3
  26. package/dist/hooks/{chunk-TT742SIO.js → chunk-XER6SSIB.js} +92 -19
  27. package/dist/hooks/{chunk-2X2ART5G.js → chunk-YW452ZAX.js} +500 -29
  28. package/dist/hooks/{chunk-6AC4C2IO.js → chunk-YZXYOAZ3.js} +187 -443
  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 +262 -201
  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 +124 -15
  36. package/dist/hooks/stop-guard.js +98 -55
  37. package/dist/josh.js +63 -10
  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-cli.ts +2 -4
  45. package/scripts/backlog/backlog-drive-epic.ts +24 -8
  46. package/scripts/backlog/backlog-drive-launch.ts +5 -0
  47. package/scripts/backlog/backlog-drive-named-offer.ts +7 -2
  48. package/scripts/backlog/backlog-drive-named.ts +4 -2
  49. package/scripts/backlog/backlog-drive-owner.ts +72 -5
  50. package/scripts/backlog/backlog-drive-unread.ts +56 -0
  51. package/scripts/backlog/backlog-drive.ts +16 -8
  52. package/scripts/backlog/backlog-overlap.ts +22 -5
  53. package/scripts/backlog/backlog-ready.ts +1 -3
  54. package/scripts/backlog/backlogrun-args.ts +13 -2
  55. package/scripts/backlog/backlogrun-cli.ts +91 -19
  56. package/scripts/backlog/backlogrun-launch.ts +80 -5
  57. package/scripts/backlog/backlogrun-replace.ts +166 -0
  58. package/scripts/backlog/backlogrun-stop.ts +178 -0
  59. package/scripts/behavior/behavior-cli.ts +6 -1
  60. package/scripts/doctor/doctor-io.ts +60 -9
  61. package/scripts/doctor/doctor.ts +20 -1
  62. package/scripts/document/document-byte-budget.ts +3 -3
  63. package/scripts/epic/epic-bundle-cli.ts +8 -8
  64. package/scripts/epic/epic-bundle-referenced.ts +2 -5
  65. package/scripts/epic/epic-bundle.ts +39 -4
  66. package/scripts/exports/unused-members-cli.ts +2 -1
  67. package/scripts/followup/followup-checks-event.ts +82 -0
  68. package/scripts/followup/git-followup-finish.ts +16 -2
  69. package/scripts/followup/git-followup-workflow.ts +5 -2
  70. package/scripts/followup/git-pr-followup-checks.ts +18 -4
  71. package/scripts/followup/git-pr-followup-wrapup.ts +5 -0
  72. package/scripts/followup/git-pr-followup.ts +4 -1
  73. package/scripts/fresh-machine/fresh-machine-cli.ts +204 -0
  74. package/scripts/fresh-machine/fresh-machine-docker.ts +65 -0
  75. package/scripts/fresh-machine/fresh-machine-image.ts +62 -0
  76. package/scripts/fresh-machine/fresh-machine-log.ts +61 -0
  77. package/scripts/fresh-machine/fresh-machine-patterns.ts +130 -0
  78. package/scripts/fresh-machine/fresh-machine-run.ts +215 -0
  79. package/scripts/fresh-machine/fresh-machine-script.ts +45 -0
  80. package/scripts/fresh-machine/fresh-machine-spec.ts +57 -0
  81. package/scripts/fresh-machine/specs/kit-test-backlogrun-python.json +34 -0
  82. package/scripts/fresh-machine/specs/kit-test-backlogrun.json +31 -0
  83. package/scripts/gate/gate-report.ts +70 -15
  84. package/scripts/gate/project-checks.ts +13 -2
  85. package/scripts/gate/scoped-green.ts +2 -1
  86. package/scripts/gate/verification-gate.ts +4 -3
  87. package/scripts/gh/gh-author-association.ts +14 -0
  88. package/scripts/gh/git-gh-conditional.ts +66 -0
  89. package/scripts/gh/git-gh-exec.ts +1 -1
  90. package/scripts/gh/git-gh-issue-read.ts +6 -3
  91. package/scripts/gh/git-gh-issue-write.ts +5 -1
  92. package/scripts/gh/git-gh-issue.ts +4 -1
  93. package/scripts/gh/git-pr-checks-eval.ts +13 -0
  94. package/scripts/gh/git-pr-checks-parse.ts +8 -1
  95. package/scripts/gh/git-pr-checks-watch.ts +11 -4
  96. package/scripts/gh/git-pr-checks.ts +36 -2
  97. package/scripts/git/git-command.ts +16 -4
  98. package/scripts/git/git-schemas.ts +1 -0
  99. package/scripts/git/git-spawn-sync.ts +1 -1
  100. package/scripts/hooks/pretool-guard.ts +3 -1
  101. package/scripts/init/hook-command-rewrite.ts +31 -0
  102. package/scripts/init/init-actions.ts +4 -1
  103. package/scripts/init/init-ai-copy.ts +33 -10
  104. package/scripts/init/init-basic.ts +8 -3
  105. package/scripts/init/init-logic-toolchain.ts +76 -0
  106. package/scripts/init/init-logic.ts +2 -0
  107. package/scripts/init/init.ts +5 -23
  108. package/scripts/init/project-profile.ts +36 -7
  109. package/scripts/init/start-exec.ts +27 -1
  110. package/scripts/init/start-plan.ts +30 -4
  111. package/scripts/init/start-steps.ts +77 -3
  112. package/scripts/init/start.ts +21 -13
  113. package/scripts/init/workflow-mark.ts +35 -0
  114. package/scripts/issue/issue-ack-cli.ts +84 -0
  115. package/scripts/issue/issue-labels.ts +40 -8
  116. package/scripts/issue/issue-state-cli.ts +14 -4
  117. package/scripts/issue/issue-watch-diff.ts +152 -0
  118. package/scripts/issue/issue-watch-gate.ts +81 -0
  119. package/scripts/issue/issue-watch-hook.ts +54 -0
  120. package/scripts/issue/issue-watch.ts +240 -0
  121. package/scripts/issue/measure-rerun-cli.ts +3 -4
  122. package/scripts/josh/josh-command-map.ts +4 -0
  123. package/scripts/josh/josh-commands-backlog.ts +2 -2
  124. package/scripts/josh/josh-commands-development.ts +1 -1
  125. package/scripts/josh/josh-commands-issue.ts +7 -0
  126. package/scripts/josh/josh-commands-lane.ts +11 -2
  127. package/scripts/josh/josh-commands-project.ts +25 -1
  128. package/scripts/josh/josh-verdict.ts +33 -4
  129. package/scripts/lane/claude-lane-broker.ts +142 -0
  130. package/scripts/lane/lane-await.ts +6 -2
  131. package/scripts/lane/lane-cache.ts +37 -7
  132. package/scripts/lane/lane-capacity.ts +8 -6
  133. package/scripts/lane/lane-child-invocation.ts +1 -0
  134. package/scripts/lane/lane-cli.ts +15 -1
  135. package/scripts/lane/lane-close.ts +15 -6
  136. package/scripts/lane/lane-dispatch.ts +36 -10
  137. package/scripts/lane/lane-environment.ts +1 -1
  138. package/scripts/lane/lane-launch-cli.ts +13 -4
  139. package/scripts/lane/lane-ledger.ts +21 -3
  140. package/scripts/lane/lane-measure-cli.ts +10 -2
  141. package/scripts/lane/lane-open.ts +9 -1
  142. package/scripts/lane/lane-preserve.ts +49 -0
  143. package/scripts/lane/lane-reap.ts +20 -5
  144. package/scripts/lane/lane-registry.ts +6 -0
  145. package/scripts/lane/lane-restart-cli.ts +51 -0
  146. package/scripts/lane/lane-restart.ts +175 -0
  147. package/scripts/lane/{openai-review-broker.ts → lane-session-broker.ts} +106 -28
  148. package/scripts/lane/lane-spent.ts +195 -0
  149. package/scripts/lane/lane-stage-stats.ts +27 -3
  150. package/scripts/lane/lane-stop-stats.ts +96 -0
  151. package/scripts/lane/openai-lane-supervisor.ts +2 -2
  152. package/scripts/lane/process-tools.ts +31 -0
  153. package/scripts/lib/buffered-process.ts +36 -2
  154. package/scripts/lib/pnpm-reach.ts +27 -0
  155. package/scripts/lib/status-icons.ts +5 -1
  156. package/scripts/lint/lint-parallel.ts +19 -2
  157. package/scripts/observations/observation-ledger-home.ts +1 -0
  158. package/scripts/review/review-attest-cli.ts +21 -6
  159. package/scripts/review/review-brief-cli.ts +2 -2
  160. package/scripts/review/review-comment-round.ts +114 -0
  161. package/scripts/review/review-record-cli.ts +39 -17
  162. package/scripts/rules/implementation-cut.ts +3 -3
  163. package/scripts/rules/pre-gate-cut.ts +1 -1
  164. package/scripts/run/board/run-board-checks.ts +42 -0
  165. package/scripts/run/board/run-board-cli.ts +2 -0
  166. package/scripts/run/board/run-board-every.ts +3 -1
  167. package/scripts/run/board/run-board-fit.ts +16 -5
  168. package/scripts/run/board/run-board-fresh.ts +64 -0
  169. package/scripts/run/board/run-board-header.ts +139 -37
  170. package/scripts/run/board/run-board-labels.ts +47 -4
  171. package/scripts/run/board/run-board-lane-event.ts +66 -0
  172. package/scripts/run/board/run-board-layout.ts +57 -9
  173. package/scripts/run/board/run-board-now.ts +234 -0
  174. package/scripts/run/board/run-board-operation-read.ts +75 -0
  175. package/scripts/run/board/run-board-operation.ts +150 -0
  176. package/scripts/run/board/run-board-phase.ts +26 -1
  177. package/scripts/run/board/run-board-plan.ts +4 -6
  178. package/scripts/run/board/run-board-read.ts +58 -5
  179. package/scripts/run/board/run-board-reason.ts +124 -0
  180. package/scripts/run/board/run-board-render-checks.ts +55 -0
  181. package/scripts/run/board/run-board-render-legend.ts +12 -3
  182. package/scripts/run/board/run-board-render-slot.ts +2 -2
  183. package/scripts/run/board/run-board-render-tail.ts +128 -0
  184. package/scripts/run/board/run-board-render-thanks.ts +50 -0
  185. package/scripts/run/board/run-board-render-title.ts +58 -0
  186. package/scripts/run/board/run-board-render.ts +96 -80
  187. package/scripts/run/board/run-board-row-yield.ts +32 -0
  188. package/scripts/run/board/run-board-setup.ts +121 -0
  189. package/scripts/run/board/run-board-span.ts +49 -7
  190. package/scripts/run/board/run-board-state.ts +10 -0
  191. package/scripts/run/board/run-board-status.ts +76 -13
  192. package/scripts/run/board/run-board-thanks-csv.ts +71 -0
  193. package/scripts/run/board/run-board-thanks-read.ts +112 -0
  194. package/scripts/run/board/run-board-thanks.ts +53 -0
  195. package/scripts/run/board/run-board-tick.ts +53 -16
  196. package/scripts/run/board/run-board-usual.ts +44 -0
  197. package/scripts/run/carry/run-carry-added.ts +1 -1
  198. package/scripts/run/carry/run-carry-args.ts +9 -1
  199. package/scripts/run/carry/run-carry-change.ts +1 -1
  200. package/scripts/run/carry/run-carry-cli.ts +39 -52
  201. package/scripts/run/carry/run-carry-end.ts +73 -0
  202. package/scripts/run/carry/run-carry-ended.ts +9 -1
  203. package/scripts/run/carry/run-carry.ts +24 -1
  204. package/scripts/run/entry/run-entry-cli.ts +5 -0
  205. package/scripts/run/event/run-event-checks.ts +54 -0
  206. package/scripts/run/event/run-event-render.ts +3 -0
  207. package/scripts/run/event/run-event-scope.ts +1 -0
  208. package/scripts/run/event/run-event-setup.ts +91 -0
  209. package/scripts/run/event/run-event-stream.ts +46 -9
  210. package/scripts/run/merge/run-merge-cli.ts +7 -4
  211. package/scripts/run/merge/run-merge-ship.ts +61 -0
  212. package/scripts/run/merge/run-merge-steps.ts +7 -14
  213. package/scripts/run/merge/run-merge-token.ts +16 -2
  214. package/scripts/run/run-headless.ts +1 -0
  215. package/scripts/run/run-step.ts +2 -0
  216. package/scripts/run/run-stranded-detect.ts +58 -12
  217. package/scripts/run/ship/run-ship-cli.ts +22 -2
  218. package/scripts/run/ship/run-ship-return.ts +31 -13
  219. package/scripts/run/ship/run-ship-review-steps.ts +17 -4
  220. package/scripts/run/ship/run-ship-stage.ts +1 -0
  221. package/scripts/run/wake/run-wake-await.ts +130 -0
  222. package/scripts/run/wake/run-wake-cli.ts +108 -74
  223. package/scripts/run/wake/run-wake-describe.ts +36 -2
  224. package/scripts/run/wake/run-wake-driver.ts +21 -5
  225. package/scripts/run/wake/run-wake-handoff.ts +1 -1
  226. package/scripts/run/wake/run-wake-session.ts +20 -3
  227. package/scripts/run/wake/run-wake-stop.ts +37 -0
  228. package/scripts/run/wake/run-wake-supervise.ts +44 -0
  229. package/scripts/sync/claude-settings-merge.ts +144 -0
  230. package/scripts/sync/sync-ai-files.ts +28 -3
  231. package/scripts/sync/sync.ts +15 -0
  232. package/scripts/test/project-test-script.ts +73 -0
  233. package/scripts/test/skip-marker.ts +14 -1
  234. package/scripts/test/test-related.ts +3 -2
  235. package/scripts/test/test-unit-guard.ts +78 -14
  236. package/scripts/time-runtime/time-reported-failure.ts +11 -3
  237. package/scripts/uninstall/distributed-files.ts +92 -0
  238. package/scripts/uninstall/uninstall-plan.ts +119 -0
  239. package/scripts/uninstall/uninstall-report.ts +61 -0
  240. package/scripts/uninstall/uninstall.ts +206 -0
  241. package/templates/AGENTS.basic.md +4 -0
  242. package/templates/GEMINI.basic.md +6 -0
  243. package/dist/hooks/chunk-6V5OPIUU.js +0 -13
  244. package/scripts/run/board/run-board-track.ts +0 -22
@@ -1,4 +1,5 @@
1
1
  import { git_next_issues } from '#scripts/issue/git-next-issues'
2
+ import { issue_watch } from '#scripts/issue/issue-watch'
2
3
  import { review_attest } from '#scripts/review/review-attest'
3
4
  import { review_stamps } from '#scripts/review/review-stamps'
4
5
  import { run_hold } from '#scripts/run/hold/run-hold'
@@ -67,11 +68,24 @@ async function clear_review_target(should_merge: boolean): Promise<void> {
67
68
  }
68
69
  }
69
70
 
70
- // The two review records are one step of the tail: both are cleared by a merged run and neither is
71
- // worth reporting on its own.
71
+ // The issue watch ends with the run it watched: a record left behind would make the next run's hook
72
+ // poll an issue that is no longer its own.
73
+ async function clear_issue_watch(should_merge: boolean): Promise<void> {
74
+ if (!should_merge) return
75
+
76
+ try {
77
+ await issue_watch.clear_here()
78
+ } catch {
79
+ /* the next `josh run:entry` replaces the record */
80
+ }
81
+ }
82
+
83
+ // The review records and the issue watch are one step of the tail: all are cleared by a merged run
84
+ // and none is worth reporting on its own.
72
85
  async function clear_review_records(should_merge: boolean): Promise<void> {
73
86
  clear_round_one_snapshot(should_merge)
74
87
  await clear_review_target(should_merge)
88
+ await clear_issue_watch(should_merge)
75
89
  }
76
90
 
77
91
  // The working-tree hold a typed entry point claims before it starts is released here, on the one seam every `fullrun` — and every child of an `epicrun` or a `queue` —
@@ -3,6 +3,7 @@ import { parseArgs } from 'node:util'
3
3
  import { git_branch } from '#scripts/git/git-branch'
4
4
  import { git_error } from '#scripts/git/git-error'
5
5
  import { issue_number_shape } from '#scripts/issue/issue-number-shape'
6
+ import { issue_watch_gate } from '#scripts/issue/issue-watch-gate'
6
7
  import { cli_body } from '#scripts/josh/cli-body'
7
8
  import { josh_environment_file } from '#scripts/josh/josh-environment-file'
8
9
  import { git_notify, type GitNotifyConfig } from '#scripts/notify/git-notify'
@@ -151,9 +152,11 @@ async function assert_review_attested(should_merge: boolean): Promise<void> {
151
152
 
152
153
  const verdict = await review_attest.check_here()
153
154
 
154
- if (is_merge_permitted(verdict.status)) return
155
+ if (!is_merge_permitted(verdict.status)) throw new Error(review_attest.refusal_message(verdict))
156
+
157
+ const refusal = await issue_watch_gate.review_refusal_here()
155
158
 
156
- throw new Error(review_attest.refusal_message(verdict))
159
+ if (refusal !== undefined) throw new Error(refusal)
157
160
  }
158
161
 
159
162
  // Recording a review round as a prose request leaves the recurrence ledger blind whenever it is
@@ -4,9 +4,11 @@ import {
4
4
  DEFAULT_STABLE_READS,
5
5
  git_pr_checks,
6
6
  WATCH_CONFIRMED_STABLE_READS,
7
+ type SnapshotObserver,
7
8
  } from '#scripts/gh/git-pr-checks'
8
9
  import { is_coderabbit_check } from '#scripts/gh/git-pr-checks-eval'
9
10
  import { CHECK_STATUS_PASS, type PrStateSnapshot } from '#scripts/gh/git-pr-checks-parse'
11
+ import { issue_watch_gate } from '#scripts/issue/issue-watch-gate'
10
12
  import { json_value } from '#scripts/lib/json-value'
11
13
 
12
14
  // The check wait and the notes read off its snapshot — split out of `git-pr-followup.ts` when it
@@ -75,12 +77,15 @@ async function handle_watch_failure(branch_name: string, error: unknown): Promis
75
77
  // reads the wait still needs. A timed-out watch (`timed_out: true`) never spanned the pending→settled
76
78
  // window, so it must keep the full stable-read window rather than claim the confirmation. A rethrown
77
79
  // failure — the empty-rollup case — never reaches a caller, so it needs no answer here.
78
- async function watch_before_polling(branch_name: string): Promise<boolean> {
80
+ async function watch_before_polling(
81
+ branch_name: string,
82
+ observe: SnapshotObserver | undefined,
83
+ ): Promise<boolean> {
79
84
  console.info('')
80
85
  console.info('📊 Watching PR checks...')
81
86
 
82
87
  try {
83
- const result = await git_gh_command.pr_checks_watch(branch_name)
88
+ const result = await git_gh_command.pr_checks_watch(branch_name, observe)
84
89
 
85
90
  return !result.timed_out
86
91
  } catch (error) {
@@ -95,14 +100,23 @@ async function watch_before_polling(branch_name: string): Promise<boolean> {
95
100
  // once rather than twice — which removes the extra interval `followup` spent re-proving a settled run.
96
101
  // A skipped or fallen-through watch keeps `DEFAULT_STABLE_READS`, and a check that turned red is still
97
102
  // caught: the poll evaluates the full merge gate and throws on failure before any stable read counts.
103
+ // `observe` is shown each poll's snapshot, the watch's included — the run's `pr-checks` line
104
+ // (joshuafolkken/kit#3770).
98
105
  async function run_checks(input: {
99
106
  branch_name: string
100
107
  is_skip_watch: boolean
108
+ observe?: SnapshotObserver
101
109
  }): Promise<PrStateSnapshot> {
102
- const is_watch_confirmed = !input.is_skip_watch && (await watch_before_polling(input.branch_name))
110
+ const is_watch_confirmed =
111
+ !input.is_skip_watch && (await watch_before_polling(input.branch_name, input.observe))
103
112
  const stable_reads = is_watch_confirmed ? WATCH_CONFIRMED_STABLE_READS : DEFAULT_STABLE_READS
104
113
 
105
- return await git_pr_checks.wait_for_pr_success(input.branch_name, stable_reads)
114
+ return await git_pr_checks.wait_for_pr_success(
115
+ input.branch_name,
116
+ stable_reads,
117
+ issue_watch_gate.assert_read_here,
118
+ input.observe,
119
+ )
106
120
  }
107
121
 
108
122
  // Temporary: record every CodeRabbit check that was not passing when the merge gate
@@ -1,5 +1,6 @@
1
1
  import { epic_close } from '#scripts/epic/epic-close'
2
2
  import { git_gh_command } from '#scripts/gh/git-gh-command'
3
+ import { issue_watch_gate } from '#scripts/issue/issue-watch-gate'
3
4
  import { git_notify, type GitNotifyConfig } from '#scripts/notify/git-notify'
4
5
  import { git_followup_cleanup } from './git-followup-cleanup'
5
6
  import { git_followup_issue_close } from './git-followup-issue-close'
@@ -232,8 +233,12 @@ async function run_tail_steps(input: WrapupInput): Promise<void> {
232
233
  ])
233
234
  }
234
235
 
236
+ // The last look at the issue before the merge: the CI poll checked it on every iteration, but a
237
+ // person's update can land between that poll and this step, and only the seconds after this read are
238
+ // left unwatched.
235
239
  async function run_wrapup(input: WrapupInput, log: StageLog): Promise<void> {
236
240
  if (input.should_merge && input.is_merged !== true) {
241
+ await issue_watch_gate.assert_read_here()
237
242
  await git_gh_command.pr_merge(input.branch_name)
238
243
  lap(log, STAGE.merge)
239
244
  }
@@ -10,6 +10,7 @@ import {
10
10
  type TelegramSendInput,
11
11
  type TelegramTaskType,
12
12
  } from '#scripts/notify/telegram-notify'
13
+ import { followup_checks_event } from './followup-checks-event'
13
14
  import { git_followup_merged } from './git-followup-merged'
14
15
  import { git_followup_pending } from './git-followup-pending'
15
16
  import { git_followup_stages, type StageLog } from './git-followup-stages'
@@ -229,6 +230,7 @@ async function run_review_checks(
229
230
  input: FollowupInput,
230
231
  context: TelegramContext,
231
232
  log: StageLog,
233
+ issue_number: string | undefined,
232
234
  ): Promise<{ notes: Array<string>; managed: Array<string> }> {
233
235
  if (input.is_merged === true) return { notes: [], managed: [] }
234
236
 
@@ -238,6 +240,7 @@ async function run_review_checks(
238
240
  const snapshot = await run_checks({
239
241
  branch_name: input.branch_name,
240
242
  is_skip_watch: input.is_skip_watch,
243
+ observe: followup_checks_event.observer_of(issue_number, context.pr_url),
241
244
  })
242
245
 
243
246
  lap(log, STAGE.checks_wait)
@@ -278,7 +281,7 @@ async function run_stages(input: FollowupInput, log: StageLog): Promise<string |
278
281
  const { issue_number, closes_number, context } = await read_run_context(input)
279
282
 
280
283
  lap(log, STAGE.closes_and_context)
281
- const checks = await run_review_checks(input, context, log)
284
+ const checks = await run_review_checks(input, context, log, issue_number)
282
285
  const is_completion_recorded = await notify_unless_recorded(input, issue_number, context, checks)
283
286
 
284
287
  lap(log, STAGE.telegram)
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env tsx
2
+ import { mkdirSync, readdirSync, readFileSync } from 'node:fs'
3
+ import path from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { parseArgs } from 'node:util'
6
+ import { claude_token } from '#scripts/auth/claude-token'
7
+ import { gh_cli_token } from '#scripts/gh/gh-cli-token'
8
+ import { git_gh_exec } from '#scripts/gh/git-gh-exec'
9
+ import { PROJECT_ROOT } from '#scripts/init/init-paths'
10
+ import { self_sync_guard } from '#scripts/self-sync-guard/self-sync-guard-logic'
11
+ import { KIT_PACKAGE_NAME } from '#scripts/version/kit-descriptor'
12
+ import { fresh_machine_docker } from './fresh-machine-docker'
13
+ import { fresh_machine_log } from './fresh-machine-log'
14
+ import { fresh_machine_patterns } from './fresh-machine-patterns'
15
+ import { fresh_machine_run, type RunRequest } from './fresh-machine-run'
16
+ import { fresh_machine_spec, type Scenario } from './fresh-machine-spec'
17
+
18
+ // `josh fresh:run <pattern>` / `josh fresh:run --spec <file>` — run one scenario on a machine that
19
+ // has nothing installed, and keep what happened as a log.
20
+
21
+ const ARGV_OFFSET = 2
22
+ const FAILURE_EXIT_CODE = 1
23
+ const USAGE =
24
+ 'Usage: josh fresh:run <pattern> | --spec <file.json> [--package-version <version>] [--log-dir <dir>] [--rebuild] | --list'
25
+ // The scratch directory kit's distributed `.gitignore` already ignores, so a log is never committed
26
+ // from kit or from a consumer toolkit.
27
+ const DEFAULT_LOG_DIRECTORY = '.claude/tmp/fresh-machine'
28
+ const NO_GITHUB_TOKEN =
29
+ 'This scenario uses GitHub and `gh auth token` printed no token. Run `gh auth login` on this machine first.'
30
+ const NO_CLAUDE_TOKEN = `This scenario uses Claude Code and neither the macOS keychain nor ${claude_token.VARIABLE} holds a token. ${claude_token.SETUP_HINT}`
31
+ // The patterns install kit by name, while the tarball and the published version are those of the
32
+ // checkout the command is run from — in a toolkit's checkout the two would be different packages.
33
+ const NOT_A_KIT_CHECKOUT = `The named patterns install ${KIT_PACKAGE_NAME} and run from its checkout only. Write this package's scenario as a JSON file and pass it with --spec.`
34
+ const OWNED_REPOSITORIES = 'user/repos?affiliation=owner&per_page=100'
35
+ const REPOSITORY_NAMES = '.[].name'
36
+
37
+ // The option names are the CLI's, so the type is read off the parser rather than restated.
38
+ // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
39
+ function parse(argv: ReadonlyArray<string>) {
40
+ return parseArgs({
41
+ args: [...argv],
42
+ allowPositionals: true,
43
+ options: {
44
+ spec: { type: 'string' },
45
+ 'package-version': { type: 'string' },
46
+ 'log-dir': { type: 'string' },
47
+ rebuild: { type: 'boolean' },
48
+ list: { type: 'boolean' },
49
+ },
50
+ })
51
+ }
52
+
53
+ type CliValues = ReturnType<typeof parse>['values']
54
+
55
+ function pattern_of(name: string): Scenario | undefined {
56
+ const scenario = fresh_machine_patterns.find(name)
57
+
58
+ if (scenario === undefined) return undefined
59
+
60
+ const is_kit_checkout = self_sync_guard.read_package_name(PROJECT_ROOT) === KIT_PACKAGE_NAME
61
+
62
+ if (!is_kit_checkout) throw new Error(NOT_A_KIT_CHECKOUT)
63
+
64
+ return scenario
65
+ }
66
+
67
+ function scenario_of(values: CliValues, pattern: string | undefined): Scenario | undefined {
68
+ if (values.spec !== undefined) {
69
+ return fresh_machine_spec.parse(readFileSync(path.resolve(PROJECT_ROOT, values.spec), 'utf8'))
70
+ }
71
+
72
+ return pattern === undefined ? undefined : pattern_of(pattern)
73
+ }
74
+
75
+ // A GitHub run's name is also the repository it creates under the account of the token, and the
76
+ // logs of this checkout are not the only place such a run was started from.
77
+ function repositories_of(scenario: Scenario): Array<string> {
78
+ if (!scenario.requires_github) return []
79
+
80
+ const names = git_gh_exec.exec_gh_api_sync({
81
+ path: OWNED_REPOSITORIES,
82
+ should_paginate: true,
83
+ jq_filter: REPOSITORY_NAMES,
84
+ })
85
+
86
+ return names.split('\n').filter((name) => name !== '')
87
+ }
88
+
89
+ function request_of(
90
+ scenario: Scenario,
91
+ values: CliValues,
92
+ secrets: Record<string, string>,
93
+ ): RunRequest {
94
+ const log_directory = path.resolve(PROJECT_ROOT, values['log-dir'] ?? DEFAULT_LOG_DIRECTORY)
95
+
96
+ mkdirSync(log_directory, { recursive: true })
97
+
98
+ const run_name = fresh_machine_log.next_name(scenario.name, {
99
+ logs: readdirSync(log_directory),
100
+ repositories: repositories_of(scenario),
101
+ })
102
+
103
+ return {
104
+ scenario,
105
+ run_name,
106
+ log_path: path.join(log_directory, fresh_machine_log.file_name(run_name)),
107
+ package_version: values['package-version'],
108
+ secrets,
109
+ is_rebuild: values.rebuild === true,
110
+ }
111
+ }
112
+
113
+ function refuse(message: string): number {
114
+ console.error(message)
115
+
116
+ return FAILURE_EXIT_CODE
117
+ }
118
+
119
+ // One secret a scenario asks for: where its value is read on the host, and what to say when there is
120
+ // none. Every one is read before Docker starts, so a missing token stops the run with nothing built.
121
+ interface Secret {
122
+ variable: string
123
+ is_required: (scenario: Scenario) => boolean
124
+ read: () => string | undefined
125
+ missing: string
126
+ }
127
+
128
+ const SECRETS: ReadonlyArray<Secret> = [
129
+ {
130
+ variable: fresh_machine_docker.TOKEN_VARIABLE,
131
+ is_required: (scenario) => scenario.requires_github,
132
+ read: gh_cli_token.get,
133
+ missing: NO_GITHUB_TOKEN,
134
+ },
135
+ {
136
+ variable: claude_token.VARIABLE,
137
+ is_required: (scenario) => scenario.requires_claude,
138
+ read: claude_token.read,
139
+ missing: NO_CLAUDE_TOKEN,
140
+ },
141
+ ]
142
+
143
+ // The values found, by variable name, and the message of the first secret that has none.
144
+ interface Secrets {
145
+ values: Record<string, string>
146
+ missing: string | undefined
147
+ }
148
+
149
+ function secrets_of(scenario: Scenario): Secrets {
150
+ const required = SECRETS.filter((secret) => secret.is_required(scenario))
151
+ const read = required.map((secret) => ({ secret, value: secret.read() }))
152
+ const found = read.flatMap(({ secret, value }) =>
153
+ value === undefined ? [] : [[secret.variable, value] as const],
154
+ )
155
+
156
+ return {
157
+ values: Object.fromEntries(found),
158
+ missing: read.find(({ value }) => value === undefined)?.secret.missing,
159
+ }
160
+ }
161
+
162
+ async function run_scenario(scenario: Scenario, values: CliValues): Promise<number> {
163
+ const secrets = secrets_of(scenario)
164
+
165
+ if (secrets.missing !== undefined) return refuse(secrets.missing)
166
+
167
+ const request = request_of(scenario, values, secrets.values)
168
+ const exit_code = await fresh_machine_run.run(request)
169
+
170
+ console.info(`Log: ${request.log_path}`)
171
+
172
+ return exit_code
173
+ }
174
+
175
+ async function run(argv: ReadonlyArray<string>): Promise<number> {
176
+ const { values, positionals } = parse(argv)
177
+ const patterns = fresh_machine_patterns.names().join('\n')
178
+
179
+ if (values.list === true) {
180
+ console.info(patterns)
181
+
182
+ return 0
183
+ }
184
+
185
+ const scenario = scenario_of(values, positionals[0])
186
+
187
+ if (scenario === undefined) return refuse(`${USAGE}\n\nPatterns:\n${patterns}`)
188
+
189
+ return await run_scenario(scenario, values)
190
+ }
191
+
192
+ async function main(argv: ReadonlyArray<string>): Promise<void> {
193
+ try {
194
+ process.exitCode = await run(argv)
195
+ } catch (error) {
196
+ process.exitCode = refuse(error instanceof Error ? error.message : String(error))
197
+ }
198
+ }
199
+
200
+ const fresh_machine_cli = { run }
201
+
202
+ if (process.argv[1] === fileURLToPath(import.meta.url)) await main(process.argv.slice(ARGV_OFFSET))
203
+
204
+ export { fresh_machine_cli }
@@ -0,0 +1,65 @@
1
+ import { fresh_machine_script } from './fresh-machine-script'
2
+
3
+ // The two docker invocations of `josh fresh:run`, as argument lists. A token is passed by name only
4
+ // (`--env GH_TOKEN`): docker reads its value from the environment of the client, so it is in no
5
+ // argument, no image layer and no printed command.
6
+
7
+ const TOKEN_VARIABLE = 'GH_TOKEN'
8
+ const ENV_FLAG = '--env'
9
+ const VOLUME_FLAG = '--volume'
10
+
11
+ interface ContainerRun {
12
+ image: string
13
+ run_name: string
14
+ script: string
15
+ seed_directory: string
16
+ pack_directory: string
17
+ // The packed checkout's file name inside `pack_directory`, for a tarball scenario.
18
+ tarball: string | undefined
19
+ package_version: string | undefined
20
+ // The variables whose values the client's environment carries into the container.
21
+ secret_names: ReadonlyArray<string>
22
+ }
23
+
24
+ // The Dockerfile arrives on stdin (`-`), so the build has no context to copy a host file from.
25
+ function build_arguments(image: string, is_rebuild: boolean): Array<string> {
26
+ const cache = is_rebuild ? ['--no-cache'] : []
27
+
28
+ return ['build', '--progress', 'plain', ...cache, '--tag', image, '-']
29
+ }
30
+
31
+ function variables(container: ContainerRun): Array<string> {
32
+ const { tarball } = container
33
+ const tarball_path = tarball === undefined ? '' : `${fresh_machine_script.PACK_MOUNT}/${tarball}`
34
+ const assignments = [
35
+ `RUN_NAME=${container.run_name}`,
36
+ `PACKAGE_TARBALL=${tarball_path}`,
37
+ `PACKAGE_VERSION=${container.package_version ?? ''}`,
38
+ ...container.secret_names,
39
+ ]
40
+
41
+ return assignments.flatMap((assignment) => [ENV_FLAG, assignment])
42
+ }
43
+
44
+ // `bash -i` is the new terminal the prerequisites guide asks for: it reads the profile the pnpm
45
+ // installer edited, which a non-interactive shell on Debian returns from before reaching.
46
+ function run_arguments(container: ContainerRun): Array<string> {
47
+ return [
48
+ 'run',
49
+ '--rm',
50
+ ...variables(container),
51
+ VOLUME_FLAG,
52
+ `${container.seed_directory}:${fresh_machine_script.SEED_MOUNT}:ro`,
53
+ VOLUME_FLAG,
54
+ `${container.pack_directory}:${fresh_machine_script.PACK_MOUNT}:ro`,
55
+ container.image,
56
+ 'bash',
57
+ '-ic',
58
+ container.script,
59
+ ]
60
+ }
61
+
62
+ const fresh_machine_docker = { build_arguments, run_arguments, TOKEN_VARIABLE }
63
+
64
+ export { fresh_machine_docker }
65
+ export type { ContainerRun }
@@ -0,0 +1,62 @@
1
+ // The "fresh machine" of `josh fresh:run`: a Debian image with curl and without Node.js, pnpm, git or
2
+ // gh, on which a normal user runs the Linux route of `docs/setup/prerequisites.md` and nothing else.
3
+ // The Dockerfile is built here rather than kept as a file because `docs/` is not in the published
4
+ // package and a consumer toolkit builds the same machine; `fresh-machine-image.test.ts` holds the
5
+ // commands below to the guide's own lines.
6
+
7
+ const BASE_IMAGE = 'buildpack-deps:bookworm-curl'
8
+ const MACHINE_USER = 'dev'
9
+ const HOME_DIRECTORY = `/home/${MACHINE_USER}`
10
+ const TAG_PREFIX = 'josh-fresh-machine'
11
+
12
+ // `docs/setup/prerequisites.md` → "Linux and macOS". The first line runs in the shell a new machine
13
+ // starts with; the guide then says to open a new terminal, which is what `NEW_TERMINAL` does.
14
+ const INSTALL_PNPM = 'curl -fsSL https://get.pnpm.io/install.sh | sh -'
15
+ const NEW_TERMINAL_COMMANDS = ['pnpm runtime set node 22 -g', 'node --version']
16
+
17
+ // The guide sends a Linux reader to the gh installation page; these are that page's Debian steps,
18
+ // run as root in place of `sudo`, with the curl the image already has. git comes from the same
19
+ // package manager, and so does procps: kit's lane commands read processes with its ps and pgrep, which
20
+ // the slim base image does not ship. Only a GitHub scenario's machine has any of them.
21
+ const KEYRING = '/etc/apt/keyrings/githubcli-archive-keyring.gpg'
22
+ const APT_UPDATE = 'apt-get update'
23
+ const GITHUB_TOOLS = [
24
+ APT_UPDATE,
25
+ 'apt-get install -y git procps',
26
+ 'mkdir -p -m 755 /etc/apt/keyrings',
27
+ `curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg -o ${KEYRING}`,
28
+ `chmod go+r ${KEYRING}`,
29
+ `echo "deb [arch=$(dpkg --print-architecture) signed-by=${KEYRING}] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list`,
30
+ APT_UPDATE,
31
+ 'apt-get install -y gh',
32
+ ].join(' && ')
33
+
34
+ function new_terminal(command: string): string {
35
+ return `RUN bash -ic '${command}'`
36
+ }
37
+
38
+ // `SHELL` is the one line the guide does not have: the pnpm installer reads it to pick the profile
39
+ // it edits, and a container build does not set it.
40
+ function dockerfile(requires_github: boolean): string {
41
+ const github_tools = requires_github ? [`RUN ${GITHUB_TOOLS}`] : []
42
+
43
+ return [
44
+ `FROM ${BASE_IMAGE}`,
45
+ ...github_tools,
46
+ `RUN useradd --create-home --shell /bin/bash ${MACHINE_USER}`,
47
+ `USER ${MACHINE_USER}`,
48
+ `WORKDIR ${HOME_DIRECTORY}`,
49
+ 'ENV SHELL=/bin/bash',
50
+ `RUN ${INSTALL_PNPM}`,
51
+ ...NEW_TERMINAL_COMMANDS.map((command) => new_terminal(command)),
52
+ '',
53
+ ].join('\n')
54
+ }
55
+
56
+ function tag(requires_github: boolean): string {
57
+ return `${TAG_PREFIX}:${requires_github ? 'github' : 'base'}`
58
+ }
59
+
60
+ const fresh_machine_image = { dockerfile, tag }
61
+
62
+ export { fresh_machine_image }
@@ -0,0 +1,61 @@
1
+ // The two rules a `josh fresh:run` log follows: a run never reuses a name, and no token passed to
2
+ // the container reaches the file.
3
+
4
+ const LOG_EXTENSION = '.log'
5
+ const FIRST_RUN = 1
6
+
7
+ // What already carries a run's name: the logs of the directory this run writes to and, for a GitHub
8
+ // scenario, the account's repositories — another checkout, or a lane worktree since removed, numbers
9
+ // from its own logs and leaves repositories this directory has no log of.
10
+ interface TakenNames {
11
+ logs: ReadonlyArray<string>
12
+ repositories: ReadonlyArray<string>
13
+ }
14
+
15
+ function logged_names(logs: ReadonlyArray<string>): Array<string> {
16
+ return logs
17
+ .filter((file) => file.endsWith(LOG_EXTENSION))
18
+ .map((file) => file.slice(0, -LOG_EXTENSION.length))
19
+ }
20
+
21
+ function run_number(scenario_name: string, run_name: string): number | undefined {
22
+ const prefix = `${scenario_name}-`
23
+
24
+ if (!run_name.startsWith(prefix)) return undefined
25
+
26
+ const digits = run_name.slice(prefix.length)
27
+
28
+ return /^\d+$/u.test(digits) ? Number(digits) : undefined
29
+ }
30
+
31
+ // The name is the project directory and, for a GitHub scenario, the repository `josh start` creates.
32
+ // It is numbered past every earlier log and repository, so a rerun leaves the last run's repository
33
+ // in place.
34
+ function next_name(scenario_name: string, taken_names: TakenNames): string {
35
+ const taken = [...logged_names(taken_names.logs), ...taken_names.repositories]
36
+ .map((run_name) => run_number(scenario_name, run_name))
37
+ .filter((number) => number !== undefined)
38
+ const next = taken.length === 0 ? FIRST_RUN : Math.max(...taken) + 1
39
+
40
+ return `${scenario_name}-${String(next)}`
41
+ }
42
+
43
+ function file_name(run_name: string): string {
44
+ return `${run_name}${LOG_EXTENSION}`
45
+ }
46
+
47
+ function redact(line: string, secrets: ReadonlyArray<string>): string {
48
+ const present = secrets.filter((secret) => secret !== '')
49
+ let redacted = line
50
+
51
+ for (const secret of present) {
52
+ redacted = redacted.replaceAll(secret, '[REDACTED]')
53
+ }
54
+
55
+ return redacted
56
+ }
57
+
58
+ const fresh_machine_log = { next_name, file_name, redact }
59
+
60
+ export { fresh_machine_log }
61
+ export type { TakenNames }