slack-term 1.43.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/README.md +18 -6
- package/SKILL.md +25 -7
- package/dist/cli.js +211 -210
- package/package.json +1 -1
- package/ts/ask.ts +137 -25
- package/ts/cli.ts +247 -43
package/package.json
CHANGED
package/ts/ask.ts
CHANGED
|
@@ -34,13 +34,45 @@ 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
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* the
|
|
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
|
+
|
|
67
|
+
/** The "explain" pill (`--explain`): pressing it asks the asker to explain the
|
|
68
|
+
* question, and is NEVER an answer. Reported 2026-10-14: an "explain" offered
|
|
69
|
+
* as a numbered choice was collected as the decision and closed the question.
|
|
70
|
+
* Not ➕: ➕ means "none of these fit — my answer follows as a reply", and that
|
|
71
|
+
* reply resolves the question; ℹ️ means "I cannot answer yet", and the question
|
|
72
|
+
* stays open for a real answer after the explanation. */
|
|
73
|
+
export const ASK_EXPLAIN_MARKER = "information_source";
|
|
74
|
+
export const ASK_EXPLAIN_PREFIX = `:${ASK_EXPLAIN_MARKER}: `;
|
|
75
|
+
|
|
44
76
|
// The copy a reader sees, per language. Japanese is the default and its
|
|
45
77
|
// strings are a WIRE FORMAT: questions already in flight were posted with them
|
|
46
78
|
// and are recognised by them, so they must never be reworded. Adding a language
|
|
@@ -118,6 +150,14 @@ interface AskCopy {
|
|
|
118
150
|
overflowNote: string;
|
|
119
151
|
otherThread: string;
|
|
120
152
|
otherHere: string;
|
|
153
|
+
/** The free-text parser matches this exactly (`askIsExplainCopy`), so it is
|
|
154
|
+
* a wire format too: questions in flight carry it. */
|
|
155
|
+
explainLine: string;
|
|
156
|
+
/** WIRE FORMAT — never reword. `--wait`/`--waitFor` find a question's
|
|
157
|
+
* thread note by this exact copy (`askIsExplainNote`), so a reworded note
|
|
158
|
+
* is not found by later runs, which would post a second one, and the old
|
|
159
|
+
* note would stop being excluded from answers and notes. */
|
|
160
|
+
explainNote: string;
|
|
121
161
|
invalidPrefix: string;
|
|
122
162
|
answeredVia: (how: string, who: string) => string;
|
|
123
163
|
howReaction: (glyph: string) => string;
|
|
@@ -128,10 +168,10 @@ interface AskCopy {
|
|
|
128
168
|
}
|
|
129
169
|
|
|
130
170
|
/** The standing "none of these" choice (`otherThread` / `otherHere`), listed
|
|
131
|
-
* after the numbered ones. It is the
|
|
132
|
-
* the reaction row reads the same as
|
|
133
|
-
*
|
|
134
|
-
* choice, and says where the actual answer has to go:
|
|
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
|
|
135
175
|
* answer text, so it never resolves the question; the reply that follows it
|
|
136
176
|
* does (exit 5, delivered as free text). Optional on the way in: questions
|
|
137
177
|
* posted before it existed have no such line and must stay collectable.
|
|
@@ -147,8 +187,10 @@ export const ASK_COPY: Record<AskLang, AskCopy> = {
|
|
|
147
187
|
"_このメッセージの *スレッド* で返信してください。本文がそのまま回答になります (チャンネルへの通常投稿は回答として拾いません)。_",
|
|
148
188
|
instructionTextHere: "_このメッセージに返信してください。本文がそのまま回答になります。_",
|
|
149
189
|
overflowNote: "_11 番目以降はリアクションがないので、返信で答えてください。_",
|
|
150
|
-
otherThread: `${
|
|
151
|
-
otherHere: `${
|
|
190
|
+
otherThread: `${ASK_OTHER_PREFIX}その他 — スレッドで返信してください`,
|
|
191
|
+
otherHere: `${ASK_OTHER_PREFIX}その他 — このメッセージに返信してください`,
|
|
192
|
+
explainLine: `${ASK_EXPLAIN_PREFIX}説明して — 押すと質問者に説明を頼みます (回答にはなりません)`,
|
|
193
|
+
explainNote: `${ASK_EXPLAIN_PREFIX}説明のリクエストを受け付けました — このスレッドで説明します。質問はまだ開いているので、回答はそのあとで大丈夫です。`,
|
|
152
194
|
invalidPrefix: "_:warning: 同時に複数選ばれているため回答として数えていません — どれか 1 つだけ残してください: ",
|
|
153
195
|
answeredVia: (how, who) => `_${how}で回答済み${who ? ` (${who})` : ""}_`,
|
|
154
196
|
howReaction: (glyph) => `リアクション ${glyph}`,
|
|
@@ -166,8 +208,10 @@ export const ASK_COPY: Record<AskLang, AskCopy> = {
|
|
|
166
208
|
"_Reply in this message's *thread* — your reply is the answer (ordinary channel posts are not picked up as answers)._",
|
|
167
209
|
instructionTextHere: "_Reply to this message — your reply is the answer._",
|
|
168
210
|
overflowNote: "_Choices 11 and up have no reaction — answer them with a reply._",
|
|
169
|
-
otherThread: `${
|
|
170
|
-
otherHere: `${
|
|
211
|
+
otherThread: `${ASK_OTHER_PREFIX}Other — reply in the thread`,
|
|
212
|
+
otherHere: `${ASK_OTHER_PREFIX}Other — reply to this message`,
|
|
213
|
+
explainLine: `${ASK_EXPLAIN_PREFIX}Explain — press to ask the asker for more detail (not an answer)`,
|
|
214
|
+
explainNote: `${ASK_EXPLAIN_PREFIX}Explanation requested — it will follow in this thread. The question stays open; answer it after that.`,
|
|
171
215
|
invalidPrefix: "_:warning: Several choices are selected, so this is not counted as an answer — keep only one: ",
|
|
172
216
|
answeredVia: (how, who) => `_Answered by ${how}${who ? ` (${who})` : ""}_`,
|
|
173
217
|
howReaction: (glyph) => `reaction ${glyph}`,
|
|
@@ -178,11 +222,36 @@ export const ASK_COPY: Record<AskLang, AskCopy> = {
|
|
|
178
222
|
},
|
|
179
223
|
};
|
|
180
224
|
|
|
181
|
-
/**
|
|
182
|
-
*
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** True for the explain line, in either spelling. Matched by prefix, like the
|
|
242
|
+
* other line — used where position alone identifies it (under ❓). */
|
|
243
|
+
function askIsExplainLine(line: string | undefined): boolean {
|
|
244
|
+
return line !== undefined && (line.startsWith(ASK_EXPLAIN_PREFIX) || line.startsWith("ℹ️ "));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** True only for this language's explain line AS WRITTEN (either spelling of
|
|
248
|
+
* the marker). For a free-text question, where nothing but position separates
|
|
249
|
+
* the explain line from the body above it: a body whose own last line starts
|
|
250
|
+
* with ℹ️ must not be read as the pill, so the copy has to match exactly. */
|
|
251
|
+
function askIsExplainCopy(line: string | undefined, c: AskCopy): boolean {
|
|
252
|
+
if (line === undefined) return false;
|
|
253
|
+
const norm = line.startsWith("ℹ️ ") ? ASK_EXPLAIN_PREFIX + line.slice("ℹ️ ".length) : line;
|
|
254
|
+
return norm === c.explainLine;
|
|
186
255
|
}
|
|
187
256
|
|
|
188
257
|
// The invalid-ballot notice, shared by `ask` and `poll` and written INTO the
|
|
@@ -228,6 +297,14 @@ export function applyInvalidNotice(text: string, invalid: string[], lang: AskLan
|
|
|
228
297
|
return out === text ? text : out;
|
|
229
298
|
}
|
|
230
299
|
|
|
300
|
+
/** True for the thread note `--wait`/`--waitFor` posts when ℹ️ is pressed, in
|
|
301
|
+
* either language. Our own bookkeeping: never an answer, never a note — even
|
|
302
|
+
* when the identity that posted it is in the audience (an @here question
|
|
303
|
+
* collected with another token). */
|
|
304
|
+
export function askIsExplainNote(text: unknown): boolean {
|
|
305
|
+
return typeof text === "string" && ASK_LANGS.some((l) => ASK_COPY[l].explainNote === text.trim());
|
|
306
|
+
}
|
|
307
|
+
|
|
231
308
|
/** True if this line is the notice — parsers skip it when walking up. */
|
|
232
309
|
export function isInvalidNotice(line: string | undefined): boolean {
|
|
233
310
|
return invalidLang(line) !== null;
|
|
@@ -242,7 +319,7 @@ export const ASK_RESOLVED_PREFIX = `:${ASK_RESOLVED_MARKER}: `;
|
|
|
242
319
|
/** Prefix `--void` stamps on a question that should no longer be answered —
|
|
243
320
|
* expired, superseded, or overtaken by events (無効化). A THIRD state beside open
|
|
244
321
|
* (`:question:`) and answered (`:white_check_mark:`), on purpose: anything that
|
|
245
|
-
* 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:`)
|
|
246
323
|
* stops counting it the moment it is voided, and nothing reads it as answered.
|
|
247
324
|
* Also seeded as a reaction, so `has::no_entry_sign:` lists every void. */
|
|
248
325
|
export const ASK_VOID_MARKER = "no_entry_sign";
|
|
@@ -293,7 +370,7 @@ export function askFlatten(s: string): string {
|
|
|
293
370
|
/** Question body: the prompt, the numbered choices matching the seeded pills,
|
|
294
371
|
* and an instruction naming the answer paths this command actually reads.
|
|
295
372
|
* `askParseMessage` is its inverse — keep the two in step. */
|
|
296
|
-
export function askBuildText(question: string, body: string, reactable: string[], overflow: string[], threadOnly: boolean, lang: AskLang = "ja"): string {
|
|
373
|
+
export function askBuildText(question: string, body: string, reactable: string[], overflow: string[], threadOnly: boolean, lang: AskLang = "ja", explain = false): string {
|
|
297
374
|
const c = ASK_COPY[lang];
|
|
298
375
|
const lines: string[] = [`${ASK_MARKER_PREFIX}*${question}*`];
|
|
299
376
|
if (body) lines.push("", body);
|
|
@@ -307,8 +384,10 @@ export function askBuildText(question: string, body: string, reactable: string[]
|
|
|
307
384
|
// form keeps posted and read-back bytes identical, which is what the
|
|
308
385
|
// round-trip test can actually check. (`poll` already did this.)
|
|
309
386
|
lines.push(...reactable.map((s, i) => `${askPill(i)} ${askFlatten(s)}`));
|
|
310
|
-
// Directly under the pills, matching the
|
|
387
|
+
// Directly under the pills, matching the ➕ seeded right after them.
|
|
311
388
|
lines.push(threadOnly ? c.otherThread : c.otherHere);
|
|
389
|
+
// Under ➕, matching the ℹ️ seeded right after it.
|
|
390
|
+
if (explain) lines.push(c.explainLine);
|
|
312
391
|
if (overflow.length) {
|
|
313
392
|
lines.push("");
|
|
314
393
|
lines.push(...overflow.map((s, i) => `(${i + 11}) ${askFlatten(s)}`));
|
|
@@ -321,6 +400,7 @@ export function askBuildText(question: string, body: string, reactable: string[]
|
|
|
321
400
|
lines.push(threadOnly ? c.instructionReactionThread : c.instructionReactionHere);
|
|
322
401
|
} else {
|
|
323
402
|
lines.push("");
|
|
403
|
+
if (explain) lines.push(c.explainLine, "");
|
|
324
404
|
lines.push(threadOnly ? c.instructionTextThread : c.instructionTextHere);
|
|
325
405
|
}
|
|
326
406
|
return lines.join("\n");
|
|
@@ -334,7 +414,7 @@ export function askBuildText(question: string, body: string, reactable: string[]
|
|
|
334
414
|
* decision was made against) and the option line that was chosen. Reported
|
|
335
415
|
* from real use 2026-10-05: collecting an answer replaced the whole message
|
|
336
416
|
* with question + answer, and the context of the decision was gone. Only what
|
|
337
|
-
* 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
|
|
338
418
|
* instructions.
|
|
339
419
|
*
|
|
340
420
|
* The head is unchanged on purpose: ✅ prefix, question, stamp, blank, quoted
|
|
@@ -405,7 +485,7 @@ export function askSalvageUnreadable(text: string): { question: string; body: st
|
|
|
405
485
|
for (const instr of [c.instructionReactionThread, c.instructionReactionHere, c.instructionTextThread, c.instructionTextHere]) {
|
|
406
486
|
if (body.includes(instr)) wasAsk = true;
|
|
407
487
|
}
|
|
408
|
-
for (const fixed of [c.instructionReactionThread, c.instructionReactionHere, c.instructionTextThread, c.instructionTextHere, c.overflowNote, c.otherThread, c.otherHere]) {
|
|
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]) {
|
|
409
489
|
body = body.split(fixed).join(" ");
|
|
410
490
|
}
|
|
411
491
|
const at = body.indexOf(c.invalidPrefix);
|
|
@@ -454,7 +534,8 @@ function askDecodeEntities(s: string): string {
|
|
|
454
534
|
}
|
|
455
535
|
|
|
456
536
|
export type AskParsed =
|
|
457
|
-
|
|
537
|
+
/** `explain`: the body offers the ℹ️ explain pill (`--explain`). */
|
|
538
|
+
| { kind: "open"; question: string; body: string; reactable: string[]; overflow: string[]; threadOnly: boolean; lang: AskLang; explain: boolean }
|
|
458
539
|
| { kind: "resolved"; question: string; answer: string }
|
|
459
540
|
/** Voided (無効化). `reason` is the stamp line as written; `supersededBy` the
|
|
460
541
|
* replacement's link when one was given. */
|
|
@@ -594,6 +675,7 @@ export function askParseMessage(text: string): AskParsed {
|
|
|
594
675
|
// there is the body — kept by the ✅ rewrite.
|
|
595
676
|
let bodyEnd = lines.length - 1;
|
|
596
677
|
if (isInvalidNotice(lines[bodyEnd - 1])) bodyEnd--;
|
|
678
|
+
let explain = false;
|
|
597
679
|
if (hasChoices) {
|
|
598
680
|
// Walk UP from the instruction line rather than down from the question. The
|
|
599
681
|
// choice block is anchored to the bottom of the body, and scanning downward
|
|
@@ -609,7 +691,8 @@ export function askParseMessage(text: string): AskParsed {
|
|
|
609
691
|
if (lines[i] !== "") return { kind: "other" };
|
|
610
692
|
i--;
|
|
611
693
|
}
|
|
612
|
-
if (
|
|
694
|
+
if (askIsExplainLine(lines[i])) { explain = true; i--; }
|
|
695
|
+
if (askOtherLineMarker(lines[i])) i--;
|
|
613
696
|
const raw: string[] = [];
|
|
614
697
|
while (i >= 0) {
|
|
615
698
|
const line = lines[i]!;
|
|
@@ -634,10 +717,39 @@ export function askParseMessage(text: string): AskParsed {
|
|
|
634
717
|
for (let n = 0; n < overflow.length; n++) {
|
|
635
718
|
if (!lines.includes(`(${n + 11}) ${overflow[n]}`)) return { kind: "other" };
|
|
636
719
|
}
|
|
720
|
+
} else if (lines[bodyEnd - 1] === "" && askIsExplainCopy(lines[bodyEnd - 2], c) && lines[bodyEnd - 3] === "" && bodyEnd - 3 >= end) {
|
|
721
|
+
// A free-text question's explain line: blank, the line, blank, instruction
|
|
722
|
+
// — and the line is our copy verbatim. A body that merely ENDS on a line
|
|
723
|
+
// starting with ℹ️ (a question built without --explain) stays body.
|
|
724
|
+
explain = true;
|
|
725
|
+
bodyEnd -= 2;
|
|
637
726
|
}
|
|
638
727
|
|
|
639
728
|
const body = lines.slice(end + 1, bodyEnd).join("\n").replace(/^\n+|\n+$/g, "");
|
|
640
|
-
return { kind: "open", question, body, reactable, overflow, threadOnly, lang };
|
|
729
|
+
return { kind: "open", question, body, reactable, overflow, threadOnly, lang, explain };
|
|
730
|
+
}
|
|
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;
|
|
641
753
|
}
|
|
642
754
|
|
|
643
755
|
/** Did a free-text reply actually PICK one of the offered choices?
|