@joshuafolkken/kit 1.1387.0 → 1.1453.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 (278) 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-park.md +2 -0
  5. package/.claude/skills/workflow-commands/backlogrun-steps.md +13 -10
  6. package/.claude/skills/workflow-commands/backlogrun.md +3 -2
  7. package/.claude/skills/workflow-commands/chain-rule.md +4 -2
  8. package/.claude/skills/workflow-commands/fullrun-steps.md +1 -1
  9. package/.claude/skills/workflow-commands/halfrun.md +1 -1
  10. package/.claude/skills/workflow-commands/issue-comments.md +6 -0
  11. package/.claude/skills/workflow-commands/kickoff.md +12 -13
  12. package/.claude/skills/workflow-commands/observation-filing.md +1 -1
  13. package/.claude/skills/workflow-commands/observation-ledger.md +3 -2
  14. package/.claude/skills/workflow-commands/pre-gate-cut.md +5 -3
  15. package/.claude/skills/workflow-commands/prerequisite.md +6 -0
  16. package/.claude/skills/workflow-commands/retrospective.md +5 -5
  17. package/.claude/skills/workflow-commands/split-assessment.md +2 -2
  18. package/README.md +27 -4
  19. package/dist/hooks/audit-provision.js +2 -2
  20. package/dist/hooks/batch-guard.js +7 -7
  21. package/dist/hooks/{chunk-2YSW3CXH.js → chunk-3C4XRYG6.js} +35 -11
  22. package/dist/hooks/{chunk-GJ7JPIHN.js → chunk-5C7L6FRC.js} +2704 -190
  23. package/dist/hooks/{chunk-FZ6FUSBM.js → chunk-723HKZO5.js} +1 -1
  24. package/dist/hooks/{chunk-STDSEPWU.js → chunk-D3NJXN5M.js} +4 -4
  25. package/dist/hooks/{chunk-NEC4AT4Z.js → chunk-DGT54IH5.js} +4 -0
  26. package/dist/hooks/{chunk-LMWBRJIP.js → chunk-EF5GQ6WS.js} +3 -3
  27. package/dist/hooks/{chunk-JBBMAPC4.js → chunk-IX6TTTI6.js} +4 -0
  28. package/dist/hooks/{chunk-IYVOLN6A.js → chunk-LBFYEOXJ.js} +3 -3
  29. package/dist/hooks/{chunk-SL4GQ2XP.js → chunk-MBJYQPEK.js} +106 -23
  30. package/dist/hooks/{chunk-WNFJLBXI.js → chunk-NO4AALP5.js} +3 -3
  31. package/dist/hooks/{chunk-J3KSS7II.js → chunk-OLMO72ON.js} +1 -1
  32. package/dist/hooks/{chunk-DCY2GWHC.js → chunk-SHYUQOIS.js} +5 -5
  33. package/dist/hooks/{chunk-EAQEGME7.js → chunk-UT7YC3N2.js} +1 -1
  34. package/dist/hooks/{chunk-EBSHN25W.js → chunk-W5ZKGTSY.js} +4 -4
  35. package/dist/hooks/{chunk-GNDJ7NJ7.js → chunk-Y3GKIKGW.js} +209 -56
  36. package/dist/hooks/{chunk-U32VSRNG.js → chunk-Z4XI7CZS.js} +89 -34
  37. package/dist/hooks/codex-hook-adapter.js +15 -15
  38. package/dist/hooks/duplicate-read-guard.js +8 -8
  39. package/dist/hooks/format-edited.js +5 -5
  40. package/dist/hooks/inputs.json +261 -201
  41. package/dist/hooks/investigation-guard.js +8 -8
  42. package/dist/hooks/pretool-guard.js +14 -14
  43. package/dist/hooks/session-lang.js +69 -16
  44. package/dist/hooks/stop-guard.js +13 -38
  45. package/dist/josh.js +95 -19
  46. package/package.json +4 -3
  47. package/prompts/collaboration-workflow/issue-template.md +10 -8
  48. package/prompts/review.md +30 -4
  49. package/scripts/agent/claude-agent-argv.ts +13 -1
  50. package/scripts/auth/auth-setup-cli.ts +101 -0
  51. package/scripts/auth/claude-token-output.ts +78 -0
  52. package/scripts/auth/claude-token.ts +68 -0
  53. package/scripts/auto-ok/auto-ok-judge-cli.ts +125 -0
  54. package/scripts/auto-ok/auto-ok-judge-read.ts +95 -0
  55. package/scripts/auto-ok/auto-ok-judge-write.ts +109 -0
  56. package/scripts/auto-ok/auto-ok-judge.ts +67 -0
  57. package/scripts/backlog/backlog-drive-cli.ts +14 -0
  58. package/scripts/backlog/backlog-drive-epic.ts +24 -8
  59. package/scripts/backlog/backlog-drive-launch.ts +3 -0
  60. package/scripts/backlog/backlog-drive-named-offer.ts +7 -2
  61. package/scripts/backlog/backlog-drive-named.ts +4 -2
  62. package/scripts/backlog/backlog-drive-unread.ts +56 -0
  63. package/scripts/backlog/backlog-drive.ts +38 -11
  64. package/scripts/backlog/backlog-overlap.ts +22 -5
  65. package/scripts/backlog/backlog-ready.ts +1 -3
  66. package/scripts/backlog/backlogrun-args.ts +13 -2
  67. package/scripts/backlog/backlogrun-cli.ts +91 -19
  68. package/scripts/backlog/backlogrun-launch.ts +24 -0
  69. package/scripts/backlog/backlogrun-replace.ts +166 -0
  70. package/scripts/backlog/backlogrun-stop.ts +178 -0
  71. package/scripts/behavior/behavior-cli.ts +6 -1
  72. package/scripts/doctor/doctor-io.ts +60 -9
  73. package/scripts/doctor/doctor.ts +20 -1
  74. package/scripts/document/document-byte-budget.ts +3 -3
  75. package/scripts/epic/epic-bundle-cli.ts +8 -8
  76. package/scripts/epic/epic-bundle-referenced.ts +2 -5
  77. package/scripts/epic/epic-bundle.ts +39 -4
  78. package/scripts/exports/unused-members-cli.ts +2 -1
  79. package/scripts/followup/followup-checks-event.ts +82 -0
  80. package/scripts/followup/git-followup-finish.ts +16 -2
  81. package/scripts/followup/git-followup-workflow.ts +5 -2
  82. package/scripts/followup/git-pr-followup-checks.ts +18 -4
  83. package/scripts/followup/git-pr-followup-wrapup.ts +5 -0
  84. package/scripts/followup/git-pr-followup.ts +4 -1
  85. package/scripts/fresh-machine/fresh-machine-cli.ts +204 -0
  86. package/scripts/fresh-machine/fresh-machine-docker.ts +65 -0
  87. package/scripts/fresh-machine/fresh-machine-image.ts +62 -0
  88. package/scripts/fresh-machine/fresh-machine-log.ts +61 -0
  89. package/scripts/fresh-machine/fresh-machine-patterns.ts +175 -0
  90. package/scripts/fresh-machine/fresh-machine-run.ts +215 -0
  91. package/scripts/fresh-machine/fresh-machine-script.ts +45 -0
  92. package/scripts/fresh-machine/fresh-machine-spec.ts +57 -0
  93. package/scripts/fresh-machine/specs/kit-test-backlogrun-python.json +34 -0
  94. package/scripts/fresh-machine/specs/kit-test-backlogrun.json +31 -0
  95. package/scripts/gate/gate-report.ts +70 -15
  96. package/scripts/gate/project-checks.ts +18 -4
  97. package/scripts/gate/scoped-green.ts +2 -1
  98. package/scripts/gate/verification-gate.ts +4 -3
  99. package/scripts/gh/gh-author-association.ts +14 -0
  100. package/scripts/gh/git-gh-conditional.ts +66 -0
  101. package/scripts/gh/git-gh-exec.ts +1 -1
  102. package/scripts/gh/git-gh-issue-read.ts +6 -3
  103. package/scripts/gh/git-gh-issue-rest.ts +1 -0
  104. package/scripts/gh/git-gh-issue-write.ts +5 -1
  105. package/scripts/gh/git-gh-issue.ts +13 -1
  106. package/scripts/gh/git-pr-checks-eval.ts +13 -0
  107. package/scripts/gh/git-pr-checks-parse.ts +8 -1
  108. package/scripts/gh/git-pr-checks-watch.ts +11 -4
  109. package/scripts/gh/git-pr-checks.ts +36 -2
  110. package/scripts/git/git-command.ts +16 -4
  111. package/scripts/git/git-schemas.ts +1 -0
  112. package/scripts/git/git-spawn-sync.ts +1 -1
  113. package/scripts/hooks/pretool-guard.ts +3 -1
  114. package/scripts/init/hook-command-rewrite.ts +31 -0
  115. package/scripts/init/init-actions.ts +7 -4
  116. package/scripts/init/init-ai-copy.ts +34 -11
  117. package/scripts/init/init-basic.ts +9 -4
  118. package/scripts/init/init-bootstrap.ts +6 -2
  119. package/scripts/init/init-dry-run-report.ts +150 -0
  120. package/scripts/init/init-dry-run.ts +189 -0
  121. package/scripts/init/init-install.ts +8 -1
  122. package/scripts/init/init-logic-toolchain.ts +76 -0
  123. package/scripts/init/init-logic.ts +2 -0
  124. package/scripts/init/init.ts +62 -42
  125. package/scripts/init/kit-setup-state.ts +11 -17
  126. package/scripts/init/own-formatter.ts +21 -0
  127. package/scripts/init/project-profile.ts +60 -12
  128. package/scripts/init/start-dry-run.ts +70 -0
  129. package/scripts/init/start-exec.ts +27 -1
  130. package/scripts/init/start-plan.ts +23 -3
  131. package/scripts/init/start-setup-pr.ts +13 -24
  132. package/scripts/init/start-steps.ts +62 -1
  133. package/scripts/init/start.ts +38 -17
  134. package/scripts/init/workflow-mark.ts +35 -0
  135. package/scripts/init/working-tree.ts +116 -0
  136. package/scripts/issue/issue-ack-cli.ts +84 -0
  137. package/scripts/issue/issue-auto-ok.ts +68 -99
  138. package/scripts/issue/issue-file-cli.ts +17 -17
  139. package/scripts/issue/issue-file.ts +1 -6
  140. package/scripts/issue/issue-labels.ts +46 -10
  141. package/scripts/issue/issue-release-cli.ts +69 -23
  142. package/scripts/issue/issue-release.ts +3 -1
  143. package/scripts/issue/issue-state-cli.ts +14 -4
  144. package/scripts/issue/issue-watch-diff.ts +152 -0
  145. package/scripts/issue/issue-watch-gate.ts +81 -0
  146. package/scripts/issue/issue-watch-hook.ts +54 -0
  147. package/scripts/issue/issue-watch.ts +240 -0
  148. package/scripts/issue/measure-rerun-cli.ts +3 -4
  149. package/scripts/josh/josh-command-map.ts +4 -0
  150. package/scripts/josh/josh-commands-ai.ts +11 -0
  151. package/scripts/josh/josh-commands-backlog.ts +2 -2
  152. package/scripts/josh/josh-commands-development.ts +1 -1
  153. package/scripts/josh/josh-commands-issue.ts +10 -3
  154. package/scripts/josh/josh-commands-lane.ts +11 -2
  155. package/scripts/josh/josh-commands-project.ts +26 -2
  156. package/scripts/josh/josh-verdict.ts +33 -4
  157. package/scripts/josh/process-identity.ts +32 -6
  158. package/scripts/lane/claude-lane-broker.ts +142 -0
  159. package/scripts/lane/lane-await.ts +6 -2
  160. package/scripts/lane/lane-broker-server.ts +69 -0
  161. package/scripts/lane/lane-cache.ts +37 -7
  162. package/scripts/lane/lane-capacity.ts +8 -6
  163. package/scripts/lane/lane-child-invocation.ts +1 -0
  164. package/scripts/lane/lane-cli.ts +15 -1
  165. package/scripts/lane/lane-close.ts +15 -6
  166. package/scripts/lane/lane-dispatch.ts +36 -10
  167. package/scripts/lane/lane-environment.ts +1 -1
  168. package/scripts/lane/lane-launch-cli.ts +13 -4
  169. package/scripts/lane/lane-ledger.ts +21 -3
  170. package/scripts/lane/lane-measure-cli.ts +10 -2
  171. package/scripts/lane/lane-open.ts +9 -1
  172. package/scripts/lane/lane-preserve.ts +49 -0
  173. package/scripts/lane/lane-reap.ts +20 -5
  174. package/scripts/lane/lane-registry.ts +6 -0
  175. package/scripts/lane/lane-restart-cli.ts +51 -0
  176. package/scripts/lane/lane-restart.ts +175 -0
  177. package/scripts/lane/lane-session-broker.ts +363 -0
  178. package/scripts/lane/lane-spent.ts +195 -0
  179. package/scripts/lane/lane-stage-stats.ts +27 -3
  180. package/scripts/lane/lane-stop-stats.ts +96 -0
  181. package/scripts/lane/openai-lane-supervisor.ts +2 -2
  182. package/scripts/lane/process-tools.ts +31 -0
  183. package/scripts/lib/buffered-process.ts +36 -2
  184. package/scripts/lib/pnpm-reach.ts +27 -0
  185. package/scripts/lib/status-icons.ts +5 -1
  186. package/scripts/lint/lint-parallel.ts +19 -2
  187. package/scripts/observations/observation-ledger-home.ts +1 -0
  188. package/scripts/observations/observation-ledger-migrate.ts +3 -15
  189. package/scripts/release/release-issue-close.ts +133 -0
  190. package/scripts/release/release-publish.ts +12 -1
  191. package/scripts/repo/repository-labels.ts +11 -1
  192. package/scripts/review/comment-ranges.ts +199 -0
  193. package/scripts/review/review-attest-cli.ts +21 -6
  194. package/scripts/review/review-brief-cli.ts +24 -7
  195. package/scripts/review/review-code-tree.ts +119 -0
  196. package/scripts/review/review-comment-round.ts +114 -0
  197. package/scripts/review/review-record-cli.ts +39 -17
  198. package/scripts/review/review-round2-cli.ts +16 -5
  199. package/scripts/review/review-round2.ts +30 -10
  200. package/scripts/review/review-stamps.ts +22 -1
  201. package/scripts/rules/implementation-cut.ts +3 -3
  202. package/scripts/rules/pre-gate-cut.ts +1 -1
  203. package/scripts/run/board/run-board-checks.ts +42 -0
  204. package/scripts/run/board/run-board-cli.ts +2 -0
  205. package/scripts/run/board/run-board-every.ts +3 -1
  206. package/scripts/run/board/run-board-fit.ts +16 -5
  207. package/scripts/run/board/run-board-fresh.ts +64 -0
  208. package/scripts/run/board/run-board-header.ts +129 -53
  209. package/scripts/run/board/run-board-labels.ts +47 -4
  210. package/scripts/run/board/run-board-lane-event.ts +66 -0
  211. package/scripts/run/board/run-board-layout.ts +57 -9
  212. package/scripts/run/board/run-board-now.ts +234 -0
  213. package/scripts/run/board/run-board-operation-read.ts +75 -0
  214. package/scripts/run/board/run-board-operation.ts +150 -0
  215. package/scripts/run/board/run-board-phase.ts +26 -1
  216. package/scripts/run/board/run-board-plan.ts +4 -6
  217. package/scripts/run/board/run-board-read.ts +49 -3
  218. package/scripts/run/board/run-board-reason.ts +124 -0
  219. package/scripts/run/board/run-board-render-checks.ts +55 -0
  220. package/scripts/run/board/run-board-render-legend.ts +12 -3
  221. package/scripts/run/board/run-board-render-slot.ts +2 -2
  222. package/scripts/run/board/run-board-render-tail.ts +128 -0
  223. package/scripts/run/board/run-board-render-thanks.ts +50 -0
  224. package/scripts/run/board/run-board-render-title.ts +58 -0
  225. package/scripts/run/board/run-board-render.ts +96 -80
  226. package/scripts/run/board/run-board-row-yield.ts +32 -0
  227. package/scripts/run/board/run-board-setup.ts +61 -13
  228. package/scripts/run/board/run-board-state.ts +10 -0
  229. package/scripts/run/board/run-board-status.ts +66 -7
  230. package/scripts/run/board/run-board-thanks-csv.ts +71 -0
  231. package/scripts/run/board/run-board-thanks-read.ts +112 -0
  232. package/scripts/run/board/run-board-thanks.ts +53 -0
  233. package/scripts/run/board/run-board-tick.ts +50 -21
  234. package/scripts/run/board/run-board-usual.ts +44 -0
  235. package/scripts/run/carry/run-carry-args.ts +9 -1
  236. package/scripts/run/carry/run-carry-cli.ts +2 -45
  237. package/scripts/run/carry/run-carry-end.ts +73 -0
  238. package/scripts/run/carry/run-carry.ts +8 -1
  239. package/scripts/run/entry/run-entry-cli.ts +5 -0
  240. package/scripts/run/event/run-event-checks.ts +54 -0
  241. package/scripts/run/event/run-event-render.ts +2 -0
  242. package/scripts/run/event/run-event-scope.ts +1 -0
  243. package/scripts/run/event/run-event-setup.ts +55 -11
  244. package/scripts/run/event/run-event-stream.ts +27 -11
  245. package/scripts/run/merge/run-merge-cli.ts +7 -4
  246. package/scripts/run/merge/run-merge-ship.ts +61 -0
  247. package/scripts/run/merge/run-merge-steps.ts +7 -14
  248. package/scripts/run/merge/run-merge-token.ts +16 -2
  249. package/scripts/run/run-headless.ts +1 -0
  250. package/scripts/run/run-step.ts +2 -0
  251. package/scripts/run/ship/run-ship-cli.ts +22 -2
  252. package/scripts/run/ship/run-ship-return.ts +31 -13
  253. package/scripts/run/ship/run-ship-review-steps.ts +17 -4
  254. package/scripts/run/ship/run-ship-stage.ts +1 -0
  255. package/scripts/run/wake/run-wake-await.ts +130 -0
  256. package/scripts/run/wake/run-wake-cli.ts +60 -71
  257. package/scripts/run/wake/run-wake-describe.ts +34 -2
  258. package/scripts/run/wake/run-wake-driver.ts +11 -4
  259. package/scripts/run/wake/run-wake-session.ts +20 -3
  260. package/scripts/run/wake/run-wake-stop.ts +37 -0
  261. package/scripts/run/wake/run-wake-supervise.ts +44 -0
  262. package/scripts/sync/claude-settings-merge.ts +144 -0
  263. package/scripts/sync/sync-ai-files.ts +28 -3
  264. package/scripts/sync/sync.ts +15 -0
  265. package/scripts/test/project-test-script.ts +73 -0
  266. package/scripts/test/skip-marker.ts +14 -1
  267. package/scripts/test/test-related.ts +3 -2
  268. package/scripts/test/test-unit-guard.ts +78 -14
  269. package/scripts/time-runtime/time-reported-failure.ts +11 -3
  270. package/scripts/uninstall/distributed-files.ts +92 -0
  271. package/scripts/uninstall/uninstall-plan.ts +119 -0
  272. package/scripts/uninstall/uninstall-report.ts +61 -0
  273. package/scripts/uninstall/uninstall.ts +206 -0
  274. package/templates/AGENTS.basic.md +4 -0
  275. package/templates/GEMINI.basic.md +6 -0
  276. package/dist/hooks/chunk-6V5OPIUU.js +0 -13
  277. package/scripts/lane/openai-review-broker.ts +0 -262
  278. package/scripts/run/board/run-board-track.ts +0 -22
@@ -18,16 +18,21 @@ import { session_cite } from './session-cite'
18
18
  // --release` calls the same `link` once its Issue exists, so a new and an existing Issue are linked
19
19
  // by one body of code.
20
20
  //
21
+ // `--wait` points the edge the other way: `<N>` is blocked by the release Issue, so a run that needs
22
+ // the published version records a dependency rather than a `needs-decision` park, and the backlog
23
+ // offers `<N>` again once a person closes the release Issue (joshuafolkken/kit#3824).
24
+ //
21
25
  // The relation is written through `issue_add_blocked_by`, which addresses the repository `gh`
22
26
  // resolves — the current one, or the target a cross-repository `issue:file` has pointed `GH_REPO` at.
23
27
 
24
28
  const SUCCESS_EXIT_CODE = 0
25
29
  const FAILURE_EXIT_CODE = 1
26
30
  const ARGV_OFFSET = 2
27
- const USAGE = 'Usage: josh issue:release <issue-number>'
31
+ const USAGE = 'Usage: josh issue:release <issue-number> [--wait]'
28
32
  const NONE = 'none'
29
33
  const UNKNOWN_REPO_MESSAGE =
30
34
  'Could not read this repository from `git remote` — check `gh auth status`.'
35
+ const OPTIONS = { wait: { type: 'boolean' } } as const
31
36
 
32
37
  interface ReleaseIssue {
33
38
  issue_number: number
@@ -35,7 +40,20 @@ interface ReleaseIssue {
35
40
  is_filed: boolean
36
41
  }
37
42
 
43
+ interface ReleaseRequest {
44
+ issue_number: number
45
+ // `<N>` waits on the release Issue, rather than blocking it.
46
+ is_wait: boolean
47
+ }
48
+
49
+ // The two ends of one `blocked_by` edge.
50
+ interface Edge {
51
+ blocked: number
52
+ blocker: number
53
+ }
54
+
38
55
  // `undefined` when the listing is unreadable, `NONE` when it reads and names no open release Issue.
56
+ // `pnpm josh release` asks the same question to close the Issue it has just published.
39
57
  async function find_open(target: string): Promise<number | typeof NONE | undefined> {
40
58
  const outcome = await git_gh_issue_list.issue_list({
41
59
  json_fields: 'number',
@@ -91,41 +109,59 @@ async function release_of(target: string): Promise<ReleaseIssue | undefined> {
91
109
  return filed === undefined ? undefined : { issue_number: filed, is_filed: true }
92
110
  }
93
111
 
94
- function linked_line(release: ReleaseIssue, issue_number: number, target: string): string {
112
+ function release_label_of(release: ReleaseIssue, target: string): string {
95
113
  const how = release.is_filed ? 'filed' : 'open'
96
- const release_cite = session_cite.issue(release.issue_number, undefined, target)
97
114
 
98
- return `release: ${release_cite} (${how}) is blocked by ${session_cite.issue(issue_number, undefined, target)}`
115
+ return `${session_cite.issue(release.issue_number, undefined, target)} (${how})`
99
116
  }
100
117
 
101
- function unlinked_line(issue_number: number, blocked: string, target: string): string {
102
- const blocker = session_cite.issue(issue_number, undefined, target)
118
+ function linked_line(release: ReleaseIssue, request: ReleaseRequest, target: string): string {
119
+ const release_label = release_label_of(release, target)
120
+ const issue_label = session_cite.issue(request.issue_number, undefined, target)
103
121
 
104
- return `✖ ${blocker} could not be recorded as a blocker of ${session_cite.issue(blocked, undefined, target)}`
122
+ return request.is_wait
123
+ ? `release: ${issue_label} waits on ${release_label}`
124
+ : `release: ${release_label} is blocked by ${issue_label}`
105
125
  }
106
126
 
107
- // Whether Issue `issue_number` of `target` now blocks the release Issue. Every outcome is printed.
108
- async function link(issue_number: number, target: string): Promise<boolean> {
127
+ function unlinked_line(edge: Edge, target: string): string {
128
+ const blocker = session_cite.issue(edge.blocker, undefined, target)
129
+
130
+ return `✖ ${blocker} could not be recorded as a blocker of ${session_cite.issue(edge.blocked, undefined, target)}`
131
+ }
132
+
133
+ function edge_of(release: ReleaseIssue, request: ReleaseRequest): Edge {
134
+ return request.is_wait
135
+ ? { blocked: request.issue_number, blocker: release.issue_number }
136
+ : { blocked: release.issue_number, blocker: request.issue_number }
137
+ }
138
+
139
+ // Whether the edge between Issue `<N>` of `target` and the release Issue is now recorded. Every
140
+ // outcome is printed.
141
+ async function record(request: ReleaseRequest, target: string): Promise<boolean> {
109
142
  const release = await release_of(target)
110
143
 
111
144
  if (release === undefined) return false
112
- const blocked = String(release.issue_number)
113
- const is_linked = await git_gh_issue_write.issue_add_blocked_by(blocked, String(issue_number))
145
+ const edge = edge_of(release, request)
146
+ const is_linked = await git_gh_issue_write.issue_add_blocked_by(
147
+ String(edge.blocked),
148
+ String(edge.blocker),
149
+ )
114
150
 
115
- if (is_linked) console.info(linked_line(release, issue_number, target))
116
- else console.error(unlinked_line(issue_number, blocked, target))
151
+ if (is_linked) console.info(linked_line(release, request, target))
152
+ else console.error(unlinked_line(edge, target))
117
153
 
118
154
  return is_linked
119
155
  }
120
156
 
121
- // An unknown flag parses to no positionals, so it refuses the call like a missing number.
122
- function positionals_of(argv: ReadonlyArray<string>): ReadonlyArray<string> {
123
- return cli_flags.arguments_of(argv, {})?.positionals ?? []
157
+ // Whether Issue `issue_number` of `target` now blocks the release Issue.
158
+ async function link(issue_number: number, target: string): Promise<boolean> {
159
+ return await record({ issue_number, is_wait: false }, target)
124
160
  }
125
161
 
126
162
  // The number is the one positional: a missing, malformed or second one refuses the whole call.
127
- function issue_number_of(argv: ReadonlyArray<string>): number | undefined {
128
- const [issue_number, ...rest] = positionals_of(argv)
163
+ function issue_number_of(positionals: ReadonlyArray<string>): number | undefined {
164
+ const [issue_number, ...rest] = positionals
129
165
  const is_single = rest.length === 0 && issue_number !== undefined
130
166
 
131
167
  return is_single && issue_number_shape.ISSUE_NUMBER_PATTERN.test(issue_number)
@@ -133,10 +169,20 @@ function issue_number_of(argv: ReadonlyArray<string>): number | undefined {
133
169
  : undefined
134
170
  }
135
171
 
172
+ // An unknown flag parses to no request at all, so it refuses the call like a missing number.
173
+ function request_of(argv: ReadonlyArray<string>): ReleaseRequest | undefined {
174
+ const parsed = cli_flags.arguments_of(argv, OPTIONS)
175
+ const issue_number = issue_number_of(parsed?.positionals ?? [])
176
+
177
+ if (parsed === undefined || issue_number === undefined) return undefined
178
+
179
+ return { issue_number, is_wait: parsed.values.wait === true }
180
+ }
181
+
136
182
  async function run(argv: ReadonlyArray<string>): Promise<number> {
137
- const issue_number = issue_number_of(argv)
183
+ const request = request_of(argv)
138
184
 
139
- if (issue_number === undefined) {
185
+ if (request === undefined) {
140
186
  console.error(USAGE)
141
187
 
142
188
  return FAILURE_EXIT_CODE
@@ -150,7 +196,7 @@ async function run(argv: ReadonlyArray<string>): Promise<number> {
150
196
  return FAILURE_EXIT_CODE
151
197
  }
152
198
 
153
- return (await link(issue_number, current)) ? SUCCESS_EXIT_CODE : FAILURE_EXIT_CODE
199
+ return (await record(request, current)) ? SUCCESS_EXIT_CODE : FAILURE_EXIT_CODE
154
200
  }
155
201
 
156
202
  // `process.exitCode` rather than `process.exit()`, so the report on standard output drains first.
@@ -158,8 +204,8 @@ async function main(argv: ReadonlyArray<string>): Promise<void> {
158
204
  process.exitCode = await run(argv)
159
205
  }
160
206
 
161
- const issue_release_cli = { link, run }
207
+ const issue_release_cli = { find_open, link, run }
162
208
 
163
209
  if (process.argv[1] === fileURLToPath(import.meta.url)) await main(process.argv.slice(ARGV_OFFSET))
164
210
 
165
- export { issue_release_cli }
211
+ export { issue_release_cli, NONE }
@@ -42,7 +42,9 @@ function body_of(repo: string): string {
42
42
  '',
43
43
  'Every Issue this one is blocked by changes something a consumer can use only once it is published. Once they have merged, a person runs `pnpm josh release` in the primary checkout.',
44
44
  '',
45
- 'Close this Issue once that release is published: the next blocker files a new one, so an open release Issue names only unreleased work.',
45
+ '`pnpm josh release` closes this Issue once that release is published: the next blocker files a new one, so an open release Issue names only unreleased work.',
46
+ '',
47
+ 'Every Issue this one blocks needs the published version (`pnpm josh issue:release <N> --wait`); closing this Issue hands each of them back to the backlog.',
46
48
  '',
47
49
  '`auto-ok` is never applied to this Issue by a run; a person may apply it.',
48
50
  '',
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { fileURLToPath } from 'node:url'
3
3
  import { git_gh_command } from '#scripts/gh/git-gh-command'
4
+ import type { IssueReadReason } from '#scripts/gh/git-gh-issue-read'
4
5
  import { issue_number_shape } from '#scripts/issue/issue-number-shape'
5
6
  import { bounded_pool } from '#scripts/lib/bounded-pool'
6
7
  import { cli_flags } from '#scripts/lib/cli-flags'
7
- import { issue_report_failures, type ReadFailureKind } from './issue-report-failures'
8
+ import { issue_report_failures } from './issue-report-failures'
8
9
  import { issue_state, type IssueState } from './issue-state'
9
10
 
10
11
  // `josh issue:state <N> [<N> ...]` — print each issue's state and labels, in the spelling the
@@ -50,7 +51,14 @@ interface StateRequest {
50
51
  // One issue's state, or the failure kind. Named for export because `run:prep` reads the state beside
51
52
  // the body and the dependency-update scope, reusing this read rather than a
52
53
  // second one that would drift from the `human_review` decision `parse_issue_state` owns.
53
- type StateRead = { kind: 'state'; state: IssueState } | { kind: ReadFailureKind }
54
+ //
55
+ // **Why a read failed rides along where the request said it**: `backlog:drive` names the reason when it
56
+ // hands an unreadable named issue back. Both are optional, so a caller that only separates the kinds
57
+ // needs no edit.
58
+ type StateRead =
59
+ | { kind: 'state'; state: IssueState }
60
+ | { kind: 'missing' }
61
+ | { kind: 'unreadable'; reason?: IssueReadReason | undefined; status?: number | undefined }
54
62
 
55
63
  interface IssueReport {
56
64
  issue_number: string
@@ -102,11 +110,13 @@ function parse_request(argv: ReadonlyArray<string>): StateRequest | undefined {
102
110
  async function read_issue(issue_number: string, repo?: string): Promise<StateRead> {
103
111
  const read = await git_gh_command.issue_view_json_classified(issue_number, STATE_FIELDS, repo)
104
112
 
105
- if (read.kind !== 'read') return { kind: read.kind }
113
+ if (read.kind !== 'read') return read
106
114
 
107
115
  const parsed = issue_state.parse_issue_state(read.json)
108
116
 
109
- return parsed === undefined ? { kind: 'unreadable' } : { kind: 'state', state: parsed }
117
+ return parsed === undefined
118
+ ? { kind: 'unreadable', reason: 'malformed' }
119
+ : { kind: 'state', state: parsed }
110
120
  }
111
121
 
112
122
  // Through the shared pool rather than a raw `Promise.all`: each read spawns a `gh` process, and a
@@ -0,0 +1,152 @@
1
+ import { read_json_listing } from '#scripts/git/parse-json-array'
2
+ import { z } from 'zod'
3
+
4
+ // What a person changed on an issue since a run last read it, as the one pure answer the hook, the
5
+ // review attestation, `josh issue:ack` and `josh followup` all print. Kept free of I/O so the four
6
+ // callers cannot each draw the line between "a person's update" and "noise" differently.
7
+ //
8
+ // **A person's update is a body edit or a comment nobody automated.** A bot's comment (`type: Bot`,
9
+ // or a `[bot]` login) is not one. A run's own comment is posted under the person's own `gh` login,
10
+ // so the login cannot tell the two apart; `git-gh-issue-write.ts` stamps every comment josh posts
11
+ // with `RUN_MARKER`, and a marked comment is not one either. An unmarked comment written by hand
12
+ // through a raw `gh api` counts as a person's — the direction that costs one `josh issue:ack`, never a
13
+ // missed correction.
14
+ //
15
+ // **Comment edits are out of scope.** A new comment carries an id above the baseline's; an edited
16
+ // one keeps its id and is not seen. The body is compared whole, so its edits are.
17
+
18
+ const RUN_MARKER = '<!-- josh:run -->'
19
+ const BOT_TYPE = 'Bot'
20
+ const BOT_SUFFIX = '[bot]'
21
+ const NO_COMMENT_ID = 0
22
+ const UNKNOWN_AUTHOR = '(unknown)'
23
+ const UNKNOWN_TIME = '(undated)'
24
+
25
+ const BODY_HEADING = 'Body edited:'
26
+ const REMOVED_PREFIX = '- '
27
+ const ADDED_PREFIX = '+ '
28
+ const COMMENT_HEADING = 'New comment by '
29
+ const AT = ' at '
30
+ const SECTION_SEPARATOR = '\n\n'
31
+ const LINE_SEPARATOR = '\n'
32
+ const CRLF = '\r\n'
33
+
34
+ const comment_schema = z.looseObject({
35
+ id: z.number(),
36
+ user: z.looseObject({ login: z.string(), type: z.string().optional() }).nullish(),
37
+ created_at: z.string().optional(),
38
+ body: z.string().nullish(),
39
+ })
40
+
41
+ interface WatchComment {
42
+ id: number
43
+ author: string
44
+ is_automated: boolean
45
+ created_at: string
46
+ body: string
47
+ }
48
+
49
+ interface WatchSnapshot {
50
+ body: string
51
+ comments: ReadonlyArray<WatchComment>
52
+ }
53
+
54
+ interface WatchBaseline {
55
+ body: string
56
+ last_comment_id: number
57
+ }
58
+
59
+ function is_bot(user: z.infer<typeof comment_schema>['user']): boolean {
60
+ return user?.type === BOT_TYPE || (user?.login.endsWith(BOT_SUFFIX) ?? false)
61
+ }
62
+
63
+ function is_automated(user: z.infer<typeof comment_schema>['user'], body: string): boolean {
64
+ return is_bot(user) || body.includes(RUN_MARKER)
65
+ }
66
+
67
+ function to_comment(raw: z.infer<typeof comment_schema>): WatchComment {
68
+ const body = raw.body ?? ''
69
+ const author = raw.user?.login ?? UNKNOWN_AUTHOR
70
+ const created_at = raw.created_at ?? UNKNOWN_TIME
71
+
72
+ return { id: raw.id, author, is_automated: is_automated(raw.user, body), created_at, body }
73
+ }
74
+
75
+ // `undefined` — never `[]` — for a listing that could not be read, so a failed read is never taken
76
+ // for "nobody commented".
77
+ function parse_comments(json: string | undefined): ReadonlyArray<WatchComment> | undefined {
78
+ if (json === undefined) return undefined
79
+
80
+ const listing = read_json_listing(json, comment_schema)
81
+
82
+ return listing.kind === 'read' ? listing.rows.map((row) => to_comment(row)) : undefined
83
+ }
84
+
85
+ function last_comment_id(comments: ReadonlyArray<WatchComment>): number {
86
+ return Math.max(NO_COMMENT_ID, ...comments.map((comment) => comment.id))
87
+ }
88
+
89
+ function baseline_of(snapshot: WatchSnapshot): WatchBaseline {
90
+ return { body: snapshot.body, last_comment_id: last_comment_id(snapshot.comments) }
91
+ }
92
+
93
+ function lines_missing_from(source: string, other: string): Array<string> {
94
+ const present = new Set(other.split(LINE_SEPARATOR))
95
+
96
+ return source.split(LINE_SEPARATOR).filter((line) => line.trim() !== '' && !present.has(line))
97
+ }
98
+
99
+ // GitHub stores a body saved in its web editor with CRLF line endings, so one edited line to a body
100
+ // josh wrote over the API with LF would otherwise read as every line removed and added.
101
+ function to_lf(body: string): string {
102
+ return body.split(CRLF).join(LINE_SEPARATOR)
103
+ }
104
+
105
+ // The lines that left and the lines that arrived — not a positional diff, which would need a
106
+ // dependency in a hook that runs on every tool call, and a reader comparing a spec only needs which
107
+ // sentences changed.
108
+ function body_section(previous_raw: string, current_raw: string): string | undefined {
109
+ const previous = to_lf(previous_raw)
110
+ const current = to_lf(current_raw)
111
+
112
+ if (previous === current) return undefined
113
+
114
+ const removed = lines_missing_from(previous, current).map((line) => `${REMOVED_PREFIX}${line}`)
115
+ const added = lines_missing_from(current, previous).map((line) => `${ADDED_PREFIX}${line}`)
116
+
117
+ return [BODY_HEADING, ...removed, ...added].join(LINE_SEPARATOR)
118
+ }
119
+
120
+ function comment_section(comment: WatchComment): string {
121
+ return `${COMMENT_HEADING}${comment.author}${AT}${comment.created_at}${LINE_SEPARATOR}${comment.body}`
122
+ }
123
+
124
+ function new_person_comments(
125
+ baseline: WatchBaseline,
126
+ snapshot: WatchSnapshot,
127
+ ): ReadonlyArray<WatchComment> {
128
+ return snapshot.comments.filter(
129
+ (comment) => comment.id > baseline.last_comment_id && !comment.is_automated,
130
+ )
131
+ }
132
+
133
+ // The unread update as the text a run is shown, or `undefined` when a person changed nothing.
134
+ function unread_diff(baseline: WatchBaseline, snapshot: WatchSnapshot): string | undefined {
135
+ const body = body_section(baseline.body, snapshot.body)
136
+ const comments = new_person_comments(baseline, snapshot).map((comment) =>
137
+ comment_section(comment),
138
+ )
139
+ const sections = [...(body === undefined ? [] : [body]), ...comments]
140
+
141
+ return sections.length === 0 ? undefined : sections.join(SECTION_SEPARATOR)
142
+ }
143
+
144
+ const issue_watch_diff = {
145
+ RUN_MARKER,
146
+ baseline_of,
147
+ parse_comments,
148
+ unread_diff,
149
+ }
150
+
151
+ export type { WatchBaseline, WatchComment, WatchSnapshot }
152
+ export { issue_watch_diff }
@@ -0,0 +1,81 @@
1
+ import { git_command } from '#scripts/git/git-command'
2
+ import { session_cite } from '#scripts/issue/session-cite'
3
+ import { review_attest } from '#scripts/review/review-attest'
4
+ import { issue_watch, type WatchRecord } from './issue-watch'
5
+
6
+ // The refusals a person's unread issue update raises, composed once so the hook, `josh review:attest
7
+ // --check` and `josh followup` say the same thing about the same state.
8
+ //
9
+ // **The gates poll on every call** (`interval_ms: 0`): they run a handful of times per run, and the
10
+ // poll is a conditional request that a 304 answers for free. Only the hook, which runs on every tool
11
+ // call, spaces its polls out.
12
+
13
+ const EVERY_CALL_MS = 0
14
+
15
+ function ack_command(issue: string): string {
16
+ return `pnpm josh issue:ack ${issue}`
17
+ }
18
+
19
+ function unread_message(record: Pick<WatchRecord, 'issue' | 'pending'>): string {
20
+ return [
21
+ `A person updated issue ${session_cite.issue(record.issue)} after this run read it, and the run has not taken the update in:`,
22
+ record.pending ?? '',
23
+ `Read it, compare it with the acceptance criteria, fix what it changes, then run \`${ack_command(record.issue)}\` to record that it was read.`,
24
+ ].join('\n\n')
25
+ }
26
+
27
+ function review_message(record: WatchRecord, briefed_at: string): string {
28
+ return [
29
+ `Issue ${session_cite.issue(record.issue)} was updated after the review was briefed (taken in at ${record.acknowledged_at}, briefed at ${briefed_at}), so the review read the earlier specification.`,
30
+ 'Re-run `/code-review` with a fresh `pnpm josh review:brief`, so the review reads the change against the updated issue.',
31
+ ].join('\n')
32
+ }
33
+
34
+ async function polled(root: string): Promise<WatchRecord | undefined> {
35
+ return await issue_watch.poll({ interval_ms: EVERY_CALL_MS }, root)
36
+ }
37
+
38
+ // The merge-time refusal: a person's update the run has not acknowledged. `undefined` when there is
39
+ // none, when no run recorded a baseline in this checkout, or when the issue could not be read — an
40
+ // unreachable GitHub does not hold a merge `josh followup` would otherwise make.
41
+ async function unread_refusal(root: string): Promise<string | undefined> {
42
+ const record = await polled(root)
43
+
44
+ return record?.pending === undefined ? undefined : unread_message(record)
45
+ }
46
+
47
+ // The review-time refusal: an unread update, or one taken in after the review was briefed — the fix
48
+ // it led to is then a change the review never saw.
49
+ async function review_refusal(root: string): Promise<string | undefined> {
50
+ const record = await polled(root)
51
+
52
+ if (record === undefined) return undefined
53
+ if (record.pending !== undefined) return unread_message(record)
54
+
55
+ const pointer = review_attest.read_pointer(root)
56
+
57
+ if (pointer === undefined || record.acknowledged_at <= pointer.taken_at) return undefined
58
+
59
+ return review_message(record, pointer.taken_at)
60
+ }
61
+
62
+ async function review_refusal_here(): Promise<string | undefined> {
63
+ return await review_refusal(await git_command.repository_root())
64
+ }
65
+
66
+ // Throws the refusal, for the `josh followup` seams that stop by throwing.
67
+ async function assert_read_here(): Promise<void> {
68
+ const refusal = await unread_refusal(await git_command.repository_root())
69
+
70
+ if (refusal !== undefined) throw new Error(refusal)
71
+ }
72
+
73
+ const issue_watch_gate = {
74
+ assert_read_here,
75
+ review_refusal,
76
+ review_refusal_here,
77
+ unread_message,
78
+ unread_refusal,
79
+ }
80
+
81
+ export { issue_watch_gate }
@@ -0,0 +1,54 @@
1
+ import { error_text } from '#scripts/lib/error-message'
2
+ import { issue_watch, type WatchRecord } from './issue-watch'
3
+ import { issue_watch_gate } from './issue-watch-gate'
4
+
5
+ // The `PreToolUse` adapter that shows a run a person's update to its issue on the next tool call,
6
+ // composed into `pretool-guard.ts` — the one PreToolUse hook — rather than added as a second
7
+ // `.claude/settings.json` entry.
8
+ //
9
+ // **It polls at most once per interval.** Every tool call reads the checkout's record from disk; only
10
+ // one whose last poll is older than `POLL_INTERVAL_MS` asks GitHub, with the ETag of the previous
11
+ // answer, so an unchanged issue costs a 304. The request is bounded well under the hook's own timeout.
12
+ //
13
+ // **It refuses once per update.** The refusal is what puts the update in front of the run; repeating
14
+ // it on every call would wedge the run before it could act. A further update changes the pending text
15
+ // and is shown again. `josh issue:ack` clears it, and the review attestation and `josh followup` keep
16
+ // refusing until it does — the hook is the early warning, not the enforcement.
17
+ //
18
+ // **It fails open.** No record, an unreadable issue or a thrown error allows the call: a hook that
19
+ // stalls every tool call over a network blip would cost more than the update it watches for.
20
+
21
+ const POLL_INTERVAL_MS = 180_000
22
+ const POLL_TIMEOUT_MS = 5000
23
+
24
+ function is_new_to_run(record: WatchRecord | undefined): record is WatchRecord {
25
+ return record?.pending !== undefined && record.pending !== record.shown
26
+ }
27
+
28
+ async function new_update_reason(now: Date): Promise<string | undefined> {
29
+ const record = await issue_watch.poll({
30
+ interval_ms: POLL_INTERVAL_MS,
31
+ timeout_ms: POLL_TIMEOUT_MS,
32
+ now,
33
+ })
34
+
35
+ if (!is_new_to_run(record)) return undefined
36
+
37
+ issue_watch.mark_shown(record)
38
+
39
+ return issue_watch_gate.unread_message(record)
40
+ }
41
+
42
+ async function watch_reason(now: Date = new Date()): Promise<string | undefined> {
43
+ try {
44
+ return await new_update_reason(now)
45
+ } catch (error) {
46
+ error_text.trace_swallowed('issue_watch_hook.watch_reason', error)
47
+
48
+ return undefined
49
+ }
50
+ }
51
+
52
+ const issue_watch_hook = { POLL_INTERVAL_MS, watch_reason }
53
+
54
+ export { issue_watch_hook }