/** * TicketReviewService — the back half of the ticket loop * (specs/ticket-loop.md, Phase 2). * * Phase 1 files a ticket for every "please do X". This service decides when * a ticket is answered and what the owner does about it: * * - **Answered.** An agent message in the ticket's own conversation (matched * through the chat-v2 turn that opened it, {@link TicketReviewService.noteChatTurn}) * is recorded as the ticket's `reply`. Once that agent is idle again (or the * reply has settled for {@link TICKET_CONSTANTS.REVIEW.SUBMIT_SETTLE_MS}) and * no WorkItem of the ticket is still open, the ticket is submitted: it goes * to 待验收 (`waiting_confirmation`), or straight to done for origins that * need no review. This replaces the old blind "close any open Request 3–10 * minutes old" sweep, which closed tickets that nobody had answered. * - **验过了** → done. **打回 + reason** → back to work; the reason becomes an * acceptance criterion (#763: criteria grow from real review) and, when the * owner sent it back from the board rather than the thread, a rework * WorkItem is queued for whoever answered. * - **Only deliverables wait.** An answer closes the ticket at once (tagged * `answered`) unless {@link answerNeedsOwner} says the owner has to look at * something — a document, an email, a draft, a form, code, a deploy, * money — or the answer asks him a question (2026-09-28). * - **Silence accepts.** 待验收 for {@link TICKET_CONSTANTS.REVIEW.AUTO_ACCEPT_MS} * (24h, a hard deadline from the answer) with no word → done, tagged * `auto_accepted` and `acceptedBy: 'silence'` (#813: accepted, not * verified — the board labels it so). The owner is never pinged; the agent * is nudged once before that to ask in its own words. * - **Stale closes.** open / running with no activity for * {@link TICKET_CONSTANTS.STALE.AFTER_MS} → cancelled, tagged `stale`, with a * note; the agent answering in its thread reopens it. * * The `done` gate itself lives in {@link RequestService.update}: without * `accepted`, a ticket that needs review cannot become done by any path. * * @module services/v3/ticket-review.service */ import { type Request, type RequestPriority } from '../../types/v2/request.types.js'; import { type TicketAcceptanceCheck, type TicketKind } from '../../types/v2/ticket.types.js'; /** Request store surface the review needs (narrow for tests). */ export interface ReviewRequestStore { getById(id: string): Promise; listAll(): Promise; update(id: string, updates: Parameters[1]): Promise; } /** The slice of a chat-v2 message the review reads. */ export interface ReviewChatMessage { id: string; channelId: string; senderType: string; senderId: string; content: string; threadId?: string; metadata?: Record; } /** A rework request for {@link TicketReviewServiceDeps.createRework}. */ export interface ReworkInput { ticket: Request; reason: string; /** Agent session to hand it to */ target: string; } /** Constructor dependencies. */ export interface TicketReviewServiceDeps { requests: ReviewRequestStore; /** Number of the ticket's WorkItems that are not terminal */ openWorkItemCount?: (requestId: string) => Promise; /** Queue a rework WorkItem; returns its id */ createRework?: (input: ReworkInput) => Promise; /** Swap the receipt to "done" (🎫 → ✅) */ markReceiptDone?: (ticket: Request) => Promise; /** * Deliver a note to the agent that answered, asking it to follow up with * the owner itself. Absent → silence accepts after AUTO_ACCEPT_MS. */ nudgeAgent?: (agentSession: string, text: string) => Promise; /** Session the rework goes to when nobody answered (the orchestrator) */ fallbackAgent: string; /** * Agent messages this agent sent that are still unanswered: an agent that * handed the work on has not answered the ticket yet. Absent → 0. */ openAsksBy?: (session: string) => number; /** Names of the agents (display names), to tell a hand-off from an answer. Absent → no hand-off check. */ agentNames?: () => Promise; now?: () => Date; } /** Result of an owner action. */ export type ReviewActionResult = { ok: true; ticket: Request; } | { ok: false; reason: 'not_found' | 'not_in_review' | 'already_done' | 'cancelled' | 'open_work' | 'invalid'; ticket?: Request; }; /** Where a 打回 came from. */ export type RejectVia = 'thread' | 'board'; /** Fields the owner may change from the board. */ export interface TicketPatch { title?: string; priority?: RequestPriority; kind?: TicketKind; assignee?: string | null; } /** * Whether an agent message passes the work to a colleague: it @-mentions * another agent by name ("@Pia please take this"). The sender's own name * and anyone who is not an agent (the owner) do not count. * * @param content - Message text * @param agentNames - Display names of the agents * @param senderId - The posting agent's session (its own name is ignored) * @returns True when another agent is addressed * * @example * handsWorkOn('@Pia 请你接这件', ['Pia', 'Ella'], 'crewly-marketing-ella-e6a6b8ea') // true */ export declare function handsWorkOn(content: string, agentNames: readonly string[], senderId: string): boolean; /** * Review lifecycle of tickets. See module docs. */ export declare class TicketReviewService { private readonly deps; private readonly logger; private chain; /** * @param deps - Store and optional pool / receipt hooks */ constructor(deps: TicketReviewServiceDeps); /** * Run one state change at a time, so an idle event and the sweep cannot * submit the same ticket twice. * * @param fn - The change * @returns Its result */ private serial; /** * Remember the chat-v2 turn that opened a ticket, so agent answers in the * same thread can be matched to it. Only the first turn counts. * * @param ticketId - The ticket * @param message - The owner's persisted message */ noteChatTurn(ticketId: string, message: Pick): Promise; /** * An agent posted in chat-v2: record it as the answer of the ticket whose * thread it is in. Top-level agent messages (no thread) count for the newest * open ticket in that channel that the agent could be answering. * * @param message - The chat-v2 message * @returns The ticket it was recorded on, or null */ onChatMessage(message: ReviewChatMessage): Promise; /** * An agent finished its turn: submit the tickets it answered. * * @param sessionName - The agent * @returns Tickets submitted */ onAgentIdle(sessionName: string): Promise; /** * Periodic pass: submit answers that have settled (in case no idle event * came), and auto-accept 待验收 tickets nobody objected to. * * @returns What changed */ sweep(): Promise<{ submitted: number; autoAccepted: number; staleClosed: number; }>; /** * Close a ticket nobody touched for {@link TICKET_CONSTANTS.STALE.AFTER_MS} * (cancelled + `stale` + a note). Skipped while one of its WorkItems is * still live — then somebody is on it. * * @param t - The ticket * @param now - Current time (ms) * @returns True when it was closed */ private closeStale; /** * Submit every answered ticket that matches and has no open work. * * @param match - Which answered tickets to look at * @returns Tickets submitted */ private submitAnswered; /** * 验过了 — the owner accepts. Works from any open status (the owner may * accept before the agent says it is finished), but not while WorkItems * are still running. * * @param ref - `TKT-123`, `123` or id * @returns The accepted ticket, or why not */ verify(ref: string): Promise; /** * Move a ticket to done with the owner's (or the auto-accept's) blessing. * * @param ticket - The ticket * @param tags - Tags to store * @param reason - Why it was accepted without the owner's word: written to the * ticket's discussion and the log, never sent to the owner * @returns Result */ private accept; /** * 打回 — the owner sends it back. The reason becomes an acceptance * criterion; from the board (not the thread, where the agent already sees * the owner's words) a rework WorkItem is queued for whoever answered. * * @param ref - `TKT-123`, `123` or id * @param reason - Why (required; the thread path fills a placeholder) * @param via - `thread` or `board` * @returns The reopened ticket, or why not */ reject(ref: string, reason: string, via: RejectVia): Promise; /** * A follow-up from the owner in a 待验收 ticket's thread that is neither * 验过了 nor 打回: the agent is working again, so the ticket leaves 待验收 * (no reject counted). * * @param ticketId - The ticket * @returns The ticket after the change */ reopenOnFollowUp(ticketId: string): Promise; /** * Replace the live acceptance list. Criteria not in `items` are marked * removed (kept for history); new texts are added as the owner's. * * @param ref - Ticket reference * @param items - The list the owner wants, in order * @returns The ticket */ setAcceptance(ref: string, items: ReadonlyArray<{ text: string; check?: TicketAcceptanceCheck; }>): Promise; /** * An agent's self-check against one live criterion. * * @param ref - Ticket reference * @param index - Position in the live list (0-based) * @param result - pass / fail * @param evidence - What it looked at * @returns The ticket */ selfCheck(ref: string, index: number, result: 'pass' | 'fail', evidence?: string): Promise; /** * Board edits: title, priority, kind, assignee. * * @param ref - Ticket reference * @param patch - Changes * @returns The ticket */ patch(ref: string, patch: TicketPatch): Promise; /** * Find a ticket by `TKT-123`, `123` or id. * * @param ref - Reference * @returns The ticket, or null */ private resolve; /** * Best-effort receipt swap. * * @param ticket - The done ticket */ private receiptDone; /** * Current time (injectable for tests). * * @returns Now */ private now; } /** * The note an agent gets when the owner has not answered its question yet. * Written for the agent, not the owner: it must not say "ticket" to them. * * @param t - The ticket * @param now - Current time (ms) * @returns Message text */ export declare function nudgeText(t: Request, now: number): string; /** * Install the process-wide review service (composition root). * * @param service - The service, or null to clear (tests) */ export declare function setTicketReviewService(service: TicketReviewService | null): void; /** * The process-wide review service, or null before boot wired it. * * @returns The service or null */ export declare function getTicketReviewService(): TicketReviewService | null; //# sourceMappingURL=ticket-review.service.d.ts.map