/** * TicketIntakeService — the single intake point of the ticket loop * (specs/ticket-loop.md, Phase 1 §1). * * Every surface the owner talks on (Slack team channels and shared rooms, * Slack agent DMs, chat-v2 — which covers the dashboard, portal and phone — * and the legacy chat + legacy Slack bridge) hands its inbound owner message * to {@link TicketIntakeService.intake}. The service decides, with one shared * gate, whether the message: * * - opens a new ticket (a `Request` with a TKT number, `kind`, `origin`, * `assignee`) and posts ONE receipt where it was said; * - is a reply in a thread that already has a ticket — open, waiting for the * owner, or finished within {@link TICKET_CONSTANTS.FOLLOW_UP.RECENT_TICKET_MS}: * a follow-up, an answer or a decision is appended to its discussion (and * on a 待验收 ticket counts as the owner's OK); only a genuinely new ask * opens its own ticket in the same thread, linked to the parent (#827, * {@link isGenuinelyNewAsk}); * - is a DM reply soon after the conversation's latest ticket * ({@link TICKET_CONSTANTS.FOLLOW_UP.DM_WINDOW_MS}): the same, for * conversations where every message is top level; * - is a reply in a thread an agent started (no ticket): an answer to the * agent, ignored unless it is a genuinely new ask (2026-09-28: 「发了」 * 「A 论文那个 开个Issue吧」 had become tickets of their own); * - is "不用记" (don't track) and dismisses the ticket it answers; * - is noise (trivial ack, file only, a status ping, agent-authored) and is * ignored. Request-phrased questions ("可以去研究一下 X 吗") are asks, and a * pure information question opens a lightweight `question` ticket (#827). * * Creation goes through {@link RequestService.create}, so `request:created` * still fires and the decompose + SLA subscribers keep working unchanged. * * @module services/v3/ticket-intake.service */ import { weightedTextLength, type AskClassification } from './ticket-ask-classifier.js'; import type { IntakeOutcomeRecorder } from './ticket-intake-log.js'; import { type Request, type RequestStatus, type TicketAcceptedBy } from '../../types/v2/request.types.js'; import { type TicketOrigin, type TicketReceipt, type TicketKind, type TicketBoardColumn, type TicketPriorityLabel, type BoardWorkItemView, type TicketAcceptance, type TicketReply } from '../../types/v2/ticket.types.js'; export { weightedTextLength }; /** * Gate 1 — trivial acknowledgement or too short to be a request. * * @param rawText - Message text * @returns `'trivial_or_short'` when suppressed, else null */ export declare function suppressTrivialOrShort(rawText: string): string | null; /** * Gate 3 — only file references (or only attachments), no narrative. * * @param rawText - Message text (may carry `[Slack File: …]` lines) * @param hasAttachments - Whether the message carried files * @returns `'file_only'` when suppressed, else null */ export declare function suppressFileOnly(rawText: string, hasAttachments?: boolean): string | null; /** * Text a ticket title is made from: Slack's raw `<@U0C2ZK849ND>` mention codes * and `` links read as noise on the board (2026-09-24: * "[Message] <@U0C2ZK849ND> 看看这个"), so mentions go and links keep their * label (or the bare URL). * * @param text - Message text * @returns Cleaned text (the ticket description keeps the original) */ export declare function titleText(text: string): string; /** * Whether a message is the "don't track" action. * * @param text - Message text * @returns True for "不用记" and its variants */ export declare function isDismissText(text: string): boolean; /** * Whether a reply in an existing conversation (a ticket's thread, an agent's * thread, a DM right after a ticket) is a genuinely new ask that deserves its * own ticket (2026-09-28). Stricter than the classifier's `new_ask` alone: * * - a question stays in the conversation (the agent answers it there); * - a short reply (≤ {@link TICKET_CONSTANTS.FOLLOW_UP.SHORT_REPLY_WEIGHTED_LENGTH} * weighted) is an answer — 「可以改到10:30吗」 — unless it says 「帮我…」 * outright or opens a new topic. * * @param text - The owner's message * @param ask - Its in-thread classification * @returns True when it opens a ticket of its own * * @example * ```typescript * isGenuinelyNewAsk('A 论文那个 开个Issue吧 放到backlog', classifyOwnerMessage(t, { inThread: true })); // false * ``` */ export declare function isGenuinelyNewAsk(text: string, ask: AskClassification): boolean; /** * The topic words of a chase message, or null for a bare ping ("进度呢", * "在吗", "status?") that names nothing. * * @param text - The owner's message * @returns Latin tokens (lowercase) and CJK bigrams, or null when no topic */ export declare function chaseTopic(text: string): { latin: string[]; cjk: string[]; } | null; /** * How well a ticket matches a chase topic: Latin word hits count double * (a name like "AZ" is far more specific than a CJK bigram). * * @param topic - From {@link chaseTopic} * @param ticket - Candidate ticket * @returns Score; a match needs at least 2 */ export declare function chaseMatchScore(topic: { latin: string[]; cjk: string[]; }, ticket: Pick): number; /** Where a receipt should be posted. */ export type ReceiptTarget = { kind: 'slack'; slackChannelId: string; /** Thread to reply in (the message's thread root, or the message itself) */ threadTs: string; /** Agent whose bot posts it (DMs, private rooms); absent = workspace bot */ postAs?: string; /** * The owner's own message. When set, the receipt is a 🎫 reaction on it * instead of a thread reply: a reply per ticket was one more message * (and notification) in every thread, next to the agent's own "working * on it" (owner, 2026-09-24: 「每次都要问一下会不会很麻烦」). */ messageTs?: string; } | { kind: 'chat-v2'; chatChannelId: string; /** Thread root the receipt goes under */ threadId?: string; }; /** One inbound message handed to intake. */ export interface IntakeMessage { /** Message text as the owner wrote it (file references allowed) */ text: string; /** Where and by whom */ origin: TicketOrigin; /** Attached files, when any */ attachments?: ReadonlyArray<{ name?: string; }>; /** True only for the owner's own words — agent-authored text never opens a ticket */ isOwner: boolean; /** The agent the message was addressed to (becomes `assignee`) */ targetAgent?: string; /** * Conversation the message belongs to (a Slack channel, a chat-v2 channel). * A top-level "不用记" dismisses the latest open ticket from it. Must be the * prefix of `origin.threadRef` (`:`). */ conversationRef?: string; /** * sourceConversationItemId of the thread root as older (pre-ticket) code * stored it, so a reply in a thread opened before the ticket loop still * counts as a continuation. */ legacyThreadParentRef?: string; /** Extra tags (e.g. `slack` / `chat-v2` for the SLA subscriber) */ tags?: readonly string[]; /** * The message is a reply inside a thread (not the thread's root). A reply * in a thread with no ticket answers whoever started it — an agent. */ isThreadReply?: boolean; /** * With `isThreadReply`: the thread's root is the OWNER's own message (not an * agent's brief). If that root opened no ticket (intake missed it, or it * read as a pure question), the owner's replies are still asks — they must * not be dropped as answers to an agent. */ threadRootIsOwner?: boolean; /** Where to post the receipt; omitted = no receipt */ receipt?: ReceiptTarget; } /** What intake did with a message. */ export type IntakeOutcome = { action: 'created'; ticket: Request; } /** A new ask in another ticket's thread: its own ticket, linked to that one (#827) */ | { action: 'created_in_thread'; ticket: Request; parent: Request; } | { action: 'appended'; ticket: Request; } | { action: 'duplicate'; ticket: Request; } | { action: 'dismissed'; ticket: Request; } /** 验过了 on a 待验收 ticket (Phase 2) */ | { action: 'verified'; ticket: Request; } /** 打回 on a 待验收 ticket (Phase 2) */ | { action: 'rejected'; ticket: Request; } | { action: 'ignored'; reason: string; }; /** The review actions intake hands 验过了 / 打回 / follow-ups to (TicketReviewService). */ export interface IntakeReviewHandler { verify(ref: string): Promise<{ ok: boolean; ticket?: Request; }>; reject(ref: string, reason: string, via: 'thread'): Promise<{ ok: boolean; ticket?: Request; }>; reopenOnFollowUp(ticketId: string): Promise; } /** Posts and edits receipts on one kind of surface. */ export interface TicketReceiptSink { /** * Post the receipt for a new ticket. * * @param ticket - The ticket * @param target - Where to post * @returns Where it landed, or null when it could not be posted */ post(ticket: Request, target: ReceiptTarget): Promise; /** * Change an existing receipt to "no longer tracked". * * @param ticket - The dismissed ticket * @param receipt - The receipt to edit */ markDismissed(ticket: Request, receipt: TicketReceipt): Promise; /** * Change an existing receipt to "done" (Phase 2). * * @param ticket - The accepted ticket * @param receipt - The receipt to edit */ markDone?(ticket: Request, receipt: TicketReceipt): Promise; } /** RequestService surface the intake needs (narrow for tests). */ export interface TicketRequestStore { create(input: Parameters[0]): Promise; getById(id: string): Promise; listAll(): Promise; update(id: string, updates: Parameters[1]): Promise; getRequestsDir(): string; } /** Board-shaped ticket row returned by the API. */ export interface TicketListItem { id: string; tkt: string | null; ticketNumber: number | null; title: string; description: string; kind: TicketKind; column: TicketBoardColumn; status: RequestStatus; priority: Request['priority']; priorityLabel: TicketPriorityLabel; origin: TicketOrigin | null; assignee: string | null; workItemIds: string[]; tags: string[]; createdAt: string; updatedAt: string; /** Live acceptance criteria (removed ones left out) */ acceptance: TicketAcceptance[]; /** Start of the latest agent answer */ reply: TicketReply | null; rejectCount: number; submitCount: number; /** When it last went to 待验收 */ submittedAt: string | null; completedAt: string | null; /** When silence will accept it (待验收 only) */ autoAcceptAt: string | null; /** * How a done ticket was accepted (#813): `owner` reviewed it, `silence` = * nobody objected in time. Null while open. Older tickets fall back to the * `auto_accepted` tag. */ acceptedBy: TicketAcceptedBy | null; } /** Filters for {@link TicketIntakeService.list}. */ export interface TicketListQuery { column?: TicketBoardColumn; kind?: TicketKind; /** Case-insensitive search over TKT, title and description */ q?: string; /** Include Requests from before the ticket loop (no TKT number) */ includeLegacy?: boolean; } /** Input for {@link TicketIntakeService.split}. */ export interface SplitTicketInput { /** * `ref` of a discussion entry to move out into the new ticket — the usual * case: a new ask was appended to the wrong ticket. Its text becomes the * new ticket's description. */ discussionRef?: string; /** Text of the new ask (required when no `discussionRef`) */ text?: string; /** Title override (default: made from the text, as for any ticket) */ title?: string; /** Who takes it (default: the source ticket's assignee) */ assignee?: string; /** A pure information question: kind `question`, no acceptance step */ question?: boolean; /** Who asked for the split (agent session), for the log */ by?: string; } /** Result of {@link TicketIntakeService.split}. */ export type SplitResult = { ok: true; ticket: Request; source: Request; moved: boolean; } | { ok: false; reason: 'not_found' | 'invalid' | 'discussion_not_found' | 'no_origin'; ticket?: Request; }; /** Result of {@link TicketIntakeService.dismiss}. */ export type DismissResult = { ok: true; ticket: Request; alreadyDismissed: boolean; } | { ok: false; reason: 'not_found' | 'already_done' | 'invalid_transition'; ticket?: Request; }; /** Constructor dependencies. */ export interface TicketIntakeServiceDeps { requests: TicketRequestStore; /** WorkItem lookup for the derived board column (optional) */ findWorkItem?: (id: string) => Promise; now?: () => Date; /** Post receipts (default {@link TICKET_CONSTANTS.RECEIPT.ENABLED}, i.e. off) */ receiptsEnabled?: boolean; /** * Where each owner message's fate is recorded (#828 coverage: the receipt * says how many messages became tickets, went into one, or were ignored). */ outcomeLog?: IntakeOutcomeRecorder | null; } /** * Single intake point for owner messages → tickets. See module docs. */ export declare class TicketIntakeService { private readonly deps; private readonly logger; private readonly sinks; /** Serialises intake so two messages in one thread cannot both open a ticket. */ private chain; /** 验过了 / 打回 handler (Phase 2); absent = those words are plain follow-ups */ private review; /** Tells a ticket's assignee the owner chased it; absent = the discussion entry only */ private chaseNudge; /** * @param deps - Request store and optional WorkItem lookup */ constructor(deps: TicketIntakeServiceDeps); /** * Register the receipt poster for a surface. * * @param kind - `slack` or `chat-v2` * @param sink - The poster, or null to remove */ setReceiptSink(kind: ReceiptTarget['kind'], sink: TicketReceiptSink | null): void; /** * Wire the nudge sent to an assignee when the owner chases its ticket. * * @param nudge - Sends text to an agent session, or null to remove */ setChaseNudge(nudge: ((agentSession: string, text: string) => Promise) | null): void; /** * Wire the review handler (Phase 2). * * @param handler - The handler, or null to remove */ setReviewHandler(handler: IntakeReviewHandler | null): void; /** * Mark a ticket's receipt done (🎫 → ✅ on Slack, "is done" in chat-v2). * * @param ticket - The done ticket */ markReceiptDone(ticket: Request): Promise; /** * Spec entry point: take one inbound message. * * @param message - The message * @returns The ticket the message now belongs to (new, appended-to or * dedupe hit), or null when it opened nothing */ intake(message: IntakeMessage): Promise; /** * Take one inbound message and say what happened to it. * * Never throws: a failure is logged and reported as `ignored`, because * intake must never stop a message from being delivered. * * @param message - The message * @returns The outcome */ intakeWithOutcome(message: IntakeMessage): Promise; /** * Log that an owner message never reached {@link intake} because the caller * skipped it (e.g. another machine owns a shared room's message). Without * this, a dropped ask leaves no trace in the intake log. Never throws. * * @param message - The message * @param reason - Why the caller skipped intake */ recordSkipped(message: IntakeMessage, reason: string): Promise; /** * Record what happened to an owner message (#828 coverage). Duplicates are * the same message delivered twice and are not counted again. Never throws. * * @param message - The message * @param outcome - What intake did with it */ private recordOutcome; /** * The gate, in order. See module docs. * * @param message - The message * @returns The outcome */ private process; /** * Handle a top-level chase. Bare pings ("进度呢", "在吗") name no topic and * return null (they stay ignored further down). * * @param all - Every Request * @param message - The owner's message * @param text - Trimmed text * @returns The outcome, or null when the message is not a chase with a topic */ private handleChase; /** * A new ask said in another ticket's thread (#827): open its own ticket in * the same thread, linked to the one it was said under. * * @param all - Every Request * @param message - The owner's message * @param text - Trimmed text * @param parent - The ticket whose thread it was said in * @param ask - The classifier's verdict (for the log) * @returns `created_in_thread` */ private createInThread; /** * Create one ticket from an owner message (top level, a new ask in a * thread, or a split). * * @param all - Every Request (for the ticket number) * @param message - The message it comes from * @param text - Its text * @param opts.parent - The ticket it came from, if any * @param opts.question - A pure information question: kind `question`, no acceptance step * @param opts.fallbackAssignee - Assignee when the message names no agent * @param opts.extraTags - More tags (e.g. `split`) * @returns The new ticket */ private createTicket; /** * Hand a 验过了 / 打回 to the review handler. * * @param ticket - The 待验收 ticket * @param review - What the owner said * @returns The outcome */ private applyReview; /** * Log and build an `ignored` outcome. * * @param message - The message * @param reason - Why * @returns The outcome */ private ignored; /** * The ticket a message in this thread should be routed to, if any. * * #827 lets a thread hold more than one open ticket at once (a new ask * said under an existing ticket gets its own, linked, ticket). #831: when * that happens, a `waiting_confirmation` ticket — one the owner still owes * an answer on — must keep priority over a newer sibling ticket, or every * reply in the thread (a review verdict, an ack, or an ordinary follow-up) * lands on whichever ticket is newest instead of the one actually awaiting * approval. Left unfixed, the awaiting ticket never hears back and only * times out as "默认通过 · 未验收" (see `escalation-router.service.ts`) — * exactly the outcome #819's verification gate exists to prevent. * * Deliberate choice: this applies to EVERY message routed through a * thread, including a plain follow-up with no review/ack wording — not * only review replies. A thread with an open ticket the owner still owes * an answer on has one live conversation; a message that isn't itself a * new, distinct ask (see {@link classifyOwnerMessage}) is presumed to be * about that conversation, not about a because-it's-newer sibling ticket. * When nothing in the thread is `waiting_confirmation`, behaviour is * unchanged: prefer any open ticket, newest first (`listAll` is * newest-first), falling back to the newest ticket overall. * * @param all - Every Request * @param message - The message * @returns The ticket to route to, or null when the thread has none */ private findThreadTicket; /** * The ticket a "不用记" is aimed at: the open ticket in its thread, else the * newest open ticket opened recently in the same conversation. * * @param all - Every Request * @param message - The dismiss message * @returns The ticket, or null */ private findDismissTarget; /** * Whether a ticket was opened in a conversation. Thread refs are built as * `:` (see ticket-channel-hooks), so the * conversation is a prefix of the thread. * * @param r - Ticket * @param conversation - Conversation ref * @returns True when it was */ private conversationMatches; /** * Whether a finished ticket finished recently enough that its thread is * still its conversation ({@link TICKET_CONSTANTS.FOLLOW_UP.RECENT_TICKET_MS}). * * @param ticket - A done or cancelled ticket * @returns True when follow-ups in its thread still belong to it */ private finishedRecently; /** * In a DM-like conversation (every message top level), the ticket a reply * most likely follows up: the conversation's ticket with the latest * activity, if that was within {@link TICKET_CONSTANTS.FOLLOW_UP.DM_WINDOW_MS}. * Dismissed tickets never take follow-ups. * * @param all - Every Request * @param message - The message * @returns The ticket, or null */ private recentConversationTicket; /** * An owner follow-up on a 待验收 ticket that is not 打回 — three outcomes * (specs/2026-10-02-harness-owned-routing.md §5): * - an approval-like answer (好 / 可以 / OK / approve / ship it / 👍 and the * review aliases) accepts it, as before (2026-09-28); * - 打回 (handled before this) sends it back, as before; * - anything else — a question, "where is it / send it again", "can you * also…", "发了 请持续关注" — neither accepts nor reopens: the ticket stays * 待验收, the message is kept in its discussion and reaches the agent as an * ordinary owner message it owes an answer to, and the reminder / * auto-accept clock (`submittedAt`) keeps running. "where did you send * it? send the link again" accepted TKT-187. * When an approval cannot be taken yet (live work), the agent is back on it. * * @param ticket - The 待验收 ticket * @param message - The follow-up * @param text - Trimmed text * @returns `verified`, or `appended` (kept in review, or reopened for live work) */ private acceptOnFollowUp; /** * Append a follow-up to a ticket's discussion. * * Pure acknowledgements ("好的", "thanks") are not written, but the message * still belongs to the ticket (so the delivered copy carries its marker). * Short answers are written: 「是绿卡」 is what the agent asked for. * * @param ticket - The open ticket * @param message - The follow-up * @param text - Trimmed text * @returns `appended` outcome */ private appendToTicket; /** * Hand out the next ticket number. Monotonic per data dir: an atomic counter * file next to the Request files, updated under a lock. The first call seeds * it from the highest number already on disk. * * @param all - Every Request (for seeding) * @returns The new number */ private nextTicketNumber; /** * Post the receipt and remember where it landed. Runs after intake returns * so delivery is not held up by Slack. If the owner dismissed the ticket * before the receipt landed, the receipt is edited straight away. * * @param ticket - New ticket * @param target - Where to post */ private postReceipt; /** * "不用记": cancel a ticket, tag it `dismissed`, and edit its receipt. * * @param ref - `TKT-123`, `123` or the ticket id * @returns What happened */ dismiss(ref: string): Promise; /** * Dismiss a loaded ticket (see {@link dismiss}). * * @param ticket - The ticket * @returns What happened */ private dismissTicket; /** * Split an ask out of a ticket into its own ticket (#827). * * Intake appends when unsure, so a new ask can land in another ticket's * discussion; the agent that receives it fixes that with one call. The new * ticket keeps the thread link (the source's `origin.threadRef`, so later * follow-ups in that thread still find a ticket), records `parentTicketId`, * and is tagged `split`. With `discussionRef`, the entry is moved: removed * from the source's discussion and used as the new ticket's text. * * @param ref - Source ticket: `TKT-123`, `123` or id * @param input - What to split out * @returns The new ticket and the (updated) source, or why not * * @example * ```typescript * await intake.split('TKT-039', { discussionRef: 'slackch-C0C2QCGE9K9-1790433421.1' }); * ``` */ split(ref: string, input: SplitTicketInput): Promise; /** * Find a ticket by `TKT-123`, `123` or id. * * @param ref - The reference * @returns The ticket, or null */ resolve(ref: string): Promise; /** * Board-shaped ticket list. * * @param query - Filters * @returns Rows (newest first) and per-column counts over the filtered set */ list(query?: TicketListQuery): Promise<{ tickets: TicketListItem[]; columns: Record; }>; /** * Shape one Request as a board row. * * @param r - The Request * @returns The row */ toListItem(r: Request): Promise; /** * Current time (injectable for tests). * * @returns Now */ private now; } /** The slice of InFlightTurnTracker the resolver reads. */ export interface InFlightTurnSource { snapshot(): Array<{ sessionName: string; messages: Array<{ text: string; originalContent?: string; }>; }>; } /** * The ticket an agent's current turn is about, from the `[TICKET:…]` markers * in the messages delivered into that turn. * * @param tracker - The in-flight turn tracker * @param sessionName - The agent creating a WorkItem * @returns The ticket id, or null when the turn has none or several tickets */ export declare function resolveTicketIdForSession(tracker: InFlightTurnSource, sessionName: string): string | null; /** * Install the process-wide intake (composition root). * * @param service - The service, or null to clear (tests) */ export declare function setTicketIntakeService(service: TicketIntakeService | null): void; /** * The process-wide intake, or null before boot wired it. Callers skip ticket * intake when null — a message is always delivered either way. * * @returns The service or null */ export declare function getTicketIntakeService(): TicketIntakeService | null; /** * Whether an owner's follow-up on a ticket in review is an approval * (specs/2026-10-02-harness-owned-routing.md §5): 好 / 可以 / OK / approve / * ship it / 👍 and the review ACK / VERIFY aliases. A question or a request * to resend is not. * * @param text - The owner's message * @returns True when it accepts the work */ export declare function isAcceptanceReply(text: string): boolean; //# sourceMappingURL=ticket-intake.service.d.ts.map