@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.
- package/.claude/skills/workflow-commands/backlogrun-handoff-report.md +1 -1
- package/.claude/skills/workflow-commands/halfrun.md +2 -1
- package/.claude/skills/workflow-commands/prrun.md +2 -1
- package/CLAUDE.md +1 -1
- package/README.md +2 -9
- package/dist/CLAUDE.md +1 -1
- package/dist/hooks/batch-guard.js +3 -3
- package/dist/hooks/{chunk-EF5GQ6WS.js → chunk-5H4SSDSD.js} +2 -2
- package/dist/hooks/{chunk-Y3GKIKGW.js → chunk-7WQBPLUN.js} +1 -1
- package/dist/hooks/{chunk-LBFYEOXJ.js → chunk-D5QBTOCZ.js} +2 -2
- package/dist/hooks/{chunk-W5ZKGTSY.js → chunk-GPLGZ6IC.js} +1 -1
- package/dist/hooks/{chunk-SHYUQOIS.js → chunk-HFGWYGUV.js} +3 -3
- package/dist/hooks/{chunk-5C7L6FRC.js → chunk-HHI6FO3R.js} +8 -8
- package/dist/hooks/{chunk-MBJYQPEK.js → chunk-LIAFCXL7.js} +1 -1
- package/dist/hooks/{chunk-D3NJXN5M.js → chunk-TBJFTSUY.js} +1 -1
- package/dist/hooks/{chunk-NO4AALP5.js → chunk-WNLVGT7E.js} +2 -2
- package/dist/hooks/codex-hook-adapter.js +9 -9
- package/dist/hooks/duplicate-read-guard.js +4 -4
- package/dist/hooks/format-edited.js +2 -2
- package/dist/hooks/inputs.json +232 -230
- package/dist/hooks/investigation-guard.js +4 -4
- package/dist/hooks/pretool-guard.js +8 -8
- package/dist/hooks/stop-guard.js +61 -8
- package/dist/josh.js +1 -1
- package/package.json +1 -1
- package/prompts/collaboration-workflow/shell-body.md +1 -1
- package/scripts/gh/git-pr-ai-review.ts +12 -1
- package/scripts/gh/git-pr-confirmation.ts +11 -7
- package/scripts/josh/josh-commands-workflow.ts +1 -1
- 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/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/dist/hooks/stop-guard.js
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
repo_party,
|
|
14
14
|
run_cut,
|
|
15
15
|
run_hold
|
|
16
|
-
} from "./chunk-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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.
|
|
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({
|
|
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
|
-
|
|
21
|
+
interface ConfirmationInput extends ConfirmationOptions {
|
|
18
22
|
context: TelegramContext
|
|
19
23
|
body: string
|
|
20
|
-
}
|
|
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
|
|
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.
|
|
@@ -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
|
|
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>\'
|
|
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:
|
|
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
|
|