agentwrangler 0.1.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.
Files changed (157) hide show
  1. package/LICENSE +191 -0
  2. package/README.md +116 -0
  3. package/dist/apply/jobs.js +429 -0
  4. package/dist/apply/open-terminal-child.mjs +98 -0
  5. package/dist/apply/open-terminal.js +221 -0
  6. package/dist/apply/settings-gen.js +35 -0
  7. package/dist/cli/agentwrangler.js +18 -0
  8. package/dist/daemon/config.js +51 -0
  9. package/dist/daemon/http.js +258 -0
  10. package/dist/daemon/index.js +372 -0
  11. package/dist/daemon/outcomes-pass.js +82 -0
  12. package/dist/daemon/readiness.js +15 -0
  13. package/dist/daemon/router.js +756 -0
  14. package/dist/daemon/static.js +146 -0
  15. package/dist/db/migrate.js +72 -0
  16. package/dist/db/migrations/001_observe.sql +196 -0
  17. package/dist/db/migrations/002_indexes.sql +6 -0
  18. package/dist/db/migrations/003_context_inventory_history.sql +20 -0
  19. package/dist/db/migrations/004_apply_jobs.sql +17 -0
  20. package/dist/db/migrations/005_tool_event_metadata.sql +17 -0
  21. package/dist/db/migrations/006_d7_query_indexes.sql +9 -0
  22. package/dist/db/migrations/007_work_item_branch_keys.sql +11 -0
  23. package/dist/db/migrations/008_thinking_tokens.sql +1 -0
  24. package/dist/db/migrations/009_user_turn_count.sql +1 -0
  25. package/dist/db/migrations/010_workspace_cwd.sql +1 -0
  26. package/dist/db/migrations/011_reports.sql +1 -0
  27. package/dist/db/migrations/012_reconcile_indexes.sql +2 -0
  28. package/dist/db/migrations/013_friction_fields.sql +5 -0
  29. package/dist/db/migrations/014_session_churn.sql +11 -0
  30. package/dist/db/migrations/015_gap_aggregates.sql +6 -0
  31. package/dist/db/open.js +30 -0
  32. package/dist/detector/benchmark-anchors.js +36 -0
  33. package/dist/detector/calibration.js +302 -0
  34. package/dist/detector/context-history-retention.js +312 -0
  35. package/dist/detector/context-probe.js +574 -0
  36. package/dist/detector/d1-source-identity.js +25 -0
  37. package/dist/detector/detectors/d10_catalog_footprint.js +146 -0
  38. package/dist/detector/detectors/d1_ctx_always_loaded.js +203 -0
  39. package/dist/detector/detectors/d2_session_long_full_context.js +119 -0
  40. package/dist/detector/detectors/d4_model_mismatch.js +258 -0
  41. package/dist/detector/detectors/d5_limit_burn_forecast.js +138 -0
  42. package/dist/detector/detectors/d6_tool_result_bloat.js +301 -0
  43. package/dist/detector/detectors/d7_loop_retry_waste.js +345 -0
  44. package/dist/detector/detectors/d8_cache_write_churn.js +201 -0
  45. package/dist/detector/detectors/d9_idle_background_session.js +101 -0
  46. package/dist/detector/engine.js +88 -0
  47. package/dist/detector/index.js +17 -0
  48. package/dist/detector/measurement.js +426 -0
  49. package/dist/detector/practice-registry.js +259 -0
  50. package/dist/detector/registry.js +32 -0
  51. package/dist/detector/savings.js +249 -0
  52. package/dist/detector/types.js +14 -0
  53. package/dist/evidence/common/approved-input.js +632 -0
  54. package/dist/evidence/common/boundary.js +84 -0
  55. package/dist/evidence/common/canonical.js +55 -0
  56. package/dist/evidence/common/redaction.js +321 -0
  57. package/dist/evidence/common/sqlite.js +25 -0
  58. package/dist/evidence/common/state.js +29 -0
  59. package/dist/evidence/cond1/cli.js +289 -0
  60. package/dist/evidence/cond1/packet.js +407 -0
  61. package/dist/evidence/cond1/prepare.js +295 -0
  62. package/dist/evidence/cond1/score.js +349 -0
  63. package/dist/evidence/cond1/types.js +1 -0
  64. package/dist/evidence/create-approval.js +365 -0
  65. package/dist/evidence/create-scratch.js +542 -0
  66. package/dist/evidence/d7/cli.js +113 -0
  67. package/dist/evidence/d7/measure.js +193 -0
  68. package/dist/evidence/d7/types.js +1 -0
  69. package/dist/evidence/discover-approval.js +492 -0
  70. package/dist/evidence/g2/adjudicate.js +20 -0
  71. package/dist/evidence/g2/cli.js +207 -0
  72. package/dist/evidence/g2/kappa.js +39 -0
  73. package/dist/evidence/g2/pipeline.js +92 -0
  74. package/dist/evidence/g2/store.js +14 -0
  75. package/dist/evidence/github/client.js +1 -0
  76. package/dist/evidence/github/gh-cli-client.js +301 -0
  77. package/dist/evidence/r3/cli.js +209 -0
  78. package/dist/evidence/r3/evaluate.js +417 -0
  79. package/dist/evidence/r3/packet.js +162 -0
  80. package/dist/evidence/r3/prepare.js +405 -0
  81. package/dist/evidence/r3/score.js +341 -0
  82. package/dist/evidence/r3/transcript.js +155 -0
  83. package/dist/evidence/r3/types.js +4 -0
  84. package/dist/hook/context-budget-hook.mjs +138 -0
  85. package/dist/hook/danger-guard-denylist.json +27 -0
  86. package/dist/hook/danger-guard-hook.mjs +167 -0
  87. package/dist/hook/install.js +0 -0
  88. package/dist/hook/limit-burn-hook.mjs +127 -0
  89. package/dist/hook/loop-guard-hook.mjs +104 -0
  90. package/dist/hook/precompact-checkpoint-hook.mjs +123 -0
  91. package/dist/ingest/churn-collector.js +122 -0
  92. package/dist/ingest/detector-hook.js +52 -0
  93. package/dist/ingest/discovery.js +207 -0
  94. package/dist/ingest/health.js +43 -0
  95. package/dist/ingest/index.js +28 -0
  96. package/dist/ingest/ingestor.js +509 -0
  97. package/dist/ingest/parser.js +344 -0
  98. package/dist/ingest/pricing.js +153 -0
  99. package/dist/ingest/reconcile.js +52 -0
  100. package/dist/ingest/tail.js +152 -0
  101. package/dist/ingest/types.js +24 -0
  102. package/dist/ingest/workspace-mapping.js +114 -0
  103. package/dist/oauth/anthropic-api-key.js +88 -0
  104. package/dist/oauth/count-tokens.js +86 -0
  105. package/dist/oauth/credentials.js +171 -0
  106. package/dist/oauth/judge-g2-client.js +154 -0
  107. package/dist/oauth/usage.js +167 -0
  108. package/dist/outcomes/branch-key.js +49 -0
  109. package/dist/outcomes/conclusions.js +45 -0
  110. package/dist/outcomes/derive.js +94 -0
  111. package/dist/outcomes/finding-extractors.js +131 -0
  112. package/dist/outcomes/findings.js +237 -0
  113. package/dist/outcomes/github/client.js +367 -0
  114. package/dist/outcomes/github/credential.js +195 -0
  115. package/dist/outcomes/github/gh-cli-client.js +340 -0
  116. package/dist/outcomes/linker.js +486 -0
  117. package/dist/outcomes/pool.js +24 -0
  118. package/dist/outcomes/sync.js +276 -0
  119. package/dist/query/api/agents-liveness.js +182 -0
  120. package/dist/query/api/burn-status.js +50 -0
  121. package/dist/query/api/context-budget.js +114 -0
  122. package/dist/query/api/context-composition.js +67 -0
  123. package/dist/query/api/cost-per-success.js +104 -0
  124. package/dist/query/api/delivery.js +92 -0
  125. package/dist/query/api/effectiveness.js +254 -0
  126. package/dist/query/api/efficiency-headroom.js +74 -0
  127. package/dist/query/api/headroom-trend.js +105 -0
  128. package/dist/query/api/hook-config.js +75 -0
  129. package/dist/query/api/hook-install.js +8 -0
  130. package/dist/query/api/hot-sessions.js +17 -0
  131. package/dist/query/api/idle-sessions.js +52 -0
  132. package/dist/query/api/index.js +40 -0
  133. package/dist/query/api/loop-guard.js +90 -0
  134. package/dist/query/api/offload-share.js +41 -0
  135. package/dist/query/api/outcomes.js +218 -0
  136. package/dist/query/api/overview.js +535 -0
  137. package/dist/query/api/rec-prompt.js +138 -0
  138. package/dist/query/api/recommendations-ledger.js +111 -0
  139. package/dist/query/api/recommendations.js +514 -0
  140. package/dist/query/api/reports.js +78 -0
  141. package/dist/query/api/self-churn.js +77 -0
  142. package/dist/query/api/self-percentiles.js +109 -0
  143. package/dist/query/api/session-drivers.js +153 -0
  144. package/dist/query/api/settings.js +85 -0
  145. package/dist/query/api/spend-flavor.js +234 -0
  146. package/dist/query/api/trends.js +155 -0
  147. package/dist/query/cap-weighted.js +119 -0
  148. package/dist/query/db-context.js +42 -0
  149. package/dist/query/envelope.js +71 -0
  150. package/dist/query/forecast.js +191 -0
  151. package/dist/query/settings-store.js +441 -0
  152. package/dist/query/spend.js +171 -0
  153. package/dist/query/trends.js +194 -0
  154. package/dist/ui/assets/index-DnRKgc21.css +1 -0
  155. package/dist/ui/assets/index-h1Q1wWq5.js +168 -0
  156. package/dist/ui/index.html +39 -0
  157. package/package.json +59 -0
@@ -0,0 +1,486 @@
1
+ /**
2
+ * src/outcomes/linker.ts — OutcomeLinker.
3
+ *
4
+ * Links sessions to work_items via ADR-103 D-7 precedence:
5
+ * 1. PR_LINK (conf 1.0) — in-memory re-harvested pr-link records
6
+ * 2. SHA_OVERLAP (conf min(0.9, 0.5+0.1·|∩|)) — tool_events.commit_sha ∩ PR SHAs
7
+ * SHA in ≥2 PRs → UNLINKED (ambiguity → honesty)
8
+ * 3. BRANCH (conf 0.6) — [UNMEASURED] coded-but-disabled stub (FLAG-D3)
9
+ * 4. MANUAL (conf 1.0) — existing row from UI link/unlink
10
+ *
11
+ * UNLINKED is implicit (no row in session_work_links).
12
+ *
13
+ * pr-link re-harvest: reads transcript files in-memory for pr-link records.
14
+ * Technique from spikes/s3-linkage/s3-harvest.mjs:68-75.
15
+ * Only ids/PR numbers are retained — no content stored (SEC-101).
16
+ */
17
+ import * as fs from "node:fs";
18
+ import * as readline from "node:readline";
19
+ import { fingerprintBranchRef } from "./branch-key.js";
20
+ export function snapshotSessionWorkLinks(db) {
21
+ const rows = db
22
+ .prepare(`SELECT session_id, work_item_id, confidence, method
23
+ FROM session_work_links
24
+ ORDER BY session_id, work_item_id, confidence, method`)
25
+ .all();
26
+ return JSON.stringify(rows.map(({ session_id, work_item_id, confidence, method }) => [
27
+ session_id,
28
+ work_item_id,
29
+ confidence,
30
+ method,
31
+ ]));
32
+ }
33
+ // ---------------------------------------------------------------------------
34
+ // Transcript pr-link harvester
35
+ // ---------------------------------------------------------------------------
36
+ /**
37
+ * Re-harvest pr-link records from a transcript JSONL file in-memory.
38
+ * Returns only ids/PR numbers — body content is discarded.
39
+ */
40
+ async function harvestTranscript(filePath, collectBranchKeys) {
41
+ const links = [];
42
+ const branchKeys = new Set();
43
+ try {
44
+ const rl = readline.createInterface({
45
+ input: fs.createReadStream(filePath, { encoding: "utf-8" }),
46
+ crlfDelay: Number.POSITIVE_INFINITY,
47
+ });
48
+ for await (const line of rl) {
49
+ const trimmed = line.trim();
50
+ if (trimmed.length === 0)
51
+ continue;
52
+ try {
53
+ const rec = JSON.parse(trimmed);
54
+ if (collectBranchKeys && branchKeys.size < 2) {
55
+ const branchKey = fingerprintBranchRef(rec.gitBranch);
56
+ if (branchKey !== null)
57
+ branchKeys.add(branchKey);
58
+ }
59
+ if (rec.type === "pr-link") {
60
+ const prNumber = rec.prNumber;
61
+ const prRepository = rec.prRepository;
62
+ if (typeof prNumber === "number" && typeof prRepository === "string") {
63
+ links.push({ prNumber, prRepository });
64
+ }
65
+ }
66
+ }
67
+ catch {
68
+ // Malformed line — skip
69
+ }
70
+ }
71
+ }
72
+ catch {
73
+ // File unreadable — skip
74
+ }
75
+ return { links, branchKeys };
76
+ }
77
+ async function harvestPrLinks(filePath) {
78
+ return (await harvestTranscript(filePath, false)).links;
79
+ }
80
+ function resolvePrLinkMatches(db, links) {
81
+ const matches = [];
82
+ for (const link of links) {
83
+ const parts = link.prRepository.split("/");
84
+ if (parts.length !== 2)
85
+ continue;
86
+ const [linkOwner, linkRepo] = parts;
87
+ const workItemId = `gh:${linkOwner}/${linkRepo}#${link.prNumber}`;
88
+ const exists = db.prepare("SELECT 1 FROM work_items WHERE work_item_id = ?").get(workItemId);
89
+ if (exists !== undefined)
90
+ matches.push(workItemId);
91
+ }
92
+ return matches;
93
+ }
94
+ // ---------------------------------------------------------------------------
95
+ // SHA_OVERLAP linkage
96
+ // ---------------------------------------------------------------------------
97
+ function shaOverlapConfidence(overlap) {
98
+ return Math.min(0.9, 0.5 + 0.1 * overlap);
99
+ }
100
+ /** Cede the event loop after this many sessions in the linkSessions loop. */
101
+ const YIELD_EVERY = 16;
102
+ /** Maximum GitHub commit-to-PR reverse lookups in a single linker cycle. */
103
+ const REVERSE_LOOKUP_CAP = 50;
104
+ /**
105
+ * Extract session ids from supported commit/PR trailer lines.
106
+ *
107
+ * This deliberately returns ids only: callers must not retain the source text.
108
+ */
109
+ function extractTrailerSessionIds(text) {
110
+ const ids = [];
111
+ for (const line of text.split(/\r?\n/)) {
112
+ const trimmed = line.trim();
113
+ const claudeSession = /^Claude-Session:\s*https:\/\/claude\.ai\/code\/session_(\S+)(?:\s|$)/.exec(trimmed);
114
+ if (claudeSession?.[1] !== undefined) {
115
+ ids.push(claudeSession[1]);
116
+ continue;
117
+ }
118
+ const agentSession = /^Agent-Session-Id:\s*([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\s*$/.exec(trimmed);
119
+ if (agentSession?.[1] !== undefined)
120
+ ids.push(agentSession[1]);
121
+ }
122
+ return ids;
123
+ }
124
+ const BRANCH_CANDIDATE_REASONS = [
125
+ "NO_VALID_SESSION_KEY",
126
+ "MULTIPLE_SESSION_KEYS",
127
+ "NO_MATCHING_WORK_ITEM_KEY",
128
+ "DUPLICATE_WORKSPACE_MATCH",
129
+ "UNIQUE_CANDIDATE",
130
+ ];
131
+ const BRANCH_DISPOSITION_REASONS = [
132
+ ...BRANCH_CANDIDATE_REASONS,
133
+ "HIGHER_PRECEDENCE",
134
+ ];
135
+ function zeroCounts(values) {
136
+ return Object.fromEntries(values.map((value) => [value, 0]));
137
+ }
138
+ function higherPrecedenceMethod(methods) {
139
+ if (methods.has("MANUAL"))
140
+ return "MANUAL";
141
+ if (methods.has("PR_LINK"))
142
+ return "PR_LINK";
143
+ if (methods.has("SHA_OVERLAP"))
144
+ return "SHA_OVERLAP";
145
+ return null;
146
+ }
147
+ /**
148
+ * Evaluate exact branch-key candidates without changing accepted links.
149
+ *
150
+ * This validation-only path is disabled by default and intentionally has no
151
+ * write mode. Its return value contains no transcript paths, raw refs, or
152
+ * fingerprint keys.
153
+ */
154
+ export async function evaluateBranchLinksShadow(db, mode = "off", options = {}) {
155
+ const candidateReasonCounts = zeroCounts(BRANCH_CANDIDATE_REASONS);
156
+ const dispositionCounts = zeroCounts(BRANCH_DISPOSITION_REASONS);
157
+ const transcriptFailureReasonCounts = zeroCounts([
158
+ "MISSING",
159
+ "UNREADABLE",
160
+ "REPLACED",
161
+ "CHANGED",
162
+ "LIMIT_EXCEEDED",
163
+ "CORPUS_MISMATCH",
164
+ ]);
165
+ let transcriptSucceeded = 0;
166
+ let malformedLines = 0;
167
+ // This return must precede every SQL preparation and transcript/branch read.
168
+ if (mode === "off") {
169
+ return {
170
+ mode,
171
+ sessionsEvaluated: 0,
172
+ evaluations: [],
173
+ candidateReasonCounts,
174
+ dispositionCounts,
175
+ };
176
+ }
177
+ const sessions = db
178
+ .prepare(`SELECT s.session_id, s.file_path, s.workspace_id
179
+ FROM sessions s
180
+ WHERE s.state = 'RECONCILED'
181
+ AND EXISTS (SELECT 1 FROM tool_events te
182
+ WHERE te.session_id = s.session_id AND te.tool_name = 'Bash')
183
+ -- R3 corpus: evidence-bearing workspaces only (see plan §5G). Shadow/on mode
184
+ -- links sessions to work_items; a workspace with none can never link. Keep this
185
+ -- predicate byte-identical with the three evidence-runner corpus queries.
186
+ AND EXISTS (SELECT 1 FROM work_items wi WHERE wi.workspace_id = s.workspace_id)
187
+ ORDER BY s.session_id`)
188
+ .all();
189
+ const matchingWorkItems = db.prepare(`SELECT wi.work_item_id
190
+ FROM work_item_branch_keys bk
191
+ JOIN work_items wi ON wi.work_item_id = bk.work_item_id
192
+ JOIN workspaces ws ON ws.workspace_id = wi.workspace_id
193
+ WHERE wi.workspace_id = ? AND bk.head_ref_key = ?
194
+ AND wi.work_item_id = 'gh:' || ws.repo_owner || '/' || ws.repo_name || '#' || wi.number
195
+ ORDER BY wi.work_item_id
196
+ LIMIT 2`);
197
+ const existingMethods = db.prepare(`SELECT method
198
+ FROM session_work_links
199
+ WHERE session_id = ? AND method IN ('MANUAL', 'PR_LINK', 'SHA_OVERLAP')`);
200
+ const evaluations = [];
201
+ let processed = 0;
202
+ for (const session of sessions) {
203
+ if (processed > 0 && processed % YIELD_EVERY === 0) {
204
+ await new Promise((resolve) => setImmediate(resolve));
205
+ }
206
+ processed += 1;
207
+ let links;
208
+ let branchKeys;
209
+ if (options.transcriptEvidence === undefined) {
210
+ ({ links, branchKeys } = await harvestTranscript(session.file_path, true));
211
+ }
212
+ else {
213
+ const entry = options.transcriptEvidence.entryForSession(session.session_id);
214
+ const result = entry === undefined
215
+ ? { ok: false, reason: "MISSING" }
216
+ : entry.sessionId !== session.session_id || entry.path !== session.file_path
217
+ ? { ok: false, reason: "CORPUS_MISMATCH" }
218
+ : await options.transcriptEvidence.harvest(entry);
219
+ if (result.ok) {
220
+ links = result.projection.links;
221
+ branchKeys = result.projection.branchKeys;
222
+ transcriptSucceeded += 1;
223
+ malformedLines += result.projection.malformedLines;
224
+ }
225
+ else {
226
+ transcriptFailureReasonCounts[result.reason] += 1;
227
+ links = [];
228
+ branchKeys = new Set();
229
+ }
230
+ }
231
+ let candidateReason;
232
+ let candidateWorkItemId = null;
233
+ if (branchKeys.size === 0) {
234
+ candidateReason = "NO_VALID_SESSION_KEY";
235
+ }
236
+ else if (branchKeys.size > 1) {
237
+ candidateReason = "MULTIPLE_SESSION_KEYS";
238
+ }
239
+ else {
240
+ const branchKey = branchKeys.values().next().value;
241
+ const matches = matchingWorkItems.all(session.workspace_id, branchKey);
242
+ if (matches.length === 0) {
243
+ candidateReason = "NO_MATCHING_WORK_ITEM_KEY";
244
+ }
245
+ else if (matches.length > 1) {
246
+ candidateReason = "DUPLICATE_WORKSPACE_MATCH";
247
+ }
248
+ else {
249
+ candidateReason = "UNIQUE_CANDIDATE";
250
+ candidateWorkItemId = matches[0]?.work_item_id ?? null;
251
+ }
252
+ }
253
+ const methods = new Set(existingMethods.all(session.session_id).map(({ method }) => method));
254
+ if (resolvePrLinkMatches(db, links).length > 0)
255
+ methods.add("PR_LINK");
256
+ const excludedBy = higherPrecedenceMethod(methods);
257
+ const disposition = excludedBy === null ? candidateReason : "HIGHER_PRECEDENCE";
258
+ candidateReasonCounts[candidateReason] += 1;
259
+ dispositionCounts[disposition] += 1;
260
+ evaluations.push({
261
+ sessionId: session.session_id,
262
+ candidateWorkItemId,
263
+ candidateReason,
264
+ disposition,
265
+ excludedBy,
266
+ });
267
+ }
268
+ return {
269
+ mode,
270
+ sessionsEvaluated: evaluations.length,
271
+ evaluations,
272
+ candidateReasonCounts,
273
+ dispositionCounts,
274
+ ...(options.transcriptEvidence === undefined
275
+ ? {}
276
+ : {
277
+ strictTranscript: {
278
+ succeeded: transcriptSucceeded,
279
+ malformedLines,
280
+ failureReasonCounts: transcriptFailureReasonCounts,
281
+ },
282
+ }),
283
+ };
284
+ }
285
+ // ---------------------------------------------------------------------------
286
+ // Core linker
287
+ // ---------------------------------------------------------------------------
288
+ /**
289
+ * Link all RECONCILED sessions (with ≥1 Bash tool_event) to work_items.
290
+ * Writes to session_work_links (INSERT OR REPLACE — idempotent).
291
+ * MANUAL rows are preserved (method='MANUAL'); re-linking a session that
292
+ * already has a MANUAL link skips it.
293
+ *
294
+ * @param client — needed only for SHA_OVERLAP: fetch PR commit SHAs.
295
+ * Pass disabled client to skip SHA_OVERLAP linkage.
296
+ */
297
+ export async function linkSessions(db, client) {
298
+ // Get all RECONCILED sessions with ≥1 Bash tool_event and their workspace mapping.
299
+ const sessions = db
300
+ .prepare(`SELECT s.session_id, s.file_path, s.workspace_id,
301
+ w.repo_owner, w.repo_name
302
+ FROM sessions s
303
+ JOIN workspaces w USING (workspace_id)
304
+ WHERE s.state = 'RECONCILED'
305
+ AND EXISTS (SELECT 1 FROM tool_events te
306
+ WHERE te.session_id = s.session_id AND te.tool_name = 'Bash')`)
307
+ .all();
308
+ const insertLink = db.prepare(`
309
+ INSERT INTO session_work_links (session_id, work_item_id, confidence, method)
310
+ VALUES (?, ?, ?, ?)
311
+ ON CONFLICT(session_id, work_item_id) DO UPDATE SET
312
+ confidence = excluded.confidence,
313
+ method = excluded.method
314
+ `);
315
+ const deleteLink = db.prepare("DELETE FROM session_work_links WHERE session_id = ? AND method != 'MANUAL'");
316
+ const hasManual = db.prepare("SELECT 1 FROM session_work_links WHERE session_id = ? AND method = 'MANUAL' LIMIT 1");
317
+ // Batch PR SHAs by work_item_id → Set<sha> for SHA_OVERLAP
318
+ // (lazy: only populated when needed for a workspace)
319
+ const prShaCache = new Map(); // work_item_id → sha set
320
+ const prTrailerIdCache = new Map(); // work_item_id → session ids
321
+ let reverseLookups = 0;
322
+ let processed = 0;
323
+ for (const session of sessions) {
324
+ // Yield to the event loop every YIELD_EVERY sessions to keep the daemon event
325
+ // loop responsive during back-to-back synchronous transcript streaming in
326
+ // linkSessions (general responsiveness). GitHub I/O now runs in a `gh` child
327
+ // process (off the parent loop), so this is no longer rescuing undici timers.
328
+ // YIELD_EVERY=16 is empirically chosen; tune if starvation recurs.
329
+ if (processed > 0 && processed % YIELD_EVERY === 0) {
330
+ await new Promise((r) => setImmediate(r));
331
+ }
332
+ processed += 1;
333
+ // If session already has a MANUAL link, skip (MANUAL wins).
334
+ const manualExists = hasManual.get(session.session_id);
335
+ if (manualExists !== undefined)
336
+ continue;
337
+ const repoOwner = session.repo_owner;
338
+ const repoName = session.repo_name;
339
+ // 1. TRAILER (GitHub commit/PR trailers, only when a repository is available)
340
+ if (repoOwner !== null && repoName !== null && client.enabled) {
341
+ const workItems = db
342
+ .prepare("SELECT work_item_id, number FROM work_items WHERE workspace_id = ?")
343
+ .all(session.workspace_id);
344
+ const trailerMatches = [];
345
+ for (const wi of workItems) {
346
+ let trailerIds = prTrailerIdCache.get(wi.work_item_id);
347
+ if (trailerIds === undefined) {
348
+ trailerIds = new Set();
349
+ const commitResult = await client.listPRCommits(repoOwner, repoName, wi.number);
350
+ if (commitResult.ok) {
351
+ prShaCache.set(wi.work_item_id, new Set(commitResult.data.map((commit) => commit.sha)));
352
+ for (const commit of commitResult.data) {
353
+ for (const trailerId of extractTrailerSessionIds(commit.message)) {
354
+ trailerIds.add(trailerId);
355
+ }
356
+ }
357
+ }
358
+ else {
359
+ prShaCache.set(wi.work_item_id, new Set());
360
+ }
361
+ const bodyResult = await client.getPRBody(repoOwner, repoName, wi.number);
362
+ if (bodyResult.ok) {
363
+ for (const trailerId of extractTrailerSessionIds(bodyResult.data)) {
364
+ trailerIds.add(trailerId);
365
+ }
366
+ }
367
+ prTrailerIdCache.set(wi.work_item_id, trailerIds);
368
+ }
369
+ if (trailerIds.has(session.session_id))
370
+ trailerMatches.push(wi.work_item_id);
371
+ }
372
+ if (trailerMatches.length > 0) {
373
+ deleteLink.run(session.session_id);
374
+ for (const wid of trailerMatches) {
375
+ insertLink.run(session.session_id, wid, 1.0, "TRAILER");
376
+ }
377
+ continue;
378
+ }
379
+ }
380
+ // 2. PR_LINK
381
+ const prLinks = await harvestPrLinks(session.file_path);
382
+ const prLinkMatches = resolvePrLinkMatches(db, prLinks);
383
+ if (prLinkMatches.length > 0) {
384
+ // Delete non-manual existing links then insert PR_LINK rows
385
+ deleteLink.run(session.session_id);
386
+ for (const wid of prLinkMatches) {
387
+ insertLink.run(session.session_id, wid, 1.0, "PR_LINK");
388
+ }
389
+ continue;
390
+ }
391
+ // 3. SHA_OVERLAP (only if no higher-precedence match + workspace has repo mapping)
392
+ if (repoOwner !== null && repoName !== null) {
393
+ // Get this session's SHAs from tool_events
394
+ const sessionShas = new Set(db
395
+ .prepare("SELECT commit_sha FROM tool_events WHERE session_id = ? AND commit_sha IS NOT NULL")
396
+ .all(session.session_id).map((r) => r.commit_sha));
397
+ if (sessionShas.size > 0) {
398
+ // Get all synced work_items for this workspace
399
+ const workItems = db
400
+ .prepare("SELECT work_item_id, number FROM work_items WHERE workspace_id = ?")
401
+ .all(session.workspace_id);
402
+ // Build SHA → [work_item_ids] map for overlap check
403
+ const shaToWorkItems = new Map();
404
+ for (const wi of workItems) {
405
+ // Populate SHA cache if missing
406
+ if (!prShaCache.has(wi.work_item_id) && client.enabled) {
407
+ const shaResult = await client.listPRCommits(repoOwner, repoName, wi.number);
408
+ if (shaResult.ok) {
409
+ prShaCache.set(wi.work_item_id, new Set(shaResult.data.map((c) => c.sha)));
410
+ }
411
+ else {
412
+ prShaCache.set(wi.work_item_id, new Set());
413
+ }
414
+ }
415
+ const prShas = prShaCache.get(wi.work_item_id) ?? new Set();
416
+ for (const sha of sessionShas) {
417
+ if (prShas.has(sha)) {
418
+ const existing = shaToWorkItems.get(sha) ?? [];
419
+ existing.push(wi.work_item_id);
420
+ shaToWorkItems.set(sha, existing);
421
+ }
422
+ }
423
+ }
424
+ // Only SHAs absent from every synced PR commit list can use a reverse lookup.
425
+ for (const sha of sessionShas) {
426
+ if (shaToWorkItems.has(sha) || reverseLookups >= REVERSE_LOOKUP_CAP || !client.enabled) {
427
+ continue;
428
+ }
429
+ reverseLookups += 1;
430
+ const pullsResult = await client.listPullsForCommit(repoOwner, repoName, sha);
431
+ if (!pullsResult.ok)
432
+ continue;
433
+ const reverseMatches = new Set();
434
+ for (const prNumber of pullsResult.data) {
435
+ for (const wi of workItems) {
436
+ if (wi.number === prNumber)
437
+ reverseMatches.add(wi.work_item_id);
438
+ }
439
+ }
440
+ if (reverseMatches.size > 0) {
441
+ shaToWorkItems.set(sha, [...reverseMatches]);
442
+ }
443
+ }
444
+ // Count overlap per work_item; check for SHA ambiguity
445
+ const overlapCount = new Map();
446
+ let ambiguous = false;
447
+ for (const [, wids] of shaToWorkItems) {
448
+ if (wids.length > 1) {
449
+ ambiguous = true;
450
+ break;
451
+ }
452
+ const wid = wids[0];
453
+ if (wid === undefined)
454
+ continue;
455
+ overlapCount.set(wid, (overlapCount.get(wid) ?? 0) + 1);
456
+ }
457
+ if (!ambiguous && overlapCount.size > 0) {
458
+ deleteLink.run(session.session_id);
459
+ for (const [wid, count] of overlapCount) {
460
+ insertLink.run(session.session_id, wid, shaOverlapConfidence(count), "SHA_OVERLAP");
461
+ }
462
+ }
463
+ }
464
+ }
465
+ // 4. BRANCH — not implemented in WP5 (ADR-103 FLAG-D3 [UNMEASURED])
466
+ // 5. UNLINKED — implicit; do nothing (no row).
467
+ }
468
+ }
469
+ // ---------------------------------------------------------------------------
470
+ // Manual link/unlink (write path — called from router POST endpoints)
471
+ // ---------------------------------------------------------------------------
472
+ export function manualLink(db, sessionId, workItemId) {
473
+ db.prepare(`
474
+ INSERT INTO session_work_links (session_id, work_item_id, confidence, method)
475
+ VALUES (?, ?, 1.0, 'MANUAL')
476
+ ON CONFLICT(session_id, work_item_id) DO UPDATE SET
477
+ confidence = 1.0,
478
+ method = 'MANUAL'
479
+ `).run(sessionId, workItemId);
480
+ }
481
+ export function manualUnlink(db, sessionId, workItemId) {
482
+ const result = db
483
+ .prepare("DELETE FROM session_work_links WHERE session_id = ? AND work_item_id = ? AND method = 'MANUAL'")
484
+ .run(sessionId, workItemId);
485
+ return result.changes;
486
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * src/outcomes/pool.ts — bounded-concurrency async map helper.
3
+ *
4
+ * Runs `fn` over `items` with at most `concurrency` promises in flight.
5
+ * Results are returned in input order. The only Promise.all is over the fixed
6
+ * set of worker loops — never over the (unbounded) item list.
7
+ */
8
+ export const DEFAULT_CONCURRENCY = 4;
9
+ export async function mapWithConcurrency(items, concurrency, fn) {
10
+ const results = new Array(items.length);
11
+ let next = 0;
12
+ const workerCount = Math.max(1, Math.min(concurrency, items.length));
13
+ const workers = [];
14
+ for (let w = 0; w < workerCount; w++) {
15
+ workers.push((async () => {
16
+ while (next < items.length) {
17
+ const index = next++;
18
+ results[index] = await fn(items[index], index);
19
+ }
20
+ })());
21
+ }
22
+ await Promise.all(workers);
23
+ return results;
24
+ }