@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.
- package/.claude/skills/workflow-commands/backlogrun-handoff-report.md +1 -1
- package/.claude/skills/workflow-commands/backlogrun-park.md +2 -0
- package/.claude/skills/workflow-commands/backlogrun-steps.md +9 -7
- package/.claude/skills/workflow-commands/backlogrun.md +2 -2
- package/.claude/skills/workflow-commands/fullrun-steps.md +1 -1
- package/.claude/skills/workflow-commands/halfrun.md +3 -2
- package/.claude/skills/workflow-commands/kickoff.md +12 -13
- package/.claude/skills/workflow-commands/observation-filing.md +1 -1
- package/.claude/skills/workflow-commands/prerequisite.md +6 -0
- package/.claude/skills/workflow-commands/prrun.md +2 -1
- package/.claude/skills/workflow-commands/retrospective.md +5 -5
- package/.claude/skills/workflow-commands/split-assessment.md +2 -2
- package/CLAUDE.md +1 -1
- package/README.md +10 -19
- package/dist/CLAUDE.md +1 -1
- package/dist/hooks/batch-guard.js +6 -6
- package/dist/hooks/{chunk-3LE6VLSR.js → chunk-3C4XRYG6.js} +18 -5
- package/dist/hooks/{chunk-7KMN5QB5.js → chunk-5H4SSDSD.js} +3 -3
- package/dist/hooks/{chunk-YZXYOAZ3.js → chunk-7WQBPLUN.js} +11 -5
- package/dist/hooks/{chunk-2G5F2YCD.js → chunk-D5QBTOCZ.js} +3 -3
- package/dist/hooks/{chunk-MU4LJFIV.js → chunk-GPLGZ6IC.js} +3 -3
- package/dist/hooks/{chunk-MSYSUSY6.js → chunk-HFGWYGUV.js} +5 -5
- package/dist/hooks/{chunk-JXQBK323.js → chunk-HHI6FO3R.js} +132 -105
- package/dist/hooks/{chunk-XER6SSIB.js → chunk-LIAFCXL7.js} +17 -7
- package/dist/hooks/{chunk-4BHUPFKF.js → chunk-OLMO72ON.js} +1 -1
- package/dist/hooks/{chunk-EMB55BDF.js → chunk-TBJFTSUY.js} +2 -2
- package/dist/hooks/{chunk-5SJSSCJG.js → chunk-UT7YC3N2.js} +1 -1
- package/dist/hooks/{chunk-QL6QTPWN.js → chunk-WNLVGT7E.js} +3 -3
- package/dist/hooks/{chunk-YW452ZAX.js → chunk-Z4XI7CZS.js} +2 -2
- package/dist/hooks/codex-hook-adapter.js +13 -13
- package/dist/hooks/duplicate-read-guard.js +7 -7
- package/dist/hooks/format-edited.js +3 -3
- package/dist/hooks/inputs.json +229 -226
- package/dist/hooks/investigation-guard.js +7 -7
- package/dist/hooks/pretool-guard.js +12 -12
- package/dist/hooks/session-lang.js +3 -3
- package/dist/hooks/stop-guard.js +64 -11
- package/dist/josh.js +34 -11
- package/package.json +1 -1
- package/prompts/collaboration-workflow/issue-template.md +10 -8
- package/prompts/collaboration-workflow/shell-body.md +1 -1
- package/prompts/review.md +8 -4
- package/scripts/auto-ok/auto-ok-judge-cli.ts +125 -0
- package/scripts/auto-ok/auto-ok-judge-read.ts +95 -0
- package/scripts/auto-ok/auto-ok-judge-write.ts +109 -0
- package/scripts/auto-ok/auto-ok-judge.ts +67 -0
- package/scripts/backlog/backlog-drive-cli.ts +14 -0
- package/scripts/backlog/backlog-drive.ts +22 -3
- package/scripts/document/document-byte-budget.ts +1 -1
- package/scripts/fresh-machine/fresh-machine-patterns.ts +60 -15
- package/scripts/gate/project-checks.ts +5 -2
- package/scripts/gh/git-gh-issue-rest.ts +1 -0
- package/scripts/gh/git-gh-issue.ts +9 -0
- package/scripts/gh/git-pr-ai-review.ts +12 -1
- package/scripts/gh/git-pr-confirmation.ts +11 -7
- package/scripts/init/init-actions.ts +3 -3
- package/scripts/init/init-ai-copy.ts +1 -1
- package/scripts/init/init-basic.ts +1 -1
- package/scripts/init/init-bootstrap.ts +6 -2
- package/scripts/init/init-dry-run-report.ts +150 -0
- package/scripts/init/init-dry-run.ts +189 -0
- package/scripts/init/init-install.ts +8 -1
- package/scripts/init/init.ts +57 -19
- package/scripts/init/kit-setup-state.ts +11 -17
- package/scripts/init/own-formatter.ts +21 -0
- package/scripts/init/project-profile.ts +25 -6
- package/scripts/init/start-dry-run.ts +70 -0
- package/scripts/init/start-plan.ts +8 -3
- package/scripts/init/start-setup-pr.ts +13 -24
- package/scripts/init/start-steps.ts +16 -1
- package/scripts/init/start.ts +38 -18
- package/scripts/init/working-tree.ts +116 -0
- package/scripts/issue/issue-auto-ok.ts +68 -99
- package/scripts/issue/issue-file-cli.ts +17 -17
- package/scripts/issue/issue-file.ts +1 -6
- package/scripts/issue/issue-labels.ts +6 -2
- package/scripts/issue/issue-release-cli.ts +69 -23
- package/scripts/issue/issue-release.ts +3 -1
- package/scripts/josh/josh-commands-ai.ts +11 -0
- package/scripts/josh/josh-commands-issue.ts +3 -3
- package/scripts/josh/josh-commands-project.ts +2 -2
- package/scripts/josh/josh-commands-workflow.ts +1 -1
- package/scripts/josh/process-identity.ts +32 -6
- package/scripts/lane/lane-broker-server.ts +69 -0
- package/scripts/lane/lane-session-broker.ts +37 -14
- package/scripts/notify/confirmation-options.ts +59 -0
- package/scripts/notify/notify-opt-in.ts +33 -0
- package/scripts/notify/telegram-notify.ts +25 -8
- package/scripts/notify/telegram-test-logic.ts +33 -1
- package/scripts/notify/telegram-test.ts +4 -2
- package/scripts/observations/observation-ledger-migrate.ts +3 -15
- package/scripts/release/release-issue-close.ts +133 -0
- package/scripts/release/release-publish.ts +12 -1
- package/scripts/repo/repository-labels.ts +11 -1
- package/scripts/review/comment-ranges.ts +199 -0
- package/scripts/review/review-brief-cli.ts +22 -5
- package/scripts/review/review-code-tree.ts +119 -0
- package/scripts/review/review-round2-cli.ts +16 -5
- package/scripts/review/review-round2.ts +30 -10
- package/scripts/review/review-stamps.ts +22 -1
- package/scripts/rules/shell-body-trigger.ts +3 -2
- package/scripts/rules/stop-rules.ts +2 -1
- package/scripts/run/entry/run-entry-stop.ts +6 -3
- package/scripts/run/run-stop-notify.ts +2 -0
- 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
|
-
|
|
83
|
-
|
|
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
|
|
89
|
-
} catch {
|
|
90
|
-
return
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
},
|
|
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
|
-
|
|
281
|
+
is_live: (server: BrokerServer) => boolean,
|
|
267
282
|
): response is BrokerResponse {
|
|
268
|
-
|
|
283
|
+
if (response === undefined) return false
|
|
269
284
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
297
|
+
target: string,
|
|
279
298
|
session: string,
|
|
280
299
|
nonce: string,
|
|
281
300
|
): 'wait' | 'success' | 'failure' {
|
|
282
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
|
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
|
-
|
|
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> {
|