/** * Ticket hygiene (specs/ticket-calm.md) — the rules that keep the ticket pile * from growing on the owner: * * - {@link answerNeedsOwner}: an answered ticket waits in 待验收 only when it * has a deliverable someone must look at (a document, an email, a draft, a * form, code, a deploy, money, anything sent out) or when the answer asks * the owner something. A plain answer closes on the spot. * - {@link isStaleTicket}: open / running with no activity for * {@link TICKET_CONSTANTS.STALE.AFTER_MS} → closed as stale. * - {@link planTicketCleanup} / {@link runTicketCleanup}: the one-time * cleanup of the pile that built up before these rules (dry run by * default, idempotent). * * Pure except for {@link runTicketCleanup}, which writes through the * request store it is given. No LLM. * * @module services/v3/ticket-hygiene */ import type { Request, UpdateRequestInput } from '../../types/v2/request.types.js'; /** * Whether an answered ticket has to wait for the owner (待验收) or can close * as soon as it is answered. * * Waits when the ask names a deliverable ({@link TICKET_CONSTANTS.REVIEW.DELIVERABLE_ASK}), * the intent is a change ({@link TICKET_CONSTANTS.REVIEW.DELIVERABLE_CATEGORIES}), * or the agent's answer asks the owner something * ({@link TICKET_CONSTANTS.REVIEW.OWNER_QUESTION}). Everything else — "看看这个", * "研究一下", "这个团队都有几个人" — is a conversation, and a conversation * that got its answer is done. * * @param ticket - The ticket (its ask, intent and latest answer) * @returns True when the owner has to look at the result * * @example * ```typescript * answerNeedsOwner({ description: '帮我写一封回信', reply: {...} }); // true * answerNeedsOwner({ description: '看看这个 https://x.com/…', reply: {...} }); // false * ``` */ export declare function answerNeedsOwner(ticket: Pick & { reply?: Pick, 'excerpt'> | undefined; }): boolean; /** The owner asks where it is / to send it again — never an approval. */ export declare const RESEND_OR_WHERE: RegExp; /** * Whether a message the owner left in a 待验收 ticket's thread is an * objection: a 打回, or "where is it / send it again". * * @param text - The owner's message * @returns True when the work is not settled by it */ export declare function isOwnerObjection(text: string): boolean; /** * Whether the owner already moved on in a 待验收 ticket's thread: they posted * after the answer, or answered one of the agent's questions on a decision * card after it, and nothing they said is an objection. Pure * acknowledgements are not stored in the discussion (they accept at once), so * a later entry is a follow-up the agent answers in the thread, not a * complaint about the finished work. Notes written by Crewly itself do not * count. A card answer is not stored in the discussion either (2026-10-15: * "你看一眼内容对不对" answered on its card left the ticket waiting). * * @param ticket - The ticket in 待验收 * @returns True when silence-about-the-work can be taken as acceptance now */ export declare function ownerMovedOn(ticket: Pick): boolean; /** * The last time anything happened on a ticket: created, updated, answered, * submitted or discussed. * * @param ticket - The ticket * @returns Epoch ms (NaN-safe: unparsable dates are skipped) */ export declare function ticketLastActivity(ticket: Pick & { reply?: Pick, 'at'> | undefined; }): number; /** * Whether a ticket has gone stale: open / ready / running and untouched for * {@link TICKET_CONSTANTS.STALE.AFTER_MS}. * * @param ticket - The ticket * @param now - Current time (ms) * @returns True when it should be closed as stale */ export declare function isStaleTicket(ticket: Request, now: number): boolean; /** * The update that closes a ticket as stale: cancelled, tagged, with a note in * its discussion so whoever opens it sees why. * * @param ticket - The ticket * @param nowIso - Current time * @returns The update */ export declare function staleCloseUpdate(ticket: Request, nowIso: string): UpdateRequestInput; /** What the cleanup does to one ticket. */ export interface TicketCleanupAction { id: string; /** `TKT-042`, or null for a Request from before the ticket loop */ tkt: string | null; title: string; from: Request['status']; /** * `answered`: 待验收 with nothing for the owner to look at → done (as a plain * answer now closes); `accept`: 待验收 answered over a day ago → done * (silence); `stale`: open / running idle → cancelled + stale */ action: 'answered' | 'accept' | 'stale'; } /** Result of {@link planTicketCleanup} / {@link runTicketCleanup}. */ export interface TicketCleanupReport { /** False = dry run: nothing was written */ applied: boolean; /** Requests looked at */ scanned: number; /** 待验收 whose answer needs nothing from the owner → done */ answered: number; /** 待验收 older than AUTO_ACCEPT_MS → accepted */ accept: number; /** open / running idle for STALE.AFTER_MS → closed as stale */ stale: number; /** Actions that failed on apply (id + reason) */ failed: Array<{ id: string; reason: string; }>; actions: TicketCleanupAction[]; } /** Options for {@link planTicketCleanup}. */ export interface TicketCleanupOptions { /** Current time (ms) */ now: number; /** * Also close Requests from before the ticket loop (no TKT number) that went * stale. Default true: they are invisible on the board and only inflate * counts (2026-09-28: 37 open test-leak Requests from August). */ includeLegacy?: boolean; } /** * Plan the one-time cleanup — the 2026-09-28 rules applied to the pile that * built up before them: * * - a 待验收 ticket whose answer needs nothing from the owner * ({@link answerNeedsOwner}) is closed as answered; * - any other 待验收 ticket answered more than * {@link TICKET_CONSTANTS.REVIEW.AUTO_ACCEPT_MS} ago is accepted (silence); * - an open / running one idle for {@link TICKET_CONSTANTS.STALE.AFTER_MS} is * closed as stale. * * Idempotent: a second plan over the result is empty. * * @param requests - Every Request in the store * @param opts - Clock and scope * @returns The report (not applied) */ export declare function planTicketCleanup(requests: readonly Request[], opts: TicketCleanupOptions): TicketCleanupReport; /** The request store surface the cleanup writes through. */ export interface TicketCleanupStore { listAll(): Promise; update(id: string, updates: UpdateRequestInput): Promise; } /** * Run the cleanup. Dry run unless `apply`; the plan is made from the store's * current contents, so running it twice changes nothing the second time. * * Accepting goes through the store's `done` gate with `accepted` (silence, * tagged `auto_accepted`) and `ignoreDeadChildren`, so a ticket with a live * WorkItem still stays open (reported under `failed`). A `waiting_confirmation` * ticket that does not need review (a question, a cron ticket) is closed the * same way without the tag. * * @param store - Request store * @param opts - Clock, scope and `apply` * @returns What was (or would be) done * * @example * ```typescript * const dry = await runTicketCleanup(RequestService.getInstance(), { now: Date.now() }); * const done = await runTicketCleanup(RequestService.getInstance(), { now: Date.now(), apply: true }); * ``` */ export declare function runTicketCleanup(store: TicketCleanupStore, opts: TicketCleanupOptions & { apply?: boolean; }): Promise; //# sourceMappingURL=ticket-hygiene.d.ts.map