@joshuafolkken/kit 1.1435.0 → 1.1455.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 (105) hide show
  1. package/.claude/skills/workflow-commands/backlogrun-handoff-report.md +1 -1
  2. package/.claude/skills/workflow-commands/backlogrun-park.md +2 -0
  3. package/.claude/skills/workflow-commands/backlogrun-steps.md +9 -7
  4. package/.claude/skills/workflow-commands/backlogrun.md +2 -2
  5. package/.claude/skills/workflow-commands/fullrun-steps.md +1 -1
  6. package/.claude/skills/workflow-commands/halfrun.md +3 -2
  7. package/.claude/skills/workflow-commands/kickoff.md +12 -13
  8. package/.claude/skills/workflow-commands/observation-filing.md +1 -1
  9. package/.claude/skills/workflow-commands/prerequisite.md +6 -0
  10. package/.claude/skills/workflow-commands/prrun.md +2 -1
  11. package/.claude/skills/workflow-commands/retrospective.md +5 -5
  12. package/.claude/skills/workflow-commands/split-assessment.md +2 -2
  13. package/CLAUDE.md +1 -1
  14. package/README.md +10 -19
  15. package/dist/CLAUDE.md +1 -1
  16. package/dist/hooks/batch-guard.js +6 -6
  17. package/dist/hooks/{chunk-3LE6VLSR.js → chunk-3C4XRYG6.js} +18 -5
  18. package/dist/hooks/{chunk-7KMN5QB5.js → chunk-5H4SSDSD.js} +3 -3
  19. package/dist/hooks/{chunk-YZXYOAZ3.js → chunk-7WQBPLUN.js} +11 -5
  20. package/dist/hooks/{chunk-2G5F2YCD.js → chunk-D5QBTOCZ.js} +3 -3
  21. package/dist/hooks/{chunk-MU4LJFIV.js → chunk-GPLGZ6IC.js} +3 -3
  22. package/dist/hooks/{chunk-MSYSUSY6.js → chunk-HFGWYGUV.js} +5 -5
  23. package/dist/hooks/{chunk-JXQBK323.js → chunk-HHI6FO3R.js} +132 -105
  24. package/dist/hooks/{chunk-XER6SSIB.js → chunk-LIAFCXL7.js} +17 -7
  25. package/dist/hooks/{chunk-4BHUPFKF.js → chunk-OLMO72ON.js} +1 -1
  26. package/dist/hooks/{chunk-EMB55BDF.js → chunk-TBJFTSUY.js} +2 -2
  27. package/dist/hooks/{chunk-5SJSSCJG.js → chunk-UT7YC3N2.js} +1 -1
  28. package/dist/hooks/{chunk-QL6QTPWN.js → chunk-WNLVGT7E.js} +3 -3
  29. package/dist/hooks/{chunk-YW452ZAX.js → chunk-Z4XI7CZS.js} +2 -2
  30. package/dist/hooks/codex-hook-adapter.js +13 -13
  31. package/dist/hooks/duplicate-read-guard.js +7 -7
  32. package/dist/hooks/format-edited.js +3 -3
  33. package/dist/hooks/inputs.json +229 -226
  34. package/dist/hooks/investigation-guard.js +7 -7
  35. package/dist/hooks/pretool-guard.js +12 -12
  36. package/dist/hooks/session-lang.js +3 -3
  37. package/dist/hooks/stop-guard.js +64 -11
  38. package/dist/josh.js +34 -11
  39. package/package.json +1 -1
  40. package/prompts/collaboration-workflow/issue-template.md +10 -8
  41. package/prompts/collaboration-workflow/shell-body.md +1 -1
  42. package/prompts/review.md +8 -4
  43. package/scripts/auto-ok/auto-ok-judge-cli.ts +125 -0
  44. package/scripts/auto-ok/auto-ok-judge-read.ts +95 -0
  45. package/scripts/auto-ok/auto-ok-judge-write.ts +109 -0
  46. package/scripts/auto-ok/auto-ok-judge.ts +67 -0
  47. package/scripts/backlog/backlog-drive-cli.ts +14 -0
  48. package/scripts/backlog/backlog-drive.ts +22 -3
  49. package/scripts/document/document-byte-budget.ts +1 -1
  50. package/scripts/fresh-machine/fresh-machine-patterns.ts +60 -15
  51. package/scripts/gate/project-checks.ts +5 -2
  52. package/scripts/gh/git-gh-issue-rest.ts +1 -0
  53. package/scripts/gh/git-gh-issue.ts +9 -0
  54. package/scripts/gh/git-pr-ai-review.ts +12 -1
  55. package/scripts/gh/git-pr-confirmation.ts +11 -7
  56. package/scripts/init/init-actions.ts +3 -3
  57. package/scripts/init/init-ai-copy.ts +1 -1
  58. package/scripts/init/init-basic.ts +1 -1
  59. package/scripts/init/init-bootstrap.ts +6 -2
  60. package/scripts/init/init-dry-run-report.ts +150 -0
  61. package/scripts/init/init-dry-run.ts +189 -0
  62. package/scripts/init/init-install.ts +8 -1
  63. package/scripts/init/init.ts +57 -19
  64. package/scripts/init/kit-setup-state.ts +11 -17
  65. package/scripts/init/own-formatter.ts +21 -0
  66. package/scripts/init/project-profile.ts +25 -6
  67. package/scripts/init/start-dry-run.ts +70 -0
  68. package/scripts/init/start-plan.ts +8 -3
  69. package/scripts/init/start-setup-pr.ts +13 -24
  70. package/scripts/init/start-steps.ts +16 -1
  71. package/scripts/init/start.ts +38 -18
  72. package/scripts/init/working-tree.ts +116 -0
  73. package/scripts/issue/issue-auto-ok.ts +68 -99
  74. package/scripts/issue/issue-file-cli.ts +17 -17
  75. package/scripts/issue/issue-file.ts +1 -6
  76. package/scripts/issue/issue-labels.ts +6 -2
  77. package/scripts/issue/issue-release-cli.ts +69 -23
  78. package/scripts/issue/issue-release.ts +3 -1
  79. package/scripts/josh/josh-commands-ai.ts +11 -0
  80. package/scripts/josh/josh-commands-issue.ts +3 -3
  81. package/scripts/josh/josh-commands-project.ts +2 -2
  82. package/scripts/josh/josh-commands-workflow.ts +1 -1
  83. package/scripts/josh/process-identity.ts +32 -6
  84. package/scripts/lane/lane-broker-server.ts +69 -0
  85. package/scripts/lane/lane-session-broker.ts +37 -14
  86. package/scripts/notify/confirmation-options.ts +59 -0
  87. package/scripts/notify/notify-opt-in.ts +33 -0
  88. package/scripts/notify/telegram-notify.ts +25 -8
  89. package/scripts/notify/telegram-test-logic.ts +33 -1
  90. package/scripts/notify/telegram-test.ts +4 -2
  91. package/scripts/observations/observation-ledger-migrate.ts +3 -15
  92. package/scripts/release/release-issue-close.ts +133 -0
  93. package/scripts/release/release-publish.ts +12 -1
  94. package/scripts/repo/repository-labels.ts +11 -1
  95. package/scripts/review/comment-ranges.ts +199 -0
  96. package/scripts/review/review-brief-cli.ts +22 -5
  97. package/scripts/review/review-code-tree.ts +119 -0
  98. package/scripts/review/review-round2-cli.ts +16 -5
  99. package/scripts/review/review-round2.ts +30 -10
  100. package/scripts/review/review-stamps.ts +22 -1
  101. package/scripts/rules/shell-body-trigger.ts +3 -2
  102. package/scripts/rules/stop-rules.ts +2 -1
  103. package/scripts/run/entry/run-entry-stop.ts +6 -3
  104. package/scripts/run/run-stop-notify.ts +2 -0
  105. package/scripts/uninstall/distributed-files.ts +1 -1
@@ -52,6 +52,7 @@ const PROBE_ENVIRONMENT = { TZ: 'UTC', LC_ALL: 'C' }
52
52
  // `process.kill(pid, 0)` runs every permission check and delivers nothing, so it is the standard
53
53
  // liveness probe: it throws `ESRCH` where the process is gone.
54
54
  const LIVENESS_SIGNAL = 0
55
+ const NO_SUCH_PROCESS = 'ESRCH'
55
56
  // Below this a pid is not a process: `0` addresses the caller's own process group and anything
56
57
  // negative addresses another group, so a truncated or hand-edited record carrying either would
57
58
  // otherwise answer "alive" forever.
@@ -79,18 +80,28 @@ interface StartProbes {
79
80
  read_ps_start: (pid: number) => string | undefined
80
81
  }
81
82
 
82
- function is_live_pid(pid: number): boolean {
83
- if (pid < LOWEST_REAL_PID) return false
84
-
83
+ // The code the liveness probe was refused with, or nothing where the caller may signal the process.
84
+ function signal_refusal(pid: number): string | undefined {
85
85
  try {
86
86
  process.kill(pid, LIVENESS_SIGNAL)
87
87
 
88
- return true
89
- } catch {
90
- return false
88
+ return undefined
89
+ } catch (error) {
90
+ return (error as NodeJS.ErrnoException).code ?? NO_SUCH_PROCESS
91
91
  }
92
92
  }
93
93
 
94
+ function is_live_pid(pid: number): boolean {
95
+ return pid >= LOWEST_REAL_PID && signal_refusal(pid) === undefined
96
+ }
97
+
98
+ // **Whether a pid names any process at all, for a reader that may not be allowed to signal it.** Only
99
+ // `ESRCH` proves the process gone: `EPERM` is the kernel refusing a signal to a process it has just
100
+ // found — one under another user, or outside a sandbox that may only signal its own — so it is present.
101
+ function is_present_pid(pid: number): boolean {
102
+ return pid >= LOWEST_REAL_PID && signal_refusal(pid) !== NO_SUCH_PROCESS
103
+ }
104
+
94
105
  // Linux exposes field 22 as the process start time in clock ticks since boot. The command name in
95
106
  // field 2 may itself contain spaces or `)`, so parsing starts after its final closing parenthesis;
96
107
  // `state` is then index 0 and `starttime` index 19. A direct file read avoids process enumeration and
@@ -315,6 +326,19 @@ function is_same_process(
315
326
  return matches_recorded_start(pid, recorded_start, read)
316
327
  }
317
328
 
329
+ // `is_same_process` for a reader that may not be allowed to signal the writer (`is_present_pid`): a
330
+ // refused signal still compares the start time, so a reissued pid separates from its writer here too.
331
+ function is_present_process(
332
+ pid: number,
333
+ recorded_start: string | undefined,
334
+ read: (pid: number) => string | undefined = read_start,
335
+ ): boolean | undefined {
336
+ if (!is_present_pid(pid)) return false
337
+ if (recorded_start === undefined) return undefined
338
+
339
+ return matches_recorded_start(pid, recorded_start, read)
340
+ }
341
+
318
342
  // **Whether a record is the caller's own, which is a different question from whether its writer is
319
343
  // alive**. `is_same_process` asks about the recorded process; this asks
320
344
  // whether that process *is this one*. A supervisor that has been replaced reads its successor's
@@ -339,6 +363,8 @@ const process_identity = {
339
363
  is_live_beacon,
340
364
  is_live_pid,
341
365
  is_own_process,
366
+ is_present_pid,
367
+ is_present_process,
342
368
  is_same_process,
343
369
  open_beacon,
344
370
  own_fields,
@@ -0,0 +1,69 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { process_identity } from '#scripts/josh/process-identity'
3
+ import { stamp_file } from '#scripts/josh/stamp-file'
4
+ import { json_value } from '#scripts/lib/json-value'
5
+ import { z } from 'zod'
6
+
7
+ // Which process serves a lane's brokered sessions, recorded beside the response it answers in.
8
+ //
9
+ // **A server is live while its process is, never while its event loop happens to answer.** One
10
+ // supervisor serves every Claude lane beside its drive passes, so any synchronous call in it — a
11
+ // `spawnSync` of an agent CLI's `--version` alone may take 10 s — stalled the timer-written heartbeat
12
+ // past its limit and failed every review then in flight (joshuafolkken/kit#3840).
13
+ //
14
+ // **A record of its own rather than fields on the response**, because a ship branched before this
15
+ // record existed parses the response strictly and reads any new field as no server at all; the
16
+ // response keeps the shape and heartbeat that ship reads, and this record names the writer by
17
+ // `process_identity.own_fields()`, so a pid the operating system reissued is not taken for it.
18
+
19
+ const SERVER_SUFFIX = '.server'
20
+ const server_schema = z
21
+ .object({
22
+ session: z.uuid(),
23
+ pid: z.number().int().positive(),
24
+ process_start: z.string().optional(),
25
+ })
26
+ .strict()
27
+ type BrokerServer = z.infer<typeof server_schema>
28
+
29
+ function server_path(response: string): string {
30
+ return `${response}${SERVER_SUFFIX}`
31
+ }
32
+
33
+ function write(response: string, session: string): void {
34
+ stamp_file.replace_stamp(server_path(response), { session, ...process_identity.own_fields() })
35
+ }
36
+
37
+ function remove(response: string): void {
38
+ stamp_file.remove_stamp(server_path(response))
39
+ }
40
+
41
+ // Only the record of the session the response names: one left by an earlier server is no answer.
42
+ function read(response: string, session: string): BrokerServer | undefined {
43
+ try {
44
+ const server = json_value.parse_with(readFileSync(server_path(response), 'utf8'), server_schema)
45
+
46
+ return server?.session === session ? server : undefined
47
+ } catch {
48
+ return undefined
49
+ }
50
+ }
51
+
52
+ // **The identity is checked when a ship first finds the server, the pid while it waits.** A response
53
+ // a killed supervisor left behind is met on that first look, where a reissued pid must not read as
54
+ // it; the wait polls every 100 ms, where a start-time probe would spawn `ps` on each. Both read
55
+ // through `is_present_*`, because a Codex lane's ship probes from inside its sandbox, where the kernel
56
+ // answers `EPERM` for a supervisor it has found. A start time that cannot be read is no proof of a
57
+ // dead server, so it does not fail the review.
58
+ function is_serving_process(server: BrokerServer): boolean {
59
+ return process_identity.is_present_process(server.pid, server.process_start) !== false
60
+ }
61
+
62
+ function is_running(server: BrokerServer): boolean {
63
+ return process_identity.is_present_pid(server.pid)
64
+ }
65
+
66
+ const lane_broker_server = { is_running, is_serving_process, read, remove, server_path, write }
67
+
68
+ export type { BrokerServer }
69
+ export { lane_broker_server }
@@ -12,6 +12,7 @@ import { detached_launch } from '#scripts/run/detached-launch'
12
12
  import { run_ship_review } from '#scripts/run/ship/run-ship-review'
13
13
  import { run_ship_stage } from '#scripts/run/ship/run-ship-stage'
14
14
  import { z } from 'zod'
15
+ import { lane_broker_server, type BrokerServer } from './lane-broker-server'
15
16
  import { lane_child_invocation, type ShipStop } from './lane-child-invocation'
16
17
  import { lane_child_marker } from './lane-child-marker'
17
18
  import type { LaneInfo } from './lane-registry'
@@ -32,8 +33,10 @@ const FINDINGS_PREFIX = 'josh-ship-review-findings-'
32
33
  const LOG_PREFIX = 'josh-ship-review-log-'
33
34
  const CACHE_DIRECTORY = ['node_modules', '.cache']
34
35
  const POLL_MS = 100
35
- const HEARTBEAT_MS = 500
36
- const HEARTBEAT_LIMIT_MS = 3000
36
+ // The heartbeat is kept for a ship branched before the server record, and read only where a
37
+ // supervisor started before it left no such record (`lane-broker-server.ts`).
38
+ const LEGACY_HEARTBEAT_MS = 500
39
+ const LEGACY_HEARTBEAT_LIMIT_MS = 3000
37
40
  const MINUTE_MS = 60_000
38
41
  const REQUEST_LIMIT_MINUTES = 30
39
42
  const REQUEST_LIMIT_MS = REQUEST_LIMIT_MINUTES * MINUTE_MS
@@ -203,6 +206,7 @@ async function serve(
203
206
  }
204
207
  }
205
208
 
209
+ // The server record is written before the response, so a ship that finds the response finds it too.
206
210
  function prepare(lane: LaneInfo): { response: string; current: BrokerResponse } {
207
211
  const response = response_path(lane.directory, lane.issue)
208
212
  if (response === undefined) throw new Error('review broker has no trusted response location')
@@ -211,6 +215,7 @@ function prepare(lane: LaneInfo): { response: string; current: BrokerResponse }
211
215
 
212
216
  mkdirSync(path.dirname(response), { recursive: true })
213
217
  mkdirSync(path.dirname(request_path(lane.directory, lane.issue)), { recursive: true })
218
+ lane_broker_server.write(response, session)
214
219
  const current: BrokerResponse = {
215
220
  session,
216
221
  nonce: '',
@@ -227,7 +232,7 @@ function start_heartbeat(state: { current: BrokerResponse }, response: string):
227
232
  const heartbeat = setInterval(() => {
228
233
  state.current = { ...state.current, heartbeat_at: Date.now() }
229
234
  write_response(response, state.current)
230
- }, HEARTBEAT_MS)
235
+ }, LEGACY_HEARTBEAT_MS)
231
236
 
232
237
  heartbeat.unref()
233
238
 
@@ -246,6 +251,7 @@ async function stop_server(
246
251
  await serving
247
252
  clearInterval(heartbeat)
248
253
  unlinkSync(response)
254
+ lane_broker_server.remove(response)
249
255
  }
250
256
 
251
257
  async function with_server<T>(lane: LaneInfo, run: () => Promise<T>): Promise<T> {
@@ -261,25 +267,40 @@ async function with_server<T>(lane: LaneInfo, run: () => Promise<T>): Promise<T>
261
267
  }
262
268
  }
263
269
 
270
+ function is_fresh_heartbeat(heartbeat_at: number): boolean {
271
+ const now = Date.now()
272
+
273
+ return heartbeat_at <= now && heartbeat_at >= now - LEGACY_HEARTBEAT_LIMIT_MS
274
+ }
275
+
276
+ // Live by the server record (`lane-broker-server.ts`); a response with none was written by a
277
+ // supervisor started before it, read by the heartbeat that supervisor still writes.
264
278
  function is_live_response(
279
+ target: string,
265
280
  response: BrokerResponse | undefined,
266
- session: string,
281
+ is_live: (server: BrokerServer) => boolean,
267
282
  ): response is BrokerResponse {
268
- const now = Date.now()
283
+ if (response === undefined) return false
269
284
 
270
- return (
271
- response?.session === session &&
272
- response.heartbeat_at <= now &&
273
- response.heartbeat_at >= now - HEARTBEAT_LIMIT_MS
274
- )
285
+ const server = lane_broker_server.read(target, response.session)
286
+
287
+ return server === undefined ? is_fresh_heartbeat(response.heartbeat_at) : is_live(server)
288
+ }
289
+
290
+ function session_response(target: string, session: string): BrokerResponse | undefined {
291
+ const response = read_record(target, response_schema)
292
+
293
+ return response?.session === session ? response : undefined
275
294
  }
276
295
 
277
296
  function response_verdict(
278
- response: BrokerResponse | undefined,
297
+ target: string,
279
298
  session: string,
280
299
  nonce: string,
281
300
  ): 'wait' | 'success' | 'failure' {
282
- if (!is_live_response(response, session)) return 'failure'
301
+ const response = session_response(target, session)
302
+
303
+ if (!is_live_response(target, response, lane_broker_server.is_running)) return 'failure'
283
304
  if (response.nonce !== nonce) return 'wait'
284
305
 
285
306
  return response.is_success ? 'success' : 'failure'
@@ -289,7 +310,7 @@ async function wait_response(target: string, session: string, nonce: string): Pr
289
310
  const deadline = Date.now() + REQUEST_LIMIT_MS
290
311
 
291
312
  while (Date.now() < deadline) {
292
- const verdict = response_verdict(read_record(target, response_schema), session, nonce)
313
+ const verdict = response_verdict(target, session, nonce)
293
314
  if (verdict !== 'wait') return verdict === 'success'
294
315
 
295
316
  // eslint-disable-next-line no-await-in-loop -- polling: each read waits on the state the previous one saw
@@ -302,7 +323,9 @@ async function wait_response(target: string, session: string, nonce: string): Pr
302
323
  function live_response(target: string): BrokerResponse | undefined {
303
324
  const response = read_record(target, response_schema)
304
325
 
305
- return is_live_response(response, response?.session ?? '') ? response : undefined
326
+ return is_live_response(target, response, lane_broker_server.is_serving_process)
327
+ ? response
328
+ : undefined
306
329
  }
307
330
 
308
331
  // Whether a server answers for this lane now — what a Claude lane's ship asks before it hands a session
@@ -0,0 +1,59 @@
1
+ import { session_language } from '#scripts/josh/session-language'
2
+
3
+ // **A confirmation names what to choose, not only why the run stopped.** The person reading it is
4
+ // off-screen by definition, so the message itself carries the recommended choice and the others —
5
+ // in the session language, because that is the language the person works in.
6
+
7
+ interface ConfirmationOptions {
8
+ recommended: string
9
+ alternatives: ReadonlyArray<string>
10
+ }
11
+
12
+ interface Headings {
13
+ recommended: string
14
+ alternatives: string
15
+ none: string
16
+ }
17
+
18
+ const JAPANESE_LANG_PREFIX = 'ja'
19
+ const JAPANESE_HEADINGS: Headings = {
20
+ recommended: '推奨:',
21
+ alternatives: '他の選択肢:',
22
+ none: 'なし',
23
+ }
24
+ const ENGLISH_HEADINGS: Headings = {
25
+ recommended: 'Recommended:',
26
+ alternatives: 'Alternatives:',
27
+ none: 'none',
28
+ }
29
+
30
+ // Only the two languages the workflow is written in have headings; any other falls back to English.
31
+ function headings(): Headings {
32
+ const { lang } = session_language.resolve_session_lang()
33
+
34
+ return lang.toLowerCase().startsWith(JAPANESE_LANG_PREFIX) ? JAPANESE_HEADINGS : ENGLISH_HEADINGS
35
+ }
36
+
37
+ // An empty list is stated as none, so a confirmation with no other option says so rather than
38
+ // leaving a reader to wonder whether the line was lost.
39
+ function alternatives_block(options: ConfirmationOptions, labels: Headings): string {
40
+ if (options.alternatives.length === 0) return `${labels.alternatives} ${labels.none}`
41
+
42
+ const lines = options.alternatives.map((alternative) => `- ${alternative}`)
43
+
44
+ return [labels.alternatives, ...lines].join('\n')
45
+ }
46
+
47
+ // The block is handed to the sender as its own `choices` field rather than appended to the body:
48
+ // the body is what a too-long message is cut from, and the choices are the one part a reader cannot
49
+ // act without, so they must survive the cut.
50
+ function block(options: ConfirmationOptions): string {
51
+ const labels = headings()
52
+
53
+ return `${labels.recommended} ${options.recommended}\n${alternatives_block(options, labels)}`
54
+ }
55
+
56
+ const confirmation_options = { block }
57
+
58
+ export { confirmation_options }
59
+ export type { ConfirmationOptions }
@@ -0,0 +1,33 @@
1
+ import type { TelegramTaskType } from './telegram-notify'
2
+
3
+ // **`planning` and `completion` reach a person only when they asked for them.** Neither needs the
4
+ // person to act now — a plan is posted on the Issue and a merge is visible on the pull request — so
5
+ // sending both on every run buried the types that do need someone. Every other type is unaffected.
6
+ //
7
+ // **One variable listing the opted-in types, not one switch per type**, so making a further type
8
+ // optional later adds an entry to `OPTIONAL_TASK_TYPES` rather than another variable.
9
+ const OPT_IN_KEY = 'JOSH_NOTIFY_OPT_IN'
10
+ const OPTIONAL_TASK_TYPES: ReadonlySet<TelegramTaskType> = new Set(['planning', 'completion'])
11
+ const LIST_SEPARATOR = ','
12
+
13
+ function opted_in_types(): ReadonlyArray<string> {
14
+ return (process.env[OPT_IN_KEY] ?? '')
15
+ .split(LIST_SEPARATOR)
16
+ .map((entry) => entry.trim().toLowerCase())
17
+ .filter((entry) => entry.length > 0)
18
+ }
19
+
20
+ // Unset means none: an optional type is sent only once it is named.
21
+ function is_skipped(task_type: TelegramTaskType): boolean {
22
+ if (!OPTIONAL_TASK_TYPES.has(task_type)) return false
23
+
24
+ return !opted_in_types().includes(task_type)
25
+ }
26
+
27
+ function skip_line(task_type: TelegramTaskType): string {
28
+ return `🔕 ${task_type} notifications are not opted in (${OPT_IN_KEY}).`
29
+ }
30
+
31
+ const notify_opt_in = { is_skipped, skip_line }
32
+
33
+ export { notify_opt_in }
@@ -1,6 +1,8 @@
1
1
  import { gh_spawn } from '#scripts/gh/gh-spawn'
2
2
  import { timed_fetch } from '#scripts/lib/timed-fetch'
3
3
  import { z } from 'zod'
4
+ import { confirmation_options, type ConfirmationOptions } from './confirmation-options'
5
+ import { notify_opt_in } from './notify-opt-in'
4
6
 
5
7
  const TELEGRAM_API_BASE = 'https://api.telegram.org'
6
8
  // `sendMessage` refuses a text longer than this.
@@ -42,6 +44,9 @@ interface TelegramSendInput {
42
44
  repo_name: string | undefined
43
45
  issue_title: string | undefined
44
46
  body: string | undefined
47
+ // A confirmation's recommended choice and the others — set beside the body rather than inside it
48
+ // so a body cut to the length limit never takes the choices with it.
49
+ choices?: string | undefined
45
50
  issue_url: string | undefined
46
51
  pr_url: string | undefined
47
52
  }
@@ -149,6 +154,16 @@ function is_notify_disabled(): boolean {
149
154
  return parse_environment_string(process.env[NOTIFY_SWITCH_KEY]).toLowerCase() === NOTIFY_OFF_VALUE
150
155
  }
151
156
 
157
+ // The line a skipped send prints, or `undefined` for a send that goes out. The blanket opt-out wins
158
+ // over the per-type opt-in, so `off` stays the one switch that silences everything.
159
+ function skip_line(task_type: TelegramTaskType): string | undefined {
160
+ if (is_notify_disabled()) {
161
+ return `🔕 Telegram notifications are disabled (${NOTIFY_SWITCH_KEY}=${NOTIFY_OFF_VALUE}).`
162
+ }
163
+
164
+ return notify_opt_in.is_skipped(task_type) ? notify_opt_in.skip_line(task_type) : undefined
165
+ }
166
+
152
167
  function build_header(task_type: TelegramTaskType, repo_name: string | undefined): string {
153
168
  const { icon, label } = TASK_DEFINITIONS[task_type]
154
169
  if (repo_name === undefined || repo_name.length === 0) return `${icon} ${label}`
@@ -185,6 +200,7 @@ function build_blocks(input: TelegramSendInput): Array<string> {
185
200
  const blocks: Array<string> = [build_title_block(input)]
186
201
 
187
202
  push_if_present(blocks, input.body)
203
+ push_if_present(blocks, input.choices)
188
204
  blocks.push(...build_url_parts(input))
189
205
 
190
206
  return blocks
@@ -243,13 +259,13 @@ function build_send_failure(error: unknown, config: TelegramConfig): Error {
243
259
  //
244
260
  // The failure it throws carries a `cause`, and that cause is redacted too — see `build_send_failure`.
245
261
  //
246
- // The opt-out is checked here, before `load_config`, so every caller — the tolerant form included —
247
- // skips the same way and none has to repeat the check.
262
+ // The opt-out and the per-type opt-in are checked here, before `load_config`, so every caller — the
263
+ // tolerant form included — skips the same way and none has to repeat the check.
248
264
  async function send(input: TelegramSendInput): Promise<void> {
249
- if (is_notify_disabled()) {
250
- console.info(
251
- `🔕 Telegram notifications are disabled (${NOTIFY_SWITCH_KEY}=${NOTIFY_OFF_VALUE}).`,
252
- )
265
+ const skipped = skip_line(input.task_type)
266
+
267
+ if (skipped !== undefined) {
268
+ console.info(skipped)
253
269
 
254
270
  return
255
271
  }
@@ -341,7 +357,7 @@ async function warn(input: WarningInput): Promise<boolean> {
341
357
  )
342
358
  }
343
359
 
344
- interface ConfirmInput {
360
+ interface ConfirmInput extends ConfirmationOptions {
345
361
  issue_title: string
346
362
  body: string
347
363
  recovery: string
@@ -365,6 +381,7 @@ async function confirm(input: ConfirmInput): Promise<boolean> {
365
381
  repo_name: gh_spawn.get_repo_name_with_owner_within(REPO_LOOKUP_TIMEOUT_MS),
366
382
  issue_title: input.issue_title,
367
383
  body: input.body,
384
+ choices: confirmation_options.block(input),
368
385
  issue_url: undefined,
369
386
  pr_url: undefined,
370
387
  },
@@ -463,4 +480,4 @@ const telegram_notify = {
463
480
  }
464
481
 
465
482
  export { telegram_notify, build_text, TASK_DEFINITIONS, telegram_environment_schema }
466
- export type { TelegramSendInput, TelegramTaskType }
483
+ export type { ConfirmInput, TelegramSendInput, TelegramTaskType }
@@ -1,4 +1,5 @@
1
1
  import { cli_body } from '#scripts/josh/cli-body'
2
+ import { confirmation_options } from './confirmation-options'
2
3
  import type { TelegramSendInput, TelegramTaskType } from './telegram-notify'
3
4
 
4
5
  /* eslint-disable @typescript-eslint/naming-convention */
@@ -10,6 +11,8 @@ interface CliValues {
10
11
  'body-file'?: string
11
12
  'issue-url'?: string
12
13
  'pr-url'?: string
14
+ recommended?: string
15
+ alternative?: Array<string>
13
16
  }
14
17
  /* eslint-enable @typescript-eslint/naming-convention */
15
18
 
@@ -67,12 +70,41 @@ function normalize_body(values: CliValues): string | undefined {
67
70
  })
68
71
  }
69
72
 
73
+ // **A confirmation without a recommended choice is refused, not sent**: the person it reaches is
74
+ // off-screen, and a stop that names no choice leaves them to reconstruct one. `--alternative` may be
75
+ // absent — a stop with one way forward says so — so only `--recommended` is required.
76
+ function require_recommended(values: CliValues): string {
77
+ const recommended = values.recommended?.trim() ?? ''
78
+
79
+ if (recommended.length === 0) {
80
+ throw new Error('--task-type confirmation needs --recommended "<the recommended choice>".')
81
+ }
82
+
83
+ return recommended
84
+ }
85
+
86
+ function given_alternatives(values: CliValues): Array<string> {
87
+ return (values.alternative ?? []).filter((alternative) => alternative.trim().length > 0)
88
+ }
89
+
90
+ function build_choices(values: CliValues, task_type: TelegramTaskType): string | undefined {
91
+ if (task_type !== 'confirmation') return undefined
92
+
93
+ return confirmation_options.block({
94
+ recommended: require_recommended(values),
95
+ alternatives: given_alternatives(values),
96
+ })
97
+ }
98
+
70
99
  function build_input(input: { values: CliValues; context: ResolvedContext }): TelegramSendInput {
100
+ const task_type = parse_task_type(input.values['task-type'])
101
+
71
102
  return {
72
- task_type: parse_task_type(input.values['task-type']),
103
+ task_type,
73
104
  repo_name: coalesce(input.values['repo-name'], input.context.repo_name),
74
105
  issue_title: coalesce(input.values['issue-title'], input.context.issue_title),
75
106
  body: normalize_body(input.values),
107
+ choices: build_choices(input.values, task_type),
76
108
  issue_url: input.values['issue-url'],
77
109
  pr_url: input.values['pr-url'],
78
110
  }
@@ -14,10 +14,10 @@ const REPO_NAME_SEPARATOR = '/'
14
14
  const ARGV_OFFSET = 2
15
15
  const FAILURE_EXIT_CODE = 1
16
16
  const USAGE =
17
- 'Usage: josh notify [--task-type <type>] [--body <text> | --body-file <path>] [--issue-url <url>] [--pr-url <url>] [--issue-title <text>] [--repo-name <name>]'
17
+ 'Usage: josh notify [--task-type <type>] [--body <text> | --body-file <path>] [--issue-url <url>] [--pr-url <url>] [--issue-title <text>] [--repo-name <name>] [--recommended <text>] [--alternative <text>]...'
18
18
 
19
19
  // The free-text values a caller writes, often opening with a Markdown `-` bullet.
20
- const FREE_TEXT_FLAGS = ['--body', '--issue-title']
20
+ const FREE_TEXT_FLAGS = ['--body', '--issue-title', '--recommended', '--alternative']
21
21
 
22
22
  const OPTIONS = {
23
23
  'task-type': { type: 'string' },
@@ -27,6 +27,8 @@ const OPTIONS = {
27
27
  'body-file': { type: 'string' },
28
28
  'issue-url': { type: 'string' },
29
29
  'pr-url': { type: 'string' },
30
+ recommended: { type: 'string' },
31
+ alternative: { type: 'string', multiple: true },
30
32
  } as const
31
33
 
32
34
  // `undefined` for a line nobody can read — an unknown flag, or a flag given no value.
@@ -8,6 +8,7 @@ import {
8
8
  rmSync,
9
9
  } from 'node:fs'
10
10
  import path from 'node:path'
11
+ import { process_identity } from '#scripts/josh/process-identity'
11
12
  import { file_reader } from '#scripts/lib/read-file'
12
13
  import {
13
14
  LEDGER_FILE_EXTENSION,
@@ -38,8 +39,6 @@ import { observation_ledger_line } from './observation-ledger-line'
38
39
  // migration with no await point cannot be interleaved with another write in the same process.
39
40
 
40
41
  const NEWLINE = '\n'
41
- const NO_SIGNAL = 0
42
- const NO_SUCH_PROCESS = 'ESRCH'
43
42
 
44
43
  function claim_path(legacy: string): string {
45
44
  return `${legacy}.${String(process.pid)}${MIGRATION_CLAIM_SUFFIX}`
@@ -55,18 +54,6 @@ function claim(from: string, to: string): boolean {
55
54
  }
56
55
  }
57
56
 
58
- // Only `ESRCH` means the process is gone; `EPERM` means it runs under another user, and its claim is
59
- // still its own.
60
- function is_alive(pid: number): boolean {
61
- try {
62
- process.kill(pid, NO_SIGNAL)
63
-
64
- return true
65
- } catch (error) {
66
- return (error as NodeJS.ErrnoException).code !== NO_SUCH_PROCESS
67
- }
68
- }
69
-
70
57
  // A claim is named `<old ledger>.<pid>.migrating`, so the pid is what sits between the two.
71
58
  function is_stale_claim(name: string, legacy_name: string): boolean {
72
59
  const prefix = `${legacy_name}.`
@@ -75,7 +62,8 @@ function is_stale_claim(name: string, legacy_name: string): boolean {
75
62
 
76
63
  const pid = Number(name.slice(prefix.length, -MIGRATION_CLAIM_SUFFIX.length))
77
64
 
78
- return Number.isSafeInteger(pid) && !is_alive(pid)
65
+ // A process under another user still holds its claim (`process_identity.is_present_pid`).
66
+ return Number.isSafeInteger(pid) && !process_identity.is_present_pid(pid)
79
67
  }
80
68
 
81
69
  function stale_claims(legacy: string): ReadonlyArray<string> {