@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.
- package/bin/xp-gate.js +29 -1
- package/hooks/lib/now-ms.sh +33 -0
- package/hooks/pre-commit +63 -47
- package/hooks/sprint-gate.sh +291 -0
- package/lib/__tests__/check-version.test.js +122 -0
- package/lib/__tests__/doctor.test.js +13 -15
- package/lib/__tests__/gate-audit.test.ts +38 -0
- package/lib/__tests__/phase-transition.test.js +241 -1
- package/lib/__tests__/retro.test.js +131 -0
- package/lib/__tests__/sprint-status-rework.test.js +184 -0
- package/lib/check-version.js +9 -0
- package/lib/doctor-tui.js +79 -9
- package/lib/doctor.js +15 -3
- package/lib/gate-audit.ts +18 -7
- package/lib/gate-runner.js +77 -30
- package/lib/init.js +10 -0
- package/lib/mattpocock-license-verdict.json +7 -0
- package/lib/phase-transition.js +251 -1
- package/lib/retro.js +222 -0
- package/lib/shared-utils.js +7 -0
- package/lib/sprint-init.js +207 -0
- package/lib/sprint-state-migrator.js +1 -0
- package/lib/sprint-status.js +111 -0
- package/mock-policy/AGENTS.md +3 -3
- package/mutation/AGENTS.md +3 -3
- package/package.json +3 -2
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/batch-grill-me/SKILL.md +15 -0
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/claude-code/skills/delphi-review/SKILL.md +59 -4
- package/plugins/claude-code/skills/delphi-review/references/requirements.md +390 -0
- package/plugins/claude-code/skills/delphi-review/references/round-templates.md +67 -0
- package/plugins/claude-code/skills/domain-modeling/ADR-FORMAT.md +64 -0
- package/plugins/claude-code/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
- package/plugins/claude-code/skills/domain-modeling/SKILL.md +74 -0
- package/plugins/claude-code/skills/grill-with-docs/SKILL.md +7 -0
- package/plugins/claude-code/skills/grilling/SKILL.md +12 -0
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/claude-code/skills/sprint-flow/SKILL.md +168 -43
- package/plugins/claude-code/skills/sprint-flow/references/components/memory.md +1 -1
- package/plugins/claude-code/skills/sprint-flow/references/components/middleware.md +1 -1
- package/plugins/claude-code/skills/sprint-flow/references/components/skill-invocations.md +21 -20
- package/plugins/claude-code/skills/sprint-flow/references/components/tool-descriptions.md +22 -23
- package/plugins/claude-code/skills/sprint-flow/references/orchestration-rules.md +14 -12
- package/plugins/claude-code/skills/sprint-flow/references/phase-2-design.md +145 -133
- package/plugins/claude-code/skills/sprint-flow/references/phase-3-build.md +69 -2
- package/plugins/claude-code/skills/sprint-flow/references/phase-4-verify.md +93 -31
- package/plugins/claude-code/skills/sprint-flow/references/phase-5-ship.md +99 -14
- package/plugins/claude-code/skills/sprint-flow/references/phase-6-close.md +37 -5
- package/plugins/claude-code/skills/sprint-flow/references/phase-overview.md +58 -53
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/claude-code/skills/test-specification-alignment/SKILL.md +8 -3
- package/plugins/claude-code/skills/to-issues/SKILL.md +1 -1
- package/plugins/opencode/index.ts +1 -0
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/batch-grill-me/SKILL.md +15 -0
- package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/opencode/skills/delphi-review/SKILL.md +59 -4
- package/plugins/opencode/skills/delphi-review/references/requirements.md +390 -0
- package/plugins/opencode/skills/delphi-review/references/round-templates.md +67 -0
- package/plugins/opencode/skills/domain-modeling/ADR-FORMAT.md +64 -0
- package/plugins/opencode/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
- package/plugins/opencode/skills/domain-modeling/SKILL.md +74 -0
- package/plugins/opencode/skills/grill-with-docs/SKILL.md +7 -0
- package/plugins/opencode/skills/grilling/SKILL.md +12 -0
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/opencode/skills/sprint-flow/SKILL.md +168 -43
- package/plugins/opencode/skills/sprint-flow/references/components/memory.md +1 -1
- package/plugins/opencode/skills/sprint-flow/references/components/middleware.md +1 -1
- package/plugins/opencode/skills/sprint-flow/references/components/skill-invocations.md +21 -20
- package/plugins/opencode/skills/sprint-flow/references/components/tool-descriptions.md +22 -23
- package/plugins/opencode/skills/sprint-flow/references/orchestration-rules.md +14 -12
- package/plugins/opencode/skills/sprint-flow/references/phase-2-design.md +145 -133
- package/plugins/opencode/skills/sprint-flow/references/phase-3-build.md +69 -2
- package/plugins/opencode/skills/sprint-flow/references/phase-4-verify.md +93 -31
- package/plugins/opencode/skills/sprint-flow/references/phase-5-ship.md +99 -14
- package/plugins/opencode/skills/sprint-flow/references/phase-6-close.md +37 -5
- package/plugins/opencode/skills/sprint-flow/references/phase-overview.md +58 -53
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/opencode/skills/test-specification-alignment/SKILL.md +8 -3
- package/plugins/opencode/skills/to-issues/SKILL.md +1 -1
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
- package/principles/AGENTS.md +3 -3
- package/scripts/__tests__/check-mattpocock-license.test.js +229 -0
- package/scripts/__tests__/sync-package-content.test.js +50 -0
- package/scripts/check-mattpocock-license.cjs +302 -0
- package/scripts/migrate-audit-anomalies.mjs +78 -0
- package/scripts/sync-package-content.js +52 -5
- package/skills/batch-grill-me/SKILL.md +15 -0
- package/skills/delphi-review/AGENTS.md +3 -3
- package/skills/delphi-review/SKILL.md +59 -4
- package/skills/delphi-review/references/requirements.md +390 -0
- package/skills/delphi-review/references/round-templates.md +67 -0
- package/skills/domain-modeling/ADR-FORMAT.md +64 -0
- package/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
- package/skills/domain-modeling/SKILL.md +74 -0
- package/skills/grill-with-docs/SKILL.md +7 -0
- package/skills/grilling/SKILL.md +12 -0
- package/skills/sprint-flow/AGENTS.md +3 -3
- package/skills/sprint-flow/SKILL.md +168 -43
- package/skills/sprint-flow/references/components/memory.md +1 -1
- package/skills/sprint-flow/references/components/middleware.md +1 -1
- package/skills/sprint-flow/references/components/skill-invocations.md +21 -20
- package/skills/sprint-flow/references/components/tool-descriptions.md +22 -23
- package/skills/sprint-flow/references/orchestration-rules.md +14 -12
- package/skills/sprint-flow/references/phase-2-design.md +145 -133
- package/skills/sprint-flow/references/phase-3-build.md +69 -2
- package/skills/sprint-flow/references/phase-4-verify.md +93 -31
- package/skills/sprint-flow/references/phase-5-ship.md +99 -14
- package/skills/sprint-flow/references/phase-6-close.md +37 -5
- package/skills/sprint-flow/references/phase-overview.md +58 -53
- package/skills/test-specification-alignment/AGENTS.md +3 -3
- package/skills/test-specification-alignment/SKILL.md +8 -3
- package/skills/to-issues/SKILL.md +1 -1
package/lib/phase-transition.js
CHANGED
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
|
|
25
25
|
const path = require('path');
|
|
26
26
|
const fs = require('fs');
|
|
27
|
+
const crypto = require('crypto');
|
|
28
|
+
const { execSync } = require('child_process');
|
|
27
29
|
const { SprintStateManager } = require('./sprint-state-manager');
|
|
28
30
|
|
|
29
31
|
/**
|
|
@@ -35,6 +37,214 @@ const PHASE_NAMES = {
|
|
|
35
37
|
4: 'VERIFY', 5: 'SHIP', 6: 'CLOSE',
|
|
36
38
|
};
|
|
37
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Phase-specific evidence file requirements.
|
|
42
|
+
* Each entry defines the evidence file path, required fields, and a blocking check.
|
|
43
|
+
* Phases NOT in this map require no evidence validation in v0.17.1.
|
|
44
|
+
*/
|
|
45
|
+
const EVIDENCE_FILES = {
|
|
46
|
+
2: {
|
|
47
|
+
path: '.sprint-state/phase-outputs/requirements-reviewed.json',
|
|
48
|
+
requiredFields: ['verdict', 'requirements_hash'],
|
|
49
|
+
blockingCheck: (data) => data.verdict === 'APPROVED',
|
|
50
|
+
blockingMessage: 'Requirements review verdict is not APPROVED',
|
|
51
|
+
},
|
|
52
|
+
4: {
|
|
53
|
+
path: '.sprint-state/phase-outputs/test-alignment-report.json',
|
|
54
|
+
requiredFields: ['alignment_status', 'head_commit', 'spec_hash'],
|
|
55
|
+
blockingCheck: (data) => data.alignment_status === 'PASS',
|
|
56
|
+
blockingMessage: 'Test alignment status is not PASS',
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Get current HEAD commit hash from a project directory.
|
|
62
|
+
* Returns 'unknown' if not in a git repo.
|
|
63
|
+
* @param {string} projectDir
|
|
64
|
+
* @returns {string}
|
|
65
|
+
*/
|
|
66
|
+
function getCurrentHeadCommit(projectDir) {
|
|
67
|
+
try {
|
|
68
|
+
return execSync('git rev-parse HEAD', {
|
|
69
|
+
cwd: projectDir,
|
|
70
|
+
encoding: 'utf8',
|
|
71
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
72
|
+
}).trim();
|
|
73
|
+
} catch {
|
|
74
|
+
return 'unknown';
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Compute SHA-256 hash of a file's contents.
|
|
80
|
+
* @param {string} filePath
|
|
81
|
+
* @returns {string} hex digest
|
|
82
|
+
*/
|
|
83
|
+
function computeFileHash(filePath) {
|
|
84
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
85
|
+
return crypto.createHash('sha256').update(content, 'utf8').digest('hex');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Validate phase evidence files for a given phase transition.
|
|
90
|
+
*
|
|
91
|
+
* Returns { ok: boolean, errors: string[], warnings: string[] }.
|
|
92
|
+
* - For phases not in EVIDENCE_FILES: ok=true, no errors.
|
|
93
|
+
* - For phases with evidence requirements:
|
|
94
|
+
* - If evidence_schema_version >= 2 (new sprint): missing/invalid evidence → ok=false (BLOCK)
|
|
95
|
+
* - If evidence_schema_version missing or < 2 (legacy sprint): missing/invalid → ok=true with WARNING
|
|
96
|
+
*
|
|
97
|
+
* @param {number} phase - Phase number (1-6)
|
|
98
|
+
* @param {string} projectDir - Project root directory
|
|
99
|
+
* @returns {{ ok: boolean, errors: string[], warnings: string[] }}
|
|
100
|
+
*/
|
|
101
|
+
function validateEvidence(phase, projectDir) {
|
|
102
|
+
const evidenceConfig = EVIDENCE_FILES[phase];
|
|
103
|
+
if (!evidenceConfig) {
|
|
104
|
+
return { ok: true, errors: [], warnings: [] };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Read sprint state to determine evidence_schema_version
|
|
108
|
+
let evidenceSchemaVersion = 0;
|
|
109
|
+
try {
|
|
110
|
+
const manager = new SprintStateManager(projectDir);
|
|
111
|
+
const state = manager.read();
|
|
112
|
+
if (state && typeof state.evidence_schema_version === 'number') {
|
|
113
|
+
evidenceSchemaVersion = state.evidence_schema_version;
|
|
114
|
+
}
|
|
115
|
+
} catch {
|
|
116
|
+
// If state can't be read, treat as legacy (version 0)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const isLegacySprint = evidenceSchemaVersion < 2;
|
|
120
|
+
const errors = [];
|
|
121
|
+
const warnings = [];
|
|
122
|
+
|
|
123
|
+
const evidenceFilePath = path.join(projectDir, evidenceConfig.path);
|
|
124
|
+
const fileName = path.basename(evidenceConfig.path);
|
|
125
|
+
|
|
126
|
+
// Check file existence
|
|
127
|
+
if (!fs.existsSync(evidenceFilePath)) {
|
|
128
|
+
const msg = `Evidence file missing: ${evidenceConfig.path}`;
|
|
129
|
+
if (isLegacySprint) {
|
|
130
|
+
warnings.push(`WARNING: ${msg}. Upgrade sprint with evidence_schema_version >= 2 to enforce.`);
|
|
131
|
+
return { ok: true, errors: [], warnings };
|
|
132
|
+
}
|
|
133
|
+
errors.push(msg);
|
|
134
|
+
return { ok: false, errors, warnings };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Parse JSON
|
|
138
|
+
let data;
|
|
139
|
+
try {
|
|
140
|
+
const raw = fs.readFileSync(evidenceFilePath, 'utf8');
|
|
141
|
+
data = JSON.parse(raw);
|
|
142
|
+
} catch {
|
|
143
|
+
const msg = `${fileName} is malformed JSON`;
|
|
144
|
+
if (isLegacySprint) {
|
|
145
|
+
warnings.push(`WARNING: ${msg}. Upgrade sprint with evidence_schema_version >= 2 to enforce.`);
|
|
146
|
+
return { ok: true, errors: [], warnings };
|
|
147
|
+
}
|
|
148
|
+
errors.push(msg);
|
|
149
|
+
return { ok: false, errors, warnings };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Check required fields
|
|
153
|
+
for (const field of evidenceConfig.requiredFields) {
|
|
154
|
+
if (!(field in data)) {
|
|
155
|
+
const msg = `${fileName} is missing required field: ${field}`;
|
|
156
|
+
if (isLegacySprint) {
|
|
157
|
+
warnings.push(`WARNING: ${msg}. Upgrade sprint with evidence_schema_version >= 2 to enforce.`);
|
|
158
|
+
} else {
|
|
159
|
+
errors.push(msg);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// If any required fields missing for new sprint, block early
|
|
165
|
+
if (errors.length > 0) {
|
|
166
|
+
return { ok: false, errors, warnings };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Run blocking check
|
|
170
|
+
if (!evidenceConfig.blockingCheck(data)) {
|
|
171
|
+
const msg = `${fileName}: ${evidenceConfig.blockingMessage} (got: ${JSON.stringify(data[evidenceConfig.requiredFields[0]])})`;
|
|
172
|
+
if (isLegacySprint) {
|
|
173
|
+
warnings.push(`WARNING: ${msg}. Upgrade sprint with evidence_schema_version >= 2 to enforce.`);
|
|
174
|
+
return { ok: true, errors: [], warnings };
|
|
175
|
+
}
|
|
176
|
+
errors.push(msg);
|
|
177
|
+
return { ok: false, errors, warnings };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Phase-specific anti-staleness checks
|
|
181
|
+
if (phase === 4) {
|
|
182
|
+
// head_commit check
|
|
183
|
+
const currentHead = getCurrentHeadCommit(projectDir);
|
|
184
|
+
if (data.head_commit !== currentHead) {
|
|
185
|
+
const msg = `${fileName}: head_commit mismatch — report has "${data.head_commit}", current HEAD is "${currentHead}"`;
|
|
186
|
+
if (isLegacySprint) {
|
|
187
|
+
warnings.push(`WARNING: ${msg}. Upgrade sprint with evidence_schema_version >= 2 to enforce.`);
|
|
188
|
+
} else {
|
|
189
|
+
errors.push(msg);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// spec_hash check (only if specification.yaml exists)
|
|
194
|
+
const specPath = path.join(projectDir, 'specification.yaml');
|
|
195
|
+
if (fs.existsSync(specPath)) {
|
|
196
|
+
const expectedHash = computeFileHash(specPath);
|
|
197
|
+
if (data.spec_hash !== expectedHash) {
|
|
198
|
+
const msg = `${fileName}: spec_hash mismatch — specification.yaml has changed since report was generated`;
|
|
199
|
+
if (isLegacySprint) {
|
|
200
|
+
warnings.push(`WARNING: ${msg}. Upgrade sprint with evidence_schema_version >= 2 to enforce.`);
|
|
201
|
+
} else {
|
|
202
|
+
errors.push(msg);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Phase 2: requirements_hash existence check (full verification in v0.18.0)
|
|
209
|
+
if (phase === 2) {
|
|
210
|
+
if (typeof data.requirements_hash !== 'string' || data.requirements_hash.length === 0) {
|
|
211
|
+
const msg = `${fileName}: requirements_hash must be a non-empty string`;
|
|
212
|
+
if (isLegacySprint) {
|
|
213
|
+
warnings.push(`WARNING: ${msg}. Upgrade sprint with evidence_schema_version >= 2 to enforce.`);
|
|
214
|
+
} else {
|
|
215
|
+
errors.push(msg);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (errors.length > 0) {
|
|
221
|
+
return { ok: false, errors, warnings };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return { ok: true, errors: [], warnings };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Append an evidence-skipped audit entry to .xp-gate/audit.jsonl.
|
|
229
|
+
* @param {string} projectDir
|
|
230
|
+
* @param {number} phase
|
|
231
|
+
* @param {string} reason
|
|
232
|
+
*/
|
|
233
|
+
function logEvidenceSkip(projectDir, phase, reason) {
|
|
234
|
+
const auditDir = path.join(projectDir, '.xp-gate');
|
|
235
|
+
if (!fs.existsSync(auditDir)) {
|
|
236
|
+
fs.mkdirSync(auditDir, { recursive: true });
|
|
237
|
+
}
|
|
238
|
+
const entry = {
|
|
239
|
+
timestamp: new Date().toISOString(),
|
|
240
|
+
event: 'evidence_skipped',
|
|
241
|
+
phase,
|
|
242
|
+
reason,
|
|
243
|
+
commit_hash: getCurrentHeadCommit(projectDir),
|
|
244
|
+
};
|
|
245
|
+
fs.appendFileSync(path.join(auditDir, 'audit.jsonl'), JSON.stringify(entry) + '\n', 'utf8');
|
|
246
|
+
}
|
|
247
|
+
|
|
38
248
|
/**
|
|
39
249
|
* Render ASCII dashboard from sprint state.
|
|
40
250
|
* Reuses the formatSprintTable logic from sprint-status.
|
|
@@ -152,11 +362,13 @@ async function handlePhaseTransition(args = []) {
|
|
|
152
362
|
console.log(' --outputs <json> JSON object of outputs to record');
|
|
153
363
|
console.log(' --render Render ASCII dashboard after transition');
|
|
154
364
|
console.log(' --dir <path> Project directory (default: cwd)');
|
|
365
|
+
console.log(' --skip-evidence <reason> Skip evidence validation (requires reason)');
|
|
155
366
|
console.log('');
|
|
156
367
|
console.log('Examples:');
|
|
157
368
|
console.log(' xp-gate phase-transition 1 completed');
|
|
158
369
|
console.log(' xp-gate phase-transition 3 completed --render');
|
|
159
370
|
console.log(' xp-gate phase-transition 2 in_progress --outputs \'{"spec":"path.yaml"}\'');
|
|
371
|
+
console.log(' xp-gate phase-transition 4 completed --skip-evidence "Emergency hotfix"');
|
|
160
372
|
return 0;
|
|
161
373
|
}
|
|
162
374
|
|
|
@@ -182,6 +394,19 @@ async function handlePhaseTransition(args = []) {
|
|
|
182
394
|
}
|
|
183
395
|
}
|
|
184
396
|
|
|
397
|
+
// Parse --skip-evidence flag
|
|
398
|
+
const skipEvidenceIdx = args.indexOf('--skip-evidence');
|
|
399
|
+
let skipEvidence = false;
|
|
400
|
+
let skipEvidenceReason = '';
|
|
401
|
+
if (skipEvidenceIdx >= 0) {
|
|
402
|
+
skipEvidence = true;
|
|
403
|
+
const nextArg = args[skipEvidenceIdx + 1];
|
|
404
|
+
// Reason is the next arg if it exists and doesn't start with '--'
|
|
405
|
+
if (nextArg && !nextArg.startsWith('--')) {
|
|
406
|
+
skipEvidenceReason = nextArg;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
185
410
|
// Validate inputs
|
|
186
411
|
if (phase < 1 || phase > 6) {
|
|
187
412
|
console.error(`Error: Phase must be 1-6, got ${phase}`);
|
|
@@ -221,6 +446,31 @@ async function handlePhaseTransition(args = []) {
|
|
|
221
446
|
// If no sprint-state.json exists yet, skip check (no previous phase to validate)
|
|
222
447
|
}
|
|
223
448
|
|
|
449
|
+
// ── Layer 2: Evidence validation (blocks on status === 'completed') ──
|
|
450
|
+
if (status === 'completed' && EVIDENCE_FILES[phase]) {
|
|
451
|
+
if (skipEvidence) {
|
|
452
|
+
if (!skipEvidenceReason) {
|
|
453
|
+
console.error('Error: --skip-evidence requires --reason "<text>" to be provided');
|
|
454
|
+
return 1;
|
|
455
|
+
}
|
|
456
|
+
logEvidenceSkip(projectDir, phase, skipEvidenceReason);
|
|
457
|
+
console.warn('⚠️ Evidence skipped. This will appear in retro reports. Single-sprint limit: >2 skips per sprint will trigger an alert.');
|
|
458
|
+
} else {
|
|
459
|
+
const evidence = validateEvidence(phase, projectDir);
|
|
460
|
+
for (const w of evidence.warnings) {
|
|
461
|
+
console.warn(`⚠️ [sprint-audit] ${w}`);
|
|
462
|
+
}
|
|
463
|
+
if (!evidence.ok) {
|
|
464
|
+
for (const e of evidence.errors) {
|
|
465
|
+
console.error(`❌ [sprint-audit] ${e}`);
|
|
466
|
+
}
|
|
467
|
+
console.error(` Phase ${phase} (${PHASE_NAMES[phase]}) cannot transition to 'completed' without valid evidence.`);
|
|
468
|
+
console.error(` Use --skip-evidence "<reason>" to bypass (audited).`);
|
|
469
|
+
return 1;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
224
474
|
const newState = manager.transitionPhase(phase, status, { outputs });
|
|
225
475
|
|
|
226
476
|
console.log(`✅ Phase ${phase} transitioned to '${status}'`);
|
|
@@ -246,4 +496,4 @@ async function handlePhaseTransition(args = []) {
|
|
|
246
496
|
}
|
|
247
497
|
}
|
|
248
498
|
|
|
249
|
-
module.exports = { handlePhaseTransition, renderDashboard, PHASE_NAMES };
|
|
499
|
+
module.exports = { handlePhaseTransition, renderDashboard, PHASE_NAMES, validateEvidence };
|
package/lib/retro.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* xp-gate retro — Sprint engineering retrospective CLI (#369, design §8.1)
|
|
3
|
+
*
|
|
4
|
+
* Reads git log + .xp-gate/audit.jsonl + .quality-history.jsonl + .sprint-history/
|
|
5
|
+
* and generates a Markdown retrospective report with rework rate trend (#369)
|
|
6
|
+
* and --skip-evidence exposure (§8.4) sections.
|
|
7
|
+
*
|
|
8
|
+
* Zero npm dependencies (Node built-ins only).
|
|
9
|
+
* @module retro
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const { execSync } = require('child_process');
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Get git commit subjects within a date window.
|
|
20
|
+
* @param {string} dir - Repository root
|
|
21
|
+
* @param {number} days - Lookback window in days
|
|
22
|
+
* @returns {Array<{hash: string, date: string, subject: string}>}
|
|
23
|
+
*/
|
|
24
|
+
function getGitCommits(dir, days) {
|
|
25
|
+
try {
|
|
26
|
+
const out = execSync(
|
|
27
|
+
`git log --since="${days} days ago" --pretty=format:"%H|%ai|%s"`,
|
|
28
|
+
{ cwd: dir, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
29
|
+
);
|
|
30
|
+
return out.split('\n').filter(Boolean).map(l => {
|
|
31
|
+
const [hash, date, ...rest] = l.split('|');
|
|
32
|
+
return { hash, date, subject: rest.join('|') };
|
|
33
|
+
});
|
|
34
|
+
} catch {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Parse .xp-gate/audit.jsonl entries.
|
|
41
|
+
* @param {string} dir - Repository root
|
|
42
|
+
* @returns {object[]} Parsed audit entries
|
|
43
|
+
*/
|
|
44
|
+
function readAuditEntries(dir) {
|
|
45
|
+
const p = path.join(dir, '.xp-gate', 'audit.jsonl');
|
|
46
|
+
if (!fs.existsSync(p)) return [];
|
|
47
|
+
return fs.readFileSync(p, 'utf8').split('\n')
|
|
48
|
+
.filter(l => l.trim())
|
|
49
|
+
.map(l => { try { return JSON.parse(l); } catch { return null; } })
|
|
50
|
+
.filter(Boolean);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Read last 10 entries from .quality-history.jsonl.
|
|
55
|
+
* @param {string} dir - Repository root
|
|
56
|
+
* @returns {object[]} Recent quality entries
|
|
57
|
+
*/
|
|
58
|
+
function readQualityHistory(dir) {
|
|
59
|
+
const p = path.join(dir, '.quality-history.jsonl');
|
|
60
|
+
if (!fs.existsSync(p)) return [];
|
|
61
|
+
const entries = fs.readFileSync(p, 'utf8').split('\n')
|
|
62
|
+
.filter(l => l.trim())
|
|
63
|
+
.map(l => { try { return JSON.parse(l); } catch { return null; } })
|
|
64
|
+
.filter(Boolean);
|
|
65
|
+
return entries.slice(-10);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Read sprint history JSON files from .sprint-state/sprint-history/.
|
|
70
|
+
* @param {string} dir - Repository root
|
|
71
|
+
* @param {string|null} sprintFilter - Optional sprint ID filter
|
|
72
|
+
* @returns {object[]} Sprint state objects
|
|
73
|
+
*/
|
|
74
|
+
function readSprintHistory(dir, sprintFilter) {
|
|
75
|
+
const histDir = path.join(dir, '.sprint-state', 'sprint-history');
|
|
76
|
+
const results = [];
|
|
77
|
+
if (fs.existsSync(histDir)) {
|
|
78
|
+
for (const f of fs.readdirSync(histDir).filter(f => f.endsWith('.json'))) {
|
|
79
|
+
try {
|
|
80
|
+
const s = JSON.parse(fs.readFileSync(path.join(histDir, f), 'utf8'));
|
|
81
|
+
if (!sprintFilter || s.id === sprintFilter) results.push(s);
|
|
82
|
+
} catch { /* skip */ }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return results;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Build the Markdown retrospective report.
|
|
90
|
+
* @param {object} opts - Report data
|
|
91
|
+
* @returns {string} Markdown report
|
|
92
|
+
*/
|
|
93
|
+
function buildReport(opts) {
|
|
94
|
+
const { days, commits, audit, quality, sprints } = opts;
|
|
95
|
+
const now = new Date();
|
|
96
|
+
const start = new Date(now.getTime() - days * 86400000);
|
|
97
|
+
const L = [];
|
|
98
|
+
|
|
99
|
+
L.push('# Sprint Engineering Retrospective');
|
|
100
|
+
L.push('');
|
|
101
|
+
L.push(`Period: ${start.toISOString().slice(0, 10)} to ${now.toISOString().slice(0, 10)}`);
|
|
102
|
+
L.push('');
|
|
103
|
+
|
|
104
|
+
// Activity Summary
|
|
105
|
+
L.push('## Activity Summary');
|
|
106
|
+
const gateEntries = audit.filter(e => e.gate_id);
|
|
107
|
+
const passed = gateEntries.filter(e => e.passed).length;
|
|
108
|
+
const normal = gateEntries.filter(e => !e.duration_anomaly);
|
|
109
|
+
const anomalous = gateEntries.length - normal.length;
|
|
110
|
+
const avgMs = normal.length > 0
|
|
111
|
+
? Math.round(normal.reduce((s, e) => s + (e.duration_ms || 0), 0) / normal.length)
|
|
112
|
+
: 0;
|
|
113
|
+
L.push(`- Total commits in window: ${commits.length}`);
|
|
114
|
+
L.push(`- Sprint transitions recorded: ${sprints.length}`);
|
|
115
|
+
L.push(`- Gate pass rate: ${gateEntries.length > 0 ? ((passed / gateEntries.length) * 100).toFixed(1) : '0.0'}% (${gateEntries.length} gates total, ${gateEntries.length - passed} failed)`);
|
|
116
|
+
if (gateEntries.length > 0) {
|
|
117
|
+
L.push(`- Avg gate duration: ${avgMs}ms (${anomalous} anomal${anomalous === 1 ? 'y' : 'ies'} excluded)`);
|
|
118
|
+
}
|
|
119
|
+
L.push('');
|
|
120
|
+
|
|
121
|
+
// Rework Rate Trend (#369)
|
|
122
|
+
L.push('## Rework Rate Trend (#369)');
|
|
123
|
+
const withRate = sprints.filter(s => s.metrics && s.metrics.rework_rate != null);
|
|
124
|
+
if (withRate.length === 0) {
|
|
125
|
+
L.push('No data');
|
|
126
|
+
} else {
|
|
127
|
+
L.push('| Sprint | Rework Rate | Fix Commits | Total Commits |');
|
|
128
|
+
L.push('|--------|-------------|-------------|---------------|');
|
|
129
|
+
for (const s of withRate) {
|
|
130
|
+
const pct = (s.metrics.rework_rate * 100).toFixed(1);
|
|
131
|
+
const fix = s.metrics.total_sprint_commits != null
|
|
132
|
+
? Math.round(s.metrics.rework_rate * s.metrics.total_sprint_commits)
|
|
133
|
+
: '?';
|
|
134
|
+
L.push(`| ${s.id || 'unknown'} | ${pct}% | ${fix} | ${s.metrics.total_sprint_commits ?? '?'} |`);
|
|
135
|
+
if (s.metrics.rework_rate > 0.30) {
|
|
136
|
+
L.push(`⚠️ Sprint ${s.id || 'unknown'}: ${pct}% rework — exceeds 30% threshold`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
L.push('');
|
|
141
|
+
|
|
142
|
+
// Evidence Skip Exposure (§8.4)
|
|
143
|
+
L.push('## Evidence Skip Exposure (§8.4)');
|
|
144
|
+
const skipped = audit.filter(e => e.event === 'evidence_skipped');
|
|
145
|
+
if (skipped.length === 0) {
|
|
146
|
+
L.push('No data');
|
|
147
|
+
} else {
|
|
148
|
+
L.push('| Phase | Reason | Timestamp |');
|
|
149
|
+
L.push('|-------|--------|-----------|');
|
|
150
|
+
for (const e of skipped) {
|
|
151
|
+
L.push(`| ${e.phase || '?'} | ${e.reason || '?'} | ${(e.timestamp || '').slice(0, 19)} |`);
|
|
152
|
+
}
|
|
153
|
+
const bySprint = {};
|
|
154
|
+
for (const e of skipped) {
|
|
155
|
+
const k = e.sprint_id || 'unknown';
|
|
156
|
+
bySprint[k] = (bySprint[k] || 0) + 1;
|
|
157
|
+
}
|
|
158
|
+
for (const [sid, count] of Object.entries(bySprint)) {
|
|
159
|
+
if (count > 2) {
|
|
160
|
+
L.push(`⚠️ Sprint ${sid} used skip ${count} times`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
L.push('');
|
|
165
|
+
|
|
166
|
+
// Quality Trend
|
|
167
|
+
L.push('## Quality Trend');
|
|
168
|
+
if (quality.length === 0) {
|
|
169
|
+
L.push('No data');
|
|
170
|
+
} else {
|
|
171
|
+
for (const q of quality) {
|
|
172
|
+
L.push(`- ${(q.timestamp || '').slice(0, 10)}: score ${q.score ?? '?'}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return L.join('\n');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* CLI entry point for xp-gate retro.
|
|
181
|
+
* @param {string[]} args - CLI subargs (without 'retro')
|
|
182
|
+
* @returns {Promise<number>} Exit code
|
|
183
|
+
*/
|
|
184
|
+
async function handleRetro(args = []) {
|
|
185
|
+
const allFlag = args.includes('--all');
|
|
186
|
+
const jsonFlag = args.includes('--json');
|
|
187
|
+
const daysIdx = args.indexOf('--days');
|
|
188
|
+
const sprintIdx = args.indexOf('--sprint');
|
|
189
|
+
|
|
190
|
+
let days = allFlag ? 30 : 7;
|
|
191
|
+
if (daysIdx >= 0 && args[daysIdx + 1]) {
|
|
192
|
+
days = parseInt(args[daysIdx + 1], 10);
|
|
193
|
+
if (isNaN(days) || days < 1) days = 7;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const sprintFilter = (sprintIdx >= 0 && args[sprintIdx + 1]) ? args[sprintIdx + 1] : null;
|
|
197
|
+
const dir = process.cwd();
|
|
198
|
+
|
|
199
|
+
const commits = getGitCommits(dir, days);
|
|
200
|
+
const audit = readAuditEntries(dir);
|
|
201
|
+
const quality = readQualityHistory(dir);
|
|
202
|
+
const sprints = readSprintHistory(dir, sprintFilter);
|
|
203
|
+
|
|
204
|
+
if (jsonFlag) {
|
|
205
|
+
const gateEntries = audit.filter(e => e.gate_id);
|
|
206
|
+
const passed = gateEntries.filter(e => e.passed).length;
|
|
207
|
+
console.log(JSON.stringify({
|
|
208
|
+
period: { days, from: new Date(Date.now() - days * 86400000).toISOString(), to: new Date().toISOString() },
|
|
209
|
+
total_commits: commits.length,
|
|
210
|
+
gate_pass_rate: gateEntries.length > 0 ? passed / gateEntries.length : null,
|
|
211
|
+
rework_rates: sprints.filter(s => s.metrics?.rework_rate != null).map(s => ({ id: s.id, rate: s.metrics.rework_rate })),
|
|
212
|
+
evidence_skipped: audit.filter(e => e.event === 'evidence_skipped'),
|
|
213
|
+
quality: quality.map(q => ({ timestamp: q.timestamp, score: q.score })),
|
|
214
|
+
}, null, 2));
|
|
215
|
+
return 0;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
console.log(buildReport({ days, commits, audit, quality, sprints }));
|
|
219
|
+
return 0;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
module.exports = { handleRetro };
|
package/lib/shared-utils.js
CHANGED
|
@@ -42,6 +42,13 @@ function copyHooks(srcDir, destDir) {
|
|
|
42
42
|
fs.chmodSync(dest, 0o755);
|
|
43
43
|
}
|
|
44
44
|
});
|
|
45
|
+
|
|
46
|
+
// Copy lib/ directory (e.g. now-ms.sh) — sourced by pre-commit at runtime
|
|
47
|
+
const libSrc = path.join(srcDir, 'hooks', 'lib');
|
|
48
|
+
const libDest = path.join(destDir, 'lib');
|
|
49
|
+
if (fs.existsSync(libSrc)) {
|
|
50
|
+
copyDirRecursive(libSrc, libDest);
|
|
51
|
+
}
|
|
45
52
|
}
|
|
46
53
|
|
|
47
54
|
function copyAdapters(srcDir, destDir) {
|