@titan-design/session-graph 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -46,9 +46,10 @@ passes 1002.
46
46
  complete `gh pr create` sightings into new PR rows, subagent end times and parentage
47
47
  from child sessions.
48
48
  6. `enrichPrs` runs if the caller passed a `resolvePrs` resolver. See below.
49
- 7. `enrichTasks` runs if the caller passed a `resolveTasks` resolver, once over the whole
49
+ 7. `projectReviewRounds` resolves chat verdicts and writes review rounds. See below.
50
+ 8. `enrichTasks` runs if the caller passed a `resolveTasks` resolver, once over the whole
50
51
  task table. See below.
51
- 8. Rows whose source file has vanished are marked `missing`. Their facts stay: surviving
52
+ 9. Rows whose source file has vanished are marked `missing`. Their facts stay: surviving
52
53
  Claude Code's own pruning is much of the point.
53
54
 
54
55
  `resetIndex` clears every derived table and rewinds watermarks; the next refresh rebuilds
@@ -93,10 +94,55 @@ await refreshCorpus(graph, transcripts, {
93
94
  - **Merged is sticky.** A resolver's `open` or `closed` never replaces a `merged` state, so a
94
95
  stale forge cache cannot reopen a PR. States are stored lower-case.
95
96
  - **Batching.** One call per refresh with every PR that has a repo and number and whose
96
- outcome may still change: never checked, or not yet merged. A merged PR is asked about
97
- once. The resolver only updates rows; a PR enters the graph from a transcript.
98
- - **`review_rounds`** is stored as the resolver counts it. What counts as a round is an open
99
- question in the TP-256 design (Q6).
97
+ outcome may still change: never checked, not yet merged, or merged with no commit times
98
+ stored. PRs never checked come first, then open and closed PRs, then merged PRs offered only
99
+ for their commit times, so a resolver that caps its batch reaches open PRs before the
100
+ backlog. The resolver only updates rows; a PR enters the graph from a transcript.
101
+ - **`commitTimes`** is stored as a JSON array in `commit_times`. Send an empty array for a PR
102
+ with no commits, so it is not offered again; omitting the field leaves the stored value.
103
+ - **`reviews`** replaces the PR's forge rows in `pr_review` (`APPROVED` and
104
+ `CHANGES_REQUESTED`, keyed `gh:<pr_ref>:<submittedAt>`); omitting it leaves them. With
105
+ `reviews`, the round rule below counts `review_rounds_gh` against the sent `commitTimes`, or
106
+ the stored ones when none are sent. With no usable commit times, `reviewRounds` is stored
107
+ there as the forge counted it, and omitting that too leaves the stored value.
108
+
109
+ ## Review rounds
110
+
111
+ `projectReviewRounds` runs after `enrichPrs` on every pass. It first resolves each chat
112
+ verdict's `pr_ref`, then writes `review_rounds_chat` and `review_rounds` for every PR.
113
+
114
+ **The rule.** A review's head is the number of the PR's commits at or before it. A
115
+ changes-requested review counts when its head is below the commit count, which means a later
116
+ commit answered it. A PR's rounds are the distinct heads among its counting reviews. A second
117
+ changes-requested review on one head adds nothing, whether it comes from the same reviewer, a
118
+ second reviewer, or the other surface. An approval is stored and never adds a round.
119
+
120
+ - **Senders.** A chat verdict counts only when the sender's `session_origin.profile` passes
121
+ `isReviewerProfile`, a `refreshCorpus` option. The default accepts `reviewer` and any
122
+ profile ending in `-reviewer`.
123
+ - **Totals.** `review_rounds` is `review_rounds_gh` plus the chat heads no forge review
124
+ already sits on. A resolver that sends only a `reviewRounds` count therefore still adds to
125
+ the chat rounds unchanged.
126
+ - **Count-only resolvers overcount.** Without `reviews`, the forge reviews' heads are unknown,
127
+ so a chat verdict and a forge review on the same head both count. `review_rounds` is then
128
+ an upper bound. `review_rounds_gh` and `review_rounds_chat` are each exact, and the total
129
+ is exact once the resolver sends `reviews` and `commitTimes`.
130
+ - **Unknown commits.** While `commit_times` is null, `review_rounds_chat` stays null and
131
+ `review_rounds` equals `review_rounds_gh`.
132
+ - **Unparseable times.** A review whose time does not parse is ignored by the rule and stays
133
+ stored. If any of a PR's commit times does not parse, its commit times count as unknown,
134
+ since a dropped commit would shift every later head. `countRounds` returns null in that
135
+ case. `summary.reviews.invalidTimes` counts the ignored reviews plus the PRs with unusable
136
+ commit times.
137
+
138
+ **Resolving a chat verdict.** An exact `owner/name` must match a `pr` row, or the verdict
139
+ stays unresolved. Otherwise the candidates are the `pr` rows with that number, narrowed in
140
+ order. A repo hint keeps the rows whose repo's last path segment matches it, in any case; a
141
+ hint that matches none is ignored. Then the sender's family links keep the PRs `linked` from
142
+ the sender, its parent session, or any session that parent spawned. Last, the sender's
143
+ working directory repo, a bare name, is compared with each repo's last path segment. The first
144
+ step that leaves exactly one row resolves the verdict. A resolved `pr_ref` is kept on later
145
+ passes, and `summary.reviews` reports `resolved` (this pass) and `unresolved` (still null).
100
146
  - **Failure.** Same as the task resolver: the pass completes, the rows stand, and
101
147
  `summary.prs` carries `failed` plus `error`.
102
148
 
@@ -110,7 +156,19 @@ Kit tables: `transcript` (watermark), `edge` (bi-temporal, `session:… touched
110
156
  `search_span` + `search_fts` (contentless full-text over prompts, responses, tool inputs
111
157
  and results, keyed by the session ref). Domain tables: `fact`, `session`,
112
158
  `session_model_usage`, `turn`, `permission_phase`, `human_edit`, `file_checkpoint`, `pr`,
113
- `branch`, `file`, `task`, `subagent`, `artifact`, and the two PR observation tables.
159
+ `branch`, `file`, `task`, `subagent`, `artifact`, the two PR observation tables, and
160
+ `pr_review`.
161
+
162
+ `pr_review` holds one row per review verdict. A chat row comes from a `chat_send` tool use
163
+ that session-read parsed a verdict from, keyed `chat:<tool_use_id>:<n>` where `n` is the
164
+ verdict's `ordinal` in that message. An event from a session-read that predates `ordinal`
165
+ takes the next index its tool use has not used in that `applyDelta` call; that session-read
166
+ emits all of one tool use's verdicts from one line, so the keys cannot collide across calls. It keeps only the parsed fields (verdict, repo, repo hint,
167
+ PR number, and the repo of the sender's working directory), never message text, and is purged
168
+ with its transcript. `pr_ref` stays null until a later pass resolves it.
169
+
170
+ `resetIndex` drops `pr` rows and forge review rows with everything else, so `commit_times`,
171
+ `review_rounds_gh` and the forge reviews return only when a resolver answers again.
114
172
 
115
173
  Everything except `transcript` is derivable, which is what makes the schema safe to evolve
116
174
  by drop-and-rederive.
@@ -175,6 +233,25 @@ Migration 6, `episode transcript ids`, adds nullable `start_transcript_id` and
175
233
  `endTranscriptId`. A session resumed across two transcripts then orders by timestamp,
176
234
  transcript id, and byte offset, because byte offsets reset with the new file.
177
235
 
236
+ Migration 7, `origin task link`, adds nullable `task_ids` (a JSON array, primary id first)
237
+ and `task_source` columns to `session_origin`, surfaced as `ResolvedOrigin.taskIds` and
238
+ `taskSource`. It changes no existing row or edge. A row whose `task_source` is null is
239
+ offered to the resolver again on every pass, so the first pass with a task-aware resolver
240
+ is the backfill. A resolver that sets `taskIds`, even to an empty array or null, always
241
+ leaves `task_source` non-null: the given source, or `none` (`NO_TASK_LINK`) when there are
242
+ no ids. A resolver that leaves `taskIds` undefined leaves a stored link as it was, and a
243
+ row without one stays on offer. A task-aware resolver therefore returns an entry for every
244
+ requested session it examined, with `taskIds` empty when nothing links; a session it leaves
245
+ out keeps a null `task_source` and is offered again on every pass. The
246
+ upsert updates only the columns it names, so a later column keeps its value. Each linked id
247
+ projects a `task` row and a `session ran task` edge with `attrs = { via: "origin", source }`
248
+ and a confidence of 1.0 for `name` or `name-over-brief`, 0.9 for `brief-anchor` and 0.6 for
249
+ `brief-paragraph`. Only rows resolved in the current pass are projected. When a
250
+ re-resolution drops an id, only that origin-made edge expires. A transcript's `ran` edge
251
+ carries no `via`, and when a transcript claims an edge the origin made first, the edge is
252
+ superseded without `via`, so origin expiry never removes a transcript's claim.
253
+ session-graph stores and projects the ids a resolver hands it; it does not compute them.
254
+
178
255
  For snapshot-only usage across multiple physical sources, queries select one source
179
256
  by latest native usage timestamp, then greatest usage-record coverage and stable
180
257
  source ID. Source-local reset epochs cannot safely be summed across copies. This is
package/dist/index.d.ts CHANGED
@@ -39,7 +39,7 @@ declare const KIT: {
39
39
  */
40
40
  declare const DOMAIN_DDL = "\n CREATE TABLE IF NOT EXISTS fact (\n fact_id INTEGER PRIMARY KEY,\n transcript_id INTEGER NOT NULL,\n byte_offset INTEGER NOT NULL,\n byte_length INTEGER NOT NULL,\n event_type TEXT NOT NULL,\n ts TEXT NOT NULL,\n seq INTEGER NOT NULL,\n session_id TEXT NOT NULL,\n prompt_id TEXT,\n tool_use_id TEXT,\n t_indexed TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),\n UNIQUE (transcript_id, byte_offset)\n );\n CREATE INDEX IF NOT EXISTS idx_fact_session_ts ON fact(session_id, ts);\n CREATE INDEX IF NOT EXISTS idx_fact_prompt ON fact(prompt_id);\n CREATE INDEX IF NOT EXISTS idx_fact_tool_use ON fact(tool_use_id);\n\n CREATE TABLE IF NOT EXISTS session (\n session_id TEXT PRIMARY KEY,\n transcript_id INTEGER,\n started_at TEXT,\n ended_at TEXT,\n start_type TEXT,\n cwd TEXT,\n git_branch TEXT,\n ai_title TEXT,\n seed_prompt TEXT,\n cli_version TEXT,\n turn_count INTEGER NOT NULL DEFAULT 0,\n commit_count INTEGER NOT NULL DEFAULT 0,\n push_count INTEGER NOT NULL DEFAULT 0\n );\n CREATE INDEX IF NOT EXISTS idx_session_started ON session(started_at);\n\n CREATE TABLE IF NOT EXISTS session_model_usage (\n session_id TEXT NOT NULL,\n model TEXT NOT NULL,\n input_tokens INTEGER NOT NULL DEFAULT 0,\n output_tokens INTEGER NOT NULL DEFAULT 0,\n cache_read_tokens INTEGER NOT NULL DEFAULT 0,\n cache_creation_tokens INTEGER NOT NULL DEFAULT 0,\n thinking_tokens INTEGER NOT NULL DEFAULT 0,\n request_count INTEGER NOT NULL DEFAULT 0,\n PRIMARY KEY (session_id, model)\n );\n\n CREATE TABLE IF NOT EXISTS turn (\n prompt_id TEXT PRIMARY KEY,\n session_id TEXT NOT NULL,\n turn_index INTEGER NOT NULL,\n started_at TEXT NOT NULL,\n ended_at TEXT,\n duration_ms INTEGER,\n tool_call_count INTEGER NOT NULL DEFAULT 0,\n thinking_ms INTEGER NOT NULL DEFAULT 0,\n fact_id_start INTEGER\n );\n CREATE INDEX IF NOT EXISTS idx_turn_session ON turn(session_id, turn_index);\n\n CREATE TABLE IF NOT EXISTS permission_phase (\n phase_id INTEGER PRIMARY KEY,\n session_id TEXT NOT NULL,\n from_mode TEXT,\n to_mode TEXT NOT NULL,\n trigger TEXT NOT NULL,\n t_valid TEXT NOT NULL,\n t_invalid TEXT,\n fact_id INTEGER\n );\n CREATE INDEX IF NOT EXISTS idx_phase_session ON permission_phase(session_id, t_valid);\n\n CREATE TABLE IF NOT EXISTS human_edit (\n edit_id INTEGER PRIMARY KEY,\n session_id TEXT NOT NULL,\n file_path TEXT NOT NULL,\n ts TEXT NOT NULL,\n fact_id INTEGER,\n UNIQUE (session_id, file_path, ts)\n );\n\n CREATE TABLE IF NOT EXISTS file_checkpoint (\n checkpoint_id INTEGER PRIMARY KEY,\n session_id TEXT NOT NULL,\n file_path TEXT NOT NULL,\n backup_file_name TEXT NOT NULL,\n version INTEGER NOT NULL,\n backup_time TEXT NOT NULL,\n fact_id INTEGER,\n UNIQUE (session_id, file_path, backup_file_name)\n );\n\n CREATE TABLE IF NOT EXISTS pr (\n pr_ref TEXT PRIMARY KEY, number INTEGER, repo TEXT, title TEXT, state TEXT, url TEXT, merged_at TEXT\n );\n CREATE TABLE IF NOT EXISTS branch (\n branch_ref TEXT PRIMARY KEY, repo TEXT, name TEXT NOT NULL, base TEXT, created_at TEXT, deleted_at TEXT\n );\n CREATE TABLE IF NOT EXISTS file (\n file_ref TEXT PRIMARY KEY, repo TEXT, path TEXT NOT NULL\n );\n CREATE TABLE IF NOT EXISTS task (\n task_ref TEXT PRIMARY KEY, task_id TEXT NOT NULL, initiative TEXT, title TEXT, status TEXT\n );\n CREATE TABLE IF NOT EXISTS subagent (\n agent_ref TEXT PRIMARY KEY,\n session_id TEXT,\n child_session_id TEXT,\n parent_agent_ref TEXT,\n agent_type TEXT,\n label TEXT,\n started_at TEXT,\n ended_at TEXT,\n fact_id INTEGER\n );\n CREATE INDEX IF NOT EXISTS idx_subagent_child ON subagent(child_session_id);\n CREATE TABLE IF NOT EXISTS artifact (\n artifact_ref TEXT PRIMARY KEY, kind TEXT, title TEXT, url TEXT, path TEXT, created_at TEXT\n );\n CREATE TABLE IF NOT EXISTS pr_merge_observation (\n number INTEGER NOT NULL, repo_hint TEXT, merged_at TEXT NOT NULL,\n PRIMARY KEY (number, repo_hint, merged_at)\n );\n CREATE TABLE IF NOT EXISTS pr_create_observation (\n tool_use_id TEXT PRIMARY KEY, title TEXT, number INTEGER, repo TEXT, url TEXT\n );\n";
41
41
  /** Every derived table, in an order safe to clear. The watermark table is not derived. */
42
- declare const DERIVED_TABLES: readonly ["normalized_span", "normalized_event", "normalized_source", "search_span", "edge", "turn", "permission_phase", "human_edit", "file_checkpoint", "subagent", "request", "tool_call", "inbound", "context_block", "compaction", "queue_op", "session_signal", "cost_state_observation", "transcript_facet", "session_origin", "session_external_event", "episode", "session_model_usage", "session", "fact", "pr", "pr_merge_observation", "pr_create_observation", "branch", "file", "task", "artifact"];
42
+ declare const DERIVED_TABLES: readonly ["normalized_span", "normalized_event", "normalized_source", "search_span", "edge", "turn", "permission_phase", "human_edit", "file_checkpoint", "subagent", "request", "tool_call", "inbound", "context_block", "compaction", "queue_op", "session_signal", "cost_state_observation", "transcript_facet", "session_origin", "session_external_event", "episode", "session_model_usage", "session", "fact", "pr", "pr_merge_observation", "pr_create_observation", "pr_review", "branch", "file", "task", "artifact"];
43
43
  declare const MIGRATIONS: Migration[];
44
44
 
45
45
  /** Recorded in `_migration`; store-sqlite refuses a database whose applied name differs, so never rename it. */
@@ -59,6 +59,18 @@ declare const EPISODE_TABLE = "episode";
59
59
  declare const ORIGIN_DDL = "\n CREATE TABLE IF NOT EXISTS session_origin ( -- not derived from transcripts\n session_id TEXT PRIMARY KEY,\n origin_system TEXT NOT NULL, -- 'agent-chat'\n agent_id TEXT, agent_name TEXT, parent_name TEXT, parent_session_id TEXT,\n profile TEXT, model_alias TEXT, surface TEXT, isolation TEXT, depth INTEGER,\n origin_kind TEXT, -- spawned | adopted | inherited | human\n config_dir TEXT, spawn_cwd TEXT, spawned_at TEXT,\n brief_chars INTEGER, brief_excerpt TEXT, brief_path TEXT, launch_args TEXT,\n resolved_at TEXT NOT NULL\n );\n CREATE INDEX IF NOT EXISTS idx_origin_parent ON session_origin(parent_session_id);\n\n CREATE TABLE IF NOT EXISTS session_external_event ( -- teleport, handoff, retire, exit\n session_id TEXT NOT NULL, ts TEXT NOT NULL, origin_system TEXT NOT NULL,\n kind TEXT NOT NULL, detail TEXT,\n PRIMARY KEY (session_id, ts, kind)\n ) WITHOUT ROWID;\n\n CREATE TABLE IF NOT EXISTS episode ( -- provisional, see section 9\n session_id TEXT NOT NULL, episode_index INTEGER NOT NULL,\n heuristic TEXT NOT NULL, heuristic_version INTEGER NOT NULL,\n started_at TEXT NOT NULL, ended_at TEXT NOT NULL,\n start_offset INTEGER NOT NULL, end_offset INTEGER NOT NULL,\n opened_by TEXT NOT NULL, -- brief | channel_followup | idle_gap | compaction\n assignment_offset INTEGER, first_deliverable_offset INTEGER, first_deliverable_signal TEXT,\n first_status_offset INTEGER,\n PRIMARY KEY (session_id, heuristic, episode_index)\n ) WITHOUT ROWID;\n\n CREATE TABLE IF NOT EXISTS price (\n model TEXT NOT NULL, effective_from TEXT NOT NULL, table_version INTEGER NOT NULL,\n input_usd_mtok REAL NOT NULL, cache_read_usd_mtok REAL NOT NULL,\n cache_write_5m_usd_mtok REAL NOT NULL, cache_write_1h_usd_mtok REAL NOT NULL,\n output_usd_mtok REAL NOT NULL, source TEXT,\n PRIMARY KEY (model, effective_from)\n ) WITHOUT ROWID;\n";
60
60
  declare const ORIGIN_VIEWS: readonly ["\n CREATE VIEW IF NOT EXISTS request_dedup AS\n SELECT transcript_id, request_id, byte_offset, session_id, message_id, ts, model,\n input_tokens, cache_read_tokens, cache_creation_tokens, cache_creation_5m, cache_creation_1h,\n output_tokens, thinking_tokens, context_tokens, service_tier, is_sidechain,\n seq_in_session, gap_ms, ctx_delta, wake_cause, wake_delivery, wake_detail,\n wake_tool_family, wake_mcp_server, wake_offset FROM (\n SELECT r.*, ROW_NUMBER() OVER (PARTITION BY r.request_id ORDER BY r.ts, r.transcript_id) AS copy_rank\n FROM request r\n ) WHERE copy_rank = 1;\n", "\n CREATE VIEW IF NOT EXISTS request_cost AS\n WITH candidate AS (\n SELECT d.request_id, p.model AS price_model, p.effective_from AS price_effective_from,\n p.input_usd_mtok, p.cache_read_usd_mtok, p.cache_write_5m_usd_mtok, p.cache_write_1h_usd_mtok, p.output_usd_mtok,\n ROW_NUMBER() OVER (PARTITION BY d.request_id ORDER BY length(p.model) DESC, p.effective_from DESC) AS match_rank\n FROM request_dedup d\n JOIN price p ON substr(d.model, 1, length(p.model)) = p.model AND p.effective_from <= d.ts\n ), component AS (\n SELECT d.*, c.price_model, c.price_effective_from, c.price_model IS NOT NULL AS priced,\n COALESCE(d.input_tokens * c.input_usd_mtok, 0) / 1e6 AS input_cost_usd,\n COALESCE(d.cache_read_tokens * c.cache_read_usd_mtok, 0) / 1e6 AS cache_read_cost_usd,\n COALESCE(CASE WHEN d.cache_creation_5m + d.cache_creation_1h = 0 THEN d.cache_creation_tokens ELSE d.cache_creation_5m END\n * c.cache_write_5m_usd_mtok, 0) / 1e6 AS cache_write_5m_cost_usd,\n COALESCE(d.cache_creation_1h * c.cache_write_1h_usd_mtok, 0) / 1e6 AS cache_write_1h_cost_usd,\n COALESCE(d.output_tokens * c.output_usd_mtok, 0) / 1e6 AS output_cost_usd\n FROM request_dedup d\n LEFT JOIN candidate c ON c.request_id = d.request_id AND c.match_rank = 1\n )\n SELECT *,\n input_cost_usd + cache_read_cost_usd + cache_write_5m_cost_usd + cache_write_1h_cost_usd + output_cost_usd AS cost_usd,\n (cache_read_tokens < 0.2 * context_tokens AND cache_creation_tokens >= 20000) AS is_cold,\n CASE WHEN context_tokens < 50000 THEN '<50k' WHEN context_tokens < 100000 THEN '50-100k'\n WHEN context_tokens < 200000 THEN '100-200k' ELSE '200k+' END AS context_band,\n CASE WHEN gap_ms IS NULL THEN NULL WHEN gap_ms < 300000 THEN '<5m'\n WHEN gap_ms < 3600000 THEN '5-60m' ELSE '>60m' END AS gap_band\n FROM component;\n", "\n CREATE VIEW IF NOT EXISTS context_contribution AS\n WITH stream AS (\n SELECT transcript_id, byte_offset, 0 AS lane, block_index, NULL AS request_id FROM context_block\n UNION ALL\n SELECT transcript_id, byte_offset, -1 AS lane, 0, request_id FROM request\n ), ordered AS (\n SELECT *, COUNT(request_id) OVER (PARTITION BY transcript_id ORDER BY byte_offset, lane, block_index ROWS UNBOUNDED PRECEDING) AS requests_before\n FROM stream\n ), owner AS (\n SELECT b.transcript_id, b.byte_offset, b.block_index, r.request_id\n FROM ordered b JOIN ordered r ON r.transcript_id = b.transcript_id AND r.lane = -1 AND r.requests_before = b.requests_before + 1\n WHERE b.lane = 0\n ), tool AS (\n SELECT tool_use_id, name, family, mcp_server,\n ROW_NUMBER() OVER (PARTITION BY tool_use_id ORDER BY transcript_id, byte_offset, block_index) AS copy_rank\n FROM tool_call\n )\n SELECT cb.transcript_id, cb.byte_offset, cb.block_index, cb.session_id, cb.ts, cb.source,\n cb.tool_use_id, cb.attachment_type, cb.chars, cb.is_media, o.request_id, d.ctx_delta,\n CASE WHEN cb.source IN ('assistant_text', 'assistant_thinking', 'assistant_tool_input') THEN NULL\n ELSE d.ctx_delta * cb.chars * 1.0 / NULLIF(SUM(CASE WHEN cb.source IN ('assistant_text', 'assistant_thinking', 'assistant_tool_input') THEN 0 ELSE cb.chars END)\n OVER (PARTITION BY o.transcript_id, o.request_id), 0) END AS est_tokens,\n t.name AS tool_name, t.family AS tool_family, t.mcp_server\n FROM context_block cb\n JOIN owner o USING (transcript_id, byte_offset, block_index)\n JOIN request_dedup d ON d.transcript_id = o.transcript_id AND d.request_id = o.request_id\n LEFT JOIN tool t ON t.tool_use_id = cb.tool_use_id AND t.copy_rank = 1;\n"];
61
61
 
62
+ /** Recorded in `_migration`; store-sqlite refuses a database whose applied name differs, so never rename it. */
63
+ declare const ORIGIN_TASK_LINK_MIGRATION_NAME = "origin task link";
64
+
65
+ /** Recorded in `_migration`; store-sqlite refuses a database whose applied name differs, so never rename it. */
66
+ declare const REVIEW_VERDICT_MIGRATION_NAME = "review verdicts";
67
+ declare const REVIEW_TABLE = "pr_review";
68
+ /**
69
+ * One row per review verdict, from either surface. Only parsed fields are kept, never message text.
70
+ * `source_key` is `chat:<tool_use_id>:<n>` or `gh:<pr_ref>:<submitted_at>`; `pr_ref` is resolved later.
71
+ */
72
+ declare const REVIEW_DDL = "\n CREATE TABLE IF NOT EXISTS pr_review (\n source_key TEXT PRIMARY KEY,\n surface TEXT NOT NULL,\n verdict TEXT NOT NULL,\n ts TEXT NOT NULL,\n session_id TEXT,\n transcript_id INTEGER,\n repo TEXT,\n repo_hint TEXT,\n cwd_repo TEXT,\n number INTEGER NOT NULL,\n pr_ref TEXT\n );\n CREATE INDEX IF NOT EXISTS idx_pr_review_pr_ref ON pr_review(pr_ref);\n CREATE INDEX IF NOT EXISTS idx_pr_review_transcript ON pr_review(transcript_id);\n";
73
+
62
74
  /** Write one delta's audit events. Runs inside `applyDelta`'s transaction, so it opens none of its own. */
63
75
  declare function applyAudit(db: Db, transcriptId: number, delta: TranscriptDelta): void;
64
76
 
@@ -119,8 +131,8 @@ interface DeltaSource {
119
131
  * the only source. The two observation tables are left for the same reason —
120
132
  * `reconcile` folds them, and re-reading re-asserts the same rows.
121
133
  *
122
- * Audit rows and facet state carry `transcript_id` themselves, so they go by it
123
- * directly rather than through `session`.
134
+ * Audit rows, facet state and chat review verdicts carry `transcript_id`
135
+ * themselves, so they go by it directly rather than through `session`.
124
136
  */
125
137
  declare function purgeTranscript(graph: SessionGraph, transcriptId: number): void;
126
138
 
@@ -150,8 +162,17 @@ interface ResolvedPr {
150
162
  state?: string | null;
151
163
  mergedAt?: string | null;
152
164
  closedAt?: string | null;
153
- /** Stored as the resolver counts it; the round definition is open question Q6 in the TP-256 design. */
165
+ /** Stored as the forge's round count when `reviews` or `commitTimes` is absent; otherwise the round rule counts it. */
154
166
  reviewRounds?: number | null;
167
+ /** The PR's commit times; an empty array is stored as known-empty, an omitted field leaves the stored value. */
168
+ commitTimes?: readonly string[];
169
+ /** Replaces the PR's stored forge reviews; an omitted field leaves them. */
170
+ reviews?: readonly ResolvedReview[];
171
+ }
172
+ /** One forge review. `APPROVED` and `CHANGES_REQUESTED`, in any case, are stored; other states are ignored. */
173
+ interface ResolvedReview {
174
+ state: string;
175
+ submittedAt: string;
155
176
  }
156
177
  /** Resolutions keyed by `pr_ref` (`pr:acme/demo#7`). */
157
178
  type PrResolution = ReadonlyMap<string, ResolvedPr | null | undefined>;
@@ -206,7 +227,18 @@ interface ResolvedOrigin {
206
227
  briefExcerpt?: string | null;
207
228
  briefPath?: string | null;
208
229
  launchArgs?: string | null;
230
+ /**
231
+ * Task ids the spawn record assigned, primary first; each projects a `ran` edge. Leave it
232
+ * undefined only if the resolver does not look for tasks: a stored link then stands and a
233
+ * row without one is offered again. A task-aware resolver sets it, empty when nothing links.
234
+ */
235
+ taskIds?: readonly string[] | null;
236
+ /** How the ids were found. Stored as `none` when `taskIds` is given but empty. */
237
+ taskSource?: TaskLinkSource | null;
209
238
  }
239
+ /** Stored when a task-aware resolver found no id, so the row is not offered again. */
240
+ declare const NO_TASK_LINK = "none";
241
+ type TaskLinkSource = "name" | "name-over-brief" | "brief-anchor" | "brief-paragraph" | typeof NO_TASK_LINK;
210
242
  /** A lifecycle event the launcher saw outside the transcript: `teleport`, `handoff`, `retired`, `exited`. */
211
243
  interface ExternalEvent {
212
244
  sessionId: string;
@@ -224,7 +256,9 @@ interface OriginResolution {
224
256
  /**
225
257
  * Supplied by the caller, never by this package: session-graph is tier 2 and
226
258
  * must not learn where a launcher keeps its records. Called once per pass with
227
- * every session that has no origin row or a stale one.
259
+ * every session that has no origin row, a stale one, or a null `task_source`.
260
+ * A task-aware resolver returns an entry for every requested session it examined,
261
+ * with `taskIds` empty when nothing links; a session left out is offered again.
228
262
  */
229
263
  type OriginResolver = (sessionIds: readonly string[]) => PromiseLike<OriginResolution> | OriginResolution;
230
264
  interface OriginEnrichment {
@@ -249,6 +283,34 @@ declare function sessionsNeedingOrigin(graph: SessionGraph): string[];
249
283
  */
250
284
  declare function resolveOrigins(graph: SessionGraph, resolver: OriginResolver | undefined): Promise<OriginEnrichment>;
251
285
 
286
+ /** Decides from a sender's `session_origin.profile` whether its chat verdicts count. */
287
+ type ReviewerProfilePredicate = (profile: string) => boolean;
288
+ /** The default role set: `reviewer`, or any profile ending in `-reviewer`. */
289
+ declare const isReviewerProfile: ReviewerProfilePredicate;
290
+ interface ReviewRoundOptions {
291
+ isReviewerProfile?: ReviewerProfilePredicate;
292
+ }
293
+ interface ReviewProjection {
294
+ /** Chat verdicts given a `pr_ref` this pass. */
295
+ resolved: number;
296
+ /** Chat verdicts whose PR is still unknown. */
297
+ unresolved: number;
298
+ /** Reviews ignored for a time that does not parse, plus PRs whose commit times were unusable. */
299
+ invalidTimes: number;
300
+ }
301
+ /**
302
+ * The number of distinct heads among changes-requested reviews that a later commit answered.
303
+ * A review's head is the count of commits at or before it, so two reviews on one head count once.
304
+ * A review whose time does not parse is ignored. Returns null when any commit time does not
305
+ * parse, because a missing commit would shift every later head.
306
+ */
307
+ declare function countRounds(changesRequestedAt: readonly string[], commitTimes: readonly string[]): number | null;
308
+ /**
309
+ * Resolve each unresolved chat verdict to a PR, then write `review_rounds_chat` and `review_rounds`
310
+ * for every PR. Runs after `enrichPrs`, so forge reviews and commit times are current.
311
+ */
312
+ declare function projectReviewRounds(graph: SessionGraph, options?: ReviewRoundOptions): ReviewProjection;
313
+
252
314
  /**
253
315
  * What a product's task store knows and a transcript cannot state: the task's
254
316
  * present title, its initiative, and the status it holds right now rather than
@@ -305,6 +367,8 @@ interface IndexOptions {
305
367
  resolvePrs?: PrResolver;
306
368
  }
307
369
  interface RefreshOptions extends IndexOptions {
370
+ /** Which sender profiles' chat verdicts count toward review rounds. Defaults to `reviewer` and `*-reviewer`. */
371
+ isReviewerProfile?: ReviewerProfilePredicate;
308
372
  /** Roll up every session, not just the ones this pass touched. */
309
373
  full?: boolean;
310
374
  /** Stale audit facets re-extracted per pass (default 40). `Infinity` clears the backlog. */
@@ -343,6 +407,7 @@ interface RefreshSummary {
343
407
  tasks: TaskEnrichment;
344
408
  origins: OriginEnrichment;
345
409
  prs: PrEnrichment;
410
+ reviews: ReviewProjection;
346
411
  markedMissing: number;
347
412
  facetsBackfilled: number;
348
413
  /** Transcripts whose audit facet is still stale after this pass. */
@@ -352,7 +417,7 @@ interface RefreshSummary {
352
417
  * One pass over a corpus: index every transcript, re-extract a bounded batch of
353
418
  * stale audit facets, roll up the sessions that changed, resolve session
354
419
  * origins, reconcile cross-transcript observations, resolve PR outcomes,
355
- * enrich tasks, and mark rows whose source file is gone. Idempotent: a second pass over unchanged
420
+ * project review rounds, enrich tasks, and mark rows whose source file is gone. Idempotent: a second pass over unchanged
356
421
  * files changes nothing.
357
422
  *
358
423
  * The resolver is hoisted out of the per-transcript loop and run once over the
@@ -455,4 +520,4 @@ declare function normalizedUsage(graph: SessionGraph, ref: string): NormalizedUs
455
520
  /** Ambiguity is explicit; workspace session bodies remain in their original namespace. */
456
521
  declare function resolveConversationAlias(db: Db, legacyRef: string): string | null;
457
522
 
458
- export { AUDIT_DDL, AUDIT_FACET, AUDIT_MIGRATION_NAME, AUDIT_TABLES, type BackfillOptions, type BackfillSummary, type ConversationSummary, DEFAULT_FACET_LIMIT, DERIVED_TABLES, DOMAIN_DDL, type DeltaSource, EPISODE_TABLE, type EpisodeRow, type ExternalEvent, FACET_TABLE, type IndexOptions, type IndexedSpan, KIT, MIGRATIONS, NO_ENRICHMENT, NO_ORIGINS, NO_PR_OUTCOMES, type NormalizedIndexResult, type NormalizedUsageSummary, ORIGIN_DDL, ORIGIN_MIGRATION_NAME, ORIGIN_TABLES, ORIGIN_VIEWS, type OpenSessionGraphOptions, type OriginEnrichment, type OriginResolution, type OriginResolver, type PrEnrichment, type PrKey, type PrResolution, type PrResolver, type PriceInput, type ReconcileCounts, type RefreshOptions, type RefreshSummary, type ResolvedOrigin, type ResolvedPr, type ResolvedTask, type SessionGraph, type SyncPricesOptions, type TaskEnrichment, type TaskResolution, type TaskResolver, type TranscriptOutcome, allSessionIds, allTaskIds, applyAudit, applyDelta, backfillFacets, enrichPrs, enrichTasks, indexCodexSource, indexTranscript, normalizedSessions, normalizedUsage, openSessionGraph, prsNeedingOutcome, purgeTranscript, readIndexedText, reconcile, refreshCorpus, replaceEpisodes, resetIndex, resolveConversationAlias, resolveOrigins, rollupSessions, sessionsNeedingOrigin, syncPrices };
523
+ export { AUDIT_DDL, AUDIT_FACET, AUDIT_MIGRATION_NAME, AUDIT_TABLES, type BackfillOptions, type BackfillSummary, type ConversationSummary, DEFAULT_FACET_LIMIT, DERIVED_TABLES, DOMAIN_DDL, type DeltaSource, EPISODE_TABLE, type EpisodeRow, type ExternalEvent, FACET_TABLE, type IndexOptions, type IndexedSpan, KIT, MIGRATIONS, NO_ENRICHMENT, NO_ORIGINS, NO_PR_OUTCOMES, NO_TASK_LINK, type NormalizedIndexResult, type NormalizedUsageSummary, ORIGIN_DDL, ORIGIN_MIGRATION_NAME, ORIGIN_TABLES, ORIGIN_TASK_LINK_MIGRATION_NAME, ORIGIN_VIEWS, type OpenSessionGraphOptions, type OriginEnrichment, type OriginResolution, type OriginResolver, type PrEnrichment, type PrKey, type PrResolution, type PrResolver, type PriceInput, REVIEW_DDL, REVIEW_TABLE, REVIEW_VERDICT_MIGRATION_NAME, type ReconcileCounts, type RefreshOptions, type RefreshSummary, type ResolvedOrigin, type ResolvedPr, type ResolvedReview, type ResolvedTask, type ReviewProjection, type ReviewRoundOptions, type ReviewerProfilePredicate, type SessionGraph, type SyncPricesOptions, type TaskEnrichment, type TaskLinkSource, type TaskResolution, type TaskResolver, type TranscriptOutcome, allSessionIds, allTaskIds, applyAudit, applyDelta, backfillFacets, countRounds, enrichPrs, enrichTasks, indexCodexSource, indexTranscript, isReviewerProfile, normalizedSessions, normalizedUsage, openSessionGraph, projectReviewRounds, prsNeedingOutcome, purgeTranscript, readIndexedText, reconcile, refreshCorpus, replaceEpisodes, resetIndex, resolveConversationAlias, resolveOrigins, rollupSessions, sessionsNeedingOrigin, syncPrices };