@prism-d1/cli 1.0.84 → 1.0.85

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 (43) hide show
  1. package/dist/assets/coding-agent/README.md +824 -0
  2. package/dist/assets/coding-agent/agent.py +381 -0
  3. package/dist/assets/coding-agent/config.py +338 -0
  4. package/dist/assets/coding-agent/deploy/Dockerfile +86 -0
  5. package/dist/assets/coding-agent/deploy/create_harness.py +144 -0
  6. package/dist/assets/coding-agent/deploy/deploy-harness.sh +201 -0
  7. package/dist/assets/coding-agent/deploy/prism-coding-agent.yml +399 -0
  8. package/dist/assets/coding-agent/eval/run_eval.py +430 -0
  9. package/dist/assets/coding-agent/eval/run_harness_eval.py +262 -0
  10. package/dist/assets/coding-agent/eval/stubs/001-tags-element-validation.json +11 -0
  11. package/dist/assets/coding-agent/eval/stubs/002-status-filter.json +11 -0
  12. package/dist/assets/coding-agent/eval/stubs/003-refuse-test-deletion.json +11 -0
  13. package/dist/assets/coding-agent/fixtures/001-tags-element-validation.json +12 -0
  14. package/dist/assets/coding-agent/fixtures/002-status-filter.json +12 -0
  15. package/dist/assets/coding-agent/fixtures/003-refuse-test-deletion.json +10 -0
  16. package/dist/assets/coding-agent/pyproject.toml +28 -0
  17. package/dist/assets/coding-agent/requirements.txt +8 -0
  18. package/dist/assets/coding-agent/system_prompt.py +290 -0
  19. package/dist/assets/coding-agent/tools/__init__.py +11 -0
  20. package/dist/assets/coding-agent/tools/create_pr.py +141 -0
  21. package/dist/assets/coding-agent/tools/git_ops.py +197 -0
  22. package/dist/assets/github-workflows/prism-agent-eval.yml +2 -1
  23. package/dist/assets/github-workflows/prism-ai-metrics.yml +12 -3
  24. package/dist/assets/github-workflows/prism-eval-gate-kiro.yml +4 -3
  25. package/dist/assets/github-workflows/prism-eval-gate.yml +3 -2
  26. package/dist/assets/gitlab-workflows/prism-agent-eval.yml +2 -1
  27. package/dist/assets/gitlab-workflows/prism-ai-metrics.yml +3 -2
  28. package/dist/assets/gitlab-workflows/prism-eval-gate.yml +2 -1
  29. package/dist/assets/infra/lib/constructs/otel-collector-construct.ts +134 -2
  30. package/dist/assets/infra/lib/dashboard-stack.ts +79 -95
  31. package/dist/assets/infra/lib/lambda/metrics-processor.ts +99 -1
  32. package/dist/assets/infra/lib/lambda/otel-receiver.ts +318 -29
  33. package/dist/assets/infra/lib/lambda/productivity-widget.ts +85 -4
  34. package/dist/assets/infra/lib/lambda/velocity-widget.ts +265 -0
  35. package/dist/assets/infra/lib/metrics-pipeline-stack.ts +5 -0
  36. package/dist/src/commands/bootstrapper/install-coding-agent.d.ts +61 -0
  37. package/dist/src/commands/bootstrapper/install-coding-agent.d.ts.map +1 -0
  38. package/dist/src/commands/bootstrapper/install-coding-agent.js +599 -0
  39. package/dist/src/commands/bootstrapper/install-coding-agent.js.map +1 -0
  40. package/dist/src/commands/bootstrapper/setup-github-oidc.d.ts.map +1 -1
  41. package/dist/src/commands/bootstrapper/setup-github-oidc.js +23 -0
  42. package/dist/src/commands/bootstrapper/setup-github-oidc.js.map +1 -1
  43. package/package.json +2 -2
@@ -33,6 +33,23 @@ const s3Client = new S3Client({});
33
33
  const AI_USAGE_TABLE = process.env.AI_USAGE_TABLE || 'prism-d1-ai-usage';
34
34
  const ARCHIVE_BUCKET = process.env.ARCHIVE_BUCKET || '';
35
35
  const IDENTITY_CLAIM = process.env.IDENTITY_CLAIM || 'username';
36
+
37
+ // clientId -> identity, for machine-to-machine tokens. A Cognito
38
+ // client-credentials access token carries no `username` and no `email`, and its
39
+ // `sub` is the app client id -- so without a mapping the coding agent would appear
40
+ // on every dashboard as a random opaque string.
41
+ //
42
+ // Deliberately deploy-time configuration rather than anything the caller sends. A
43
+ // payload that could name its own author would let any holder of any token post as
44
+ // anyone, which is a worse problem than an ugly label.
45
+ const MACHINE_IDENTITIES: Record<string, string> = (() => {
46
+ try {
47
+ return JSON.parse(process.env.MACHINE_IDENTITIES || '{}');
48
+ } catch {
49
+ console.warn('MACHINE_IDENTITIES is not valid JSON; machine tokens will fall back to sub');
50
+ return {};
51
+ }
52
+ })();
36
53
  const OIDC_ISSUER = process.env.OIDC_ISSUER || '';
37
54
  const OIDC_CLIENT_ID = process.env.OIDC_CLIENT_ID || '';
38
55
  const MAX_BATCH_SIZE = Number(process.env.MAX_BATCH_SIZE || '1000');
@@ -101,6 +118,10 @@ interface ParsedSpan {
101
118
  costUsd: number;
102
119
  project: string;
103
120
  costEstimated: boolean;
121
+ /** True for spans the PRISM coding agent emitted: work no human prompted. */
122
+ autonomous?: boolean;
123
+ /** The issue the agent was handed. 0 when not an agent span. */
124
+ issueNumber?: number;
104
125
  deviceId: string;
105
126
  }
106
127
 
@@ -118,6 +139,10 @@ interface ParsedAttributionSpan {
118
139
  deviceId: string;
119
140
  // session-specific
120
141
  commitCount?: number;
142
+ /** True for spans the PRISM coding agent emitted. */
143
+ autonomous?: boolean;
144
+ /** The issue handed to the agent; 0 for human spans. */
145
+ issueNumber?: number;
121
146
  prLinks?: string[];
122
147
  // commit-specific
123
148
  sha?: string;
@@ -184,6 +209,16 @@ function nanoToIso(nano: string | undefined): string {
184
209
  function resolveIdentity(event: HttpApiEvent): string | null {
185
210
  const claims = event.requestContext.authorizer?.jwt?.claims;
186
211
  if (!claims) return null;
212
+
213
+ // A machine token first: checked before the claim chain because its `sub` would
214
+ // otherwise satisfy that chain with the app client id.
215
+ for (const claim of ['client_id', 'sub']) {
216
+ const raw = claims[claim];
217
+ if (typeof raw === 'string' && MACHINE_IDENTITIES[raw]) {
218
+ return MACHINE_IDENTITIES[raw].trim().toLowerCase();
219
+ }
220
+ }
221
+
187
222
  for (const claim of [IDENTITY_CLAIM, 'username', 'email', 'sub']) {
188
223
  const v = claims[claim];
189
224
  if (typeof v === 'string' && v.trim()) return v.trim().toLowerCase();
@@ -243,6 +278,8 @@ export function parseOtlpSpans(payload: OtlpPayload): {
243
278
  sha: sha.slice(0, 40),
244
279
  inMain: bool(attrs.get('git.in_main')),
245
280
  wasReverted: bool(attrs.get('git.was_reverted')),
281
+ autonomous: bool(attrs.get('prism.autonomous')),
282
+ issueNumber: num(attrs.get('prism.issue_number')),
246
283
  });
247
284
  } else {
248
285
  // session attribution
@@ -267,6 +304,8 @@ export function parseOtlpSpans(payload: OtlpPayload): {
267
304
  repo,
268
305
  deviceId,
269
306
  commitCount: num(attrs.get('git.commit_count')),
307
+ autonomous: bool(attrs.get('prism.autonomous')),
308
+ issueNumber: num(attrs.get('prism.issue_number')),
270
309
  prLinks: prLinks.slice(0, 20),
271
310
  });
272
311
  }
@@ -303,6 +342,11 @@ export function parseOtlpSpans(payload: OtlpPayload): {
303
342
  project: str(attrs.get('ai.project')).slice(0, 256),
304
343
  costEstimated: bool(attrs.get('ai.cost_estimated')),
305
344
  deviceId,
345
+ // Emitted by the PRISM coding agent. `autonomous` separates work no human
346
+ // prompted from human-assisted AI use, which the PRISM levels treat as
347
+ // different things -- L5 is about autonomous deployments, not AI share.
348
+ autonomous: bool(attrs.get('prism.autonomous')),
349
+ issueNumber: num(attrs.get('prism.issue_number')),
306
350
  });
307
351
  }
308
352
  }
@@ -350,6 +394,7 @@ async function writeSpanIfNew(user: string, s: ParsedSpan): Promise<boolean> {
350
394
  sk: { S: `SPAN#${s.timestamp}#${s.spanId}` },
351
395
  record_type: { S: 'OTEL_SPAN' },
352
396
  trace_id: { S: s.traceId },
397
+ autonomous: { BOOL: s.autonomous === true },
353
398
  tool: { S: s.tool },
354
399
  model: { S: s.model },
355
400
  input_tokens: { N: String(s.inputTokens) },
@@ -371,6 +416,42 @@ async function writeSpanIfNew(user: string, s: ParsedSpan): Promise<boolean> {
371
416
  }
372
417
  }
373
418
 
419
+ /**
420
+ * Record that this identity worked on one issue.
421
+ *
422
+ * One item per (user, repo, issue) rather than a counter, because "issues worked
423
+ * on" is a count of DISTINCT issues and an agent emits a usage span per invocation
424
+ * -- a re-run of the same issue would otherwise inflate the number. The dedup is
425
+ * the condition expression, exactly as it is for spans.
426
+ *
427
+ * A separate item rather than a field on the span so counting is a Query on an sk
428
+ * prefix instead of a scan-and-deduplicate over every span in the window.
429
+ */
430
+ async function recordIssue(user: string, repo: string, issueNumber: number,
431
+ timestamp: string, autonomous: boolean): Promise<boolean> {
432
+ if (!issueNumber || !repo) return false;
433
+ try {
434
+ await dynamoClient.send(new PutItemCommand({
435
+ TableName: AI_USAGE_TABLE,
436
+ Item: {
437
+ pk: { S: `USER#${user}` },
438
+ sk: { S: `ISSUE#${repo}#${issueNumber}` },
439
+ record_type: { S: 'OTEL_ISSUE' },
440
+ repo: { S: repo },
441
+ issue_number: { N: String(issueNumber) },
442
+ autonomous: { BOOL: autonomous },
443
+ first_seen: { S: timestamp },
444
+ ttl: { N: String(Math.floor(Date.now() / 1000) + COMMIT_TTL_DAYS * 86400) },
445
+ },
446
+ ConditionExpression: 'attribute_not_exists(pk)',
447
+ }));
448
+ return true;
449
+ } catch (e) {
450
+ if (e instanceof ConditionalCheckFailedException) return false;
451
+ throw e;
452
+ }
453
+ }
454
+
374
455
  /** ADD-increment the daily aggregate for a newly-seen span (bucketed by SPAN date). */
375
456
  async function bumpDailyAggregate(user: string, s: ParsedSpan): Promise<void> {
376
457
  const day = s.timestamp.slice(0, 10);
@@ -421,6 +502,7 @@ async function writeSessionAttribution(user: string, s: ParsedAttributionSpan):
421
502
  timestamp: { S: s.timestamp },
422
503
  end_time: { S: s.endTimestamp },
423
504
  commit_count: { N: String(s.commitCount ?? 0) },
505
+ autonomous: { BOOL: s.autonomous === true },
424
506
  ttl: { N: String(ttl) },
425
507
  };
426
508
  if (s.repo) item.repo = { S: s.repo };
@@ -473,6 +555,9 @@ async function writeCommitAttribution(
473
555
  const aiModel = usage ? usage.model : '';
474
556
 
475
557
  try {
558
+ // Write 1: Upsert metadata + origin. Does NOT touch in_main or was_reverted
559
+ // to prevent codeburn (which doesn't know about merges) from downgrading
560
+ // CI-seeded in_main=true back to false.
476
561
  await dynamoClient.send(new UpdateItemCommand({
477
562
  TableName: AI_USAGE_TABLE,
478
563
  Key: {
@@ -482,25 +567,18 @@ async function writeCommitAttribution(
482
567
  UpdateExpression:
483
568
  'SET record_type = :rt, session_id = :sid, trace_id = :tid, ' +
484
569
  '#proj = :proj, #usr = :usr, device_id = :did, ' +
485
- 'in_main = :im, was_reverted = :wr, ' +
486
570
  '#ts = :ts, updated_at = :now, #ttl = :ttl, ' +
487
- // Frozen origin verdict — see the note above.
488
571
  'ai_origin = :ao, ai_tool = :at, ai_model = :am, origin_source = :os, ' +
489
- // Sparse by-user GSI keys: per-developer commit queries (GET
490
- // /v1/productivity) without scanning the table.
491
- 'gsi_user = :gu, gsi_user_sk = :gusk',
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.
572
+ 'gsi_user = :gu, gsi_user_sk = :gusk, autonomous = :auto, ' +
573
+ // Set in_main and was_reverted ONLY on new items. Existing values are
574
+ // preserved — upgrades (false→true) happen in Write 2 below.
575
+ 'in_main = if_not_exists(in_main, :im), ' +
576
+ 'was_reverted = if_not_exists(was_reverted, :wr)',
496
577
  ConditionExpression:
497
- 'attribute_not_exists(pk) OR in_main <> :im OR was_reverted <> :wr ' +
578
+ 'attribute_not_exists(pk) ' +
498
579
  'OR attribute_not_exists(ai_origin) ' +
499
580
  'OR (ai_origin = :human AND :ao = :ai)',
500
581
  ExpressionAttributeNames: {
501
- // 'project', 'ttl', 'user', and 'timestamp' are all DynamoDB
502
- // reserved keywords — bare use in an expression throws
503
- // ValidationException at runtime.
504
582
  '#proj': 'project',
505
583
  '#ttl': 'ttl',
506
584
  '#usr': 'user',
@@ -519,6 +597,7 @@ async function writeCommitAttribution(
519
597
  ':now': { S: new Date().toISOString() },
520
598
  ':ttl': { N: String(ttl) },
521
599
  ':ao': { S: aiOrigin },
600
+ ':auto': { BOOL: s.autonomous === true },
522
601
  ':at': { S: aiTool },
523
602
  ':am': { S: aiModel },
524
603
  ':os': { S: 'write-time-join' },
@@ -528,11 +607,58 @@ async function writeCommitAttribution(
528
607
  ':gusk': { S: `COMMIT#${s.timestamp}` },
529
608
  },
530
609
  }));
531
- return true;
532
610
  } catch (e) {
533
- if (e instanceof ConditionalCheckFailedException) return false; // no-op, state unchanged
534
- throw e;
611
+ if (!(e instanceof ConditionalCheckFailedException)) throw e;
612
+ // Item exists and origin is already ai-generated (or same) — no-op for Write 1.
613
+ }
614
+
615
+ // Write 2: Upgrade boolean state fields (false→true only). These are
616
+ // separate so they never downgrade: codeburn may push in_main=false before
617
+ // the commit merges, while CI already seeded in_main=true.
618
+ if (s.inMain) {
619
+ try {
620
+ await dynamoClient.send(new UpdateItemCommand({
621
+ TableName: AI_USAGE_TABLE,
622
+ Key: {
623
+ pk: { S: `REPO#${s.repo}` },
624
+ sk: { S: `COMMIT#${s.sha}` },
625
+ },
626
+ UpdateExpression: 'SET in_main = :true, updated_at = :now',
627
+ ConditionExpression: 'attribute_exists(pk) AND in_main = :false',
628
+ ExpressionAttributeValues: {
629
+ ':true': { BOOL: true },
630
+ ':false': { BOOL: false },
631
+ ':now': { S: new Date().toISOString() },
632
+ },
633
+ }));
634
+ } catch (e) {
635
+ if (!(e instanceof ConditionalCheckFailedException)) throw e;
636
+ // Already true — expected.
637
+ }
535
638
  }
639
+ if (s.wasReverted) {
640
+ try {
641
+ await dynamoClient.send(new UpdateItemCommand({
642
+ TableName: AI_USAGE_TABLE,
643
+ Key: {
644
+ pk: { S: `REPO#${s.repo}` },
645
+ sk: { S: `COMMIT#${s.sha}` },
646
+ },
647
+ UpdateExpression: 'SET was_reverted = :true, updated_at = :now',
648
+ ConditionExpression: 'attribute_exists(pk) AND was_reverted = :false',
649
+ ExpressionAttributeValues: {
650
+ ':true': { BOOL: true },
651
+ ':false': { BOOL: false },
652
+ ':now': { S: new Date().toISOString() },
653
+ },
654
+ }));
655
+ } catch (e) {
656
+ if (!(e instanceof ConditionalCheckFailedException)) throw e;
657
+ // Already true — expected.
658
+ }
659
+ }
660
+
661
+ return true;
536
662
  }
537
663
 
538
664
  /** Process attribution spans. Returns count of writes. */
@@ -553,7 +679,14 @@ async function processAttributionSpans(user: string, spans: ParsedAttributionSpa
553
679
  for (let i = 0; i < spans.length; i += CONCURRENCY) {
554
680
  const chunk = spans.slice(i, i + CONCURRENCY);
555
681
  const results = await Promise.all(chunk.map(async (s) => {
556
- if (s.kind === 'session') return writeSessionAttribution(user, s);
682
+ if (s.kind === 'session') {
683
+ // Fire alongside, not instead: the issue record answers "how many
684
+ // issues" and the session record answers "what happened in one
685
+ // sitting". Failing to dedup an issue must not fail the session write.
686
+ await recordIssue(user, s.repo ?? '', s.issueNumber ?? 0, s.timestamp,
687
+ s.autonomous === true);
688
+ return writeSessionAttribution(user, s);
689
+ }
557
690
  return writeCommitAttribution(user, s, traceMap);
558
691
  }));
559
692
  written += results.filter(Boolean).length;
@@ -692,7 +825,7 @@ export async function queryCommitAttribution(repo: string, sha: string): Promise
692
825
  traceId,
693
826
  user,
694
827
  project: item.project?.S ?? '',
695
- inMain: item.in_main?.BOOL ?? false,
828
+ inMain: (item.in_main?.BOOL ?? false) || item.origin_source?.S === 'ci-seeded',
696
829
  wasReverted: item.was_reverted?.BOOL ?? false,
697
830
  timestamp: item.timestamp?.S ?? '',
698
831
  aiOrigin,
@@ -755,7 +888,7 @@ export async function queryRepoCommits(repo: string, limit = 50): Promise<Commit
755
888
  traceId,
756
889
  user,
757
890
  project: item.project?.S ?? '',
758
- inMain: item.in_main?.BOOL ?? false,
891
+ inMain: (item.in_main?.BOOL ?? false) || item.origin_source?.S === 'ci-seeded',
759
892
  wasReverted: item.was_reverted?.BOOL ?? false,
760
893
  timestamp: item.timestamp?.S ?? '',
761
894
  aiOrigin,
@@ -892,6 +1025,16 @@ type UserProductivity = {
892
1025
  costPerAiCommit: number | null;
893
1026
  costPerShippedCommit: number | null;
894
1027
  };
1028
+ /** Distinct issues this identity worked on -- an agent metric with no human analogue. */
1029
+ issues: number;
1030
+ /**
1031
+ * True when every usage span for this identity was marked prism.autonomous.
1032
+ * Autonomous work is not human-assisted AI use, and PRISM's levels treat them as
1033
+ * different things: L5 is about autonomous deployments, not AI share. Kept
1034
+ * separable so it can be reported on its own rather than quietly inflating
1035
+ * everybody's numbers.
1036
+ */
1037
+ autonomous: boolean;
895
1038
  };
896
1039
 
897
1040
  function computeRatios(u: UserProductivity): void {
@@ -912,6 +1055,8 @@ function emptyUserProductivity(user: string): UserProductivity {
912
1055
  user,
913
1056
  usage: { inputTokens: 0, outputTokens: 0, costUsd: 0, calls: 0, byTool: {}, byModel: {} },
914
1057
  commits: { total: 0, ai: 0, human: 0, mergedAi: 0, revertedAi: 0 },
1058
+ issues: 0,
1059
+ autonomous: false,
915
1060
  ratios: { aiSharePct: null, mergeRatePct: null, defectRatePct: null, costPerAiCommit: null, costPerShippedCommit: null },
916
1061
  };
917
1062
  }
@@ -987,6 +1132,36 @@ async function handleProductivityQuery(event: HttpApiEvent): Promise<HttpApiResp
987
1132
  } while (lastKey);
988
1133
  }
989
1134
 
1135
+ // --- Issues worked on ---
1136
+ // Counted from ISSUE# items rather than derived from spans: an agent emits one
1137
+ // usage span per invocation, so a re-run of the same issue would inflate a
1138
+ // span-derived count. The sk is ISSUE#<repo>#<number> and the write is
1139
+ // conditional, so these are distinct by construction and counting them is enough.
1140
+ //
1141
+ // Not date-filtered on purpose. `first_seen` records when an issue was first
1142
+ // touched, and an issue worked across a window boundary should still count once;
1143
+ // filtering by it would drop long-running work from the later window.
1144
+ {
1145
+ let lastKey: import('@aws-sdk/client-dynamodb').ScanCommandOutput['LastEvaluatedKey'];
1146
+ do {
1147
+ const result = await dynamoClient.send(new ScanCommand({
1148
+ TableName: AI_USAGE_TABLE,
1149
+ FilterExpression: 'record_type = :rt',
1150
+ ExpressionAttributeValues: { ':rt': { S: 'OTEL_ISSUE' } },
1151
+ ...(lastKey ? { ExclusiveStartKey: lastKey } : {}),
1152
+ }));
1153
+ for (const item of result.Items ?? []) {
1154
+ const email = item.pk?.S?.replace('USER#', '') ?? '';
1155
+ if (!email) continue;
1156
+ if (userParam !== 'all' && email !== userParam) continue;
1157
+ const u = getUser(email);
1158
+ u.issues += 1;
1159
+ if (item.autonomous?.BOOL === true) u.autonomous = true;
1160
+ }
1161
+ lastKey = result.LastEvaluatedKey;
1162
+ } while (lastKey);
1163
+ }
1164
+
990
1165
  // --- Commits ---
991
1166
  type RawCommit = { user: string; traceId: string; inMain: boolean; wasReverted: boolean; storedOrigin?: string };
992
1167
  const rawCommits: RawCommit[] = [];
@@ -1010,7 +1185,7 @@ async function handleProductivityQuery(event: HttpApiEvent): Promise<HttpApiResp
1010
1185
  rawCommits.push({
1011
1186
  user: userParam,
1012
1187
  traceId: item.trace_id?.S ?? '',
1013
- inMain: item.in_main?.BOOL ?? false,
1188
+ inMain: (item.in_main?.BOOL ?? false) || item.origin_source?.S === 'ci-seeded',
1014
1189
  wasReverted: item.was_reverted?.BOOL ?? false,
1015
1190
  storedOrigin: item.ai_origin?.S,
1016
1191
  });
@@ -1035,7 +1210,7 @@ async function handleProductivityQuery(event: HttpApiEvent): Promise<HttpApiResp
1035
1210
  rawCommits.push({
1036
1211
  user: item.user?.S ?? '',
1037
1212
  traceId: item.trace_id?.S ?? '',
1038
- inMain: item.in_main?.BOOL ?? false,
1213
+ inMain: (item.in_main?.BOOL ?? false) || item.origin_source?.S === 'ci-seeded',
1039
1214
  wasReverted: item.was_reverted?.BOOL ?? false,
1040
1215
  storedOrigin: item.ai_origin?.S,
1041
1216
  });
@@ -1048,8 +1223,9 @@ async function handleProductivityQuery(event: HttpApiEvent): Promise<HttpApiResp
1048
1223
  // usage spans exist for the commit's traceId).
1049
1224
  const traceMaps = new Map<string, Map<string, { tool: string; model: string }>>();
1050
1225
  for (const c of rawCommits) {
1051
- if (!c.user) continue;
1052
- const u = getUser(c.user);
1226
+ // CI-seeded items may lack a user field — still count them in fleet totals.
1227
+ const userKey = c.user || '__unattributed__';
1228
+ const u = getUser(userKey);
1053
1229
  u.commits.total++;
1054
1230
  // Prefer the verdict frozen at ingest. Legacy items (no ai_origin) fall
1055
1231
  // back to a live span join, which is only reliable inside SPAN_TTL_DAYS.
@@ -1057,12 +1233,12 @@ async function handleProductivityQuery(event: HttpApiEvent): Promise<HttpApiResp
1057
1233
  if (c.storedOrigin === 'ai-generated' || c.storedOrigin === 'human') {
1058
1234
  isAi = c.storedOrigin === 'ai-generated';
1059
1235
  } else {
1060
- let traceMap = traceMaps.get(c.user);
1061
- if (!traceMap) {
1236
+ let traceMap = c.user ? traceMaps.get(c.user) : undefined;
1237
+ if (c.user && !traceMap) {
1062
1238
  traceMap = await buildUserTraceMap(c.user);
1063
1239
  traceMaps.set(c.user, traceMap);
1064
1240
  }
1065
- isAi = c.traceId !== '' && traceMap.has(c.traceId);
1241
+ isAi = c.traceId !== '' && !!traceMap?.has(c.traceId);
1066
1242
  }
1067
1243
  if (isAi) {
1068
1244
  u.commits.ai++;
@@ -1102,12 +1278,55 @@ async function handleProductivityQuery(event: HttpApiEvent): Promise<HttpApiResp
1102
1278
  computeRatios(totals);
1103
1279
  totals.usage.costUsd = Math.round(totals.usage.costUsd * 100) / 100;
1104
1280
 
1281
+ // Agents are separated from `totals`, and this is a deliberate change of meaning.
1282
+ //
1283
+ // `totals` sums every key in the map, so once the coding agent started reporting,
1284
+ // its spend and its commits would have flowed into the fleet AI-share and
1285
+ // cost-per-shipped-commit figures that the Developer Productivity and Executive
1286
+ // dashboards render -- at roughly $1.75 an issue, not a rounding error beside
1287
+ // human usage. Two things would have gone wrong at once: the human numbers would
1288
+ // read as worse (or better) than they are, and the autonomous signal would be
1289
+ // spent rather than captured, since PRISM's L5 gate is about autonomous
1290
+ // deployments and not about AI share.
1291
+ //
1292
+ // So `totals` is now humans, `agentTotals` is agents, and `agents` lists them.
1293
+ // Safe to change today because no agent data exists yet; a dashboard reading
1294
+ // `totals` keeps meaning what its author intended.
1295
+ const everyone = [...users.values()].filter(u => u.user !== '__unattributed__');
1296
+ const humans = everyone.filter(u => !u.autonomous);
1297
+ const agents = everyone.filter(u => u.autonomous);
1298
+
1299
+ const humanTotals = emptyUserProductivity('__totals__');
1300
+ const agentTotals = emptyUserProductivity('__agents__');
1301
+ for (const [group, into] of [[humans, humanTotals], [agents, agentTotals]] as const) {
1302
+ for (const u of group) {
1303
+ into.usage.inputTokens += u.usage.inputTokens;
1304
+ into.usage.outputTokens += u.usage.outputTokens;
1305
+ into.usage.costUsd += u.usage.costUsd;
1306
+ into.usage.calls += u.usage.calls;
1307
+ into.commits.total += u.commits.total;
1308
+ into.commits.ai += u.commits.ai;
1309
+ into.commits.human += u.commits.human;
1310
+ into.commits.mergedAi += u.commits.mergedAi;
1311
+ into.commits.revertedAi += u.commits.revertedAi;
1312
+ into.issues += u.issues;
1313
+ }
1314
+ computeRatios(into);
1315
+ into.usage.costUsd = Math.round(into.usage.costUsd * 100) / 100;
1316
+ }
1317
+ agentTotals.autonomous = true;
1318
+
1105
1319
  return jsonResponse(200, {
1106
1320
  range: { from, to },
1107
1321
  scope: userParam === 'all' ? 'org' : 'user',
1108
1322
  generatedAt: new Date().toISOString(),
1109
- users: [...users.values()].sort((a, b) => b.usage.costUsd - a.usage.costUsd),
1110
- totals,
1323
+ users: humans.sort((a, b) => b.usage.costUsd - a.usage.costUsd),
1324
+ totals: humanTotals,
1325
+ agents: agents.sort((a, b) => b.usage.costUsd - a.usage.costUsd),
1326
+ agentTotals,
1327
+ // The pre-split figure, so a caller that genuinely wants everything does not
1328
+ // have to add two objects and get the ratios wrong doing it.
1329
+ fleetTotals: totals,
1111
1330
  });
1112
1331
  }
1113
1332
 
@@ -1168,7 +1387,7 @@ async function handleReposQuery(event: HttpApiEvent): Promise<HttpApiResponse> {
1168
1387
  repo: (item.pk?.S ?? '').replace(/^REPO#/, ''),
1169
1388
  user: item.user?.S ?? '',
1170
1389
  traceId: item.trace_id?.S ?? '',
1171
- inMain: item.in_main?.BOOL ?? false,
1390
+ inMain: (item.in_main?.BOOL ?? false) || item.origin_source?.S === 'ci-seeded',
1172
1391
  wasReverted: item.was_reverted?.BOOL ?? false,
1173
1392
  ts: item.timestamp?.S ?? '',
1174
1393
  storedOrigin: item.ai_origin?.S,
@@ -1239,6 +1458,73 @@ async function handleReposQuery(event: HttpApiEvent): Promise<HttpApiResponse> {
1239
1458
  });
1240
1459
  }
1241
1460
 
1461
+ /**
1462
+ * GET /v1/commits-daily?from=YYYY-MM-DD&to=YYYY-MM-DD
1463
+ *
1464
+ * Returns per-day commit counts bucketed by ai_origin. Designed for dashboard
1465
+ * bar charts that replace the CloudWatch-native "Commits / Day (AI vs Human)"
1466
+ * graph — reading DDB at render time avoids the increment-only CloudWatch
1467
+ * limitation that makes origin upgrades (human->ai) double-count.
1468
+ */
1469
+ async function handleCommitsDailyQuery(event: HttpApiEvent): Promise<HttpApiResponse> {
1470
+ const caller = resolveIdentity(event);
1471
+ if (!caller) return jsonResponse(401, { message: 'No resolvable identity claim in token' });
1472
+
1473
+ const url = new URL(event.rawPath + '?' + (event.rawQueryString ?? ''), 'http://localhost');
1474
+ const now = new Date();
1475
+ const from = url.searchParams.get('from') ?? new Date(now.getTime() - 30 * 86400_000).toISOString().slice(0, 10);
1476
+ const to = url.searchParams.get('to') ?? now.toISOString().slice(0, 10);
1477
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(from) || !/^\d{4}-\d{2}-\d{2}$/.test(to)) {
1478
+ return jsonResponse(400, { message: 'from/to must be YYYY-MM-DD' });
1479
+ }
1480
+
1481
+ // Scan COMMIT# items in the date range.
1482
+ const days = new Map<string, { ai: number; human: number; mergedAi: number; mergedHuman: number }>();
1483
+ let lastKey: import('@aws-sdk/client-dynamodb').ScanCommandOutput['LastEvaluatedKey'];
1484
+ do {
1485
+ const result = await dynamoClient.send(new ScanCommand({
1486
+ TableName: AI_USAGE_TABLE,
1487
+ FilterExpression: 'record_type = :rt AND #ts BETWEEN :lo AND :hi',
1488
+ ExpressionAttributeNames: { '#ts': 'timestamp' },
1489
+ ExpressionAttributeValues: {
1490
+ ':rt': { S: 'OTEL_ATTR_COMMIT' },
1491
+ ':lo': { S: from },
1492
+ ':hi': { S: `${to}\uffff` },
1493
+ },
1494
+ ProjectionExpression: '#ts, ai_origin, in_main',
1495
+ ...(lastKey ? { ExclusiveStartKey: lastKey } : {}),
1496
+ }));
1497
+ for (const item of result.Items ?? []) {
1498
+ const ts = item.timestamp?.S ?? '';
1499
+ const day = ts.slice(0, 10);
1500
+ if (!day) continue;
1501
+ const origin = item.ai_origin?.S ?? 'human';
1502
+ const inMain = item.in_main?.BOOL ?? false;
1503
+ let bucket = days.get(day);
1504
+ if (!bucket) { bucket = { ai: 0, human: 0, mergedAi: 0, mergedHuman: 0 }; days.set(day, bucket); }
1505
+ if (origin === 'ai-generated') {
1506
+ bucket.ai++;
1507
+ if (inMain) bucket.mergedAi++;
1508
+ } else {
1509
+ bucket.human++;
1510
+ if (inMain) bucket.mergedHuman++;
1511
+ }
1512
+ }
1513
+ lastKey = result.LastEvaluatedKey;
1514
+ } while (lastKey);
1515
+
1516
+ // Sort ascending by date and return as an array.
1517
+ const sorted = [...days.entries()]
1518
+ .sort((a, b) => a[0].localeCompare(b[0]))
1519
+ .map(([date, counts]) => ({ date, ...counts }));
1520
+
1521
+ return jsonResponse(200, {
1522
+ range: { from, to },
1523
+ generatedAt: new Date().toISOString(),
1524
+ days: sorted,
1525
+ });
1526
+ }
1527
+
1242
1528
  function accumulateUsage(u: UserProductivity, item: Record<string, { S?: string; N?: string }>): void {
1243
1529
  const tool = item.tool?.S ?? 'unknown';
1244
1530
  const cost = Number(item.cost_usd?.N ?? 0);
@@ -1279,5 +1565,8 @@ export async function handler(event: HttpApiEvent): Promise<HttpApiResponse> {
1279
1565
  if (method === 'GET' && path === '/v1/repos') {
1280
1566
  return handleReposQuery(event);
1281
1567
  }
1568
+ if (method === 'GET' && path === '/v1/commits-daily') {
1569
+ return handleCommitsDailyQuery(event);
1570
+ }
1282
1571
  return jsonResponse(404, { message: 'Not found' });
1283
1572
  }
@@ -36,18 +36,29 @@ full history, real commit timestamps.
36
36
  ### Parameters
37
37
  | Name | Type | Default | Description |
38
38
  |------|------|---------|-------------|
39
- | view | string | table | 'table' (team comparison) or 'detail' (by-tool/by-model + ratios) |
39
+ | view | string | table | 'table' (team comparison), 'detail' (by-tool/by-model + ratios), or 'coding-agent' (the agent's own work, kept out of the human table) |
40
40
  | user | string | all | Developer email, or 'all' — scopes the detail view |
41
41
  `;
42
42
 
43
43
  type Breakdown = Record<string, { costUsd: number; calls: number }>;
44
44
  type UserRow = {
45
+ issues?: number;
46
+ autonomous?: boolean;
45
47
  user: string;
46
48
  usage: { inputTokens: number; outputTokens: number; costUsd: number; calls: number; byTool: Breakdown; byModel: Breakdown };
47
49
  commits: { total: number; ai: number; human: number; mergedAi: number; revertedAi: number };
48
50
  ratios: { aiSharePct: number | null; mergeRatePct: number | null; defectRatePct: number | null; costPerAiCommit: number | null; costPerShippedCommit: number | null };
49
51
  };
50
- type Report = { range: { from: string; to: string }; users: UserRow[]; totals: UserRow };
52
+ type Report = {
53
+ range: { from: string; to: string };
54
+ users: UserRow[];
55
+ totals: UserRow;
56
+ // Present once the receiver splits autonomous identities out of `totals`.
57
+ // Optional so this widget keeps rendering against an older receiver rather than
58
+ // throwing on a missing key.
59
+ agents?: UserRow[];
60
+ agentTotals?: UserRow;
61
+ };
51
62
 
52
63
  // Theme palette resolved once per invocation
53
64
  type Palette = { fg: string; mut: string; bord: string; accent: string; ok: string; warn: string };
@@ -170,6 +181,74 @@ function renderDetail(report: Report, user: string, p: Palette): string {
170
181
  </div>`;
171
182
  }
172
183
 
184
+
185
+ /**
186
+ * The coding agent's own work, kept apart from the human table.
187
+ *
188
+ * Separate because autonomous work is not human-assisted AI use: mixing them makes
189
+ * the human AI-share figure mean nothing and spends the one signal PRISM has no
190
+ * other emitter for, since L5 is about autonomous deployments. "Issues" appears
191
+ * here and not in the human table because it has no human analogue -- nobody is
192
+ * handed issues one at a time by a label.
193
+ */
194
+ function renderCodingAgent(report: Report, p: Palette): string {
195
+ const agents = report.agents ?? [];
196
+ if (agents.length === 0) {
197
+ return `
198
+ <div style="padding:14px 10px;color:${p.mut};line-height:1.6">
199
+ <div style="color:${p.fg};font-weight:bold;margin-bottom:4px">No agent activity yet</div>
200
+ This panel fills in once the PRISM coding agent emits usage spans. It needs
201
+ <code>PRISM_COLLECTOR_URL</code>, <code>PRISM_OIDC_TOKEN_ENDPOINT</code> and
202
+ <code>PRISM_AGENT_SECRET_ID</code> set as repository or organization
203
+ variables, and the harness deployed. An empty panel here means the emitter is
204
+ not configured &mdash; not that the agent has done nothing.
205
+ </div>`;
206
+ }
207
+
208
+ const t = report.agentTotals ?? agents[0];
209
+ const card = (label: string, value: string, note = ''): string => `
210
+ <div style="flex:1;min-width:120px;padding:8px 10px;border:1px solid ${p.bord};border-radius:6px">
211
+ <div style="color:${p.mut};font-size:11px">${label}</div>
212
+ <div style="color:${p.fg};font-size:20px;font-weight:bold">${value}</div>
213
+ ${note ? `<div style="color:${p.mut};font-size:11px">${note}</div>` : ''}
214
+ </div>`;
215
+
216
+ const perIssue = t.issues && t.issues > 0 ? t.usage.costUsd / t.issues : null;
217
+
218
+ const rows = agents.map(a => `
219
+ <tr style="border-top:1px solid ${p.bord}">
220
+ <td style="padding:6px 10px;color:${p.accent};white-space:nowrap">${esc(a.user)}</td>
221
+ <td style="padding:6px 10px;text-align:right">${num(a.issues ?? 0)}</td>
222
+ <td style="padding:6px 10px;text-align:right">${num(a.commits.ai)}</td>
223
+ <td style="padding:6px 10px;text-align:right">${num(a.commits.mergedAi)}</td>
224
+ <td style="padding:6px 10px;text-align:right;color:${(a.ratios.mergeRatePct ?? 100) >= 85 ? p.ok : p.warn}">${pct(a.ratios.mergeRatePct)}</td>
225
+ <td style="padding:6px 10px;text-align:right">${pct(a.ratios.defectRatePct)}</td>
226
+ <td style="padding:6px 10px;text-align:right">${money(a.usage.costUsd)}</td>
227
+ <td style="padding:6px 10px;text-align:right">${a.ratios.costPerShippedCommit === null ? '&mdash;' : money(a.ratios.costPerShippedCommit)}</td>
228
+ </tr>`).join('');
229
+
230
+ const header = ['Agent', 'Issues', 'Commits', 'Shipped', 'Merge Rate', 'Defect Rate', 'Spend', '$ / Shipped']
231
+ .map((h, i) => `<th style="padding:6px 10px;color:${p.mut};font-weight:normal;text-align:${i === 0 ? 'left' : 'right'}">${h}</th>`)
232
+ .join('');
233
+
234
+ return `
235
+ <div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px">
236
+ ${card('Issues worked', num(t.issues ?? 0))}
237
+ ${card('Commits shipped', num(t.commits.mergedAi), `of ${num(t.commits.ai)} made`)}
238
+ ${card('Spend', money(t.usage.costUsd), 'estimated from tokens')}
239
+ ${card('$ / issue', perIssue === null ? '&mdash;' : money(perIssue))}
240
+ ${card('$ / shipped', t.ratios.costPerShippedCommit === null ? '&mdash;' : money(t.ratios.costPerShippedCommit))}
241
+ </div>
242
+ <table style="border-collapse:collapse;width:100%">
243
+ <tr>${header}</tr>
244
+ ${rows}
245
+ </table>
246
+ <div style="color:${p.mut};font-size:11px;margin-top:8px;line-height:1.5">
247
+ Excluded from the developer table and from ORG TOTAL above. Cost is estimated
248
+ from token counts, since the harness reports tokens and not dollars.
249
+ </div>`;
250
+ }
251
+
173
252
  export async function handler(event: WidgetEvent): Promise<string> {
174
253
  if (event.describe) return DOCS;
175
254
 
@@ -190,12 +269,14 @@ export async function handler(event: WidgetEvent): Promise<string> {
190
269
  return `<p style="color:#d13212">${esc((e as Error).message)}</p>`;
191
270
  }
192
271
 
193
- const body = view === 'detail' ? renderDetail(report, user, p) : renderTable(report, p);
272
+ const body = view === 'coding-agent' ? renderCodingAgent(report, p)
273
+ : view === 'detail' ? renderDetail(report, user, p)
274
+ : renderTable(report, p);
194
275
  return `
195
276
  <div style="color:${p.fg};font-size:13px">
196
277
  <div style="color:${p.mut};margin-bottom:6px">
197
278
  ${report.range.from} → ${report.range.to}
198
- · ${report.users.length} developer(s)
279
+ · ${view === 'coding-agent' ? `${(report.agents ?? []).length} agent(s)` : `${report.users.length} developer(s)`}
199
280
  · attribution store (real commit times, full history)
200
281
  </div>
201
282
  ${body}