@boyingliu01/xp-gate 0.32.1 → 0.32.3

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 (69) hide show
  1. package/adapters/gate-3.sh +483 -28
  2. package/adapters/gate-4.sh +20 -0
  3. package/adapters/gate-7.sh +4 -0
  4. package/adapters/powershell.sh +33 -7
  5. package/bin/xp-gate.js +48 -2
  6. package/gate-3.sh +37 -2
  7. package/gate-4.sh +8 -0
  8. package/hooks/gate-3.sh +37 -2
  9. package/hooks/gate-4.sh +8 -0
  10. package/hooks/post-commit +82 -0
  11. package/hooks/pre-commit +19 -0
  12. package/hooks/pre-push +21 -2
  13. package/lib/__tests__/doctor-audit-ledger.test.js +61 -0
  14. package/lib/__tests__/gate-audit.test.ts +144 -0
  15. package/lib/__tests__/shared-utils.test.js +22 -0
  16. package/lib/doctor.js +59 -1
  17. package/lib/gate-audit.ts +215 -7
  18. package/lib/mattpocock-license-verdict.json +1 -1
  19. package/lib/shared-utils.js +4 -1
  20. package/mock-policy/AGENTS.md +3 -3
  21. package/mutation/AGENTS.md +3 -3
  22. package/package.json +1 -1
  23. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  24. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  25. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  26. package/plugins/claude-code/skills/sprint-flow/references/phase-3-build.md +21 -0
  27. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  28. package/plugins/dsh/README.md +51 -2
  29. package/plugins/dsh/client/client.js +760 -0
  30. package/plugins/dsh/lib/client.js +760 -0
  31. package/plugins/dsh/lib/delphi-settings.d.ts +52 -0
  32. package/plugins/dsh/lib/delphi-settings.d.ts.map +1 -0
  33. package/plugins/dsh/lib/delphi-settings.js +76 -0
  34. package/plugins/dsh/lib/hooks.d.ts +14 -1
  35. package/plugins/dsh/lib/hooks.d.ts.map +1 -1
  36. package/plugins/dsh/lib/hooks.js +44 -5
  37. package/plugins/dsh/lib/index.d.ts +63 -0
  38. package/plugins/dsh/lib/index.d.ts.map +1 -1
  39. package/plugins/dsh/lib/index.js +126 -21
  40. package/plugins/dsh/package.json +13 -3
  41. package/plugins/dsh/scripts/copy-client.cjs +37 -0
  42. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  43. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  44. package/plugins/dsh/skills/sprint-flow/references/phase-3-build.md +21 -0
  45. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  46. package/plugins/dsh/src/client.test.ts +231 -0
  47. package/plugins/dsh/src/contract.test.ts +25 -1
  48. package/plugins/dsh/src/delphi-settings.test.ts +101 -0
  49. package/plugins/dsh/src/delphi-settings.ts +111 -0
  50. package/plugins/dsh/src/hooks.test.ts +48 -0
  51. package/plugins/dsh/src/hooks.ts +45 -5
  52. package/plugins/dsh/src/index.test.ts +3 -2
  53. package/plugins/dsh/src/index.ts +147 -25
  54. package/plugins/opencode/package.json +1 -1
  55. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  56. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  57. package/plugins/opencode/skills/sprint-flow/references/phase-3-build.md +21 -0
  58. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  59. package/plugins/qoder/plugin.json +1 -1
  60. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  61. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  62. package/plugins/qoder/skills/sprint-flow/references/phase-3-build.md +21 -0
  63. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  64. package/principles/AGENTS.md +3 -3
  65. package/scripts/sync-package-content.js +1 -1
  66. package/skills/delphi-review/AGENTS.md +3 -3
  67. package/skills/sprint-flow/AGENTS.md +3 -3
  68. package/skills/sprint-flow/references/phase-3-build.md +21 -0
  69. package/skills/test-specification-alignment/AGENTS.md +3 -3
@@ -13,6 +13,12 @@ import {
13
13
  rotateIfNeeded,
14
14
  parseCliOptions,
15
15
  getCommitHash,
16
+ resolveCommitHash,
17
+ stageAuditEntry,
18
+ flushPendingAudit,
19
+ sanitizeDurationMs,
20
+ computeAnomalies,
21
+ AUDIT_ANOMALY_SINCE,
16
22
  type GateAuditEntry,
17
23
  } from '../gate-audit';
18
24
 
@@ -293,4 +299,142 @@ describe('gate-audit', () => {
293
299
  expect(existsSync(`${logPath}.4`)).toBe(false);
294
300
  });
295
301
  });
302
+ describe('pre-commit staging + post-commit flush (commit attribution)', () => {
303
+ const NL = String.fromCharCode(10);
304
+ const repo = () => {
305
+ const r = join(TEST_DIR, 'attr');
306
+ mkdirSync(r, { recursive: true });
307
+ return r;
308
+ };
309
+
310
+ it('@covers AUDIT-001-07 resolveCommitHash prefers an explicit --commit-hash', () => {
311
+ expect(resolveCommitHash({ 'commit-hash': 'deadbeef' }, () => 'PARENT')).toBe('deadbeef');
312
+ });
313
+
314
+ it('@covers AUDIT-001-07 resolveCommitHash falls back to HEAD when no explicit hash', () => {
315
+ expect(resolveCommitHash({}, () => 'HEADHASH')).toBe('HEADHASH');
316
+ });
317
+
318
+ it('@covers AUDIT-001-08 flushPendingAudit stamps staged entries with the real commit', () => {
319
+ const r = repo();
320
+ stageAuditEntry(makeEntry({ repo_path: r, gate_id: 'gate-1', commit_hash: '' }), r);
321
+ stageAuditEntry(makeEntry({ repo_path: r, gate_id: 'gate-2', commit_hash: '' }), r);
322
+ const logPath = join(r, '.xp-gate', 'audit.jsonl');
323
+ expect(existsSync(logPath)).toBe(false);
324
+
325
+ const n = flushPendingAudit(r, 'REAL_COMMIT_SHA');
326
+ expect(n).toBe(2);
327
+
328
+ const entries = readFileSync(logPath, 'utf8').trim().split(NL).map((l) => JSON.parse(l));
329
+ expect(entries.map((e) => e.gate_id)).toEqual(['gate-1', 'gate-2']);
330
+ expect(entries.every((e) => e.commit_hash === 'REAL_COMMIT_SHA')).toBe(true);
331
+ expect(existsSync(join(r, '.xp-gate', 'audit.pending.jsonl'))).toBe(false);
332
+ });
333
+
334
+ it('@covers AUDIT-001-08 flush is idempotent and a no-op with nothing staged', () => {
335
+ const r = repo();
336
+ expect(flushPendingAudit(r, 'X')).toBe(0);
337
+ expect(existsSync(join(r, '.xp-gate', 'audit.jsonl'))).toBe(false);
338
+ });
339
+
340
+ it('@covers AUDIT-001-08 a blank commit hash never invents one', () => {
341
+ const r = repo();
342
+ stageAuditEntry(makeEntry({ repo_path: r, gate_id: 'gate-9', commit_hash: '' }), r);
343
+ expect(flushPendingAudit(r, '')).toBe(1);
344
+ const e = JSON.parse(readFileSync(join(r, '.xp-gate', 'audit.jsonl'), 'utf8').trim());
345
+ expect(e.commit_hash).toBe('');
346
+ });
347
+
348
+ it('@covers AUDIT-001-08 a torn pending line is dropped, not fatal', () => {
349
+ const r = repo();
350
+ mkdirSync(join(r, '.xp-gate'), { recursive: true });
351
+ const pp = join(r, '.xp-gate', 'audit.pending.jsonl');
352
+ const good = JSON.stringify(makeEntry({ repo_path: r, gate_id: 'gate-3', commit_hash: '' }));
353
+ writeFileSync(pp, good + NL + '{ broken' + NL, 'utf8');
354
+ expect(flushPendingAudit(r, 'SHA')).toBe(1);
355
+ const entries = readFileSync(join(r, '.xp-gate', 'audit.jsonl'), 'utf8').trim().split(NL);
356
+ expect(entries.length).toBe(1);
357
+ });
358
+ });
359
+
360
+ describe('sanitizeDurationMs (writer-side epoch guard, REQ-B backstop)', () => {
361
+ it('@covers AUDIT-001-09 keeps a plausible duration untouched', () => {
362
+ expect(sanitizeDurationMs(1234)).toEqual({ duration_ms: 1234 });
363
+ expect(sanitizeDurationMs(0)).toEqual({ duration_ms: 0 });
364
+ });
365
+
366
+ it('@covers AUDIT-001-09 null stays null with no reason', () => {
367
+ expect(sanitizeDurationMs(null)).toEqual({ duration_ms: null });
368
+ });
369
+
370
+ it('@covers AUDIT-001-09 an epoch-sized duration is rejected, not stored as a number', () => {
371
+ // end - 0 == Date.now() ~= 1.7e12 — the classic poisoned-start bug.
372
+ const r = sanitizeDurationMs(1750000000000);
373
+ expect(r.duration_ms).toBeNull();
374
+ expect(r.duration_reason).toBe('poisoned_start_ms');
375
+ });
376
+
377
+ it('@covers AUDIT-001-09 negative or NaN durations are rejected', () => {
378
+ expect(sanitizeDurationMs(-5).duration_reason).toBe('poisoned_start_ms');
379
+ expect(sanitizeDurationMs(Number.NaN).duration_reason).toBe('poisoned_start_ms');
380
+ });
381
+ });
382
+
383
+ describe('computeAnomalies (CI ledger gate, P0-2)', () => {
384
+ const NL = String.fromCharCode(10);
385
+ const repo = () => {
386
+ const r = join(TEST_DIR, 'anom');
387
+ mkdirSync(r, { recursive: true });
388
+ return r;
389
+ };
390
+ const write = (r: string, rows: Partial<GateAuditEntry>[]) => {
391
+ mkdirSync(join(r, '.xp-gate'), { recursive: true });
392
+ const lines = rows.map((x) => JSON.stringify(makeEntry({ repo_path: r, ...x }))).join(NL);
393
+ writeFileSync(join(r, '.xp-gate', 'audit.jsonl'), lines + NL, 'utf8');
394
+ };
395
+
396
+ it('@covers AUDIT-001-10 a clean ledger has zero anomalies', () => {
397
+ const r = repo();
398
+ write(r, [{ gate_id: 'gate-1', commit_hash: 'abc', duration_ms: 100 }]);
399
+ const a = computeAnomalies(r);
400
+ expect(a.total).toBe(0);
401
+ expect(a.scanned).toBe(1);
402
+ });
403
+
404
+ it('@covers AUDIT-001-10 epoch duration + blank commit are both counted', () => {
405
+ const r = repo();
406
+ write(r, [
407
+ { gate_id: 'gate-1', commit_hash: 'abc', duration_ms: 1750000000000 },
408
+ { gate_id: 'gate-2', commit_hash: '', duration_ms: 100 },
409
+ { gate_id: 'gate-3', commit_hash: 'unknown', duration_ms: 100 },
410
+ { gate_id: 'gate-4', commit_hash: 'def', duration_ms: 50 },
411
+ ]);
412
+ const a = computeAnomalies(r);
413
+ expect(a.implausible_duration).toBe(1);
414
+ expect(a.missing_commit).toBe(2);
415
+ expect(a.total).toBe(3);
416
+ });
417
+
418
+ it('@covers AUDIT-001-10 null+reason is honest, not an anomaly', () => {
419
+ const r = repo();
420
+ write(r, [{ gate_id: 'gate-1', commit_hash: 'abc', duration_ms: null, duration_reason: 'poisoned_start_ms' }]);
421
+ const a = computeAnomalies(r);
422
+ expect(a.null_with_reason).toBe(1);
423
+ expect(a.total).toBe(0);
424
+ });
425
+
426
+ it('@covers AUDIT-001-10 since-scope ignores grandfathered anomalies', () => {
427
+ const r = repo();
428
+ write(r, [
429
+ { gate_id: 'gate-1', commit_hash: '', duration_ms: 1750000000000, timestamp: '2020-01-01T00:00:00.000Z' },
430
+ { gate_id: 'gate-2', commit_hash: 'abc', duration_ms: 100, timestamp: '2030-01-01T00:00:00.000Z' },
431
+ ]);
432
+ const all = computeAnomalies(r);
433
+ expect(all.total).toBe(2); // old row: 1 implausible + 1 missing commit
434
+ const scoped = computeAnomalies(r, AUDIT_ANOMALY_SINCE);
435
+ expect(scoped.total).toBe(0);
436
+ expect(scoped.scanned).toBe(1);
437
+ });
438
+ });
439
+
296
440
  });
@@ -87,4 +87,26 @@ describe('shared-utils', () => {
87
87
  expect(fs.existsSync(path.join(destDir, 'lib', 'now-ms.sh'))).toBe(true);
88
88
  });
89
89
  });
90
+ describe('copyHooks ships post-commit (audit flush + bypass detection)', () => {
91
+ it('@covers gate-script deployment: copyHooks installs post-commit, not just pre-commit/pre-push/post-merge', () => {
92
+ // Regression: post-commit was absent from both the ship list and copyHooks,
93
+ // so the audit-attribution flush and --no-verify bypass detection that live
94
+ // in post-commit never executed for any installed user.
95
+ const pkgRoot = path.join(tmpRoot, 'node_modules', '@boyingliu01', 'xp-gate');
96
+ const hooksSrc = path.join(pkgRoot, 'hooks');
97
+ fs.mkdirSync(hooksSrc, { recursive: true });
98
+ for (const h of ['pre-commit', 'pre-push', 'post-commit', 'post-merge']) {
99
+ fs.writeFileSync(path.join(hooksSrc, h), `# ${h}`);
100
+ }
101
+ const destDir = path.join(tmpRoot, 'dest-hooks');
102
+ fs.mkdirSync(destDir, { recursive: true });
103
+
104
+ const { copyHooks } = require('../shared-utils');
105
+ copyHooks(pkgRoot, destDir);
106
+
107
+ expect(fs.existsSync(path.join(destDir, 'post-commit'))).toBe(true);
108
+ expect(fs.existsSync(path.join(destDir, 'pre-commit'))).toBe(true);
109
+ });
110
+ });
111
+
90
112
  });
package/lib/doctor.js CHANGED
@@ -676,6 +676,59 @@ function readPair(repoFile, installedFile) {
676
676
  * @param {{repoRoot?: string, repoHooks?: string, installedAdapters?: string}} [ctx]
677
677
  * @returns {{checks: Array<{name: string, status: string, detail: string}>, issues: number}}
678
678
  */
679
+ /**
680
+ * Audit ledger health (P0): surface gate-telemetry anomalies so `doctor` warns
681
+ * before they silently poison per-commit analysis. Mirrors gate-audit.ts's
682
+ * computeAnomalies (kept in sync deliberately — doctor is CommonJS and cannot
683
+ * import the .ts module). Grandfathers everything before AUDIT_LEDGER_SINCE;
684
+ * only NEW implausible durations or unattributed commits count as issues.
685
+ */
686
+ const AUDIT_LEDGER_SINCE = '2026-10-30T00:00:00.000Z';
687
+ const AUDIT_MAX_PLAUSIBLE_MS = 6 * 60 * 60 * 1000;
688
+
689
+ function diagnoseAuditLedger(ctx = {}) {
690
+ const checks = [];
691
+ let issues = 0;
692
+ const repoRoot = ctx.repoRoot || process.cwd();
693
+ const logPath = path.join(repoRoot, '.xp-gate', 'audit.jsonl');
694
+ if (!fs.existsSync(logPath)) {
695
+ checks.push({ name: 'Audit ledger', status: 'SKIP', detail: 'no .xp-gate/audit.jsonl' });
696
+ return { checks, issues };
697
+ }
698
+ let entries;
699
+ try {
700
+ entries = fs.readFileSync(logPath, 'utf8').split(/\r?\n/).filter((l) => l.trim()).map((l) => JSON.parse(l));
701
+ } catch (err) {
702
+ checks.push({ name: 'Audit ledger', status: 'WARN', detail: `unreadable: ${err.message}` });
703
+ return { checks, issues };
704
+ }
705
+ let implausible = 0;
706
+ let missingCommit = 0;
707
+ let considered = 0;
708
+ for (const e of entries) {
709
+ if (typeof e.timestamp === 'string' && e.timestamp < AUDIT_LEDGER_SINCE) continue;
710
+ considered += 1;
711
+ if (typeof e.duration_ms === 'number'
712
+ && (!Number.isFinite(e.duration_ms) || e.duration_ms < 0 || e.duration_ms > AUDIT_MAX_PLAUSIBLE_MS)) {
713
+ implausible += 1;
714
+ }
715
+ if (!e.commit_hash || e.commit_hash === 'unknown') missingCommit += 1;
716
+ }
717
+ const total = implausible + missingCommit;
718
+ if (total > 0) {
719
+ checks.push({
720
+ name: 'Audit ledger',
721
+ status: 'WARN',
722
+ detail: `${total} anomal${total === 1 ? 'y' : 'ies'} since ${AUDIT_LEDGER_SINCE} (${implausible} implausible duration, ${missingCommit} unattributed) — run: xp-gate audit --check`,
723
+ });
724
+ // WARN, not FAIL: telemetry integrity is not a blocking gate, but it must
725
+ // be visible. Kept at 0 issues so it never inflates the FAIL count.
726
+ } else {
727
+ checks.push({ name: 'Audit ledger', status: 'PASS', detail: `${considered} entr${considered === 1 ? 'y' : 'ies'} clean since ${AUDIT_LEDGER_SINCE}` });
728
+ }
729
+ return { checks, issues };
730
+ }
731
+
679
732
  function diagnoseModuleDrift(ctx = {}) {
680
733
  const checks = [];
681
734
  let issues = 0;
@@ -1547,7 +1600,11 @@ async function diagnoseAsync(refreshEnv = false) {
1547
1600
  // hook files and live in a different directory (#495).
1548
1601
  const moduleDrift = diagnoseModuleDrift();
1549
1602
  checks.push(...moduleDrift.checks);
1550
- return drift.issues + moduleDrift.issues;
1603
+ // Audit-ledger health (P0): telemetry anomalies are not hook drift, but
1604
+ // they ride the same repo-scoped group and must be surfaced by doctor.
1605
+ const ledger = diagnoseAuditLedger();
1606
+ checks.push(...ledger.checks);
1607
+ return drift.issues + moduleDrift.issues + ledger.issues;
1551
1608
  });
1552
1609
 
1553
1610
  // Wait for ALL parallel groups
@@ -3225,6 +3282,7 @@ module.exports = {
3225
3282
  diagnoseHookDrift,
3226
3283
  syncGlobalHooksFromRepo,
3227
3284
  diagnoseModuleDrift,
3285
+ diagnoseAuditLedger,
3228
3286
  syncModulesFromRepo,
3229
3287
  resolveExecutedModuleDirs,
3230
3288
  executedModulePath,
package/lib/gate-audit.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  renameSync,
15
15
  statSync,
16
16
  chmodSync,
17
+ rmSync,
17
18
  } from 'fs';
18
19
  import { join } from 'path';
19
20
 
@@ -23,6 +24,7 @@ const AUDIT_DIR = '.xp-gate';
23
24
  const AUDIT_FILE = 'audit.jsonl';
24
25
  const MAX_FILE_BYTES = 10 * 1024 * 1024; // 10 MB
25
26
  const MAX_ARCHIVES = 3;
27
+ const MAX_PLAUSIBLE_DURATION_MS = 6 * 60 * 60 * 1000; // 6h — no single gate runs longer
26
28
 
27
29
  // ── Types ────────────────────────────────────────────────────────────────────
28
30
 
@@ -85,6 +87,75 @@ export function appendAuditEntry(
85
87
  }
86
88
  }
87
89
 
90
+ // ── Pre-commit staging ───────────────────────────────────────────────────────
91
+ // Pre-commit cannot know the commit it is about to create (HEAD is still the
92
+ // parent there), so per-gate entries are staged to a sidecar and stamped with
93
+ // the real commit by post-commit. Fallback safety: `flushPendingAudit` also
94
+ // runs at the START of the next pre-commit, so if post-commit never fired
95
+ // (hooks skipped, exception) the entries still land, attributed by `git log -1`
96
+ // at that later point — slightly late but never lost, never misattributed to
97
+ // the wrong commit when post-commit does run.
98
+
99
+ const PENDING_SUFFIX = 'audit.pending.jsonl';
100
+
101
+ function pendingPath(repoRoot: string): string {
102
+ return join(repoRoot, AUDIT_DIR, PENDING_SUFFIX);
103
+ }
104
+
105
+ /** Stage one entry for the not-yet-created commit. Never throws. */
106
+ export function stageAuditEntry(entry: GateAuditEntry, repoRoot: string = process.cwd()): void {
107
+ try {
108
+ const dir = join(repoRoot, AUDIT_DIR);
109
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
110
+ appendFileSync(pendingPath(repoRoot), JSON.stringify(entry) + '\n', 'utf8');
111
+ } catch (err) {
112
+ console.error('[xp-gate audit] Failed to stage entry:', (err as Error).message);
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Move staged entries into audit.jsonl, stamped with the real commit hash.
118
+ * Idempotent and safe to call when nothing is staged. A blank/absent hash
119
+ * leaves `commit_hash` exactly as staged so a wrong commit is never invented.
120
+ */
121
+ export function flushPendingAudit(
122
+ repoRoot: string = process.cwd(),
123
+ commitHash: string = '',
124
+ ): number {
125
+ const p = pendingPath(repoRoot);
126
+ if (!existsSync(p)) return 0;
127
+ let flushed = 0;
128
+ try {
129
+ const raw = readFileSync(p, 'utf8');
130
+ const lines = raw.split('\n').filter((l) => l.trim());
131
+ for (const line of lines) {
132
+ let entry: GateAuditEntry;
133
+ try {
134
+ entry = JSON.parse(line) as GateAuditEntry;
135
+ } catch {
136
+ // A torn line (interrupted mid-write) is dropped rather than crashing
137
+ // the hook that is trying to flush a previous run's leftovers.
138
+ continue;
139
+ }
140
+ if (commitHash) entry.commit_hash = commitHash;
141
+ appendAuditEntry(entry, repoRoot);
142
+ flushed++;
143
+ }
144
+ rmSyncSafe(p);
145
+ } catch (err) {
146
+ console.error('[xp-gate audit] Failed to flush pending entries:', (err as Error).message);
147
+ }
148
+ return flushed;
149
+ }
150
+
151
+ function rmSyncSafe(p: string): void {
152
+ try {
153
+ rmSync(p, { force: true });
154
+ } catch {
155
+ /* best effort */
156
+ }
157
+ }
158
+
88
159
  /**
89
160
  * Read the last N entries from the audit log.
90
161
  */
@@ -152,7 +223,10 @@ function aggregateByGate(entries: GateAuditEntry[]): Map<string, { total: number
152
223
  const buckets = new Map<string, { total: number; passed: number; ms: number; issues: number; anomaly_count: number }>();
153
224
 
154
225
  for (const e of entries) {
155
- const b = buckets.get(e.gate_id) ?? { total: 0, passed: 0, ms: 0, issues: 0, anomaly_count: 0 };
226
+ // Tolerate malformed/legacy rows: a missing gate_id would otherwise create
227
+ // an `undefined` bucket key (and crash the sort comparing it to a string).
228
+ const gateId = typeof e.gate_id === 'string' && e.gate_id ? e.gate_id : 'unknown';
229
+ const b = buckets.get(gateId) ?? { total: 0, passed: 0, ms: 0, issues: 0, anomaly_count: 0 };
156
230
  b.total += 1;
157
231
  if (e.passed) b.passed += 1;
158
232
  // Issue #370: exclude anomalous durations from ms aggregation. REQ-B: a
@@ -163,8 +237,10 @@ function aggregateByGate(entries: GateAuditEntry[]): Map<string, { total: number
163
237
  } else if (typeof e.duration_ms === 'number') {
164
238
  b.ms += e.duration_ms;
165
239
  }
166
- b.issues += e.issues_found;
167
- buckets.set(e.gate_id, b);
240
+ // A malformed/legacy row with a non-numeric issues_found must not turn
241
+ // the whole gate's average into NaN.
242
+ b.issues += Number.isFinite(e.issues_found) ? e.issues_found : 0;
243
+ buckets.set(gateId, b);
168
244
  }
169
245
 
170
246
  return buckets;
@@ -202,6 +278,70 @@ export function computeStats(
202
278
  return results;
203
279
  }
204
280
 
281
+ export interface AuditAnomalies {
282
+ /** Entries with an explicit duration_anomaly flag (Issue #370). */
283
+ flagged: number;
284
+ /** Entries with a null duration + reason (REQ-B unmeasurable). */
285
+ null_with_reason: number;
286
+ /** Entries persisting a provably-impossible duration (>6h / negative). */
287
+ implausible_duration: number;
288
+ /** Entries with no commit attribution (the off-by-one bug's fingerprints). */
289
+ missing_commit: number;
290
+ /** Sum of the above — the number CI should treat as "must be 0". */
291
+ total: number;
292
+ /** Running counter of entries scanned. */
293
+ scanned: number;
294
+ }
295
+
296
+ /**
297
+ * Count audit-health problems so CI can fail when the ledger is untrustworthy.
298
+ *
299
+ * These are exactly the artefacts of the two telemetry bugs we fixed: epoch
300
+ * durations (implausible_duration) and misattributed/blank commits
301
+ * (missing_commit). A clean ledger returns total=0.
302
+ *
303
+ * `since` (ISO-8601) scopes the check to entries at/after that instant. Both
304
+ * bugs predate the fix and left ~900 historical rows in the ledger; a CI gate
305
+ * that counted them would be permanently red. Scoping by the fix date turns
306
+ * the gate into "no NEW anomalies", which is exactly what we can enforce.
307
+ */
308
+ export function computeAnomalies(repoRoot: string = process.cwd(), since?: string): AuditAnomalies {
309
+ const logPath = join(repoRoot, AUDIT_DIR, AUDIT_FILE);
310
+ const entries = parseAuditEntries(logPath);
311
+ let flagged = 0;
312
+ let null_with_reason = 0;
313
+ let implausible = 0;
314
+ let missingCommit = 0;
315
+ let considered = 0;
316
+ for (const e of entries) {
317
+ if (since && typeof e.timestamp === 'string' && e.timestamp < since) continue;
318
+ considered += 1;
319
+ if (e.duration_anomaly === true) flagged += 1;
320
+ if (e.duration_ms === null && e.duration_reason) null_with_reason += 1;
321
+ if (typeof e.duration_ms === 'number'
322
+ && (!Number.isFinite(e.duration_ms) || e.duration_ms < 0 || e.duration_ms > MAX_PLAUSIBLE_DURATION_MS)) {
323
+ implausible += 1;
324
+ }
325
+ if (!e.commit_hash || e.commit_hash === 'unknown') missingCommit += 1;
326
+ }
327
+ return {
328
+ flagged,
329
+ null_with_reason,
330
+ implausible_duration: implausible,
331
+ missing_commit: missingCommit,
332
+ total: implausible + missingCommit,
333
+ scanned: considered,
334
+ };
335
+ }
336
+
337
+ /**
338
+ * Entries at/after this instant are expected to be clean. It is the date the
339
+ * two telemetry bugs were fixed; anything older is grandfathered because it
340
+ * cannot be re-measured. Keep in sync with the fix commit if you regenerate.
341
+ */
342
+ export const AUDIT_ANOMALY_SINCE = '2026-10-30T00:00:00.000Z';
343
+
344
+
205
345
  /**
206
346
  * Rotate the audit log if it exceeds MAX_FILE_BYTES.
207
347
  * Renames current file to .1, shifts existing archives.
@@ -250,24 +390,48 @@ function runCli(): void {
250
390
 
251
391
  if (command === 'record') {
252
392
  const opts = parseCliOptions(args.slice(1));
393
+ // Writer-side backstop: never persist an implausible duration (epoch-sized
394
+ // `end - 0`, unit error). Any caller-supplied reason wins; otherwise a
395
+ // rejected number is recorded as null + poisoned_start_ms.
396
+ const rawDuration = opts['duration-ms'] === 'null' ? null : parseInt(opts['duration-ms'] || '0', 10);
397
+ const durationCheck = sanitizeDurationMs(rawDuration);
398
+ const durationReason = opts['duration-reason'] || durationCheck.duration_reason;
253
399
  const entry: GateAuditEntry = {
254
400
  timestamp: new Date().toISOString(),
255
401
  gate_id: opts['gate-id'] || 'unknown',
256
402
  gate_name: opts['gate-name'] || 'unknown',
257
403
  passed: opts['passed'] === 'true',
258
404
  issues_found: parseInt(opts['issues-found'] || '0', 10),
259
- duration_ms: opts['duration-ms'] === 'null' ? null : parseInt(opts['duration-ms'] || '0', 10),
405
+ duration_ms: durationCheck.duration_ms,
260
406
  trigger: (opts['trigger'] as 'commit' | 'push' | 'manual') || 'manual',
261
407
  repo_path: process.cwd(),
262
- commit_hash: getCommitHash(),
408
+ // Pre-commit runs BEFORE the commit object exists, so `git rev-parse HEAD`
409
+ // here resolves the PARENT. resolveCommitHash lets an explicit
410
+ // --commit-hash win and falls back to HEAD otherwise.
411
+ commit_hash: resolveCommitHash(opts),
263
412
  // Issue #370: only set when explicitly flagged (omit field otherwise)
264
413
  ...(opts['duration-anomaly'] === 'true' ? { duration_anomaly: true } : {}),
265
414
  // REQ-B: why the duration is null — never guess a number for it.
266
- ...(opts['duration-reason'] ? { duration_reason: opts['duration-reason'] as 'poisoned_start_ms' | 'clock_skew' } : {}),
415
+ ...(durationReason ? { duration_reason: durationReason as 'poisoned_start_ms' | 'clock_skew' } : {}),
267
416
  // #507: only set when provided (omit field otherwise)
268
417
  ...(opts['detail'] ? { detail: opts['detail'] } : {}),
269
418
  };
270
- appendAuditEntry(entry);
419
+ // Pre-commit (trigger=commit) is staging for a commit that does not exist
420
+ // yet: park it in the pending sidecar so post-commit can stamp the real
421
+ // hash. Its commit_hash is deliberately blank until then — stamping the
422
+ // parent here is exactly the off-by-one we are removing.
423
+ if (entry.trigger === 'commit' && !opts['commit-hash']) {
424
+ entry.commit_hash = '';
425
+ stageAuditEntry(entry);
426
+ } else {
427
+ appendAuditEntry(entry);
428
+ }
429
+ } else if (command === 'flush-pending') {
430
+ // Called by post-commit with the now-real HEAD.
431
+ const opts = parseCliOptions(args.slice(1));
432
+ const n = flushPendingAudit(process.cwd(), opts['commit-hash'] || '');
433
+ // Quiet by default (hooks are noisy enough); --report prints the count.
434
+ if (opts['report'] === 'true') console.log(`flushed ${n}`);
271
435
  } else {
272
436
  console.error(`Unknown audit command: ${command}`);
273
437
  process.exit(1);
@@ -297,3 +461,47 @@ export function getCommitHash(): string {
297
461
  return 'unknown';
298
462
  }
299
463
  }
464
+
465
+ /**
466
+ * Which commit a gate entry belongs to.
467
+ *
468
+ * Pre-commit fires BEFORE the commit object exists, so `git rev-parse HEAD`
469
+ * there returns the PARENT. If the hook let that stand, every gate entry would
470
+ * be filed under the previous commit — the newest commit would look like it
471
+ * "ran no gates", and any per-commit battery analysis silently mixes one
472
+ * commit's timings into another's. An explicit `--commit-hash` (from a caller
473
+ * that knows the subject, e.g. post-commit) always wins; otherwise HEAD is the
474
+ * subject (push/manual) and the resolver is used.
475
+ *
476
+ * `headResolver` is injectable so the precedence can be tested without a repo.
477
+ */
478
+ export function resolveCommitHash(
479
+ opts: Record<string, string>,
480
+ headResolver: () => string = getCommitHash,
481
+ ): string {
482
+ const explicit = (opts['commit-hash'] || '').trim();
483
+ if (explicit) return explicit;
484
+ return headResolver();
485
+ }
486
+
487
+ // ── Duration sanity (last line of defence) ───────────────────────────────────
488
+ // The shell side sanitizes start_ms before computing the delta
489
+ // (githooks/lib/audit-sanitize.sh, REQ-B). This is the writer-side backstop:
490
+ // if any caller path ever hands us a duration that is provably not a real
491
+ // gate runtime, refuse to persist it as a number. An epoch-sized value (the
492
+ // old bug produced `end - 0 = now`, ~1.7e12) or anything absurdly large would
493
+ // otherwise poison every average and p90 downstream and looks exactly like a
494
+ // "78-hour gate". Precision matters less than honesty: a rejected duration is
495
+ // null with a reason, never a fabricated number.
496
+
497
+ export function sanitizeDurationMs(
498
+ raw: number | null,
499
+ ): { duration_ms: number | null; duration_reason?: 'poisoned_start_ms' } {
500
+ if (raw === null) return { duration_ms: null };
501
+ if (!Number.isFinite(raw) || raw < 0 || raw > MAX_PLAUSIBLE_DURATION_MS) {
502
+ // >6h or negative can only come from a poisoned start_ms (epoch-sized
503
+ // value, unit error) — mark it, don't guess a number.
504
+ return { duration_ms: null, duration_reason: 'poisoned_start_ms' };
505
+ }
506
+ return { duration_ms: raw };
507
+ }
@@ -2,6 +2,6 @@
2
2
  "verdict": "PASS",
3
3
  "license_type": "MIT",
4
4
  "sha256": "0e7ac423bf2c6e223b7c5b156f8cf72da49d748e56a1641402c31f22ad07dbb5",
5
- "checked_at": "2026-10-29T23:47:25.119Z",
5
+ "checked_at": "2026-10-30T11:58:38.831Z",
6
6
  "source": "network"
7
7
  }
@@ -34,7 +34,10 @@ function readXpGateConfig() {
34
34
  }
35
35
 
36
36
  function copyHooks(srcDir, destDir) {
37
- ['pre-commit', 'pre-push', 'post-merge'].forEach(hook => {
37
+ // post-commit carries the audit-attribution flush and --no-verify detection.
38
+ // It was absent here, so neither ever ran for installed users (the source
39
+ // existed but no installer copied it).
40
+ ['pre-commit', 'pre-push', 'post-commit', 'post-merge'].forEach(hook => {
38
41
  const src = path.join(srcDir, 'hooks', hook);
39
42
  const dest = path.join(destDir, hook);
40
43
  if (fs.existsSync(src)) {
@@ -1,9 +1,9 @@
1
1
  # SRC/MOCK-POLICY KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-29
4
- **Commit:** 101a0ff3
3
+ **Generated:** 2026-10-30
4
+ **Commit:** e5ca7d9d
5
5
  **Branch:** main
6
- **Version:** 0.32.1.0
6
+ **Version:** 0.32.3.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Mock layering policy enforcement — Gate M3 of pre-push hook. Ensures integration tests use real implementations for internal dependencies, mock external dependencies, and annotate pending mocks with removal plans. Combines project scope scanning, mock decision engine, and per-file validation into a single pipeline.
@@ -1,9 +1,9 @@
1
1
  # SRC/MUTATION KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-29
4
- **Commit:** 101a0ff3
3
+ **Generated:** 2026-10-30
4
+ **Commit:** e5ca7d9d
5
5
  **Branch:** main
6
- **Version:** 0.32.1.0
6
+ **Version:** 0.32.3.0
7
7
 
8
8
  ## OVERVIEW
9
9
  **Gate M** (incremental mutation testing) + **Gate M2** helpers (test-layer detection used by `src/mock-policy/`). Pre-push quality gate. TypeScript-only; uses Stryker.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boyingliu01/xp-gate",
3
- "version": "0.32.1",
3
+ "version": "0.32.3",
4
4
  "description": "AI-driven development workflow: 6 quality gates + Delphi review + Sprint Flow",
5
5
  "bin": {
6
6
  "xp-gate": "bin/xp-gate.js"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xp-gate",
3
- "version": "0.32.1",
3
+ "version": "0.32.3",
4
4
  "displayName": "XP-Gate",
5
5
  "description": "Extreme Programming quality gates + AI workflow skills for Claude Code. Includes 12 pre-commit gates (Gate 0-11), 8 pre-push gates, Sprint Flow (6 phases), and Delphi multi-expert review (>=90% consensus).",
6
6
  "author": {
@@ -1,9 +1,9 @@
1
1
  # SKILLS/DELPHI-REVIEW KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-29
4
- **Commit:** 101a0ff3
3
+ **Generated:** 2026-10-30
4
+ **Commit:** e5ca7d9d
5
5
  **Branch:** main
6
- **Version:** 0.32.1.0
6
+ **Version:** 0.32.3.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Delphi Consensus Review — multi-round anonymous expert review (≥90% threshold, exactly 3 experts with distinct executable model IDs). Supports design, requirements, and code-walkthrough modes.
@@ -1,9 +1,9 @@
1
1
  # SKILLS/SPRINT-FLOW KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-29
4
- **Commit:** 101a0ff3
3
+ **Generated:** 2026-10-30
4
+ **Commit:** e5ca7d9d
5
5
  **Branch:** main
6
- **Version:** 0.32.1.0
6
+ **Version:** 0.32.3.0
7
7
 
8
8
  ## OVERVIEW
9
9
  **6-phase** development pipeline (v2.0 compact redesign, Issue #290): PREP → DESIGN → BUILD → VERIFY → SHIP → CLOSE. Phase 3/6 BUILD default build mode is **ralph-loop** (REQ-level iteration, 40-67% token savings vs parallel). HARD-GATE between DESIGN (2/6) and BUILD (3/6): design must pass Delphi review (≥90% consensus) before any coding.