slack-term 1.43.0 → 1.44.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slack-term",
3
- "version": "1.43.0",
3
+ "version": "1.44.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/slack-term.git"
package/ts/ask.ts CHANGED
@@ -41,6 +41,15 @@ export type AskFound = { answer: string; how: string; who?: string; ts?: string;
41
41
  export const ASK_MARKER = "question";
42
42
  export const ASK_MARKER_PREFIX = `:${ASK_MARKER}: `;
43
43
 
44
+ /** The "explain" pill (`--explain`): pressing it asks the asker to explain the
45
+ * question, and is NEVER an answer. Reported 2026-10-14: an "explain" offered
46
+ * as a numbered choice was collected as the decision and closed the question.
47
+ * Not ❓: ❓ means "none of these fit — my answer follows as a reply", and that
48
+ * reply resolves the question; ℹ️ means "I cannot answer yet", and the question
49
+ * stays open for a real answer after the explanation. */
50
+ export const ASK_EXPLAIN_MARKER = "information_source";
51
+ export const ASK_EXPLAIN_PREFIX = `:${ASK_EXPLAIN_MARKER}: `;
52
+
44
53
  // The copy a reader sees, per language. Japanese is the default and its
45
54
  // strings are a WIRE FORMAT: questions already in flight were posted with them
46
55
  // and are recognised by them, so they must never be reworded. Adding a language
@@ -118,6 +127,14 @@ interface AskCopy {
118
127
  overflowNote: string;
119
128
  otherThread: string;
120
129
  otherHere: string;
130
+ /** The free-text parser matches this exactly (`askIsExplainCopy`), so it is
131
+ * a wire format too: questions in flight carry it. */
132
+ explainLine: string;
133
+ /** WIRE FORMAT — never reword. `--wait`/`--waitFor` find a question's
134
+ * thread note by this exact copy (`askIsExplainNote`), so a reworded note
135
+ * is not found by later runs, which would post a second one, and the old
136
+ * note would stop being excluded from answers and notes. */
137
+ explainNote: string;
121
138
  invalidPrefix: string;
122
139
  answeredVia: (how: string, who: string) => string;
123
140
  howReaction: (glyph: string) => string;
@@ -149,6 +166,8 @@ export const ASK_COPY: Record<AskLang, AskCopy> = {
149
166
  overflowNote: "_11 番目以降はリアクションがないので、返信で答えてください。_",
150
167
  otherThread: `${ASK_MARKER_PREFIX}その他 — スレッドで返信してください`,
151
168
  otherHere: `${ASK_MARKER_PREFIX}その他 — このメッセージに返信してください`,
169
+ explainLine: `${ASK_EXPLAIN_PREFIX}説明して — 押すと質問者に説明を頼みます (回答にはなりません)`,
170
+ explainNote: `${ASK_EXPLAIN_PREFIX}説明のリクエストを受け付けました — このスレッドで説明します。質問はまだ開いているので、回答はそのあとで大丈夫です。`,
152
171
  invalidPrefix: "_:warning: 同時に複数選ばれているため回答として数えていません — どれか 1 つだけ残してください: ",
153
172
  answeredVia: (how, who) => `_${how}で回答済み${who ? ` (${who})` : ""}_`,
154
173
  howReaction: (glyph) => `リアクション ${glyph}`,
@@ -168,6 +187,8 @@ export const ASK_COPY: Record<AskLang, AskCopy> = {
168
187
  overflowNote: "_Choices 11 and up have no reaction — answer them with a reply._",
169
188
  otherThread: `${ASK_MARKER_PREFIX}Other — reply in the thread`,
170
189
  otherHere: `${ASK_MARKER_PREFIX}Other — reply to this message`,
190
+ explainLine: `${ASK_EXPLAIN_PREFIX}Explain — press to ask the asker for more detail (not an answer)`,
191
+ explainNote: `${ASK_EXPLAIN_PREFIX}Explanation requested — it will follow in this thread. The question stays open; answer it after that.`,
171
192
  invalidPrefix: "_:warning: Several choices are selected, so this is not counted as an answer — keep only one: ",
172
193
  answeredVia: (how, who) => `_Answered by ${how}${who ? ` (${who})` : ""}_`,
173
194
  howReaction: (glyph) => `reaction ${glyph}`,
@@ -185,6 +206,22 @@ function askIsOtherLine(line: string | undefined): boolean {
185
206
  return line !== undefined && (line.startsWith(ASK_MARKER_PREFIX) || line.startsWith("❓ "));
186
207
  }
187
208
 
209
+ /** True for the explain line, in either spelling. Matched by prefix, like the
210
+ * other line — used where position alone identifies it (under ❓). */
211
+ function askIsExplainLine(line: string | undefined): boolean {
212
+ return line !== undefined && (line.startsWith(ASK_EXPLAIN_PREFIX) || line.startsWith("ℹ️ "));
213
+ }
214
+
215
+ /** True only for this language's explain line AS WRITTEN (either spelling of
216
+ * the marker). For a free-text question, where nothing but position separates
217
+ * the explain line from the body above it: a body whose own last line starts
218
+ * with ℹ️ must not be read as the pill, so the copy has to match exactly. */
219
+ function askIsExplainCopy(line: string | undefined, c: AskCopy): boolean {
220
+ if (line === undefined) return false;
221
+ const norm = line.startsWith("ℹ️ ") ? ASK_EXPLAIN_PREFIX + line.slice("ℹ️ ".length) : line;
222
+ return norm === c.explainLine;
223
+ }
224
+
188
225
  // The invalid-ballot notice, shared by `ask` and `poll` and written INTO the
189
226
  // message rather than only onto the collector's terminal. The person who has to
190
227
  // fix it is the voter, and the voter is in Slack — a warning that only the
@@ -228,6 +265,14 @@ export function applyInvalidNotice(text: string, invalid: string[], lang: AskLan
228
265
  return out === text ? text : out;
229
266
  }
230
267
 
268
+ /** True for the thread note `--wait`/`--waitFor` posts when ℹ️ is pressed, in
269
+ * either language. Our own bookkeeping: never an answer, never a note — even
270
+ * when the identity that posted it is in the audience (an @here question
271
+ * collected with another token). */
272
+ export function askIsExplainNote(text: unknown): boolean {
273
+ return typeof text === "string" && ASK_LANGS.some((l) => ASK_COPY[l].explainNote === text.trim());
274
+ }
275
+
231
276
  /** True if this line is the notice — parsers skip it when walking up. */
232
277
  export function isInvalidNotice(line: string | undefined): boolean {
233
278
  return invalidLang(line) !== null;
@@ -293,7 +338,7 @@ export function askFlatten(s: string): string {
293
338
  /** Question body: the prompt, the numbered choices matching the seeded pills,
294
339
  * and an instruction naming the answer paths this command actually reads.
295
340
  * `askParseMessage` is its inverse — keep the two in step. */
296
- export function askBuildText(question: string, body: string, reactable: string[], overflow: string[], threadOnly: boolean, lang: AskLang = "ja"): string {
341
+ export function askBuildText(question: string, body: string, reactable: string[], overflow: string[], threadOnly: boolean, lang: AskLang = "ja", explain = false): string {
297
342
  const c = ASK_COPY[lang];
298
343
  const lines: string[] = [`${ASK_MARKER_PREFIX}*${question}*`];
299
344
  if (body) lines.push("", body);
@@ -309,6 +354,8 @@ export function askBuildText(question: string, body: string, reactable: string[]
309
354
  lines.push(...reactable.map((s, i) => `${askPill(i)} ${askFlatten(s)}`));
310
355
  // Directly under the pills, matching the ❓ seeded right after them.
311
356
  lines.push(threadOnly ? c.otherThread : c.otherHere);
357
+ // Under ❓, matching the ℹ️ seeded right after it.
358
+ if (explain) lines.push(c.explainLine);
312
359
  if (overflow.length) {
313
360
  lines.push("");
314
361
  lines.push(...overflow.map((s, i) => `(${i + 11}) ${askFlatten(s)}`));
@@ -321,6 +368,7 @@ export function askBuildText(question: string, body: string, reactable: string[]
321
368
  lines.push(threadOnly ? c.instructionReactionThread : c.instructionReactionHere);
322
369
  } else {
323
370
  lines.push("");
371
+ if (explain) lines.push(c.explainLine, "");
324
372
  lines.push(threadOnly ? c.instructionTextThread : c.instructionTextHere);
325
373
  }
326
374
  return lines.join("\n");
@@ -405,7 +453,7 @@ export function askSalvageUnreadable(text: string): { question: string; body: st
405
453
  for (const instr of [c.instructionReactionThread, c.instructionReactionHere, c.instructionTextThread, c.instructionTextHere]) {
406
454
  if (body.includes(instr)) wasAsk = true;
407
455
  }
408
- for (const fixed of [c.instructionReactionThread, c.instructionReactionHere, c.instructionTextThread, c.instructionTextHere, c.overflowNote, c.otherThread, c.otherHere]) {
456
+ for (const fixed of [c.instructionReactionThread, c.instructionReactionHere, c.instructionTextThread, c.instructionTextHere, c.overflowNote, c.otherThread, c.otherHere, c.explainLine]) {
409
457
  body = body.split(fixed).join(" ");
410
458
  }
411
459
  const at = body.indexOf(c.invalidPrefix);
@@ -454,7 +502,8 @@ function askDecodeEntities(s: string): string {
454
502
  }
455
503
 
456
504
  export type AskParsed =
457
- | { kind: "open"; question: string; body: string; reactable: string[]; overflow: string[]; threadOnly: boolean; lang: AskLang }
505
+ /** `explain`: the body offers the ℹ️ explain pill (`--explain`). */
506
+ | { kind: "open"; question: string; body: string; reactable: string[]; overflow: string[]; threadOnly: boolean; lang: AskLang; explain: boolean }
458
507
  | { kind: "resolved"; question: string; answer: string }
459
508
  /** Voided (無効化). `reason` is the stamp line as written; `supersededBy` the
460
509
  * replacement's link when one was given. */
@@ -594,6 +643,7 @@ export function askParseMessage(text: string): AskParsed {
594
643
  // there is the body — kept by the ✅ rewrite.
595
644
  let bodyEnd = lines.length - 1;
596
645
  if (isInvalidNotice(lines[bodyEnd - 1])) bodyEnd--;
646
+ let explain = false;
597
647
  if (hasChoices) {
598
648
  // Walk UP from the instruction line rather than down from the question. The
599
649
  // choice block is anchored to the bottom of the body, and scanning downward
@@ -609,6 +659,7 @@ export function askParseMessage(text: string): AskParsed {
609
659
  if (lines[i] !== "") return { kind: "other" };
610
660
  i--;
611
661
  }
662
+ if (askIsExplainLine(lines[i])) { explain = true; i--; }
612
663
  if (askIsOtherLine(lines[i])) i--;
613
664
  const raw: string[] = [];
614
665
  while (i >= 0) {
@@ -634,10 +685,16 @@ export function askParseMessage(text: string): AskParsed {
634
685
  for (let n = 0; n < overflow.length; n++) {
635
686
  if (!lines.includes(`(${n + 11}) ${overflow[n]}`)) return { kind: "other" };
636
687
  }
688
+ } else if (lines[bodyEnd - 1] === "" && askIsExplainCopy(lines[bodyEnd - 2], c) && lines[bodyEnd - 3] === "" && bodyEnd - 3 >= end) {
689
+ // A free-text question's explain line: blank, the line, blank, instruction
690
+ // — and the line is our copy verbatim. A body that merely ENDS on a line
691
+ // starting with ℹ️ (a question built without --explain) stays body.
692
+ explain = true;
693
+ bodyEnd -= 2;
637
694
  }
638
695
 
639
696
  const body = lines.slice(end + 1, bodyEnd).join("\n").replace(/^\n+|\n+$/g, "");
640
- return { kind: "open", question, body, reactable, overflow, threadOnly, lang };
697
+ return { kind: "open", question, body, reactable, overflow, threadOnly, lang, explain };
641
698
  }
642
699
 
643
700
  /** Did a free-text reply actually PICK one of the offered choices?
package/ts/cli.ts CHANGED
@@ -20,9 +20,11 @@ import { agentCommands } from "./agent.ts";
20
20
  import {
21
21
  ASK_KEYCAPS,
22
22
  ASK_MARKER,
23
+ ASK_EXPLAIN_MARKER,
23
24
  ASK_RESOLVED_MARKER,
24
25
  ASK_MAX_REACTION_CHOICES,
25
26
  askBuildText,
27
+ askIsExplainNote,
26
28
  askBuildResolvedText,
27
29
  askParseMessage,
28
30
  askExplainReject,
@@ -93,6 +95,7 @@ import {
93
95
  openDm,
94
96
  parseSlackPermalink,
95
97
  replies,
98
+ repliesPage,
96
99
  resolveChannel,
97
100
  resolveUserId,
98
101
  search,
@@ -2457,6 +2460,8 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
2457
2460
  // pressed later still settles it (`--waitFor … --after=<reply ts>`).
2458
2461
  // 6 the question was VOIDED (無効化, `slack ask --void`) — it will never be
2459
2462
  // answered; stop waiting. stdout stays empty.
2463
+ // 7 only with --explain-exit: someone pressed ℹ️ (explain) — the question is
2464
+ // STILL OPEN; explain in its thread, then collect again. stdout stays empty.
2460
2465
  // Everything human-facing goes to stderr, so `ANS=$(slack ask ... --wait)` is safe.
2461
2466
 
2462
2467
  const ASK_EXIT_TIMEOUT = 2;
@@ -2476,6 +2481,20 @@ const ASK_EXIT_FREETEXT = 5;
2476
2481
  * Neither "open" nor "answered": a monitor that kept waiting on it would wait
2477
2482
  * forever, and one that read it as answered would act on nothing. */
2478
2483
  const ASK_EXIT_VOID = 6;
2484
+ /** `--explain-exit` only: an audience member pressed ℹ️ and asked for an
2485
+ * explanation. Not an answer and not a state of the question — it stays open —
2486
+ * but a caller parked on `--waitFor` cannot explain anything until it hears.
2487
+ * Opt-in, so no existing caller meets a code it does not know. */
2488
+ const ASK_EXIT_EXPLAIN = 7;
2489
+ /** How many 200-reply pages the explain-note lookup reads before giving up. */
2490
+ const ASK_EXPLAIN_NOTE_MAX_PAGES = 10;
2491
+
2492
+ /** Whether a new question offers the ℹ️ explain pill when neither --explain nor
2493
+ * --no-explain is given: `SLACK_TERM_ASK_EXPLAIN=1` turns it on for every ask
2494
+ * from a fleet, without every caller having to remember the flag. */
2495
+ function askExplainDefault(): boolean {
2496
+ return /^(1|true|yes|on)$/i.test(process.env.SLACK_TERM_ASK_EXPLAIN ?? "");
2497
+ }
2479
2498
 
2480
2499
  const ASK_PHI = 1.618033988749895;
2481
2500
  const ASK_POLL_MIN_MS = 1000;
@@ -2500,6 +2519,10 @@ interface AskArgs {
2500
2519
  userId?: string;
2501
2520
  asBot?: boolean;
2502
2521
  wait?: boolean;
2522
+ /** `--explain`: offer the ℹ️ pill — "explain this first", never an answer. */
2523
+ explain?: boolean;
2524
+ /** `--explain-exit` with `--wait`: exit 7 when an explanation is requested. */
2525
+ explainExit?: boolean;
2503
2526
  /** `--json` with `--wait`: one JSON object on stdout (answer, notes, cursor). */
2504
2527
  json?: boolean;
2505
2528
  timeout?: number;
@@ -2614,6 +2637,11 @@ interface AskWaitCtx {
2614
2637
  /** `--json`: stdout is one JSON object (answer + notes + cursor) instead of
2615
2638
  * the bare answer. Exit codes are the same either way. */
2616
2639
  json: boolean;
2640
+ /** `--explain-exit`: stop with exit 7 the first time an explanation is
2641
+ * requested (the run that posts the thread note), instead of waiting on. */
2642
+ explainExit?: boolean;
2643
+ /** The question offers the ℹ️ pill — so the ✅ rewrite takes our seed off. */
2644
+ explain?: boolean;
2617
2645
  }
2618
2646
 
2619
2647
  /** A reply from the question's audience that is NOT the answer — an extra
@@ -2682,7 +2710,7 @@ async function askCollectNotes(token: string, sc: AskNoteScope, after: string |
2682
2710
  // integration's) is excluded here, so nothing echoes back as a "note".
2683
2711
  .filter((m) => sc.isAnswerer(m.user) && !m.bot_id)
2684
2712
  .filter((m) => typeof m.subtype !== "string" || ASK_ANSWERABLE_SUBTYPES.has(m.subtype))
2685
- .filter((m) => typeof m.text === "string" && m.text.trim() !== "")
2713
+ .filter((m) => typeof m.text === "string" && m.text.trim() !== "" && !askIsExplainNote(m.text))
2686
2714
  // Sorted BEFORE `skip`, which may be stateful ("the first reply that …").
2687
2715
  .sort((a, b) => Number(a.ts) - Number(b.ts))
2688
2716
  .filter((m) => !skip(m));
@@ -2712,12 +2740,15 @@ function askNextCursor(...tss: (string | undefined)[]): string {
2712
2740
  async function askEmit(
2713
2741
  token: string,
2714
2742
  o: { json: boolean; shown: string; asBot: boolean; cookie?: string | undefined },
2715
- r: { status: "answered" | "freetext" | "ambiguous" | "timeout" | "void"; answer: string | null; how?: string; who?: string; reason?: string; supersededBy?: string; notes: AskNote[]; cursor: string },
2743
+ r: { status: "answered" | "freetext" | "ambiguous" | "timeout" | "void" | "explain"; answer: string | null; how?: string; who?: string; reason?: string; supersededBy?: string; notes: AskNote[]; cursor: string; explain?: string[] },
2716
2744
  ): Promise<void> {
2717
2745
  if (o.json) {
2718
2746
  console.log(JSON.stringify({
2719
2747
  status: r.status, answer: r.answer, how: r.how ?? null, who: r.who ?? null,
2720
2748
  ...(r.status === "void" ? { reason: r.reason ?? "", supersededBy: r.supersededBy ?? null } : {}),
2749
+ // Who is asking for an explanation right now. Only when someone is, so
2750
+ // the object every existing caller reads is unchanged otherwise.
2751
+ ...(r.explain?.length ? { explain: r.explain } : {}),
2721
2752
  notes: r.notes, cursor: r.cursor, permalink: o.shown,
2722
2753
  }));
2723
2754
  return;
@@ -2754,6 +2785,13 @@ interface AskPollState {
2754
2785
  replyCount?: number;
2755
2786
  /** The question's text as last read — what the ✅ rewrite keeps the body of. */
2756
2787
  ownText?: string;
2788
+ /** Who is on ℹ️ (explain) right now — audience only, never a bot, never the
2789
+ * asker. Recomputed every pass, so a press taken back stops counting. Only
2790
+ * on a question that offers ℹ️ (`ctx.explain`). */
2791
+ explainNow: string[];
2792
+ /** THIS run posted the explain thread note — the one run `--explain-exit`
2793
+ * stops on. Never set read-only (`--pending` posts nothing). */
2794
+ explainPosted: boolean;
2757
2795
  }
2758
2796
 
2759
2797
  /** The polling half of `--wait`: one `poll()` is one look at the question,
@@ -2769,7 +2807,7 @@ function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
2769
2807
  } {
2770
2808
  const { channelId, ts, reactable, threadOnly, audience, broadcast, cookie } = ctx;
2771
2809
  const copy = ASK_COPY[ctx.lang];
2772
- const state: AskPollState = {};
2810
+ const state: AskPollState = { explainNow: [], explainPosted: false };
2773
2811
  const askerUserId = ctx.askerUserId;
2774
2812
  const askerBotId = ctx.askerBotId;
2775
2813
 
@@ -2845,7 +2883,93 @@ function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
2845
2883
  }
2846
2884
  }
2847
2885
 
2886
+ const explainReported = new Set<string>();
2887
+ const botCache = new Map<string, boolean>();
2888
+ /** The thread note is settled for this run (found already there, or posted). */
2889
+ let explainNoted = false;
2890
+ /** Null when it cannot be told — not cached, so the next pass asks again.
2891
+ * Until then the press does not count: a bot must never post the note or
2892
+ * wake an --explain-exit caller, and a person is only delayed a tick. */
2893
+ async function isBotUser(u: string): Promise<boolean | null> {
2894
+ if (u === "USLACKBOT") return true;
2895
+ const hit = botCache.get(u);
2896
+ if (hit !== undefined) return hit;
2897
+ try {
2898
+ // A reply with no boolean is_bot tells us nothing — unknown, not human.
2899
+ const bot = asRecord(asRecord((await userInfo(token, u, cookie)) as Json).user).is_bot;
2900
+ if (typeof bot !== "boolean") return null;
2901
+ botCache.set(u, bot);
2902
+ return bot;
2903
+ } catch {
2904
+ return null;
2905
+ }
2906
+ }
2907
+ /** One thread note per QUESTION, however many presses and runs: the thread
2908
+ * itself is the record, so there is no local state to lose. The note is
2909
+ * recognised by its exact copy (either language), whoever posted it — the
2910
+ * identity that collects now need not be the one that collected before, and
2911
+ * under @here it may even be in the audience.
2912
+ *
2913
+ * The whole thread after the question is read (paged), not one page: a
2914
+ * question posted into a long thread may have more than a page before its
2915
+ * note. Past the page cap it is not posted at all ("exists") rather than
2916
+ * risk a duplicate.
2917
+ *
2918
+ * "unread": the thread could not be read — nothing is posted, and the next
2919
+ * pass tries again rather than risk a duplicate. "failed": the post itself
2920
+ * failed — not retried this run (no error every tick) and NOT a reason for
2921
+ * --explain-exit, which promises a note is up; the next run tries again. */
2922
+ async function postExplainNote(): Promise<"exists" | "posted" | "unread" | "failed"> {
2923
+ const root = ctx.threadParentTs ?? ts;
2924
+ try {
2925
+ let cursor: string | undefined;
2926
+ let pages = 0;
2927
+ for (;;) {
2928
+ const rep = asRecord((await repliesPage(token, channelId, root, { oldest: ts, ...(cursor ? { cursor } : {}) }, cookie)) as Json);
2929
+ if (asArray(rep.messages).map(asRecord).some((m) => Number(m.ts) > Number(ts) && askIsExplainNote(m.text))) return "exists";
2930
+ cursor = String(asRecord(rep.response_metadata).next_cursor ?? "") || undefined;
2931
+ if (!cursor) break;
2932
+ if (++pages >= ASK_EXPLAIN_NOTE_MAX_PAGES) return "exists";
2933
+ }
2934
+ } catch (e: unknown) {
2935
+ console.error(` (スレッドを読めないため、説明リクエストの案内はまだ出していません: ${e instanceof Error ? e.message : String(e)})`);
2936
+ return "unread";
2937
+ }
2938
+ try {
2939
+ await slackSend(token, channelId, copy.explainNote, root, false, cookie, true);
2940
+ } catch (e: unknown) {
2941
+ // The press is still reported on stderr; the note is a courtesy.
2942
+ console.error(` (説明リクエストの案内をスレッドに出せませんでした: ${e instanceof Error ? e.message : String(e)})`);
2943
+ return "failed";
2944
+ }
2945
+ return "posted";
2946
+ }
2947
+ async function noteExplain(msg: Record<string, Json>): Promise<void> {
2948
+ const r = asArray(msg.reactions).map(asRecord).find((x) => x.name === ASK_EXPLAIN_MARKER);
2949
+ const now: string[] = [];
2950
+ for (const u of r ? asArray(r.users).filter(isAnswerer) : []) {
2951
+ if ((await isBotUser(u)) !== false) continue;
2952
+ now.push(u);
2953
+ if (explainReported.has(u)) continue;
2954
+ explainReported.add(u);
2955
+ if (!readOnly) console.error(` (<@${u}> が ℹ️ 説明して を押しました — 回答ではありません。スレッドで説明してください。回答は待ち続けます)`);
2956
+ }
2957
+ state.explainNow = now;
2958
+ // Read-only (`--pending`): the press is reported, the note is not posted —
2959
+ // that is the waiting session's to do.
2960
+ if (now.length && !explainNoted && !readOnly) {
2961
+ const r = await postExplainNote();
2962
+ if (r !== "unread") {
2963
+ explainNoted = true;
2964
+ state.explainPosted = r === "posted";
2965
+ }
2966
+ }
2967
+ }
2968
+
2848
2969
  async function answerFromReactions(msg: Record<string, Json>): Promise<AskFound | null> {
2970
+ // Only a question that offers ℹ️ treats it as a request — on any other,
2971
+ // ℹ️ is just an emoji somebody added.
2972
+ if (ctx.explain) await noteExplain(msg);
2849
2973
  if (reactable.length) noteOther(msg);
2850
2974
  const picks = humanChoices(msg);
2851
2975
  // Nothing ambiguous any more — take the notice back down if one is up.
@@ -2895,7 +3019,9 @@ function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
2895
3019
  if (typeof m.subtype === "string" && !ASK_ANSWERABLE_SUBTYPES.has(m.subtype)) continue;
2896
3020
  if (Number(m.ts) >= before) break;
2897
3021
  const t = typeof m.text === "string" ? m.text.trim() : "";
2898
- if (!t) continue;
3022
+ // Our ℹ️ note is bookkeeping — only on a question that offers ℹ️, so an
3023
+ // older question's reply is never second-guessed.
3024
+ if (!t || (ctx.explain && askIsExplainNote(t))) continue;
2899
3025
  // A question asked WITHOUT choices is answered by whatever comes back —
2900
3026
  // there is nothing to match against, and every reply is the answer.
2901
3027
  if (!candidates.length) return { answer: t, how: copy.howReply, who: m.user, ts: String(m.ts) };
@@ -3041,6 +3167,12 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3041
3167
  } catch {
3042
3168
  // Cosmetic: the ✅ in the body is what actually marks it resolved.
3043
3169
  }
3170
+ try {
3171
+ // Asking for an explanation is over once it is answered.
3172
+ if (ctx.explain) await reactionRemove(token, channelId, ts, ASK_EXPLAIN_MARKER, cookie);
3173
+ } catch {
3174
+ // Cosmetic: the ✅ in the body is what actually marks it resolved.
3175
+ }
3044
3176
  } catch (e: unknown) {
3045
3177
  // The edit is bookkeeping, not the answer. It fails whenever the resuming
3046
3178
  // token is not the one that posted (Slack lets only the author update), and
@@ -3102,6 +3234,15 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3102
3234
  await askEmit(token, ctx, r);
3103
3235
  process.exit(0);
3104
3236
  }
3237
+ // ℹ️ is never an answer: the question stays open, ✅ is not written, the
3238
+ // seeds stay. Only a caller that asked to hear about it stops here — and
3239
+ // only on the run that posted the thread note, so a `--timeout 0` poller is
3240
+ // woken once per question, not on every look.
3241
+ if (state.explainPosted && ctx.explainExit) {
3242
+ console.error(` 説明を求められています (質問はまだ開いています)。スレッドで説明してから回収する: ${askResumeCommand(shown, ctx.asBot, ctx.after)}`);
3243
+ if (ctx.json) await askEmit(token, ctx, { status: "explain", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after), explain: state.explainNow });
3244
+ process.exit(ASK_EXIT_EXPLAIN);
3245
+ }
3105
3246
  // A free-text reply that picks nothing is delivered NOW, not held until the
3106
3247
  // deadline: it is usually an instruction or a question back, and the only
3107
3248
  // one who can act on it is the caller. Left unstamped — a pill pressed
@@ -3122,7 +3263,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3122
3263
  // but still reports a pill pressed after it.
3123
3264
  markAskDelivered(channelId, ts, unchosen.text, askNextCursor(ts, ctx.after, unchosen.ts));
3124
3265
  if (ctx.json) {
3125
- await askEmit(token, ctx, { status: "freetext", answer: unchosen.text, who: unchosen.who, notes: [], cursor: askNextCursor(ts, ctx.after, unchosen.ts) });
3266
+ await askEmit(token, ctx, { status: "freetext", answer: unchosen.text, who: unchosen.who, notes: [], cursor: askNextCursor(ts, ctx.after, unchosen.ts), explain: state.explainNow });
3126
3267
  } else {
3127
3268
  console.log(unchosen.text);
3128
3269
  }
@@ -3167,7 +3308,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3167
3308
  console.error(` 選択肢: ${candidates.map((c, i) => `${i + 1}. ${stripTerminalControls(askFlatten(c))}`).join(" ")}`);
3168
3309
  console.error(` ${shown}`);
3169
3310
  console.error(` 返答してから回収する: ${askResumeCommand(shown, ctx.asBot, ctx.after)}`);
3170
- if (ctx.json) await askEmit(token, ctx, { status: "ambiguous", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after) });
3311
+ if (ctx.json) await askEmit(token, ctx, { status: "ambiguous", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after), explain: state.explainNow });
3171
3312
  process.exit(ASK_EXIT_UNCHOSEN);
3172
3313
  }
3173
3314
  if (timeout === 0) {
@@ -3177,7 +3318,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3177
3318
  console.error(` ${shown}`);
3178
3319
  console.error(` あとで回収する: ${askResumeCommand(shown, ctx.asBot, ctx.after)}`);
3179
3320
  }
3180
- if (ctx.json) await askEmit(token, ctx, { status: "timeout", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after) });
3321
+ if (ctx.json) await askEmit(token, ctx, { status: "timeout", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after), explain: state.explainNow });
3181
3322
  process.exit(ASK_EXIT_TIMEOUT);
3182
3323
  }
3183
3324
 
@@ -3306,7 +3447,8 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
3306
3447
  process.exit(ASK_EXIT_ERROR);
3307
3448
  }
3308
3449
  const lang = picked.lang;
3309
- const message = askBuildText(question, body, reactable, overflow, threadOnly, lang);
3450
+ const explain = args.explain ?? askExplainDefault();
3451
+ const message = askBuildText(question, body, reactable, overflow, threadOnly, lang, explain);
3310
3452
  guardUrlBoundaries(message, args.allowUrlAdjacent);
3311
3453
 
3312
3454
  // Preview the destination's last message, exactly as `send` does — the gate's
@@ -3357,6 +3499,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
3357
3499
  // shown as its own line rather than left to be inferred from the body.
3358
3500
  ` Answerable by: ${audienceLabel}`,
3359
3501
  ` Via: ${threadOnly ? "reactions or thread replies" : "reactions or replies in this DM"}`,
3502
+ ...(explain ? [` Explain: ℹ️ pill offered — pressing it asks you to explain; it is never an answer`] : []),
3360
3503
  ...(args.voidAfter ? [` Then 無効化: ${stripTerminalControls(args.voidAfter.link)} (reason: ${stripTerminalControls(args.voidAfter.reason || "none")}) — replaced by this question`] : []),
3361
3504
  ` Language: ${lang} (${({ flag: "--lang", env: "SLACK_TERM_LANG", readers: "answerers' Slack locale", content: "the question's text", system: "system locale", default: "default" })[picked.source]}; override with --lang)`,
3362
3505
  `--------------------------------────────────`,
@@ -3385,7 +3528,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
3385
3528
  // `two|one|question|three`. See ts/reactionSeed.ts for the evidence and for
3386
3529
  // SLACK_REACTION_SEED_GAP_MS.
3387
3530
  await seedReactionsInOrder(
3388
- [...reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER],
3531
+ [...reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER, ...(explain ? [ASK_EXPLAIN_MARKER] : [])],
3389
3532
  (name) => reactionAdd(token, channelId, ts, name, cookie),
3390
3533
  (name, e) => {
3391
3534
  const why = e instanceof Error ? e.message : String(e);
@@ -3441,6 +3584,8 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
3441
3584
  shown,
3442
3585
  asBot: !!args.asBot,
3443
3586
  json: !!args.json,
3587
+ explain,
3588
+ explainExit: !!args.explainExit,
3444
3589
  };
3445
3590
  if (threadTs) ctx.threadParentTs = threadTs;
3446
3591
  if (cookie) ctx.cookie = cookie;
@@ -3600,7 +3745,7 @@ async function cmdAskEdit(token: string, args: {
3600
3745
  const reactable = choices.slice(0, ASK_MAX_REACTION_CHOICES);
3601
3746
  const overflow = choices.slice(ASK_MAX_REACTION_CHOICES);
3602
3747
  const oldText = typeof t.msg.text === "string" ? t.msg.text : "";
3603
- let next = askBuildText(question, body, reactable, overflow, old.threadOnly, lang);
3748
+ let next = askBuildText(question, body, reactable, overflow, old.threadOnly, lang, old.explain);
3604
3749
  // An invalid-ballot notice still in force stays (it names the people who
3605
3750
  // have to fix their vote); it is only possible when the options did not change.
3606
3751
  const invalid = readInvalidNotice(oldText);
@@ -3635,11 +3780,12 @@ async function cmdAskEdit(token: string, args: {
3635
3780
  await editMessage(token, t.channelId, t.ts, next, args.cookie, true);
3636
3781
  if (choicesChanged) {
3637
3782
  // Re-seed in order: 1..n then ❓ last, as a fresh question has them.
3638
- for (const name of [...old.reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER]) {
3783
+ const tail = old.explain ? [ASK_MARKER, ASK_EXPLAIN_MARKER] : [ASK_MARKER];
3784
+ for (const name of [...old.reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ...tail]) {
3639
3785
  try { await reactionRemove(token, t.channelId, t.ts, name, args.cookie); } catch { /* already gone */ }
3640
3786
  }
3641
3787
  await seedReactionsInOrder(
3642
- [...reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER],
3788
+ [...reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ...tail],
3643
3789
  (name) => reactionAdd(token, t.channelId, t.ts, name, args.cookie),
3644
3790
  (name, e) => console.error(` (リアクション ${name} を付けられませんでした: ${e instanceof Error ? e.message : String(e)})`),
3645
3791
  );
@@ -3765,7 +3911,10 @@ async function cmdAskPending(
3765
3911
  }
3766
3912
  const rows = opts.channel ? all.filter((r) => r.channel === opts.channel || r.target === opts.channel) : all;
3767
3913
  const pending: AskPendingItem[] = [];
3768
- const count = { unanswered: 0, ambiguous: 0, resolved: 0, void: 0 };
3914
+ const count = { unanswered: 0, ambiguous: 0, resolved: 0, void: 0, explain: 0 };
3915
+ /** Open questions someone pressed ℹ️ on — reported only: the thread note is
3916
+ * the waiting session's to post (`--pending` writes nothing). */
3917
+ const explainAsked: string[] = [];
3769
3918
  const problems: string[] = [];
3770
3919
  // The log is machine-wide, but a token reads one workspace. Asks recorded
3771
3920
  // under another workspace are counted and named, not probed with the wrong
@@ -3810,6 +3959,10 @@ async function cmdAskPending(
3810
3959
  const unchosen = poller.state.unchosen;
3811
3960
  if (poller.state.voided) { count.void++; continue; }
3812
3961
  const collect = askResumeCommand(link, asBot, after);
3962
+ if (poller.state.explainNow.length) {
3963
+ count.explain++;
3964
+ explainAsked.push(`${link}: ℹ️ 説明を求められています (${poller.state.explainNow.map((u) => `<@${u}>`).join(" ")}) — スレッドで説明してください`);
3965
+ }
3813
3966
  const base = { row, link, question: parsed.question, collect, alive: senderAlive(row) };
3814
3967
  if (found) {
3815
3968
  pending.push({ ...base, answer: found.answer, how: found.how, freeText: false });
@@ -3858,6 +4011,7 @@ async function cmdAskPending(
3858
4011
  }
3859
4012
  }
3860
4013
  for (const p of problems) console.error(` (${stripTerminalControls(p)})`);
4014
+ for (const p of explainAsked) console.error(` (${stripTerminalControls(p)})`);
3861
4015
  for (const [team, n] of otherTeams) {
3862
4016
  console.error(` (${n} 件は別のワークスペース「${stripTerminalControls(team)}」の質問です — slack -w <name> ask --pending で確認)`);
3863
4017
  }
@@ -3866,6 +4020,7 @@ async function cmdAskPending(
3866
4020
  (count.ambiguous ? `, ${count.ambiguous} 件は返信が複数の選択肢に一致 (未確定)` : "") +
3867
4021
  (count.resolved ? `, ${count.resolved} 件は ✅ 回収済み` : "") +
3868
4022
  (count.void ? `, ${count.void} 件は無効化済み` : "") +
4023
+ (count.explain ? `, ${count.explain} 件は ℹ️ 説明のリクエストあり` : "") +
3869
4024
  ` — ${rows.length} 件の未回収の質問を確認 (log: ${sentDbPath()})`,
3870
4025
  );
3871
4026
  }
@@ -3890,7 +4045,7 @@ async function askApplyVoid(token: string, t: AskVoidTarget, reason: string, sup
3890
4045
  console.error(`✗ ${shown}: 書き換えに失敗しました: ${e instanceof Error ? e.message : String(e)}`);
3891
4046
  return false;
3892
4047
  }
3893
- for (const name of [...t.parsed.reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER]) {
4048
+ for (const name of [...t.parsed.reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER, ...(t.parsed.explain ? [ASK_EXPLAIN_MARKER] : [])]) {
3894
4049
  try { await reactionRemove(token, t.channelId, t.ts, name, cookie); } catch { /* not ours / already gone */ }
3895
4050
  }
3896
4051
  try {
@@ -3984,6 +4139,8 @@ async function cmdAskVoid(token: string, args: { links: string[]; reason: string
3984
4139
  // Placeholders: only the COUNT is used — which of our keycap seeds to take off.
3985
4140
  reactable: Array.from({ length: seeded.length ? Math.max(...seeded) + 1 : 0 }, () => ""),
3986
4141
  overflow: [], threadOnly: true, lang: askDetectLang(text) ?? "ja",
4142
+ // Our ℹ️ seed comes off too, if we put one on.
4143
+ explain: asArray(msg.reactions).map(asRecord).some((r) => r.name === ASK_EXPLAIN_MARKER && asArray(r.users).includes(self?.userId ?? "\u0000")),
3987
4144
  };
3988
4145
  unreadable = true;
3989
4146
  }
@@ -4030,7 +4187,7 @@ async function cmdAskVoid(token: string, args: { links: string[]; reason: string
4030
4187
  * file to go stale, lose, or disagree with Slack, and any machine holding the
4031
4188
  * permalink can collect — at the cost of the body being a parseable format
4032
4189
  * (`askBuildText` ⇄ `askParseMessage`). */
4033
- async function cmdAskWaitFor(token: string, args: { link: string; timeout: number; asBot: boolean; json: boolean; cookie?: string; after?: string }): Promise<void> {
4190
+ async function cmdAskWaitFor(token: string, args: { link: string; timeout: number; asBot: boolean; json: boolean; cookie?: string; after?: string; explainExit?: boolean }): Promise<void> {
4034
4191
  const url = parseSlackPermalink(args.link);
4035
4192
  let channelId: string;
4036
4193
  let ts: string;
@@ -4184,7 +4341,7 @@ function askWaitCtxFrom(
4184
4341
  q: AskReadBack,
4185
4342
  parsed: Extract<AskReadBack["parsed"], { kind: "open" }>,
4186
4343
  loc: { channelId: string; ts: string; threadTs?: string | undefined },
4187
- o: { timeout: number; link: string; asBot: boolean; json: boolean; cookie?: string | undefined; after?: string | undefined },
4344
+ o: { timeout: number; link: string; asBot: boolean; json: boolean; cookie?: string | undefined; after?: string | undefined; explainExit?: boolean | undefined },
4188
4345
  ): AskWaitCtx {
4189
4346
  const ctx: AskWaitCtx = {
4190
4347
  channelId: loc.channelId,
@@ -4202,6 +4359,8 @@ function askWaitCtxFrom(
4202
4359
  shown: o.link,
4203
4360
  asBot: o.asBot,
4204
4361
  json: o.json,
4362
+ explain: parsed.explain,
4363
+ explainExit: !!o.explainExit,
4205
4364
  };
4206
4365
  if (loc.threadTs) ctx.threadParentTs = loc.threadTs;
4207
4366
  if (o.cookie) ctx.cookie = o.cookie;
@@ -5745,8 +5904,8 @@ async function main(): Promise<void> {
5745
5904
  .option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
5746
5905
  .option("body", { type: "string", describe: "Extra context shown under the question" })
5747
5906
  .option("lang", { type: "string", choices: [...ASK_LANGS], describe: "Language of the instructions in the posted body. Default: $SLACK_TERM_LANG, else the answerers' Slack locale, else the question's own language, else the system locale, else ja" })
5748
- .option("wait", { type: "boolean", default: false, describe: "Block until answered; print ONLY the answer on stdout. Exit 0 = answered, 2 = nobody replied, 3 = transport failure, 4 = a reply matched several choices (stdout empty), 5 = a free-text reply that picked none of the choices (the reply is on stdout; NOT a decision — the question stays open), 6 = the question was voided (無効化, `slack ask void`) — stop waiting." })
5749
- .option("waitFor", { type: "string", describe: "Collect the answer to a question already posted: pass its permalink. Nothing is posted. Same stdout/exit contract as --wait; --timeout 0 checks once and exits 2 if still open." })
5907
+ .option("wait", { type: "boolean", default: false, describe: "Block until answered; print ONLY the answer on stdout. Exit 0 = answered, 2 = nobody replied, 3 = transport failure, 4 = a reply matched several choices (stdout empty), 5 = a free-text reply that picked none of the choices (the reply is on stdout; NOT a decision — the question stays open), 6 = the question was voided (無効化, `slack ask void`) — stop waiting. 7 = only with --explain-exit: ℹ️ pressed, explain and collect again (the question stays open)." })
5908
+ .option("waitFor", { type: "string", describe: "Collect the answer to a question already posted: pass its permalink. Nothing is posted (except, on an --explain question someone pressed ℹ️ on, one thread note per question). Same stdout/exit contract as --wait; --timeout 0 checks once and exits 2 if still open." })
5750
5909
  .option("after", { type: "string", describe: "With --waitFor: the cursor — everything at or before this ts was already delivered. Text replies there are neither answers nor notes again (pass the `cursor` a previous --json result gave you, or the reply ts an exit 5 printed). Reactions still count." })
5751
5910
  .option("void", { type: "string", array: true, describe: "無効化: void question(s) you posted that expired or stopped meaning anything — --void=<permalink>, repeatable. The text becomes 🚫 with --reason (body kept, options removed), our pills come off, and --wait/--waitFor on it exits 6. Same --code gate. Only your own questions (--as-bot for the bot's). Alias: `slack ask void <link…>`." })
5752
5911
  .option("reask", { type: "boolean", default: false, describe: "With ONE --void: post the question again (same channel/thread, same text and choices unless you give new ones as positionals / --body) and void the old one as replaced by it — one gate for both. The way to change the options of a question someone already answered." })
@@ -5760,6 +5919,8 @@ async function main(): Promise<void> {
5760
5919
  .option("limit", { type: "number", default: 50, describe: "With --ls: how many recent asks to check" })
5761
5920
  .option("reason", { type: "string", describe: "With --void: why it is void (written into the message)" })
5762
5921
  .option("superseded-by", { type: "string", describe: "With --void: permalink of the question that replaces it" })
5922
+ .option("explain", { type: "boolean", describe: "Also offer an ℹ️ 'explain' pill (seeded after ❓): pressing it asks YOU to explain the question in its thread and is never an answer — the question stays open. --wait/--waitFor report it on stderr (and as `explain` in --json), and post one thread note per question. Default: $SLACK_TERM_ASK_EXPLAIN=1 turns it on; --no-explain turns it off." })
5923
+ .option("explain-exit", { type: "boolean", default: false, describe: "With --wait / --waitFor: exit 7 (stdout empty; --json status \"explain\") when someone presses ℹ️ — once per question, on the run that posts the thread note. The question stays open: explain, then collect again." })
5763
5924
  .option("json", { type: "boolean", default: false, describe: "With --pending: one JSON object per answered question (sender, answer, collect command). With --wait / --waitFor: print ONE JSON object on stdout — {status, answer, how, who, notes:[{ts,user,text,permalink}], cursor, permalink} — instead of the bare answer. `notes` are the audience's replies that are not the answer (thread replies; plain replies too in a 1:1 DM). Exit codes unchanged." })
5764
5925
  .option("timeout", { type: "number", default: 3600, describe: "Overall limit for --wait / --waitFor, in seconds (0 with --waitFor = check once)" })
5765
5926
  .option("channel-id", { type: "string", describe: "Raw channel ID" })
@@ -5884,6 +6045,8 @@ async function main(): Promise<void> {
5884
6045
  argv.choices = newChoices.length ? newChoices : [...t.parsed.reactable, ...t.parsed.overflow].map(escapeArg);
5885
6046
  if (argv.body === undefined && t.parsed.body) argv.body = escapeArg(t.parsed.body);
5886
6047
  if (!argv.lang) argv.lang = t.parsed.lang;
6048
+ // A re-ask offers what the old question offered, unless told otherwise.
6049
+ if (argv.explain === undefined && t.parsed.explain) argv.explain = true;
5887
6050
  argv.void = undefined;
5888
6051
  }
5889
6052
  if (!voidAfter && (argv.target === "void" || voidFlag.length)) {
@@ -5973,7 +6136,8 @@ async function main(): Promise<void> {
5973
6136
  waitCookie = ck(argv as W);
5974
6137
  }
5975
6138
  try {
5976
- const a: { link: string; timeout: number; asBot: boolean; json: boolean; cookie?: string; after?: string } = { link: waitFor, timeout, asBot: !!argv["as-bot"], json: !!argv.json };
6139
+ const a: { link: string; timeout: number; asBot: boolean; json: boolean; cookie?: string; after?: string; explainExit?: boolean } = { link: waitFor, timeout, asBot: !!argv["as-bot"], json: !!argv.json };
6140
+ if (argv["explain-exit"]) a.explainExit = true;
5977
6141
  if (waitCookie) a.cookie = waitCookie;
5978
6142
  if (argv.after) {
5979
6143
  const after = String(argv.after);
@@ -6011,6 +6175,8 @@ async function main(): Promise<void> {
6011
6175
  if (argv.lang) args.lang = String(argv.lang);
6012
6176
  if (argv.wait) args.wait = true;
6013
6177
  if (argv.json) args.json = true;
6178
+ if (typeof argv.explain === "boolean") args.explain = argv.explain;
6179
+ if (argv["explain-exit"]) args.explainExit = true;
6014
6180
  if (voidAfter) args.voidAfter = voidAfter;
6015
6181
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
6016
6182
  if (argv["user-id"]) args.userId = argv["user-id"];