@prism-d1/cli 1.0.98 → 1.0.99

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.
@@ -32,6 +32,8 @@ on:
32
32
  pull_request:
33
33
  types: [closed]
34
34
  branches: [main, master]
35
+ push:
36
+ branches: [main, master]
35
37
 
36
38
 
37
39
  env:
@@ -45,7 +47,7 @@ permissions:
45
47
 
46
48
  jobs:
47
49
  emit-metrics:
48
- if: github.event.pull_request.merged == true
50
+ if: github.event_name == 'pull_request' && github.event.pull_request.merged == true
49
51
  runs-on: ubuntu-latest
50
52
  steps:
51
53
  - name: Checkout
@@ -245,3 +247,172 @@ jobs:
245
247
  \"EventBusName\": \"prism-d1-metrics\",
246
248
  \"Detail\": $(echo "${DEPLOY_EVENT}" | jq -c '.' | jq -Rs '.')
247
249
  }]"
250
+
251
+ # ---------------------------------------------------------------------------
252
+ # Census-only path: commits that reached the default branch WITHOUT a PR.
253
+ #
254
+ # WHY THIS EXISTS. Attribution Coverage is attributed_commits / ci_commits,
255
+ # and ci_commits was sourced solely from merged-PR events. A direct push to
256
+ # main therefore shrank the denominator while codeburn still attributed the
257
+ # commits, so coverage rose toward a confident 100% precisely as the census
258
+ # became less complete -- the one number meant to flag untrustworthy AI
259
+ # metrics failed in the reassuring direction. See velocity-widget.ts →
260
+ # attributionCoverage().
261
+ #
262
+ # WHAT IT DELIBERATELY DOES NOT DO. It emits no `dora` block and no
263
+ # prism.d1.deploy event, and it uses its own detail type rather than
264
+ # prism.d1.pr. Three reasons, each load-bearing:
265
+ # - Change Failure Rate is failure_fixes / prs.length. Pushing census
266
+ # events into prism.d1.pr would inflate that denominator and make the
267
+ # revert rate look better, reintroducing the same class of lie.
268
+ # - A push has no created_at → merged_at pair, so any lead time would be
269
+ # fabricated.
270
+ # - A push has no title convention to test for revert/hotfix/rollback.
271
+ # Deployment Frequency therefore still counts merges only. That is the
272
+ # accepted trade: this job fixes the census, it does not invent DORA data.
273
+ #
274
+ # NOT prism.d1.commit, which already exists: that type is per-commit and
275
+ # carries ai_context.{tool,model,origin} from the deprecated git hook. Its
276
+ # two remaining consumers (api-handler.ts, security-agent-processor.ts)
277
+ # expect that shape, and AI origin is deliberately resolved at render time
278
+ # here rather than at CI time.
279
+ emit-push-census:
280
+ # A PR merge fires BOTH pull_request:closed AND push, so this job must not
281
+ # double-count it. The head-commit-has-no-PR check below is the guard; this
282
+ # condition only keeps push-triggered runs.
283
+ if: github.event_name == 'push'
284
+ runs-on: ubuntu-latest
285
+ steps:
286
+ - name: Checkout
287
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
288
+ with:
289
+ # Full history: `git rev-list before..after` needs both endpoints.
290
+ fetch-depth: 0
291
+
292
+ - name: Skip if this push came from a PR merge
293
+ id: gate
294
+ env:
295
+ GH_TOKEN: ${{ github.token }}
296
+ run: |
297
+ set -uo pipefail
298
+ SHA="${{ github.event.after }}"
299
+ REPO="${{ github.repository }}"
300
+
301
+ # Covers all three merge strategies in one check. A merge commit, a
302
+ # squashed commit, and a rebased commit are all associated with their
303
+ # PR by this endpoint, whereas a hand-pushed commit is associated with
304
+ # none. Testing parent count instead would only catch merge commits
305
+ # and would double-count every squash merge.
306
+ PR_COUNT=$(gh api "repos/${REPO}/commits/${SHA}/pulls" --jq 'length' 2>/dev/null || echo "unknown")
307
+
308
+ if [[ "${PR_COUNT}" == "unknown" ]]; then
309
+ # Fail closed: emitting on an inconclusive check risks double-counting
310
+ # the census, which corrupts coverage in the direction this job exists
311
+ # to prevent. Under-counting is the safer error.
312
+ echo "Could not determine PR association for ${SHA}; skipping to avoid double-count."
313
+ echo "emit=false" >> "$GITHUB_OUTPUT"
314
+ elif [[ "${PR_COUNT}" != "0" ]]; then
315
+ echo "${SHA} belongs to ${PR_COUNT} PR(s); the emit-metrics job owns this merge."
316
+ echo "emit=false" >> "$GITHUB_OUTPUT"
317
+ else
318
+ echo "${SHA} has no associated PR — direct push, emitting census."
319
+ echo "emit=true" >> "$GITHUB_OUTPUT"
320
+ fi
321
+
322
+ - name: Configure AWS credentials (OIDC)
323
+ if: steps.gate.outputs.emit == 'true'
324
+ uses: aws-actions/configure-aws-credentials@b47578312673ae6fa5b5096b330d9fbac3d116df # v4.2.1
325
+ with:
326
+ role-to-assume: ${{ secrets.PRISM_METRICS_ROLE_ARN }}
327
+ aws-region: ${{ env.PRISM_AWS_REGION }}
328
+
329
+ - name: Read team ID from .prism/config.json
330
+ if: steps.gate.outputs.emit == 'true'
331
+ id: prism-config
332
+ run: |
333
+ if [[ -f .prism/config.json ]]; then
334
+ TEAM_ID=$(jq -r '.team_id // ""' .prism/config.json 2>/dev/null || echo "")
335
+ else
336
+ TEAM_ID=""
337
+ fi
338
+ if [[ -z "${TEAM_ID}" ]]; then
339
+ TEAM_ID="no_team"
340
+ fi
341
+ echo "team_id=${TEAM_ID}" >> "$GITHUB_OUTPUT"
342
+
343
+ - name: Collect commit census
344
+ if: steps.gate.outputs.emit == 'true'
345
+ id: census
346
+ run: |
347
+ set -uo pipefail
348
+ BEFORE="${{ github.event.before }}"
349
+ AFTER="${{ github.event.after }}"
350
+
351
+ # A new branch (or the first push) reports an all-zero `before`, and
352
+ # a force-push reports a SHA that is no longer an ancestor. Both make
353
+ # `before..after` invalid or unboundedly large, so fall back to the
354
+ # single head commit rather than emitting a bogus count.
355
+ if [[ "${BEFORE}" =~ ^0+$ ]] || ! git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then
356
+ echo "Unusable before-SHA (${BEFORE}); counting the head commit only."
357
+ RANGE="${AFTER} -1"
358
+ else
359
+ RANGE="${BEFORE}..${AFTER}"
360
+ fi
361
+
362
+ # --no-merges and the 50-item cap match the PR path exactly, so both
363
+ # sides of the coverage ratio are counted on the same basis. A merge
364
+ # commit introduces no authored lines and can never appear in the
365
+ # attribution store, so counting one would put a structural ceiling
366
+ # on coverage.
367
+ COMMIT_SHAS=$(git rev-list --no-merges ${RANGE} 2>/dev/null | head -50 | jq -R . | jq -sc . || echo '[]')
368
+ TOTAL_COMMITS=$(git rev-list --no-merges --count ${RANGE} 2>/dev/null || echo 0)
369
+ COMMIT_AUTHORS=$(git rev-list --no-merges ${RANGE} 2>/dev/null | head -50 | while read sha; do git log -1 --format='%ae' "$sha"; done | jq -R . | jq -sc . || echo '[]')
370
+
371
+ echo "commit_shas=${COMMIT_SHAS}" >> "$GITHUB_OUTPUT"
372
+ echo "commit_authors=${COMMIT_AUTHORS}" >> "$GITHUB_OUTPUT"
373
+ echo "total_commits=${TOTAL_COMMITS}" >> "$GITHUB_OUTPUT"
374
+
375
+ - name: Emit prism.d1.push event
376
+ if: steps.gate.outputs.emit == 'true' && steps.census.outputs.total_commits != '0'
377
+ run: |
378
+ TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
379
+
380
+ PUSH_EVENT=$(jq -n \
381
+ --arg team_id "${{ steps.prism-config.outputs.team_id }}" \
382
+ --arg repo "${PRISM_REPO}" \
383
+ --arg timestamp "${TIMESTAMP}" \
384
+ --arg sha "${{ github.event.after }}" \
385
+ --arg pusher "${{ github.event.pusher.name }}" \
386
+ --argjson total_commits "${{ steps.census.outputs.total_commits }}" \
387
+ --argjson commit_shas '${{ steps.census.outputs.commit_shas }}' \
388
+ --argjson commit_authors '${{ steps.census.outputs.commit_authors }}' \
389
+ '{
390
+ "team_id": $team_id,
391
+ "repo": $repo,
392
+ "timestamp": $timestamp,
393
+ "prism_level": 2,
394
+ "metric": {
395
+ "name": "push_commits",
396
+ "value": $total_commits,
397
+ "unit": "count"
398
+ },
399
+ "push": {
400
+ "sha": $sha,
401
+ "pusher": $pusher,
402
+ "total_commits": $total_commits,
403
+ "commit_shas": $commit_shas,
404
+ "commit_authors": $commit_authors
405
+ }
406
+ }')
407
+
408
+ aws events put-events \
409
+ --region $PRISM_AWS_REGION \
410
+ --entries "[{
411
+ \"Source\": \"prism.d1.velocity\",
412
+ \"DetailType\": \"prism.d1.push\",
413
+ \"EventBusName\": \"prism-d1-metrics\",
414
+ \"Detail\": $(echo "${PUSH_EVENT}" | jq -c '.' | jq -Rs '.')
415
+ }]"
416
+
417
+ echo "Emitted prism.d1.push for ${{ github.event.after }}"
418
+ echo " commits in census: ${{ steps.census.outputs.total_commits }}"
@@ -36,6 +36,7 @@ export const PERSISTED_SECTIONS = [
36
36
  'security_agent_finding',
37
37
  'security_remediation',
38
38
  'pr',
39
+ 'push',
39
40
  ] as const;
40
41
 
41
42
  export type PersistedSection = (typeof PERSISTED_SECTIONS)[number];
@@ -76,7 +77,16 @@ export const SECTION_FIELDS: Record<PersistedSection, readonly string[]> = {
76
77
  ],
77
78
  pr: [
78
79
  'number', 'author', 'reviews_approved', 'reviews_changes_requested',
79
- 'total_commits', 'commit_shas',
80
+ 'total_commits', 'commit_shas', 'commit_authors',
81
+ ],
82
+ // Census-only counterpart to `pr`, emitted by prism-ai-metrics.yml when
83
+ // commits reach the default branch without a PR. Deliberately carries no
84
+ // lead-time or failure-fix field: a push has neither a created_at→merged_at
85
+ // pair nor a title convention, so any such value would be fabricated. Kept a
86
+ // separate section (rather than reusing `pr`) because Change Failure Rate
87
+ // divides by the count of prism.d1.pr events.
88
+ push: [
89
+ 'sha', 'pusher', 'total_commits', 'commit_shas', 'commit_authors',
80
90
  ],
81
91
  };
82
92
 
@@ -892,15 +892,20 @@ function mapUnit(unit: string): StandardUnit {
892
892
  * processor already handles both forms for point lookups.
893
893
  */
894
894
  async function seedCommitAttribution(detailType: string, detail: any): Promise<void> {
895
- // Only process prism.d1.pr events that carry commit SHAs
896
- if (detailType !== 'prism.d1.pr') return;
895
+ // prism.d1.pr and prism.d1.push both report commits that reached the default
896
+ // branch, so both seed in_main=true. A direct push is as much "shipped" as a
897
+ // merge; excluding it would undercount shipped commits and inflate the
898
+ // cost-per-shipped-commit KPI, while still charging the tokens that produced
899
+ // them. The census fields are identically shaped on the two events, only
900
+ // nested under a different key.
901
+ if (detailType !== 'prism.d1.pr' && detailType !== 'prism.d1.push') return;
897
902
  if (!AI_USAGE_TABLE) return;
898
903
 
899
- const pr = detail.pr as PrDetail | undefined;
900
- const shas = pr?.commit_shas;
904
+ const census = (detailType === 'prism.d1.pr' ? detail.pr : detail.push) as PrDetail | undefined;
905
+ const shas = census?.commit_shas;
901
906
  if (!shas || shas.length === 0) return;
902
907
 
903
- const authors: string[] = (pr as any)?.commit_authors ?? [];
908
+ const authors: string[] = (census as any)?.commit_authors ?? [];
904
909
  const repo = detail.repo as string;
905
910
  // CI workflows now emit fully-qualified repo names (github.com/owner/repo
906
911
  // or gitlab.com/group/project) matching codeburn's convention. No
@@ -193,7 +193,7 @@ async function renderDora(fromIso: string, toIso: string, days: number, p: Palet
193
193
  ]);
194
194
 
195
195
  if (deploys.length === 0 && prs.length === 0) {
196
- return emptyState('deploy / PR', 'Populated by prism-ai-metrics.yml on PR merge. Install the workflow and set PRISM_METRICS_ROLE_ARN.', p);
196
+ return emptyState('deploy / PR', 'Populated by prism-ai-metrics.yml on PR merge (and on direct pushes to the default branch, census only). Install the workflow and set PRISM_METRICS_ROLE_ARN.', p);
197
197
  }
198
198
 
199
199
  const mergeFreq = days > 0 ? deploys.length / days : deploys.length;
@@ -246,18 +246,32 @@ function median(values: number[]): number | null {
246
246
  * of who authored it and reports pr.total_commits. Attribution gives us the
247
247
  * sample. The ratio is the coverage.
248
248
  *
249
+ * `pushes` carries the other half of that census. Commits pushed straight to
250
+ * the default branch produce no PR event, so before they were counted they
251
+ * shrank this denominator while codeburn still attributed them -- coverage rose
252
+ * toward 100% exactly as the census got less complete, and the Math.min below
253
+ * hid the overshoot that would otherwise have exposed it. Both event types are
254
+ * summed here; they are kept separate from `prs` everywhere else, because
255
+ * Change Failure Rate divides by prs.length and a push has no title to test.
256
+ *
257
+ * The emitter guarantees these do not overlap: emit-push-census skips any push
258
+ * whose head commit is associated with a PR, which covers merge, squash and
259
+ * rebase strategies alike.
260
+ *
249
261
  * Returns null when CI events are absent, since coverage is unknowable then —
250
262
  * deliberately not 0%, which would imply "measured and found nothing".
251
263
  */
252
- function attributionCoverage(prs: PrismEvent[], attributedCommits: number | null): {
264
+ function attributionCoverage(prs: PrismEvent[], pushes: PrismEvent[], attributedCommits: number | null): {
253
265
  pct: number | null;
254
266
  ciCommits: number;
255
267
  attributed: number | null;
256
268
  } {
257
- const ciCommits = prs.reduce((sum, e) => {
258
- const n = e.data.pr?.total_commits;
259
- return sum + (typeof n === 'number' && n > 0 ? n : 0);
260
- }, 0);
269
+ const countCommits = (events: PrismEvent[], key: 'pr' | 'push') =>
270
+ events.reduce((sum, e) => {
271
+ const n = (e.data as any)[key]?.total_commits;
272
+ return sum + (typeof n === 'number' && n > 0 ? n : 0);
273
+ }, 0);
274
+ const ciCommits = countCommits(prs, 'pr') + countCommits(pushes, 'push');
261
275
  if (ciCommits === 0 || attributedCommits === null) {
262
276
  return { pct: null, ciCommits, attributed: attributedCommits };
263
277
  }
@@ -345,17 +359,18 @@ async function renderRepos(fromIso: string, toIso: string, p: Palette): Promise<
345
359
  }
346
360
 
347
361
  async function renderAidora(fromIso: string, toIso: string, p: Palette): Promise<string> {
348
- const [report, evals, prs] = await Promise.all([
362
+ const [report, evals, prs, pushes] = await Promise.all([
349
363
  fetchProductivity(fromIso, toIso).catch(() => null),
350
364
  queryEvents('prism.d1.eval', fromIso, toIso),
351
365
  queryEvents('prism.d1.pr', fromIso, toIso),
366
+ queryEvents('prism.d1.push', fromIso, toIso),
352
367
  ]);
353
368
 
354
369
  const t = report?.totals;
355
370
  const passRate = evals.length > 0
356
371
  ? (evals.filter(e => e.data.eval?.result === 'PASS').length / evals.length) * 100
357
372
  : null;
358
- const cov = attributionCoverage(prs, t?.commits?.total ?? null);
373
+ const cov = attributionCoverage(prs, pushes, t?.commits?.total ?? null);
359
374
 
360
375
  const cells = [
361
376
  { label: 'AI Share of Commits', value: pct(t?.ratios?.aiSharePct ?? null), note: 'attribution store', color: (t?.ratios?.aiSharePct ?? 0) >= 30 ? p.ok : undefined },
@@ -458,13 +473,14 @@ function computeObservedLevel(input: {
458
473
  // ---- View: exec (business KPIs + delivery proxies + observed level) ----
459
474
 
460
475
  async function renderExec(fromIso: string, toIso: string, days: number, p: Palette): Promise<string> {
461
- const [report, evals, mcp, guardrails, deploys, prs] = await Promise.all([
476
+ const [report, evals, mcp, guardrails, deploys, prs, pushes] = await Promise.all([
462
477
  fetchProductivity(fromIso, toIso).catch(() => null),
463
478
  queryEvents('prism.d1.eval', fromIso, toIso),
464
479
  queryEvents('prism.d1.mcp.tool_call', fromIso, toIso),
465
480
  queryEvents('prism.d1.guardrail', fromIso, toIso),
466
481
  queryEvents('prism.d1.deploy', fromIso, toIso),
467
482
  queryEvents('prism.d1.pr', fromIso, toIso),
483
+ queryEvents('prism.d1.push', fromIso, toIso),
468
484
  ]);
469
485
 
470
486
  const t = report?.totals;
@@ -506,7 +522,7 @@ async function renderExec(fromIso: string, toIso: string, days: number, p: Palet
506
522
  ]), p, 1);
507
523
 
508
524
  // --- Business KPIs (attribution store) ---
509
- const cov = attributionCoverage(prs, t?.commits?.total ?? null);
525
+ const cov = attributionCoverage(prs, pushes, t?.commits?.total ?? null);
510
526
  const businessKpis = kpiRow([
511
527
  { label: 'AI Share of Commits', value: pct(r.aiSharePct ?? null), note: 'L2 >= 30%', color: (r.aiSharePct ?? 0) >= 30 ? p.ok : undefined },
512
528
  { label: 'AI Merge Rate', value: pct(r.mergeRatePct ?? null), note: 'L2 >= 20% · L4 >= 45%', color: (r.mergeRatePct ?? 0) >= 45 ? p.ok : (r.mergeRatePct ?? 0) >= 20 ? p.warn : undefined },
@@ -288,6 +288,7 @@ export class MetricsPipelineStack extends cdk.Stack {
288
288
  const detailTypes = [
289
289
  'prism.d1.commit',
290
290
  'prism.d1.pr',
291
+ 'prism.d1.push',
291
292
  'prism.d1.deploy',
292
293
  'prism.d1.eval',
293
294
  'prism.d1.incident',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prism-d1/cli",
3
- "version": "1.0.98",
3
+ "version": "1.0.99",
4
4
  "description": "PRISM D1 Velocity CLI — AI development lifecycle assessment tool",
5
5
  "author": "andklee",
6
6
  "engines": {