@boyingliu01/xp-gate 0.32.2 → 0.33.0

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 (49) 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/bin/xp-gate.js +50 -4
  5. package/gate-4.sh +8 -0
  6. package/hooks/gate-4.sh +8 -0
  7. package/hooks/post-commit +82 -0
  8. package/hooks/pre-commit +19 -0
  9. package/hooks/pre-push +21 -2
  10. package/lib/__tests__/check-changed-brief.test.js +63 -0
  11. package/lib/__tests__/doctor-audit-ledger.test.js +61 -0
  12. package/lib/__tests__/gate-audit.test.ts +144 -0
  13. package/lib/__tests__/shared-utils.test.js +22 -0
  14. package/lib/check.js +114 -2
  15. package/lib/doctor.js +59 -1
  16. package/lib/gate-audit.ts +215 -7
  17. package/lib/mattpocock-license-verdict.json +1 -1
  18. package/lib/shared-utils.js +4 -1
  19. package/mock-policy/AGENTS.md +2 -2
  20. package/mutation/AGENTS.md +2 -2
  21. package/package.json +1 -1
  22. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  23. package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
  24. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
  25. package/plugins/claude-code/skills/sprint-flow/references/phase-3-build.md +47 -0
  26. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
  27. package/plugins/dsh/package.json +1 -1
  28. package/plugins/dsh/scripts/copy-client.cjs +6 -1
  29. package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
  30. package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
  31. package/plugins/dsh/skills/sprint-flow/references/phase-3-build.md +47 -0
  32. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
  33. package/plugins/dsh/src/contract.test.ts +25 -1
  34. package/plugins/opencode/package.json +1 -1
  35. package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
  36. package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
  37. package/plugins/opencode/skills/sprint-flow/references/phase-3-build.md +47 -0
  38. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
  39. package/plugins/qoder/plugin.json +1 -1
  40. package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
  41. package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
  42. package/plugins/qoder/skills/sprint-flow/references/phase-3-build.md +47 -0
  43. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
  44. package/principles/AGENTS.md +2 -2
  45. package/scripts/sync-package-content.js +1 -1
  46. package/skills/delphi-review/AGENTS.md +2 -2
  47. package/skills/sprint-flow/AGENTS.md +2 -2
  48. package/skills/sprint-flow/references/phase-3-build.md +47 -0
  49. package/skills/test-specification-alignment/AGENTS.md +2 -2
package/lib/check.js CHANGED
@@ -13,13 +13,15 @@ function resolveGateToken(token) {
13
13
  }
14
14
 
15
15
  function parseCheckArgs(args) {
16
- const flags = { all: false, list: false, gateIds: null };
16
+ const flags = { all: false, list: false, gateIds: null, changed: false, brief: false };
17
17
  const posArgs = [];
18
18
  let i = 0;
19
19
  while (i < args.length) {
20
20
  const arg = args[i];
21
21
  if (arg === '--all') { flags.all = true; i++; }
22
22
  else if (arg === '--list') { flags.list = true; i++; }
23
+ else if (arg === '--changed') { flags.changed = true; i++; }
24
+ else if (arg === '--brief') { flags.brief = true; i++; }
23
25
  else if (arg === '--gates' && i + 1 < args.length) {
24
26
  flags.gateIds = args[i + 1].split(',').map(s => s.trim()).filter(Boolean);
25
27
  i += 2;
@@ -45,6 +47,7 @@ async function check(args) {
45
47
  const opts = parseCheckArgs(args);
46
48
 
47
49
  if (opts.list) return listGates();
50
+ if (opts.changed) return runChangedBrief();
48
51
  if (opts.gateNumber && !opts.all && !opts.gateIds) return runGate(opts.gateNumber, opts.pathArg);
49
52
  if (opts.all) return checkAllGates(opts.pathArg);
50
53
  if (opts.gateIds && opts.gateIds.length > 0) return runGatesByIds(opts.gateIds, opts.pathArg);
@@ -55,6 +58,7 @@ async function check(args) {
55
58
  console.error('Options:');
56
59
  console.error(' --gates <ids> Run specific gates (comma-separated IDs or aliases, e.g. "version,4,secrets")');
57
60
  console.error(' --all Run all invokable gates');
61
+ console.error(' --changed --brief Fast: run only the gates the current diff can trip, one-screen summary');
58
62
  console.error(' --list List all available gates');
59
63
  console.error('');
60
64
  console.error('Available Gates:');
@@ -68,6 +72,7 @@ async function check(args) {
68
72
  console.error(' xp-gate check secrets # Run Gate 8 via alias');
69
73
  console.error(' xp-gate check . --gates version,4,secrets # Run gates 0, 4, and 8');
70
74
  console.error(' xp-gate check . --all # Run all invokable gates');
75
+ console.error(' xp-gate check . --changed --brief # Fast: only what this diff trips');
71
76
  console.error(' xp-gate check --list # List available gates');
72
77
  return 1;
73
78
  }
@@ -212,6 +217,113 @@ async function runGatesByIds(gateIds, target) {
212
217
  return failures === 0 ? 0 : 1;
213
218
  }
214
219
 
220
+ /**
221
+ * Fast "what will this commit trip?" view (P1-4, diagnosis 2026-10-30).
222
+ *
223
+ * `check . --all` runs the whole tree (minutes). During BUILD the developer only
224
+ * needs to know which blocking rules the CURRENT diff touches, in one screen and
225
+ * in seconds. This maps the diff to the gates that read changed files and runs
226
+ * only those, so the common case (docs / a couple of sources) returns fast.
227
+ *
228
+ * Honest by construction: prints only gate->verdict, never implying a gate ran
229
+ * when it was skipped. Gates not modelled here are listed as "commit-time".
230
+ */
231
+
232
+ /** Extension of a path, lowercased, without the dot ('' when none). */
233
+ function fileExtension(file) {
234
+ const m = /\.([a-z0-9]+)$/i.exec(file);
235
+ return m ? m[1].toLowerCase() : '';
236
+ }
237
+
238
+ /** Wall-clock budget for one `git diff` used by the --changed resolver. */
239
+ const GIT_DIFF_TIMEOUT_MS = 10000;
240
+
241
+ /**
242
+ * Run one git diff and split its stdout into file names. Returns null ONLY for a
243
+ * failed diff (not a git repo); any git error is caught and mapped to null so the
244
+ * caller can report "not a repository" instead of crashing on a null status.
245
+ */
246
+ function gitChangedNames(spawnSync, args) {
247
+ try {
248
+ const res = spawnSync('git', args, { encoding: 'utf-8', timeout: GIT_DIFF_TIMEOUT_MS });
249
+ if (res.status !== 0 || typeof res.stdout !== 'string') return null;
250
+ return res.stdout.split('\n').map((f) => f.trim()).filter(Boolean);
251
+ } catch {
252
+ return null;
253
+ }
254
+ }
255
+
256
+ /** Diff scope mirrors pre-commit: staged first, then anything vs HEAD. */
257
+ function readChangedFiles() {
258
+ const { spawnSync } = require('child_process');
259
+ const staged = gitChangedNames(spawnSync, ['diff', '--cached', '--name-only', '--diff-filter=ACM']);
260
+ if (staged === null) return null;
261
+ if (staged.length > 0) return staged;
262
+ const vsHead = gitChangedNames(spawnSync, ['diff', 'HEAD', '--name-only', '--diff-filter=ACM']);
263
+ return vsHead === null ? staged : vsHead;
264
+ }
265
+
266
+ /**
267
+ * Run the gates a diff can trip that are both diff-scoped and fast. Gate 2
268
+ * (jscpd) is excluded on purpose: it scans the whole tree and duplicate
269
+ * detection is a codebase-wide question, not a per-diff one. Gate 4 is handed the
270
+ * actual files — calling it with '.' examined 0 files and printed a vacuous
271
+ * "No violations found", the exact anti-pattern this project forbids.
272
+ */
273
+ async function runBriefGates(sourceFiles) {
274
+ const results = [];
275
+ if (sourceFiles.length === 0) return results;
276
+ results.push({ id: '3', code: await runGate('3', '.', {}) });
277
+ const { principles } = require('./principles.js');
278
+ results.push({ id: '4', code: await principles(sourceFiles) });
279
+ return results;
280
+ }
281
+
282
+ const BRIEF_GATE_NAMES = { '2': 'Duplicates', '3': 'Complexity', '4': 'Principles' };
283
+ const COMMIT_TIME_GATES = ['1 lint', '5 tests', '6 Boy Scout', '7 IaC', '8 secrets', '9 sast', '10 build', '11 sprint'];
284
+
285
+ /** Print the one-screen summary; returns the number of blocking gates. */
286
+ function printBriefSummary(changed, sourceFiles, results, ms) {
287
+ console.log('');
288
+ console.log(`━━━ xp-gate check --changed (${changed.length} file(s), ${sourceFiles.length} source) ━━━`);
289
+ if (sourceFiles.length === 0) {
290
+ console.log(' No source files changed — no blocking source gate applies (docs/config only).');
291
+ }
292
+ let failures = 0;
293
+ for (const r of results) {
294
+ let verdict;
295
+ if (r.code === 0) verdict = 'PASS';
296
+ else if (r.code === 'NOT_RUN') verdict = 'SKIP';
297
+ else { verdict = 'BLOCK'; failures += 1; }
298
+ console.log(` Gate ${r.id} ${(BRIEF_GATE_NAMES[r.id] || r.id).padEnd(12)} ${verdict}`);
299
+ }
300
+ const rest = sourceFiles.length > 0 ? COMMIT_TIME_GATES.filter((g) => !/^(1 |5 |6 )/.test(g)) : COMMIT_TIME_GATES;
301
+ console.log(` commit-time (not run here): ${rest.join(', ')}`);
302
+ console.log(` ${failures === 0 ? '✅ clear' : `❌ ${failures} blocking`} — ${ms} ms`);
303
+ return failures;
304
+ }
305
+
306
+ async function runChangedBrief() {
307
+ const { SOURCE_FILE_EXTENSIONS } = require('./gate-runner.js');
308
+ const t0 = Date.now();
309
+
310
+ const changed = readChangedFiles();
311
+ if (changed === null) {
312
+ console.error('[xp-gate check --changed] not a git repository');
313
+ return 1;
314
+ }
315
+ if (changed.length === 0) {
316
+ console.log('[xp-gate check --changed] no staged/tracked changes — nothing to gate (0 files).');
317
+ return 0;
318
+ }
319
+
320
+ const srcExt = new Set(SOURCE_FILE_EXTENSIONS);
321
+ const sourceFiles = changed.filter((f) => srcExt.has(fileExtension(f)));
322
+ const results = await runBriefGates(sourceFiles);
323
+ const failures = printBriefSummary(changed, sourceFiles, results, Date.now() - t0);
324
+ return failures === 0 ? 0 : 1;
325
+ }
326
+
215
327
  function listGates() {
216
328
  const { getAllGates } = require('./gate-runner.js');
217
329
  const allGates = getAllGates();
@@ -244,4 +356,4 @@ function listGates() {
244
356
  return 0;
245
357
  }
246
358
 
247
- module.exports = { check, checkAllGates, formatGateTally };
359
+ module.exports = { check, checkAllGates, formatGateTally, runChangedBrief, fileExtension, printBriefSummary };
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-30T08:22:09.989Z",
5
+ "checked_at": "2026-10-30T12:14:24.278Z",
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
3
  **Generated:** 2026-10-30
4
- **Commit:** 78786c3f
4
+ **Commit:** 3c5e9aaf
5
5
  **Branch:** main
6
- **Version:** 0.32.2.0
6
+ **Version:** 0.33.0.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
3
  **Generated:** 2026-10-30
4
- **Commit:** 78786c3f
4
+ **Commit:** 3c5e9aaf
5
5
  **Branch:** main
6
- **Version:** 0.32.2.0
6
+ **Version:** 0.33.0.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.2",
3
+ "version": "0.33.0",
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.2",
3
+ "version": "0.33.0",
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
3
  **Generated:** 2026-10-30
4
- **Commit:** 78786c3f
4
+ **Commit:** 3c5e9aaf
5
5
  **Branch:** main
6
- **Version:** 0.32.2.0
6
+ **Version:** 0.33.0.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
3
  **Generated:** 2026-10-30
4
- **Commit:** 78786c3f
4
+ **Commit:** 3c5e9aaf
5
5
  **Branch:** main
6
- **Version:** 0.32.2.0
6
+ **Version:** 0.33.0.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.