/** * Open items — Crewly tracks what agents promise and ask the owner in their * replies, so nothing is silently dropped (specs/2026-10-01-reply-open-items.md). * * Incident (TKT-185, 2026-10-01): in #book-publish Atlas told the owner "Kai is * going through the four episodes… 明天中午给我,我核过以后挑最有用的几条发你" * and asked "第 13 章「互评当体检用」这个读法,你同意吗?". The ticket was already * closed. Kai finished at 18:14 and Atlas verified it, but nobody delivered * it and Atlas went idle; the question was never tracked. * * Now, for every agent message in a ticket's conversation: * * - **Extract** commitments and owner questions (rules, {@link extractOpenItems}). * - **Hold the ticket open**: items live on `Request.openItems`; while one is * active the ticket is `awaiting_followup` (RequestService gates `done`). * - **Commitments**: a follow-up WorkItem for the agent, due at the parsed * time; the delegated child work is linked; when that work is finished * before the agent delivers, the agent is woken at once ("the work you * promised the owner is ready — deliver it now"); past the due time the * agent is nudged once, then the owner is told in the thread what is late * and why; the agent's next post in the thread once the work is ready * marks it delivered. * - **Questions**: a decision card in the same thread, asked as the agent, * with options and a default derived from its words ({@link deriveQuestionCard}); * answers reach the agent through the decision flow. No second card when * the agent already asked the same thing with ask-owner. * * Collaborators are injected ({@link OpenItemsDeps}); `open-items.wiring.ts` * connects the real ones. * * @module services/open-items/open-items.service */ import { type ComponentLogger } from '../core/logger.service.js'; import type { Request } from '../../types/v2/request.types.js'; import type { WorkItem } from '../../types/v2/work-item.types.js'; import { type RequestOpenItem } from '../../types/v2/open-item.types.js'; import type { DecisionSource, OwnerDecision } from '../../types/decision.types.js'; import { type DerivedQuestionCard } from './open-item-card.js'; import { type ReplyReference } from '../orc/agent-prompt-reference.service.js'; import { isStaleCommitment, staleCommitmentNotice, supersededReason } from './stale-commitment.js'; /** The chat-v2 message fields the service reads. */ export interface OpenItemsChatMessage { id: string; channelId: string; senderType: string; senderId: string; content: string; threadId?: string; /** Epoch ms (defaults to now) */ createdAt?: number; metadata?: Record; } /** Where a request's conversation lives in Slack, when it does. */ export interface OpenItemsSlackPlace { slackChannelId: string; threadTs: string; } /** Input to create a commitment's follow-up WorkItem. */ export interface FollowUpInput { request: Request; item: RequestOpenItem; /** Slack thread of the conversation, when it has one */ place: OpenItemsSlackPlace | null; } /** Input to card a question. */ export interface QuestionCardInput { request: Request; item: RequestOpenItem; card: DerivedQuestionCard; place: OpenItemsSlackPlace | null; /** `backfill` when the backfill cards an old reply */ source?: DecisionSource; } /** Collaborators. */ export interface OpenItemsDeps { requests: { getById(id: string): Promise; listAll(): Promise; update(id: string, updates: Parameters[1]): Promise; }; /** Every WorkItem in the pool */ listWorkItems: () => Promise; /** Queue the follow-up WorkItem (held until delivered); returns its id */ createFollowUp?: (input: FollowUpInput) => Promise; /** Close the follow-up WorkItem: `delivered` → done, else cancelled */ closeFollowUp?: (workItemId: string, outcome: 'delivered' | 'cancelled', reason: string) => Promise; /** The agent stated an estimate after the promise: move the follow-up WorkItem's due time (crewly#1360) */ updateFollowUpDue?: (workItemId: string, due: Date) => Promise; /** Decisions the agent asked recently (for the ask-owner dedupe) */ recentDecisionsBy?: (agent: string, sinceMs: number) => Promise; /** Post a card for a question; returns the decision */ askQuestion?: (input: QuestionCardInput) => Promise; /** Withdraw a question's card (the ticket was cancelled) */ cancelQuestion?: (decisionId: string, note: string) => Promise; /** A question the owner skipped in this request (not asked again for 30 days) */ skippedQuestion?: (requestId: string, agent: string, question: string) => Promise; /** Skip a question's card (the owner skipped the open item); false when it was not pending */ skipQuestion?: (decisionId: string) => Promise; /** A decision by id (sweep catches answers a missed handler call left behind) */ getDecision?: (decisionId: string) => Promise; /** Deliver a note to an agent, waking it; false when it could not */ deliverToAgent: (session: string, text: string) => Promise; /** Post a note for the owner in the request's conversation */ postOwnerNote: (request: Request, text: string) => Promise; /** Tell an agent's team lead something (an item too old to remind the agent about); false when it could not */ notifyLead?: (agentSession: string, text: string) => Promise; /** Display name of an agent ("Atlas") */ displayName?: (session: string) => Promise; /** Names of the agent's colleagues (questions to them are not for the owner) */ colleagueNames?: (session: string) => Promise; /** The owner's Slack user id */ ownerSlackUserId?: () => string | null; now?: () => Date; logger?: ComponentLogger; } /** What one extraction would add (also the backfill's dry-run row). */ export interface PlannedOpenItem { item: RequestOpenItem; /** Question: the card it would post (absent when an ask-owner card already covers it) */ card?: DerivedQuestionCard; /** Question: the ask-owner decision it is linked to instead of a new card */ linkedDecisionId?: string; /** Question: the owner already skipped the same question here — tracked as skipped, never carded */ skippedDecisionId?: string; /** * Question: where the card goes instead of the request's thread (the agent * asked elsewhere and the ticket thread is stale); null = the agent's * current conversation / team channel */ cardPlace?: OpenItemsSlackPlace | null; } /** Thrown for open-item requests the caller must fix (HTTP 4xx). */ export declare class OpenItemsError extends Error { readonly status: number; constructor(status: number, message: string); } /** * The Slack thread of a request's conversation (`origin.threadRef` is * `slack::`). * * @param request - Request * @returns Place, or null for chat-only conversations */ export declare function slackPlaceOf(request: Pick): OpenItemsSlackPlace | null; /** Where an agent's chat message sits in Slack, read from its metadata. */ export interface AgentMessageSlackPlace { slackChannelId: string; /** Thread root ts (the message's own ts when it is a top-level post) */ threadTs?: string; /** The message's own ts, when known */ messageTs?: string; } /** * Where an agent's message was posted in Slack: `metadata.slackThreadKey` * (`:`), else `slackChannelId` + `slackThreadTs` / * `slackTs` (a top-level post records its own ts as `slackThreadTs`). * * @param message - chat-v2 message * @returns Place, or null when it was not mirrored to Slack */ export declare function agentMessageSlackPlace(message: Pick): AgentMessageSlackPlace | null; /** * A link to an agent's message in Slack (or to its thread when the message's * own ts is unknown). * * @param p - Where it was posted * @returns The link and whether it points at the thread, or null */ export declare function agentMessageLink(p: AgentMessageSlackPlace | null): { url: string; isThread: boolean; } | null; /** * Whether a message is in the request's conversation. * * @param request - Ticket with a chatRef * @param message - chat-v2 message * @returns True for its thread (or its root) */ export declare function inRequestThread(request: Pick, message: Pick): boolean; /** * Project ticket ids a promise names ("the links for CE-16 and CE-36"), upper-cased. * The request's own ticket label (TKT-185) is not one. * * @param text - The promise's words * @param request - The request the promise was made in * @returns Referenced ids */ export declare function projectTicketRefs(text: string, request: Request): Set; /** * The delegated child work a commitment waits on: WorkItems of the request * given to someone else, started around the promise and not already finished * before it. * * @param request - Request * @param item - Commitment * @param pool - Every WorkItem * @returns Child WorkItem ids */ export declare function childWorkFor(request: Request, item: Pick & { text?: string; }, pool: readonly WorkItem[]): string[]; /** * Whether every child of a commitment is finished (cancelled ones ignored). * * @param ids - Child ids * @param pool - Every WorkItem * @returns `ready` with the latest finish time, or not ready (with what is left) */ export declare function childrenState(ids: readonly string[], pool: readonly WorkItem[]): { ready: boolean; finishedAt?: string; pending: WorkItem[]; done: WorkItem[]; }; /** * Whether two items are the same thing said once: same message, or the same * words by the same agent within {@link OPEN_ITEMS_CONSTANTS.DUPLICATE_WINDOW_MS}. * * @param a - Item * @param b - Item * @returns True for a duplicate */ export declare function isSameItem(a: Pick, b: Pick): boolean; /** Whether the owner's message agrees ("可以", "同意", "ok", "go ahead", 👍) and does not hold back. */ export declare function isApproval(text: string): boolean; /** * Whether a post plausibly fulfils a promise (specs/2026-10-02-harness-owned-routing.md §5): * the harness marked it as the ticket's delivery; or, for a promise of a * deliverable (preview, PDF, link, report, file…), it carries a link, an * attachment or names the same kind of thing; or, for a plain promise, it * is a substantive post — not an ack, a progress line, or only a question. * * @param promise - The commitment's words * @param message - The post * @returns True when it can be the delivery */ export declare function plausiblyFulfils(promise: string, message: Pick): boolean; /** * Whether two commitments are the same promise: the same words, or — from the * same agent within {@link OPEN_ITEMS_CONSTANTS.PROMISE_DUPLICATE_WINDOW_MS} — * very similar words, or the same deliverable (same person named and the same * kind of thing handed over). * * @param a - One item * @param b - The other * @returns True for one promise said twice */ export declare function isSamePromise(a: RequestOpenItem, b: RequestOpenItem): boolean; /** * The reference a follow-up prompt names (specs/2026-10-02-harness-owned-routing.md §4). * * @param request - Request * @param item - Commitment * @returns The ticket (and follow-up work item) the agent answers about */ export declare function followUpReference(request: Request, item: Pick): ReplyReference; /** * The exact command a follow-up prompt tells the agent to run — a reference, * never a raw thread key (the harness finds the thread). * * @param ref - Follow-up reference * @returns Command text */ export declare function followUpCommand(ref: ReplyReference): string; export { isStaleCommitment, staleCommitmentNotice, supersededReason }; /** An owner promise still owed, as the restart path needs it. */ export interface OwedCommitment { /** Agent that promised */ sessionName: string; /** Ticket label (TKT-194), when the request has one */ ticket?: string; /** The promise */ text: string; /** Request holding it */ requestId: string; /** The open item */ itemId: string; /** When it was due (ISO) */ due?: string; /** When it was promised (ISO) */ createdAt?: string; } /** * Promises a restart should act on: past due, never nudged and never * reminded after a restart, on requests that are not done or cancelled * (specs/2026-10-02-restart-busy-and-resume.md). Not-yet-due promises are left * alone (the sweep acts when they come due), and nudged / overdue ones belong * to the sweep, so a restart never adds a second nudge. * * @param requests - Every request * @param now - Clock * @returns One entry per such commitment */ export declare function owedCommitments(requests: readonly Request[], now?: Date): OwedCommitment[]; /** * Record that a restart reminder was sent for a commitment. It counts as the * one nudge: the sweep then moves on to the owner note, never nudging again. * Re-reads the request first; returns false when the sweep nudged it already. * * @param requests - Request store * @param requestId - Request * @param itemId - Commitment * @param at - When the reminder goes out * @returns True when the reminder should be (and is now recorded as) sent */ export declare function markRestartReminded(requests: Pick, requestId: string, itemId: string, at?: Date): Promise; /** * Whether a commitment from an interim note is worth tracking: an explicit * time and a concrete deliverable. "On it, I'll report back" and "稍后回复你" * are not: they could never be delivered (PR #1013 review). * * @param item - The planned commitment * @returns True to track it */ export declare function isConcreteInterimPromise(item: Pick): boolean; /** * Tracks commitments and questions on Requests. See module docs. */ export declare class OpenItemsService { private readonly deps; private static instance; private readonly logger; private readonly now; private chain; private timer; /** * @param deps - Collaborators */ constructor(deps: OpenItemsDeps); /** @returns The process-wide instance, or null before wiring */ static getInstance(): OpenItemsService | null; /** @param service - Instance to install (null clears) */ static setInstance(service: OpenItemsService | null): void; /** Start the periodic sweep. */ start(): void; /** Stop the sweep. */ stop(): void; /** * One change at a time (a chat message, a WorkItem event and the sweep may * touch the same request). */ private serial; /** * {@link markRestartReminded}, serialized with every other change to the * same requests (the sweep may be nudging the same promise). * * @param requestId - Request * @param itemId - Commitment * @returns True when the reminder should be (and is now recorded as) sent */ markRestartReminded(requestId: string, itemId: string): Promise; /** * The stall watcher took a step for a commitment (crewly#1363): stamp it on * the item so the sweep does not repeat it — `agent` → `stallNudgedAt`, * `lead` → `leadNotifiedAt`, `owner` → `ownerNotifiedAt`. An item that is * no longer active is left alone. Serialized with every other change. * * @param requestId - Request * @param itemId - Commitment * @param step - Which rung of the ladder fired * @param at - When * @returns True when the item was stamped */ recordStallStep(requestId: string, itemId: string, step: 'agent' | 'lead' | 'owner', at?: Date): Promise; /** * The ticket an agent message belongs to: the one whose thread it is in * (any status but cancelled, closed within the lookback), else — for a * top-level message — the newest open ticket in that channel the agent is * answering. * * @param message - chat-v2 message * @param all - Every request * @returns The request, or null */ findRequestFor(message: OpenItemsChatMessage, all: readonly Request[]): Request | null; /** * An agent posted in chat-v2: mark what it delivered, and track what it * newly promised or asked. * * @param message - The message * @returns The request it changed, or null */ onAgentMessage(message: OpenItemsChatMessage): Promise; /** * The owner posted in a ticket's thread: a yes opens the conditional * promises that wait on them. * * @param message - The owner's message * @returns The request it changed, or null */ onOwnerMessage(message: OpenItemsChatMessage): Promise; /** * The owner wrote in a thread where an agent asked him a yes/no decision in * prose (an uncarded owner question): his words are the answer on file * (crewly#1369 gap 2). Runs inside {@link serial}. * * @param message - The owner's message * @param at - When it was posted * @returns The request it changed, or null */ private answerOwnerQuestions; /** * Open waiting promises: their due time is counted from `at`, and the * follow-up WorkItem is created now. * * @param request - Request * @param at - When the owner agreed * @param why - For the log * @param pick - Which waiting items * @returns The updated request, or null when none matched */ private openWaiting; /** * What a message would add, without side effects (also the backfill's view). * * A promise that names no time is due in * {@link OPEN_ITEMS_CONSTANTS.CHAT_PROMISE_DEFAULT_DUE_MS} when it was made * live in the owner's conversation (`opts.live`: the owner is waiting for * the reply), else in {@link OPEN_ITEMS_CONSTANTS.DEFAULT_DUE_MS} (the * backfill reading old history) (crewly#1360). * * @param request - Its request * @param message - The agent message * @param at - When it was posted * @param opts - `live`: the message was just posted in the owner's conversation * @returns Planned items */ plan(request: Request, message: OpenItemsChatMessage, at: Date, opts?: { live?: boolean; }): Promise; /** * Yes/no decisions the agent asked the owner in prose (anywhere in the * message) go on file with a due time, unless a card already carries them: * an uncarded final question is marked, the others are added as owner * questions (crewly#1369 gap 2). Live messages only — the backfill does not * open questions nobody can still answer. * * @param out - Planned items (changed in place) * @param message - The agent message * @param at - When it was posted * @param names - Colleague names * @param owner - Owner Slack id * @param base - Base fields of the n-th extra question item (1-based) */ private planOwnerQuestions; /** * Where a question card from this message goes, and the links it carries. * The card goes where the question was asked: the request's thread when * the agent wrote there; when it wrote somewhere else (another thread / * channel, or a top-level post) the card goes where the agent's message is * (null: the agent's current conversation / team channel) and links the * request's thread instead of reviving it. (Until 2026-10-08 a request * thread active within the last day still took the card: 12 of 17 * checkable auto cards landed in a thread other than the conversation.) * * @param request - Its request * @param message - The agent message * @param at - When it was posted * @returns Place override (undefined = the request's thread) and links */ private questionPlace; /** * The newest ask-owner decision this agent made shortly before `at` (the card a conditional promise waits on). * * @param agent - Agent * @param at - When the promise was made * @returns The decision, or null */ private recentDecision; /** * Everything the agent asked the owner within the lifted-card window (for the card gate). * * @param agent - Asking agent * @param at - When the reply was posted * @returns Its decisions (empty when unknown) */ private recentLifted; /** * The ask-owner decision the agent already made for this question, if any. * * @param agent - Asking agent * @param q - The question * @param at - When it was asked in the reply * @returns The decision, or null */ private alreadyAsked; /** * Turn a planned item into a tracked one: link child work and queue the * follow-up (commitment), or post / link the card (question). * * @param request - Its request * @param p - Planned item * @param pool - Every WorkItem * @returns The stored item */ private activate; /** * Undated promises this message dates: the same agent, in the same thread, * within {@link OPEN_ITEMS_CONSTANTS.ESTIMATE_WINDOW_MS} of a promise whose * due time was the default, states an estimate ("预计一小时左右", "大约 40 * 分钟", "应该今晚能好") in a post that promises nothing new of its own * (crewly#1360: the promise at 23:14, the estimate at 23:15, the follow-up * waited 24 h). * * @param items - Current items * @param message - The post * @param at - Its time * @param planned - What the post itself would add (a new promise in it leaves older ones alone) * @returns The items with their new due time (empty when none) */ private estimates; /** * Commitments this message delivers. * * @param items - Current items * @param message - The post * @param at - Its time * @param pool - Every WorkItem * @returns Updated (delivered) items */ private deliveries; /** * Whether a post in the thread delivers a commitment: by the promising * agent (or one doing the child work), once the child work is finished. * With no child work: a later post by the promising agent, at least * {@link OPEN_ITEMS_CONSTANTS.MIN_DELIVERY_GAP_MS} after the promise, that * does not itself promise something new. * * @param item - Commitment * @param message - The post (`createdAt` set) * @param pool - Every WorkItem * @returns True when it is the delivery */ deliveredBy(item: RequestOpenItem, message: OpenItemsChatMessage, pool: readonly WorkItem[]): boolean; /** * Whether a post makes a NEW promise: restating the one it delivers does not count. * * @param item - The commitment being delivered * @param message - The post * @param at - Its time (epoch ms) * @returns True when it promises something else */ private promisesAnew; /** * The agent that owns a follow-up WorkItem closes it as already delivered: * the open item is closed and the WorkItem finished (no done_by_worker step). * * @param workItemId - The follow-up WorkItem * @param agent - The session closing it (must be the promising agent) * @param summary - What the agent says (where it was delivered) * @returns True when an active item of that agent was closed */ closeByAgent(workItemId: string, agent: string, summary: string): Promise; /** * Take planned items onto a request (the backfill's apply step). * * @param requestId - Request * @param planned - Items from {@link plan} * @param opts - `source: 'backfill'` marks the cards it posts as backfilled; `caller` is * the session of whoever asked for it (logged: who ran an apply) * @returns Updated request, or null when it is gone */ adopt(requestId: string, planned: PlannedOpenItem[], opts?: { source?: DecisionSource; caller?: string; }): Promise; /** * Store items, and move the request between `done` and `awaiting_followup`. * * @param request - Request as read * @param items - New item list * @param added - New active items were added * @returns Updated request */ private save; /** * A WorkItem finished (task:done / task:verified): wake whoever promised * the owner the result, if that was the last piece. * * @param workItemId - The WorkItem * @returns Items that became ready */ onWorkItemSettled(workItemId: string): Promise; /** * Deliver a follow-up reminder about a ticket's promise. If the agent is * busy and it waits on the queue, a newer reminder for the same ticket * replaces it there (2026-10-05: several TKT-270 "promised work is ready" * reminders sat ahead of the owner's answer). * * @param request - The ticket's request * @param agent - The agent * @param text - The reminder * @returns Whether it was accepted */ private remindAgent; /** * The child work is finished: wake the agent to deliver. * * @param request - Request * @param item - Commitment * @param finishedAt - When the last child finished * @param done - Finished children * @returns Updated item (`ready`) */ private markReady; /** * An item is too old to remind its agent about: tell the agent's team lead * once (never the owner). * * @param request - Request * @param item - Commitment * @param now - Clock * @returns Whether the lead was told */ private tellLeadItemIsStale; /** * A `reply_question` decision settled: close its open item and give the * agent the answer (the generic decision note). * * @param d - The decision * @param fallback - The generic note * @returns The note for the agent (null when withdrawn) */ onDecisionSettled(d: OwnerDecision, fallback: string | null | undefined): Promise; /** * What would happen to a waiting promise now, without doing it. * * @param item - Commitment in `waiting_owner` * @param items - All items of its request * @param now - Clock * @param hint - A decision already in hand (the one just settled) * @returns The action, the item with its gate card linked, and when the owner answered */ private decideGate; /** * Settle one waiting promise by its gate: open it on a yes (follow-up * WorkItem, the reconciler starts a stopped agent), close it on a no / skip * (a skip tells the agent once to drop it), tell the owner when no gate can * ever answer it. * * @param request - Request * @param item - Commitment in `waiting_owner` * @param items - All items of the request * @param now - Clock * @param pool - Every WorkItem * @param hint - A decision already in hand * @returns The item after (the same object when nothing changed) */ private runGate; /** * One pass over every request holding a waiting promise (the CREW-440 * backfill: promises stored before they carried a gate, e.g. TKT-374 * c-e21ac6f0-1, are settled by the card the owner already answered). The * regular sweep does the same every minute; this adds a dry-run view and a * one-time pass at startup. * * @param opts - `apply: true` to make the changes (default: dry-run) * @returns How many requests / promises it examined, and what each would get */ backfillWaitingPromises(opts?: { apply?: boolean; }): Promise<{ dryRun: boolean; examinedRequests: number; examinedItems: number; rows: Array<{ requestId: string; ticket: string; item: string; action: string; }>; }>; /** * The owner skipped an open item ("I don't care about this anymore"). A * promise is closed, its follow-up WorkItem cancelled and the agent told * once to drop it; a question's card is skipped (which closes the item and * tells the agent). The request completes when nothing else is open. * * @param requestId - Request * @param itemId - Open item * @returns The closed item * @throws OpenItemsError(404 / 409) */ skipItem(requestId: string, itemId: string): Promise; /** * Periodic pass: link late child work, catch missed readiness, nudge * overdue promises once and then tell the owner, sync answered questions, * expire stale items, close cancelled tickets' items. * * @returns Counts of what happened */ sweep(): Promise<{ ready: number; nudged: number; ownerNotes: number; expired: number; closed: number; }>; /** * Promises this sweep closed without delivery are told to the owner once, * in one note per request: * - the follow-up WorkItem was cancelled by ANOTHER agent (an agent's * cleanup, a bulk script) — the cancel API records who * (`metadata.cancelledBy`). * * A promise that merely expired (7 days, no progress) is NOT told here: it * closes as stale and appears only in the weekly summary. * * The promising agent cancelling its own follow-up, owner skips, * superseded promises and cancelled tickets were closed on purpose and * stay quiet. Before this, ~60 tracked promises were cancelled in one * cleanup and Owen's two article links were never sent (crewly#1015 §10). * * @param request - Request * @param previous - Its items before this sweep * @param items - Its items after this sweep (same order) * @param pool - Every WorkItem * @param now - Clock * @returns The items, with `ownerNotifiedAt` set on the ones the owner was told about */ private tellOwnerAboutDroppedPromises; /** * One item's sweep step. */ private sweepItem; /** * Commitments that expired as stale (7 days, no progress) since a time, for * the weekly / Drive summary. They are never posted as a fresh owner ask. * * @param sinceMs - Epoch ms; only items closed at or after it * @returns One row per expired commitment */ staleExpiredCommitments(sinceMs: number): Promise>; /** * The owner's note for a late promise: what, by when, and why it is late. * * @param request - Request * @param item - Commitment * @param pool - Every WorkItem * @param now - Clock * @returns Note text (English harness text; the agent's words quoted) */ ownerNote(request: Request, item: RequestOpenItem, pool: readonly WorkItem[], now: Date): Promise; private closeFollowUp; } /** * The open-item status a settled decision leaves its question in. * * @param d - Decision * @returns Status (`open` while it is still pending) */ export declare function itemStatusFor(d: Pick): RequestOpenItem['status']; //# sourceMappingURL=open-items.service.d.ts.map