slack-term 1.44.0 → 1.45.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.44.0",
3
+ "version": "1.45.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/slack-term.git"
package/ts/ask.ts CHANGED
@@ -34,17 +34,40 @@ export type AskFound = { answer: string; how: string; who?: string; ts?: string;
34
34
  * in-flight question unparseable. The marker carries identity now, and the
35
35
  * instruction is free to be translated.
36
36
  *
37
- * It is also seeded as a REACTION, which is what makes `has:question` list
38
- * every question the way `has:pushpin` lists every todo. `todo` gave this
39
- * emoji up (its needs-discussion flag moved to :thinking_face:) precisely so
40
- * the two would not collide in search. */
37
+ * It is also seeded as a REACTION on free-text questions and on pills posted
38
+ * before ➕; a question with choices now seeds ➕ (`ASK_OTHER_MARKER`) in its
39
+ * place. So `has::question: OR has::heavy_plus_sign:`-style search lists the
40
+ * open questions the way `has::pushpin:` lists every todo — with the trade-off
41
+ * that ➕ is a common emoji, so that search can also match unrelated messages
42
+ * (`slack ask --ls` reads the local sent-log and is unaffected). `todo` gave
43
+ * ❓ up (its needs-discussion flag moved to :thinking_face:) precisely so the
44
+ * two would not collide in search. */
41
45
  export const ASK_MARKER = "question";
42
46
  export const ASK_MARKER_PREFIX = `:${ASK_MARKER}: `;
43
47
 
48
+ /** The "other" pill — "none of these fit, my answer follows as a reply" —
49
+ * seeded after the numbered pills and listed under them in the body. ➕, not
50
+ * ❓ (taku 2026-10-14): ❓ read as "I have a question", not "add my own answer".
51
+ *
52
+ * ❓ stays the HEAD marker (`ASK_MARKER_PREFIX` on the first line): that is the
53
+ * wire format that identifies an ask, and every reader keys on it. Only the
54
+ * pill moved. A free-text question has no other line and still seeds ❓.
55
+ *
56
+ * Pills posted before this carry ❓ as the other pill, both as the seed and
57
+ * as the body line. They stay fully working: the parser accepts either line
58
+ * (`askSeedMarker(text)` says which one a question was seeded with), a press
59
+ * on either counts as "other", and void / edit / resolve take off exactly the
60
+ * seed that is there. */
61
+ export const ASK_OTHER_MARKER = "heavy_plus_sign";
62
+ export const ASK_OTHER_PREFIX = `:${ASK_OTHER_MARKER}: `;
63
+ /** Every reaction that means "other" on some open pill: ➕ now, ❓ on the
64
+ * pills posted before ➕. */
65
+ export const ASK_OTHER_MARKERS: readonly string[] = [ASK_OTHER_MARKER, ASK_MARKER];
66
+
44
67
  /** The "explain" pill (`--explain`): pressing it asks the asker to explain the
45
68
  * question, and is NEVER an answer. Reported 2026-10-14: an "explain" offered
46
69
  * 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
70
+ * Not ➕: ➕ means "none of these fit — my answer follows as a reply", and that
48
71
  * reply resolves the question; ℹ️ means "I cannot answer yet", and the question
49
72
  * stays open for a real answer after the explanation. */
50
73
  export const ASK_EXPLAIN_MARKER = "information_source";
@@ -145,10 +168,10 @@ interface AskCopy {
145
168
  }
146
169
 
147
170
  /** The standing "none of these" choice (`otherThread` / `otherHere`), listed
148
- * after the numbered ones. It is the ❓ marker pill, which is seeded LAST so
149
- * the reaction row reads the same as the body: 1️⃣ 2️⃣ 3️⃣ ❓. People already
150
- * pressed ❓ to mean "none of these fit" — the line makes that an explicit
151
- * choice, and says where the actual answer has to go: ❓ itself carries no
171
+ * after the numbered ones. It is the ➕ pill (`ASK_OTHER_MARKER`; ❓ on pills
172
+ * posted before 2026-10-14), seeded LAST so the reaction row reads the same as
173
+ * the body: 1️⃣ 2️⃣ 3️⃣ ➕. The line makes "none of these fit" an explicit
174
+ * choice, and says where the actual answer has to go: ➕ itself carries no
152
175
  * answer text, so it never resolves the question; the reply that follows it
153
176
  * does (exit 5, delivered as free text). Optional on the way in: questions
154
177
  * posted before it existed have no such line and must stay collectable.
@@ -164,8 +187,8 @@ export const ASK_COPY: Record<AskLang, AskCopy> = {
164
187
  "_このメッセージの *スレッド* で返信してください。本文がそのまま回答になります (チャンネルへの通常投稿は回答として拾いません)。_",
165
188
  instructionTextHere: "_このメッセージに返信してください。本文がそのまま回答になります。_",
166
189
  overflowNote: "_11 番目以降はリアクションがないので、返信で答えてください。_",
167
- otherThread: `${ASK_MARKER_PREFIX}その他 — スレッドで返信してください`,
168
- otherHere: `${ASK_MARKER_PREFIX}その他 — このメッセージに返信してください`,
190
+ otherThread: `${ASK_OTHER_PREFIX}その他 — スレッドで返信してください`,
191
+ otherHere: `${ASK_OTHER_PREFIX}その他 — このメッセージに返信してください`,
169
192
  explainLine: `${ASK_EXPLAIN_PREFIX}説明して — 押すと質問者に説明を頼みます (回答にはなりません)`,
170
193
  explainNote: `${ASK_EXPLAIN_PREFIX}説明のリクエストを受け付けました — このスレッドで説明します。質問はまだ開いているので、回答はそのあとで大丈夫です。`,
171
194
  invalidPrefix: "_:warning: 同時に複数選ばれているため回答として数えていません — どれか 1 つだけ残してください: ",
@@ -185,8 +208,8 @@ export const ASK_COPY: Record<AskLang, AskCopy> = {
185
208
  "_Reply in this message's *thread* — your reply is the answer (ordinary channel posts are not picked up as answers)._",
186
209
  instructionTextHere: "_Reply to this message — your reply is the answer._",
187
210
  overflowNote: "_Choices 11 and up have no reaction — answer them with a reply._",
188
- otherThread: `${ASK_MARKER_PREFIX}Other — reply in the thread`,
189
- otherHere: `${ASK_MARKER_PREFIX}Other — reply to this message`,
211
+ otherThread: `${ASK_OTHER_PREFIX}Other — reply in the thread`,
212
+ otherHere: `${ASK_OTHER_PREFIX}Other — reply to this message`,
190
213
  explainLine: `${ASK_EXPLAIN_PREFIX}Explain — press to ask the asker for more detail (not an answer)`,
191
214
  explainNote: `${ASK_EXPLAIN_PREFIX}Explanation requested — it will follow in this thread. The question stays open; answer it after that.`,
192
215
  invalidPrefix: "_:warning: Several choices are selected, so this is not counted as an answer — keep only one: ",
@@ -199,11 +222,20 @@ export const ASK_COPY: Record<AskLang, AskCopy> = {
199
222
  },
200
223
  };
201
224
 
202
- /** True for the "other" line in either spelling — the shortcode we write, or the
203
- * glyph a body hand-edited in the Slack UI can carry. Matched by prefix, not
204
- * verbatim, so the copy stays free to change. */
205
- function askIsOtherLine(line: string | undefined): boolean {
206
- return line !== undefined && (line.startsWith(ASK_MARKER_PREFIX) || line.startsWith("❓ "));
225
+ /** The "other" copy pills posted before ➕ carried — the same words under ❓.
226
+ * Kept so `askSalvageUnreadable` can still strip it from an old flattened body. */
227
+ const ASK_LEGACY_OTHER_LINES: string[] = ASK_LANGS.flatMap((l) =>
228
+ [ASK_COPY[l].otherThread, ASK_COPY[l].otherHere].map((s) => ASK_MARKER_PREFIX + s.slice(ASK_OTHER_PREFIX.length)));
229
+
230
+ /** Which reaction the "other" line names, or null if the line is not one: ➕ or
231
+ * ❓ (pills posted before ➕), each as the shortcode we write or the glyph a
232
+ * body hand-edited in the Slack UI can carry. Matched by prefix, not verbatim,
233
+ * so the copy stays free to change. */
234
+ function askOtherLineMarker(line: string | undefined): string | null {
235
+ if (line === undefined) return null;
236
+ if (line.startsWith(ASK_OTHER_PREFIX) || line.startsWith("➕ ")) return ASK_OTHER_MARKER;
237
+ if (line.startsWith(ASK_MARKER_PREFIX) || line.startsWith("❓ ")) return ASK_MARKER;
238
+ return null;
207
239
  }
208
240
 
209
241
  /** True for the explain line, in either spelling. Matched by prefix, like the
@@ -287,7 +319,7 @@ export const ASK_RESOLVED_PREFIX = `:${ASK_RESOLVED_MARKER}: `;
287
319
  /** Prefix `--void` stamps on a question that should no longer be answered —
288
320
  * expired, superseded, or overtaken by events (無効化). A THIRD state beside open
289
321
  * (`:question:`) and answered (`:white_check_mark:`), on purpose: anything that
290
- * keys on "starts with :question:" (the stale-pill reminder, `has::question:`)
322
+ * keys on "starts with :question:" (the stale-pill reminder, `has::question: OR has::heavy_plus_sign:`)
291
323
  * stops counting it the moment it is voided, and nothing reads it as answered.
292
324
  * Also seeded as a reaction, so `has::no_entry_sign:` lists every void. */
293
325
  export const ASK_VOID_MARKER = "no_entry_sign";
@@ -352,9 +384,9 @@ export function askBuildText(question: string, body: string, reactable: string[]
352
384
  // form keeps posted and read-back bytes identical, which is what the
353
385
  // round-trip test can actually check. (`poll` already did this.)
354
386
  lines.push(...reactable.map((s, i) => `${askPill(i)} ${askFlatten(s)}`));
355
- // Directly under the pills, matching the ❓ seeded right after them.
387
+ // Directly under the pills, matching the ➕ seeded right after them.
356
388
  lines.push(threadOnly ? c.otherThread : c.otherHere);
357
- // Under ❓, matching the ℹ️ seeded right after it.
389
+ // Under ➕, matching the ℹ️ seeded right after it.
358
390
  if (explain) lines.push(c.explainLine);
359
391
  if (overflow.length) {
360
392
  lines.push("");
@@ -382,7 +414,7 @@ export function askBuildText(question: string, body: string, reactable: string[]
382
414
  * decision was made against) and the option line that was chosen. Reported
383
415
  * from real use 2026-10-05: collecting an answer replaced the whole message
384
416
  * with question + answer, and the context of the decision was gone. Only what
385
- * no longer applies goes — the options NOT chosen, the ❓ line, the answering
417
+ * no longer applies goes — the options NOT chosen, the ➕ (or ❓) line, the answering
386
418
  * instructions.
387
419
  *
388
420
  * The head is unchanged on purpose: ✅ prefix, question, stamp, blank, quoted
@@ -453,7 +485,7 @@ export function askSalvageUnreadable(text: string): { question: string; body: st
453
485
  for (const instr of [c.instructionReactionThread, c.instructionReactionHere, c.instructionTextThread, c.instructionTextHere]) {
454
486
  if (body.includes(instr)) wasAsk = true;
455
487
  }
456
- for (const fixed of [c.instructionReactionThread, c.instructionReactionHere, c.instructionTextThread, c.instructionTextHere, c.overflowNote, c.otherThread, c.otherHere, c.explainLine]) {
488
+ for (const fixed of [c.instructionReactionThread, c.instructionReactionHere, c.instructionTextThread, c.instructionTextHere, c.overflowNote, c.otherThread, c.otherHere, ...ASK_LEGACY_OTHER_LINES, c.explainLine]) {
457
489
  body = body.split(fixed).join(" ");
458
490
  }
459
491
  const at = body.indexOf(c.invalidPrefix);
@@ -660,7 +692,7 @@ export function askParseMessage(text: string): AskParsed {
660
692
  i--;
661
693
  }
662
694
  if (askIsExplainLine(lines[i])) { explain = true; i--; }
663
- if (askIsOtherLine(lines[i])) i--;
695
+ if (askOtherLineMarker(lines[i])) i--;
664
696
  const raw: string[] = [];
665
697
  while (i >= 0) {
666
698
  const line = lines[i]!;
@@ -697,6 +729,29 @@ export function askParseMessage(text: string): AskParsed {
697
729
  return { kind: "open", question, body, reactable, overflow, threadOnly, lang, explain };
698
730
  }
699
731
 
732
+ /** The marker reaction `ask` seeded on an open question: ➕ when its other line
733
+ * says ➕, else ❓ (a free-text question, or a pill posted before ➕ — with a ❓
734
+ * other line or none at all). What void / edit / the ✅ swap take off. Read
735
+ * from the line directly under the last numbered pill, the one place the
736
+ * other line can be; null when the text is not an open ask.
737
+ *
738
+ * Separate from `askParseMessage` so the parse result keeps its shape. */
739
+ export function askSeedMarker(text: string): string | null {
740
+ const p = askParseMessage(text);
741
+ if (p.kind !== "open") return null;
742
+ if (!p.reactable.length) return ASK_MARKER;
743
+ const lines = text.split("\n");
744
+ const last = askPill(p.reactable.length - 1);
745
+ const k = ASK_KEYCAPS[p.reactable.length - 1]!.glyph;
746
+ // The last pill line is the LAST line opening with that keycap: the block is
747
+ // anchored to the bottom, and a body line can start with the same keycap.
748
+ let at = -1;
749
+ for (let i = lines.length - 1; i >= 0; i--) {
750
+ if (lines[i]!.startsWith(`${last} `) || lines[i] === last || lines[i]!.startsWith(`${k} `) || lines[i] === k) { at = i; break; }
751
+ }
752
+ return (at >= 0 ? askOtherLineMarker(lines[at + 1]) : null) ?? ASK_MARKER;
753
+ }
754
+
700
755
  /** Did a free-text reply actually PICK one of the offered choices?
701
756
  *
702
757
  * Reported from real use 2026-09-04: three options were offered, the answerer
package/ts/cli.ts CHANGED
@@ -20,6 +20,8 @@ import { agentCommands } from "./agent.ts";
20
20
  import {
21
21
  ASK_KEYCAPS,
22
22
  ASK_MARKER,
23
+ ASK_OTHER_MARKER,
24
+ ASK_OTHER_MARKERS,
23
25
  ASK_EXPLAIN_MARKER,
24
26
  ASK_RESOLVED_MARKER,
25
27
  ASK_MAX_REACTION_CHOICES,
@@ -27,6 +29,7 @@ import {
27
29
  askIsExplainNote,
28
30
  askBuildResolvedText,
29
31
  askParseMessage,
32
+ askSeedMarker,
30
33
  askExplainReject,
31
34
  askFlatten,
32
35
  askMatchChoice,
@@ -2612,6 +2615,9 @@ interface AskWaitCtx {
2612
2615
  * nothing". */
2613
2616
  overflow: string[];
2614
2617
  threadOnly: boolean;
2618
+ /** The marker reaction this question was seeded with (➕, or ❓ on a
2619
+ * free-text question or one posted before ➕) — what the ✅ swap takes off. */
2620
+ seedMarker: string;
2615
2621
  /** The language the question's copy is in — the ✅ stamp and the
2616
2622
  * invalid-ballot line are written in the same one. */
2617
2623
  lang: AskLang;
@@ -2869,17 +2875,21 @@ function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
2869
2875
  }
2870
2876
  }
2871
2877
 
2872
- /** Who has pressed ❓ ("other"). Not an answer — ❓ carries no text — so it
2873
- * never resolves anything; the reply that should follow it does. Reported
2874
- * once per person so the operator knows a reply is on its way rather than
2875
- * reading the silence as "nobody looked". */
2878
+ /** Who has pressed ➕ ("other") — or ❓, the other pill on questions posted
2879
+ * before ➕, which is still pressed out of habit. Not an answer — neither
2880
+ * carries text — so it never resolves anything; the reply that should
2881
+ * follow it does. Reported once per person so the operator knows a reply is
2882
+ * on its way rather than reading the silence as "nobody looked". */
2876
2883
  const otherReported = new Set<string>();
2877
2884
  function noteOther(msg: Record<string, Json>): void {
2878
- const r = asArray(msg.reactions).map(asRecord).find((x) => x.name === ASK_MARKER);
2879
- for (const u of r ? asArray(r.users).filter(isAnswerer) : []) {
2880
- if (otherReported.has(u)) continue;
2881
- otherReported.add(u);
2882
- if (!readOnly) console.error(` (<@${u}> が ❓ その他 を押しました。返信を待ちます)`);
2885
+ for (const r of asArray(msg.reactions).map(asRecord)) {
2886
+ if (typeof r.name !== "string" || !ASK_OTHER_MARKERS.includes(r.name)) continue;
2887
+ const glyph = r.name === ASK_OTHER_MARKER ? "➕" : "❓";
2888
+ for (const u of asArray(r.users).filter(isAnswerer)) {
2889
+ if (otherReported.has(u)) continue;
2890
+ otherReported.add(u);
2891
+ if (!readOnly) console.error(` (<@${u}> が ${glyph} その他 を押しました。返信を待ちます)`);
2892
+ }
2883
2893
  }
2884
2894
  }
2885
2895
 
@@ -3158,12 +3168,12 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3158
3168
  const keep = state.ownText ? askResolvedKeep(state.ownText, found.choice) : undefined;
3159
3169
  await editMessage(token, channelId, ts, askBuildResolvedText(question, found, who, ctx.lang, keep), cookie, true);
3160
3170
  // Swap the marker for the resolved one so search reflects reality:
3161
- // `has::question:` should list what still needs answering, not everything
3162
- // ever asked. Removal last — a crash between the two leaves the question
3171
+ // `has::question: OR has::heavy_plus_sign:` should list what still needs
3172
+ // answering, not everything ever asked. Removal last — a crash between the two leaves the question
3163
3173
  // findable under BOTH, which is recoverable; the reverse loses it.
3164
3174
  try {
3165
3175
  await reactionAdd(token, channelId, ts, ASK_RESOLVED_MARKER, cookie);
3166
- await reactionRemove(token, channelId, ts, ASK_MARKER, cookie);
3176
+ await reactionRemove(token, channelId, ts, ctx.seedMarker, cookie);
3167
3177
  } catch {
3168
3178
  // Cosmetic: the ✅ in the body is what actually marks it resolved.
3169
3179
  }
@@ -3513,10 +3523,12 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
3513
3523
  const ts = await slackSend(token, channelId, message, threadTs, false, cookie, true, attr.metadata);
3514
3524
 
3515
3525
  // The marker goes on LAST, after 1,2,3, because it doubles as the "other"
3516
- // choice the body lists under the numbered ones (people were already pressing
3517
- // ❓ for "none of these fit"). It is a reaction so
3518
- // `has::question:` lists every question the way `has::pushpin:` lists every
3519
- // todo. Body text cannot do that job: Slack's index splits on punctuation, so
3526
+ // choice the body lists under the numbered ones: ➕ (ASK_OTHER_MARKER). A
3527
+ // free-text question has no other choice and seeds ❓. It is a reaction so
3528
+ // `has::question: OR has::heavy_plus_sign:` lists every open question the
3529
+ // way `has::pushpin:` lists every todo — though ➕ is a common emoji, so that
3530
+ // search can also match unrelated messages (`slack ask --ls` reads the local
3531
+ // sent-log and is unaffected). Body text cannot do that job: Slack's index splits on punctuation, so
3520
3532
  // a `:question:` written in the text is indexed as the word "question" and
3521
3533
  // matches unrelated messages (measured). Only a real reaction is searchable.
3522
3534
  //
@@ -3527,14 +3539,15 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
3527
3539
  // enough: measured on two real questions from the same build, one came back
3528
3540
  // `two|one|question|three`. See ts/reactionSeed.ts for the evidence and for
3529
3541
  // SLACK_REACTION_SEED_GAP_MS.
3542
+ const seedMarker = reactable.length ? ASK_OTHER_MARKER : ASK_MARKER;
3530
3543
  await seedReactionsInOrder(
3531
- [...reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER, ...(explain ? [ASK_EXPLAIN_MARKER] : [])],
3544
+ [...reactable.map((_, i) => ASK_KEYCAPS[i]!.name), seedMarker, ...(explain ? [ASK_EXPLAIN_MARKER] : [])],
3532
3545
  (name) => reactionAdd(token, channelId, ts, name, cookie),
3533
3546
  (name, e) => {
3534
3547
  const why = e instanceof Error ? e.message : String(e);
3535
3548
  // The marker is cosmetic + searchability; a keycap only costs the
3536
3549
  // answerer a trip to the emoji picker. Neither abandons a posted question.
3537
- if (name === ASK_MARKER) console.error(` (marker ${name} を付けられませんでした: ${why})`);
3550
+ if (name === seedMarker) console.error(` (marker ${name} を付けられませんでした: ${why})`);
3538
3551
  else console.error(` (リアクション ${name} を付けられませんでした: ${why})`);
3539
3552
  },
3540
3553
  );
@@ -3575,6 +3588,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
3575
3588
  reactable,
3576
3589
  overflow,
3577
3590
  threadOnly,
3591
+ seedMarker,
3578
3592
  lang,
3579
3593
  audience,
3580
3594
  broadcast,
@@ -3667,7 +3681,7 @@ async function askLoadOwnOpen(token: string, link: string, self: Self | null, as
3667
3681
  if (parsed.kind === "void") return "void";
3668
3682
  if (parsed.kind === "resolved") { console.error(`✗ ${shown}: 回答済みの質問です(回答: ${stripTerminalControls(parsed.answer.split("\n")[0] ?? "")})— 変更できません`); return null; }
3669
3683
  if (parsed.kind !== "open") { console.error(`✗ ${shown}: \`slack ask\` の質問として読み取れません — ${askExplainReject(text)}`); return null; }
3670
- const out: AskVoidTarget & { msg: Record<string, Json>; threadTs?: string } = { link, channelId: got.channelId, ts: got.ts, parsed, msg };
3684
+ const out: AskVoidTarget & { msg: Record<string, Json>; threadTs?: string } = { link, channelId: got.channelId, ts: got.ts, parsed, seedMarker: askSeedMarker(text) ?? ASK_MARKER, msg };
3671
3685
  if (got.threadTs) out.threadTs = got.threadTs;
3672
3686
  return out;
3673
3687
  }
@@ -3695,7 +3709,7 @@ async function askHasResponses(token: string, t: { channelId: string; ts: string
3695
3709
  }
3696
3710
 
3697
3711
  /** `slack ask --edit=<link> ["new question" [choices…]] [--body …]` — change an
3698
- * open question THROUGH the ask builder, so pills, ❓, the instructions and the
3712
+ * open question THROUGH the ask builder, so pills, ➕, the instructions and the
3699
3713
  * body stay in the layout collect reads back (a free-hand `slack edit` is what
3700
3714
  * this replaces; `edit` now refuses ask messages without --force).
3701
3715
  *
@@ -3778,17 +3792,32 @@ async function cmdAskEdit(token: string, args: {
3778
3792
  }
3779
3793
 
3780
3794
  await editMessage(token, t.channelId, t.ts, next, args.cookie, true);
3795
+ // The body was rebuilt, so its other line now says ➕ (or there is none, on
3796
+ // a free-text question) — the marker seed follows it.
3797
+ const seedMarker = reactable.length ? ASK_OTHER_MARKER : ASK_MARKER;
3798
+ const oldSeed = askSeedMarker(oldText) ?? ASK_MARKER;
3781
3799
  if (choicesChanged) {
3782
- // Re-seed in order: 1..n then ❓ last, as a fresh question has them.
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]) {
3800
+ // Re-seed in order: 1..n then ➕ last, as a fresh question has them. The
3801
+ // old marker comes off as whatever it was seeded as (❓ on an old pill).
3802
+ const tail = (m: string) => old.explain ? [m, ASK_EXPLAIN_MARKER] : [m];
3803
+ for (const name of [...old.reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ...tail(oldSeed)]) {
3785
3804
  try { await reactionRemove(token, t.channelId, t.ts, name, args.cookie); } catch { /* already gone */ }
3786
3805
  }
3787
3806
  await seedReactionsInOrder(
3788
- [...reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ...tail],
3807
+ [...reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ...tail(seedMarker)],
3789
3808
  (name) => reactionAdd(token, t.channelId, t.ts, name, args.cookie),
3790
3809
  (name, e) => console.error(` (リアクション ${name} を付けられませんでした: ${e instanceof Error ? e.message : String(e)})`),
3791
3810
  );
3811
+ } else if (oldSeed !== seedMarker) {
3812
+ // An old ❓ pill edited without touching its choices: the body now lists
3813
+ // ➕, so swap the seed to match. Add before remove, as the ✅ swap does —
3814
+ // a failure in between leaves both, which still collects.
3815
+ try {
3816
+ await reactionAdd(token, t.channelId, t.ts, seedMarker, args.cookie);
3817
+ await reactionRemove(token, t.channelId, t.ts, oldSeed, args.cookie);
3818
+ } catch (e: unknown) {
3819
+ console.error(` (marker を ${seedMarker} に替えられませんでした: ${e instanceof Error ? e.message : String(e)})`);
3820
+ }
3792
3821
  }
3793
3822
  console.error(`✓ 編集しました: ${stripTerminalControls(args.link)}`);
3794
3823
  }
@@ -4031,9 +4060,12 @@ interface AskVoidTarget {
4031
4060
  channelId: string;
4032
4061
  ts: string;
4033
4062
  parsed: Extract<ReturnType<typeof askParseMessage>, { kind: "open" }>;
4063
+ /** The marker reaction it was seeded with (➕, or ❓ on an old pill / free
4064
+ * text) — what the void takes off. */
4065
+ seedMarker: string;
4034
4066
  }
4035
4067
 
4036
- /** Void one question: the 🚫 text (body kept), our seeds and ❓ off, 🚫 on.
4068
+ /** Void one question: the 🚫 text (body kept), our seeds and marker (➕, or ❓) off, 🚫 on.
4037
4069
  * The text is what marks it void; the reactions are cleanup + searchability,
4038
4070
  * so a failure there is reported and does not undo the void. */
4039
4071
  async function askApplyVoid(token: string, t: AskVoidTarget, reason: string, supersededBy: string | undefined, cookie?: string): Promise<boolean> {
@@ -4045,7 +4077,7 @@ async function askApplyVoid(token: string, t: AskVoidTarget, reason: string, sup
4045
4077
  console.error(`✗ ${shown}: 書き換えに失敗しました: ${e instanceof Error ? e.message : String(e)}`);
4046
4078
  return false;
4047
4079
  }
4048
- for (const name of [...t.parsed.reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER, ...(t.parsed.explain ? [ASK_EXPLAIN_MARKER] : [])]) {
4080
+ for (const name of [...t.parsed.reactable.map((_, i) => ASK_KEYCAPS[i]!.name), t.seedMarker, ...(t.parsed.explain ? [ASK_EXPLAIN_MARKER] : [])]) {
4049
4081
  try { await reactionRemove(token, t.channelId, t.ts, name, cookie); } catch { /* not ours / already gone */ }
4050
4082
  }
4051
4083
  try {
@@ -4068,7 +4100,7 @@ async function askApplyVoid(token: string, t: AskVoidTarget, reason: string, sup
4068
4100
  *
4069
4101
  * What voiding does, per question: the text becomes the 🚫 form (head, void
4070
4102
  * stamp with the reason, optional pointer to the replacement, the body); our
4071
- * seeded pills and the ❓ marker come off; a 🚫 reaction goes on. Pills someone
4103
+ * seeded pills and the marker (➕, or ❓) come off; a 🚫 reaction goes on. Pills someone
4072
4104
  * already pressed stay — Slack lets only their owner remove them — and are
4073
4105
  * never collected, because a void is never polled again.
4074
4106
  *
@@ -4101,7 +4133,7 @@ async function cmdAskVoid(token: string, args: { links: string[]; reason: string
4101
4133
  process.exit(ASK_EXIT_ERROR);
4102
4134
  }
4103
4135
  }
4104
- type Target = { link: string; channelId: string; ts: string; text: string; parsed: Extract<ReturnType<typeof askParseMessage>, { kind: "open" }>; pressed: string[]; unreadable?: boolean };
4136
+ type Target = { link: string; channelId: string; ts: string; text: string; parsed: Extract<ReturnType<typeof askParseMessage>, { kind: "open" }>; seedMarker: string; pressed: string[]; unreadable?: boolean };
4105
4137
  const targets: Target[] = [];
4106
4138
  let refused = 0;
4107
4139
  for (const link of args.links) {
@@ -4144,11 +4176,16 @@ async function cmdAskVoid(token: string, args: { links: string[]; reason: string
4144
4176
  };
4145
4177
  unreadable = true;
4146
4178
  }
4179
+ // Our marker: what the body names, or — for an unreadable body — ➕ if we
4180
+ // seeded one, else ❓ (an old pill, or free text).
4181
+ const seedMarker = unreadable
4182
+ ? (asArray(msg.reactions).map(asRecord).some((r) => r.name === ASK_OTHER_MARKER && asArray(r.users).includes(self?.userId ?? "\u0000")) ? ASK_OTHER_MARKER : ASK_MARKER)
4183
+ : askSeedMarker(text) ?? ASK_MARKER;
4147
4184
  const pressed = asArray(msg.reactions).map(asRecord)
4148
4185
  .filter((r) => typeof r.name === "string" && ASK_KEYCAPS.some((k) => k.name === r.name))
4149
4186
  .filter((r) => asArray(r.users).some((u) => u !== self?.userId))
4150
4187
  .map((r) => `:${String(r.name)}:`);
4151
- targets.push({ link, channelId: got.channelId, ts: got.ts, text, parsed, pressed, ...(unreadable ? { unreadable } : {}) });
4188
+ targets.push({ link, channelId: got.channelId, ts: got.ts, text, parsed, seedMarker, pressed, ...(unreadable ? { unreadable } : {}) });
4152
4189
  }
4153
4190
  if (!targets.length) {
4154
4191
  console.error(refused ? "無効化できる質問がありません。" : "何もすることがありません。");
@@ -4166,7 +4203,7 @@ async function cmdAskVoid(token: string, args: { links: string[]; reason: string
4166
4203
  ...targets.map((t) => ` • ${stripTerminalControls(t.link)}\n ${stripTerminalControls(t.parsed.question.split("\n")[0] ?? "").slice(0, 100)}` +
4167
4204
  (t.pressed.length ? `\n (already pressed: ${t.pressed.join(" ")} — left in place, never collected)` : "") +
4168
4205
  (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` : "")),
4169
- ` Each becomes 🚫 (body kept, options and instructions removed); our pills and ❓ come off; a 🚫 reaction is added.`,
4206
+ ` Each becomes 🚫 (body kept, options and instructions removed); our pills and marker (➕, or ❓) come off; a 🚫 reaction is added.`,
4170
4207
  ...(refused ? [` (${refused} link(s) above were refused and will be skipped)`] : []),
4171
4208
  `----------------------------------------------`,
4172
4209
  ]);
@@ -4350,6 +4387,7 @@ function askWaitCtxFrom(
4350
4387
  reactable: parsed.reactable,
4351
4388
  overflow: parsed.overflow,
4352
4389
  threadOnly: parsed.threadOnly,
4390
+ seedMarker: askSeedMarker(q.text) ?? ASK_MARKER,
4353
4391
  lang: parsed.lang,
4354
4392
  audience: q.audience,
4355
4393
  broadcast: q.broadcast,
@@ -5919,7 +5957,7 @@ async function main(): Promise<void> {
5919
5957
  .option("limit", { type: "number", default: 50, describe: "With --ls: how many recent asks to check" })
5920
5958
  .option("reason", { type: "string", describe: "With --void: why it is void (written into the message)" })
5921
5959
  .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." })
5960
+ .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
5961
  .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." })
5924
5962
  .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." })
5925
5963
  .option("timeout", { type: "number", default: 3600, describe: "Overall limit for --wait / --waitFor, in seconds (0 with --waitFor = check once)" })