/** * Outcome-event collector — story-completion + project-shipped emitter * (outcome-leaderboard US-004). * * Sibling to `./telemetry.ts` (token usage) and `./skill-telemetry.ts` (skill * invocations). Where those diff `~/.claude/projects/**\/*.jsonl` session logs, * this one diffs HQ PROJECT STATE against a persisted cursor at * `~/.hq/outcome-telemetry-cursor.json`: * * - `prd.json` `userStories[].passes` flipping `false → true` * → a `story-completed` outcome event. * - `board.json` project `status` transitioning to a DONE status * → a `project-shipped` outcome event. * * After each successful sync (the `all-complete` arm of `bin/sync-runner.ts`, * via `defaultCollectTelemetry`), it walks `/companies/*` for those two * files, compares each transition-eligible value against the cursor, and POSTs * new transitions to `/v1/outcome-events`. The cursor is only advanced for the * transitions the server 2xx'd, so a transient outage retries next sync. * * Trust model (identical to `./telemetry.ts`): the caller's `personUid` is * resolved SERVER-side from the Cognito JWT — never from the body. The wire row * carries ONLY the outcome type, the ISO-8601 `occurredAt`, the resolved * `companyUid`, `repo`/`branch` context, a `dedupeKey`, and the type-specific * refs (`projectName`, and `storyId` for stories). NO prd/board file content * beyond project name + story id + the status transition ever leaves the machine * — enforced by the `toWireRow` allowlist, matching the server's KEEP_FIELDS in * `apps/hq-pro/src/vault-service/handlers/outcome-events.ts`. * * companyUid resolution mirrors `./telemetry.ts`: the manifest at * `/companies/manifest.yaml` is parsed ONCE per run (`buildRepoCompanyMap`) * and a project's owning company `cmp_*` uid is looked up by the `companies/` * directory the prd/board lives under (`RepoCompanyMap.bySlug`). A project whose * company is not cloud-backed resolves to no uid and is SKIPPED — an outcome event * requires a company (the ingest handler makes companyUid a required field). * * dedupeKey (idempotency anchor — replays + multi-machine syncs never * double-count): * - story-completed → `##` * - project-shipped → `#` * The server's conditional PutItem collapses a re-synced event with the same * dedupeKey to a single stored item. * * Errors are swallowed by design — an outcome collector must never abort or * delay a sync (matches the existing collectors). The opt-in gate is the same * `getTelemetryOptIn()` used by usage/skill telemetry. */ import type { OutcomeEventsBatch, OutcomeEventsIngestResult, TelemetryOptInResponse } from "./vault-client.js"; /** * Minimal subset of `VaultClient` the collector needs. Declared as an interface * so tests can inject a stub without a fetch mock. The real `VaultClient` from * `./vault-client.js` satisfies this structurally. */ export interface OutcomeTelemetryClientSurface { getTelemetryOptIn(): Promise; postOutcomeEvents(batch: OutcomeEventsBatch): Promise; } export interface CollectOutcomeTelemetryOptions { client: OutcomeTelemetryClientSurface; /** * HQ root — the collector scans `/companies/*` for prd.json/board.json * and resolves each project's owning company via `/companies/ * manifest.yaml`. REQUIRED: with no hqRoot there is nothing to scan and no * company to attribute, so the collector no-ops. */ hqRoot?: string; /** Override `~/.hq/outcome-telemetry-cursor.json` for tests. */ cursorPath?: string; /** Override `~/.hq/menubar.json` (the offline opt-in fallback) for tests. */ menubarPath?: string; /** Repo context stamped on every event (required common field). Defaults to `hq`. */ repo?: string; /** Branch context stamped on every event (required common field). Defaults to `main`. */ branch?: string; /** Injectable clock (ISO-8601) for deterministic `occurredAt` in tests. */ now?: () => string; /** Diagnostic sink. No-op by default. */ log?: (msg: string) => void; /** * Fleet agent boxes skip the human consent gate. Collection is always on * (company-attributed outcomes; unattributed projects still skip because * ingest requires companyUid). */ forceCollect?: boolean; } export interface CollectOutcomeTelemetryResult { /** Whether the opt-in check resolved to true. When false, nothing else ran. */ enabled: boolean; optInSource: "server" | "menubar-fallback" | "skipped" | "agent-required"; /** prd.json + board.json files considered. */ filesScanned: number; /** Total outcome events successfully POSTed. */ eventsSent: number; /** Number of `POST /v1/outcome-events` requests made. */ batchesSent: number; } export declare function isDoneStatus(status: unknown): boolean; /** A single detected outcome transition, before it is shaped for the wire. */ export interface OutcomeTransition { type: "story-completed" | "project-shipped"; companyUid: string; projectName: string; /** Present only for story-completed. */ storyId?: string; dedupeKey: string; } /** * Extract the story-completed transitions from a parsed prd.json. A transition * is a story whose `passes` is currently `true` — the cursor decides whether it * is NEW (false→true since last sync) vs already-emitted. `passes` that is not * boolean `true`, or a story with no string `id`, is not a completion. * * `projectName` is the prd's `name`; a prd without one is skipped (the ingest * requires a non-empty projectName). NOTHING else from the prd — no * description, acceptance criteria, files, notes — is read. */ export declare function extractStoryTransitions(prd: unknown, companyUid: string): OutcomeTransition[]; /** * Extract the project-shipped transitions from a parsed board.json. A transition * is a project whose `status` is a DONE status (see `isDoneStatus`). The * `projectName` is the project's `title` (falling back to `id`); a project with * neither is skipped. NOTHING else from the board — description, scope, app, * prd_path, timestamps — is read. */ export declare function extractProjectTransitions(board: unknown, companyUid: string): OutcomeTransition[]; /** * Shape a detected transition for the wire — the STRICT allowlist that proves * no prd/board content beyond project name, story id, and the status transition * is transmitted. Mirrors the server's KEEP_FIELDS + per-type ref rules in * `apps/hq-pro/src/vault-service/handlers/outcome-events.ts`. `personUid` is * never produced (resolved server-side from the JWT); any other field would be * rejected 4xx by the ingest handler. */ export declare function toWireRow(t: OutcomeTransition, ctx: { occurredAt: string; repo: string; branch: string; }): Record; /** * Per-string-field character ceiling enforced by the ingest handler (mirrors the * 2048-char IAM/field limit). A projectName / storyId longer than this makes the * server reject the WHOLE batch, so an over-long field on ONE local project must * not be allowed to poison up to 199 other valid outcomes. */ export declare const MAX_FIELD_CHARS = 2048; /** * Per-event serialized-byte ceiling (4 KB). The server rejects a whole batch if * any single event JSON exceeds this, so an event that would blow the limit is * dropped locally rather than sent. */ export declare const MAX_EVENT_BYTES: number; /** * Decide whether a shaped wire row is within the ingest limits. The * dedupe-relevant fields (companyUid / projectName / storyId, which compose the * dedupeKey) CANNOT be truncated without changing the identity of the outcome, * so an over-limit transition is SKIPPED wholesale rather than mangled — one * malformed local project must never block the valid outcomes in its batch. * * Returns `null` when the row is acceptable, or a human-readable reason string * when it must be skipped (so the caller can log the skip). */ export declare function wireRowRejectReason(row: Record): string | null; /** * Scan HQ project state, detect new story-completed / project-shipped * transitions since the last sync, and POST them. * * Fire-and-forget from the caller's perspective: all errors are caught * internally and surfaced only via `log`. The cursor advances ONLY for * transitions whose batch the server accepted, so a failed POST re-sends next * sync (and the server-side dedupe makes that re-send idempotent). */ export declare function collectAndSendOutcomeTelemetry(opts: CollectOutcomeTelemetryOptions): Promise; //# sourceMappingURL=outcome-telemetry.d.ts.map