@newrelic/preflight 1.14.35 → 1.14.37

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/dashboard/routes/api-handler.d.ts +3 -3
  2. package/dist/dashboard/routes/api-handler.d.ts.map +1 -1
  3. package/dist/dashboard/routes/api-handler.js +170 -39
  4. package/dist/dashboard/routes/api-handler.js.map +1 -1
  5. package/dist/dashboard/workflow-store.d.ts +20 -0
  6. package/dist/dashboard/workflow-store.d.ts.map +1 -1
  7. package/dist/dashboard/workflow-store.js +8 -0
  8. package/dist/dashboard/workflow-store.js.map +1 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +126 -42
  11. package/dist/index.js.map +1 -1
  12. package/dist/metrics/cost-tracker.d.ts +17 -0
  13. package/dist/metrics/cost-tracker.d.ts.map +1 -1
  14. package/dist/metrics/cost-tracker.js +19 -0
  15. package/dist/metrics/cost-tracker.js.map +1 -1
  16. package/dist/metrics/decision-tracker.d.ts +18 -1
  17. package/dist/metrics/decision-tracker.d.ts.map +1 -1
  18. package/dist/metrics/decision-tracker.js +28 -11
  19. package/dist/metrics/decision-tracker.js.map +1 -1
  20. package/dist/metrics/git-efficiency-tracker.d.ts +18 -5
  21. package/dist/metrics/git-efficiency-tracker.d.ts.map +1 -1
  22. package/dist/metrics/git-efficiency-tracker.js +335 -137
  23. package/dist/metrics/git-efficiency-tracker.js.map +1 -1
  24. package/dist/metrics/turn-cost-attributor.d.ts +37 -7
  25. package/dist/metrics/turn-cost-attributor.d.ts.map +1 -1
  26. package/dist/metrics/turn-cost-attributor.js +140 -45
  27. package/dist/metrics/turn-cost-attributor.js.map +1 -1
  28. package/dist/security/audit-trail.d.ts +32 -1
  29. package/dist/security/audit-trail.d.ts.map +1 -1
  30. package/dist/security/audit-trail.js +142 -4
  31. package/dist/security/audit-trail.js.map +1 -1
  32. package/dist/storage/local-store.d.ts +29 -0
  33. package/dist/storage/local-store.d.ts.map +1 -1
  34. package/dist/storage/local-store.js +93 -0
  35. package/dist/storage/local-store.js.map +1 -1
  36. package/dist/web/assets/{index-DI6sTfcj.js → index-ndsRsiKh.js} +3 -3
  37. package/dist/web/index.html +1 -1
  38. package/package.json +1 -1
@@ -48,6 +48,10 @@ const GIT_DIFF_RE = /\bgit\s+diff\b/;
48
48
  const GIT_LOG_RE = /\bgit\s+log\b/;
49
49
  const GIT_COMMIT_RE = /\bgit\s+commit\b/;
50
50
  const GIT_WORKTREE_RE = /\bgit\s+worktree\b/;
51
+ // Only `add`/`remove` create or tear down real isolation — `list`/`prune`/
52
+ // `lock`/etc. are read-only inspection and shouldn't inflate the "worktree
53
+ // ops" count with commands that don't reflect any parallel-isolation work.
54
+ const GIT_WORKTREE_ADD_REMOVE_RE = /\bgit\s+worktree\s+(?:add|remove)\b/;
51
55
  const GIT_CHECKOUT_OURS_RE = /\bgit\s+checkout\s+--ours\b/;
52
56
  const GIT_CHECKOUT_THEIRS_RE = /\bgit\s+checkout\s+--theirs\b/;
53
57
  const GIT_CHERRY_PICK_RE = /\bgit\s+cherry-pick\b/;
@@ -61,6 +65,26 @@ const GH_PR_CHECKS_RE = /\bgh\s+pr\s+checks\b/;
61
65
  const GH_COMMAND_RE = /\bgh\s+/;
62
66
  // Extract PR number from gh commands
63
67
  const GH_PR_NUMBER_RE = /\bgh\s+pr\s+\w+\s+(\d+)/;
68
+ // hydrateGitLog()'s dedup window, used only against a hook-observed commit
69
+ // event (one with no hash in its command text): `git log`'s %ct has 1-second
70
+ // resolution and a hook-observed commit event's timestamp is recorded when
71
+ // the tool call completes, so the two timestamps for the same commit are
72
+ // close but never exactly equal — and the hook event's command text is the
73
+ // raw pre-execution shell string, which never contains the resulting hash,
74
+ // so dedup against it can't key on a hash match. Treat any existing
75
+ // hook-observed commit event within this window as the same commit.
76
+ //
77
+ // A hydrated commit ALWAYS carries a real hash from `git log`, so comparing
78
+ // it against another hydrated event uses exact hash equality (via
79
+ // HYDRATED_COMMIT_HASH_RE below) instead of this proximity window — two
80
+ // genuinely distinct commits landing within the window (e.g. rapid
81
+ // sequential commits in the same `git log` batch) must not collapse into
82
+ // one just because their timestamps are close.
83
+ const COMMIT_DEDUP_WINDOW_MS = 5_000;
84
+ // Matches the synthetic command text hydrateGitLog() gives its own events
85
+ // (see below), letting isDuplicate() tell a hydrated event apart from a
86
+ // hook-observed one and recover its hash.
87
+ const HYDRATED_COMMIT_HASH_RE = /^git commit \((.+)\)$/;
64
88
  const REJECT_INDICATORS = [
65
89
  /\[rejected\]/i,
66
90
  /non-fast-forward/i,
@@ -69,15 +93,27 @@ const REJECT_INDICATORS = [
69
93
  ];
70
94
  // Conflict file path extraction: "CONFLICT (content): Merge conflict in <path>"
71
95
  const CONFLICT_FILE_RE = /Merge conflict in (.+)/g;
96
+ // Render order for suggestions/best-practices — most severe first — rather
97
+ // than fixed source-code push order, which could put a critical item below
98
+ // a milder one.
99
+ const SUGGESTION_SEVERITY_RANK = {
100
+ critical: 0,
101
+ warning: 1,
102
+ info: 2,
103
+ };
104
+ const BEST_PRACTICE_STATUS_RANK = {
105
+ fail: 0,
106
+ warn: 1,
107
+ pass: 2,
108
+ unknown: 3,
109
+ };
72
110
  // ---------------------------------------------------------------------------
73
111
  // Tracker
74
112
  // ---------------------------------------------------------------------------
75
113
  export class GitEfficiencyTracker {
76
114
  events = [];
77
115
  conflictRecords = [];
78
- pendingConflictTimestamp = null;
79
- pendingConflictCommand = '';
80
- pendingConflictFiles = [];
116
+ pendingConflicts = [];
81
117
  lastSyncTimestamp = null;
82
118
  pullsSinceLastConflict = 0;
83
119
  consecutiveFailedPushes = 0;
@@ -93,9 +129,33 @@ export class GitEfficiencyTracker {
93
129
  editedFiles = new Set();
94
130
  hasUsedWorktree = false;
95
131
  hasUsedForceWithLease = false;
132
+ // Tracks whether ANY bare (non-lease) force-push occurred this session,
133
+ // independent of whether --force-with-lease was ever also used. This is
134
+ // the single shared signal both the force_with_lease best-practice check
135
+ // and the force_push suggestion's severity gate on, so the two checks
136
+ // agree on what counts as "was this session's force-push usage safe."
137
+ hasUsedBareForcePush = false;
138
+ // Count of bare (non-lease) force-pushes specifically — kept separate from
139
+ // stats.forcePushes (which sums bare AND lease-protected pushes) so
140
+ // severity scales with how much *unsafe* force-pushing happened, not with
141
+ // total force-push volume. A safe --force-with-lease push must never be
142
+ // able to escalate severity that a bare push alone already set.
143
+ bareForcePushCount = 0;
144
+ // Snapshot of whether any bare force-push landed while repoContext.branch
145
+ // matched repoContext.defaultBranch — a bare push to the shared default
146
+ // branch can clobber other collaborators' work, unlike an identical push
147
+ // to a personal feature branch only one person is using, so the two must
148
+ // not scale to the same severity.
149
+ hasForcePushedToDefaultBranch = false;
96
150
  totalToolCalls = 0;
97
151
  sessionStartTimestamp = null;
98
152
  commitTimestamps = [];
153
+ // Latest commit timestamp covered by a `hydrateGitLog()` call. A live
154
+ // `git commit` event with a timestamp at or before this is one `git log`
155
+ // already saw at hydration time — recordToolCall() must skip it rather
156
+ // than double-count a commit that's about to arrive (or already has)
157
+ // through the normal hook-observed path too. See recordToolCall().
158
+ hydratedThroughMs = 0;
99
159
  worktreeCommands = 0;
100
160
  oursCount = 0;
101
161
  theirsCount = 0;
@@ -107,7 +167,6 @@ export class GitEfficiencyTracker {
107
167
  commitsBehindMain = null;
108
168
  quickConflictResolutions = 0;
109
169
  prEvents = [];
110
- firstCommitTimestamp = null;
111
170
  repoContext = {
112
171
  repoName: null,
113
172
  branch: null,
@@ -136,14 +195,31 @@ export class GitEfficiencyTracker {
136
195
  const command = record.command;
137
196
  if (!command)
138
197
  return;
139
- // Track GitHub CLI PR commands (skip if `git commit` is the *command*, not
140
- // text that happens to appear inside a gh argument like --title).
141
- if (GH_COMMAND_RE.test(command) && !command.trimStart().startsWith('git ')) {
142
- this.processGhCommand(command, record.timestamp);
198
+ // Track GitHub CLI PR commands. Split on shell separators first so a
199
+ // `gh` invocation chained after a `git` command (e.g. `git push && gh pr
200
+ // create --fill`) is still detected — checking the git-prefix guard
201
+ // against the whole compound string would skip it even though only the
202
+ // first segment is a `git` command. Each segment still skips the case
203
+ // where "gh" is just text inside a git argument, e.g. `git commit -m "gh
204
+ // pr create note"`.
205
+ for (const segment of command.split(/&&|;|\|/)) {
206
+ const trimmedSegment = segment.trim();
207
+ if (GH_COMMAND_RE.test(trimmedSegment) && !trimmedSegment.startsWith('git ')) {
208
+ this.processGhCommand(trimmedSegment, record.timestamp);
209
+ }
143
210
  }
144
211
  if (!GIT_COMMAND_RE.test(command))
145
212
  return;
146
213
  const event = this.classifyGitCommand(command, record);
214
+ // A hook-observed commit whose timestamp `hydrateGitLog()` already saw
215
+ // (via `git log`) is the same commit, not a new one — hydrateGitLog()
216
+ // can't dedupe this itself since a live event's `command` is the raw
217
+ // shell string, not `git commit (<hash>)`, so it never matches its own
218
+ // hash-based check. Without this, a day-boundary hydration followed by
219
+ // this same commit's hook event arriving from the same drain batch
220
+ // would count it twice.
221
+ if (event.type === 'commit' && event.timestamp <= this.hydratedThroughMs)
222
+ return;
147
223
  this.events.push(event);
148
224
  this.processEvent(event, command, record);
149
225
  }
@@ -181,14 +257,29 @@ export class GitEfficiencyTracker {
181
257
  const merged = this.prEvents.filter((e) => e.action === 'merge').length;
182
258
  const checksViewed = this.prEvents.filter((e) => e.action === 'checks').length;
183
259
  const prsUpdated = this.prEvents.filter((e) => e.action === 'edit' || e.action === 'ready').length;
184
- // Time from first commit to first PR creation
185
- let avgTimeToCreateMs = null;
186
- if (created > 0 && this.firstCommitTimestamp !== null) {
187
- const firstCreate = this.prEvents.find((e) => e.action === 'create');
188
- if (firstCreate) {
189
- avgTimeToCreateMs = Math.max(0, firstCreate.timestamp - this.firstCommitTimestamp);
260
+ // Time from each PR's most recent preceding commit to its `gh pr
261
+ // create`, averaged across every PR opened this session — not a single
262
+ // delta anchored to whichever commit happened to be the very first one
263
+ // this tracker ever saw, which would go stale after the first PR and
264
+ // ignore every PR opened later in the same session.
265
+ const sortedCommitTimestamps = [...this.commitTimestamps].sort((a, b) => a - b);
266
+ const timesToCreate = [];
267
+ for (const prEvent of this.prEvents) {
268
+ if (prEvent.action !== 'create')
269
+ continue;
270
+ let precedingCommitTimestamp = null;
271
+ for (const commitTimestamp of sortedCommitTimestamps) {
272
+ if (commitTimestamp > prEvent.timestamp)
273
+ break;
274
+ precedingCommitTimestamp = commitTimestamp;
275
+ }
276
+ if (precedingCommitTimestamp !== null) {
277
+ timesToCreate.push(Math.max(0, prEvent.timestamp - precedingCommitTimestamp));
190
278
  }
191
279
  }
280
+ const avgTimeToCreateMs = timesToCreate.length > 0
281
+ ? timesToCreate.reduce((a, b) => a + b, 0) / timesToCreate.length
282
+ : null;
192
283
  return {
193
284
  created,
194
285
  merged,
@@ -209,17 +300,57 @@ export class GitEfficiencyTracker {
209
300
  success: true,
210
301
  durationMs: null,
211
302
  };
212
- // Only add if we don't already have this commit tracked
213
- const isDuplicate = this.events.some((e) => e.type === 'commit' && e.command?.includes(commit.hash));
303
+ // Only add if we don't already have this commit tracked. Against an
304
+ // existing hydrated event (one carrying its own real hash), match by
305
+ // exact hash equality — precise, and avoids collapsing two genuinely
306
+ // distinct commits that just happen to land within the proximity
307
+ // window. Against an existing hook-observed event, fall back to
308
+ // timestamp proximity: a prior session's hook-observed `commit` event,
309
+ // replayed via replayTimeline() before this method ever runs, has no
310
+ // hash in its command text at all, so a hash match would never catch
311
+ // it and every restart would double-count that commit.
312
+ const isDuplicate = this.events.some((e) => {
313
+ if (e.type !== 'commit')
314
+ return false;
315
+ const existingHash = e.command ? HYDRATED_COMMIT_HASH_RE.exec(e.command)?.[1] : undefined;
316
+ if (existingHash !== undefined) {
317
+ return existingHash === commit.hash;
318
+ }
319
+ return Math.abs(e.timestamp - commit.timestamp) < COMMIT_DEDUP_WINDOW_MS;
320
+ });
214
321
  if (!isDuplicate) {
215
322
  this.events.push(event);
216
323
  this.commitTimestamps.push(commit.timestamp);
217
324
  // Don't increment commitsSinceLastSync for historical commits — this counter
218
325
  // tracks real-time session activity, not replayed history.
219
326
  }
327
+ // Advance the watermark even for a commit that was already tracked —
328
+ // either way, `git log` has now vouched for everything up to here, so
329
+ // recordToolCall() must not add it again when the hook-observed event
330
+ // for it (still queued in the same drain batch) gets processed.
331
+ if (commit.timestamp > this.hydratedThroughMs) {
332
+ this.hydratedThroughMs = commit.timestamp;
333
+ }
220
334
  }
221
335
  }
222
- replayTimeline(entries) {
336
+ /**
337
+ * @param repoName The replayed session's own repo (from `SessionSummary.repoName`).
338
+ * When both this and the tracker's own `repoContext.repoName` (set via
339
+ * `hydrateRepoContext()`) are known and they don't match, the whole
340
+ * timeline is skipped — otherwise a session worked on in a
341
+ * different repo earlier today would have its commits/conflicts/force-
342
+ * pushes counted against whichever repo this process's header currently
343
+ * names. When either side is unknown (null/undefined — e.g. no git
344
+ * remote configured, or `repoContext` not hydrated yet), the timeline is
345
+ * replayed unfiltered rather than risk dropping legitimate same-repo
346
+ * history.
347
+ */
348
+ replayTimeline(entries, repoName) {
349
+ if (repoName != null &&
350
+ this.repoContext.repoName != null &&
351
+ repoName !== this.repoContext.repoName) {
352
+ return;
353
+ }
223
354
  for (const entry of entries) {
224
355
  const syntheticRecord = {
225
356
  id: `replay-${entry.timestamp}`,
@@ -250,8 +381,27 @@ export class GitEfficiencyTracker {
250
381
  const pushCount = this.events.filter((e) => e.type === 'push' || e.type === 'force_push' || e.type === 'force_push_lease').length;
251
382
  const commitCount = this.events.filter((e) => e.type === 'commit').length;
252
383
  const branchOperations = this.events.filter((e) => e.type === 'branch').length;
253
- const resolved = this.conflictRecords.filter((c) => c.resolution === 'resolved');
254
- const conflictResolutionRate = this.conflictRecords.length > 0 ? resolved.length / this.conflictRecords.length : null;
384
+ // A conflict that's currently open (mid-merge, not yet aborted or
385
+ // resolved) is counted in the mergeConflicts/rebaseConflicts KPI above,
386
+ // but this.conflictRecords only gains an entry once it's aborted or
387
+ // resolved — so it would otherwise never enter the resolution-rate
388
+ // denominator, letting a session with one resolved and one still-open
389
+ // conflict show a "perfect" 100% rate. Synthesizing (not persisting) a
390
+ // 'pending' record per still-queued conflict keeps the denominator
391
+ // honest without ever double-counting once that conflict does resolve —
392
+ // at that point it leaves the queue and gets a real entry instead.
393
+ const allConflictRecords = [
394
+ ...this.conflictRecords,
395
+ ...this.pendingConflicts.map((p) => ({
396
+ timestamp: p.timestamp,
397
+ resolution: 'pending',
398
+ resolutionTimeMs: null,
399
+ command: p.command,
400
+ files: p.files,
401
+ })),
402
+ ];
403
+ const resolved = allConflictRecords.filter((c) => c.resolution === 'resolved');
404
+ const conflictResolutionRate = allConflictRecords.length > 0 ? resolved.length / allConflictRecords.length : null;
255
405
  const resolutionTimes = resolved
256
406
  .filter((c) => c.resolutionTimeMs !== null)
257
407
  .map((c) => c.resolutionTimeMs);
@@ -309,10 +459,21 @@ export class GitEfficiencyTracker {
309
459
  conflictResolutionRate,
310
460
  avgConflictResolutionMs,
311
461
  staleBranchPulls,
312
- gitCommandTimeline: this.events.slice(-50),
313
- conflictHistory: this.conflictRecords,
314
- suggestions,
315
- bestPractices,
462
+ // this.events/this.conflictRecords are append-only in processing
463
+ // order, not timestamp order — parallel tool calls within a session,
464
+ // or multi-session buffer draining, can push an earlier-timestamped
465
+ // event after a later one. Sort by timestamp ascending (oldest-first)
466
+ // before exposing, so gitCommandTimeline's consumer
467
+ // (GitEfficiency.tsx's `[...timeline].reverse().slice(0, 30)`) picks
468
+ // the true newest 30, not whichever 30 happened to be pushed last.
469
+ gitCommandTimeline: [...this.events].sort((a, b) => a.timestamp - b.timestamp).slice(-50),
470
+ conflictHistory: [...allConflictRecords].sort((a, b) => a.timestamp - b.timestamp),
471
+ // Both arrays render in whatever order the checks above happen to
472
+ // .push() in — fixed source-code order, not severity order — so a
473
+ // critical-severity item could render below a milder one. Sort by
474
+ // severity (most severe first) before exposing.
475
+ suggestions: [...suggestions].sort((a, b) => SUGGESTION_SEVERITY_RANK[a.severity] - SUGGESTION_SEVERITY_RANK[b.severity]),
476
+ bestPractices: [...bestPractices].sort((a, b) => BEST_PRACTICE_STATUS_RANK[a.status] - BEST_PRACTICE_STATUS_RANK[b.status]),
316
477
  preventionScore,
317
478
  efficiencyScore,
318
479
  riskIndicators,
@@ -325,9 +486,7 @@ export class GitEfficiencyTracker {
325
486
  reset(_sessionId) {
326
487
  this.events = [];
327
488
  this.conflictRecords = [];
328
- this.pendingConflictTimestamp = null;
329
- this.pendingConflictCommand = '';
330
- this.pendingConflictFiles = [];
489
+ this.pendingConflicts = [];
331
490
  this.lastSyncTimestamp = null;
332
491
  this.pullsSinceLastConflict = 0;
333
492
  this.consecutiveFailedPushes = 0;
@@ -343,9 +502,13 @@ export class GitEfficiencyTracker {
343
502
  this.editedFiles.clear();
344
503
  this.hasUsedWorktree = false;
345
504
  this.hasUsedForceWithLease = false;
505
+ this.hasUsedBareForcePush = false;
506
+ this.bareForcePushCount = 0;
507
+ this.hasForcePushedToDefaultBranch = false;
346
508
  this.totalToolCalls = 0;
347
509
  this.sessionStartTimestamp = null;
348
510
  this.commitTimestamps = [];
511
+ this.hydratedThroughMs = 0;
349
512
  this.worktreeCommands = 0;
350
513
  this.oursCount = 0;
351
514
  this.theirsCount = 0;
@@ -357,7 +520,6 @@ export class GitEfficiencyTracker {
357
520
  this.commitsBehindMain = null;
358
521
  this.quickConflictResolutions = 0;
359
522
  this.prEvents = [];
360
- this.firstCommitTimestamp = null;
361
523
  this.repoContext = { repoName: null, branch: null, remoteName: null, defaultBranch: null };
362
524
  }
363
525
  // -------------------------------------------------------------------------
@@ -425,18 +587,26 @@ export class GitEfficiencyTracker {
425
587
  return { ...base, type: 'other_git' };
426
588
  }
427
589
  processEvent(event, command, record) {
428
- // Track conflict resolution strategies regardless of event type
429
- if (GIT_CHECKOUT_OURS_RE.test(command))
430
- this.oursCount++;
431
- if (GIT_CHECKOUT_THEIRS_RE.test(command))
432
- this.theirsCount++;
433
- if (GIT_CHERRY_PICK_RE.test(command))
434
- this.cherryPickCount++;
590
+ // Attribute ours/theirs/cherry-pick resolution strategy to the oldest
591
+ // still-open conflict, not to every matching command — a multi-file
592
+ // conflict resolved with one `--ours` per file must count once toward
593
+ // that conflict, not once per file, and a strategy command run with no
594
+ // conflict pending isn't resolving anything. `--abort` also matches the
595
+ // bare cherry-pick pattern, so it's excluded explicitly — aborting isn't
596
+ // a resolution strategy.
597
+ const oldestPending = this.pendingConflicts[0];
598
+ if (oldestPending) {
599
+ if (GIT_CHECKOUT_OURS_RE.test(command))
600
+ oldestPending.usedOurs = true;
601
+ if (GIT_CHECKOUT_THEIRS_RE.test(command))
602
+ oldestPending.usedTheirs = true;
603
+ if (GIT_CHERRY_PICK_RE.test(command) && !CHERRY_PICK_ABORT_RE.test(command)) {
604
+ oldestPending.usedCherryPick = true;
605
+ }
606
+ }
435
607
  switch (event.type) {
436
608
  case 'merge_conflict':
437
609
  case 'rebase_conflict': {
438
- this.pendingConflictTimestamp = event.timestamp;
439
- this.pendingConflictCommand = command;
440
610
  const output = record.error ?? '';
441
611
  const files = [];
442
612
  let match;
@@ -445,56 +615,64 @@ export class GitEfficiencyTracker {
445
615
  files.push(match[1].trim());
446
616
  this.conflictedFiles.add(match[1].trim());
447
617
  }
448
- this.pendingConflictFiles = files;
618
+ this.pendingConflicts.push({
619
+ timestamp: event.timestamp,
620
+ command,
621
+ files,
622
+ usedOurs: false,
623
+ usedTheirs: false,
624
+ usedCherryPick: false,
625
+ });
449
626
  this.pullsSinceLastConflict = 0;
450
627
  break;
451
628
  }
452
629
  case 'merge_abort':
453
630
  case 'rebase_abort':
454
- case 'cherry_pick_abort':
455
- if (this.pendingConflictTimestamp !== null) {
631
+ case 'cherry_pick_abort': {
632
+ const pending = this.pendingConflicts.shift();
633
+ if (pending) {
456
634
  this.conflictRecords.push({
457
- timestamp: this.pendingConflictTimestamp,
635
+ timestamp: pending.timestamp,
458
636
  resolution: 'aborted',
459
- resolutionTimeMs: event.timestamp - this.pendingConflictTimestamp,
460
- command: this.pendingConflictCommand,
461
- files: this.pendingConflictFiles,
637
+ resolutionTimeMs: event.timestamp - pending.timestamp,
638
+ command: pending.command,
639
+ files: pending.files,
462
640
  });
463
- this.pendingConflictTimestamp = null;
464
- this.pendingConflictCommand = '';
465
- this.pendingConflictFiles = [];
466
641
  }
467
642
  break;
643
+ }
468
644
  case 'commit': {
469
645
  // git commit --amend fixes a prior commit, not a merge conflict.
470
- // Clear the pending conflict on amend so the *next* normal commit
471
- // doesn't see a stale pendingConflictTimestamp (potentially hours old).
646
+ // Drop the oldest pending conflict on amend (without recording a
647
+ // resolution) so a later, unrelated commit doesn't retroactively
648
+ // "resolve" it.
472
649
  if (command.includes('--amend')) {
473
- this.pendingConflictTimestamp = null;
474
- this.pendingConflictCommand = '';
475
- this.pendingConflictFiles = [];
650
+ this.pendingConflicts.shift();
476
651
  }
477
- if (this.pendingConflictTimestamp !== null && !command.includes('--amend')) {
478
- const resolutionMs = event.timestamp - this.pendingConflictTimestamp;
479
- this.conflictRecords.push({
480
- timestamp: this.pendingConflictTimestamp,
481
- resolution: 'resolved',
482
- resolutionTimeMs: resolutionMs,
483
- command: this.pendingConflictCommand,
484
- files: this.pendingConflictFiles,
485
- });
486
- // Under 30s resolution with multiple conflicted files is suspiciously fast
487
- if (resolutionMs < 30_000 && this.pendingConflictFiles.length > 1) {
488
- this.quickConflictResolutions++;
652
+ else {
653
+ const pending = this.pendingConflicts.shift();
654
+ if (pending) {
655
+ const resolutionMs = event.timestamp - pending.timestamp;
656
+ this.conflictRecords.push({
657
+ timestamp: pending.timestamp,
658
+ resolution: 'resolved',
659
+ resolutionTimeMs: resolutionMs,
660
+ command: pending.command,
661
+ files: pending.files,
662
+ });
663
+ if (pending.usedOurs)
664
+ this.oursCount++;
665
+ if (pending.usedTheirs)
666
+ this.theirsCount++;
667
+ if (pending.usedCherryPick)
668
+ this.cherryPickCount++;
669
+ // Under 30s resolution with multiple conflicted files is suspiciously fast
670
+ if (resolutionMs < 30_000 && pending.files.length > 1) {
671
+ this.quickConflictResolutions++;
672
+ }
489
673
  }
490
- this.pendingConflictTimestamp = null;
491
- this.pendingConflictCommand = '';
492
- this.pendingConflictFiles = [];
493
674
  }
494
675
  this.commitTimestamps.push(event.timestamp);
495
- if (this.firstCommitTimestamp === null) {
496
- this.firstCommitTimestamp = event.timestamp;
497
- }
498
676
  this.commitsSinceLastSync++;
499
677
  this.statusChecksSinceLastAction = 0;
500
678
  break;
@@ -535,6 +713,13 @@ export class GitEfficiencyTracker {
535
713
  this.statusChecksSinceLastAction = 0;
536
714
  break;
537
715
  case 'force_push':
716
+ this.hasUsedBareForcePush = true;
717
+ this.bareForcePushCount++;
718
+ if (this.repoContext.branch !== null &&
719
+ this.repoContext.defaultBranch !== null &&
720
+ this.repoContext.branch === this.repoContext.defaultBranch) {
721
+ this.hasForcePushedToDefaultBranch = true;
722
+ }
538
723
  if (this.lastPushRejectedTimestamp !== null &&
539
724
  event.timestamp - this.lastPushRejectedTimestamp < 300_000) {
540
725
  this.forceAfterReject++;
@@ -566,8 +751,14 @@ export class GitEfficiencyTracker {
566
751
  this.statusChecksSinceLastAction = 0;
567
752
  break;
568
753
  case 'worktree':
569
- this.hasUsedWorktree = true;
570
- this.worktreeCommands++;
754
+ // Both the "worktree ops" count and the usesWorktrees/use_worktrees
755
+ // signal are meant to reflect real worktree usage, not read-only
756
+ // inspection — `list`/`prune`/`lock`/etc. shouldn't count as evidence
757
+ // that worktrees were used to isolate parallel work.
758
+ if (GIT_WORKTREE_ADD_REMOVE_RE.test(command)) {
759
+ this.worktreeCommands++;
760
+ this.hasUsedWorktree = true;
761
+ }
571
762
  break;
572
763
  case 'status':
573
764
  this.statusChecksSinceLastAction++;
@@ -577,18 +768,24 @@ export class GitEfficiencyTracker {
577
768
  break;
578
769
  }
579
770
  }
580
- // A pull immediately followed by a conflict means the branch had already
581
- // diverged enough that even the act of syncing produced a conflict — a
582
- // stronger signal of a stale branch than a conflict from, say, a later
583
- // merge or rebase attempted well after the pull.
771
+ // A pull that diverged enough to conflict is the strongest signal of a
772
+ // stale branch checked directly off the conflicting event's own command
773
+ // (a `git pull` whose own output contains a conflict indicator classifies
774
+ // as merge_conflict/rebase_conflict, never as 'pull', so this can't rely
775
+ // on the event's `type`). A pull immediately followed by a *separate*
776
+ // command that then conflicts is a weaker but still real signal, kept as a
777
+ // fallback for events that don't match the direct case.
584
778
  countStaleBranchPulls() {
585
779
  let staleCount = 0;
586
- for (let i = 0; i < this.events.length - 1; i++) {
587
- if (this.events[i].type === 'pull') {
588
- const next = this.events[i + 1];
589
- if (next.type === 'merge_conflict' || next.type === 'rebase_conflict') {
590
- staleCount++;
591
- }
780
+ for (let i = 0; i < this.events.length; i++) {
781
+ const event = this.events[i];
782
+ if (event.type !== 'merge_conflict' && event.type !== 'rebase_conflict')
783
+ continue;
784
+ if (GIT_PULL_RE.test(event.command ?? '')) {
785
+ staleCount++;
786
+ }
787
+ else if (i > 0 && this.events[i - 1].type === 'pull') {
788
+ staleCount++;
592
789
  }
593
790
  }
594
791
  return staleCount;
@@ -739,7 +936,12 @@ export class GitEfficiencyTracker {
739
936
  detail: 'No worktree usage detected. Consider worktrees if you run parallel sessions.',
740
937
  });
741
938
  }
742
- // 5. Use --force-with-lease instead of --force
939
+ // 5. Use --force-with-lease instead of --force. Gated on
940
+ // hasUsedBareForcePush rather than forcePushes/usesForceWithLease alone —
941
+ // those two count safe and unsafe force-pushes together, so checking
942
+ // usesForceWithLease alone would let one safe `--force-with-lease` mask a
943
+ // dangerous bare `--force` in the same session with a fully-passing
944
+ // status.
743
945
  if (stats.forcePushes === 0) {
744
946
  practices.push({
745
947
  id: 'force_with_lease',
@@ -748,15 +950,15 @@ export class GitEfficiencyTracker {
748
950
  detail: 'No force pushes yet.',
749
951
  });
750
952
  }
751
- else if (risk.usesForceWithLease) {
953
+ else if (this.hasUsedBareForcePush && risk.usesForceWithLease) {
752
954
  practices.push({
753
955
  id: 'force_with_lease',
754
956
  label: 'Use --force-with-lease',
755
- status: 'pass',
756
- detail: "Goodusing --force-with-lease which refuses to overwrite remote commits you haven't seen.",
957
+ status: 'warn',
958
+ detail: 'Mixed usage this session some force pushes used --force-with-lease, but at least one bare --force (unsafe) push also occurred. Always use --force-with-lease; it refuses to push if someone else has pushed to the branch since your last fetch.',
757
959
  });
758
960
  }
759
- else {
961
+ else if (this.hasUsedBareForcePush) {
760
962
  practices.push({
761
963
  id: 'force_with_lease',
762
964
  label: 'Use --force-with-lease',
@@ -764,24 +966,15 @@ export class GitEfficiencyTracker {
764
966
  detail: 'Using bare --force instead of --force-with-lease. The --force-with-lease flag is a safety net: it refuses to push if someone else has pushed to the branch since your last fetch. Always prefer it.',
765
967
  });
766
968
  }
767
- // 6. Don't force-push after a rejection without investigating
768
- if (risk.forceAfterReject > 0) {
769
- practices.push({
770
- id: 'no_force_after_reject',
771
- label: "Don't force-push after rejection",
772
- status: 'fail',
773
- detail: `Push was rejected ${risk.pushRejections} time(s) and then force-pushed ${risk.forceAfterReject} time(s). When a push is rejected, pull + rebase first to incorporate upstream changes. Force pushing after a rejection overwrites others' work.`,
774
- });
775
- }
776
- else if (risk.pushRejections > 0) {
969
+ else {
777
970
  practices.push({
778
- id: 'no_force_after_reject',
779
- label: "Don't force-push after rejection",
971
+ id: 'force_with_lease',
972
+ label: 'Use --force-with-lease',
780
973
  status: 'pass',
781
- detail: 'Push was rejected but correctly handled without force pushing.',
974
+ detail: "Good using --force-with-lease which refuses to overwrite remote commits you haven't seen.",
782
975
  });
783
976
  }
784
- // 7. Keep PRs small (proxy: many commits without pushing)
977
+ // 6. Keep PRs small (proxy: many commits without pushing)
785
978
  if (risk.commitsSinceLastSync > 15) {
786
979
  practices.push({
787
980
  id: 'small_increments',
@@ -798,7 +991,7 @@ export class GitEfficiencyTracker {
798
991
  detail: 'Good — committing and syncing in small batches.',
799
992
  });
800
993
  }
801
- // 8. Avoid editing hot files
994
+ // 7. Avoid editing hot files
802
995
  if (risk.hotFiles.length > 0) {
803
996
  practices.push({
804
997
  id: 'avoid_hot_files',
@@ -807,7 +1000,7 @@ export class GitEfficiencyTracker {
807
1000
  detail: `Editing files that previously conflicted: ${risk.hotFiles.slice(0, 3).join(', ')}${risk.hotFiles.length > 3 ? ` (+${risk.hotFiles.length - 3} more)` : ''}. These are "hot" files with active upstream changes — edits here are likely to conflict again. Consider coordinating or waiting for upstream to stabilize.`,
808
1001
  });
809
1002
  }
810
- // 9. Build/test before pushing
1003
+ // 8. Build/test before pushing
811
1004
  if (this.buildBeforePush === null && this.lastPushTimestamp === null) {
812
1005
  practices.push({
813
1006
  id: 'verify_before_push',
@@ -852,22 +1045,14 @@ export class GitEfficiencyTracker {
852
1045
  generateSuggestions(stats) {
853
1046
  const suggestions = [];
854
1047
  // --- Proactive prevention suggestions (fire BEFORE conflicts happen) ---
855
- if (stats.riskIndicators.syncedBeforeEditing === false) {
856
- suggestions.push({
857
- severity: 'warning',
858
- category: 'no_initial_sync',
859
- message: 'Started editing without syncing first. Run `git fetch && git rebase origin/main` (or your target branch) at the start of every session. This single habit prevents the majority of AI-assisted coding conflicts.',
860
- evidence: 'First file edit occurred before any git pull/fetch',
861
- });
862
- }
863
- if (stats.riskIndicators.commitsSinceLastSync > 8) {
864
- suggestions.push({
865
- severity: 'warning',
866
- category: 'drift_risk',
867
- message: `${stats.riskIndicators.commitsSinceLastSync} commits without syncing. You're accumulating drift that will compound into painful conflicts. Run \`git fetch && git rebase origin/main\` now — smaller, frequent rebases are far easier than one large one later.`,
868
- evidence: `${stats.riskIndicators.commitsSinceLastSync} commits since last pull/fetch/rebase`,
869
- });
870
- }
1048
+ //
1049
+ // syncedBeforeEditing, commitsSinceLastSync (drift), and hotFiles are
1050
+ // ongoing-state conditions already surfaced as their own Best Practice
1051
+ // checks (sync_before_edit, frequent_sync, avoid_hot_files) — duplicating
1052
+ // them here as one-off suggestions rendered the same fact twice, with a
1053
+ // conflicting severity in the hot-files case. Best Practices is their
1054
+ // canonical home; only force-after-reject (a one-time, reactive event
1055
+ // with actionable remediation) is kept as a suggestion.
871
1056
  if (stats.riskIndicators.forceAfterReject > 0) {
872
1057
  suggestions.push({
873
1058
  severity: 'critical',
@@ -876,14 +1061,6 @@ export class GitEfficiencyTracker {
876
1061
  evidence: `${stats.riskIndicators.forceAfterReject} force push(es) within 5 min of a rejection`,
877
1062
  });
878
1063
  }
879
- if (stats.riskIndicators.hotFiles.length > 0) {
880
- suggestions.push({
881
- severity: 'info',
882
- category: 'hot_files',
883
- message: `You're editing files that previously conflicted (${stats.riskIndicators.hotFiles.slice(0, 3).join(', ')}). These likely have active upstream work. Consider: (1) rebasing immediately to get latest state, (2) coordinating with whoever else is touching these files, or (3) deferring changes until upstream settles.`,
884
- evidence: `${stats.riskIndicators.hotFiles.length} previously-conflicted file(s) re-edited`,
885
- });
886
- }
887
1064
  // --- Reactive suggestions (fire after problems occur) ---
888
1065
  if (stats.mergeConflicts + stats.rebaseConflicts >= 3) {
889
1066
  suggestions.push({
@@ -909,20 +1086,40 @@ export class GitEfficiencyTracker {
909
1086
  evidence: `${stats.abortedOperations} aborted operations`,
910
1087
  });
911
1088
  }
912
- if (stats.forcePushes >= 2) {
1089
+ // Severity is gated on hasUsedBareForcePush/bareForcePushCount — the same
1090
+ // shared signal the force_with_lease best-practice check uses — rather
1091
+ // than the raw forcePushes count, which sums bare AND lease-protected
1092
+ // pushes together. Gating (and scaling) on the bare-only count keeps this
1093
+ // suggestion's ranking consistent with the best-practice check: adding a
1094
+ // safe --force-with-lease push on top of an existing bare push must never
1095
+ // raise severity, since only the bare push is actually risky.
1096
+ //
1097
+ // A bare push to the shared default branch is escalated to 'critical'
1098
+ // outright, regardless of count — it can clobber other collaborators'
1099
+ // work, unlike an identical push to a personal feature branch (which
1100
+ // still scales by count, as above).
1101
+ if (this.hasUsedBareForcePush) {
913
1102
  suggestions.push({
914
- severity: 'critical',
1103
+ severity: this.hasForcePushedToDefaultBranch
1104
+ ? 'critical'
1105
+ : this.bareForcePushCount >= 2
1106
+ ? 'critical'
1107
+ : 'warning',
915
1108
  category: 'force_push',
916
- message: 'Multiple force pushes this session. Always use --force-with-lease as a safety net. If you need to rewrite history, coordinate with collaborators first and ensure your local refs are up to date with `git fetch` before force pushing.',
917
- evidence: `${stats.forcePushes} force pushes this session`,
1109
+ message: this.hasForcePushedToDefaultBranch
1110
+ ? `Bare --force push used on the shared default branch (${this.repoContext.defaultBranch ?? 'default branch'}) — this can overwrite history other collaborators are building on. Always use --force-with-lease, and avoid force-pushing the default branch entirely if possible.`
1111
+ : 'Bare --force push used. Always use --force-with-lease instead — it refuses to push if someone else has pushed to the branch since your last fetch. If you need to rewrite history, coordinate with collaborators first and ensure your local refs are up to date with `git fetch` before force pushing.',
1112
+ evidence: this.hasForcePushedToDefaultBranch
1113
+ ? `${this.bareForcePushCount} bare --force push(es) this session, including at least one on the default branch`
1114
+ : `${this.bareForcePushCount} bare --force push(es) this session`,
918
1115
  });
919
1116
  }
920
- else if (stats.forcePushes === 1) {
1117
+ else if (stats.riskIndicators.usesForceWithLease && stats.forcePushes >= 2) {
921
1118
  suggestions.push({
922
1119
  severity: 'info',
923
1120
  category: 'force_push',
924
- message: "Force push used. Prefer --force-with-lease for safer force pushes it refuses to overwrite commits you haven't seen locally.",
925
- evidence: '1 force push',
1121
+ message: 'Multiple force pushes this session, all using --force-with-lease the safe pattern. Repeated history rewrites can still be worth a second look if they indicate a workflow issue upstream.',
1122
+ evidence: `${stats.forcePushes} lease-protected force pushes this session`,
926
1123
  });
927
1124
  }
928
1125
  if (stats.resetHards >= 2) {
@@ -1076,15 +1273,16 @@ export class GitEfficiencyTracker {
1076
1273
  };
1077
1274
  }
1078
1275
  computeConflictStrategy() {
1079
- const manualMergeCount = this.conflictRecords.filter((c) => c.resolution === 'resolved').length -
1276
+ const manualMergeCount = Math.max(0, this.conflictRecords.filter((c) => c.resolution === 'resolved').length -
1080
1277
  this.oursCount -
1081
- this.theirsCount;
1278
+ this.theirsCount -
1279
+ this.cherryPickCount);
1082
1280
  return {
1083
1281
  oursCount: this.oursCount,
1084
1282
  theirsCount: this.theirsCount,
1085
- manualMergeCount: Math.max(0, manualMergeCount),
1283
+ manualMergeCount,
1086
1284
  cherryPickCount: this.cherryPickCount,
1087
- totalResolutions: this.oursCount + this.theirsCount + Math.max(0, manualMergeCount),
1285
+ totalResolutions: this.oursCount + this.theirsCount + this.cherryPickCount + manualMergeCount,
1088
1286
  };
1089
1287
  }
1090
1288
  }