@prism-d1/cli 1.0.79 → 1.0.80

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 (38) hide show
  1. package/dist/assets/github-workflows/prism-ai-metrics.yml +83 -122
  2. package/dist/assets/github-workflows/prism-eval-gate-kiro.yml +66 -0
  3. package/dist/assets/github-workflows/prism-eval-gate.yml +1 -1
  4. package/dist/assets/gitlab-workflows/.gitlab-ci.yml +0 -3
  5. package/dist/assets/gitlab-workflows/prism-ai-metrics.yml +53 -56
  6. package/dist/assets/infra/bin/app.ts +1 -0
  7. package/dist/assets/infra/lib/constructs/otel-collector-construct.ts +4 -0
  8. package/dist/assets/infra/lib/dashboard-stack.ts +378 -1235
  9. package/dist/assets/infra/lib/lambda/event-schema.ts +144 -0
  10. package/dist/assets/infra/lib/lambda/metrics-processor.ts +124 -36
  11. package/dist/assets/infra/lib/lambda/otel-receiver.ts +249 -35
  12. package/dist/assets/infra/lib/lambda/security-remediation-tracker.ts +17 -2
  13. package/dist/assets/infra/lib/lambda/velocity-widget.ts +1483 -0
  14. package/dist/assets/infra/lib/metrics-pipeline-stack.ts +110 -119
  15. package/dist/assets/infra/package.json +2 -1
  16. package/dist/assets/infra/tsconfig.json +9 -2
  17. package/dist/src/commands/bootstrapper/install-eval-harness.d.ts +8 -2
  18. package/dist/src/commands/bootstrapper/install-eval-harness.d.ts.map +1 -1
  19. package/dist/src/commands/bootstrapper/install-eval-harness.js +32 -32
  20. package/dist/src/commands/bootstrapper/install-eval-harness.js.map +1 -1
  21. package/dist/src/commands/bootstrapper/install-github-workflows.d.ts.map +1 -1
  22. package/dist/src/commands/bootstrapper/install-github-workflows.js +7 -2
  23. package/dist/src/commands/bootstrapper/install-github-workflows.js.map +1 -1
  24. package/dist/src/commands/bootstrapper/install-gitlab-workflows.d.ts.map +1 -1
  25. package/dist/src/commands/bootstrapper/install-gitlab-workflows.js +7 -1
  26. package/dist/src/commands/bootstrapper/install-gitlab-workflows.js.map +1 -1
  27. package/dist/src/utils/region.d.ts +50 -0
  28. package/dist/src/utils/region.d.ts.map +1 -0
  29. package/dist/src/utils/region.js +62 -0
  30. package/dist/src/utils/region.js.map +1 -0
  31. package/package.json +2 -2
  32. package/dist/assets/eval-harness/README.md +0 -144
  33. package/dist/assets/github-workflows/README.md +0 -110
  34. package/dist/assets/github-workflows/prism-dora-weekly.yml +0 -334
  35. package/dist/assets/gitlab-workflows/prism-dora-weekly.yml +0 -181
  36. package/dist/assets/infra/lib/lambda/defect-correlator.ts +0 -142
  37. package/dist/assets/infra/lib/lambda/spec-to-code-calculator.ts +0 -123
  38. package/dist/assets/metric-hooks/README.md +0 -107
@@ -444,14 +444,34 @@ async function writeSessionAttribution(user: string, s: ParsedAttributionSpan):
444
444
  * This prevents redundant DDB stream MODIFY events that would double-count
445
445
  * CloudWatch metrics in the attribution-metrics-publisher.
446
446
  *
447
- * AI-Origin inference: done at query time by joining with usage spans via traceId.
448
- * If no usage spans share this traceId → origin=human, tool=none.
449
- * If usage spans exist → origin=ai-generated, tool=provider from usage span.
447
+ * AI-Origin is resolved HERE, at write time, and persisted on the item.
448
+ *
449
+ * It used to be inferred at query time by joining against usage spans. That was
450
+ * a latent data-corruption bug: commit facts live COMMIT_TTL_DAYS (365) but
451
+ * usage spans live SPAN_TTL_DAYS (90), so on day 91 the join found nothing and
452
+ * every aging AI commit silently reclassified as `human` — making AI adoption
453
+ * appear to decline over time, depressing the observed PRISM level's L2 gate,
454
+ * and skewing the CISO per-100-commits comparison on both sides.
455
+ *
456
+ * "Derive at read time" is only sound when the inputs outlive the output. They
457
+ * don't here, so the derivation is frozen at ingest while the spans are fresh.
458
+ * Usage spans for this batch are written before attribution spans (see
459
+ * handleTraces), so the trace map passed in already includes them.
450
460
  */
451
- async function writeCommitAttribution(user: string, s: ParsedAttributionSpan): Promise<boolean> {
461
+ async function writeCommitAttribution(
462
+ user: string,
463
+ s: ParsedAttributionSpan,
464
+ traceMap: Map<string, { tool: string; model: string }>,
465
+ ): Promise<boolean> {
452
466
  if (!s.repo || !s.sha) return false;
453
467
  const ttl = Math.floor(Date.now() / 1000) + COMMIT_TTL_DAYS * 24 * 60 * 60;
454
468
 
469
+ // Resolve origin now, against spans that are guaranteed to still exist.
470
+ const usage = s.traceId ? traceMap.get(s.traceId) : undefined;
471
+ const aiOrigin = usage ? 'ai-generated' : 'human';
472
+ const aiTool = usage ? usage.tool : 'none';
473
+ const aiModel = usage ? usage.model : '';
474
+
455
475
  try {
456
476
  await dynamoClient.send(new UpdateItemCommand({
457
477
  TableName: AI_USAGE_TABLE,
@@ -464,13 +484,19 @@ async function writeCommitAttribution(user: string, s: ParsedAttributionSpan): P
464
484
  '#proj = :proj, #usr = :usr, device_id = :did, ' +
465
485
  'in_main = :im, was_reverted = :wr, ' +
466
486
  '#ts = :ts, updated_at = :now, #ttl = :ttl, ' +
487
+ // Frozen origin verdict — see the note above.
488
+ 'ai_origin = :ao, ai_tool = :at, ai_model = :am, origin_source = :os, ' +
467
489
  // Sparse by-user GSI keys: per-developer commit queries (GET
468
490
  // /v1/productivity) without scanning the table.
469
491
  'gsi_user = :gu, gsi_user_sk = :gusk',
470
- // Only write if: item doesn't exist, OR inMain/wasReverted actually changed.
471
- // Suppresses no-op MODIFY stream events that would double-count metrics.
492
+ // Write if: item is new, OR inMain/wasReverted changed, OR we can now
493
+ // resolve an origin we previously could not. The last clause lets a
494
+ // commit whose usage spans arrived in a LATER push get upgraded from
495
+ // human to ai-generated, instead of being frozen wrong forever.
472
496
  ConditionExpression:
473
- 'attribute_not_exists(pk) OR in_main <> :im OR was_reverted <> :wr',
497
+ 'attribute_not_exists(pk) OR in_main <> :im OR was_reverted <> :wr ' +
498
+ 'OR attribute_not_exists(ai_origin) ' +
499
+ 'OR (ai_origin = :human AND :ao = :ai)',
474
500
  ExpressionAttributeNames: {
475
501
  // 'project', 'ttl', 'user', and 'timestamp' are all DynamoDB
476
502
  // reserved keywords — bare use in an expression throws
@@ -492,6 +518,12 @@ async function writeCommitAttribution(user: string, s: ParsedAttributionSpan): P
492
518
  ':ts': { S: s.timestamp },
493
519
  ':now': { S: new Date().toISOString() },
494
520
  ':ttl': { N: String(ttl) },
521
+ ':ao': { S: aiOrigin },
522
+ ':at': { S: aiTool },
523
+ ':am': { S: aiModel },
524
+ ':os': { S: 'write-time-join' },
525
+ ':human': { S: 'human' },
526
+ ':ai': { S: 'ai-generated' },
495
527
  ':gu': { S: `USER#${user}` },
496
528
  ':gusk': { S: `COMMIT#${s.timestamp}` },
497
529
  },
@@ -507,11 +539,22 @@ async function writeCommitAttribution(user: string, s: ParsedAttributionSpan): P
507
539
  async function processAttributionSpans(user: string, spans: ParsedAttributionSpan[]): Promise<number> {
508
540
  const CONCURRENCY = 20;
509
541
  let written = 0;
542
+
543
+ // Build the user's traceId → usage map ONCE for the whole batch. Commit
544
+ // origin is resolved at write time against this map (see
545
+ // writeCommitAttribution), so it must be built AFTER processSpans has
546
+ // persisted this batch's usage spans. Building it per commit would rescan
547
+ // every span for every commit in the push.
548
+ const needsTraceMap = spans.some(s => s.kind !== 'session');
549
+ const traceMap = needsTraceMap
550
+ ? await buildUserTraceMap(user)
551
+ : new Map<string, { tool: string; model: string }>();
552
+
510
553
  for (let i = 0; i < spans.length; i += CONCURRENCY) {
511
554
  const chunk = spans.slice(i, i + CONCURRENCY);
512
555
  const results = await Promise.all(chunk.map(async (s) => {
513
556
  if (s.kind === 'session') return writeSessionAttribution(user, s);
514
- return writeCommitAttribution(user, s);
557
+ return writeCommitAttribution(user, s, traceMap);
515
558
  }));
516
559
  written += results.filter(Boolean).length;
517
560
  }
@@ -600,6 +643,12 @@ export interface CommitAttribution {
600
643
  aiTool: string;
601
644
  /** Model from correlated usage spans, or empty if human. */
602
645
  aiModel: string;
646
+ /**
647
+ * 'stored' = verdict frozen at ingest (authoritative).
648
+ * 'joined' = re-derived from usage spans for a legacy item; unreliable once
649
+ * the spans have aged past SPAN_TTL_DAYS.
650
+ */
651
+ originSource?: 'stored' | 'joined';
603
652
  }
604
653
 
605
654
  /**
@@ -629,19 +678,12 @@ export async function queryCommitAttribution(repo: string, sha: string): Promise
629
678
 
630
679
  if (!traceId || !user) return null;
631
680
 
632
- // Step 2: Find correlated usage spans for this session's traceId.
633
- const usage = await lookupTraceUsage(user, traceId);
634
-
635
- // Step 3: Infer AI origin
636
- let aiOrigin: 'ai-generated' | 'human' = 'human';
637
- let aiTool = 'none';
638
- let aiModel = '';
639
-
640
- if (usage) {
641
- aiOrigin = 'ai-generated';
642
- aiTool = usage.tool;
643
- aiModel = usage.model;
644
- }
681
+ // Prefer the origin frozen at ingest. Only fall back to a live span join for
682
+ // legacy items written before write-time resolution existed.
683
+ const stored = item.ai_origin?.S;
684
+ const { aiOrigin, aiTool, aiModel, originSource } = (stored === 'ai-generated' || stored === 'human')
685
+ ? resolveStoredOrigin(item, null)
686
+ : resolveStoredOrigin(item, await buildUserTraceMap(user));
645
687
 
646
688
  return {
647
689
  sha,
@@ -656,6 +698,7 @@ export async function queryCommitAttribution(repo: string, sha: string): Promise
656
698
  aiOrigin,
657
699
  aiTool,
658
700
  aiModel,
701
+ originSource,
659
702
  };
660
703
  }
661
704
 
@@ -689,19 +732,20 @@ export async function queryRepoCommits(repo: string, limit = 50): Promise<Commit
689
732
  let aiOrigin: 'ai-generated' | 'human' = 'human';
690
733
  let aiTool = 'none';
691
734
  let aiModel = '';
692
-
693
- if (traceId && user) {
735
+ let originSource: 'stored' | 'joined' = 'stored';
736
+
737
+ const storedOrigin = item.ai_origin?.S;
738
+ if (storedOrigin === 'ai-generated' || storedOrigin === 'human') {
739
+ // Frozen at ingest — authoritative regardless of span age.
740
+ ({ aiOrigin, aiTool, aiModel, originSource } = resolveStoredOrigin(item, null));
741
+ } else if (traceId && user) {
742
+ // Legacy item: re-derive. Reliable only inside SPAN_TTL_DAYS.
694
743
  let traceMap = traceMaps.get(user);
695
744
  if (!traceMap) {
696
745
  traceMap = await buildUserTraceMap(user);
697
746
  traceMaps.set(user, traceMap);
698
747
  }
699
- const usage = traceMap.get(traceId);
700
- if (usage) {
701
- aiOrigin = 'ai-generated';
702
- aiTool = usage.tool;
703
- aiModel = usage.model;
704
- }
748
+ ({ aiOrigin, aiTool, aiModel, originSource } = resolveStoredOrigin(item, traceMap));
705
749
  }
706
750
 
707
751
  commits.push({
@@ -792,6 +836,36 @@ async function lookupTraceUsage(user: string, traceId: string): Promise<{ tool:
792
836
  return map.get(traceId) ?? null;
793
837
  }
794
838
 
839
+ /**
840
+ * Resolve a commit item's AI origin, preferring the verdict frozen at ingest.
841
+ *
842
+ * New items carry ai_origin/ai_tool/ai_model written by writeCommitAttribution
843
+ * while the usage spans were still alive. Items written before that change have
844
+ * no stored verdict, so they fall back to the live traceId join — which stays
845
+ * correct for commits under SPAN_TTL_DAYS old and degrades to `human` beyond
846
+ * that. `originSource` makes the distinction visible to callers rather than
847
+ * silently mixing frozen and re-derived values.
848
+ */
849
+ function resolveStoredOrigin(
850
+ item: Record<string, import('@aws-sdk/client-dynamodb').AttributeValue>,
851
+ traceMap: Map<string, { tool: string; model: string }> | null,
852
+ ): { aiOrigin: 'ai-generated' | 'human'; aiTool: string; aiModel: string; originSource: 'stored' | 'joined' } {
853
+ const stored = item.ai_origin?.S;
854
+ if (stored === 'ai-generated' || stored === 'human') {
855
+ return {
856
+ aiOrigin: stored,
857
+ aiTool: item.ai_tool?.S ?? (stored === 'human' ? 'none' : ''),
858
+ aiModel: item.ai_model?.S ?? '',
859
+ originSource: 'stored',
860
+ };
861
+ }
862
+ const traceId = item.trace_id?.S ?? '';
863
+ const usage = traceId && traceMap ? traceMap.get(traceId) : undefined;
864
+ return usage
865
+ ? { aiOrigin: 'ai-generated', aiTool: usage.tool, aiModel: usage.model, originSource: 'joined' }
866
+ : { aiOrigin: 'human', aiTool: 'none', aiModel: '', originSource: 'joined' };
867
+ }
868
+
795
869
  // ---- Route: productivity query ----
796
870
 
797
871
  type UserProductivity = {
@@ -914,7 +988,7 @@ async function handleProductivityQuery(event: HttpApiEvent): Promise<HttpApiResp
914
988
  }
915
989
 
916
990
  // --- Commits ---
917
- type RawCommit = { user: string; traceId: string; inMain: boolean; wasReverted: boolean };
991
+ type RawCommit = { user: string; traceId: string; inMain: boolean; wasReverted: boolean; storedOrigin?: string };
918
992
  const rawCommits: RawCommit[] = [];
919
993
  const commitFrom = `COMMIT#${from}`;
920
994
  const commitTo = `COMMIT#${to}\uffff`;
@@ -938,6 +1012,7 @@ async function handleProductivityQuery(event: HttpApiEvent): Promise<HttpApiResp
938
1012
  traceId: item.trace_id?.S ?? '',
939
1013
  inMain: item.in_main?.BOOL ?? false,
940
1014
  wasReverted: item.was_reverted?.BOOL ?? false,
1015
+ storedOrigin: item.ai_origin?.S,
941
1016
  });
942
1017
  }
943
1018
  lastKey = result.LastEvaluatedKey;
@@ -962,6 +1037,7 @@ async function handleProductivityQuery(event: HttpApiEvent): Promise<HttpApiResp
962
1037
  traceId: item.trace_id?.S ?? '',
963
1038
  inMain: item.in_main?.BOOL ?? false,
964
1039
  wasReverted: item.was_reverted?.BOOL ?? false,
1040
+ storedOrigin: item.ai_origin?.S,
965
1041
  });
966
1042
  }
967
1043
  lastKey = result.LastEvaluatedKey;
@@ -975,12 +1051,19 @@ async function handleProductivityQuery(event: HttpApiEvent): Promise<HttpApiResp
975
1051
  if (!c.user) continue;
976
1052
  const u = getUser(c.user);
977
1053
  u.commits.total++;
978
- let traceMap = traceMaps.get(c.user);
979
- if (!traceMap) {
980
- traceMap = await buildUserTraceMap(c.user);
981
- traceMaps.set(c.user, traceMap);
1054
+ // Prefer the verdict frozen at ingest. Legacy items (no ai_origin) fall
1055
+ // back to a live span join, which is only reliable inside SPAN_TTL_DAYS.
1056
+ let isAi: boolean;
1057
+ if (c.storedOrigin === 'ai-generated' || c.storedOrigin === 'human') {
1058
+ isAi = c.storedOrigin === 'ai-generated';
1059
+ } else {
1060
+ let traceMap = traceMaps.get(c.user);
1061
+ if (!traceMap) {
1062
+ traceMap = await buildUserTraceMap(c.user);
1063
+ traceMaps.set(c.user, traceMap);
1064
+ }
1065
+ isAi = c.traceId !== '' && traceMap.has(c.traceId);
982
1066
  }
983
- const isAi = c.traceId !== '' && traceMap.has(c.traceId);
984
1067
  if (isAi) {
985
1068
  u.commits.ai++;
986
1069
  if (c.inMain) u.commits.mergedAi++;
@@ -1028,6 +1111,134 @@ async function handleProductivityQuery(event: HttpApiEvent): Promise<HttpApiResp
1028
1111
  });
1029
1112
  }
1030
1113
 
1114
+ // ---- Route: per-repo attribution query ----
1115
+
1116
+ type RepoProductivity = {
1117
+ repo: string;
1118
+ commits: { total: number; ai: number; human: number; mergedAi: number; revertedAi: number };
1119
+ contributors: number;
1120
+ ratios: { aiSharePct: number | null; mergeRatePct: number | null; defectRatePct: number | null };
1121
+ lastActivity: string | null;
1122
+ };
1123
+
1124
+ /**
1125
+ * GET /v1/repos?from=YYYY-MM-DD&to=YYYY-MM-DD
1126
+ *
1127
+ * Aggregates commit outcomes by repository. The per-developer view
1128
+ * (/v1/productivity) answers "who", this answers "where" — which repos are
1129
+ * AI-heavy, which have low merge rates or high revert rates. Repo comes from
1130
+ * the commit item pk (REPO#<repo>); AI-origin classification uses the same
1131
+ * traceId-join rule as /v1/productivity (a commit is AI-generated only if
1132
+ * correlated usage spans exist for its trace).
1133
+ *
1134
+ * Spend is deliberately NOT included: usage rollups are keyed by user/day,
1135
+ * not by repo, so per-repo cost cannot be derived without double-counting
1136
+ * sessions that touch multiple repos.
1137
+ */
1138
+ async function handleReposQuery(event: HttpApiEvent): Promise<HttpApiResponse> {
1139
+ const caller = resolveIdentity(event);
1140
+ if (!caller) return jsonResponse(401, { message: 'No resolvable identity claim in token' });
1141
+
1142
+ const url = new URL(event.rawPath + '?' + (event.rawQueryString ?? ''), 'http://localhost');
1143
+ const now = new Date();
1144
+ const from = url.searchParams.get('from') ?? new Date(now.getTime() - 30 * 86400_000).toISOString().slice(0, 10);
1145
+ const to = url.searchParams.get('to') ?? now.toISOString().slice(0, 10);
1146
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(from) || !/^\d{4}-\d{2}-\d{2}$/.test(to)) {
1147
+ return jsonResponse(400, { message: 'from/to must be YYYY-MM-DD' });
1148
+ }
1149
+
1150
+ type RawCommit = { repo: string; user: string; traceId: string; inMain: boolean; wasReverted: boolean; ts: string; storedOrigin?: string };
1151
+ const rawCommits: RawCommit[] = [];
1152
+ let lastKey: import('@aws-sdk/client-dynamodb').ScanCommandOutput['LastEvaluatedKey'];
1153
+ do {
1154
+ const result = await dynamoClient.send(new ScanCommand({
1155
+ TableName: AI_USAGE_TABLE,
1156
+ FilterExpression: 'record_type = :rt AND #ts BETWEEN :lo AND :hi',
1157
+ ExpressionAttributeNames: { '#ts': 'timestamp' },
1158
+ ExpressionAttributeValues: {
1159
+ ':rt': { S: 'OTEL_ATTR_COMMIT' },
1160
+ ':lo': { S: from },
1161
+ ':hi': { S: `${to}\uffff` },
1162
+ },
1163
+ ...(lastKey ? { ExclusiveStartKey: lastKey } : {}),
1164
+ }));
1165
+ for (const item of result.Items ?? []) {
1166
+ rawCommits.push({
1167
+ // pk is REPO#<repo>; strip the prefix for display.
1168
+ repo: (item.pk?.S ?? '').replace(/^REPO#/, ''),
1169
+ user: item.user?.S ?? '',
1170
+ traceId: item.trace_id?.S ?? '',
1171
+ inMain: item.in_main?.BOOL ?? false,
1172
+ wasReverted: item.was_reverted?.BOOL ?? false,
1173
+ ts: item.timestamp?.S ?? '',
1174
+ storedOrigin: item.ai_origin?.S,
1175
+ });
1176
+ }
1177
+ lastKey = result.LastEvaluatedKey;
1178
+ } while (lastKey);
1179
+
1180
+ // One trace map per user (cached across repos — a user's spans are the same
1181
+ // regardless of which repo the commit landed in).
1182
+ const traceMaps = new Map<string, Map<string, { tool: string; model: string }>>();
1183
+ const repos = new Map<string, RepoProductivity>();
1184
+ const contributors = new Map<string, Set<string>>();
1185
+
1186
+ for (const c of rawCommits) {
1187
+ if (!c.repo) continue;
1188
+ let r = repos.get(c.repo);
1189
+ if (!r) {
1190
+ r = {
1191
+ repo: c.repo,
1192
+ commits: { total: 0, ai: 0, human: 0, mergedAi: 0, revertedAi: 0 },
1193
+ contributors: 0,
1194
+ ratios: { aiSharePct: null, mergeRatePct: null, defectRatePct: null },
1195
+ lastActivity: null,
1196
+ };
1197
+ repos.set(c.repo, r);
1198
+ contributors.set(c.repo, new Set());
1199
+ }
1200
+ r.commits.total++;
1201
+ if (c.user) contributors.get(c.repo)!.add(c.user);
1202
+ if (!r.lastActivity || c.ts > r.lastActivity) r.lastActivity = c.ts;
1203
+
1204
+ let isAi: boolean;
1205
+ if (c.storedOrigin === 'ai-generated' || c.storedOrigin === 'human') {
1206
+ isAi = c.storedOrigin === 'ai-generated';
1207
+ } else {
1208
+ let traceMap = c.user ? traceMaps.get(c.user) : undefined;
1209
+ if (c.user && !traceMap) {
1210
+ traceMap = await buildUserTraceMap(c.user);
1211
+ traceMaps.set(c.user, traceMap);
1212
+ }
1213
+ isAi = c.traceId !== '' && !!traceMap?.has(c.traceId);
1214
+ }
1215
+ if (isAi) {
1216
+ r.commits.ai++;
1217
+ if (c.inMain) r.commits.mergedAi++;
1218
+ if (c.wasReverted) r.commits.revertedAi++;
1219
+ } else {
1220
+ r.commits.human++;
1221
+ }
1222
+ }
1223
+
1224
+ const pct = (num: number, den: number): number | null =>
1225
+ den > 0 ? Math.round((100 * num / den) * 10) / 10 : null;
1226
+ for (const r of repos.values()) {
1227
+ r.contributors = contributors.get(r.repo)?.size ?? 0;
1228
+ r.ratios = {
1229
+ aiSharePct: pct(r.commits.ai, r.commits.total),
1230
+ mergeRatePct: pct(r.commits.mergedAi, r.commits.ai),
1231
+ defectRatePct: pct(r.commits.revertedAi, r.commits.mergedAi),
1232
+ };
1233
+ }
1234
+
1235
+ return jsonResponse(200, {
1236
+ range: { from, to },
1237
+ generatedAt: new Date().toISOString(),
1238
+ repos: [...repos.values()].sort((a, b) => b.commits.total - a.commits.total),
1239
+ });
1240
+ }
1241
+
1031
1242
  function accumulateUsage(u: UserProductivity, item: Record<string, { S?: string; N?: string }>): void {
1032
1243
  const tool = item.tool?.S ?? 'unknown';
1033
1244
  const cost = Number(item.cost_usd?.N ?? 0);
@@ -1065,5 +1276,8 @@ export async function handler(event: HttpApiEvent): Promise<HttpApiResponse> {
1065
1276
  if (method === 'GET' && path === '/v1/productivity') {
1066
1277
  return handleProductivityQuery(event);
1067
1278
  }
1279
+ if (method === 'GET' && path === '/v1/repos') {
1280
+ return handleReposQuery(event);
1281
+ }
1068
1282
  return jsonResponse(404, { message: 'Not found' });
1069
1283
  }
@@ -21,7 +21,7 @@ interface PrEvent {
21
21
  repo: string;
22
22
  timestamp: string;
23
23
  ai_context?: { origin: string };
24
- pr?: { merged_at?: string; number?: number };
24
+ pr?: { merged_at?: string; number?: number; commit_shas?: string[] };
25
25
  };
26
26
  }
27
27
 
@@ -90,7 +90,20 @@ export async function handler(event: PrEvent): Promise<void> {
90
90
  finding_id: finding.finding_id,
91
91
  severity: finding.severity,
92
92
  remediation_time_hours: Math.round(remediationHours * 100) / 100,
93
+ // WHO FIXED IT — resolved by deferred attribution join at render
94
+ // time, not here. These are the FIX PR's commits (not the
95
+ // offending commits on the finding), which is the correct signal
96
+ // for "who remediated this". SHAs are immutable facts; a verdict
97
+ // computed now can be permanently wrong because `codeburn sync
98
+ // --attribution` runs on a ~12h schedule and has not necessarily
99
+ // covered these commits yet.
100
+ fix_commit_shas: detail.pr?.commit_shas ?? [],
101
+ // Legacy trailer-derived verdict, retained for the Phase 1
102
+ // parallel run. Reads 'human' for everything once git hooks are
103
+ // removed (ai_context.origin <- ai_ratio <- trailer counting), so
104
+ // dashboards prefer the join above and only fall back to this.
93
105
  remediated_by_origin: detail.ai_context?.origin ?? 'unknown',
106
+ remediated_by_origin_source: 'git-trailers',
94
107
  fix_pr_number: detail.pr?.number ?? null,
95
108
  finding_phase: finding.phase,
96
109
  },
@@ -110,7 +123,9 @@ export async function handler(event: PrEvent): Promise<void> {
110
123
  );
111
124
 
112
125
  console.log(
113
- `Remediation event: finding ${finding.finding_id} (${finding.severity}) fixed in ${remediationHours.toFixed(1)}h by ${detail.ai_context?.origin ?? 'unknown'}`,
126
+ `Remediation event: finding ${finding.finding_id} (${finding.severity}) fixed in ${remediationHours.toFixed(1)}h ` +
127
+ `in PR #${detail.pr?.number ?? '?'} (${(detail.pr?.commit_shas ?? []).length} commit SHAs for deferred origin join; ` +
128
+ `trailer said ${detail.ai_context?.origin ?? 'unknown'})`,
114
129
  );
115
130
  }
116
131
  }