slack-term 1.40.0 โ†’ 1.41.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/ts/cli.ts CHANGED
@@ -28,7 +28,18 @@ import {
28
28
  askMatchChoice,
29
29
  applyInvalidNotice,
30
30
  readInvalidNotice,
31
+ askResolveLang,
32
+ askResolvedHow,
33
+ askReplyPicked,
34
+ askResolvedKeep,
35
+ askBuildVoidText,
36
+ askSalvageUnreadable,
37
+ askDetectLang,
38
+ ASK_VOID_MARKER,
39
+ ASK_COPY,
40
+ ASK_LANGS,
31
41
  type AskFound,
42
+ type AskLang,
32
43
  } from "./ask.ts";
33
44
  import {
34
45
  POLL_KEYCAPS,
@@ -74,6 +85,7 @@ import {
74
85
  listDrafts,
75
86
  listUsers,
76
87
  userInfo,
88
+ userLocale,
77
89
  conversationInfo,
78
90
  openDm,
79
91
  parseSlackPermalink,
@@ -1291,6 +1303,8 @@ function sumCounts(m: Map<string, number>): number {
1291
1303
  // --- edit ---
1292
1304
  interface EditArgs {
1293
1305
  allowUrlAdjacent?: boolean;
1306
+ /** Edit an `ask`/`poll` message anyway. Without it, `edit` refuses one. */
1307
+ force?: boolean;
1294
1308
  target: string;
1295
1309
  newText: string;
1296
1310
  code?: string;
@@ -1385,14 +1399,47 @@ async function cmdEdit(token: string, args: EditArgs): Promise<void> {
1385
1399
 
1386
1400
  guardUrlBoundaries(newText, args.allowUrlAdjacent);
1387
1401
 
1402
+ // An `ask` or `poll` body is a wire format: collect reads the question, the
1403
+ // pills, the โœ…/๐Ÿšซ state and the kept body back out of it. What is refused is
1404
+ // an edit that BREAKS that โ€” new text that no longer reads as one, or an
1405
+ // answered/void question turned back into an open one. An edit that keeps it
1406
+ // readable is fine, and must stay fine: scripts resolve questions exactly this
1407
+ // way (the ask-devteam skill writes the โœ… form with `slack edit`, from hosts
1408
+ // that may run older builds โ€” it cannot be asked to pass a new flag).
1409
+ const askBefore = askParseMessage(originalText).kind;
1410
+ const askAfter = askParseMessage(newText).kind;
1411
+ const isPoll = pollParseMessage(originalText).kind !== "other";
1412
+ const breaks = isPoll
1413
+ ? pollParseMessage(newText).kind === "other"
1414
+ : askBefore !== "other" && (askAfter === "other" || (askBefore !== "open" && askAfter === "open"));
1415
+ if (breaks && !args.force) {
1416
+ const what = isPoll ? "a `slack poll`" : `a \`slack ask\` question (${askBefore === "open" ? "open" : askBefore === "resolved" ? "answered" : "void"})`;
1417
+ const how = isPoll || askAfter === "other"
1418
+ ? "the new text would no longer read back as one (collect could not find the question, the pills or the answer)"
1419
+ : "the new text would turn it back into an OPEN question";
1420
+ console.error(
1421
+ `Error: this message is ${what}, and ${how}.\n` +
1422
+ (isPoll
1423
+ ? ` Edit it anyway with --force.`
1424
+ : ` To retire it: slack ask --void='${args.target}' --reason 'โ€ฆ'\n Edit it anyway with --force.`),
1425
+ );
1426
+ process.exit(1);
1427
+ }
1428
+ const guarded = breaks;
1429
+
1388
1430
  // Identity is part of the hash for the same reason it is on `send`: a code
1389
1431
  // minted while previewing as one identity must not confirm the write as
1390
1432
  // another (Slack only lets you edit your own messages, so a silent profile
1391
1433
  // switch between preview and confirm otherwise turns into a bare API error).
1392
1434
  const self = await getSelf();
1393
- const code = safetyCode(originalText, newText, self?.userId ?? "");
1435
+ // Overriding the guard is part of what the code authorizes: a code minted for
1436
+ // an ordinary edit must not confirm a forced rewrite of a question.
1437
+ const code = guarded
1438
+ ? safetyCode("force-edit-ask-poll", originalText, newText, self?.userId ?? "")
1439
+ : safetyCode(originalText, newText, self?.userId ?? "");
1394
1440
  if (args.code !== code) {
1395
1441
  requireCode(args.code, code, [
1442
+ ...(guarded ? [`โš  --force: rewriting a \`slack ${isPoll ? "poll" : "ask"}\` message by hand โ€” collect may no longer read it back`] : []),
1396
1443
  `--- Editing as -------------------------------`,
1397
1444
  fromLine(self, { asBot: args.asBot }),
1398
1445
  `--- Original message -------------------------`,
@@ -1404,7 +1451,13 @@ async function cmdEdit(token: string, args: EditArgs): Promise<void> {
1404
1451
  }
1405
1452
 
1406
1453
  const attr = sentAttribution("edit");
1407
- const newTs = await editMessage(token, channelId, ts, newText, args.cookie, undefined, attr.metadata);
1454
+ // An ask/poll body goes up PLAIN (no blocks): with blocks attached Slack
1455
+ // rewrites the stored text โ€” every newline becomes a space โ€” and the message
1456
+ // can never be read back as an ask again (2026-10-01: a release ask edited
1457
+ // this way could neither be collected nor voided). Ordinary messages keep
1458
+ // the blocks they always had.
1459
+ const askish = askBefore !== "other" || askAfter !== "other" || isPoll || pollParseMessage(newText).kind !== "other";
1460
+ const newTs = await editMessage(token, channelId, ts, newText, args.cookie, askish ? true : undefined, attr.metadata);
1408
1461
  console.log(`โœ“ Edited (ts: ${newTs})`);
1409
1462
  attr.record({ team: self?.team, channel: channelId, target: args.target, ts, text: newText, asBot: args.asBot });
1410
1463
  }
@@ -2384,6 +2437,8 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
2384
2437
  // 5 replied in free text, picking none of the offered choices โ€” the reply
2385
2438
  // text is on stdout. NOT a decision: the question is left open, so a pill
2386
2439
  // pressed later still settles it (`--waitFor โ€ฆ --after=<reply ts>`).
2440
+ // 6 the question was VOIDED (็„กๅŠนๅŒ–, `slack ask --void`) โ€” it will never be
2441
+ // answered; stop waiting. stdout stays empty.
2387
2442
  // Everything human-facing goes to stderr, so `ANS=$(slack ask ... --wait)` is safe.
2388
2443
 
2389
2444
  const ASK_EXIT_TIMEOUT = 2;
@@ -2399,6 +2454,10 @@ const ASK_EXIT_UNCHOSEN = 4;
2399
2454
  * (measured 2026-09-27: 8 of 71 asks answered this way, none collected). Still
2400
2455
  * not 0, so nothing mistakes it for a decision. */
2401
2456
  const ASK_EXIT_FREETEXT = 5;
2457
+ /** The question was voided (็„กๅŠนๅŒ–) โ€” expired, superseded, or overtaken by events.
2458
+ * Neither "open" nor "answered": a monitor that kept waiting on it would wait
2459
+ * forever, and one that read it as answered would act on nothing. */
2460
+ const ASK_EXIT_VOID = 6;
2402
2461
 
2403
2462
  const ASK_PHI = 1.618033988749895;
2404
2463
  const ASK_POLL_MIN_MS = 1000;
@@ -2408,6 +2467,12 @@ const ASK_MAX_CONSECUTIVE_ERRORS = 8;
2408
2467
 
2409
2468
  interface AskArgs {
2410
2469
  allowUrlAdjacent?: boolean;
2470
+ /** `--void=<old> --reask`: once this question is posted, void `old` with this
2471
+ * question as its replacement. Validated (own, open) by the caller. */
2472
+ voidAfter?: AskVoidTarget & { reason: string };
2473
+ /** `--lang`. Unset = pick from the readers' Slack locale, then the content,
2474
+ * then the system locale โ€” see `askResolveLang`. */
2475
+ lang?: string;
2411
2476
  target: string;
2412
2477
  question: string;
2413
2478
  choices: string[];
@@ -2417,6 +2482,8 @@ interface AskArgs {
2417
2482
  userId?: string;
2418
2483
  asBot?: boolean;
2419
2484
  wait?: boolean;
2485
+ /** `--json` with `--wait`: one JSON object on stdout (answer, notes, cursor). */
2486
+ json?: boolean;
2420
2487
  timeout?: number;
2421
2488
  cookie?: string;
2422
2489
  // Token used to resolve the @tags that decide who may answer (needs
@@ -2488,6 +2555,9 @@ interface AskWaitCtx {
2488
2555
  * nothing". */
2489
2556
  overflow: string[];
2490
2557
  threadOnly: boolean;
2558
+ /** The language the question's copy is in โ€” the โœ… stamp and the
2559
+ * invalid-ballot line are written in the same one. */
2560
+ lang: AskLang;
2491
2561
  /** Who may answer. Empty + broadcast=false would accept nobody, which is why
2492
2562
  * posting an unaddressed question is refused up front. */
2493
2563
  audience: Set<string>;
@@ -2503,16 +2573,148 @@ interface AskWaitCtx {
2503
2573
  /** Only so the resume hint printed on timeout names the right identity โ€” the
2504
2574
  * โœ… rewrite can be done by the original poster alone. */
2505
2575
  asBot: boolean;
2506
- /** Text replies at or before this ts are ignored โ€” the free-text reply a
2507
- * previous exit 5 already delivered. Reactions still count. */
2576
+ /** The CURSOR: everything at or before this ts was already delivered to the
2577
+ * caller. Text replies there are not answers again (the free-text reply an
2578
+ * exit 5 delivered), and not thread notes again. Reactions still count. */
2508
2579
  after?: string;
2580
+ /** `--json`: stdout is one JSON object (answer + notes + cursor) instead of
2581
+ * the bare answer. Exit codes are the same either way. */
2582
+ json: boolean;
2509
2583
  }
2510
2584
 
2585
+ /** A reply from the question's audience that is NOT the answer โ€” an extra
2586
+ * instruction ("also look at X"), a caveat, a question back after a pill.
2587
+ *
2588
+ * Reported from real use 2026-10-02: a pill was pressed and collected, โœ… was
2589
+ * written, and the watcher exited; 40 s later the same person added a note in
2590
+ * the thread, and nothing ever delivered it. A pill was the whole answer as far
2591
+ * as `ask` was concerned, so the thread was never read โ€” and once โœ… was
2592
+ * written, `--waitFor` only ever returned the stored answer. */
2593
+ interface AskNote { ts: string; user: string; text: string; permalink: string }
2594
+
2595
+ /** Who and where notes are read from โ€” the same audience rule as answers. */
2596
+ interface AskNoteScope {
2597
+ channelId: string;
2598
+ ts: string;
2599
+ threadParentTs?: string;
2600
+ threadOnly: boolean;
2601
+ /** The question's own `reply_count`, or undefined when the question was not
2602
+ * in the page we read. Its thread is skipped only on a KNOWN zero: a call
2603
+ * per collection on every threadless question is a cost paid for nothing,
2604
+ * but an unknown count read as zero loses the notes โ€” in a busy channel the
2605
+ * question falls out of the 30-message history page. */
2606
+ replyCount: number | undefined;
2607
+ isAnswerer: (user: unknown) => user is string;
2608
+ askerUserId: string;
2609
+ askerBotId: string;
2610
+ cookie?: string;
2611
+ }
2612
+
2613
+ /** The audience's replies after `after` (or after the question), oldest first.
2614
+ * `skip` drops the reply that IS the answer.
2615
+ *
2616
+ * Fail-soft: a note is extra information, never the answer, so a failed fetch
2617
+ * is reported on stderr and must not cost the caller the answer it already
2618
+ * has. Thread replies come from the thread; in a 1:1 DM, plain replies count
2619
+ * too โ€” bounded by our NEXT question there, exactly as answers are, so a note
2620
+ * meant for a newer question is not delivered with an older one. */
2621
+ async function askCollectNotes(token: string, sc: AskNoteScope, after: string | undefined, skip: (m: Record<string, Json>) => boolean): Promise<AskNote[]> {
2622
+ const pool = new Map<string, Record<string, Json>>();
2623
+ if (sc.threadParentTs || sc.replyCount !== 0) {
2624
+ try {
2625
+ const rep = asRecord((await replies(token, sc.channelId, sc.threadParentTs ?? sc.ts, 100, sc.cookie)) as Json);
2626
+ for (const m of asArray(rep.messages).map(asRecord)) pool.set(String(m.ts), m);
2627
+ } catch (e: unknown) {
2628
+ console.error(` (ใ‚นใƒฌใƒƒใƒ‰ใฎ่ฟฝ่จ˜ใ‚’่ชญใ‚ใพใ›ใ‚“ใงใ—ใŸ: ${e instanceof Error ? e.message : String(e)})`);
2629
+ }
2630
+ }
2631
+ if (!sc.threadOnly && !sc.threadParentTs) {
2632
+ try {
2633
+ const hist = asRecord((await history(token, sc.channelId, 30, sc.ts, undefined, sc.cookie, true)) as Json);
2634
+ const msgs = asArray(hist.messages).map(asRecord);
2635
+ const bound = msgs
2636
+ .filter((m) => Number(m.ts) > Number(sc.ts) && (
2637
+ (sc.askerBotId && m.bot_id === sc.askerBotId) || (sc.askerUserId && m.user === sc.askerUserId)))
2638
+ .reduce((min, m) => Math.min(min, Number(m.ts)), Infinity);
2639
+ for (const m of msgs) if (Number(m.ts) < bound && !pool.has(String(m.ts))) pool.set(String(m.ts), m);
2640
+ } catch (e: unknown) {
2641
+ console.error(` (DM ใฎ่ฟฝ่จ˜ใ‚’่ชญใ‚ใพใ›ใ‚“ใงใ—ใŸ: ${e instanceof Error ? e.message : String(e)})`);
2642
+ }
2643
+ }
2644
+ const picked = [...pool.values()]
2645
+ .filter((m) => Number(m.ts) > Number(sc.ts))
2646
+ .filter((m) => !after || Number(m.ts) > Number(after))
2647
+ // The asker is excluded by isAnswerer; any bot post (ours or another
2648
+ // integration's) is excluded here, so nothing echoes back as a "note".
2649
+ .filter((m) => sc.isAnswerer(m.user) && !m.bot_id)
2650
+ .filter((m) => typeof m.subtype !== "string" || ASK_ANSWERABLE_SUBTYPES.has(m.subtype))
2651
+ .filter((m) => typeof m.text === "string" && m.text.trim() !== "")
2652
+ // Sorted BEFORE `skip`, which may be stateful ("the first reply that โ€ฆ").
2653
+ .sort((a, b) => Number(a.ts) - Number(b.ts))
2654
+ .filter((m) => !skip(m));
2655
+ const out: AskNote[] = [];
2656
+ for (const m of picked) {
2657
+ let permalink = "";
2658
+ try {
2659
+ permalink = await getPermalink(token, sc.channelId, String(m.ts), sc.cookie);
2660
+ } catch {
2661
+ // fail-soft: the ts still names it
2662
+ }
2663
+ out.push({ ts: String(m.ts), user: String(m.user), text: (m.text as string).trim(), permalink });
2664
+ }
2665
+ return out;
2666
+ }
2667
+
2668
+ /** The next cursor: the newest thing this result delivered, never older than
2669
+ * the cursor it was given. A caller that passes it back as `--after` gets
2670
+ * nothing twice. */
2671
+ function askNextCursor(...tss: (string | undefined)[]): string {
2672
+ return tss.filter((t): t is string => !!t).reduce((a, b) => (!a || Number(b) > Number(a) ? b : a), "");
2673
+ }
2674
+
2675
+ /** Deliver an answer (or the lack of one). Plain mode keeps the contract โ€”
2676
+ * stdout is the answer alone โ€” and puts the notes and the next-cursor hint on
2677
+ * stderr. `--json` puts all of it in one object on stdout. */
2678
+ async function askEmit(
2679
+ token: string,
2680
+ o: { json: boolean; shown: string; asBot: boolean; cookie?: string | undefined },
2681
+ r: { status: "answered" | "freetext" | "ambiguous" | "timeout" | "void"; answer: string | null; how?: string; who?: string; reason?: string; supersededBy?: string; notes: AskNote[]; cursor: string },
2682
+ ): Promise<void> {
2683
+ if (o.json) {
2684
+ console.log(JSON.stringify({
2685
+ status: r.status, answer: r.answer, how: r.how ?? null, who: r.who ?? null,
2686
+ ...(r.status === "void" ? { reason: r.reason ?? "", supersededBy: r.supersededBy ?? null } : {}),
2687
+ notes: r.notes, cursor: r.cursor, permalink: o.shown,
2688
+ }));
2689
+ return;
2690
+ }
2691
+ if (r.notes.length) {
2692
+ console.error(` ใ‚นใƒฌใƒƒใƒ‰ใฎ่ฟฝ่จ˜ ${r.notes.length} ไปถ (ๅ›ž็ญ”ใจใฏๅˆฅ):`);
2693
+ for (const n of r.notes) {
2694
+ let who = n.user;
2695
+ try {
2696
+ who = await userName(token, n.user, o.cookie);
2697
+ } catch {
2698
+ // the id still says who
2699
+ }
2700
+ for (const [i, line] of n.text.split("\n").entries()) {
2701
+ console.error(` ${i ? " ".repeat(stripTerminalControls(who).length + 2) : `${stripTerminalControls(who)}: `}${stripTerminalControls(line)}`);
2702
+ }
2703
+ }
2704
+ }
2705
+ if (r.status === "answered") {
2706
+ console.error(` ใ“ใฎๅพŒใฎ่ฟฝ่จ˜ใ‚’ๆ‹พใ†: ${askResumeCommand(o.shown, o.asBot, r.cursor)}`);
2707
+ }
2708
+ if (r.answer !== null) console.log(r.answer);
2709
+ }
2710
+
2711
+
2511
2712
  /** Poll until answered. Never returns: exits 0 with the answer on stdout, or 2
2512
2713
  * on timeout. A timeout of 0 means "look exactly once", so a caller can poll
2513
2714
  * cheaply on its own schedule instead of parking a process on `--wait`. */
2514
2715
  async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never> {
2515
2716
  const { channelId, ts, question, reactable, threadOnly, audience, broadcast, cookie, timeout, shown } = ctx;
2717
+ const copy = ASK_COPY[ctx.lang];
2516
2718
  const askerUserId = ctx.askerUserId;
2517
2719
  const askerBotId = ctx.askerBotId;
2518
2720
 
@@ -2523,6 +2725,19 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2523
2725
  const isAnswerer = (user: unknown): user is string =>
2524
2726
  typeof user === "string" && !!user && user !== askerUserId && (broadcast || audience.has(user));
2525
2727
 
2728
+ /** The question's `reply_count` as last seen โ€” whether its thread is worth a
2729
+ * fetch when collecting notes. Undefined until the question itself is seen. */
2730
+ let replyCount: number | undefined;
2731
+ /** The question's text as last read โ€” what the โœ… rewrite keeps the body of. */
2732
+ let ownText: string | undefined;
2733
+ /** Set when the question is voided while we wait โ€” the wait ends there. */
2734
+ let voided: Extract<ReturnType<typeof askParseMessage>, { kind: "void" }> | undefined;
2735
+ const checkVoid = (own: Record<string, Json>): boolean => {
2736
+ const p = typeof own.text === "string" ? askParseMessage(own.text) : undefined;
2737
+ if (p?.kind === "void") voided = p;
2738
+ return !!voided;
2739
+ };
2740
+
2526
2741
  /** The earliest reply from an answerer that picked none of the choices. Kept
2527
2742
  * across polls so the timeout can report it, and so the operator is told once
2528
2743
  * rather than on every tick. */
@@ -2556,7 +2771,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2556
2771
  async function noteInvalid(msg: Record<string, Json>, offenders: string[]): Promise<void> {
2557
2772
  const text = typeof msg.text === "string" ? msg.text : "";
2558
2773
  if (!text) return;
2559
- const next = applyInvalidNotice(text, offenders);
2774
+ const next = applyInvalidNotice(text, offenders, ctx.lang);
2560
2775
  if (next === text) return;
2561
2776
  try {
2562
2777
  await editMessage(token, channelId, ts, next, cookie, true);
@@ -2566,7 +2781,22 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2566
2781
  }
2567
2782
  }
2568
2783
 
2784
+ /** Who has pressed โ“ ("other"). Not an answer โ€” โ“ carries no text โ€” so it
2785
+ * never resolves anything; the reply that should follow it does. Reported
2786
+ * once per person so the operator knows a reply is on its way rather than
2787
+ * reading the silence as "nobody looked". */
2788
+ const otherReported = new Set<string>();
2789
+ function noteOther(msg: Record<string, Json>): void {
2790
+ const r = asArray(msg.reactions).map(asRecord).find((x) => x.name === ASK_MARKER);
2791
+ for (const u of r ? asArray(r.users).filter(isAnswerer) : []) {
2792
+ if (otherReported.has(u)) continue;
2793
+ otherReported.add(u);
2794
+ console.error(` (<@${u}> ใŒ โ“ ใใฎไป– ใ‚’ๆŠผใ—ใพใ—ใŸใ€‚่ฟ”ไฟกใ‚’ๅพ…ใกใพใ™)`);
2795
+ }
2796
+ }
2797
+
2569
2798
  async function answerFromReactions(msg: Record<string, Json>): Promise<AskFound | null> {
2799
+ if (reactable.length) noteOther(msg);
2570
2800
  const picks = humanChoices(msg);
2571
2801
  // Nothing ambiguous any more โ€” take the notice back down if one is up.
2572
2802
  if (picks.length <= 1 && readInvalidNotice(typeof msg.text === "string" ? msg.text : "").length) {
@@ -2575,15 +2805,26 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2575
2805
  if (!picks.length) return null;
2576
2806
  if (picks.length === 1) {
2577
2807
  const { index, users } = picks[0]!;
2578
- return { answer: reactable[index]!, how: `ใƒชใ‚ขใ‚ฏใ‚ทใƒงใƒณ ${ASK_KEYCAPS[index]!.glyph}`, who: users[0]! };
2808
+ return { answer: reactable[index]!, how: copy.howReaction(ASK_KEYCAPS[index]!.glyph), who: users[0]!, choice: index + 1 };
2579
2809
  }
2580
2810
  const glyphs = picks.map((p) => ASK_KEYCAPS[p.index]!.glyph).join(" / ");
2581
- console.error(` (${glyphs} ใŒๅŒๆ™‚ใซ้ธใฐใ‚Œใฆใ„ใพใ™ใ€‚1 ใคใซ็ตžใฃใฆใ‚‚ใ‚‰ใ†ใพใงๅพ…ใกใพใ™)`);
2582
- // Whoever is on more than one pill. Named in the question itself so the
2583
- // person who has to fix it is the person who sees it โ€” and rewritten from
2584
- // the current state each pass, so reruns cannot stack up notices and the
2585
- // line clears itself once a reaction is taken back.
2586
- const offenders = [...new Set(picks.flatMap((p) => p.users))].sort();
2811
+ // Two cases, both "do not guess": one person on several pills, or
2812
+ // different people on different pills (a split). Only the first is the
2813
+ // voter's to fix, so only those people are named in the message.
2814
+ const pillsPerUser = new Map<string, number>();
2815
+ for (const p of picks) for (const u of p.users) pillsPerUser.set(u, (pillsPerUser.get(u) ?? 0) + 1);
2816
+ const multi = [...pillsPerUser].filter(([, n]) => n > 1).map(([u]) => u);
2817
+ console.error(multi.length
2818
+ ? ` (${glyphs} ใŒๅŒๆ™‚ใซ้ธใฐใ‚Œใฆใ„ใพใ™ใ€‚1 ใคใซ็ตžใฃใฆใ‚‚ใ‚‰ใ†ใพใงๅพ…ใกใพใ™)`
2819
+ : ` (ๅ›ž็ญ”ใŒๅˆ†ใ‹ใ‚Œใฆใ„ใพใ™: ${glyphs}ใ€‚ใฉใกใ‚‰ใ‹ไธ€ๆ–นใซใใ‚ใ†ใพใงๅพ…ใกใพใ™)`);
2820
+ // Whoever is on more than one pill โ€” and ONLY them. Naming everyone on any
2821
+ // pill told a voter who had pressed exactly one that she had pressed two
2822
+ // (2026-10-01: a voter on 1๏ธโƒฃ only was named because someone else was on 1๏ธโƒฃ
2823
+ // and 2๏ธโƒฃ). Named in the question itself so the person who has to fix it is
2824
+ // the person who sees it; rewritten from the current state each pass, so
2825
+ // reruns cannot stack up notices and the line clears itself once a
2826
+ // reaction is taken back. A pure split names nobody (empty = no notice).
2827
+ const offenders = multi.sort();
2587
2828
  await noteInvalid(msg, offenders);
2588
2829
  return null;
2589
2830
  }
@@ -2607,13 +2848,13 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2607
2848
  if (!t) continue;
2608
2849
  // A question asked WITHOUT choices is answered by whatever comes back โ€”
2609
2850
  // there is nothing to match against, and every reply is the answer.
2610
- if (!candidates.length) return { answer: t, how: "่ฟ”ไฟก", who: m.user };
2851
+ if (!candidates.length) return { answer: t, how: copy.howReply, who: m.user, ts: String(m.ts) };
2611
2852
  const match = askMatchChoice(t, candidates);
2612
2853
  if (match.kind === "chosen") {
2613
2854
  // Answer with the CHOICE, not with the reply that selected it: "2" and
2614
2855
  // "2. ไธญๆญข" have to reach the caller as the same decision a pill would
2615
2856
  // have produced, or the same answer arrives in three spellings.
2616
- return { answer: candidates[match.index - 1]!, how: `่ฟ”ไฟก (${match.index})`, who: m.user };
2857
+ return { answer: candidates[match.index - 1]!, how: copy.howReplyN(match.index), who: m.user, ts: String(m.ts), choice: match.index };
2617
2858
  }
2618
2859
  // Replied, but picked nothing. Recorded rather than returned: a later
2619
2860
  // reply may still choose, and the first one is what the operator needs to
@@ -2647,6 +2888,9 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2647
2888
  const messages = asArray(rep.messages).map(asRecord);
2648
2889
  const own = messages.find((m) => m.ts === ts);
2649
2890
  if (own) {
2891
+ replyCount = Number(own.reply_count) || 0;
2892
+ if (typeof own.text === "string") ownText = own.text;
2893
+ if (checkVoid(own)) return null;
2650
2894
  const byReaction = await answerFromReactions(own);
2651
2895
  if (byReaction) return byReaction;
2652
2896
  }
@@ -2660,6 +2904,11 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2660
2904
  const messages = asArray(hist.messages).map(asRecord);
2661
2905
  const own = messages.find((m) => m.ts === ts);
2662
2906
 
2907
+ if (own) {
2908
+ replyCount = Number(own.reply_count) || 0;
2909
+ if (typeof own.text === "string") ownText = own.text;
2910
+ if (checkVoid(own)) return null;
2911
+ }
2663
2912
  // A reaction is the intended path, so it wins when both are present.
2664
2913
  if (own) {
2665
2914
  const byReaction = await answerFromReactions(own);
@@ -2713,7 +2962,11 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2713
2962
  // and must keep getting the answer body alone.
2714
2963
  if (who) console.error(` ๅ›ž็ญ”่€…: ${stripTerminalControls(who)}`);
2715
2964
  try {
2716
- await editMessage(token, channelId, ts, askBuildResolvedText(question, found, who), cookie, true);
2965
+ // The body and the chosen option stay; only what no longer applies goes.
2966
+ // Built from the message as it is NOW (an edit made in Slack after asking
2967
+ // is kept). Not seen this run โ€” the bare form, as before.
2968
+ const keep = ownText ? askResolvedKeep(ownText, found.choice) : undefined;
2969
+ await editMessage(token, channelId, ts, askBuildResolvedText(question, found, who, ctx.lang, keep), cookie, true);
2717
2970
  // Swap the marker for the resolved one so search reflects reality:
2718
2971
  // `has::question:` should list what still needs answering, not everything
2719
2972
  // ever asked. Removal last โ€” a crash between the two leaves the question
@@ -2749,10 +3002,34 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2749
3002
  }
2750
3003
  console.error(` (Slack ใ‚จใƒฉใƒผ ${errors}/${ASK_MAX_CONSECUTIVE_ERRORS}, ๅ†่ฉฆ่กŒใ—ใพใ™: ${e instanceof Error ? e.message : String(e)})`);
2751
3004
  }
3005
+ if (voided) {
3006
+ askReportVoid(voided, shown);
3007
+ if (ctx.json) {
3008
+ await askEmit(token, ctx, {
3009
+ status: "void", answer: null, reason: voided.reason,
3010
+ ...(voided.supersededBy ? { supersededBy: voided.supersededBy } : {}),
3011
+ notes: [], cursor: askNextCursor(ts, ctx.after),
3012
+ });
3013
+ }
3014
+ process.exit(ASK_EXIT_VOID);
3015
+ }
2752
3016
  if (found) {
3017
+ // Notes once the answer is in hand: a pill used to end the poll before
3018
+ // the thread was ever read, which is how a note beside it was lost. Read
3019
+ // BEFORE โœ… is written, and delivered right after โ€” โœ… is what tells a
3020
+ // later --waitFor the question is settled.
3021
+ const notes = await askCollectNotes(token, {
3022
+ channelId, ts, threadOnly, replyCount, isAnswerer, askerUserId, askerBotId,
3023
+ ...(ctx.threadParentTs ? { threadParentTs: ctx.threadParentTs } : {}),
3024
+ ...(cookie ? { cookie } : {}),
3025
+ }, ctx.after, (m) => m.ts === found!.ts);
2753
3026
  await markResolved(found);
2754
- // stdout gets the answer and nothing else.
2755
- console.log(found.answer);
3027
+ const r: Parameters<typeof askEmit>[2] = {
3028
+ status: "answered", answer: found.answer, how: found.how, notes,
3029
+ cursor: askNextCursor(ts, ctx.after, found.ts, ...notes.map((n) => n.ts)),
3030
+ };
3031
+ if (found.who) r.who = found.who;
3032
+ await askEmit(token, ctx, r);
2756
3033
  process.exit(0);
2757
3034
  }
2758
3035
  // A free-text reply that picks nothing is delivered NOW, not held until the
@@ -2771,7 +3048,11 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2771
3048
  console.error(` ้ธๆŠž่‚ข: ${candidates.map((c, i) => `${i + 1}. ${stripTerminalControls(askFlatten(c))}`).join(" ")}`);
2772
3049
  console.error(` ${shown}`);
2773
3050
  console.error(` ใ“ใฎ่ฟ”ไฟกใฎๅพŒใ‚’ๅพ…ใค: ${askResumeCommand(shown, ctx.asBot, unchosen.ts)}`);
2774
- console.log(unchosen.text);
3051
+ if (ctx.json) {
3052
+ await askEmit(token, ctx, { status: "freetext", answer: unchosen.text, who: unchosen.who, notes: [], cursor: askNextCursor(ts, ctx.after, unchosen.ts) });
3053
+ } else {
3054
+ console.log(unchosen.text);
3055
+ }
2775
3056
  process.exit(ASK_EXIT_FREETEXT);
2776
3057
  }
2777
3058
  // Said as soon as it is seen, not only at the timeout: the reply is usually
@@ -2813,6 +3094,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2813
3094
  console.error(` ้ธๆŠž่‚ข: ${candidates.map((c, i) => `${i + 1}. ${stripTerminalControls(askFlatten(c))}`).join(" ")}`);
2814
3095
  console.error(` ${shown}`);
2815
3096
  console.error(` ่ฟ”็ญ”ใ—ใฆใ‹ใ‚‰ๅ›žๅŽใ™ใ‚‹: ${askResumeCommand(shown, ctx.asBot, ctx.after)}`);
3097
+ if (ctx.json) await askEmit(token, ctx, { status: "ambiguous", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after) });
2816
3098
  process.exit(ASK_EXIT_UNCHOSEN);
2817
3099
  }
2818
3100
  if (timeout === 0) {
@@ -2822,6 +3104,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2822
3104
  console.error(` ${shown}`);
2823
3105
  console.error(` ใ‚ใจใงๅ›žๅŽใ™ใ‚‹: ${askResumeCommand(shown, ctx.asBot, ctx.after)}`);
2824
3106
  }
3107
+ if (ctx.json) await askEmit(token, ctx, { status: "timeout", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after) });
2825
3108
  process.exit(ASK_EXIT_TIMEOUT);
2826
3109
  }
2827
3110
 
@@ -2872,6 +3155,8 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
2872
3155
  const qEnc = askEncodeBroadcasts(unescapeArg(args.question));
2873
3156
  const bEnc = askEncodeBroadcasts(unescapeArg(args.body ?? ""));
2874
3157
  const broadcastKinds = new Set([...qEnc.kinds, ...bEnc.kinds]);
3158
+ // Already-encoded broadcasts (`<!here>`) โ€” what a re-ask of a posted question carries.
3159
+ for (const m of `${qEnc.text}\n${bEnc.text}`.matchAll(/<!(here|channel|everyone)(?:\|[^>]*)?>/g)) broadcastKinds.add(m[1]!);
2875
3160
  const mentionToken = args.mentionToken ?? token;
2876
3161
  const qRep = await encodeMentionsDetailed(mentionToken, qEnc.text, channelId, args.mentionCookie);
2877
3162
  const bRep = bEnc.text
@@ -2889,6 +3174,14 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
2889
3174
  // Tagging yourself grants nothing โ€” your own reactions are the seeds.
2890
3175
  const audience = new Set(resolved.map((r) => r.userId).filter((id) => id !== selfId));
2891
3176
  const audienceNames = new Map(resolved.map((r) => [r.userId, r.display]));
3177
+ // Tags already in Slack's `<@Uโ€ฆ>` form name someone just as surely โ€” that is
3178
+ // what a re-ask built from a posted question carries. Same self-exclusion.
3179
+ for (const m of `${question}\n${body}`.matchAll(/<@([UW][A-Z0-9]+)(?:\|[^>]*)?>/g)) {
3180
+ const id = m[1]!;
3181
+ if (id === selfId || audience.has(id)) continue;
3182
+ audience.add(id);
3183
+ audienceNames.set(id, await userName(mentionToken, id, args.mentionCookie));
3184
+ }
2892
3185
  let audienceLabel: string;
2893
3186
  if (broadcast) {
2894
3187
  audienceLabel = `anyone in ${ref} (${[...broadcastKinds].map((k) => `@${k}`).join(", ")})`;
@@ -2931,7 +3224,19 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
2931
3224
  // which makes channel mode unambiguous by construction. A question posted
2932
3225
  // INTO a thread is thread-scoped for the same reason.
2933
3226
  const threadOnly = !channelId.startsWith("D") || !!threadTs;
2934
- const message = askBuildText(question, body, reactable, overflow, threadOnly);
3227
+ // The copy's language. The readers are the people who have to read it, so
3228
+ // their Slack locale comes first โ€” but only for a known set of people: an
3229
+ // @here audience is not one, and is left to the content and the system.
3230
+ const readerLocales = broadcast
3231
+ ? []
3232
+ : await Promise.all([...audience].map((id) => userLocale(mentionToken, id, args.mentionCookie)));
3233
+ const picked = askResolveLang({ flag: args.lang, readerLocales, content: [question, body, ...reactable, ...overflow] });
3234
+ if (!picked) {
3235
+ console.error(`Error: unsupported language "${stripTerminalControls(args.lang ?? process.env.SLACK_TERM_LANG ?? "")}" โ€” use one of: ${ASK_LANGS.join(", ")}`);
3236
+ process.exit(ASK_EXIT_ERROR);
3237
+ }
3238
+ const lang = picked.lang;
3239
+ const message = askBuildText(question, body, reactable, overflow, threadOnly, lang);
2935
3240
  guardUrlBoundaries(message, args.allowUrlAdjacent);
2936
3241
 
2937
3242
  // Preview the destination's last message, exactly as `send` does โ€” the gate's
@@ -2958,7 +3263,8 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
2958
3263
  // Resolved before the gate so the clock can name the recipient's zone. DM
2959
3264
  // only; null means "unknown" and the label says so rather than guessing.
2960
3265
  const recipientTz = await recipientTzFor(token, channelId, cookie);
2961
- const code = safetyCode(channelId, threadTs ?? "", lastText, message, self?.userId ?? "");
3266
+ const code = safetyCode(channelId, threadTs ?? "", lastText, message, self?.userId ?? "",
3267
+ ...(args.voidAfter ? ["reask", `${args.voidAfter.channelId}:${args.voidAfter.ts}`, args.voidAfter.reason] : []));
2962
3268
  if (args.code !== code) {
2963
3269
  const dest = await destLabel(token, channelId, ref, cookie);
2964
3270
  const choiceLines = reactable.length
@@ -2981,6 +3287,8 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
2981
3287
  // shown as its own line rather than left to be inferred from the body.
2982
3288
  ` Answerable by: ${audienceLabel}`,
2983
3289
  ` Via: ${threadOnly ? "reactions or thread replies" : "reactions or replies in this DM"}`,
3290
+ ...(args.voidAfter ? [` Then ็„กๅŠนๅŒ–: ${stripTerminalControls(args.voidAfter.link)} (reason: ${stripTerminalControls(args.voidAfter.reason || "none")}) โ€” replaced by this question`] : []),
3291
+ ` 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)`,
2984
3292
  `--------------------------------โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€`,
2985
3293
  ], recipientTz);
2986
3294
  }
@@ -2991,7 +3299,9 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
2991
3299
  const attr = sentAttribution("ask");
2992
3300
  const ts = await slackSend(token, channelId, message, threadTs, false, cookie, true, attr.metadata);
2993
3301
 
2994
- // The marker goes on FIRST so it sits left of the pills, and as a reaction so
3302
+ // The marker goes on LAST, after 1,2,3, because it doubles as the "other"
3303
+ // choice the body lists under the numbered ones (people were already pressing
3304
+ // โ“ for "none of these fit"). It is a reaction so
2995
3305
  // `has::question:` lists every question the way `has::pushpin:` lists every
2996
3306
  // todo. Body text cannot do that job: Slack's index splits on punctuation, so
2997
3307
  // a `:question:` written in the text is indexed as the word "question" and
@@ -3000,12 +3310,12 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
3000
3310
  // NOTE the colons: the modifier's argument is itself colon-wrapped, and the
3001
3311
  // bare `has:question` form does NOT error โ€” it silently degrades to a
3002
3312
  // full-text search and returns plausible-looking counts. See ts/todo.ts.
3003
- // Marker first, then 1,2,3 โ€” and spaced out. Sequential awaits alone are NOT
3313
+ // 1,2,3, then the marker โ€” and spaced out. Sequential awaits alone are NOT
3004
3314
  // enough: measured on two real questions from the same build, one came back
3005
3315
  // `two|one|question|three`. See ts/reactionSeed.ts for the evidence and for
3006
3316
  // SLACK_REACTION_SEED_GAP_MS.
3007
3317
  await seedReactionsInOrder(
3008
- [ASK_MARKER, ...reactable.map((_, i) => ASK_KEYCAPS[i]!.name)],
3318
+ [...reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER],
3009
3319
  (name) => reactionAdd(token, channelId, ts, name, cookie),
3010
3320
  (name, e) => {
3011
3321
  const why = e instanceof Error ? e.message : String(e);
@@ -3025,6 +3335,12 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
3025
3335
  const shown = permalink || `${channelId}:${ts}`;
3026
3336
  console.error(`โœ“ Asked: ${shown}`);
3027
3337
  attr.record({ team: self?.team, channel: channelId, target: args.target, ts, threadTs, permalink, text: message, asBot: args.asBot });
3338
+ // The replacement is up; now retire what it replaces. A failure here leaves
3339
+ // two open questions, which is reported loudly โ€” never a missing one.
3340
+ if (args.voidAfter) {
3341
+ const ok = await askApplyVoid(token, args.voidAfter, args.voidAfter.reason, shown, cookie);
3342
+ if (!ok) console.error(` โš  ๆ–ฐใ—ใ„่ณชๅ•ใฏๅ‡บใพใ—ใŸใŒใ€ๅคใ„่ณชๅ•ใ‚’็„กๅŠนๅŒ–ใงใใพใ›ใ‚“ใงใ—ใŸ โ€” ๆ‰‹ใง: slack ask --void='${stripTerminalControls(args.voidAfter.link)}' --superseded-by='${shown}'`);
3343
+ }
3028
3344
 
3029
3345
  if (!args.wait) {
3030
3346
  // Not waiting. stdout is the RESUME COMMAND, not just a link: nothing else
@@ -3046,6 +3362,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
3046
3362
  reactable,
3047
3363
  overflow,
3048
3364
  threadOnly,
3365
+ lang,
3049
3366
  audience,
3050
3367
  broadcast,
3051
3368
  askerUserId: self?.userId ?? "",
@@ -3053,6 +3370,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
3053
3370
  timeout,
3054
3371
  shown,
3055
3372
  asBot: !!args.asBot,
3373
+ json: !!args.json,
3056
3374
  };
3057
3375
  if (threadTs) ctx.threadParentTs = threadTs;
3058
3376
  if (cookie) ctx.cookie = cookie;
@@ -3061,6 +3379,423 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
3061
3379
 
3062
3380
 
3063
3381
 
3382
+ /** Say on stderr that a question is void, and where its replacement is. */
3383
+ function askReportVoid(p: { reason: string; supersededBy?: string }, shown: string): void {
3384
+ console.error(`ใ“ใฎ่ณชๅ•ใฏ็„กๅŠนใงใ™ โ€” ๅ›ž็ญ”ใ•ใ‚Œใ‚‹ใ“ใจใฏใ‚ใ‚Šใพใ›ใ‚“ใ€‚ๅพ…ใคใฎใ‚’ใ‚„ใ‚ใฆใใ ใ•ใ„: ${stripTerminalControls(shown)}`);
3385
+ if (p.reason) console.error(` ${stripTerminalControls(p.reason)}`);
3386
+ if (p.supersededBy) console.error(` ๆ–ฐใ—ใ„่ณชๅ•: ${stripTerminalControls(p.supersededBy)}`);
3387
+ }
3388
+
3389
+ /** Find the one message a permalink (or `Cโ€ฆ:ts`) names. A question posted into
3390
+ * a thread is not in history at all, so where the link says it is a reply, it
3391
+ * is read from the thread. */
3392
+ async function askFetchByLink(token: string, link: string, cookie?: string): Promise<
3393
+ { channelId: string; ts: string; threadTs?: string; msg?: Record<string, Json> } | null
3394
+ > {
3395
+ const url = parseSlackPermalink(link);
3396
+ let channelId: string;
3397
+ let ts: string;
3398
+ let threadTs: string | undefined;
3399
+ if (url?.ts) {
3400
+ channelId = url.channel;
3401
+ ts = url.ts;
3402
+ threadTs = url.threadTs && url.threadTs !== url.ts ? url.threadTs : undefined;
3403
+ } else {
3404
+ const m = link.match(/^([A-Za-z0-9]+):(\d{10}\.\d{6})$/);
3405
+ if (!m) return null;
3406
+ channelId = m[1]!;
3407
+ ts = m[2]!;
3408
+ }
3409
+ let msg: Record<string, Json> | undefined;
3410
+ if (threadTs) {
3411
+ const rep = asRecord((await replies(token, channelId, threadTs, 100, cookie)) as Json);
3412
+ msg = asArray(rep.messages).map(asRecord).find((m) => m.ts === ts);
3413
+ } else {
3414
+ const hist = asRecord((await history(token, channelId, 1, ts, undefined, cookie, true)) as Json);
3415
+ msg = asArray(hist.messages).map(asRecord).find((m) => m.ts === ts);
3416
+ }
3417
+ const out: { channelId: string; ts: string; threadTs?: string; msg?: Record<string, Json> } = { channelId, ts };
3418
+ if (threadTs) out.threadTs = threadTs;
3419
+ if (msg) out.msg = msg;
3420
+ return out;
3421
+ }
3422
+
3423
+ /** Look up a question for a lifecycle action (edit / void / reask): it must
3424
+ * exist, be OUR OWN post (Slack lets only the author edit, and changing
3425
+ * someone else's decision is not a thing this should make possible), and be
3426
+ * open. Says why on stderr and returns null otherwise. */
3427
+ async function askLoadOwnOpen(token: string, link: string, self: Self | null, asBot: boolean, cookie?: string):
3428
+ Promise<(AskVoidTarget & { msg: Record<string, Json>; threadTs?: string }) | "void" | null> {
3429
+ const shown = stripTerminalControls(link);
3430
+ const got = await askFetchByLink(token, link, cookie);
3431
+ if (!got) { console.error(`โœ— ${shown}: permalink (ใพใŸใฏ 'Cโ€ฆ:1700000000.000100') ใงใฏใ‚ใ‚Šใพใ›ใ‚“`); return null; }
3432
+ if (!got.msg) { console.error(`โœ— ${shown}: ใƒกใƒƒใ‚ปใƒผใ‚ธใŒ่ฆ‹ใคใ‹ใ‚Šใพใ›ใ‚“`); return null; }
3433
+ const msg = got.msg;
3434
+ const own = (!!self?.userId && msg.user === self.userId) || (!!self?.botId && msg.bot_id === self.botId);
3435
+ if (!own) {
3436
+ console.error(`โœ— ${shown}: ่‡ชๅˆ†๏ผˆ${senderLabel(self, asBot)}๏ผ‰ใŒๆŠ•็จฟใ—ใŸ่ณชๅ•ใงใฏใ‚ใ‚Šใพใ›ใ‚“` +
3437
+ `${asBot ? "" : " โ€” bot ใŒๅ‡บใ—ใŸ่ณชๅ•ใชใ‚‰ --as-bot ใง"}`);
3438
+ return null;
3439
+ }
3440
+ const text = typeof msg.text === "string" ? msg.text : "";
3441
+ const parsed = askParseMessage(text);
3442
+ if (parsed.kind === "void") return "void";
3443
+ if (parsed.kind === "resolved") { console.error(`โœ— ${shown}: ๅ›ž็ญ”ๆธˆใฟใฎ่ณชๅ•ใงใ™๏ผˆๅ›ž็ญ”: ${stripTerminalControls(parsed.answer.split("\n")[0] ?? "")}๏ผ‰โ€” ๅค‰ๆ›ดใงใใพใ›ใ‚“`); return null; }
3444
+ if (parsed.kind !== "open") { console.error(`โœ— ${shown}: \`slack ask\` ใฎ่ณชๅ•ใจใ—ใฆ่ชญใฟๅ–ใ‚Œใพใ›ใ‚“ โ€” ${askExplainReject(text)}`); return null; }
3445
+ const out: AskVoidTarget & { msg: Record<string, Json>; threadTs?: string } = { link, channelId: got.channelId, ts: got.ts, parsed, msg };
3446
+ if (got.threadTs) out.threadTs = got.threadTs;
3447
+ return out;
3448
+ }
3449
+
3450
+ /** Has anyone but us answered, or started to? A pressed keycap, a reply in the
3451
+ * thread, or (in a 1:1 DM) a plain message after the question. Used to lock the
3452
+ * OPTIONS: after a vote, renumbering them would make the reactions already
3453
+ * given silently mean something else. */
3454
+ async function askHasResponses(token: string, t: { channelId: string; ts: string; msg: Record<string, Json> }, self: Self | null, cookie?: string): Promise<boolean> {
3455
+ const notSelf = (u: unknown) => typeof u === "string" && u !== self?.userId;
3456
+ const pressed = asArray(t.msg.reactions).map(asRecord)
3457
+ .some((r) => ASK_KEYCAPS.some((k) => k.name === r.name) && asArray(r.users).some(notSelf));
3458
+ if (pressed) return true;
3459
+ // A thread counts only if someone OTHER than us is in it โ€” our own
3460
+ // follow-up note must not lock the options.
3461
+ if (Number(t.msg.reply_count) > 0) {
3462
+ const rep = asRecord((await replies(token, t.channelId, t.ts, 100, cookie)) as Json);
3463
+ if (asArray(rep.messages).map(asRecord).some((m) => Number(m.ts) > Number(t.ts) && notSelf(m.user) && !m.bot_id)) return true;
3464
+ }
3465
+ if (t.channelId.startsWith("D")) {
3466
+ const hist = asRecord((await history(token, t.channelId, 30, t.ts, undefined, cookie, true)) as Json);
3467
+ if (asArray(hist.messages).map(asRecord).some((m) => Number(m.ts) > Number(t.ts) && notSelf(m.user) && !m.bot_id)) return true;
3468
+ }
3469
+ return false;
3470
+ }
3471
+
3472
+ /** `slack ask --edit=<link> ["new question" [choicesโ€ฆ]] [--body โ€ฆ]` โ€” change an
3473
+ * open question THROUGH the ask builder, so pills, โ“, the instructions and the
3474
+ * body stay in the layout collect reads back (a free-hand `slack edit` is what
3475
+ * this replaces; `edit` now refuses ask messages without --force).
3476
+ *
3477
+ * The wording (question, body) can change any time. The OPTIONS can change only
3478
+ * while nobody has answered: after a vote the old reactions would silently mean
3479
+ * the new options. Then it refuses and points at `--void=<link> --reask`, which
3480
+ * retires the question and asks the new one. Answered / void: refused. */
3481
+ async function cmdAskEdit(token: string, args: {
3482
+ link: string; question?: string; body?: string; choices?: string[]; lang?: string;
3483
+ code?: string; asBot: boolean; cookie?: string; mentionToken: string; mentionCookie?: string;
3484
+ allowUrlAdjacent?: boolean;
3485
+ }): Promise<void> {
3486
+ const self = await selfIdentity(token, args.cookie);
3487
+ const t = await askLoadOwnOpen(token, args.link, self, args.asBot, args.cookie);
3488
+ if (t === "void") { console.error(`โœ— ${stripTerminalControls(args.link)}: ็„กๅŠนๅŒ–ๆธˆใฟใฎ่ณชๅ•ใฏ็ทจ้›†ใงใใพใ›ใ‚“`); process.exit(ASK_EXIT_ERROR); }
3489
+ if (!t) process.exit(ASK_EXIT_ERROR);
3490
+ const old = t.parsed;
3491
+ const encode = async (raw: string): Promise<string> => {
3492
+ const enc = askEncodeBroadcasts(unescapeArg(raw));
3493
+ return (await encodeMentionsDetailed(args.mentionToken, enc.text, t.channelId, args.mentionCookie)).text;
3494
+ };
3495
+ const question = args.question !== undefined ? await encode(args.question) : old.question;
3496
+ const body = args.body !== undefined ? await encode(args.body) : old.body;
3497
+ const oldChoices = [...old.reactable, ...old.overflow];
3498
+ const choices = args.choices ? args.choices.map(unescapeArg) : oldChoices;
3499
+ const choicesChanged = JSON.stringify(choices.map(askFlatten)) !== JSON.stringify(oldChoices.map(askFlatten));
3500
+ if (choicesChanged && await askHasResponses(token, t, self, args.cookie)) {
3501
+ console.error(
3502
+ `โœ— ใ“ใฎ่ณชๅ•ใซใฏใ‚‚ใ†ๅ›ž็ญ”๏ผˆใƒชใ‚ขใ‚ฏใ‚ทใƒงใƒณใ‹่ฟ”ไฟก๏ผ‰ใŒใ‚ใ‚Šใพใ™ โ€” ้ธๆŠž่‚ขใ‚’ๅค‰ใˆใ‚‹ใจใ€ๆŠผใ•ใ‚ŒใŸใƒชใ‚ขใ‚ฏใ‚ทใƒงใƒณใฎๆ„ๅ‘ณใŒๅค‰ใ‚ใฃใฆใ—ใพใ„ใพใ™ใ€‚\n` +
3503
+ ` ่ณชๅ•ๆ–‡ใจๆœฌๆ–‡ใฏๅค‰ใˆใ‚‰ใ‚Œใพใ™ใ€‚้ธๆŠž่‚ขใ‚’ๅค‰ใˆใ‚‹ใชใ‚‰็„กๅŠนๅŒ–ใ—ใฆๅ‡บใ—็›ดใ—ใฆใใ ใ•ใ„:\n` +
3504
+ ` slack ask --void='${stripTerminalControls(args.link)}' --reask${args.asBot ? " --as-bot" : ""} โ€ฆ`,
3505
+ );
3506
+ process.exit(ASK_EXIT_ERROR);
3507
+ }
3508
+ // Still addressed to someone: the tags are who may answer, and an edit must
3509
+ // not leave a question nobody may answer (a 1:1 DM needs none).
3510
+ // Tagging yourself grants nothing โ€” same rule as when asking.
3511
+ const tagged = [...`${question}\n${body}`.matchAll(/<@([UW][A-Z0-9]+)(?:\|[^>]*)?>/g)].some((m) => m[1] !== self?.userId);
3512
+ const addressed = tagged || /<!(?:here|channel|everyone)/.test(`${question}\n${body}`) || t.channelId.startsWith("D");
3513
+ if (!addressed) {
3514
+ console.error(`โœ— ่ชฐใซใ‚‚ๅฎ›ใฆใ‚‰ใ‚Œใฆใ„ใชใ„่ณชๅ•ใซใชใ‚Šใพใ™ โ€” ๅ›ž็ญ”ใงใใ‚‹ไบบใ‚’ @ ใงๆฎ‹ใ—ใฆใใ ใ•ใ„`);
3515
+ process.exit(ASK_EXIT_ERROR);
3516
+ }
3517
+ const lang = args.lang ? askResolveLang({ flag: args.lang })?.lang ?? old.lang : old.lang;
3518
+ const reactable = choices.slice(0, ASK_MAX_REACTION_CHOICES);
3519
+ const overflow = choices.slice(ASK_MAX_REACTION_CHOICES);
3520
+ const oldText = typeof t.msg.text === "string" ? t.msg.text : "";
3521
+ let next = askBuildText(question, body, reactable, overflow, old.threadOnly, lang);
3522
+ // An invalid-ballot notice still in force stays (it names the people who
3523
+ // have to fix their vote); it is only possible when the options did not change.
3524
+ const invalid = readInvalidNotice(oldText);
3525
+ if (invalid.length) next = applyInvalidNotice(next, invalid, lang);
3526
+ guardUrlBoundaries(next, args.allowUrlAdjacent);
3527
+ if (next === oldText) {
3528
+ console.error("ๅค‰ๆ›ดใฏใ‚ใ‚Šใพใ›ใ‚“ใ€‚");
3529
+ return;
3530
+ }
3531
+
3532
+ const code = safetyCode("ask-edit", `${t.channelId}:${t.ts}`, next, self?.userId ?? "");
3533
+ if (args.code !== code) {
3534
+ const show = (label: string, a: string, b: string) => a === b
3535
+ ? [` ${label}: (unchanged)`]
3536
+ : [` ${label}:`, ...a.split("\n").map((l) => ` - ${l}`), ...b.split("\n").map((l) => ` + ${l}`)];
3537
+ requireCode(args.code, code, [
3538
+ `--- Editing question --------------------------`,
3539
+ fromLine(self, { asBot: args.asBot }),
3540
+ ` ${stripTerminalControls(args.link)}`,
3541
+ ...show("Question", old.question, question),
3542
+ ...show("Body", old.body, body),
3543
+ ...(choicesChanged
3544
+ ? [` Choices (nobody has answered yet โ€” pills are re-seeded):`,
3545
+ ...oldChoices.map((c, i) => ` - ${i + 1}. ${askFlatten(c)}`),
3546
+ ...choices.map((c, i) => ` + ${i + 1}. ${askFlatten(c)}`)]
3547
+ : [` Choices: (unchanged)`]),
3548
+ ...(lang !== old.lang ? [` Language: ${old.lang} โ†’ ${lang} (the instructions are rewritten)`] : []),
3549
+ `----------------------------------------------`,
3550
+ ]);
3551
+ }
3552
+
3553
+ await editMessage(token, t.channelId, t.ts, next, args.cookie, true);
3554
+ if (choicesChanged) {
3555
+ // Re-seed in order: 1..n then โ“ last, as a fresh question has them.
3556
+ for (const name of [...old.reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER]) {
3557
+ try { await reactionRemove(token, t.channelId, t.ts, name, args.cookie); } catch { /* already gone */ }
3558
+ }
3559
+ await seedReactionsInOrder(
3560
+ [...reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER],
3561
+ (name) => reactionAdd(token, t.channelId, t.ts, name, args.cookie),
3562
+ (name, e) => console.error(` (ใƒชใ‚ขใ‚ฏใ‚ทใƒงใƒณ ${name} ใ‚’ไป˜ใ‘ใ‚‰ใ‚Œใพใ›ใ‚“ใงใ—ใŸ: ${e instanceof Error ? e.message : String(e)})`),
3563
+ );
3564
+ }
3565
+ console.error(`โœ“ ็ทจ้›†ใ—ใพใ—ใŸ: ${stripTerminalControls(args.link)}`);
3566
+ }
3567
+
3568
+ /** `slack ask --ls [--stale <dur>] [--state โ€ฆ] [--channel โ€ฆ] [--json]` โ€” the
3569
+ * questions I posted, with their LIVE state.
3570
+ *
3571
+ * The list comes from the local sent-log (every `ask` is recorded there with
3572
+ * its channel, ts and identity, user or bot); the state comes from Slack, one
3573
+ * read per question, so a question answered, voided or edited elsewhere shows
3574
+ * as it is now. Limitation: only asks sent from THIS machine's log.
3575
+ *
3576
+ * `--stale 24h` = open and older than that โ€” the morning triage's list. It
3577
+ * prints, on stderr, the one `--void=` command that would retire them all
3578
+ * (behind the usual gate), rather than voiding anything itself. */
3579
+ async function cmdAskLs(tokens: { user?: string; bot?: string; cookie?: string }, args: {
3580
+ stale?: string; state: string; channel?: string; json: boolean; limit: number;
3581
+ }): Promise<void> {
3582
+ const q: Parameters<typeof querySent>[0] = { kind: "ask", limit: args.limit };
3583
+ if (args.channel) q.channel = args.channel;
3584
+ const rows = querySent(q);
3585
+ if (!rows) {
3586
+ console.error(`Error: ${SENT_LOG_UNAVAILABLE}\n (${sentDbPath()})`);
3587
+ process.exit(ASK_EXIT_ERROR);
3588
+ }
3589
+ let staleBefore: number | undefined;
3590
+ if (args.stale) {
3591
+ staleBefore = parseSince(args.stale);
3592
+ if (staleBefore === undefined) {
3593
+ console.error(`Error: --stale needs a duration like 24h / 3d (got ${stripTerminalControls(args.stale)})`);
3594
+ process.exit(ASK_EXIT_ERROR);
3595
+ }
3596
+ }
3597
+ type Item = { state: string; age_h: number; asBot: boolean; channel: string; target: string; link: string; question: string; answer?: string };
3598
+ const items: Item[] = [];
3599
+ for (const r of rows) {
3600
+ if (staleBefore !== undefined && r.sent_at > staleBefore) continue;
3601
+ const token = r.as_bot ? tokens.bot : tokens.user;
3602
+ const link = r.permalink || `${r.channel}:${r.ts}`;
3603
+ let state = "unknown";
3604
+ let question = (r.text.split("\n")[0] ?? "").replace(/^:question: /, "");
3605
+ let answer: string | undefined;
3606
+ if (token) {
3607
+ try {
3608
+ const got = await askFetchByLink(token, `${r.channel}:${r.ts}`, r.as_bot ? undefined : tokens.cookie);
3609
+ if (!got?.msg) state = "gone";
3610
+ else {
3611
+ const p = askParseMessage(typeof got.msg.text === "string" ? got.msg.text : "");
3612
+ state = p.kind === "resolved" ? "answered" : p.kind === "other" ? "edited" : p.kind;
3613
+ if (p.kind !== "other" && p.question) question = p.question;
3614
+ if (p.kind === "resolved") answer = p.answer;
3615
+ }
3616
+ } catch {
3617
+ state = "unknown";
3618
+ }
3619
+ }
3620
+ if (args.state !== "all" && state !== args.state) continue;
3621
+ const item: Item = {
3622
+ state, age_h: Math.round((Date.now() - r.sent_at) / 3600e3), asBot: !!r.as_bot,
3623
+ channel: r.channel, target: r.target ?? r.channel, link, question: askFlatten(question),
3624
+ };
3625
+ if (answer !== undefined) item.answer = answer;
3626
+ items.push(item);
3627
+ }
3628
+ if (args.json) {
3629
+ console.log(JSON.stringify(items));
3630
+ } else {
3631
+ for (const it of items) {
3632
+ console.log(`${it.state.padEnd(8)} ${String(it.age_h).padStart(4)}h ${it.asBot ? "bot " : "user"} ${stripTerminalControls(it.target)} ${it.link}\n ${stripTerminalControls(it.question).slice(0, 100)}`);
3633
+ }
3634
+ console.error(`${items.length} question(s)${args.state === "all" ? "" : ` in state "${args.state}"`}${args.stale ? `, older than ${args.stale}` : ""}.`);
3635
+ }
3636
+ // The bulk path: one gated command per identity, never an automatic void.
3637
+ const open = items.filter((it) => it.state === "open");
3638
+ if (args.stale && open.length) {
3639
+ for (const bot of [false, true]) {
3640
+ const mine = open.filter((it) => it.asBot === bot);
3641
+ if (!mine.length) continue;
3642
+ console.error(`็„กๅŠนๅŒ–ใ™ใ‚‹ใชใ‚‰ (${mine.length} ไปถ, ${bot ? "bot" : "user"}):\n slack ask ${mine.map((it) => `--void='${it.link}'`).join(" ")} --reason 'stale'${bot ? " --as-bot" : ""}`);
3643
+ }
3644
+ }
3645
+ }
3646
+
3647
+ /** A question about to be voided: where it is, and what it parsed as. */
3648
+ interface AskVoidTarget {
3649
+ link: string;
3650
+ channelId: string;
3651
+ ts: string;
3652
+ parsed: Extract<ReturnType<typeof askParseMessage>, { kind: "open" }>;
3653
+ }
3654
+
3655
+ /** Void one question: the ๐Ÿšซ text (body kept), our seeds and โ“ off, ๐Ÿšซ on.
3656
+ * The text is what marks it void; the reactions are cleanup + searchability,
3657
+ * so a failure there is reported and does not undo the void. */
3658
+ async function askApplyVoid(token: string, t: AskVoidTarget, reason: string, supersededBy: string | undefined, cookie?: string): Promise<boolean> {
3659
+ const shown = stripTerminalControls(t.link);
3660
+ try {
3661
+ const next = askBuildVoidText(t.parsed.question, reason, t.parsed.lang, t.parsed.body || undefined, supersededBy);
3662
+ await editMessage(token, t.channelId, t.ts, next, cookie, true);
3663
+ } catch (e: unknown) {
3664
+ console.error(`โœ— ${shown}: ๆ›ธใๆ›ใˆใซๅคฑๆ•—ใ—ใพใ—ใŸ: ${e instanceof Error ? e.message : String(e)}`);
3665
+ return false;
3666
+ }
3667
+ for (const name of [...t.parsed.reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER]) {
3668
+ try { await reactionRemove(token, t.channelId, t.ts, name, cookie); } catch { /* not ours / already gone */ }
3669
+ }
3670
+ try {
3671
+ await reactionAdd(token, t.channelId, t.ts, ASK_VOID_MARKER, cookie);
3672
+ } catch (e: unknown) {
3673
+ console.error(` (${shown}: ๐Ÿšซ ใƒชใ‚ขใ‚ฏใ‚ทใƒงใƒณใ‚’ไป˜ใ‘ใ‚‰ใ‚Œใพใ›ใ‚“ใงใ—ใŸ: ${e instanceof Error ? e.message : String(e)})`);
3674
+ }
3675
+ console.error(`โœ“ ็„กๅŠนๅŒ–: ${shown}`);
3676
+ return true;
3677
+ }
3678
+
3679
+ /** `slack ask --void <permalink>โ€ฆ` โ€” ็„กๅŠนๅŒ–: mark questions that have expired or
3680
+ * stopped meaning anything, so nobody answers them and nothing waits on them.
3681
+ *
3682
+ * Case, 2026-10-05: a release ask was pinned to one head SHA; three minutes
3683
+ * later main moved and approving it would have approved a different head. The
3684
+ * bot had to void it by hand (prefix the text, leave the pills), and nothing
3685
+ * machine-readable said so: `--waitFor` still treated it as open, the
3686
+ * stale-pill reminder still counted it, the pills were still pressable.
3687
+ *
3688
+ * What voiding does, per question: the text becomes the ๐Ÿšซ form (head, void
3689
+ * stamp with the reason, optional pointer to the replacement, the body); our
3690
+ * seeded pills and the โ“ marker come off; a ๐Ÿšซ reaction goes on. Pills someone
3691
+ * already pressed stay โ€” Slack lets only their owner remove them โ€” and are
3692
+ * never collected, because a void is never polled again.
3693
+ *
3694
+ * Only your OWN questions: Slack lets only the author edit a message, and
3695
+ * voiding someone else's decision is not a thing this should make possible.
3696
+ * An answered question is final and is refused; an already-void one is a
3697
+ * no-op. Several links at once โ€” the morning triage found 38 stale pills โ€”
3698
+ * all behind ONE confirm code covering every target and the reason. */
3699
+ async function cmdAskVoid(token: string, args: { links: string[]; reason: string; supersededBy?: string; code?: string; asBot: boolean; cookie?: string }): Promise<void> {
3700
+ const self = await selfIdentity(token, args.cookie);
3701
+ // The replacement link is written into every voided question for good, so it
3702
+ // must name a real message before anything is touched โ€” a typo would point
3703
+ // people at nothing, permanently.
3704
+ if (args.supersededBy) {
3705
+ const shown = stripTerminalControls(args.supersededBy);
3706
+ let found = false;
3707
+ try {
3708
+ const rep = await askFetchByLink(token, args.supersededBy, args.cookie);
3709
+ found = !!rep?.msg;
3710
+ if (!rep) {
3711
+ console.error(`Error: --superseded-by must be a message permalink (or 'Cโ€ฆ:1700000000.000100'): ${shown}`);
3712
+ process.exit(ASK_EXIT_ERROR);
3713
+ }
3714
+ } catch (e: unknown) {
3715
+ console.error(`Error: --superseded-by ใ‚’็ขบ่ชใงใใพใ›ใ‚“: ${shown} (${e instanceof Error ? e.message : String(e)})`);
3716
+ process.exit(ASK_EXIT_ERROR);
3717
+ }
3718
+ if (!found) {
3719
+ console.error(`Error: --superseded-by ใฎใƒกใƒƒใ‚ปใƒผใ‚ธใŒ่ฆ‹ใคใ‹ใ‚Šใพใ›ใ‚“: ${shown}`);
3720
+ process.exit(ASK_EXIT_ERROR);
3721
+ }
3722
+ }
3723
+ type Target = { link: string; channelId: string; ts: string; text: string; parsed: Extract<ReturnType<typeof askParseMessage>, { kind: "open" }>; pressed: string[]; unreadable?: boolean };
3724
+ const targets: Target[] = [];
3725
+ let refused = 0;
3726
+ for (const link of args.links) {
3727
+ const shown = stripTerminalControls(link);
3728
+ const got = await askFetchByLink(token, link, args.cookie);
3729
+ if (!got) { console.error(`โœ— ${shown}: permalink (ใพใŸใฏ 'Cโ€ฆ:1700000000.000100') ใงใฏใ‚ใ‚Šใพใ›ใ‚“`); refused++; continue; }
3730
+ if (!got.msg) { console.error(`โœ— ${shown}: ใƒกใƒƒใ‚ปใƒผใ‚ธใŒ่ฆ‹ใคใ‹ใ‚Šใพใ›ใ‚“`); refused++; continue; }
3731
+ const msg = got.msg;
3732
+ const own = (!!self?.userId && msg.user === self.userId) || (!!self?.botId && msg.bot_id === self.botId);
3733
+ if (!own) {
3734
+ console.error(`โœ— ${shown}: ่‡ชๅˆ†๏ผˆ${senderLabel(self, args.asBot)}๏ผ‰ใŒๆŠ•็จฟใ—ใŸ่ณชๅ•ใงใฏใชใ„ใฎใง็„กๅŠนๅŒ–ใงใใพใ›ใ‚“` +
3735
+ `${args.asBot ? "" : " โ€” bot ใŒๅ‡บใ—ใŸ่ณชๅ•ใชใ‚‰ --as-bot ใง"}`);
3736
+ refused++;
3737
+ continue;
3738
+ }
3739
+ const text = typeof msg.text === "string" ? msg.text : "";
3740
+ let parsed = askParseMessage(text);
3741
+ let unreadable = false;
3742
+ if (parsed.kind === "void") { console.error(`- ${shown}: ใ™ใงใซ็„กๅŠนๅŒ–ๆธˆใฟใงใ™๏ผˆไฝ•ใ‚‚ใ—ใพใ›ใ‚“๏ผ‰`); continue; }
3743
+ if (parsed.kind === "resolved") { console.error(`โœ— ${shown}: ๅ›ž็ญ”ๆธˆใฟใฎ่ณชๅ•ใฏ็„กๅŠนๅŒ–ใงใใพใ›ใ‚“๏ผˆๅ›ž็ญ”: ${stripTerminalControls(parsed.answer.split("\n")[0] ?? "")}๏ผ‰`); refused++; continue; }
3744
+ if (parsed.kind !== "open") {
3745
+ // Our own โ“ question whose layout no longer parses (2026-10-01: a
3746
+ // `slack edit` flattened one; it could then be neither collected nor
3747
+ // voided, and stayed โ“ forever). Voiding is exactly what is left to do
3748
+ // with it, so it is voided from a best-effort read โ€” question and body
3749
+ // kept as they are, our own seeded pills removed โ€” and the gate says so.
3750
+ const salvage = askSalvageUnreadable(text);
3751
+ if (!salvage) { console.error(`โœ— ${shown}: \`slack ask\` ใฎ่ณชๅ•ใจใ—ใฆ่ชญใฟๅ–ใ‚Œใพใ›ใ‚“ โ€” ${askExplainReject(text)}`); refused++; continue; }
3752
+ const seeded = asArray(msg.reactions).map(asRecord)
3753
+ .filter((r) => asArray(r.users).includes(self?.userId ?? "\u0000"))
3754
+ .map((r) => ASK_KEYCAPS.findIndex((k) => k.name === r.name))
3755
+ .filter((i) => i >= 0);
3756
+ parsed = {
3757
+ kind: "open", question: salvage.question, body: salvage.body,
3758
+ // Placeholders: only the COUNT is used โ€” which of our keycap seeds to take off.
3759
+ reactable: Array.from({ length: seeded.length ? Math.max(...seeded) + 1 : 0 }, () => ""),
3760
+ overflow: [], threadOnly: true, lang: askDetectLang(text) ?? "ja",
3761
+ };
3762
+ unreadable = true;
3763
+ }
3764
+ const pressed = asArray(msg.reactions).map(asRecord)
3765
+ .filter((r) => typeof r.name === "string" && ASK_KEYCAPS.some((k) => k.name === r.name))
3766
+ .filter((r) => asArray(r.users).some((u) => u !== self?.userId))
3767
+ .map((r) => `:${String(r.name)}:`);
3768
+ targets.push({ link, channelId: got.channelId, ts: got.ts, text, parsed, pressed, ...(unreadable ? { unreadable } : {}) });
3769
+ }
3770
+ if (!targets.length) {
3771
+ console.error(refused ? "็„กๅŠนๅŒ–ใงใใ‚‹่ณชๅ•ใŒใ‚ใ‚Šใพใ›ใ‚“ใ€‚" : "ไฝ•ใ‚‚ใ™ใ‚‹ใ“ใจใŒใ‚ใ‚Šใพใ›ใ‚“ใ€‚");
3772
+ process.exit(refused ? ASK_EXIT_ERROR : 0);
3773
+ }
3774
+
3775
+ const code = safetyCode("ask-void", ...targets.map((t) => `${t.channelId}:${t.ts}`), args.reason, args.supersededBy ?? "", self?.userId ?? "");
3776
+ if (args.code !== code) {
3777
+ requireCode(args.code, code, [
3778
+ `--- ็„กๅŠนๅŒ– (void) -------------------------------`,
3779
+ fromLine(self, { asBot: args.asBot }),
3780
+ ` Reason: ${args.reason ? stripTerminalControls(args.reason) : "(none)"}`,
3781
+ ...(args.supersededBy ? [` Replaced: ${stripTerminalControls(args.supersededBy)}`] : []),
3782
+ ` ${targets.length} question(s):`,
3783
+ ...targets.map((t) => ` โ€ข ${stripTerminalControls(t.link)}\n ${stripTerminalControls(t.parsed.question.split("\n")[0] ?? "").slice(0, 100)}` +
3784
+ (t.pressed.length ? `\n (already pressed: ${t.pressed.join(" ")} โ€” left in place, never collected)` : "") +
3785
+ (t.unreadable ? `\n โš  its layout no longer reads as an ask (edited by hand?) โ€” voided from a best-effort read: question and the rest of the text kept as they are` : "")),
3786
+ ` Each becomes ๐Ÿšซ (body kept, options and instructions removed); our pills and โ“ come off; a ๐Ÿšซ reaction is added.`,
3787
+ ...(refused ? [` (${refused} link(s) above were refused and will be skipped)`] : []),
3788
+ `----------------------------------------------`,
3789
+ ]);
3790
+ }
3791
+
3792
+ let failed = 0;
3793
+ for (const t of targets) {
3794
+ if (!(await askApplyVoid(token, t, args.reason, args.supersededBy, args.cookie))) failed++;
3795
+ }
3796
+ process.exit(failed || refused ? ASK_EXIT_ERROR : 0);
3797
+ }
3798
+
3064
3799
  /** `slack ask --waitFor <permalink>` โ€” collect the answer to a question that was
3065
3800
  * posted without blocking.
3066
3801
  *
@@ -3069,7 +3804,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
3069
3804
  * file to go stale, lose, or disagree with Slack, and any machine holding the
3070
3805
  * permalink can collect โ€” at the cost of the body being a parseable format
3071
3806
  * (`askBuildText` โ‡„ `askParseMessage`). */
3072
- async function cmdAskWaitFor(token: string, args: { link: string; timeout: number; asBot: boolean; cookie?: string; after?: string }): Promise<void> {
3807
+ async function cmdAskWaitFor(token: string, args: { link: string; timeout: number; asBot: boolean; json: boolean; cookie?: string; after?: string }): Promise<void> {
3073
3808
  const url = parseSlackPermalink(args.link);
3074
3809
  let channelId: string;
3075
3810
  let ts: string;
@@ -3121,13 +3856,6 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
3121
3856
  );
3122
3857
  process.exit(ASK_EXIT_ERROR);
3123
3858
  }
3124
- if (parsed.kind === "resolved") {
3125
- // Answered while nobody was watching โ€” the case that makes fire-and-forget
3126
- // safe. Report it exactly as `--wait` would have.
3127
- console.error(`โœ“ ๅ›ž็ญ”ๆธˆใฟ: ${stripTerminalControls(parsed.question)}`);
3128
- console.log(parsed.answer);
3129
- return;
3130
- }
3131
3859
 
3132
3860
  // Who may answer, recovered from the tags the readers can see. They are
3133
3861
  // already `<@Uโ€ฆ>`-encoded in the stored text, so no directory lookup is
@@ -3146,6 +3874,64 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
3146
3874
  const counterpart = await imCounterpart(token, channelId, args.cookie);
3147
3875
  if (counterpart && counterpart !== askerUserId) audience.add(counterpart);
3148
3876
  }
3877
+ if (parsed.kind === "void") {
3878
+ askReportVoid(parsed, args.link);
3879
+ if (args.json) {
3880
+ await askEmit(token, { json: true, shown: args.link, asBot: args.asBot, cookie: args.cookie }, {
3881
+ status: "void", answer: null, reason: parsed.reason,
3882
+ ...(parsed.supersededBy ? { supersededBy: parsed.supersededBy } : {}),
3883
+ notes: [], cursor: askNextCursor(ts, args.after),
3884
+ });
3885
+ }
3886
+ process.exit(ASK_EXIT_VOID);
3887
+ }
3888
+
3889
+ if (parsed.kind === "resolved") {
3890
+ // Answered while nobody was watching โ€” the case that makes fire-and-forget
3891
+ // safe. Report it exactly as `--wait` would have, plus any notes the
3892
+ // audience left in the thread AFTER it was answered: that is the note that
3893
+ // was lost in real use (2026-10-02), written 40 s after โœ….
3894
+ //
3895
+ // There is NO default cursor here. The โœ… edit time looks like one, but it
3896
+ // would drop every note written before โœ… whenever the run that wrote โœ… did
3897
+ // not deliver them: a crash between the edit and the output, or โ€” the case
3898
+ // that matters โ€” any question resolved by a build that did not collect
3899
+ // notes at all. A caller that wants no repeats passes the cursor back.
3900
+ //
3901
+ // What must not come back is the reply that IS the answer. The โœ… stamp says
3902
+ // whether the answer was a reply, and which number it picked: the first
3903
+ // audience reply before the โœ… edit that reads as that answer is it.
3904
+ console.error(`โœ“ ๅ›ž็ญ”ๆธˆใฟ: ${stripTerminalControls(parsed.question)}`);
3905
+ const editedTs = typeof asRecord(msg.edited).ts === "string" ? String(asRecord(msg.edited).ts) : undefined;
3906
+ const how = askResolvedHow(text);
3907
+ let answerSkipped = false;
3908
+ const isAnswerReply = (m: Record<string, Json>): boolean => {
3909
+ if (answerSkipped || !how.byReply) return false;
3910
+ if (editedTs && Number(m.ts) > Number(editedTs)) return false;
3911
+ const t = typeof m.text === "string" ? m.text.trim() : "";
3912
+ const hit = t === parsed.answer || (how.n !== undefined && (new RegExp(`^[(๏ผˆ]?${how.n}(?![0-9])`).test(t) || askReplyPicked(t, parsed.answer, how.n)));
3913
+ if (hit) answerSkipped = true;
3914
+ return hit;
3915
+ };
3916
+ const after = args.after;
3917
+ let notes: AskNote[] = [];
3918
+ if (audience.size || broadcast) {
3919
+ const isAnswerer = (user: unknown): user is string =>
3920
+ typeof user === "string" && !!user && user !== askerUserId && (broadcast || audience.has(user));
3921
+ notes = await askCollectNotes(token, {
3922
+ channelId, ts, threadOnly: !!threadTs || !channelId.startsWith("D"),
3923
+ replyCount: Number(msg.reply_count) || 0, isAnswerer, askerUserId, askerBotId,
3924
+ ...(threadTs ? { threadParentTs: threadTs } : {}),
3925
+ ...(args.cookie ? { cookie: args.cookie } : {}),
3926
+ }, after, isAnswerReply);
3927
+ }
3928
+ await askEmit(token, { json: args.json, shown: args.link, asBot: args.asBot, cookie: args.cookie }, {
3929
+ status: "answered", answer: parsed.answer, notes,
3930
+ cursor: askNextCursor(ts, after, ...notes.map((n) => n.ts)),
3931
+ });
3932
+ return;
3933
+ }
3934
+
3149
3935
  if (!audience.size && !broadcast) {
3150
3936
  console.error(`Error: ใ“ใฎ่ณชๅ•ใฏ่ชฐใซใ‚‚ๅฎ›ใฆใ‚‰ใ‚Œใฆใ„ใชใ„ใŸใ‚ใ€ๆœ‰ๅŠนใชๅ›ž็ญ”่€…ใ‚’ๅˆคๅฎšใงใใพใ›ใ‚“: ${stripTerminalControls(args.link)}`);
3151
3937
  process.exit(ASK_EXIT_ERROR);
@@ -3161,6 +3947,7 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
3161
3947
  reactable: parsed.reactable,
3162
3948
  overflow: parsed.overflow,
3163
3949
  threadOnly: parsed.threadOnly,
3950
+ lang: parsed.lang,
3164
3951
  audience,
3165
3952
  broadcast,
3166
3953
  askerUserId,
@@ -3168,6 +3955,7 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
3168
3955
  timeout: args.timeout,
3169
3956
  shown: args.link,
3170
3957
  asBot: args.asBot,
3958
+ json: args.json,
3171
3959
  };
3172
3960
  if (threadTs) ctx.threadParentTs = threadTs;
3173
3961
  if (args.cookie) ctx.cookie = args.cookie;
@@ -3319,7 +4107,8 @@ async function cmdPoll(token: string, args: PollArgs): Promise<void> {
3319
4107
  const attr = sentAttribution("poll");
3320
4108
  const ts = await slackSend(token, channelId, message, threadTs, false, cookie, true, attr.metadata);
3321
4109
 
3322
- // Marker first, for the same reason as `ask`: it is what makes
4110
+ // Marker first. (`ask` seeds its marker LAST, because there it doubles as the
4111
+ // "other" choice; a poll has no such choice.) It is what makes
3323
4112
  // `has::ballot_box_with_ballot:` list every poll. An unrelated reaction on the message is
3324
4113
  // already ignored by the tally (it counts keycap names only), so this costs
3325
4114
  // the ballot nothing.
@@ -4686,7 +5475,7 @@ async function main(): Promise<void> {
4686
5475
  )
4687
5476
  .command(
4688
5477
  "ask [target] [question] [choices..]",
4689
- "Ask a question with its choices pre-seeded as 1๏ธโƒฃ..๐Ÿ”Ÿ reactions (confirm-hash safety gate)",
5478
+ "Ask a question with its choices pre-seeded as 1๏ธโƒฃ..๐Ÿ”Ÿ reactions (confirm-hash safety gate). Lifecycle: --waitFor=<link> collect, --void=<link> ็„กๅŠนๅŒ– (verb aliases: `ask collect <link>`, `ask void <linkโ€ฆ>`)",
4690
5479
  (y) => y
4691
5480
  .positional("target", { type: "string", describe: "#chan, @user, #chan:thread_ts, or permalink (omit with --waitFor)" })
4692
5481
  .positional("question", { type: "string", describe: "The question. Must @tag whoever may answer (@alice), or the whole channel (@here / @channel / @everyone) โ€” only their answer counts. In a 1:1 DM the other party counts automatically." })
@@ -4694,14 +5483,176 @@ async function main(): Promise<void> {
4694
5483
  .option("code", { type: "string", describe: "Safety hash to confirm the ask" })
4695
5484
  .option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
4696
5485
  .option("body", { type: "string", describe: "Extra context shown under the question" })
4697
- .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)." })
5486
+ .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" })
5487
+ .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." })
4698
5488
  .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." })
4699
- .option("after", { type: "string", describe: "With --waitFor: ignore text replies at or before this ts (the reply an exit 5 already delivered). Reactions still count." })
5489
+ .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." })
5490
+ .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โ€ฆ>`." })
5491
+ .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." })
5492
+ .option("edit", { type: "string", describe: "Change an open question you posted: --edit=<permalink> [\"new question\" [choicesโ€ฆ]] [--body โ€ฆ]. Rebuilt with the ask layout so collect still reads it. Options can change only while nobody has answered (then: --void=<link> --reask). Same --code gate." })
5493
+ .option("ls", { type: "boolean", default: false, describe: "List the questions you posted (local sent-log) with their LIVE state: --state open|answered|void|all (default open), --stale 24h, --channel, --json. With --stale it prints the --void command that would retire them." })
5494
+ .option("stale", { type: "string", describe: "With --ls: only open questions older than this (24h, 3d)" })
5495
+ .option("state", { type: "string", default: "open", choices: ["open", "answered", "void", "edited", "gone", "unknown", "all"], describe: "With --ls: which state to list" })
5496
+ .option("channel", { type: "string", describe: "With --ls: only this channel (id, or the target as typed)" })
5497
+ .option("limit", { type: "number", default: 50, describe: "With --ls: how many recent asks to check" })
5498
+ .option("reason", { type: "string", describe: "With --void: why it is void (written into the message)" })
5499
+ .option("superseded-by", { type: "string", describe: "With --void: permalink of the question that replaces it" })
5500
+ .option("json", { type: "boolean", default: false, describe: "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." })
4700
5501
  .option("timeout", { type: "number", default: 3600, describe: "Overall limit for --wait / --waitFor, in seconds (0 with --waitFor = check once)" })
4701
5502
  .option("channel-id", { type: "string", describe: "Raw channel ID" })
4702
5503
  .option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
4703
5504
  .option("as-bot", { type: "boolean", default: false, describe: "Ask via the bot token (xoxb / SLACK_BOT_TOKEN) so a DM notifies the recipient" }),
4704
5505
  async (argv) => {
5506
+ // Verbs. A target is always #chan / @user / a permalink / a raw ID, so a
5507
+ // bare word here cannot be one โ€” `slack ask void โ€ฆ` is unambiguous.
5508
+ // slack ask void <linkโ€ฆ> [--reason โ€ฆ] [--superseded-by <link>] ็„กๅŠนๅŒ–
5509
+ // slack ask collect <link> = --waitFor
5510
+ // Lifecycle flags (maintainer: "maybe slack ask --edit=<target>"), primary;
5511
+ // the bare verbs `void` / `collect` stay as aliases.
5512
+ if (argv.ls) {
5513
+ const user = (() => { try { return tok(argv as W); } catch { return undefined; } })();
5514
+ const tokens: { user?: string; bot?: string; cookie?: string } = {};
5515
+ if (user) tokens.user = user;
5516
+ const bot = resolveBotToken();
5517
+ if (bot) tokens.bot = bot;
5518
+ const c = ck(argv as W);
5519
+ if (c) tokens.cookie = c;
5520
+ const a: { stale?: string; state: string; channel?: string; json: boolean; limit: number } = {
5521
+ state: String(argv.state ?? "open"), json: !!argv.json, limit: Number(argv.limit) || 50,
5522
+ };
5523
+ if (argv.stale) a.stale = String(argv.stale);
5524
+ if (argv.channel) a.channel = String(argv.channel);
5525
+ await cmdAskLs(tokens, a);
5526
+ return;
5527
+ }
5528
+ const askTokenFor = (asBot: boolean): { token: string; cookie?: string } => {
5529
+ if (!asBot) {
5530
+ const c = ck(argv as W);
5531
+ return c ? { token: tok(argv as W), cookie: c } : { token: tok(argv as W) };
5532
+ }
5533
+ const botToken = resolveBotToken();
5534
+ if (!botToken) {
5535
+ console.error(
5536
+ "Error: --as-bot needs a bot token, but no xoxb- token was found.\n" +
5537
+ " Set SLACK_BOT_TOKEN=xoxb-... in ~/.config/slack-cli/.env (or your shell).",
5538
+ );
5539
+ process.exit(ASK_EXIT_ERROR);
5540
+ }
5541
+ return { token: botToken };
5542
+ };
5543
+ // Positionals after a lifecycle flag are the NEW question and choices.
5544
+ const newQuestion = argv.target !== undefined && argv.target !== "void" && argv.target !== "collect" ? String(argv.target) : undefined;
5545
+ const newChoices = [argv.question, ...((argv.choices as string[] | undefined) ?? [])].filter((x) => x !== undefined).map(String);
5546
+ if (argv.edit) {
5547
+ if (argv.void || argv.wait || argv.waitFor) {
5548
+ console.error("Error: --edit changes one open question โ€” do not combine it with --void/--wait/--waitFor.");
5549
+ process.exit(ASK_EXIT_ERROR);
5550
+ }
5551
+ const { token, cookie } = askTokenFor(!!argv["as-bot"]);
5552
+ const a: Parameters<typeof cmdAskEdit>[1] = {
5553
+ link: String(argv.edit), asBot: !!argv["as-bot"], mentionToken: tok(argv as W),
5554
+ allowUrlAdjacent: !!argv["allow-url-adjacent"],
5555
+ };
5556
+ if (newQuestion !== undefined) a.question = newQuestion;
5557
+ if (newChoices.length) a.choices = newChoices.map((x) => x.trim()).filter(Boolean);
5558
+ if (argv.body !== undefined) a.body = String(argv.body);
5559
+ if (argv.lang) a.lang = String(argv.lang);
5560
+ if (argv.code) a.code = String(argv.code);
5561
+ if (cookie) a.cookie = cookie;
5562
+ const mc = ck(argv as W);
5563
+ if (mc) a.mentionCookie = mc;
5564
+ try {
5565
+ await cmdAskEdit(token, a);
5566
+ } catch (e: unknown) {
5567
+ console.error(friendlySlackError(e));
5568
+ process.exit(ASK_EXIT_ERROR);
5569
+ }
5570
+ return;
5571
+ }
5572
+ // --void=<link> --reask: re-ask through the CREATE path below (same
5573
+ // gate, same audience rules), voiding the old question once it is up.
5574
+ let voidAfter: AskArgs["voidAfter"];
5575
+ const voidFlag = ((argv.void as string[] | undefined) ?? []).map(String).filter(Boolean);
5576
+ if (argv.reask) {
5577
+ if (voidFlag.length !== 1 || argv.target === "void") {
5578
+ console.error("Error: --reask goes with exactly one --void=<permalink> (the question to replace).");
5579
+ process.exit(ASK_EXIT_ERROR);
5580
+ }
5581
+ if (argv.waitFor) {
5582
+ console.error("Error: --reask posts a new question โ€” --wait works, --waitFor does not.");
5583
+ process.exit(ASK_EXIT_ERROR);
5584
+ }
5585
+ const { token, cookie } = askTokenFor(!!argv["as-bot"]);
5586
+ const self = await selfIdentity(token, cookie);
5587
+ const t = await askLoadOwnOpen(token, voidFlag[0]!, self, !!argv["as-bot"], cookie);
5588
+ if (t === "void") { console.error(`โœ— ${stripTerminalControls(voidFlag[0]!)}: ใ™ใงใซ็„กๅŠนๅŒ–ๆธˆใฟใงใ™ โ€” ๆ–ฐใ—ใ่žใใชใ‚‰ๆ™ฎ้€šใซ slack ask ใง`); process.exit(ASK_EXIT_ERROR); }
5589
+ if (!t) process.exit(ASK_EXIT_ERROR);
5590
+ voidAfter = { ...t, reason: unescapeArg(String(argv.reason ?? "")).trim() };
5591
+ // Same place, same words, unless new ones were given. The create path
5592
+ // below unescapes its arguments, so stored text goes in escaped: a
5593
+ // literal `\n` in the old question must not turn into a line break.
5594
+ argv.target = t.threadTs ? `${t.channelId}:${t.threadTs}` : t.channelId;
5595
+ argv["channel-id"] = t.channelId;
5596
+ argv.question = newQuestion ?? escapeArg(t.parsed.question);
5597
+ argv.choices = newChoices.length ? newChoices : [...t.parsed.reactable, ...t.parsed.overflow].map(escapeArg);
5598
+ if (argv.body === undefined && t.parsed.body) argv.body = escapeArg(t.parsed.body);
5599
+ if (!argv.lang) argv.lang = t.parsed.lang;
5600
+ argv.void = undefined;
5601
+ }
5602
+ if (!voidAfter && (argv.target === "void" || voidFlag.length)) {
5603
+ const verbLinks = argv.target === "void" ? [argv.question, ...((argv.choices as string[] | undefined) ?? [])] : [];
5604
+ if (argv.target !== "void" && (argv.target || argv.question || (argv.choices as string[] | undefined)?.length)) {
5605
+ console.error("Error: --void voids existing questions โ€” do not also pass a target/question/choices.");
5606
+ process.exit(ASK_EXIT_ERROR);
5607
+ }
5608
+ const links = [...voidFlag, ...verbLinks.map((x) => String(x ?? ""))].filter(Boolean);
5609
+ if (!links.length) {
5610
+ console.error("Error: --void=<permalink> โ€” which question(s)?");
5611
+ process.exit(ASK_EXIT_ERROR);
5612
+ }
5613
+ if (argv.wait || argv.waitFor) {
5614
+ console.error("Error: --void voids existing questions โ€” do not also pass --wait/--waitFor.");
5615
+ process.exit(ASK_EXIT_ERROR);
5616
+ }
5617
+ let voidToken: string;
5618
+ let voidCookie: string | undefined;
5619
+ if (argv["as-bot"]) {
5620
+ const botToken = resolveBotToken();
5621
+ if (!botToken) {
5622
+ console.error(
5623
+ "Error: --as-bot needs a bot token, but no xoxb- token was found.\n" +
5624
+ " Set SLACK_BOT_TOKEN=xoxb-... in ~/.config/slack-cli/.env (or your shell).",
5625
+ );
5626
+ process.exit(ASK_EXIT_ERROR);
5627
+ }
5628
+ voidToken = botToken;
5629
+ } else {
5630
+ voidToken = tok(argv as W);
5631
+ voidCookie = ck(argv as W);
5632
+ }
5633
+ const a: { links: string[]; reason: string; supersededBy?: string; code?: string; asBot: boolean; cookie?: string } = {
5634
+ links, reason: unescapeArg(String(argv.reason ?? "")).trim(), asBot: !!argv["as-bot"],
5635
+ };
5636
+ if (argv["superseded-by"]) a.supersededBy = String(argv["superseded-by"]);
5637
+ if (argv.code) a.code = String(argv.code);
5638
+ if (voidCookie) a.cookie = voidCookie;
5639
+ try {
5640
+ await cmdAskVoid(voidToken, a);
5641
+ } catch (e: unknown) {
5642
+ console.error(friendlySlackError(e));
5643
+ process.exit(ASK_EXIT_ERROR);
5644
+ }
5645
+ return;
5646
+ }
5647
+ if (argv.target === "collect") {
5648
+ if (!argv.question || (argv.choices as string[] | undefined)?.length) {
5649
+ console.error("Error: slack ask collect <permalink> โ€” exactly one question.");
5650
+ process.exit(ASK_EXIT_ERROR);
5651
+ }
5652
+ argv.waitFor = String(argv.question);
5653
+ argv.target = undefined;
5654
+ argv.question = undefined;
5655
+ }
4705
5656
  const waitFor = argv.waitFor ? String(argv.waitFor) : "";
4706
5657
  const timeout = Number(argv.timeout);
4707
5658
  // 0 is a real value only for --waitFor ("look once"). For --wait it would
@@ -4735,7 +5686,7 @@ async function main(): Promise<void> {
4735
5686
  waitCookie = ck(argv as W);
4736
5687
  }
4737
5688
  try {
4738
- const a: { link: string; timeout: number; asBot: boolean; cookie?: string; after?: string } = { link: waitFor, timeout, asBot: !!argv["as-bot"] };
5689
+ const a: { link: string; timeout: number; asBot: boolean; json: boolean; cookie?: string; after?: string } = { link: waitFor, timeout, asBot: !!argv["as-bot"], json: !!argv.json };
4739
5690
  if (waitCookie) a.cookie = waitCookie;
4740
5691
  if (argv.after) {
4741
5692
  const after = String(argv.after);
@@ -4770,7 +5721,10 @@ async function main(): Promise<void> {
4770
5721
  };
4771
5722
  if (argv.code) args.code = argv.code;
4772
5723
  if (argv.body) args.body = argv.body;
5724
+ if (argv.lang) args.lang = String(argv.lang);
4773
5725
  if (argv.wait) args.wait = true;
5726
+ if (argv.json) args.json = true;
5727
+ if (voidAfter) args.voidAfter = voidAfter;
4774
5728
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
4775
5729
  if (argv["user-id"]) args.userId = argv["user-id"];
4776
5730
  // Always resolve @tags with the user token (it has users:read), even
@@ -5144,12 +6098,14 @@ async function main(): Promise<void> {
5144
6098
  .positional("target", { type: "string", demandOption: true, describe: "#chan:ts, @user:ts, or permalink" })
5145
6099
  .positional("newText", { type: "string", demandOption: true })
5146
6100
  .option("code", { type: "string", describe: "Safety hash to confirm edit" })
6101
+ .option("force", { type: "boolean", default: false, describe: "Apply an edit that BREAKS a `slack ask` / `slack poll` message (new text no longer reads as one, or an answered/void question made open again). Refused by default because collect reads that body back; edits that keep it readable (e.g. writing the โœ… form) need no flag. Prefer `slack ask --edit=` / `--void=`." })
5147
6102
  .option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
5148
6103
  .option("channel-id", { type: "string", describe: "Raw channel ID" })
5149
6104
  .option("mentions", { type: "boolean", default: true, describe: "Convert @handle tokens in the new text to real <@USERID> mentions (on by default). Unresolved handles stay as plain text. Disable with --no-mentions for literal @text." })
5150
6105
  .option("as-bot", { type: "boolean", default: false, describe: "Edit as the bot (xoxb / SLACK_BOT_TOKEN). REQUIRED to correct a message the bot posted: Slack only lets a token edit its OWN messages, so a bot-authored message is uneditable by the user token and chat.update returns cant_update_message. Address it by permalink or --channel-id; an @user target resolves against the BOT's DM list, which is where a bot-authored DM actually lives." }),
5151
6106
  async (argv) => {
5152
6107
  const args: EditArgs = { target: argv.target!, newText: argv.newText!, allowUrlAdjacent: argv["allow-url-adjacent"] };
6108
+ if (argv.force) args.force = true;
5153
6109
  if (argv.code) args.code = argv.code;
5154
6110
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
5155
6111
  if (argv.mentions !== false) args.mentions = true;