@joshuafolkken/kit 1.1453.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 (38) hide show
  1. package/.claude/skills/workflow-commands/backlogrun-handoff-report.md +1 -1
  2. package/.claude/skills/workflow-commands/halfrun.md +2 -1
  3. package/.claude/skills/workflow-commands/prrun.md +2 -1
  4. package/CLAUDE.md +1 -1
  5. package/README.md +2 -9
  6. package/dist/CLAUDE.md +1 -1
  7. package/dist/hooks/batch-guard.js +3 -3
  8. package/dist/hooks/{chunk-EF5GQ6WS.js → chunk-5H4SSDSD.js} +2 -2
  9. package/dist/hooks/{chunk-Y3GKIKGW.js → chunk-7WQBPLUN.js} +1 -1
  10. package/dist/hooks/{chunk-LBFYEOXJ.js → chunk-D5QBTOCZ.js} +2 -2
  11. package/dist/hooks/{chunk-W5ZKGTSY.js → chunk-GPLGZ6IC.js} +1 -1
  12. package/dist/hooks/{chunk-SHYUQOIS.js → chunk-HFGWYGUV.js} +3 -3
  13. package/dist/hooks/{chunk-5C7L6FRC.js → chunk-HHI6FO3R.js} +8 -8
  14. package/dist/hooks/{chunk-MBJYQPEK.js → chunk-LIAFCXL7.js} +1 -1
  15. package/dist/hooks/{chunk-D3NJXN5M.js → chunk-TBJFTSUY.js} +1 -1
  16. package/dist/hooks/{chunk-NO4AALP5.js → chunk-WNLVGT7E.js} +2 -2
  17. package/dist/hooks/codex-hook-adapter.js +9 -9
  18. package/dist/hooks/duplicate-read-guard.js +4 -4
  19. package/dist/hooks/format-edited.js +2 -2
  20. package/dist/hooks/inputs.json +232 -230
  21. package/dist/hooks/investigation-guard.js +4 -4
  22. package/dist/hooks/pretool-guard.js +8 -8
  23. package/dist/hooks/stop-guard.js +61 -8
  24. package/dist/josh.js +1 -1
  25. package/package.json +1 -1
  26. package/prompts/collaboration-workflow/shell-body.md +1 -1
  27. package/scripts/gh/git-pr-ai-review.ts +12 -1
  28. package/scripts/gh/git-pr-confirmation.ts +11 -7
  29. package/scripts/josh/josh-commands-workflow.ts +1 -1
  30. package/scripts/notify/confirmation-options.ts +59 -0
  31. package/scripts/notify/notify-opt-in.ts +33 -0
  32. package/scripts/notify/telegram-notify.ts +25 -8
  33. package/scripts/notify/telegram-test-logic.ts +33 -1
  34. package/scripts/notify/telegram-test.ts +4 -2
  35. package/scripts/rules/shell-body-trigger.ts +3 -2
  36. package/scripts/rules/stop-rules.ts +2 -1
  37. package/scripts/run/entry/run-entry-stop.ts +6 -3
  38. package/scripts/run/run-stop-notify.ts +2 -0
@@ -13,7 +13,7 @@ import {
13
13
  repo_party,
14
14
  run_cut,
15
15
  run_hold
16
- } from "./chunk-Y3GKIKGW.js";
16
+ } from "./chunk-7WQBPLUN.js";
17
17
  import {
18
18
  agent_headless,
19
19
  agent_role_profile,
@@ -30,12 +30,12 @@ import {
30
30
  import "./chunk-CDY4NFQG.js";
31
31
  import {
32
32
  hook_decision
33
- } from "./chunk-W5ZKGTSY.js";
33
+ } from "./chunk-GPLGZ6IC.js";
34
34
  import {
35
35
  time_density_hook,
36
36
  time_hook_transcript,
37
37
  time_reported_failure
38
- } from "./chunk-MBJYQPEK.js";
38
+ } from "./chunk-LIAFCXL7.js";
39
39
  import {
40
40
  lane_child_marker
41
41
  } from "./chunk-OLMO72ON.js";
@@ -317,6 +317,52 @@ var backlog_ready = {
317
317
 
318
318
  // scripts/notify/telegram-notify.ts
319
319
  import { z } from "zod";
320
+
321
+ // scripts/notify/confirmation-options.ts
322
+ var JAPANESE_LANG_PREFIX = "ja";
323
+ var JAPANESE_HEADINGS = {
324
+ recommended: "\u63A8\u5968:",
325
+ alternatives: "\u4ED6\u306E\u9078\u629E\u80A2:",
326
+ none: "\u306A\u3057"
327
+ };
328
+ var ENGLISH_HEADINGS = {
329
+ recommended: "Recommended:",
330
+ alternatives: "Alternatives:",
331
+ none: "none"
332
+ };
333
+ function headings() {
334
+ const { lang } = session_language.resolve_session_lang();
335
+ return lang.toLowerCase().startsWith(JAPANESE_LANG_PREFIX) ? JAPANESE_HEADINGS : ENGLISH_HEADINGS;
336
+ }
337
+ function alternatives_block(options, labels) {
338
+ if (options.alternatives.length === 0) return `${labels.alternatives} ${labels.none}`;
339
+ const lines = options.alternatives.map((alternative) => `- ${alternative}`);
340
+ return [labels.alternatives, ...lines].join("\n");
341
+ }
342
+ function block(options) {
343
+ const labels = headings();
344
+ return `${labels.recommended} ${options.recommended}
345
+ ${alternatives_block(options, labels)}`;
346
+ }
347
+ var confirmation_options = { block };
348
+
349
+ // scripts/notify/notify-opt-in.ts
350
+ var OPT_IN_KEY = "JOSH_NOTIFY_OPT_IN";
351
+ var OPTIONAL_TASK_TYPES = /* @__PURE__ */ new Set(["planning", "completion"]);
352
+ var LIST_SEPARATOR = ",";
353
+ function opted_in_types() {
354
+ return (process.env[OPT_IN_KEY] ?? "").split(LIST_SEPARATOR).map((entry) => entry.trim().toLowerCase()).filter((entry) => entry.length > 0);
355
+ }
356
+ function is_skipped(task_type) {
357
+ if (!OPTIONAL_TASK_TYPES.has(task_type)) return false;
358
+ return !opted_in_types().includes(task_type);
359
+ }
360
+ function skip_line(task_type) {
361
+ return `\u{1F515} ${task_type} notifications are not opted in (${OPT_IN_KEY}).`;
362
+ }
363
+ var notify_opt_in = { is_skipped, skip_line };
364
+
365
+ // scripts/notify/telegram-notify.ts
320
366
  var TELEGRAM_API_BASE = "https://api.telegram.org";
321
367
  var TELEGRAM_TEXT_LIMIT = 4096;
322
368
  var BODY_TRUNCATION_MARK = "\n...";
@@ -377,6 +423,12 @@ function load_config() {
377
423
  function is_notify_disabled() {
378
424
  return parse_environment_string(process.env[NOTIFY_SWITCH_KEY]).toLowerCase() === NOTIFY_OFF_VALUE;
379
425
  }
426
+ function skip_line2(task_type) {
427
+ if (is_notify_disabled()) {
428
+ return `\u{1F515} Telegram notifications are disabled (${NOTIFY_SWITCH_KEY}=${NOTIFY_OFF_VALUE}).`;
429
+ }
430
+ return notify_opt_in.is_skipped(task_type) ? notify_opt_in.skip_line(task_type) : void 0;
431
+ }
380
432
  function build_header(task_type, repo_name) {
381
433
  const { icon, label } = TASK_DEFINITIONS[task_type];
382
434
  if (repo_name === void 0 || repo_name.length === 0) return `${icon} ${label}`;
@@ -402,6 +454,7 @@ function build_url_parts(input) {
402
454
  function build_blocks(input) {
403
455
  const blocks = [build_title_block(input)];
404
456
  push_if_present(blocks, input.body);
457
+ push_if_present(blocks, input.choices);
405
458
  blocks.push(...build_url_parts(input));
406
459
  return blocks;
407
460
  }
@@ -435,10 +488,9 @@ function build_send_failure(error, config) {
435
488
  return new Error(`${SEND_FAILED_PREFIX}: ${reason}`, { cause: new Error(reason) });
436
489
  }
437
490
  async function send(input) {
438
- if (is_notify_disabled()) {
439
- console.info(
440
- `\u{1F515} Telegram notifications are disabled (${NOTIFY_SWITCH_KEY}=${NOTIFY_OFF_VALUE}).`
441
- );
491
+ const skipped = skip_line2(input.task_type);
492
+ if (skipped !== void 0) {
493
+ console.info(skipped);
442
494
  return;
443
495
  }
444
496
  const config = load_config();
@@ -487,6 +539,7 @@ async function confirm(input) {
487
539
  repo_name: gh_spawn.get_repo_name_with_owner_within(REPO_LOOKUP_TIMEOUT_MS),
488
540
  issue_title: input.issue_title,
489
541
  body: input.body,
542
+ choices: confirmation_options.block(input),
490
543
  issue_url: void 0,
491
544
  pr_url: void 0
492
545
  },
@@ -762,7 +815,7 @@ var stop_payload_schema = z3.object({
762
815
  last_assistant_message: z3.string().nullish()
763
816
  });
764
817
  var NO_OUTCOME = { reason: void 0 };
765
- var STOP_NOTIFY_REASON = '\u26D4 mid-workflow stop notification: this working tree is still held by a run and no `confirmation` Telegram was sent this turn, so a person is being left to wait off-screen without knowing the run paused. `CLAUDE.md` \u2192 "Mid-workflow stop notification" requires `pnpm josh notify --task-type confirmation --issue-url "<url>" --body=$\'<reason>\'` before any mid-workflow pause. Send it, then end with a one-line confirmation that it was sent \u2014 do not repeat your previous reply. This fires only while the hold is held and no notify is on the transcript, and `stop_hook_active` lets a second stop through so a run is never wedged.';
818
+ var STOP_NOTIFY_REASON = '\u26D4 mid-workflow stop notification: this working tree is still held by a run and no `confirmation` Telegram was sent this turn, so a person is being left to wait off-screen without knowing the run paused. `CLAUDE.md` \u2192 "Mid-workflow stop notification" requires `pnpm josh notify --task-type confirmation --issue-url "<url>" --body=$\'<reason>\' --recommended "<choice>" --alternative "<choice>"` before any mid-workflow pause. Send it, then end with a one-line confirmation that it was sent \u2014 do not repeat your previous reply. This fires only while the hold is held and no notify is on the transcript, and `stop_hook_active` lets a second stop through so a run is never wedged.';
766
819
  var HOLD_RELEASE_REASON = "\u26D4 working-tree hold not released: this working tree is clean but its `run:hold` record is still in place, so the next run here runs `pnpm josh ms` believing the tree is free while you hold it. The workflow-commands skill \u2192 `working-tree-hold.md`: a stop that leaves the tree clean releases the hold with `pnpm josh run:release <N>` (bare for a `new` entry). A `halfrun` pre-commit stop and a `needs-human-review` stop keep the hold because their tree is dirty, and a `prrun` stop keeps it by its stop mark \u2014 this row is silent there. Release it, then end with a one-line confirmation that it was released \u2014 do not repeat your previous reply.";
767
820
  var ISSUE_CITATION_REASON = "Session-facing output cites an Issue as a number-link so the reader can click through and see which repository it is (`CLAUDE.md`, `prompts/collaboration-workflow/issue-citation.md`).";
768
821
  function build_citation_reason(references) {
package/dist/josh.js CHANGED
@@ -2747,7 +2747,7 @@ var WORKFLOW_COMMANDS = {
2747
2747
  description: "Send Telegram notification",
2748
2748
  category: "Workflow",
2749
2749
  reference: [
2750
- "[--task-type <type>] [--body <text> | --body-file <path>] [--issue-url <url>] [--pr-url <url>] [--issue-title <text>] [--repo-name <name>]",
2750
+ "[--task-type <type>] [--body <text> | --body-file <path>] [--issue-url <url>] [--pr-url <url>] [--issue-title <text>] [--repo-name <name>] [--recommended <text>] [--alternative <text>]...",
2751
2751
  "automation",
2752
2752
  ["notifications"]
2753
2753
  ],
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.1453.0",
2
+ "version": "1.1455.0",
3
3
  "name": "@joshuafolkken/kit",
4
4
  "description": "Development toolkit for AI-assisted projects: formatting, AI assistant rules and a Git/GitHub Issue workflow for any repository, plus lint, type check, tests and hooks for Node projects",
5
5
  "keywords": [
@@ -25,7 +25,7 @@ zsh は `pnpm josh ms` を実行し、その標準出力を本文に埋め込む
25
25
  pnpm josh issue:comment <N> --body-file <path>
26
26
  gh api -X PATCH repos/{owner}/{repo}/issues/<N> --field body=@<path>
27
27
  pnpm josh followup "<title> #<N>" --notify-message-file <path>
28
- pnpm josh notify --task-type confirmation --issue-url "<url>" --body-file <path>
28
+ pnpm josh notify --task-type confirmation --issue-url "<url>" --body-file <path> --recommended "<choice>"
29
29
  ```
30
30
 
31
31
  **PR コメントも同じ経路である** — REST では pull request のコメントは issue のコメントであり、`pnpm josh issue:comment <N> --body-file <path>` がそのまま使える。`gh issue comment` / `gh pr comment` は使わない(GraphQL 経由。理由は `docs/maintainers/shell-body-rationale.md` → "Why no `gh` subcommand is written in an executable block")。
@@ -89,6 +89,10 @@ async function fetch_review_comments(
89
89
  return parsed?.map((comment) => to_review_comment(comment))
90
90
  }
91
91
 
92
+ // The other choice both AI-review stops offer: a person who has looked records why and moves on.
93
+ const IGNORE_ALTERNATIVE =
94
+ 'Re-run `pnpm josh followup --ai-review-ignore-reason "<reason>"` if the gate does not apply'
95
+
92
96
  async function handle_blockers(input: {
93
97
  branch_name: string
94
98
  ignore_reason: string | undefined
@@ -105,7 +109,12 @@ async function handle_blockers(input: {
105
109
  return
106
110
  }
107
111
 
108
- await git_pr_confirmation.notify_confirmation({ context: input.context, body })
112
+ await git_pr_confirmation.notify_confirmation({
113
+ context: input.context,
114
+ body,
115
+ recommended: 'Fix the findings and re-run `pnpm josh followup`',
116
+ alternatives: [IGNORE_ALTERNATIVE],
117
+ })
109
118
 
110
119
  throw new Error(body)
111
120
  }
@@ -140,6 +149,8 @@ async function handle_unreadable_comments(input: {
140
149
  await git_pr_confirmation.notify_confirmation({
141
150
  context: input.context,
142
151
  body: UNREADABLE_COMMENTS_BODY,
152
+ recommended: 'Re-run `pnpm josh followup` once the comments can be read',
153
+ alternatives: [IGNORE_ALTERNATIVE],
143
154
  })
144
155
 
145
156
  throw new Error(UNREADABLE_COMMENTS_BODY)
@@ -1,3 +1,7 @@
1
+ import {
2
+ confirmation_options,
3
+ type ConfirmationOptions,
4
+ } from '#scripts/notify/confirmation-options'
1
5
  import { telegram_notify, type TelegramSendInput } from '#scripts/notify/telegram-notify'
2
6
 
3
7
  // What to type when the confirmation notification itself could not be delivered.
@@ -5,7 +9,7 @@ import { telegram_notify, type TelegramSendInput } from '#scripts/notify/telegra
5
9
  // commands and a backtick inside shell double quotes is evaluated before the command runs.
6
10
  const CONFIRMATION_RECOVERY =
7
11
  'send it by hand — `pnpm josh notify --task-type confirmation --issue-url <url> ' +
8
- '--body-file <path>`'
12
+ '--body-file <path> --recommended "<choice>"`'
9
13
 
10
14
  interface TelegramContext {
11
15
  repo_name: string | undefined
@@ -14,15 +18,18 @@ interface TelegramContext {
14
18
  pr_url: string | undefined
15
19
  }
16
20
 
17
- function build_confirmation_input(input: {
21
+ interface ConfirmationInput extends ConfirmationOptions {
18
22
  context: TelegramContext
19
23
  body: string
20
- }): TelegramSendInput {
24
+ }
25
+
26
+ function build_confirmation_input(input: ConfirmationInput): TelegramSendInput {
21
27
  return {
22
28
  task_type: 'confirmation',
23
29
  repo_name: input.context.repo_name,
24
30
  issue_title: input.context.issue_title,
25
31
  body: input.body,
32
+ choices: confirmation_options.block(input),
26
33
  issue_url: input.context.issue_url,
27
34
  pr_url: input.context.pr_url,
28
35
  }
@@ -34,10 +41,7 @@ function build_confirmation_input(input: {
34
41
  //
35
42
  // Unlike the completion notification, this one *has* a recovery: a `confirmation` is exactly what
36
43
  // `josh notify` is for, and the run stops here anyway, so re-sending it by hand loses nothing.
37
- async function notify_confirmation(input: {
38
- context: TelegramContext
39
- body: string
40
- }): Promise<void> {
44
+ async function notify_confirmation(input: ConfirmationInput): Promise<void> {
41
45
  await telegram_notify.send_or_report(build_confirmation_input(input), CONFIRMATION_RECOVERY)
42
46
  }
43
47
 
@@ -47,7 +47,7 @@ const WORKFLOW_COMMANDS: Record<string, CommandEntry> = {
47
47
  description: 'Send Telegram notification',
48
48
  category: 'Workflow',
49
49
  reference: [
50
- '[--task-type <type>] [--body <text> | --body-file <path>] [--issue-url <url>] [--pr-url <url>] [--issue-title <text>] [--repo-name <name>]',
50
+ '[--task-type <type>] [--body <text> | --body-file <path>] [--issue-url <url>] [--pr-url <url>] [--issue-title <text>] [--repo-name <name>] [--recommended <text>] [--alternative <text>]...',
51
51
  'automation',
52
52
  ['notifications'],
53
53
  ],
@@ -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.
@@ -6,7 +6,8 @@
6
6
 
7
7
  // A body handed to a command as an inline double-quoted argument, in the spellings a run reaches for:
8
8
  // `gh`'s field flags (`-f` / `-F` / `--field` / `--raw-field` with `body=`), `gh`'s own `--body`, and
9
- // `josh`'s `--body` and `--notify-message`. The value is captured so the decision can be made on what
9
+ // `josh`'s `--body` and `--notify-message`, and `josh notify`'s `--recommended` / `--alternative` —
10
+ // free text every confirmation carries, naming the very commands a backtick would run. The value is captured so the decision can be made on what
10
11
  // the body actually contains rather than on the flag alone.
11
12
  //
12
13
  // **`-b` is deliberately absent.** It is `gh`'s short `--body`, but it is also `git checkout -b`, and
@@ -14,7 +15,7 @@
14
15
  // spellings end in `-` where this pattern needs whitespace or `=`, so `--body-file <path>` and
15
16
  // `--notify-message-file <path>` cannot match it.
16
17
  const FIELD_FLAGS = '(?:-f|-F|--field|--raw-field)'
17
- const MESSAGE_FLAGS = '(?:--body|--notify-message)'
18
+ const MESSAGE_FLAGS = '(?:--body|--notify-message|--recommended|--alternative)'
18
19
 
19
20
  // **The opening quote sits on either side of `body=`, and both spellings are the same command.** zsh
20
21
  // strips the quotes before `gh` is started, so `-f body="… \`pnpm josh ms\` …"` and
@@ -103,7 +103,8 @@ const STOP_NOTIFY_REASON =
103
103
  '⛔ mid-workflow stop notification: this working tree is still held by a run and no `confirmation` ' +
104
104
  'Telegram was sent this turn, so a person is being left to wait off-screen without knowing the run ' +
105
105
  'paused. `CLAUDE.md` → "Mid-workflow stop notification" requires ' +
106
- '`pnpm josh notify --task-type confirmation --issue-url "<url>" --body=$\'<reason>\'` before any ' +
106
+ '`pnpm josh notify --task-type confirmation --issue-url "<url>" --body=$\'<reason>\' ' +
107
+ '--recommended "<choice>" --alternative "<choice>"` before any ' +
107
108
  'mid-workflow pause. Send it, then end with a one-line confirmation that it was sent — do not repeat ' +
108
109
  'your previous reply. This fires only while the hold is held and no notify is on the transcript, and ' +
109
110
  '`stop_hook_active` lets a second stop through so a run is never wedged.'
@@ -1,6 +1,6 @@
1
1
  import { issue_cite } from '#scripts/issue/issue-cite'
2
2
  import { josh_command } from '#scripts/josh/josh-run'
3
- import { telegram_notify } from '#scripts/notify/telegram-notify'
3
+ import { telegram_notify, type ConfirmInput } from '#scripts/notify/telegram-notify'
4
4
  import { lane_park } from '#scripts/rules/lane-park'
5
5
 
6
6
  // What `run:entry` does on the way out of a stop it decided itself.
@@ -26,14 +26,15 @@ interface StopNotice {
26
26
  }
27
27
 
28
28
  interface StopPorts {
29
- notify: (input: { issue_title: string; body: string; recovery: string }) => Promise<boolean>
29
+ notify: (input: ConfirmInput) => Promise<boolean>
30
30
  release: (issue_number: string) => Promise<boolean>
31
31
  is_parking_child: () => boolean
32
32
  }
33
33
 
34
34
  const RECOVERY = 'If this did not arrive, the run:entry output in the session names the stop.'
35
+ const LATER_ALTERNATIVE = 'Leave the Issue for a later session — nothing was changed'
35
36
  const NOTIFY_FAILED_NOTE =
36
- '(the confirmation Telegram could not be sent — send it with `pnpm josh notify --task-type confirmation` before stopping)'
37
+ '(the confirmation Telegram could not be sent — send it with `pnpm josh notify --task-type confirmation --recommended "<choice>"` before stopping)'
37
38
  const RELEASED_NOTE = '(hold released by run:entry — the tree is free for the resume)'
38
39
  const RELEASE_FAILED_NOTE =
39
40
  '(the hold could not be released — run `pnpm josh run:release` before stopping)'
@@ -78,6 +79,8 @@ async function notify_stop(notice: StopNotice, ports: StopPorts): Promise<void>
78
79
  issue_title: `${notice.command} ${issue_cite.plain(notice.issue_number)} stopped`,
79
80
  body: body_of(notice),
80
81
  recovery: RECOVERY,
82
+ recommended: `Run \`${notice.command} ${issue_cite.plain(notice.issue_number)}\` again in a fresh session`,
83
+ alternatives: [LATER_ALTERNATIVE],
81
84
  })
82
85
 
83
86
  console.info(is_sent ? lane_park.COMMAND_NOTIFY_MARKER : NOTIFY_FAILED_NOTE)
@@ -38,6 +38,8 @@ async function announce(notice: StopNotice): Promise<boolean> {
38
38
  issue_title: STOP_TITLE,
39
39
  body: notice.reason,
40
40
  recovery: STOP_RECOVERY,
41
+ recommended: 'Resolve the reason above, then run `backlogrun` again',
42
+ alternatives: ['Leave the backlog stopped until a later session'],
41
43
  })
42
44
  }
43
45