@boyingliu01/xp-gate 0.15.4 → 0.18.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 (117) hide show
  1. package/bin/xp-gate.js +29 -1
  2. package/hooks/lib/now-ms.sh +33 -0
  3. package/hooks/pre-commit +63 -47
  4. package/hooks/sprint-gate.sh +291 -0
  5. package/lib/__tests__/check-version.test.js +122 -0
  6. package/lib/__tests__/doctor.test.js +13 -15
  7. package/lib/__tests__/gate-audit.test.ts +38 -0
  8. package/lib/__tests__/phase-transition.test.js +241 -1
  9. package/lib/__tests__/retro.test.js +131 -0
  10. package/lib/__tests__/sprint-status-rework.test.js +184 -0
  11. package/lib/check-version.js +9 -0
  12. package/lib/doctor-tui.js +79 -9
  13. package/lib/doctor.js +15 -3
  14. package/lib/gate-audit.ts +18 -7
  15. package/lib/gate-runner.js +77 -30
  16. package/lib/init.js +10 -0
  17. package/lib/mattpocock-license-verdict.json +7 -0
  18. package/lib/phase-transition.js +251 -1
  19. package/lib/retro.js +222 -0
  20. package/lib/shared-utils.js +7 -0
  21. package/lib/sprint-init.js +207 -0
  22. package/lib/sprint-state-migrator.js +1 -0
  23. package/lib/sprint-status.js +111 -0
  24. package/mock-policy/AGENTS.md +3 -3
  25. package/mutation/AGENTS.md +3 -3
  26. package/package.json +3 -2
  27. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  28. package/plugins/claude-code/skills/batch-grill-me/SKILL.md +15 -0
  29. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  30. package/plugins/claude-code/skills/delphi-review/SKILL.md +59 -4
  31. package/plugins/claude-code/skills/delphi-review/references/requirements.md +390 -0
  32. package/plugins/claude-code/skills/delphi-review/references/round-templates.md +67 -0
  33. package/plugins/claude-code/skills/domain-modeling/ADR-FORMAT.md +64 -0
  34. package/plugins/claude-code/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
  35. package/plugins/claude-code/skills/domain-modeling/SKILL.md +74 -0
  36. package/plugins/claude-code/skills/grill-with-docs/SKILL.md +7 -0
  37. package/plugins/claude-code/skills/grilling/SKILL.md +12 -0
  38. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  39. package/plugins/claude-code/skills/sprint-flow/SKILL.md +168 -43
  40. package/plugins/claude-code/skills/sprint-flow/references/components/memory.md +1 -1
  41. package/plugins/claude-code/skills/sprint-flow/references/components/middleware.md +1 -1
  42. package/plugins/claude-code/skills/sprint-flow/references/components/skill-invocations.md +21 -20
  43. package/plugins/claude-code/skills/sprint-flow/references/components/tool-descriptions.md +22 -23
  44. package/plugins/claude-code/skills/sprint-flow/references/orchestration-rules.md +14 -12
  45. package/plugins/claude-code/skills/sprint-flow/references/phase-2-design.md +145 -133
  46. package/plugins/claude-code/skills/sprint-flow/references/phase-3-build.md +69 -2
  47. package/plugins/claude-code/skills/sprint-flow/references/phase-4-verify.md +93 -31
  48. package/plugins/claude-code/skills/sprint-flow/references/phase-5-ship.md +99 -14
  49. package/plugins/claude-code/skills/sprint-flow/references/phase-6-close.md +37 -5
  50. package/plugins/claude-code/skills/sprint-flow/references/phase-overview.md +58 -53
  51. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  52. package/plugins/claude-code/skills/test-specification-alignment/SKILL.md +8 -3
  53. package/plugins/claude-code/skills/to-issues/SKILL.md +1 -1
  54. package/plugins/opencode/index.ts +1 -0
  55. package/plugins/opencode/package.json +1 -1
  56. package/plugins/opencode/skills/batch-grill-me/SKILL.md +15 -0
  57. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  58. package/plugins/opencode/skills/delphi-review/SKILL.md +59 -4
  59. package/plugins/opencode/skills/delphi-review/references/requirements.md +390 -0
  60. package/plugins/opencode/skills/delphi-review/references/round-templates.md +67 -0
  61. package/plugins/opencode/skills/domain-modeling/ADR-FORMAT.md +64 -0
  62. package/plugins/opencode/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
  63. package/plugins/opencode/skills/domain-modeling/SKILL.md +74 -0
  64. package/plugins/opencode/skills/grill-with-docs/SKILL.md +7 -0
  65. package/plugins/opencode/skills/grilling/SKILL.md +12 -0
  66. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  67. package/plugins/opencode/skills/sprint-flow/SKILL.md +168 -43
  68. package/plugins/opencode/skills/sprint-flow/references/components/memory.md +1 -1
  69. package/plugins/opencode/skills/sprint-flow/references/components/middleware.md +1 -1
  70. package/plugins/opencode/skills/sprint-flow/references/components/skill-invocations.md +21 -20
  71. package/plugins/opencode/skills/sprint-flow/references/components/tool-descriptions.md +22 -23
  72. package/plugins/opencode/skills/sprint-flow/references/orchestration-rules.md +14 -12
  73. package/plugins/opencode/skills/sprint-flow/references/phase-2-design.md +145 -133
  74. package/plugins/opencode/skills/sprint-flow/references/phase-3-build.md +69 -2
  75. package/plugins/opencode/skills/sprint-flow/references/phase-4-verify.md +93 -31
  76. package/plugins/opencode/skills/sprint-flow/references/phase-5-ship.md +99 -14
  77. package/plugins/opencode/skills/sprint-flow/references/phase-6-close.md +37 -5
  78. package/plugins/opencode/skills/sprint-flow/references/phase-overview.md +58 -53
  79. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  80. package/plugins/opencode/skills/test-specification-alignment/SKILL.md +8 -3
  81. package/plugins/opencode/skills/to-issues/SKILL.md +1 -1
  82. package/plugins/qoder/plugin.json +1 -1
  83. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  84. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  85. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  86. package/principles/AGENTS.md +3 -3
  87. package/scripts/__tests__/check-mattpocock-license.test.js +229 -0
  88. package/scripts/__tests__/sync-package-content.test.js +50 -0
  89. package/scripts/check-mattpocock-license.cjs +302 -0
  90. package/scripts/migrate-audit-anomalies.mjs +78 -0
  91. package/scripts/sync-package-content.js +52 -5
  92. package/skills/batch-grill-me/SKILL.md +15 -0
  93. package/skills/delphi-review/AGENTS.md +3 -3
  94. package/skills/delphi-review/SKILL.md +59 -4
  95. package/skills/delphi-review/references/requirements.md +390 -0
  96. package/skills/delphi-review/references/round-templates.md +67 -0
  97. package/skills/domain-modeling/ADR-FORMAT.md +64 -0
  98. package/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
  99. package/skills/domain-modeling/SKILL.md +74 -0
  100. package/skills/grill-with-docs/SKILL.md +7 -0
  101. package/skills/grilling/SKILL.md +12 -0
  102. package/skills/sprint-flow/AGENTS.md +3 -3
  103. package/skills/sprint-flow/SKILL.md +168 -43
  104. package/skills/sprint-flow/references/components/memory.md +1 -1
  105. package/skills/sprint-flow/references/components/middleware.md +1 -1
  106. package/skills/sprint-flow/references/components/skill-invocations.md +21 -20
  107. package/skills/sprint-flow/references/components/tool-descriptions.md +22 -23
  108. package/skills/sprint-flow/references/orchestration-rules.md +14 -12
  109. package/skills/sprint-flow/references/phase-2-design.md +145 -133
  110. package/skills/sprint-flow/references/phase-3-build.md +69 -2
  111. package/skills/sprint-flow/references/phase-4-verify.md +93 -31
  112. package/skills/sprint-flow/references/phase-5-ship.md +99 -14
  113. package/skills/sprint-flow/references/phase-6-close.md +37 -5
  114. package/skills/sprint-flow/references/phase-overview.md +58 -53
  115. package/skills/test-specification-alignment/AGENTS.md +3 -3
  116. package/skills/test-specification-alignment/SKILL.md +8 -3
  117. package/skills/to-issues/SKILL.md +1 -1
@@ -31,6 +31,12 @@ describe('doctor', () => {
31
31
  originalDetectDepsTools = [...detectDeps.GATE_CLI_TOOLS];
32
32
  detectDeps.GATE_CLI_TOOLS.length = 0;
33
33
  }
34
+ // Mock checkBash to return success by default (tests override as needed)
35
+ detectDeps.checkBash = () => ({
36
+ ok: true,
37
+ path: 'bash',
38
+ version: '5.1.16',
39
+ });
34
40
  });
35
41
 
36
42
  afterAll(() => {
@@ -217,9 +223,6 @@ describe('doctor', () => {
217
223
  if (cmd === 'git --version') {
218
224
  return 'git version 2.39.0\n';
219
225
  }
220
- if (cmd === 'bash --version') {
221
- return 'GNU bash, version 5.1.16\n';
222
- }
223
226
  return '';
224
227
  };
225
228
 
@@ -237,8 +240,6 @@ describe('doctor', () => {
237
240
  callback(null, 'v20.0.0\n', '');
238
241
  } else if (cmd === 'git --version') {
239
242
  callback(null, 'git version 2.39.0\n', '');
240
- } else if (cmd === 'bash --version') {
241
- callback(null, 'GNU bash, version 5.1.16\n', '');
242
243
  } else {
243
244
  callback(null, '', '');
244
245
  }
@@ -401,9 +402,6 @@ describe('doctor', () => {
401
402
  if (cmd === 'git --version') {
402
403
  return 'git version 2.39.0\n';
403
404
  }
404
- if (cmd === 'bash --version') {
405
- return 'GNU bash, version 5.1.16\n';
406
- }
407
405
  return '';
408
406
  };
409
407
  // Async mock matching execSync above
@@ -417,8 +415,6 @@ describe('doctor', () => {
417
415
  callback(null, 'v20.0.0\n', '');
418
416
  } else if (cmd === 'git --version') {
419
417
  callback(null, 'git version 2.39.0\n', '');
420
- } else if (cmd === 'bash --version') {
421
- callback(null, 'GNU bash, version 5.1.16\n', '');
422
418
  } else {
423
419
  callback(null, '', '');
424
420
  }
@@ -502,9 +498,6 @@ describe('doctor', () => {
502
498
  if (cmd === 'git --version') {
503
499
  return 'git version 2.39.0\n';
504
500
  }
505
- if (cmd === 'bash --version') {
506
- return 'GNU bash, version 5.1.16\n';
507
- }
508
501
  return '';
509
502
  };
510
503
  // Async exec mock for execWithTimeout (util.promisify(exec) in doctor.js)
@@ -518,8 +511,6 @@ describe('doctor', () => {
518
511
  callback(null, 'v20.0.0\n', '');
519
512
  } else if (cmd === 'git --version') {
520
513
  callback(null, 'git version 2.39.0\n', '');
521
- } else if (cmd === 'bash --version') {
522
- callback(null, 'GNU bash, version 5.1.16\n', '');
523
514
  } else {
524
515
  callback(null, '', '');
525
516
  }
@@ -569,6 +560,10 @@ describe('doctor', () => {
569
560
  it('detects missing environment dependencies', async () => {
570
561
  setupLocalInstall();
571
562
  seedVersionCache();
563
+ // Make checkBash fail for this test (detect-deps module already loaded + mocked in beforeAll)
564
+ const detectDeps = require('../detect-deps.js');
565
+ const origCheckBash = detectDeps.checkBash;
566
+ detectDeps.checkBash = () => ({ ok: false, message: 'Not found' });
572
567
  mockExecFail();
573
568
  const { doctor } = require('../doctor');
574
569
 
@@ -578,6 +573,9 @@ describe('doctor', () => {
578
573
  expect(logSpy).toHaveBeenCalledWith(
579
574
  expect.stringContaining('Not found')
580
575
  );
576
+
577
+ // Restore
578
+ detectDeps.checkBash = origCheckBash;
581
579
  });
582
580
 
583
581
  // === Issue #186: Version mismatch detection ===
@@ -201,6 +201,44 @@ describe('gate-audit', () => {
201
201
  });
202
202
  });
203
203
 
204
+ describe('duration_anomaly handling', () => {
205
+ it('should exclude duration_anomaly entries from avg_ms calculation', () => {
206
+ const repoRoot = join(TEST_DIR, 'anomaly-avg-test');
207
+ // 2 normal entries: 100ms + 200ms → avg 150ms
208
+ appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-1', duration_ms: 100 }), repoRoot);
209
+ appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-1', duration_ms: 200 }), repoRoot);
210
+ // 1 anomalous entry: should be excluded from avg
211
+ appendAuditEntry(
212
+ makeEntry({ repo_path: repoRoot, gate_id: 'gate-1', duration_ms: 1784536915891, duration_anomaly: true }),
213
+ repoRoot,
214
+ );
215
+
216
+ const stats = computeStats(repoRoot);
217
+ const g1 = stats.find(s => s.gate_id === 'gate-1')!;
218
+ // avg should be (100+200)/2 = 150, NOT (100+200+1784536915891)/3
219
+ expect(g1.avg_ms).toBe(150);
220
+ });
221
+
222
+ it('should count anomaly entries separately in stats output', () => {
223
+ const repoRoot = join(TEST_DIR, 'anomaly-count-test');
224
+ appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-5', duration_ms: 500 }), repoRoot);
225
+ appendAuditEntry(
226
+ makeEntry({ repo_path: repoRoot, gate_id: 'gate-5', duration_ms: 9999999999999, duration_anomaly: true }),
227
+ repoRoot,
228
+ );
229
+ appendAuditEntry(
230
+ makeEntry({ repo_path: repoRoot, gate_id: 'gate-5', duration_ms: 8888888888888, duration_anomaly: true }),
231
+ repoRoot,
232
+ );
233
+
234
+ const stats = computeStats(repoRoot);
235
+ const g5 = stats.find(s => s.gate_id === 'gate-5')!;
236
+ expect(g5.anomaly_count).toBe(2);
237
+ // avg_ms only from the 1 normal entry
238
+ expect(g5.avg_ms).toBe(500);
239
+ });
240
+ });
241
+
204
242
  describe('CLI helpers', () => {
205
243
  it('should parse CLI options and skip dangling flags', () => {
206
244
  expect(parseCliOptions(['--gate-id', 'gate-1', '--passed', 'true', '--dangling'])).toEqual({
@@ -12,7 +12,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
12
12
  import fs from 'node:fs';
13
13
  import path from 'node:path';
14
14
  import os from 'node:os';
15
- import { handlePhaseTransition, renderDashboard, PHASE_NAMES } from '../phase-transition.js';
15
+ import { handlePhaseTransition, renderDashboard, PHASE_NAMES, validateEvidence } from '../phase-transition.js';
16
16
 
17
17
  describe('phase-transition', () => {
18
18
  let tmpDir;
@@ -25,6 +25,29 @@ describe('phase-transition', () => {
25
25
  fs.rmSync(tmpDir, { recursive: true, force: true });
26
26
  });
27
27
 
28
+ /** Helper: write phase 2 evidence (requirements-reviewed.json) so phase 2 completed passes */
29
+ function writePhase2Evidence(dir) {
30
+ const outputsDir = path.join(dir, '.sprint-state', 'phase-outputs');
31
+ fs.mkdirSync(outputsDir, { recursive: true });
32
+ fs.writeFileSync(path.join(outputsDir, 'requirements-reviewed.json'), JSON.stringify({
33
+ verdict: 'APPROVED',
34
+ requirements_hash: 'test-hash-placeholder',
35
+ timestamp: new Date().toISOString(),
36
+ }));
37
+ }
38
+
39
+ /** Helper: write phase 4 evidence (test-alignment-report.json) so phase 4 completed passes */
40
+ function writePhase4Evidence(dir) {
41
+ const outputsDir = path.join(dir, '.sprint-state', 'phase-outputs');
42
+ fs.mkdirSync(outputsDir, { recursive: true });
43
+ fs.writeFileSync(path.join(outputsDir, 'test-alignment-report.json'), JSON.stringify({
44
+ alignment_status: 'PASS',
45
+ head_commit: 'unknown',
46
+ spec_hash: null,
47
+ timestamp: new Date().toISOString(),
48
+ }));
49
+ }
50
+
28
51
  describe('handlePhaseTransition()', () => {
29
52
  it('returns 0 and shows help with --help', async () => {
30
53
  const code = await handlePhaseTransition(['--help']);
@@ -61,6 +84,7 @@ describe('phase-transition', () => {
61
84
  });
62
85
 
63
86
  it('records outputs when --outputs flag is provided', async () => {
87
+ writePhase2Evidence(tmpDir);
64
88
  const outputs = JSON.stringify({ spec: 'path/to/spec.yaml' });
65
89
  const code = await handlePhaseTransition(['2', 'completed', '--outputs', outputs, '--dir', tmpDir]);
66
90
  expect(code).toBe(0);
@@ -78,6 +102,7 @@ describe('phase-transition', () => {
78
102
  it('transitions through multiple phases sequentially', async () => {
79
103
  await handlePhaseTransition(['1', 'completed', '--dir', tmpDir]);
80
104
  await handlePhaseTransition(['2', 'in_progress', '--dir', tmpDir]);
105
+ writePhase2Evidence(tmpDir);
81
106
  await handlePhaseTransition(['2', 'completed', '--dir', tmpDir]);
82
107
  await handlePhaseTransition(['3', 'in_progress', '--dir', tmpDir]);
83
108
 
@@ -187,6 +212,7 @@ describe('phase-transition', () => {
187
212
 
188
213
  it('does NOT warn when previous phase is completed', async () => {
189
214
  await handlePhaseTransition(['1', 'completed', '--dir', tmpDir]);
215
+ writePhase2Evidence(tmpDir);
190
216
  await handlePhaseTransition(['2', 'completed', '--dir', tmpDir]);
191
217
 
192
218
  const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
@@ -247,8 +273,10 @@ describe('phase-transition', () => {
247
273
  it('outputs sprint-audit reminder on Phase 6 completed', async () => {
248
274
  // Set up state through Phase 5
249
275
  await handlePhaseTransition(['1', 'completed', '--dir', tmpDir]);
276
+ writePhase2Evidence(tmpDir);
250
277
  await handlePhaseTransition(['2', 'completed', '--dir', tmpDir]);
251
278
  await handlePhaseTransition(['3', 'completed', '--dir', tmpDir]);
279
+ writePhase4Evidence(tmpDir);
252
280
  await handlePhaseTransition(['4', 'completed', '--dir', tmpDir]);
253
281
  await handlePhaseTransition(['5', 'completed', '--dir', tmpDir]);
254
282
 
@@ -269,4 +297,216 @@ describe('phase-transition', () => {
269
297
  expect(PHASE_NAMES[6]).toBe('CLOSE');
270
298
  });
271
299
  });
300
+
301
+ describe('evidence validation', () => {
302
+ /** Helper: create a sprint-state.json with given evidence_schema_version */
303
+ function createSprintState(dir, evidenceSchemaVersion) {
304
+ const stateDir = path.join(dir, '.sprint-state');
305
+ fs.mkdirSync(stateDir, { recursive: true });
306
+ const state = {
307
+ _schema_version: 1,
308
+ id: 'sprint-test-evidence',
309
+ task_description: 'Evidence test',
310
+ phase: 4,
311
+ status: 'in_progress',
312
+ started_at: new Date().toISOString(),
313
+ phase_history: [
314
+ { phase: 1, phase_name: 'PREP', status: 'completed' },
315
+ { phase: 2, phase_name: 'DESIGN', status: 'completed' },
316
+ { phase: 3, phase_name: 'BUILD', status: 'completed' },
317
+ { phase: 4, phase_name: 'VERIFY', status: 'in_progress' },
318
+ ],
319
+ isolation: { worktree_path: dir, branch: null },
320
+ outputs: {},
321
+ metrics: {},
322
+ };
323
+ if (evidenceSchemaVersion !== undefined) {
324
+ state.evidence_schema_version = evidenceSchemaVersion;
325
+ }
326
+ fs.writeFileSync(path.join(stateDir, 'sprint-state.json'), JSON.stringify(state, null, 2));
327
+ }
328
+
329
+ /** Helper: write a test-alignment-report.json */
330
+ function writeAlignmentReport(dir, report) {
331
+ const outputsDir = path.join(dir, '.sprint-state', 'phase-outputs');
332
+ fs.mkdirSync(outputsDir, { recursive: true });
333
+ fs.writeFileSync(path.join(outputsDir, 'test-alignment-report.json'), JSON.stringify(report, null, 2));
334
+ }
335
+
336
+ it('(a) BLOCKs when evidence file is missing for new sprint (evidence_schema_version >= 2)', () => {
337
+ createSprintState(tmpDir, 2);
338
+ const result = validateEvidence(4, tmpDir);
339
+ expect(result.ok).toBe(false);
340
+ expect(result.errors.length).toBeGreaterThan(0);
341
+ expect(result.errors[0]).toContain('test-alignment-report.json');
342
+ });
343
+
344
+ it('(b) BLOCKs when alignment_status is FAIL', () => {
345
+ createSprintState(tmpDir, 2);
346
+ writeAlignmentReport(tmpDir, {
347
+ alignment_status: 'FAIL',
348
+ head_commit: 'unknown',
349
+ spec_hash: null,
350
+ timestamp: new Date().toISOString(),
351
+ });
352
+ const result = validateEvidence(4, tmpDir);
353
+ expect(result.ok).toBe(false);
354
+ expect(result.errors.some(e => e.includes('alignment') || e.includes('PASS'))).toBe(true);
355
+ });
356
+
357
+ it('(c) BLOCKs when head_commit is stale (does not match current HEAD)', () => {
358
+ createSprintState(tmpDir, 2);
359
+ writeAlignmentReport(tmpDir, {
360
+ alignment_status: 'PASS',
361
+ head_commit: 'deadbeef00000000000000000000000000000000',
362
+ spec_hash: null,
363
+ timestamp: new Date().toISOString(),
364
+ });
365
+ const result = validateEvidence(4, tmpDir);
366
+ // In tmpDir (no git repo), current HEAD is 'unknown'.
367
+ // A non-'unknown' head_commit that doesn't match 'unknown' should BLOCK.
368
+ expect(result.ok).toBe(false);
369
+ expect(result.errors.some(e => e.includes('head_commit'))).toBe(true);
370
+ });
371
+
372
+ it('(d) BLOCKs when evidence file is malformed JSON (treated as missing)', () => {
373
+ createSprintState(tmpDir, 2);
374
+ const outputsDir = path.join(tmpDir, '.sprint-state', 'phase-outputs');
375
+ fs.mkdirSync(outputsDir, { recursive: true });
376
+ fs.writeFileSync(path.join(outputsDir, 'test-alignment-report.json'), '{invalid json!!!');
377
+ const result = validateEvidence(4, tmpDir);
378
+ expect(result.ok).toBe(false);
379
+ expect(result.errors.some(e => e.includes('malformed'))).toBe(true);
380
+ });
381
+
382
+ it('(e) WARNs but does NOT block for legacy sprint (no evidence_schema_version)', () => {
383
+ createSprintState(tmpDir); // no evidence_schema_version
384
+ const result = validateEvidence(4, tmpDir);
385
+ expect(result.ok).toBe(true);
386
+ expect(result.warnings.length).toBeGreaterThan(0);
387
+ expect(result.warnings[0]).toContain('evidence_schema_version');
388
+ });
389
+
390
+ it('(f) allows bypass with --skip-evidence and --reason via handlePhaseTransition', async () => {
391
+ createSprintState(tmpDir, 2);
392
+ // No evidence file — would normally BLOCK
393
+ const code = await handlePhaseTransition([
394
+ '4', 'completed', '--dir', tmpDir,
395
+ '--skip-evidence', 'Emergency hotfix',
396
+ ]);
397
+ expect(code).toBe(0);
398
+
399
+ // Verify audit entry was written
400
+ const auditFile = path.join(tmpDir, '.xp-gate', 'audit.jsonl');
401
+ expect(fs.existsSync(auditFile)).toBe(true);
402
+ const lines = fs.readFileSync(auditFile, 'utf8').trim().split('\n');
403
+ const lastEntry = JSON.parse(lines[lines.length - 1]);
404
+ expect(lastEntry.event).toBe('evidence_skipped');
405
+ expect(lastEntry.phase).toBe(4);
406
+ expect(lastEntry.reason).toBe('Emergency hotfix');
407
+ });
408
+
409
+ it('(g) rejects --skip-evidence without --reason', async () => {
410
+ createSprintState(tmpDir, 2);
411
+ const code = await handlePhaseTransition([
412
+ '4', 'completed', '--dir', tmpDir,
413
+ '--skip-evidence',
414
+ ]);
415
+ expect(code).toBe(1);
416
+ });
417
+
418
+ it('returns ok for phases without evidence requirements (phase 1, 3, 5, 6)', () => {
419
+ createSprintState(tmpDir, 2);
420
+ for (const phase of [1, 3, 5, 6]) {
421
+ const result = validateEvidence(phase, tmpDir);
422
+ expect(result.ok).toBe(true);
423
+ expect(result.errors).toHaveLength(0);
424
+ }
425
+ });
426
+
427
+ it('accepts valid evidence with head_commit matching current HEAD (unknown in non-git dir)', () => {
428
+ createSprintState(tmpDir, 2);
429
+ writeAlignmentReport(tmpDir, {
430
+ alignment_status: 'PASS',
431
+ head_commit: 'unknown', // matches non-git tmpDir
432
+ spec_hash: null,
433
+ timestamp: new Date().toISOString(),
434
+ });
435
+ const result = validateEvidence(4, tmpDir);
436
+ expect(result.ok).toBe(true);
437
+ expect(result.errors).toHaveLength(0);
438
+ });
439
+
440
+ it('validates spec_hash when specification.yaml exists', () => {
441
+ createSprintState(tmpDir, 2);
442
+ // Create a specification.yaml
443
+ const specContent = 'requirements:\n - id: REQ-001\n';
444
+ fs.writeFileSync(path.join(tmpDir, 'specification.yaml'), specContent);
445
+
446
+ // Compute expected hash
447
+ const crypto = require('crypto');
448
+ const expectedHash = crypto.createHash('sha256').update(specContent, 'utf8').digest('hex');
449
+
450
+ writeAlignmentReport(tmpDir, {
451
+ alignment_status: 'PASS',
452
+ head_commit: 'unknown',
453
+ spec_hash: expectedHash,
454
+ timestamp: new Date().toISOString(),
455
+ });
456
+ const result = validateEvidence(4, tmpDir);
457
+ expect(result.ok).toBe(true);
458
+ });
459
+
460
+ it('BLOCKs when spec_hash does not match specification.yaml', () => {
461
+ createSprintState(tmpDir, 2);
462
+ fs.writeFileSync(path.join(tmpDir, 'specification.yaml'), 'requirements:\n - id: REQ-001\n');
463
+ writeAlignmentReport(tmpDir, {
464
+ alignment_status: 'PASS',
465
+ head_commit: 'unknown',
466
+ spec_hash: 'wronghash000000000000000000000000000000000000000000000000000000',
467
+ timestamp: new Date().toISOString(),
468
+ });
469
+ const result = validateEvidence(4, tmpDir);
470
+ expect(result.ok).toBe(false);
471
+ expect(result.errors.some(e => e.includes('spec_hash'))).toBe(true);
472
+ });
473
+
474
+ it('BLOCKs when required fields are missing from evidence', () => {
475
+ createSprintState(tmpDir, 2);
476
+ writeAlignmentReport(tmpDir, {
477
+ alignment_status: 'PASS',
478
+ // missing head_commit and spec_hash
479
+ });
480
+ const result = validateEvidence(4, tmpDir);
481
+ expect(result.ok).toBe(false);
482
+ expect(result.errors.some(e => e.includes('head_commit'))).toBe(true);
483
+ });
484
+
485
+ it('does NOT validate evidence for non-completed statuses', async () => {
486
+ createSprintState(tmpDir, 2);
487
+ // in_progress should not trigger evidence validation
488
+ const code = await handlePhaseTransition(['4', 'in_progress', '--dir', tmpDir]);
489
+ expect(code).toBe(0);
490
+ });
491
+
492
+ it('handlePhaseTransition BLOCKs phase 4 completed without evidence for new sprint', async () => {
493
+ createSprintState(tmpDir, 2);
494
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
495
+ const code = await handlePhaseTransition(['4', 'completed', '--dir', tmpDir]);
496
+ expect(code).toBe(1);
497
+ errorSpy.mockRestore();
498
+ });
499
+
500
+ it('handlePhaseTransition allows phase 4 completed with valid evidence', async () => {
501
+ createSprintState(tmpDir, 2);
502
+ writeAlignmentReport(tmpDir, {
503
+ alignment_status: 'PASS',
504
+ head_commit: 'unknown',
505
+ spec_hash: null,
506
+ timestamp: new Date().toISOString(),
507
+ });
508
+ const code = await handlePhaseTransition(['4', 'completed', '--dir', tmpDir]);
509
+ expect(code).toBe(0);
510
+ });
511
+ });
272
512
  });
@@ -0,0 +1,131 @@
1
+ /**
2
+ * TDD: written RED first (Phase G per design §12)
3
+ * @test REQ-RETRO-001 Sprint engineering retrospective CLI
4
+ * @intent Validate xp-gate retro generates Markdown report with rework trend + skip exposure sections
5
+ */
6
+ const path = require('path');
7
+ const fs = require('fs');
8
+ const os = require('os');
9
+
10
+ const { handleRetro } = require('../retro');
11
+
12
+ let TMP_DIR;
13
+ let cwdSpy;
14
+
15
+ beforeAll(() => {
16
+ TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'retro-test-'));
17
+ cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(TMP_DIR);
18
+ });
19
+
20
+ afterAll(() => {
21
+ cwdSpy.mockRestore();
22
+ fs.rmSync(TMP_DIR, { recursive: true, force: true });
23
+ });
24
+
25
+ function writeAudit(entries) {
26
+ const dir = path.join(TMP_DIR, '.xp-gate');
27
+ fs.mkdirSync(dir, { recursive: true });
28
+ fs.writeFileSync(
29
+ path.join(dir, 'audit.jsonl'),
30
+ entries.map(e => JSON.stringify(e)).join('\n') + '\n'
31
+ );
32
+ }
33
+
34
+ function writeHistory(entry) {
35
+ const dir = path.join(TMP_DIR, '.sprint-state', 'sprint-history');
36
+ fs.mkdirSync(dir, { recursive: true });
37
+ fs.writeFileSync(path.join(dir, `${entry.id}.json`), JSON.stringify(entry, null, 2));
38
+ }
39
+
40
+ function cleanDir() {
41
+ for (const sub of ['.xp-gate', '.sprint-state', '.quality-history.jsonl']) {
42
+ fs.rmSync(path.join(TMP_DIR, sub), { recursive: true, force: true });
43
+ }
44
+ }
45
+
46
+ describe('retro: report generation', () => {
47
+ // TDD: written RED first (G phase per design §12)
48
+ test('(a) empty data sources → skeleton report with "No data" sections', async () => {
49
+ cleanDir();
50
+
51
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
52
+ const code = await handleRetro(['--days', '7']);
53
+ const output = logSpy.mock.calls.map(c => c.join(' ')).join('\n');
54
+ logSpy.mockRestore();
55
+
56
+ expect(code).toBe(0);
57
+ expect(output).toContain('# Sprint Engineering Retrospective');
58
+ expect(output).toContain('## Activity Summary');
59
+ expect(output).toContain('## Rework Rate Trend (#369)');
60
+ expect(output).toContain('## Evidence Skip Exposure');
61
+ expect(output).toContain('## Quality Trend');
62
+ expect(output).toContain('No data');
63
+ });
64
+
65
+ // TDD: written RED first (G phase per design §12)
66
+ test('(b) rework rate > 30% → alert line present with sprint id', async () => {
67
+ cleanDir();
68
+ writeHistory({
69
+ id: 'sprint-high-rework',
70
+ task_description: 'High rework sprint',
71
+ metrics: {
72
+ completed_at: new Date(Date.now() - 2 * 86400000).toISOString(),
73
+ total_sprint_commits: 10,
74
+ rework_rate: 0.42,
75
+ },
76
+ });
77
+
78
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
79
+ const code = await handleRetro(['--days', '7']);
80
+ const output = logSpy.mock.calls.map(c => c.join(' ')).join('\n');
81
+ logSpy.mockRestore();
82
+
83
+ expect(code).toBe(0);
84
+ expect(output).toContain('⚠️');
85
+ expect(output).toContain('sprint-high-rework');
86
+ expect(output).toContain('exceeds 30% threshold');
87
+ });
88
+
89
+ // TDD: written RED first (G phase per design §12)
90
+ test('(c) skip-evidence events > 2 per sprint → alert present', async () => {
91
+ cleanDir();
92
+ const ts = new Date().toISOString();
93
+ writeAudit([
94
+ { event: 'evidence_skipped', phase: '3', reason: 'r1', timestamp: ts, sprint_id: 'sprint-skip' },
95
+ { event: 'evidence_skipped', phase: '4', reason: 'r2', timestamp: ts, sprint_id: 'sprint-skip' },
96
+ { event: 'evidence_skipped', phase: '5', reason: 'r3', timestamp: ts, sprint_id: 'sprint-skip' },
97
+ ]);
98
+
99
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
100
+ const code = await handleRetro(['--days', '7']);
101
+ const output = logSpy.mock.calls.map(c => c.join(' ')).join('\n');
102
+ logSpy.mockRestore();
103
+
104
+ expect(code).toBe(0);
105
+ expect(output).toContain('⚠️');
106
+ expect(output).toContain('sprint-skip');
107
+ expect(output).toContain('used skip 3 times');
108
+ });
109
+
110
+ // TDD: written RED first (G phase per design §12)
111
+ test('(d) duration_anomaly entries excluded from avg_ms', async () => {
112
+ cleanDir();
113
+ const ts = new Date().toISOString();
114
+ writeAudit([
115
+ { gate_id: 'gate-1', gate_name: 'g1', passed: true, issues_found: 0, duration_ms: 100, timestamp: ts, trigger: 'manual', repo_path: TMP_DIR, commit_hash: 'abc' },
116
+ { gate_id: 'gate-1', gate_name: 'g1', passed: true, issues_found: 0, duration_ms: 200, timestamp: ts, trigger: 'manual', repo_path: TMP_DIR, commit_hash: 'abc' },
117
+ { gate_id: 'gate-1', gate_name: 'g1', passed: true, issues_found: 0, duration_ms: 99999, duration_anomaly: true, timestamp: ts, trigger: 'manual', repo_path: TMP_DIR, commit_hash: 'abc' },
118
+ ]);
119
+
120
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
121
+ const code = await handleRetro(['--days', '7']);
122
+ const output = logSpy.mock.calls.map(c => c.join(' ')).join('\n');
123
+ logSpy.mockRestore();
124
+
125
+ expect(code).toBe(0);
126
+ // avg_ms = (100+200)/2 = 150 (anomaly excluded)
127
+ expect(output).toContain('150ms');
128
+ // If anomaly were included: (100+200+99999)/3 ≈ 33433ms
129
+ expect(output).not.toContain('33433ms');
130
+ });
131
+ });