Skip to content

feat: allow Firstmate to retitle Discord topic threads - #259

Merged
dnth merged 4 commits into
mainfrom
fm/fm-ext-thread-retitle
Oct 19, 2026
Merged

dnth merged 4 commits into
mainfrom
fm/fm-ext-thread-retitle

Conversation

@dnth

@dnth dnth commented Oct 18, 2026

Copy link
Copy Markdown
Owner

Intent

Let Firstmate rename a Discord topic thread's base name after creation, so the name can follow the conversation when it drifts from the first message.

Captain (chat, 18 Oct 2026): "i want a full rename from firstmate based on the conversation. the conversation might have changed from the beginning of the thread."

Change:

  • Add an explicit retitle path: bin/fm-ext-emit.sh --thread-retitle "<Project> · <topic>" on any reply into an existing Firstmate-created topic thread. A plain later --thread-title keeps its current no-replace meaning.
  • The new title passes the same screen and bounds as creation titles (one line, at most 60 characters, unsafe or multi-line or over-long text refused) and becomes the thread's stored base name, so later needs-you/done/none markers keep the new base and reconciliation targets it.
  • Same eligibility as markers: only threads this home created in the dedicated channel; never captain-made threads, other channels, DMs, or aged-out records. Best effort like marker renames, with the existing retry behaviour; a failed rename must not block the reply text.
  • Keep the current marker prefix when retitling (a needs-you thread stays "❓ ").
  • .agents/skills/ext-respond/SKILL.md: one short rule for when Firstmate retitles - when the thread's live topic has clearly moved on from its name (Firstmate judges from the conversation), retitle on its next reply as " · "; not on every reply.
  • docs/configuration.md "Topic threads": replace the "later titles do not replace" / "preserve the original base name" wording with the new contract.
  • bin/fm-ext-emit.sh header/help and the shared title screen's owner (bin/fm-ext-topic.sh) describe the new behaviour consistently.
  • Out of scope: renaming captain-made threads, bulk retitling old threads, and Discord API calls from bin scripts (the gateway poster owns Discord).

Acceptance criteria:

  • AC1: A reply emitted with the retitle option into an existing Firstmate-created topic thread renames the Discord thread to the new base, keeping any current marker prefix, and a later status marker uses the new base, not the original.
  • AC2: Retitle is refused or ignored safely for ineligible threads (captain-made, other channel, DM, aged-out record) and for titles failing the shared screen; the reply text still posts.
  • AC3: docs/configuration.md Topic threads, bin/fm-ext-emit.sh header/help, and the ext-respond skill describe the new behaviour consistently, with no leftover "later titles do not replace its name" / "preserve the original base name" wording.
  • AC4: The repository's full test suite passes; the PR description notes that the hermes-gateway-fmcomms user unit must be restarted after merge for the poster change to take effect.

Evidence constraints accepted with firstmate:

  • This worktree cannot run the 11-script real-herdr-gated family: this brief is unguarded for Herdr lifecycle, and those scripts start, stop, and delete real Herdr containers and tasks. Do not run them locally.
  • Local portable-lane runs have 19 not-ok assertions across 20 unrelated suites (harness spawn, secondmate, session-start, tangle/prepush/trace guards). Of those 20, 18 also fail at the base commit in a clean clone (14 with the same primary failure, 2 exiting 1 with no not-ok line, 2 with a different primary message); the remaining 2 (fm-boat-lane-runner, fm-wake-queue) passed at base under a lighter run and are being rechecked isolated on this branch to separate flakiness under 9-way lane contention from a real regression. None of the 20 references the changed ext-bridge or poster surface.
  • AC4's "repository's full test suite passes" verdict therefore comes from the PR's CI lanes once green, including the required real-Herdr lane.
  • If any PR CI lane fails, report it before changing anything outside this task's surface.

What Changed

  • Add --thread-retitle to replies and choice posts for existing Firstmate-created Discord topic threads, preserving the current marker and storing the new base for later markers and reconciliation.
  • Apply the shared title screen and thread eligibility checks; rejected retitles and rename failures leave replies deliverable, with existing rename retries retained.
  • Update guidance and regression coverage for retitling. Restart the hermes-gateway-fmcomms user unit after merge for the poster change to take effect.

Risk Assessment

✅ Low: The retitle change is bounded, preserves existing eligibility and retry behavior, and the prior findings are resolved without substantiated new defects.

Testing

Both focused suites, isolated CLI checks, and supplemental HTTP simulation passed. No Discord screenshot was possible without authorized sandbox access. Full-suite and CI validation remain with the outer executor.

  • Live validation: ⚠️ inconclusive - 3 of 7 scenarios driven live against the product
Scenario Result Live Evidence
Emit a valid retitle, including a 60-character title and both title flags; the outbox preserves the explicit retitle and reply. ✅ pass live cli-transcript.log
Emit unsafe, multiline, or oversized retitles through emit and ask; warnings explain rejection while replies remain queued without a retitle. ✅ pass live cli-transcript.log; ask-cli-transcript.log
Ask a choice with a valid retitle; the persisted choice retains its options, needs-you status, and retitle. ✅ pass live ask-cli-transcript.log
Retitle an existing Firstmate thread; Discord preserves its marker and later status changes use the new base. ⏸️ untested no Passed against a disposable HTTP simulator using the real CLI, poster, and transport. Actual Discord requires a sandbox bot token and guild/channel permissions not supplied here; creating a local endp…
Manually clear a Discord marker before retitling; it stays cleared, while an explicit status overrides it and an unreadable name uses recorded status. ⏸️ untested no All cases passed in simulation. A real manual Discord edit and subsequent rename require an authorized sandbox bot/guild and Discord access, which are unavailable. Provide those credentials and permis…
Request retitles for captain-made threads, other channels, DMs, or aged-out records; replies post without unauthorized renaming. ⏸️ untested no Targeted tests and the local HTTP simulation exercised eligibility boundaries, but no real Discord destinations were available. Disposable local records cannot prove Discord delivery. Provide an autho…
Encounter a rename failure; the reply remains delivered and reconciliation retries the new base. ⏸️ untested no A simulated HTTP 503 preserved the reply receipt and reconciliation successfully retried. Actual Discord failure/recovery remains untested because no authorized sandbox token or guild is available. Pr…
Evidence: Real CLI: title screening, warnings, and persisted replies
$ fm-ext-intake.sh '--request-id' 'discord:111111111111111111:222222222222222222:777777777777777777:444444444444444444' '--guild-id' '111111111111111111' '--channel-id' '222222222222222222' '--thread-id' '777777777777777777' '--message-id' '444444444444444444' '--author' '555555555555555555' '--secret-file' '~/.no-mistakes/worktrees/dd71c22cc6d7/01M57XMB5P92F0T92C61FPXR7Z/.test-phase/cli-home/config/ext-secret' '--text-file' '-' '--origin' 'message' '--no-wake'
exit: 0
4533fb69f17e3427a002f9a156f7b45e8348eb6f040bf1e9ff0147db71b60c7a
$ fm-ext-emit.sh '--request-id' 'discord:111111111111111111:222222222222222222:777777777777777777:444444444444444444' '--kind' 'followup' '--text-file' '-' '--generation' '1' '--thread-retitle' 'Firstmate · migration' '--thread-title' 'Firstmate · original'
exit: 0
4533fb69f17e3427a002f9a156f7b45e8348eb6f040bf1e9ff0147db71b60c7a
persisted outbox: {"channel_id": "222222222222222222", "generation": 1, "guild_id": "111111111111111111", "kind": "followup", "message_id": "444444444444444444", "origin": "message", "platform": "discord", "recorded_at": 1792342133, "request_id": "discord:111111111111111111:222222222222222222:777777777777777777:444444444444444444", "schema_version": 1, "slug": "4533fb69f17e3427a002f9a156f7b45e8348eb6f040bf1e9ff0147db71b60c7a", "source": "hermes-gateway", "text": "The reply remains unchanged.", "thread_id": "777777777777777777", "thread_retitle": "Firstmate · migration", "thread_title": "Firstmate · original"}
$ fm-ext-emit.sh '--request-id' 'discord:111111111111111111:222222222222222222:777777777777777777:444444444444444444' '--kind' 'followup' '--text-file' '-' '--generation' '2' '--thread-retitle' 'Firstmate · topic topic topic topic topic topic topic topicx'
exit: 0
4533fb69f17e3427a002f9a156f7b45e8348eb6f040bf1e9ff0147db71b60c7a
persisted outbox: {"channel_id": "222222222222222222", "generation": 2, "guild_id": "111111111111111111", "kind": "followup", "message_id": "444444444444444444", "origin": "message", "platform": "discord", "recorded_at": 1792342134, "request_id": "discord:111111111111111111:222222222222222222:777777777777777777:444444444444444444", "schema_version": 1, "slug": "4533fb69f17e3427a002f9a156f7b45e8348eb6f040bf1e9ff0147db71b60c7a", "source": "hermes-gateway", "text": "The reply remains unchanged.", "thread_id": "777777777777777777", "thread_retitle": "Firstmate · topic topic topic topic topic topic topic topicx"}
$ fm-ext-emit.sh '--request-id' 'discord:111111111111111111:222222222222222222:777777777777777777:444444444444444444' '--kind' 'followup' '--text-file' '-' '--generation' '3' '--thread-retitle' 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
exit: 0
4533fb69f17e3427a002f9a156f7b45e8348eb6f040bf1e9ff0147db71b60c7a
stderr: fm-ext-emit: warning: thread retitle ignored: title must be at most 60 characters
persisted outbox: {"channel_id": "222222222222222222", "generation": 3, "guild_id": "111111111111111111", "kind": "followup", "message_id": "444444444444444444", "origin": "message", "platform": "discord", "recorded_at": 1792342134, "request_id": "discord:111111111111111111:222222222222222222:777777777777777777:444444444444444444", "schema_version": 1, "slug": "4533fb69f17e3427a002f9a156f7b45e8348eb6f040bf1e9ff0147db71b60c7a", "source": "hermes-gateway", "text": "The reply remains unchanged.", "thread_id": "777777777777777777"}
$ fm-ext-emit.sh '--request-id' 'discord:111111111111111111:222222222222222222:777777777777777777:444444444444444444' '--kind' 'followup' '--text-file' '-' '--generation' '4' '--thread-retitle' 'two\nlines'
exit: 0
4533fb69f17e3427a002f9a156f7b45e8348eb6f040bf1e9ff0147db71b60c7a
stderr: fm-ext-emit: warning: thread retitle ignored: title must be one line
persisted outbox: {"channel_id": "222222222222222222", "generation": 4, "guild_id": "111111111111111111", "kind": "followup", "message_id": "444444444444444444", "origin": "message", "platform": "discord", "recorded_at": 1792342135, "request_id": "discord:111111111111111111:222222222222222222:777777777777777777:444444444444444444", "schema_version": 1, "slug": "4533fb69f17e3427a002f9a156f7b45e8348eb6f040bf1e9ff0147db71b60c7a", "source": "hermes-gateway", "text": "The reply remains unchanged.", "thread_id": "777777777777777777"}
$ fm-ext-emit.sh '--request-id' 'discord:111111111111111111:222222222222222222:777777777777777777:444444444444444444' '--kind' 'followup' '--text-file' '-' '--generation' '5' '--thread-retitle' 'fix fm-ship-login now'
exit: 0
4533fb69f17e3427a002f9a156f7b45e8348eb6f040bf1e9ff0147db71b60c7a
stderr: fm-ext-emit: warning: thread retitle ignored: title failed the safety screen
persisted outbox: {"channel_id": "222222222222222222", "generation": 5, "guild_id": "111111111111111111", "kind": "followup", "message_id": "444444444444444444", "origin": "message", "platform": "discord", "recorded_at": 1792342136, "request_id": "discord:111111111111111111:222222222222222222:777777777777777777:444444444444444444", "schema_version": 1, "slug": "4533fb69f17e3427a002f9a156f7b45e8348eb6f040bf1e9ff0147db71b60c7a", "source": "hermes-gateway", "text": "The reply remains unchanged.", "thread_id": "777777777777777777"}
$ fm-ext-emit.sh '--request-id' 'discord:111111111111111111:222222222222222222:777777777777777777:444444444444444444' '--kind' 'followup' '--text-file' '-' '--generation' '6' '--thread-title' 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
exit: 2
stderr: title must be at most 60 characters
fm-ext-emit: thread title failed the shared bounds or safety check
$ fm-ext-emit.sh '--help'
exit: 0
Emit one Communication Officer outbox payload (ack/answer/followup/final).

Usage:
  fm-ext-emit.sh --request-id discord:<guild>:<channel>:<thread>:<message>
    --kind ack|answer|followup|final --generation <n>
    (--text-file <path> | --text-file -) [--decision-file <path>]
    [--no-buttons "<reason>"]
    [--thread-title <text>] [--thread-retitle <text>]
    [--thread-state needs-you|done|none]

To ask the captain a choice, use bin/fm-ext-ask.sh: it holds the call, writes
the decision file, numbers the options in the text, and posts through this
script, so text and buttons always match.

--thread-title names the request's topic thread ("<Project> · <topic>", one
line, at most 60 characters, screened like reply text) on the reply that
creates it; once the thread exists, later titles are recorded but do not
replace its name. Over-long, multi-line, or unsafe titles are
refused here instead of silently cut. --thread-retitle replaces that name on
any reply into an existing Firstmate-created topic thread when the
conversation has moved on from it: it passes the same screen and bounds,
keeps the thread's current marker, and becomes the thread's base name, so
every later marker, auto-clear, and reconciliation builds on it. A rejected
retitle is dropped with a warning while the reply still posts. Both title
flags may be given; --thread-title only names a thread at creation.
--thread-state renames the existing topic thread: needs-you prefixes "❓ ",
done prefixes "✅ ", none removes the prefix. docs/configuration.md
"Topic threads" owns rename eligibility, best-effort delivery, auto-clear,
and retry behaviour; the ext-respond skill owns when Firstmate retitles.

Writes state/ext-outbox/<slug>.<kind>.<generation>.json once. Re-emitting the
same identity is a no-op success. A posting marker without a receipt is
refused (mid-delivery), matching public-follow-up delivery-posting behavior.
Destination fields come from the durable per-request context, so a delayed
follow-up still works after the inbox file is gone. The payload also carries
the request's origin (slash, message, button, or open for a thread
bin/fm-ext-open.sh opened; slash when the context predates it), which tells
the gateway poster whether it can react to the original message.

--decision-file attaches option buttons to an answer, followup, or final.
The file is JSON:
  {"key": "hold:<task-id>" | "key:<task-id>:<slug>",
   "mode": "done" | "release",
   "options": [{"label": "<text>", "confirm": true|false, "recommended": true|false}, ...],
   "sections": {"question": "<text>", "explanation": "<text>",
                "options": "<numbered list>", "cue": "<reply cue>"}}
with 1-5 options whose input and displayed labels are distinct, 1-80 characters, and free of
control characters, in the same order as the post text numbers them; option
ids are 1-based in that order. "mode" (captain calls only, default done)
chooses how a chosen option closes the call, as in fm-captain-hold.sh
answers. "confirm" and "recommended" are optional booleans, default false.
"sections" is optional; when present, its question, explanation, option list,
and cue must reproduce the post text exactly. Without it, the whole post is
protected question text. fm-ext-ask.sh writes these boundaries explicitly.
A decision post reserves room for its later outcome header and keeps the
option list on the final button-carrying chunk.
At most one option may set "recommended": true; emit adds "⭐ " unless the
label already starts with ⭐, and stores the displayed label in the payload.
The key must name a decision that is open right now (a held captain
call, or a worker's open keyed status decision) and must fit Discord's
100-character custom_id as fmd:<key>:<n>. An option needs a confirmation
click when it declares confirm, and always when its label matches
FM_EXT_DECISION_CONFIRM_PATTERN in bin/fm-ext-lib.sh. The post text and labels
are refused when they name the key or its task id, because only the button
custom_ids may carry the key. The payload's decision field also carries
the mode, numbered options, post text cut to its first 1000 characters,
structured post sections, and current lifecycle
(fm_ext_decision_key_occurrence in bin/fm-ext-lib.sh), so a post can only ever
answer that lifecycle.

CHOICE GUARD. An answer, followup, or final whose text has the shape of a choice is refused unless --decision-file is given, so a choice cannot go out as plain text by mistake.
Detection is case-insensitive, cues match within one line, and it uses these rules:
  option line - a line, after optional indent and a "-", "*", or bullet marker, that starts with a 1-2 digit number ended by ".", ")", or ":" and optionally prefixed by "(" ("1." / "2)" / "(3)"), or a code of one letter plus 1-2 digits with an optional ".", ")", or ":" ("A1." / "B2:" / "B2 text"); the token must be followed by whitespace and non-whitespace text, or immediately by the end of the line.
  choice cue - "reply/respond/answer/type/send/pick/choose/select/tap/click", 1-40 intervening characters, then "number/code/letter/option/choice" (singular or plural), without crossing ".", "!", or "?", or "which one", "which option(s)", "pick one", or "choose one"; cue words match whole words.
    Letters, digits, and underscore are word characters; intervening spans start and end with non-word characters, and fixed phrases use single spaces.
  digit cue - a reply, pick, choose, select, or say verb followed within 1-40 characters by at least two 1-2 digit numbers joined by commas, "/", or "or" (for example "1, 2 or 3", "2 or 3", "1/2", or "1 or 2").
  choice heading - a standalone "Option" or "Options" heading, "Your call" heading, or "Choose" heading, optionally wrapped in Markdown bold ("**" or "__") and optionally followed by a colon inside or outside the closing bold marker; two or more option lines must follow it.
Digit cues use the same word boundaries and intervening-span punctuation restrictions as choice cues.
Two or more option lines plus a choice cue are refused, as is a digit cue by itself.
A standalone reply cue also suffices: "reply", 1-30 intervening characters, "with/using", 1-20 intervening characters, then "number/code/letter/option/choice" (singular or plural), with the same word boundaries and punctuation restrictions.
Numbered lists without a choice cue post normally, except when a choice heading is followed by two or more option lines.
--no-buttons "<reason>" posts a deliberate plain list anyway: the reason (one line, at most 200 characters) is recorded in the outbox payload as no_buttons_reason.
It cannot be combined with --decision-file.
The guard never checks an ack.
Discord text is untrusted: pass it with --text-file or stdin.
CLI scenarios verified: valid and 60-character retitles queued; invalid retitles warned and omitted; reply text retained; both flags accepted; invalid plain title refused.
Evidence: Real choice CLI: retitle passthrough and rejection
$ fm-ext-ask.sh ['--request-id', 'discord:111111111111111111:222222222222222222:777777777777777777:444444444444444444', '--kind', 'followup', '--generation', '10', '--task', 'route-question', '--title', 'Choose the next route', '--reason', 'route pending', '--mode', 'done', '--text-file', '-', '--option', 'North', '--option', 'South', '--thread-retitle', 'Firstmate · route decision']
exit: 0
4533fb69f17e3427a002f9a156f7b45e8348eb6f040bf1e9ff0147db71b60c7a

route-question

Persisted choice payload: {"schema_version": 1, "request_id": "discord:111111111111111111:222222222222222222:777777777777777777:444444444444444444", "slug": "4533fb69f17e3427a002f9a156f7b45e8348eb6f040bf1e9ff0147db71b60c7a", "kind": "followup", "generation": 10, "platform": "discord", "source": "hermes-gateway", "guild_id": "111111111111111111", "channel_id": "222222222222222222", "thread_id": "777777777777777777", "message_id": "444444444444444444", "origin": "message", "text": "Which direction should we take?\n\n1. North\n2. South\nTap a button, or reply with the number.", "recorded_at": 1792342234, "thread_retitle": "Firstmate · route decision", "thread_state": "needs-you", "decision": {"key": "hold:route-question", "mode": "done", "occurrence": "2026-10-18T16:50:30Z#0", "sections": {"question": "Which direction should we take?", "explanation": "", "options": "1. North\n2. South", "cue": "Tap a button, or reply with the number.", "structured": true}, "options": [{"id": 1, "label": "North", "confirm": false}, {"id": 2, "label": "South", "confirm": false}], "text": "Which direction should we take?\n\n1. North\n2. South\nTap a button, or reply with the number."}}
$ fm-ext-ask.sh ['--request-id', 'discord:111111111111111111:222222222222222222:777777777777777777:444444444444444444', '--kind', 'followup', '--generation', '11', '--task', 'route-question', '--title', 'Choose the next route', '--reason', 'route pending', '--mode', 'done', '--text-file', '-', '--option', 'North', '--option', 'South', '--thread-retitle', 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA']
exit: 0
4533fb69f17e3427a002f9a156f7b45e8348eb6f040bf1e9ff0147db71b60c7a

route-question
fm-ext-emit: warning: thread retitle ignored: title must be at most 60 characters

Persisted choice payload: {"schema_version": 1, "request_id": "discord:111111111111111111:222222222222222222:777777777777777777:444444444444444444", "slug": "4533fb69f17e3427a002f9a156f7b45e8348eb6f040bf1e9ff0147db71b60c7a", "kind": "followup", "generation": 11, "platform": "discord", "source": "hermes-gateway", "guild_id": "111111111111111111", "channel_id": "222222222222222222", "thread_id": "777777777777777777", "message_id": "444444444444444444", "origin": "message", "text": "Which direction should we take?\n\n1. North\n2. South\nTap a button, or reply with the number.", "recorded_at": 1792342241, "thread_state": "needs-you", "decision": {"key": "hold:route-question", "mode": "done", "occurrence": "2026-10-18T16:50:30Z#0", "sections": {"question": "Which direction should we take?", "explanation": "", "options": "1. North\n2. South", "cue": "Tap a button, or reply with the number.", "structured": true}, "options": [{"id": 1, "label": "North", "confirm": false}, {"id": 2, "label": "South", "confirm": false}], "text": "Which direction should we take?\n\n1. North\n2. South\nTap a button, or reply with the number."}}
$ fm-ext-ask.sh ['--request-id', 'discord:111111111111111111:222222222222222222:777777777777777777:444444444444444444', '--kind', 'followup', '--generation', '12', '--task', 'route-question', '--title', 'Choose the next route', '--reason', 'route pending', '--mode', 'done', '--text-file', '-', '--option', 'North', '--option', 'South', '--thread-retitle', 'two\nlines']
exit: 0
4533fb69f17e3427a002f9a156f7b45e8348eb6f040bf1e9ff0147db71b60c7a

route-question
fm-ext-emit: warning: thread retitle ignored: title must be one line

Persisted choice payload: {"schema_version": 1, "request_id": "discord:111111111111111111:222222222222222222:777777777777777777:444444444444444444", "slug": "4533fb69f17e3427a002f9a156f7b45e8348eb6f040bf1e9ff0147db71b60c7a", "kind": "followup", "generation": 12, "platform": "discord", "source": "hermes-gateway", "guild_id": "111111111111111111", "channel_id": "222222222222222222", "thread_id": "777777777777777777", "message_id": "444444444444444444", "origin": "message", "text": "Which direction should we take?\n\n1. North\n2. South\nTap a button, or reply with the number.", "recorded_at": 1792342247, "thread_state": "needs-you", "decision": {"key": "hold:route-question", "mode": "done", "occurrence": "2026-10-18T16:50:30Z#0", "sections": {"question": "Which direction should we take?", "explanation": "", "options": "1. North\n2. South", "cue": "Tap a button, or reply with the number.", "structured": true}, "options": [{"id": 1, "label": "North", "confirm": false}, {"id": 2, "label": "South", "confirm": false}], "text": "Which direction should we take?\n\n1. North\n2. South\nTap a button, or reply with the number."}}
$ fm-ext-ask.sh ['--request-id', 'discord:111111111111111111:222222222222222222:777777777777777777:444444444444444444', '--kind', 'followup', '--generation', '13', '--task', 'route-question', '--title', 'Choose the next route', '--reason', 'route pending', '--mode', 'done', '--text-file', '-', '--option', 'North', '--option', 'South', '--thread-retitle', 'fm-ship-login now']
exit: 0
4533fb69f17e3427a002f9a156f7b45e8348eb6f040bf1e9ff0147db71b60c7a

route-question
fm-ext-emit: warning: thread retitle ignored: title failed the safety screen

Persisted choice payload: {"schema_version": 1, "request_id": "discord:111111111111111111:222222222222222222:777777777777777777:444444444444444444", "slug": "4533fb69f17e3427a002f9a156f7b45e8348eb6f040bf1e9ff0147db71b60c7a", "kind": "followup", "generation": 13, "platform": "discord", "source": "hermes-gateway", "guild_id": "111111111111111111", "channel_id": "222222222222222222", "thread_id": "777777777777777777", "message_id": "444444444444444444", "origin": "message", "text": "Which direction should we take?\n\n1. North\n2. South\nTap a button, or reply with the number.", "recorded_at": 1792342255, "thread_state": "needs-you", "decision": {"key": "hold:route-question", "mode": "done", "occurrence": "2026-10-18T16:50:30Z#0", "sections": {"question": "Which direction should we take?", "explanation": "", "options": "1. North\n2. South", "cue": "Tap a button, or reply with the number.", "structured": true}, "options": [{"id": 1, "label": "North", "confirm": false}, {"id": 2, "label": "South", "confirm": false}], "text": "Which direction should we take?\n\n1. North\n2. South\nTap a button, or reply with the number."}}
Ask CLI scenarios passed with real tasks-axi and isolated backlog; no injected commands.
Evidence: Supplemental HTTP simulation—NOT live Discord evidence
Supplemental HTTP simulation: real Firstmate CLI, poster and HTTP transport; local endpoint stands in for Discord. This is NOT live Discord evidence.
HTTP ["POST", "/api/v10/channels/222222222222222222/messages/444444444444444444/threads", {"name": "Firstmate · original", "auto_archive_duration": 1440}, 200, {"id": "777777777777777777"}]
HTTP ["POST", "/api/v10/channels/777777777777777777/messages", {"content": "Initial reply."}, 200, {"id": "900000000000000001"}]
HTTP ["PATCH", "/api/v10/channels/777777777777777777", {"name": "❓ Firstmate · original"}, 200, {"id": "777777777777777777", "name": "❓ Firstmate · original"}]
DELIVERY {"result": "sent", "reply": "Initial reply.", "shown": "❓ Firstmate · original", "record": {"request_id": "discord:111111111111111111:222222222222222222:222222222222222222:444444444444444444", "topic_thread_id": "777777777777777777", "topic_name": "❓ Firstmate · original", "thread_state": "needs-you", "creating_title": "Firstmate · original", "creator": "firstmate", "state_at": 1792342173.0, "rename": {"name": "❓ Firstmate · original", "at": 1792342175.5147007, "outcome": "applied"}}}
HTTP ["POST", "/api/v10/channels/777777777777777777/messages", {"content": "Conversation drifted."}, 200, {"id": "900000000000000002"}]
HTTP ["GET", "/api/v10/channels/777777777777777777", {}, 200, {"id": "777777777777777777", "name": "❓ Firstmate · original", "owner_id": "123456789012345678"}]
HTTP ["PATCH", "/api/v10/channels/777777777777777777", {"name": "❓ Firstmate · migration"}, 200, {"id": "777777777777777777", "name": "❓ Firstmate · migration"}]
DELIVERY {"result": "sent", "reply": "Conversation drifted.", "shown": "❓ Firstmate · migration", "record": {"request_id": "discord:111111111111111111:222222222222222222:222222222222222222:444444444444444444", "topic_thread_id": "777777777777777777", "topic_name": "❓ Firstmate · migration", "thread_state": "needs-you", "creating_title": "Firstmate · original", "creator": "firstmate", "retitle": "Firstmate · migration", "state_at": 1792342173.0, "rename": {"name": "❓ Firstmate · migration", "at": 1792342177.1166418, "outcome": "applied"}}}
HTTP ["POST", "/api/v10/channels/777777777777777777/messages", {"content": "Completed."}, 200, {"id": "900000000000000003"}]
HTTP ["PATCH", "/api/v10/channels/777777777777777777", {"name": "✅ Firstmate · migration"}, 200, {"id": "777777777777777777", "name": "✅ Firstmate · migration"}]
DELIVERY {"result": "sent", "reply": "Completed.", "shown": "✅ Firstmate · migration", "record": {"request_id": "discord:111111111111111111:222222222222222222:222222222222222222:444444444444444444", "topic_thread_id": "777777777777777777", "topic_name": "✅ Firstmate · migration", "thread_state": "done", "creating_title": "Firstmate · original", "creator": "firstmate", "retitle": "Firstmate · migration", "state_at": 1792342177.0, "rename": {"name": "✅ Firstmate · migration", "at": 1792342178.8092976, "outcome": "applied"}}}
HTTP ["POST", "/api/v10/channels/777777777777777777/messages", {"content": "Plain later title."}, 200, {"id": "900000000000000004"}]
DELIVERY {"result": "sent", "reply": "Plain later title.", "shown": "✅ Firstmate · migration", "record": {"request_id": "discord:111111111111111111:222222222222222222:222222222222222222:444444444444444444", "topic_thread_id": "777777777777777777", "topic_name": "✅ Firstmate · migration", "thread_state": "done", "creating_title": "Firstmate · original", "creator": "firstmate", "retitle": "Firstmate · migration", "state_at": 1792342177.0, "rename": {"name": "✅ Firstmate · migration", "at": 1792342178.8092976, "outcome": "applied"}}}
HTTP ["POST", "/api/v10/channels/777777777777777777/messages", {"content": "Captain cleared marker."}, 200, {"id": "900000000000000005"}]
HTTP ["GET", "/api/v10/channels/777777777777777777", {}, 200, {"id": "777777777777777777", "name": "Firstmate · migration", "owner_id": "123456789012345678"}]
HTTP ["PATCH", "/api/v10/channels/777777777777777777", {"name": "Firstmate · ledger"}, 200, {"id": "777777777777777777", "name": "Firstmate · ledger"}]
DELIVERY {"result": "sent", "reply": "Captain cleared marker.", "shown": "Firstmate · ledger", "record": {"request_id": "discord:111111111111111111:222222222222222222:222222222222222222:444444444444444444", "topic_thread_id": "777777777777777777", "topic_name": "Firstmate · ledger", "thread_state": "none", "creating_title": "Firstmate · original", "creator": "firstmate", "retitle": "Firstmate · ledger", "state_at": 1792342177.0, "rename": {"name": "Firstmate · ledger", "at": 1792342181.2155778, "outcome": "applied"}}}
HTTP ["POST", "/api/v10/channels/777777777777777777/messages", {"content": "Explicit state wins."}, 200, {"id": "900000000000000006"}]
HTTP ["PATCH", "/api/v10/channels/777777777777777777", {"name": "❓ Firstmate · accounting"}, 200, {"id": "777777777777777777", "name": "❓ Firstmate · accounting"}]
DELIVERY {"result": "sent", "reply": "Explicit state wins.", "shown": "❓ Firstmate · accounting", "record": {"request_id": "discord:111111111111111111:222222222222222222:222222222222222222:444444444444444444", "topic_thread_id": "777777777777777777", "topic_name": "❓ Firstmate · accounting", "thread_state": "needs-you", "creating_title": "Firstmate · original", "creator": "firstmate", "retitle": "Firstmate · accounting", "state_at": 1792342181.0, "rename": {"name": "❓ Firstmate · accounting", "at": 1792342182.397973, "outcome": "applied"}}}
HTTP ["POST", "/api/v10/channels/777777777777777777/messages", {"content": "Read unavailable."}, 200, {"id": "900000000000000007"}]
HTTP ["GET", "/api/v10/channels/777777777777777777", {}, 503, {}]
HTTP ["PATCH", "/api/v10/channels/777777777777777777", {"name": "❓ Firstmate · fallback"}, 200, {"id": "777777777777777777", "name": "❓ Firstmate · fallback"}]
DELIVERY {"result": "sent", "reply": "Read unavailable.", "shown": "❓ Firstmate · fallback", "record": {"request_id": "discord:111111111111111111:222222222222222222:222222222222222222:444444444444444444", "topic_thread_id": "777777777777777777", "topic_name": "❓ Firstmate · fallback", "thread_state": "needs-you", "creating_title": "Firstmate · original", "creator": "firstmate", "retitle": "Firstmate · fallback", "state_at": 1792342181.0, "rename": {"name": "❓ Firstmate · fallback", "at": 1792342183.5209565, "outcome": "applied"}}}
HTTP ["POST", "/api/v10/channels/777777777777777777/messages", {"content": "Rename failure leaves reply delivered."}, 200, {"id": "900000000000000008"}]
HTTP ["GET", "/api/v10/channels/777777777777777777", {}, 200, {"id": "777777777777777777", "name": "❓ Firstmate · fallback", "owner_id": "123456789012345678"}]
HTTP ["PATCH", "/api/v10/channels/777777777777777777", {"name": "❓ Firstmate · retry"}, 503, {"message": "disposable endpoint transient failure"}]
fm-ext-poster: thread 777777777777777777 rename failed: discord HTTP 503
DELIVERY {"result": "sent", "reply": "Rename failure leaves reply delivered.", "shown": "❓ Firstmate · fallback", "record": {"request_id": "discord:111111111111111111:222222222222222222:222222222222222222:444444444444444444", "topic_thread_id": "777777777777777777", "topic_name": "❓ Firstmate · fallback", "thread_state": "needs-you", "creating_title": "Firstmate · original", "creator": "firstmate", "retitle": "Firstmate · retry", "state_at": 1792342181.0, "rename": {"name": "❓ Firstmate · retry", "at": 1792342185.04617, "outcome": "failed", "error": "discord HTTP 503", "retryable": true, "attempts": 1}}}
HTTP ["PATCH", "/api/v10/channels/777777777777777777", {"name": "❓ Firstmate · retry"}, 200, {"id": "777777777777777777", "name": "❓ Firstmate · retry"}]
RECONCILED {"request_id": "discord:111111111111111111:222222222222222222:222222222222222222:444444444444444444", "topic_thread_id": "777777777777777777", "topic_name": "\u2753 Firstmate \u00b7 retry", "thread_state": "needs-you", "creating_title": "Firstmate \u00b7 original", "creator": "firstmate", "retitle": "Firstmate \u00b7 retry", "state_at": 1792342181.0, "rename": {"name": "\u2753 Firstmate \u00b7 retry", "at": 1792342185.1643677, "outcome": "applied"}}
fm-ext-emit: warning: thread retitle ignored: title must be at most 60 characters
HTTP ["POST", "/api/v10/channels/777777777777777777/messages", {"content": "Rejected title still delivers."}, 200, {"id": "900000000000000009"}]
DELIVERY {"result": "sent", "reply": "Rejected title still delivers.", "shown": "❓ Firstmate · retry", "record": {"request_id": "discord:111111111111111111:222222222222222222:222222222222222222:444444444444444444", "topic_thread_id": "777777777777777777", "topic_name": "❓ Firstmate · retry", "thread_state": "needs-you", "creating_title": "Firstmate · original", "creator": "firstmate", "retitle": "Firstmate · retry", "state_at": 1792342181.0, "rename": {"name": "❓ Firstmate · retry", "at": 1792342185.1643677, "outcome": "applied"}}}
fm-ext-emit: warning: thread retitle ignored: title must be one line
HTTP ["POST", "/api/v10/channels/777777777777777777/messages", {"content": "Rejected title still delivers."}, 200, {"id": "900000000000000010"}]
DELIVERY {"result": "sent", "reply": "Rejected title still delivers.", "shown": "❓ Firstmate · retry", "record": {"request_id": "discord:111111111111111111:222222222222222222:222222222222222222:444444444444444444", "topic_thread_id": "777777777777777777", "topic_name": "❓ Firstmate · retry", "thread_state": "needs-you", "creating_title": "Firstmate · original", "creator": "firstmate", "retitle": "Firstmate · retry", "state_at": 1792342181.0, "rename": {"name": "❓ Firstmate · retry", "at": 1792342185.1643677, "outcome": "applied"}}}
fm-ext-emit: warning: thread retitle ignored: title failed the safety screen
HTTP ["POST", "/api/v10/channels/777777777777777777/messages", {"content": "Rejected title still delivers."}, 200, {"id": "900000000000000011"}]
DELIVERY {"result": "sent", "reply": "Rejected title still delivers.", "shown": "❓ Firstmate · retry", "record": {"request_id": "discord:111111111111111111:222222222222222222:222222222222222222:444444444444444444", "topic_thread_id": "777777777777777777", "topic_name": "❓ Firstmate · retry", "thread_state": "needs-you", "creating_title": "Firstmate · original", "creator": "firstmate", "retitle": "Firstmate · retry", "state_at": 1792342181.0, "rename": {"name": "❓ Firstmate · retry", "at": 1792342185.1643677, "outcome": "applied"}}}
HTTP ["POST", "/api/v10/channels/888888888888888888/messages", {"content": "Captain-made thread still receives reply."}, 200, {"id": "900000000000000012"}]
DELIVERY {"result": "sent", "reply": "Captain-made thread still receives reply.", "shown": "❓ Firstmate · retry", "record": {"request_id": "discord:111111111111111111:222222222222222222:222222222222222222:444444444444444444", "topic_thread_id": "777777777777777777", "topic_name": "❓ Firstmate · retry", "thread_state": "needs-you", "creating_title": "Firstmate · original", "creator": "firstmate", "retitle": "Firstmate · retry", "state_at": 1792342181.0, "rename": {"name": "❓ Firstmate · retry", "at": 1792342185.1643677, "outcome": "applied"}}}
HTTP ["POST", "/api/v10/channels/777777777777777777/messages", {"content": "Aged-out thread still receives reply."}, 200, {"id": "900000000000000013"}]
DELIVERY {"result": "sent", "reply": "Aged-out thread still receives reply.", "shown": "❓ Firstmate · retry", "record": null}
HTTP ["POST", "/api/v10/channels/777777777777777777/messages", {"content": "Other-channel reply still arrives."}, 200, {"id": "900000000000000014"}]
DELIVERY {"result": "sent", "reply": "Other-channel reply still arrives.", "shown": "❓ Firstmate · retry", "record": null}
HTTP simulation completed; all assertions passed. Actual Discord acceptance and rendered titles remain untested.
- Outcome: ⚠️ 2 warnings across 1 run (5m38s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 3 issues found → auto-fixed ✅
  • 🚨 bin/fm-ext-emit.sh:161 - AC2 requires that titles failing the shared screen be refused or ignored safely while “the reply text still posts.” This hunk instead uses || die, so a safe reply carrying a 61-character or multiline retitle exits before publication. The ask passthrough inherits this at bin/fm-ext-ask.sh:193, and tests/fm-ext-topic-threads.test.sh:1944 explicitly asserts that nothing queues. Ignore the rejected retitle and continue publishing the reply; update those assertions to match AC2.
  • 🚨 contrib/hermes-gateway-firstmate-comms/outbox_poster.py:1168 - AC1 requires retitling while “keeping any current marker prefix,” but this hunk selects recorded.get(&#34;thread_state&#34;) rather than the marker Discord currently shows. If a captain manually clears ❓ before the next retitle-only reply, the retitle restores ❓. The read in apply_thread_state only runs when the entire wanted name already equals the stored name, so it cannot correct this case. Preserve the live marker for retitle-only operations, with a safe fallback when the live name cannot be read.
  • ⚠️ bin/fm-ext-emit.sh:158 - Simplification: the unconditional mutual-exclusion branch rejects both flags even for an existing thread, although the intent only requires plain later --thread-title to retain its no-replace meaning. No requirement needs this additional rejection path. Remove the prohibition and let the explicit retitle determine the existing thread's new base. Also remove the corresponding restriction in bin/fm-ext-emit.sh:25 and rejection assertion at tests/fm-ext-topic-threads.test.sh:1947.

🔧 Fix applied.
✅ Re-checked - no issues remain.

⚠️ **Test** - 2 warnings
  • ⚠️ The central Discord outcome remains unverified. Real CLI checks and a local HTTP simulation passed, but actual Discord renaming requires a disposable bot token and guild/channel permissions unavailable in this run. Provide an authorized sandbox bot and guild to complete live validation and capture the rendered thread title.
  • ⚠️ live validation verdict: inconclusive (3 of 7 scenarios were driven live against the product); untested: Retitle an existing Firstmate thread; Discord preserves its marker and later status changes use the new base., Manually clear a Discord marker before retitling; it stays cleared, while an explicit status overrides it and an unreadable name uses recorded status., Request retitles for captain-made threads, other channels, DMs, or aged-out records; replies post without unauthorized renaming., Encounter a rename failure; the reply remains delivered and reconciliation retries the new base.
  • Live validation: ⚠️ inconclusive - 3 of 7 scenarios driven live against the product
Scenario Result Live Evidence
Emit a valid retitle, including a 60-character title and both title flags; the outbox preserves the explicit retitle and reply. ✅ pass live cli-transcript.log
Emit unsafe, multiline, or oversized retitles through emit and ask; warnings explain rejection while replies remain queued without a retitle. ✅ pass live cli-transcript.log; ask-cli-transcript.log
Ask a choice with a valid retitle; the persisted choice retains its options, needs-you status, and retitle. ✅ pass live ask-cli-transcript.log
Retitle an existing Firstmate thread; Discord preserves its marker and later status changes use the new base. ⏸️ untested no Passed against a disposable HTTP simulator using the real CLI, poster, and transport. Actual Discord requires a sandbox bot token and guild/channel permissions not supplied here; creating a local endp…
Manually clear a Discord marker before retitling; it stays cleared, while an explicit status overrides it and an unreadable name uses recorded status. ⏸️ untested no All cases passed in simulation. A real manual Discord edit and subsequent rename require an authorized sandbox bot/guild and Discord access, which are unavailable. Provide those credentials and permis…
Request retitles for captain-made threads, other channels, DMs, or aged-out records; replies post without unauthorized renaming. ⏸️ untested no Targeted tests and the local HTTP simulation exercised eligibility boundaries, but no real Discord destinations were available. Disposable local records cannot prove Discord delivery. Provide an autho…
Encounter a rename failure; the reply remains delivered and reconciliation retries the new base. ⏸️ untested no A simulated HTTP 503 preserved the reply receipt and reconciliation successfully retried. Actual Discord failure/recovery remains untested because no authorized sandbox token or guild is available. Pr…
  • TMPDIR=&#34;$PWD/.test-phase/tmp&#34; bash tests/fm-ext-topic-threads.test.sh — passed.
  • TMPDIR=&#34;$PWD/.test-phase/tmp&#34; bash tests/fm-ext-ask.test.sh — passed.
  • python3 .test-phase/drive.py — drove real intake/emit commands against an isolated home and checked persisted payloads and warnings.
  • python3 .test-phase/ask-drive.py — drove real ask commands with real tasks-axi and an isolated backlog.
  • PYTHONDONTWRITEBYTECODE=1 python3 .test-phase/http-drive.py — exercised real poster HTTP transport against a disposable Discord simulator.
  • Reviewed emitted CLI help and changed documentation/skill contracts for consistency.
  • Removed disposable homes, scripts, temporary data, and generated Python caches; verified clean git status --short.
✅ **Document** - passed

✅ No issues found.

🔧 **Lint** - 1 issue found → auto-fixed ✅
  • ⚠️ linter found issues (exit code 1)

🔧 Fix applied.
✅ Re-checked - no issues remain.

✅ **Push** - passed

✅ No issues found.

dnth added 4 commits October 18, 2026 23:37
A thread's base name was fixed by the reply that created it, so a topic that
drifted from the first message kept a stale name. bin/fm-ext-emit.sh now takes
--thread-retitle, and bin/fm-ext-ask.sh passes it through, so any reply into an
existing Firstmate-created topic thread can rename it.

The new title passes the same screen and bounds as a creating title and becomes
the thread's stored base name, keeping whatever ❓ / ✅ marker the thread wears,
so every later marker, auto-clear, and reconciliation builds on the new name. A
plain later --thread-title still replaces nothing. The retitle cannot ride the
reply that creates the thread.

The poster screens the retitle again before recording it and keeps marker
eligibility: only a thread this home created in the dedicated channel, never a
captain-made thread, another channel, a DM, or an aged-out record. It stays best
effort, so a refused, unusable, or ineligible retitle never blocks the reply
text, and a failed rename is retried by the next reply and the periodic sweep.

Post-merge deployment note: restart the hermes-gateway-fmcomms user unit so the
gateway poster runs the new outbox_poster.py.

Tests: tests/fm-ext-topic-threads.test.sh gains retitle coverage (rename,
marker preservation, later markers, screen refusals, ineligible threads, retry,
opened threads) and tests/fm-ext-ask.test.sh covers the ask passthrough.
@dnth
dnth merged commit b834d4a into main Oct 19, 2026
18 checks passed
@dnth
dnth deleted the fm/fm-ext-thread-retitle branch October 19, 2026 00:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant