@joshuafolkken/kit 1.1387.0 → 1.1435.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (236) hide show
  1. package/.claude/skills/epic-commands/SKILL.md +1 -1
  2. package/.claude/skills/epic-commands/epic-bundle.md +5 -0
  3. package/.claude/skills/workflow-commands/backlogrun-lanes.md +5 -3
  4. package/.claude/skills/workflow-commands/backlogrun-steps.md +4 -3
  5. package/.claude/skills/workflow-commands/backlogrun.md +1 -0
  6. package/.claude/skills/workflow-commands/chain-rule.md +4 -2
  7. package/.claude/skills/workflow-commands/issue-comments.md +6 -0
  8. package/.claude/skills/workflow-commands/observation-ledger.md +3 -2
  9. package/.claude/skills/workflow-commands/pre-gate-cut.md +5 -3
  10. package/README.md +25 -0
  11. package/dist/hooks/audit-provision.js +2 -2
  12. package/dist/hooks/batch-guard.js +7 -7
  13. package/dist/hooks/{chunk-IYVOLN6A.js → chunk-2G5F2YCD.js} +3 -3
  14. package/dist/hooks/{chunk-2YSW3CXH.js → chunk-3LE6VLSR.js} +17 -6
  15. package/dist/hooks/{chunk-J3KSS7II.js → chunk-4BHUPFKF.js} +1 -1
  16. package/dist/hooks/{chunk-EAQEGME7.js → chunk-5SJSSCJG.js} +1 -1
  17. package/dist/hooks/{chunk-FZ6FUSBM.js → chunk-723HKZO5.js} +1 -1
  18. package/dist/hooks/{chunk-LMWBRJIP.js → chunk-7KMN5QB5.js} +3 -3
  19. package/dist/hooks/{chunk-NEC4AT4Z.js → chunk-DGT54IH5.js} +4 -0
  20. package/dist/hooks/{chunk-STDSEPWU.js → chunk-EMB55BDF.js} +4 -4
  21. package/dist/hooks/{chunk-JBBMAPC4.js → chunk-IX6TTTI6.js} +4 -0
  22. package/dist/hooks/{chunk-GJ7JPIHN.js → chunk-JXQBK323.js} +2674 -187
  23. package/dist/hooks/{chunk-DCY2GWHC.js → chunk-MSYSUSY6.js} +5 -5
  24. package/dist/hooks/{chunk-EBSHN25W.js → chunk-MU4LJFIV.js} +4 -4
  25. package/dist/hooks/{chunk-WNFJLBXI.js → chunk-QL6QTPWN.js} +3 -3
  26. package/dist/hooks/{chunk-SL4GQ2XP.js → chunk-XER6SSIB.js} +91 -18
  27. package/dist/hooks/{chunk-U32VSRNG.js → chunk-YW452ZAX.js} +89 -34
  28. package/dist/hooks/{chunk-GNDJ7NJ7.js → chunk-YZXYOAZ3.js} +202 -55
  29. package/dist/hooks/codex-hook-adapter.js +15 -15
  30. package/dist/hooks/duplicate-read-guard.js +8 -8
  31. package/dist/hooks/format-edited.js +5 -5
  32. package/dist/hooks/inputs.json +288 -229
  33. package/dist/hooks/investigation-guard.js +8 -8
  34. package/dist/hooks/pretool-guard.js +14 -14
  35. package/dist/hooks/session-lang.js +69 -16
  36. package/dist/hooks/stop-guard.js +13 -38
  37. package/dist/josh.js +62 -9
  38. package/package.json +4 -3
  39. package/prompts/review.md +22 -0
  40. package/scripts/agent/claude-agent-argv.ts +13 -1
  41. package/scripts/auth/auth-setup-cli.ts +101 -0
  42. package/scripts/auth/claude-token-output.ts +78 -0
  43. package/scripts/auth/claude-token.ts +68 -0
  44. package/scripts/backlog/backlog-drive-epic.ts +24 -8
  45. package/scripts/backlog/backlog-drive-launch.ts +3 -0
  46. package/scripts/backlog/backlog-drive-named-offer.ts +7 -2
  47. package/scripts/backlog/backlog-drive-named.ts +4 -2
  48. package/scripts/backlog/backlog-drive-unread.ts +56 -0
  49. package/scripts/backlog/backlog-drive.ts +16 -8
  50. package/scripts/backlog/backlog-overlap.ts +22 -5
  51. package/scripts/backlog/backlog-ready.ts +1 -3
  52. package/scripts/backlog/backlogrun-args.ts +13 -2
  53. package/scripts/backlog/backlogrun-cli.ts +91 -19
  54. package/scripts/backlog/backlogrun-launch.ts +24 -0
  55. package/scripts/backlog/backlogrun-replace.ts +166 -0
  56. package/scripts/backlog/backlogrun-stop.ts +178 -0
  57. package/scripts/behavior/behavior-cli.ts +6 -1
  58. package/scripts/doctor/doctor-io.ts +60 -9
  59. package/scripts/doctor/doctor.ts +20 -1
  60. package/scripts/document/document-byte-budget.ts +3 -3
  61. package/scripts/epic/epic-bundle-cli.ts +8 -8
  62. package/scripts/epic/epic-bundle-referenced.ts +2 -5
  63. package/scripts/epic/epic-bundle.ts +39 -4
  64. package/scripts/exports/unused-members-cli.ts +2 -1
  65. package/scripts/followup/followup-checks-event.ts +82 -0
  66. package/scripts/followup/git-followup-finish.ts +16 -2
  67. package/scripts/followup/git-followup-workflow.ts +5 -2
  68. package/scripts/followup/git-pr-followup-checks.ts +18 -4
  69. package/scripts/followup/git-pr-followup-wrapup.ts +5 -0
  70. package/scripts/followup/git-pr-followup.ts +4 -1
  71. package/scripts/fresh-machine/fresh-machine-cli.ts +204 -0
  72. package/scripts/fresh-machine/fresh-machine-docker.ts +65 -0
  73. package/scripts/fresh-machine/fresh-machine-image.ts +62 -0
  74. package/scripts/fresh-machine/fresh-machine-log.ts +61 -0
  75. package/scripts/fresh-machine/fresh-machine-patterns.ts +130 -0
  76. package/scripts/fresh-machine/fresh-machine-run.ts +215 -0
  77. package/scripts/fresh-machine/fresh-machine-script.ts +45 -0
  78. package/scripts/fresh-machine/fresh-machine-spec.ts +57 -0
  79. package/scripts/fresh-machine/specs/kit-test-backlogrun-python.json +34 -0
  80. package/scripts/fresh-machine/specs/kit-test-backlogrun.json +31 -0
  81. package/scripts/gate/gate-report.ts +70 -15
  82. package/scripts/gate/project-checks.ts +13 -2
  83. package/scripts/gate/scoped-green.ts +2 -1
  84. package/scripts/gate/verification-gate.ts +4 -3
  85. package/scripts/gh/gh-author-association.ts +14 -0
  86. package/scripts/gh/git-gh-conditional.ts +66 -0
  87. package/scripts/gh/git-gh-exec.ts +1 -1
  88. package/scripts/gh/git-gh-issue-read.ts +6 -3
  89. package/scripts/gh/git-gh-issue-write.ts +5 -1
  90. package/scripts/gh/git-gh-issue.ts +4 -1
  91. package/scripts/gh/git-pr-checks-eval.ts +13 -0
  92. package/scripts/gh/git-pr-checks-parse.ts +8 -1
  93. package/scripts/gh/git-pr-checks-watch.ts +11 -4
  94. package/scripts/gh/git-pr-checks.ts +36 -2
  95. package/scripts/git/git-command.ts +16 -4
  96. package/scripts/git/git-schemas.ts +1 -0
  97. package/scripts/git/git-spawn-sync.ts +1 -1
  98. package/scripts/hooks/pretool-guard.ts +3 -1
  99. package/scripts/init/hook-command-rewrite.ts +31 -0
  100. package/scripts/init/init-actions.ts +4 -1
  101. package/scripts/init/init-ai-copy.ts +33 -10
  102. package/scripts/init/init-basic.ts +8 -3
  103. package/scripts/init/init-logic-toolchain.ts +76 -0
  104. package/scripts/init/init-logic.ts +2 -0
  105. package/scripts/init/init.ts +5 -23
  106. package/scripts/init/project-profile.ts +36 -7
  107. package/scripts/init/start-exec.ts +27 -1
  108. package/scripts/init/start-plan.ts +15 -0
  109. package/scripts/init/start-steps.ts +46 -0
  110. package/scripts/init/start.ts +1 -0
  111. package/scripts/init/workflow-mark.ts +35 -0
  112. package/scripts/issue/issue-ack-cli.ts +84 -0
  113. package/scripts/issue/issue-labels.ts +40 -8
  114. package/scripts/issue/issue-state-cli.ts +14 -4
  115. package/scripts/issue/issue-watch-diff.ts +152 -0
  116. package/scripts/issue/issue-watch-gate.ts +81 -0
  117. package/scripts/issue/issue-watch-hook.ts +54 -0
  118. package/scripts/issue/issue-watch.ts +240 -0
  119. package/scripts/issue/measure-rerun-cli.ts +3 -4
  120. package/scripts/josh/josh-command-map.ts +4 -0
  121. package/scripts/josh/josh-commands-backlog.ts +2 -2
  122. package/scripts/josh/josh-commands-development.ts +1 -1
  123. package/scripts/josh/josh-commands-issue.ts +7 -0
  124. package/scripts/josh/josh-commands-lane.ts +11 -2
  125. package/scripts/josh/josh-commands-project.ts +24 -0
  126. package/scripts/josh/josh-verdict.ts +33 -4
  127. package/scripts/lane/claude-lane-broker.ts +142 -0
  128. package/scripts/lane/lane-await.ts +6 -2
  129. package/scripts/lane/lane-cache.ts +37 -7
  130. package/scripts/lane/lane-capacity.ts +8 -6
  131. package/scripts/lane/lane-child-invocation.ts +1 -0
  132. package/scripts/lane/lane-cli.ts +15 -1
  133. package/scripts/lane/lane-close.ts +15 -6
  134. package/scripts/lane/lane-dispatch.ts +36 -10
  135. package/scripts/lane/lane-environment.ts +1 -1
  136. package/scripts/lane/lane-launch-cli.ts +13 -4
  137. package/scripts/lane/lane-ledger.ts +21 -3
  138. package/scripts/lane/lane-measure-cli.ts +10 -2
  139. package/scripts/lane/lane-open.ts +9 -1
  140. package/scripts/lane/lane-preserve.ts +49 -0
  141. package/scripts/lane/lane-reap.ts +20 -5
  142. package/scripts/lane/lane-registry.ts +6 -0
  143. package/scripts/lane/lane-restart-cli.ts +51 -0
  144. package/scripts/lane/lane-restart.ts +175 -0
  145. package/scripts/lane/{openai-review-broker.ts → lane-session-broker.ts} +106 -28
  146. package/scripts/lane/lane-spent.ts +195 -0
  147. package/scripts/lane/lane-stage-stats.ts +27 -3
  148. package/scripts/lane/lane-stop-stats.ts +96 -0
  149. package/scripts/lane/openai-lane-supervisor.ts +2 -2
  150. package/scripts/lane/process-tools.ts +31 -0
  151. package/scripts/lib/buffered-process.ts +36 -2
  152. package/scripts/lib/pnpm-reach.ts +27 -0
  153. package/scripts/lib/status-icons.ts +5 -1
  154. package/scripts/lint/lint-parallel.ts +19 -2
  155. package/scripts/observations/observation-ledger-home.ts +1 -0
  156. package/scripts/review/review-attest-cli.ts +21 -6
  157. package/scripts/review/review-brief-cli.ts +2 -2
  158. package/scripts/review/review-comment-round.ts +114 -0
  159. package/scripts/review/review-record-cli.ts +39 -17
  160. package/scripts/rules/implementation-cut.ts +3 -3
  161. package/scripts/rules/pre-gate-cut.ts +1 -1
  162. package/scripts/run/board/run-board-checks.ts +42 -0
  163. package/scripts/run/board/run-board-cli.ts +2 -0
  164. package/scripts/run/board/run-board-every.ts +3 -1
  165. package/scripts/run/board/run-board-fit.ts +16 -5
  166. package/scripts/run/board/run-board-fresh.ts +64 -0
  167. package/scripts/run/board/run-board-header.ts +129 -53
  168. package/scripts/run/board/run-board-labels.ts +47 -4
  169. package/scripts/run/board/run-board-lane-event.ts +66 -0
  170. package/scripts/run/board/run-board-layout.ts +57 -9
  171. package/scripts/run/board/run-board-now.ts +234 -0
  172. package/scripts/run/board/run-board-operation-read.ts +75 -0
  173. package/scripts/run/board/run-board-operation.ts +150 -0
  174. package/scripts/run/board/run-board-phase.ts +26 -1
  175. package/scripts/run/board/run-board-plan.ts +4 -6
  176. package/scripts/run/board/run-board-read.ts +49 -3
  177. package/scripts/run/board/run-board-reason.ts +124 -0
  178. package/scripts/run/board/run-board-render-checks.ts +55 -0
  179. package/scripts/run/board/run-board-render-legend.ts +12 -3
  180. package/scripts/run/board/run-board-render-slot.ts +2 -2
  181. package/scripts/run/board/run-board-render-tail.ts +128 -0
  182. package/scripts/run/board/run-board-render-thanks.ts +50 -0
  183. package/scripts/run/board/run-board-render-title.ts +58 -0
  184. package/scripts/run/board/run-board-render.ts +96 -80
  185. package/scripts/run/board/run-board-row-yield.ts +32 -0
  186. package/scripts/run/board/run-board-setup.ts +61 -13
  187. package/scripts/run/board/run-board-state.ts +10 -0
  188. package/scripts/run/board/run-board-status.ts +66 -7
  189. package/scripts/run/board/run-board-thanks-csv.ts +71 -0
  190. package/scripts/run/board/run-board-thanks-read.ts +112 -0
  191. package/scripts/run/board/run-board-thanks.ts +53 -0
  192. package/scripts/run/board/run-board-tick.ts +50 -21
  193. package/scripts/run/board/run-board-usual.ts +44 -0
  194. package/scripts/run/carry/run-carry-args.ts +9 -1
  195. package/scripts/run/carry/run-carry-cli.ts +2 -45
  196. package/scripts/run/carry/run-carry-end.ts +73 -0
  197. package/scripts/run/carry/run-carry.ts +8 -1
  198. package/scripts/run/entry/run-entry-cli.ts +5 -0
  199. package/scripts/run/event/run-event-checks.ts +54 -0
  200. package/scripts/run/event/run-event-render.ts +2 -0
  201. package/scripts/run/event/run-event-scope.ts +1 -0
  202. package/scripts/run/event/run-event-setup.ts +55 -11
  203. package/scripts/run/event/run-event-stream.ts +27 -11
  204. package/scripts/run/merge/run-merge-cli.ts +7 -4
  205. package/scripts/run/merge/run-merge-ship.ts +61 -0
  206. package/scripts/run/merge/run-merge-steps.ts +7 -14
  207. package/scripts/run/merge/run-merge-token.ts +16 -2
  208. package/scripts/run/run-headless.ts +1 -0
  209. package/scripts/run/run-step.ts +2 -0
  210. package/scripts/run/ship/run-ship-cli.ts +22 -2
  211. package/scripts/run/ship/run-ship-return.ts +31 -13
  212. package/scripts/run/ship/run-ship-review-steps.ts +17 -4
  213. package/scripts/run/ship/run-ship-stage.ts +1 -0
  214. package/scripts/run/wake/run-wake-await.ts +130 -0
  215. package/scripts/run/wake/run-wake-cli.ts +60 -71
  216. package/scripts/run/wake/run-wake-describe.ts +34 -2
  217. package/scripts/run/wake/run-wake-driver.ts +11 -4
  218. package/scripts/run/wake/run-wake-session.ts +20 -3
  219. package/scripts/run/wake/run-wake-stop.ts +37 -0
  220. package/scripts/run/wake/run-wake-supervise.ts +44 -0
  221. package/scripts/sync/claude-settings-merge.ts +144 -0
  222. package/scripts/sync/sync-ai-files.ts +28 -3
  223. package/scripts/sync/sync.ts +15 -0
  224. package/scripts/test/project-test-script.ts +73 -0
  225. package/scripts/test/skip-marker.ts +14 -1
  226. package/scripts/test/test-related.ts +3 -2
  227. package/scripts/test/test-unit-guard.ts +78 -14
  228. package/scripts/time-runtime/time-reported-failure.ts +11 -3
  229. package/scripts/uninstall/distributed-files.ts +92 -0
  230. package/scripts/uninstall/uninstall-plan.ts +119 -0
  231. package/scripts/uninstall/uninstall-report.ts +61 -0
  232. package/scripts/uninstall/uninstall.ts +206 -0
  233. package/templates/AGENTS.basic.md +4 -0
  234. package/templates/GEMINI.basic.md +6 -0
  235. package/dist/hooks/chunk-6V5OPIUU.js +0 -13
  236. package/scripts/run/board/run-board-track.ts +0 -22
@@ -0,0 +1,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 }
@@ -0,0 +1,240 @@
1
+ import { git_gh_api_path } from '#scripts/gh/git-gh-api-path'
2
+ import { git_gh_conditional } from '#scripts/gh/git-gh-conditional'
3
+ import { git_gh_issue_read } from '#scripts/gh/git-gh-issue-read'
4
+ import { git_command } from '#scripts/git/git-command'
5
+ import { PROJECT_ROOT } from '#scripts/init/init-paths'
6
+ import { stamp_file } from '#scripts/josh/stamp-file'
7
+ import { json_value } from '#scripts/lib/json-value'
8
+ import { z } from 'zod'
9
+ import { issue_watch_diff, type WatchSnapshot } from './issue-watch-diff'
10
+
11
+ // The record of what a run last read of its issue, and the poll that compares the issue against it.
12
+ //
13
+ // **The baseline is written where the run reads the issue** — `josh run:entry`, which prints the body
14
+ // and every comment — so it is the same for a dispatched lane child and a `fullrun` in a person's own
15
+ // checkout, and a resume that prints nothing keeps the baseline it had. One record per checkout,
16
+ // keyed on its root like the review attestation, carries the issue number, so the hook needs no other
17
+ // way to learn which issue the session works on.
18
+ //
19
+ // **A poll is a conditional request.** The issue object's ETag changes with its body and with its
20
+ // comment count, so a 304 there means neither moved and the comments are not read at all.
21
+
22
+ const RECORD_PREFIX = 'josh-issue-watch-'
23
+ const CLOSED_STATE = 'closed'
24
+
25
+ const record_schema = z.object({
26
+ issue: z.string(),
27
+ body: z.string(),
28
+ last_comment_id: z.number(),
29
+ acknowledged_at: z.string(),
30
+ checked_at: z.string(),
31
+ etag: z.string().optional(),
32
+ pending: z.string().optional(),
33
+ shown: z.string().optional(),
34
+ })
35
+
36
+ const issue_schema = z.looseObject({ body: z.string().nullish(), state: z.string() })
37
+
38
+ type WatchRecord = z.infer<typeof record_schema>
39
+
40
+ type Fetched =
41
+ | { kind: 'unchanged' }
42
+ | { kind: 'unreadable' }
43
+ | { kind: 'closed' }
44
+ | { kind: 'read'; snapshot: WatchSnapshot; etag: string | undefined }
45
+
46
+ const UNREADABLE: Fetched = { kind: 'unreadable' }
47
+
48
+ function record_path(root: string = PROJECT_ROOT): string {
49
+ return stamp_file.stamp_path(RECORD_PREFIX, root)
50
+ }
51
+
52
+ function read_record(root?: string): WatchRecord | undefined {
53
+ const raw = stamp_file.read_stamp_text(record_path(root))
54
+
55
+ return raw === undefined ? undefined : json_value.parse_with(raw, record_schema)
56
+ }
57
+
58
+ function write_record(record: WatchRecord, root?: string): WatchRecord {
59
+ stamp_file.replace_stamp(record_path(root), record)
60
+
61
+ return record
62
+ }
63
+
64
+ function clear(root?: string): void {
65
+ stamp_file.remove_stamp(record_path(root))
66
+ }
67
+
68
+ interface FetchRequest {
69
+ issue: string
70
+ etag?: string | undefined
71
+ timeout_ms?: number | undefined
72
+ }
73
+
74
+ async function read_snapshot(
75
+ request: FetchRequest,
76
+ body: string,
77
+ etag: string | undefined,
78
+ ): Promise<Fetched> {
79
+ const comments = issue_watch_diff.parse_comments(
80
+ await git_gh_issue_read.issue_list_comments(request.issue, request.timeout_ms),
81
+ )
82
+
83
+ return comments === undefined ? UNREADABLE : { kind: 'read', snapshot: { body, comments }, etag }
84
+ }
85
+
86
+ async function fetch_issue(request: FetchRequest): Promise<Fetched> {
87
+ const read = await git_gh_conditional.read_conditional(
88
+ git_gh_api_path.issue_api_path(request.issue),
89
+ request.etag,
90
+ request.timeout_ms,
91
+ )
92
+
93
+ if (read.kind !== 'changed') return read
94
+
95
+ const fields = json_value.parse_with(read.body, issue_schema)
96
+
97
+ if (fields === undefined) return UNREADABLE
98
+ if (fields.state === CLOSED_STATE) return { kind: 'closed' }
99
+
100
+ return await read_snapshot(request, fields.body ?? '', read.etag)
101
+ }
102
+
103
+ // `acknowledged_at` is when a person's update was last taken in — the baseline's own read, or an
104
+ // acknowledgement that had something to take in. The review attestation compares it with the brief.
105
+ function fresh_record(
106
+ issue: string,
107
+ fetched: Extract<Fetched, { kind: 'read' }>,
108
+ now: Date,
109
+ acknowledged_at: string = now.toISOString(),
110
+ ): WatchRecord {
111
+ const baseline = issue_watch_diff.baseline_of(fetched.snapshot)
112
+ const checked_at = now.toISOString()
113
+
114
+ return { issue, ...baseline, acknowledged_at, checked_at, etag: fetched.etag }
115
+ }
116
+
117
+ // Called by `josh run:entry` once it has printed the issue. `false` when the issue could not be read,
118
+ // which leaves no record — a watch with no baseline would call the whole issue unread.
119
+ async function record_baseline(
120
+ issue: string,
121
+ root?: string,
122
+ now: Date = new Date(),
123
+ ): Promise<boolean> {
124
+ const fetched = await fetch_issue({ issue })
125
+
126
+ if (fetched.kind !== 'read') return false
127
+
128
+ write_record(fresh_record(issue, fetched, now), root)
129
+
130
+ return true
131
+ }
132
+
133
+ // The `_here` forms key the record on the repository root rather than `process.cwd()`, the same reason
134
+ // `review_attest.check_here` does: a run started from a subdirectory must find the record it wrote.
135
+ async function record_baseline_here(issue: string): Promise<boolean> {
136
+ return await record_baseline(issue, await git_command.repository_root())
137
+ }
138
+
139
+ async function clear_here(): Promise<void> {
140
+ clear(await git_command.repository_root())
141
+ }
142
+
143
+ function with_fetched(record: WatchRecord, fetched: Fetched, checked_at: string): WatchRecord {
144
+ if (fetched.kind !== 'read') return { ...record, checked_at }
145
+
146
+ const pending = issue_watch_diff.unread_diff(record, fetched.snapshot)
147
+
148
+ return { ...record, checked_at, etag: fetched.etag, pending }
149
+ }
150
+
151
+ interface PollOptions {
152
+ interval_ms: number
153
+ timeout_ms?: number
154
+ now?: Date
155
+ }
156
+
157
+ function is_due(record: WatchRecord, options: PollOptions, now: Date): boolean {
158
+ return now.getTime() - Date.parse(record.checked_at) >= options.interval_ms
159
+ }
160
+
161
+ async function refresh(
162
+ record: WatchRecord,
163
+ options: PollOptions,
164
+ now: Date,
165
+ root?: string,
166
+ ): Promise<WatchRecord | undefined> {
167
+ const fetched = await fetch_issue({
168
+ issue: record.issue,
169
+ etag: record.etag,
170
+ timeout_ms: options.timeout_ms,
171
+ })
172
+
173
+ if (fetched.kind === 'closed') {
174
+ clear(root)
175
+
176
+ return undefined
177
+ }
178
+
179
+ return write_record(with_fetched(record, fetched, now.toISOString()), root)
180
+ }
181
+
182
+ // The checkout's record, polled when the last poll is at least `interval_ms` old. `undefined` when no
183
+ // run recorded a baseline here, or the issue has closed — a closed issue ends its watch.
184
+ async function poll(options: PollOptions, root?: string): Promise<WatchRecord | undefined> {
185
+ const record = read_record(root)
186
+ const now = options.now ?? new Date()
187
+
188
+ if (record === undefined || !is_due(record, options, now)) return record
189
+
190
+ return await refresh(record, options, now, root)
191
+ }
192
+
193
+ // Marks a pending update shown, so the hook stops once per update rather than on every call.
194
+ function mark_shown(record: WatchRecord, root?: string): WatchRecord {
195
+ return write_record({ ...record, shown: record.pending }, root)
196
+ }
197
+
198
+ interface Acknowledged {
199
+ issue: string
200
+ diff: string | undefined
201
+ }
202
+
203
+ // `josh issue:ack`: read the issue as it is now, return what changed since the baseline, and make
204
+ // that the new baseline. **What is returned is exactly what is acknowledged** — the read and the new
205
+ // baseline are the same snapshot, so an update landing between the two cannot be acknowledged unseen.
206
+ async function acknowledge(
207
+ root?: string,
208
+ now: Date = new Date(),
209
+ ): Promise<Acknowledged | undefined> {
210
+ const record = read_record(root)
211
+
212
+ if (record === undefined) return undefined
213
+
214
+ const fetched = await fetch_issue({ issue: record.issue })
215
+
216
+ if (fetched.kind !== 'read') return undefined
217
+
218
+ const diff = issue_watch_diff.unread_diff(record, fetched.snapshot)
219
+
220
+ const acknowledged_at = diff === undefined ? record.acknowledged_at : now.toISOString()
221
+
222
+ write_record(fresh_record(record.issue, fetched, now, acknowledged_at), root)
223
+
224
+ return { issue: record.issue, diff }
225
+ }
226
+
227
+ const issue_watch = {
228
+ acknowledge,
229
+ clear,
230
+ clear_here,
231
+ mark_shown,
232
+ poll,
233
+ read_record,
234
+ record_baseline,
235
+ record_baseline_here,
236
+ record_path,
237
+ }
238
+
239
+ export type { Acknowledged, PollOptions, WatchRecord }
240
+ export { issue_watch }
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { execSync } from 'node:child_process'
3
3
  import { fileURLToPath } from 'node:url'
4
+ import { gh_author_association } from '#scripts/gh/gh-author-association'
4
5
  import { git_gh_issue_read } from '#scripts/gh/git-gh-issue-read'
5
6
  import { parse_json_object_or_undefined } from '#scripts/git/parse-json-array'
6
7
  import { issue_number_shape } from '#scripts/issue/issue-number-shape'
@@ -24,8 +25,6 @@ import { session_cite } from './session-cite'
24
25
  const FAILURE_EXIT_CODE = 1
25
26
  const ARGV_OFFSET = 2
26
27
  const ISSUE_FIELDS = 'body,author_association'
27
- const TRUSTED_ASSOCIATIONS: ReadonlySet<string> = new Set(['OWNER', 'MEMBER', 'COLLABORATOR'])
28
- const TRUSTED_LIST = [...TRUSTED_ASSOCIATIONS].join(' / ')
29
28
  const UNKNOWN_ASSOCIATION = 'unknown'
30
29
  const USAGE = 'Usage: josh measure:rerun <issue-number>'
31
30
  const NO_BASELINE = 'no `command → value` baseline found in ## ベースライン — nothing to re-run'
@@ -100,10 +99,10 @@ async function read_trusted_body(issue_number: string): Promise<BodyRead> {
100
99
 
101
100
  const association = issue.author_association ?? UNKNOWN_ASSOCIATION
102
101
 
103
- if (!TRUSTED_ASSOCIATIONS.has(association)) {
102
+ if (!gh_author_association.is_trusted(association)) {
104
103
  return {
105
104
  kind: 'refused',
106
- reason: `refusing to run the baseline of ${session_cite.issue(issue_number)}: its author is ${association}, not ${TRUSTED_LIST}`,
105
+ reason: `refusing to run the baseline of ${session_cite.issue(issue_number)}: its author is ${association}, not ${gh_author_association.TRUSTED_LIST}`,
107
106
  }
108
107
  }
109
108
 
@@ -54,6 +54,7 @@ const ALIASES: Record<string, string> = {
54
54
  pf: 'profile',
55
55
  st: 'start',
56
56
  sy: 'sync',
57
+ un: 'uninstall',
57
58
  rmi: 'registry:migrate',
58
59
  g: 'git',
59
60
  gp: 'pr',
@@ -79,9 +80,12 @@ const ALIASES: Record<string, string> = {
79
80
  rvf: 'review:findings',
80
81
  ruv: 'rule:value',
81
82
  ev: 'eval',
83
+ fr: 'fresh:run',
84
+ as: 'auth:setup',
82
85
  lsm: 'lane:sample',
83
86
  lst: 'lane:stats',
84
87
  lli: 'lane:limit',
88
+ lrs: 'lane:restart',
85
89
  blr: 'backlogrun',
86
90
  }
87
91
 
@@ -11,10 +11,10 @@ const BACKLOG_COMMANDS: Record<string, CommandEntry> = {
11
11
  backlogrun: {
12
12
  script: 'scripts/backlog/backlogrun-cli.ts',
13
13
  description:
14
- 'Start a backlogrun in the background and show its board; with one already running, only show the board',
14
+ 'Start a backlogrun in the background and show its board; with one already running, only show the board, or replace it when it is idle and the invocation differs; --stop stops it',
15
15
  category: 'AI tools',
16
16
  reference: [
17
- '[#<n>...] [--only] [--max <n>] [--idle <minutes>] [--agent claude|codex]',
17
+ '[<n>...] [--only] [--max <n>] [--idle <minutes>] [--agent claude|codex] | --stop',
18
18
  'developer',
19
19
  ['files', 'network', 'processes'],
20
20
  ],
@@ -181,7 +181,7 @@ const DEV_COMMANDS: Record<string, CommandEntry> = {
181
181
  'test:unit': {
182
182
  script: 'scripts/test/test-unit-guard.ts',
183
183
  description:
184
- 'Run unit tests with Vitest (skips when Vitest is absent; fails when it has no tests)',
184
+ 'Run unit tests with Vitest (without Vitest: a basic project runs its own test script, others skip; fails when Vitest has no tests)',
185
185
  category: 'Development',
186
186
  reference: [FILTER_ARGUMENTS, 'developer', ['processes']],
187
187
  core_weight: gate_plan.direct_unit_weight,
@@ -11,6 +11,13 @@ const ISSUES_WITH_REPO = '<issue...> [--repo <owner/repo>]'
11
11
 
12
12
  /* eslint-disable @typescript-eslint/naming-convention */
13
13
  const ISSUE_COMMANDS: Record<string, CommandEntry> = {
14
+ 'issue:ack': {
15
+ script: 'scripts/issue/issue-ack-cli.ts',
16
+ description:
17
+ "Record that the run read a person's update to its issue, printing the update it acknowledges",
18
+ category: 'AI tools',
19
+ reference: ['<issue>', 'automation', ['network']],
20
+ },
14
21
  'issue:read': {
15
22
  script: 'scripts/issue/issue-read-cli.ts',
16
23
  description: "Print each issue's title, body and every comment on it, in one call",
@@ -40,7 +40,7 @@ const LANE_COMMANDS: Record<string, CommandEntry> = {
40
40
  'lane:prune': {
41
41
  ...LANE_ARGUMENTS,
42
42
  description:
43
- 'Close every lane an interruption left without its work tree, then sweep unregistered leftovers under the lanes root',
43
+ 'Close every lane an interruption left without its work tree and every spent lane, then sweep unregistered leftovers under the lanes root',
44
44
  category: 'AI tools',
45
45
  reference: ['', 'automation', ['files', 'git']],
46
46
  default_script_arguments: ['prune'],
@@ -74,6 +74,15 @@ const LANE_COMMANDS: Record<string, CommandEntry> = {
74
74
  category: 'AI tools',
75
75
  reference: ['<issue> [--stash <message>]', 'automation', ['files', 'git', 'processes']],
76
76
  },
77
+ // `.env` is read for `JOSH_LANE_ROOT`, as every lane command reads it, so the lane is found.
78
+ 'lane:restart': {
79
+ script: 'scripts/lane/lane-restart-cli.ts',
80
+ tsx_arguments: OPTIONAL_ENV_FILE_FLAGS,
81
+ description:
82
+ 'Stop a running lane child, stash its uncommitted work and re-dispatch its issue in a fresh session — not counted as an outage or a failure',
83
+ category: 'AI tools',
84
+ reference: ['<issue>', 'developer', ['files', 'git', 'processes', 'network']],
85
+ },
77
86
  // A live run's lane limit: `.env` is read so the bare form reports the
78
87
  // `JOSH_LANE_LIMIT` a person set there when no override stands.
79
88
  'lane:limit': {
@@ -97,7 +106,7 @@ const LANE_COMMANDS: Record<string, CommandEntry> = {
97
106
  'lane:stats': {
98
107
  ...MEASURE_ARGUMENTS,
99
108
  description:
100
- 'Print one table row of lane throughput, gate duration and machine load over a period, and the per-stage durations of a lane under it',
109
+ 'Print one table row of lane throughput, gate duration and machine load over a period, the per-stage durations of a lane under it, and the wait after each ship stop reason under those',
101
110
  category: 'AI tools',
102
111
  reference: ['--period <days> [--limit <lane-limit>]', 'maintainer', ['none']],
103
112
  default_script_arguments: ['stats'],