/** * Active Work Briefing Service * * Generates an authoritative-state briefing for an agent at session startup * (and on-demand mid-session via the `core/get-my-active-work` skill). * * Today the agent recovery prompt is memory-only — `SessionMemoryService` * dumps the last session summary, agent/project knowledge, daily logs, and * recent learning files. That is supplementary; it does NOT capture * authoritative runtime state. Symptom: an agent restarts amnesiac about * Requests it owes a reply on, queued WorkItems that survived the restart, * and in-flight delegations. * * This service mirrors `SessionMemoryService` (`generate*` + `format*` * methods, singleton, soft-fail wrapping at the call site) and produces a * per-agent slice of the live Request + WorkItem state. The briefing is * prepended ABOVE the memory briefing in the agent's startup prompt so the * first thing the agent reads is "what am I on the hook for RIGHT NOW". * * Sources: * - {@link RequestService.listAll} — all Requests on disk * - {@link TaskPoolService.getAllItems} — all WorkItems in the pool * * Design doc: `/tmp/agent-state-recovery-design.md` (architect output) * GitHub issue: stevehuang0115/crewly#395 * * @module services/agent/active-work-briefing.service */ import type { Request, RequestStatus } from '../../types/v2/request.types.js'; import type { WorkItem, WorkItemStatus } from '../../types/v2/work-item.types.js'; /** * Narrow projection of `RequestService` used by the briefing — only * `listAll` is needed. Defining the contract here lets the test inject a * fake without dragging in the full RequestService surface. */ interface RequestProvider { listAll(): Promise; } /** * Narrow projection of `TaskPoolService` used by the briefing — only * `getAllItems` is needed. */ interface TaskPoolProvider { getAllItems(): Promise; } /** * Per-section item cap shape used both for {@link DEFAULT_CAPS} and for * {@link GenerateOptions.caps} overrides. Kept as a plain `number` so the * orchestrator multiplier path can return the multiplied value without * confusing TypeScript with literal types. */ export interface BriefingCaps { openRequests: number; activeWorkItems: number; pendingReviews: number; outboundDelegations: number; recentlyAutoResolved: number; } /** Priority value used for sorting briefing rows. */ export type BriefingPriority = 'urgent' | 'high' | 'normal' | 'low'; /** * One Request row in the briefing. */ export interface OpenRequestRow { id: string; title: string; status: RequestStatus; priority: BriefingPriority; ageHours: number; source?: string; /** `TKT-418`, when the row is an owner ticket assigned to this agent */ ticket?: string; /** The owner's separate deliverables with what is delivered (a request that lists several) */ checklist?: { delivered: number; total: number; items: Array<{ text: string; delivered: boolean; }>; }; } /** * One WorkItem row in the briefing. */ export interface ActiveWorkItemRow { id: string; title: string; status: WorkItemStatus; ageHours: number; priority: BriefingPriority; requestId?: string; description?: string; } /** * Pending review row — a WorkItem in `done_by_worker` awaiting this agent's * verification. */ export interface PendingReviewRow { id: string; title: string; ageHours: number; claimedBy?: string; } /** * Outbound delegation row — a WorkItem this agent delegated, still in-flight. */ export interface OutboundDelegationRow { id: string; title: string; status: WorkItemStatus; ageHours: number; target?: string; } /** * Recently auto-resolved row — closed by an SLA / system action within the * configured window. Surfaces "this just closed while you were down". */ export interface RecentlyResolvedRow { id: string; title: string; resolvedAt: string; resolvedReason?: string; } /** * Per-section truncation marker. When a section exceeds its cap, the * service emits the top N rows + a marker telling the agent how many were * dropped and how to fetch the rest. */ export interface SectionTruncation { /** Total candidate items before the cap was applied. */ totalCandidates: number; /** Per-section cap that was applied. */ cap: number; /** True if any rows were dropped. */ truncated: boolean; } /** * Briefing payload. Pure state, no formatting. */ export interface ActiveWorkBriefing { openRequests: OpenRequestRow[]; activeWorkItems: ActiveWorkItemRow[]; pendingReviews: PendingReviewRow[]; outboundDelegations: OutboundDelegationRow[]; recentlyAutoResolved: RecentlyResolvedRow[]; /** Per-section truncation metadata. */ truncation: { openRequests: SectionTruncation; activeWorkItems: SectionTruncation; pendingReviews: SectionTruncation; outboundDelegations: SectionTruncation; recentlyAutoResolved: SectionTruncation; }; /** Aggregate truncation flag (any section truncated). */ truncated: boolean; /** Snapshot timestamp. */ generatedAt: string; /** * Raw counts before per-section caps. Useful for the agent to know * the system-wide load. */ totalCounts: { requests: number; workItems: number; }; } /** * Optional generation overrides — used by tests and by the skill endpoint * for fine-grained control. */ export interface GenerateOptions { /** Override the per-section caps. Useful for tests. */ caps?: Partial; /** Override the recently-resolved window in milliseconds. */ recentlyResolvedWindowMs?: number; /** Override "now" for deterministic tests. */ nowMs?: number; } /** * Memory hints map — caller passes keys like `wi:` or `req:` * mapped to a short note string. The formatter cross-references this map * when emitting each row and appends `(memory: )` to the row line. * * Keys are namespaced so `req:` and `wi:` cannot collide. */ export type MemoryHints = ReadonlyMap; /** * Singleton service producing per-agent active-work briefings. * * Mirrors the `SessionMemoryService.generate* + format*` shape so the * `agent-registration.service.ts` glue can call them in parallel and * concatenate the markdown. * * Stateless beyond singleton bookkeeping — every call hits the live * `RequestService` + `TaskPoolService` instances. */ export declare class ActiveWorkBriefingService { private readonly requestServiceFactory; private readonly taskPoolFactory; private static instance; /** * Memoised promise of the in-flight async `getInstance()` resolution. * * We cache the *promise* (not the resolved value) so that concurrent * callers awaiting `getInstance()` during the cold-start window all * land on the same async resolution — only ONE pair of dynamic * `import()` calls runs, even under heavy parallelism. Once resolved, * subsequent `await getInstance()` calls are microtask-only overhead * (the promise is already settled, so `await` resolves on the next * microtask without re-entering the import pipeline). * * Cleared by {@link resetInstance} for tests so each test can * independently re-load the dynamic imports. */ private static instancePromise; private readonly logger; /** * Private constructor — use {@link getInstance}. The `requestServiceFactory` * and `taskPoolFactory` are dependency seams for tests; production code * lets {@link getInstance} provide the live singletons. */ private constructor(); /** * Returns the singleton instance. * * **Async** — the `RequestService` and `TaskPoolService` modules are * loaded LAZILY via ESM dynamic `import()` so that consumers of this * module do not pay the import cost — and more importantly, do not * transitively load `chokidar` via `v3-data.service.ts` — until they * actually call `getInstance()`. This keeps the agent-registration * test suite mockable without forcing every caller to mock the v3 * pipeline. * * The async contract was introduced by the build-fix that succeeded * PR #494 (`fa05720e`). PR #494 used CJS `require` via a * `createRequire` shim anchored to `process.argv[1]`; that anchor * worked for bare module names (Node walks up to `node_modules/`) * but BROKE relative-path resolution — symptom: this method threw * `Cannot find module '../v3/request.service.js'` for every * agent registration that flowed through here. The fix replaces * `require('')` with `await import('')` * which resolves correctly from this file under both ESM (production) * and ts-jest's CJS transpile (jest treats dynamic `import()` like a * resolvable function). * * **Promise memoization**: the resolved promise is cached so concurrent * cold-start callers all land on the same async resolution — only ONE * pair of dynamic imports runs even under heavy parallelism. Once * resolved, repeat `await getInstance()` calls are microtask-only * overhead. * * @returns Promise resolving to the singleton instance */ static getInstance(): Promise; /** * Resets the singleton — for tests only. * * Clears BOTH the resolved instance AND the memoised in-flight * promise so the next `await getInstance()` re-runs the dynamic * imports cleanly (e.g. picks up freshly-mocked modules). */ static resetInstance(): void; /** * Test-only escape hatch to swap the underlying service factories. * Production code never calls this; the public surface is the singleton. * * @param requestServiceFactory - Returns a `listAll` provider * @param taskPoolFactory - Returns a `getAllItems` provider */ static createForTest(requestServiceFactory: () => RequestProvider, taskPoolFactory: () => TaskPoolProvider): ActiveWorkBriefingService; /** * Compute the effective per-section caps for the requested role. Orchestrator * gets caps × 3 for system-wide visibility; everyone else uses the defaults * (with optional overrides from {@link GenerateOptions.caps}). * * @param role - Agent role (orchestrator gets the multiplier) * @param overrides - Optional explicit caps that win over both defaults and * the role multiplier. * @returns Effective caps for each section */ private resolveCaps; /** * Convert a 'low' | 'normal' | 'high' | 'urgent' priority to a sort key. * Higher number = higher priority (sorted DESC). * * @param p - Priority value * @returns Numeric sort key */ private priorityRank; /** * Compute age in hours since `createdAt`. Negative or NaN inputs are * coerced to 0 — defensive against malformed timestamps. * * @param createdAt - ISO8601 timestamp * @param nowMs - Current time in milliseconds * @returns Age in hours (>= 0) */ private ageHours; /** * Read a string-typed field from a `metadata` blob, or null if missing / * not a string. WorkItem.metadata is typed as `Record` so * every read needs this guard. * * @param metadata - WorkItem.metadata or undefined * @param key - Field name to read * @returns Trimmed string value or null */ private readMetadataString; /** * Apply the per-section cap. Returns the slice + a {@link SectionTruncation} * object that the formatter renders as a "... and X more" marker. * * @param items - Sorted candidate rows * @param cap - Per-section cap * @returns Truncation metadata + the kept rows */ private applyCap; /** * Project Requests visible to this agent. Non-orchestrator agents only see * Requests where they are the `ownerAgent`; the orchestrator sees all open * Requests system-wide. * * @param requests - All Requests from {@link RequestService.listAll} * @param sessionName - Agent session name * @param role - Agent role * @param nowMs - "Now" for age computation * @returns Sorted candidate rows (priority DESC, then age DESC — older first) */ private projectRequests; /** * Project active WorkItems for this agent: items the agent is on the hook * to execute (target=me) OR items the agent has actively claimed * (claimedBy=me, surfaced via `metadata.claimedBy`). Items targeted at this * agent but already claimed by *another* agent are excluded — they are * not actionable for the current session. * * Orchestrator override: include ALL active work items. * * @param items - All WorkItems from {@link TaskPoolService.getAllItems} * @param sessionName - Agent session name * @param role - Agent role * @param nowMs - "Now" for age computation * @returns Sorted candidate rows */ private projectActiveWorkItems; /** * Pending review rows — WorkItems in `done_by_worker` where this agent * is the verifier. Verifier signal = `metadata.verifier === sessionName` * (TL/orchestrator pattern). For the orchestrator role we surface ALL * `done_by_worker` items system-wide. * * @param items - All WorkItems from the pool * @param sessionName - Agent session name * @param role - Agent role * @param nowMs - "Now" for age computation * @returns Sorted candidate rows (oldest first — review backlog) */ private projectPendingReviews; /** * Outbound delegations — WorkItems this agent delegated and that haven't * resolved yet. Identified by `metadata.delegatedBy === sessionName`. * Forward-compatible: if upstream code does not yet populate * `metadata.delegatedBy`, this section is empty. * * @param items - All WorkItems from the pool * @param sessionName - Agent session name * @param nowMs - "Now" for age computation * @returns Sorted candidate rows */ private projectOutboundDelegations; /** * Recently auto-resolved WorkItems within the configured window * (default 30 minutes). Surfaced so the agent knows what closed * automatically while it was down. Identified by * `metadata.slaResolvedAt` falling inside the window. * * @param items - All WorkItems from the pool * @param sessionName - Agent session name (filters to items relevant to this agent) * @param role - Agent role * @param nowMs - "Now" for age computation * @param windowMs - Window size in milliseconds * @returns Sorted candidate rows (most recent first) */ private projectRecentlyResolved; /** * Read a priority hint from WorkItem.metadata. Falls back to `'normal'`. * * @param metadata - WorkItem metadata blob * @returns Briefing priority */ private readPriorityFromMetadata; /** * Truncate a row title to {@link MAX_TITLE_CHARS}. * * @param title - Raw title * @returns Truncated title (with ellipsis suffix if cut) */ private truncateTitle; /** * Truncate a row description to {@link MAX_DESCRIPTION_CHARS}. Replaces * newlines with spaces so a description never wraps the briefing layout. * * @param desc - Raw description * @returns Truncated description */ private truncateDescription; /** * Generates an active-work briefing for the given agent. * * Pulls from {@link RequestService} + {@link TaskPoolService}, filters * to the agent's slice (orchestrator gets system-wide), applies per-section * caps, and returns the structured payload. The caller renders it via * {@link formatBriefingAsMarkdown}. * * Soft-fail policy: any error from the underlying services bubbles up. * The CALL SITE in `agent-registration.service.ts` wraps this in a try/catch * with WARN-level logging — we deliberately do NOT swallow errors here so * test scenarios can assert that the rejection occurs, and so the optional * skill endpoint can return a structured 500 response. * * @param sessionName - Agent session name (e.g. `crewly-product-sam-dd2b46f7`) * @param role - Agent role (`developer`, `orchestrator`, etc.) * @param options - Optional caps + window overrides for testing * @returns The structured briefing * * @throws Rethrows any error from {@link RequestService.listAll} or * {@link TaskPoolService.getAllItems}. * * @example * ```typescript * const briefing = await ActiveWorkBriefingService.getInstance() * .generateActiveWorkBriefing('dev-001', 'developer'); * const md = ActiveWorkBriefingService.getInstance() * .formatBriefingAsMarkdown(briefing); * ``` */ generateActiveWorkBriefing(sessionName: string, role: string, options?: GenerateOptions): Promise; /** * Format a briefing as markdown suitable for prompt injection. * * Output structure: * * ``` * ## Your Active Work * * ### Open Requests * - **request-abc** — Slack inbound — _open · 2h_ *(memory: marked done — verify)* * ... * ### Active WorkItems * - **wi-123** — Implement feature X — _running · 1h_ * - desc here * ... * ``` * * If a {@link MemoryHints} map is provided, contradicting memory notes are * appended INLINE on each row, per design Q5/decision #2. * * Soft-formatting policy: empty briefing collapses to "(No active work — * fresh start)". Sections with zero rows are omitted (no empty headings). * * @param briefing - Structured briefing payload * @param memoryHints - Optional namespaced map of `req:` / `wi:` → * short note string * @returns Markdown string */ formatBriefingAsMarkdown(briefing: ActiveWorkBriefing, memoryHints?: MemoryHints): string; /** * Render the five briefing sub-sections to markdown lines. Pulled out of * {@link formatBriefingAsMarkdown} so the overflow-recover path can reuse * the same renderer with `compact=true` (drops descriptions to bound * the orchestrator briefing). * * @param briefing - Briefing payload * @param memoryHints - Optional contradiction notes * @param compact - If true, drop description lines under WorkItem rows * @returns Array of markdown blocks (joined by blank lines by caller) */ private renderSections; /** * Render the truncation marker line for a section. No-op when the section * was not truncated. * * @param truncation - Section truncation metadata * @returns Marker line (empty string if not truncated) */ private renderTruncationMarker; /** * Append a memory-hint annotation to a row line, if a hint exists for the * given key. Renders the annotation italicized so it visually subordinates * to the actionable state row. * * @param line - Row line so far * @param key - Hint lookup key (e.g. `req:abc-123`) * @param hints - Memory hints map (optional) * @returns The line with annotation appended, or unchanged */ private appendMemoryHint; /** Format hours into a compact "Nh" / "Nm" string for row metadata. */ private formatAge; /** * Render the Open Requests section. * * @param rows - Capped rows * @param truncation - Section truncation metadata * @param hints - Memory hints * @returns Markdown block */ private renderOpenRequests; /** * Render the Active WorkItems section. * * @param rows - Capped rows * @param truncation - Section truncation metadata * @param hints - Memory hints * @param compact - If true, drop description lines (orchestrator overflow) * @returns Markdown block */ private renderActiveWorkItems; /** * Render the Pending Reviews section. * * @param rows - Capped rows * @param truncation - Section truncation metadata * @param hints - Memory hints * @returns Markdown block */ private renderPendingReviews; /** * Render the Outbound Delegations section. * * @param rows - Capped rows * @param truncation - Section truncation metadata * @param hints - Memory hints * @returns Markdown block */ private renderOutboundDelegations; /** * Render the Recently Auto-Resolved section. * * @param rows - Capped rows * @param truncation - Section truncation metadata * @param hints - Memory hints * @returns Markdown block */ private renderRecentlyResolved; } export {}; //# sourceMappingURL=active-work-briefing.service.d.ts.map