@boyingliu01/xp-gate 0.24.0 → 0.25.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/adapter-common.sh +51 -2
- package/adapters/python.sh +10 -2
- package/hooks/adapter-common.sh +51 -2
- package/hooks/lib/coverage-total.sh +20 -0
- package/hooks/lib/python-test-selection.sh +132 -0
- package/hooks/pre-commit +100 -11
- package/lib/__tests__/init-gitleaks-notice.test.js +57 -0
- package/lib/__tests__/issue-519-phase-transition-guard.test.js +233 -0
- package/lib/init.js +46 -1
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/phase-transition.js +61 -0
- package/lib/sprint-state-manager.js +86 -39
- package/lib/sprint-state-migrator.js +60 -22
- package/mock-policy/AGENTS.md +2 -2
- package/mutation/AGENTS.md +2 -2
- package/mutation/__tests__/gremlins-runner.test.ts +167 -0
- package/mutation/__tests__/mutmut-runner.test.ts +20 -22
- package/mutation/__tests__/runners-index.test.ts +31 -3
- package/mutation/gate-m.ts +22 -4
- package/mutation/runners/gremlins-runner.ts +238 -0
- package/mutation/runners/index.ts +9 -1
- package/mutation/runners/mutmut-runner.ts +18 -12
- package/mutation/runners/types.ts +20 -0
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
- package/principles/AGENTS.md +2 -2
- package/skills/delphi-review/AGENTS.md +2 -2
- package/skills/sprint-flow/AGENTS.md +2 -2
- package/skills/test-specification-alignment/AGENTS.md +2 -2
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @intent #519: A real pointer-drift incident (#507) showed that a MISSED
|
|
3
|
+
* phase transition is invisible — the state file cannot distinguish
|
|
4
|
+
* "never ran" from "not yet advanced". Guards under test:
|
|
5
|
+
* (1) sequence guard: `completed` on phase n is refused unless phase
|
|
6
|
+
* n-1 is completed/skipped, with `--force` as the explicit escape;
|
|
7
|
+
* (2) before/after echo so "command didn't run" and "ran without
|
|
8
|
+
* effect" are distinguishable in the output;
|
|
9
|
+
* (3) an append-only transition audit log;
|
|
10
|
+
* (4) phase_history field normalization (`name` → `phase_name`).
|
|
11
|
+
* @covers handlePhaseTransition guard/echo, appendTransitionAudit, migrateState
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import { handlePhaseTransition } from '../phase-transition.js';
|
|
19
|
+
import { SprintStateManager } from '../sprint-state-manager.js';
|
|
20
|
+
import { migrateState } from '../sprint-state-migrator.js';
|
|
21
|
+
|
|
22
|
+
function writeState(dir, state) {
|
|
23
|
+
try {
|
|
24
|
+
fs.mkdirSync(path.join(dir, '.sprint-state'), { recursive: true });
|
|
25
|
+
fs.writeFileSync(
|
|
26
|
+
path.join(dir, '.sprint-state', 'sprint-state.json'),
|
|
27
|
+
JSON.stringify(state, null, 2),
|
|
28
|
+
);
|
|
29
|
+
} catch (err) {
|
|
30
|
+
throw new Error(`writeState failed for ${dir}: ${err.message}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function baseState(overrides = {}) {
|
|
35
|
+
return {
|
|
36
|
+
_schema_version: 1,
|
|
37
|
+
id: 'sprint-519',
|
|
38
|
+
task_description: 'test',
|
|
39
|
+
phase: 2,
|
|
40
|
+
status: 'in_progress',
|
|
41
|
+
started_at: '2026-10-14T00:00:00Z',
|
|
42
|
+
phase_history: [],
|
|
43
|
+
isolation: { worktree_path: null, branch: null },
|
|
44
|
+
...overrides,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('#519 phase-transition sequence guard', () => {
|
|
49
|
+
let dir;
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'p519-'));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('refuses `completed` on phase 3 when phase 2 is not completed/skipped', async () => {
|
|
60
|
+
writeState(dir, baseState({
|
|
61
|
+
phase: 3,
|
|
62
|
+
phase_history: [{ phase: 2, phase_name: 'DESIGN', status: 'in_progress' }],
|
|
63
|
+
}));
|
|
64
|
+
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
65
|
+
const code = await handlePhaseTransition(['3', 'completed', '--dir', dir, '--skip-evidence', 'x']);
|
|
66
|
+
const stderr = errSpy.mock.calls.map(c => c.join(' ')).join('\n');
|
|
67
|
+
errSpy.mockRestore();
|
|
68
|
+
expect(code).toBe(1);
|
|
69
|
+
expect(stderr).toContain('Cannot complete phase 3');
|
|
70
|
+
expect(stderr).toContain('phase 2');
|
|
71
|
+
expect(stderr).toContain('phase history');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('refuses the jump and names --force as the override', async () => {
|
|
75
|
+
writeState(dir, baseState({ phase: 3, phase_history: [] }));
|
|
76
|
+
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
77
|
+
await handlePhaseTransition(['5', 'completed', '--dir', dir, '--skip-evidence', 'x']);
|
|
78
|
+
const stderr = errSpy.mock.calls.map(c => c.join(' ')).join('\n');
|
|
79
|
+
errSpy.mockRestore();
|
|
80
|
+
expect(stderr).toContain('--force');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('allows `completed` when the previous phase is completed', async () => {
|
|
84
|
+
writeState(dir, baseState({
|
|
85
|
+
phase: 2,
|
|
86
|
+
phase_history: [{ phase: 2, phase_name: 'DESIGN', status: 'completed' }],
|
|
87
|
+
}));
|
|
88
|
+
const outSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
89
|
+
const code = await handlePhaseTransition(['3', 'completed', '--dir', dir, '--skip-evidence', 'x']);
|
|
90
|
+
outSpy.mockRestore();
|
|
91
|
+
expect(code).toBe(0);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('treats a skipped previous phase as valid', async () => {
|
|
95
|
+
writeState(dir, baseState({
|
|
96
|
+
phase: 2,
|
|
97
|
+
phase_history: [{ phase: 2, phase_name: 'DESIGN', status: 'skipped' }],
|
|
98
|
+
}));
|
|
99
|
+
const outSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
100
|
+
const code = await handlePhaseTransition(['3', 'completed', '--dir', dir, '--skip-evidence', 'x']);
|
|
101
|
+
outSpy.mockRestore();
|
|
102
|
+
expect(code).toBe(0);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('--force overrides the guard and warns', async () => {
|
|
106
|
+
writeState(dir, baseState({ phase: 3, phase_history: [] }));
|
|
107
|
+
const outSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
108
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
109
|
+
const code = await handlePhaseTransition(['3', 'completed', '--dir', dir, '--skip-evidence', 'x', '--force']);
|
|
110
|
+
const warned = warnSpy.mock.calls.map(c => c.join(' ')).join('\n');
|
|
111
|
+
outSpy.mockRestore();
|
|
112
|
+
warnSpy.mockRestore();
|
|
113
|
+
expect(code).toBe(0);
|
|
114
|
+
expect(warned).toContain('overridden');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('does not guard phase 1 (no predecessor)', async () => {
|
|
118
|
+
writeState(dir, baseState({ phase: 1, phase_history: [] }));
|
|
119
|
+
const outSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
120
|
+
const code = await handlePhaseTransition(['1', 'completed', '--dir', dir, '--skip-evidence', 'x']);
|
|
121
|
+
outSpy.mockRestore();
|
|
122
|
+
expect(code).toBe(0);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('does not guard non-completed statuses', async () => {
|
|
126
|
+
writeState(dir, baseState({ phase: 3, phase_history: [] }));
|
|
127
|
+
const outSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
128
|
+
const code = await handlePhaseTransition(['3', 'in_progress', '--dir', dir]);
|
|
129
|
+
outSpy.mockRestore();
|
|
130
|
+
expect(code).toBe(0);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('#519 before/after echo', () => {
|
|
135
|
+
let dir;
|
|
136
|
+
beforeEach(() => {
|
|
137
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'p519echo-'));
|
|
138
|
+
});
|
|
139
|
+
afterEach(() => {
|
|
140
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('prints before→after for the top-level phase/status and the entry', async () => {
|
|
144
|
+
// Phase 1 must be completed for the phase-2 completion to pass the
|
|
145
|
+
// sequence guard (#519) — otherwise the echo is never reached.
|
|
146
|
+
writeState(dir, baseState({
|
|
147
|
+
phase: 2,
|
|
148
|
+
status: 'in_progress',
|
|
149
|
+
phase_history: [
|
|
150
|
+
{ phase: 1, name: 'PREP', status: 'completed' },
|
|
151
|
+
{ phase: 2, phase_name: 'DESIGN', status: 'in_progress' },
|
|
152
|
+
],
|
|
153
|
+
}));
|
|
154
|
+
const outSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
155
|
+
const code = await handlePhaseTransition(['2', 'completed', '--dir', dir, '--skip-evidence', 'x']);
|
|
156
|
+
const output = outSpy.mock.calls.map(c => c.join(' ')).join('\n');
|
|
157
|
+
outSpy.mockRestore();
|
|
158
|
+
expect(code).toBe(0);
|
|
159
|
+
expect(output).toContain('before→after');
|
|
160
|
+
expect(output).toContain('in_progress → completed');
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('#519 transition audit log + field normalization', () => {
|
|
165
|
+
let dir;
|
|
166
|
+
beforeEach(() => {
|
|
167
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'p519audit-'));
|
|
168
|
+
});
|
|
169
|
+
afterEach(() => {
|
|
170
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('appends one JSON line per transition with from/to', () => {
|
|
174
|
+
writeState(dir, baseState({ phase: 2, phase_history: [] }));
|
|
175
|
+
const manager = new SprintStateManager(dir);
|
|
176
|
+
manager.transitionPhase(2, 'in_progress');
|
|
177
|
+
manager.transitionPhase(2, 'completed');
|
|
178
|
+
const auditFile = path.join(dir, '.sprint-state', 'transition-audit.jsonl');
|
|
179
|
+
expect(fs.existsSync(auditFile)).toBe(true);
|
|
180
|
+
const lines = fs.readFileSync(auditFile, 'utf8').trim().split('\n');
|
|
181
|
+
expect(lines.length).toBe(2);
|
|
182
|
+
const first = JSON.parse(lines[0]);
|
|
183
|
+
expect(first.phase).toBe(2);
|
|
184
|
+
expect(first.phase_name).toBe('DESIGN');
|
|
185
|
+
expect(first.to_status).toBe('in_progress');
|
|
186
|
+
expect(first.timestamp).toBeTruthy();
|
|
187
|
+
const second = JSON.parse(lines[1]);
|
|
188
|
+
expect(second.to_status).toBe('completed');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('audit is best-effort: a write failure does not break the transition', () => {
|
|
192
|
+
writeState(dir, baseState({ phase: 2, phase_history: [] }));
|
|
193
|
+
const manager = new SprintStateManager(dir);
|
|
194
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
195
|
+
// Break the real appendFileSync so appendTransitionAudit's OWN try/catch is
|
|
196
|
+
// exercised (mocking the method itself would bypass the production guard).
|
|
197
|
+
const appendSpy = vi.spyOn(fs, 'appendFileSync').mockImplementation(() => {
|
|
198
|
+
throw new Error('disk full');
|
|
199
|
+
});
|
|
200
|
+
expect(() => manager.transitionPhase(2, 'in_progress')).not.toThrow();
|
|
201
|
+
const warned = warnSpy.mock.calls.map(c => c.join(' ')).join('\n');
|
|
202
|
+
appendSpy.mockRestore();
|
|
203
|
+
warnSpy.mockRestore();
|
|
204
|
+
expect(warned).toContain('transition audit write failed');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('migrateState normalizes legacy `name` into `phase_name`', () => {
|
|
208
|
+
// A legacy file (no _schema_version) whose phase 1 entry carries `name`
|
|
209
|
+
// rather than `phase_name` — the exact shape #507 left behind.
|
|
210
|
+
const legacy = baseState({
|
|
211
|
+
_schema_version: undefined,
|
|
212
|
+
phase_history: [{ phase: 1, name: 'PREP', status: 'completed' }],
|
|
213
|
+
});
|
|
214
|
+
const migrated = migrateState(legacy, dir);
|
|
215
|
+
const entry = migrated.phase_history[0];
|
|
216
|
+
// The canonical field is populated (via the legacy remap and/or the
|
|
217
|
+
// name→phase_name normalization) and the legacy field is preserved.
|
|
218
|
+
expect(entry.phase_name).toBeTruthy();
|
|
219
|
+
expect(entry.name).toBe('PREP');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('migrateState backfills `name` from `phase_name` (both directions)', () => {
|
|
223
|
+
const legacy = baseState({
|
|
224
|
+
_schema_version: undefined,
|
|
225
|
+
phase_history: [{ phase: 2, phase_name: 'DESIGN', status: 'in_progress' }],
|
|
226
|
+
});
|
|
227
|
+
const migrated = migrateState(legacy, dir);
|
|
228
|
+
const entry = migrated.phase_history[0];
|
|
229
|
+
expect(entry.phase_name).toBeTruthy();
|
|
230
|
+
// `name` is backfilled so a reader consulting either field agrees.
|
|
231
|
+
expect(entry.name).toBe(entry.phase_name);
|
|
232
|
+
});
|
|
233
|
+
});
|
package/lib/init.js
CHANGED
|
@@ -271,6 +271,46 @@ function printCliToolStatus() {
|
|
|
271
271
|
}
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
+
/**
|
|
275
|
+
* Probe for gitleaks and warn when it is missing (#511).
|
|
276
|
+
*
|
|
277
|
+
* Gate 8 became fail-closed in #499: a missing gitleaks BLOCKs the commit
|
|
278
|
+
* outright (it does NOT skip, unlike the tools reported by
|
|
279
|
+
* printCliToolStatus). The user-facing installers never provisioned it, so an
|
|
280
|
+
* existing user upgrading into #499 hits a hard BLOCK on their next commit with
|
|
281
|
+
* no prior signal — this is the missing migration notice.
|
|
282
|
+
*
|
|
283
|
+
* Probe-and-prompt only: nothing is installed here (installing is the user's
|
|
284
|
+
* explicit choice, via the printed command). The fail-closed semantics of
|
|
285
|
+
* #499 are untouched.
|
|
286
|
+
*
|
|
287
|
+
* @param {(name: string) => {available: boolean, version?: string}} checkTool
|
|
288
|
+
* @param {(line: string) => void} [log]
|
|
289
|
+
* @returns {{available: boolean}} the probe result, for callers that branch on it
|
|
290
|
+
*/
|
|
291
|
+
function probeGitleaksNotice(checkTool, log = console.log) {
|
|
292
|
+
const result = checkTool('gitleaks');
|
|
293
|
+
if (result.available) {
|
|
294
|
+
log(`✓ gitleaks present${result.version ? `: ${result.version}` : ''}`);
|
|
295
|
+
return { available: true };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
log('');
|
|
299
|
+
log('⚠ gitleaks is NOT installed.');
|
|
300
|
+
log(' Gate 8 (secret scanning) is FAIL-CLOSED since #499: your next commit');
|
|
301
|
+
log(' will be BLOCKED until gitleaks is available.');
|
|
302
|
+
log('');
|
|
303
|
+
log(' Install it now:');
|
|
304
|
+
log(' bash scripts/install-gitleaks.sh');
|
|
305
|
+
log('');
|
|
306
|
+
log(' Per-OS alternatives:');
|
|
307
|
+
log(' macOS: brew install gitleaks');
|
|
308
|
+
log(' Linux: https://github.com/gitleaks/gitleaks/releases');
|
|
309
|
+
log(' Windows: winget install gitleaks (or: scoop install gitleaks)');
|
|
310
|
+
log('');
|
|
311
|
+
return { available: false };
|
|
312
|
+
}
|
|
313
|
+
|
|
274
314
|
/**
|
|
275
315
|
* Ask the user for confirmation via stdin.
|
|
276
316
|
* @param {string} question - The yes/no question to display
|
|
@@ -352,6 +392,11 @@ async function init(args) {
|
|
|
352
392
|
console.log('Checking CLI tools...');
|
|
353
393
|
printCliToolStatus();
|
|
354
394
|
|
|
395
|
+
// #511: gitleaks is the one tool whose absence BLOCKS rather than SKIPs
|
|
396
|
+
// (Gate 8 fail-closed, #499) — surface it separately so an upgrading user
|
|
397
|
+
// has a migration path before the first blocked commit.
|
|
398
|
+
probeGitleaksNotice(checkCliTool);
|
|
399
|
+
|
|
355
400
|
console.log('Checking platform dependencies...');
|
|
356
401
|
// Detect platform and check/auto-install dependencies
|
|
357
402
|
const platform = detectPlatform();
|
|
@@ -925,4 +970,4 @@ function injectKarpathyPrinciples(projectRoot) {
|
|
|
925
970
|
// setupGlobal is exported so the global-install wiring (agents deployed to the
|
|
926
971
|
// real HOME) can be audited under a sandboxed HOME, not just asserted to exist
|
|
927
972
|
// — the historical miss was "local deployed, global did not" (#417, #429).
|
|
928
|
-
module.exports = { init, promptBootstrap, installModuleRuntimeDeps, deployQoderDelphiAgents, configureQoderDelphiAgents, readQoderModelId, setupGlobal };
|
|
973
|
+
module.exports = { init, promptBootstrap, installModuleRuntimeDeps, deployQoderDelphiAgents, configureQoderDelphiAgents, readQoderModelId, setupGlobal, probeGitleaksNotice };
|
package/lib/phase-transition.js
CHANGED
|
@@ -532,12 +532,14 @@ async function handlePhaseTransition(args = []) {
|
|
|
532
532
|
console.log(' --render Render ASCII dashboard after transition');
|
|
533
533
|
console.log(' --dir <path> Project directory (default: cwd)');
|
|
534
534
|
console.log(' --skip-evidence <reason> Skip evidence validation (requires reason)');
|
|
535
|
+
console.log(' --force Override the phase sequence guard (#519)');
|
|
535
536
|
console.log('');
|
|
536
537
|
console.log('Examples:');
|
|
537
538
|
console.log(' xp-gate phase-transition 1 completed');
|
|
538
539
|
console.log(' xp-gate phase-transition 3 completed --render');
|
|
539
540
|
console.log(' xp-gate phase-transition 2 in_progress --outputs \'{"spec":"path.yaml"}\'');
|
|
540
541
|
console.log(' xp-gate phase-transition 4 completed --skip-evidence "Emergency hotfix"');
|
|
542
|
+
console.log(' xp-gate phase-transition 3 completed --force # skip-order override');
|
|
541
543
|
return 0;
|
|
542
544
|
}
|
|
543
545
|
|
|
@@ -576,6 +578,9 @@ async function handlePhaseTransition(args = []) {
|
|
|
576
578
|
}
|
|
577
579
|
}
|
|
578
580
|
|
|
581
|
+
// #519: explicit override for the sequence guard below.
|
|
582
|
+
const forceTransition = args.includes('--force');
|
|
583
|
+
|
|
579
584
|
// Validate inputs
|
|
580
585
|
if (phase < 1 || phase > 6) {
|
|
581
586
|
console.error(`Error: Phase must be 1-6, got ${phase}`);
|
|
@@ -592,6 +597,37 @@ async function handlePhaseTransition(args = []) {
|
|
|
592
597
|
try {
|
|
593
598
|
const manager = new SprintStateManager(projectDir);
|
|
594
599
|
|
|
600
|
+
// ── #519 Layer 0: Sequence guard ──
|
|
601
|
+
// `completed` on phase n requires phase n-1 to be completed/skipped first.
|
|
602
|
+
// #507 proved that a missed transition is invisible in the state file (the
|
|
603
|
+
// pointer just looks "not yet advanced"), so out-of-order completion —
|
|
604
|
+
// skipping a phase, or completing from a stale pointer — must be refused
|
|
605
|
+
// loudly rather than written silently. `--force` overrides for the rare
|
|
606
|
+
// legitimate case (e.g. retroactively recording a skipped phase).
|
|
607
|
+
if (status === 'completed' && phase >= 2) {
|
|
608
|
+
const seqState = manager.read();
|
|
609
|
+
if (seqState && Array.isArray(seqState.phase_history)) {
|
|
610
|
+
const prevPhase = phase - 1;
|
|
611
|
+
const prevEntry = seqState.phase_history.find(e => e.phase === prevPhase);
|
|
612
|
+
const prevOk = prevEntry && (prevEntry.status === 'completed' || prevEntry.status === 'skipped');
|
|
613
|
+
if (!prevOk && !forceTransition) {
|
|
614
|
+
const summary = seqState.phase_history
|
|
615
|
+
.map(e => ` ${e.phase}:${e.phase_name || PHASE_NAMES[e.phase] || '?'} = ${e.status}`)
|
|
616
|
+
.join('\n');
|
|
617
|
+
console.error(`❌ [sprint-audit] Cannot complete phase ${phase} (${PHASE_NAMES[phase]}):`);
|
|
618
|
+
console.error(` phase ${prevPhase} (${PHASE_NAMES[prevPhase]}) is not completed/skipped.`);
|
|
619
|
+
console.error(' Current phase history:');
|
|
620
|
+
console.error(summary || ' (empty)');
|
|
621
|
+
console.error(` Advance it first: xp-gate phase-transition ${prevPhase} completed`);
|
|
622
|
+
console.error(' Or override explicitly: --force');
|
|
623
|
+
return 1;
|
|
624
|
+
}
|
|
625
|
+
if (!prevOk && forceTransition) {
|
|
626
|
+
console.warn(`⚠️ [sprint-audit] Sequence guard overridden (--force): phase ${prevPhase} is not completed/skipped.`);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
595
631
|
// ── Layer 1: Pre-transition gate check ──
|
|
596
632
|
// Check BEFORE transitionPhase() to validate previous phase completion.
|
|
597
633
|
// Runs when status === 'in_progress' and phase >= 2.
|
|
@@ -640,9 +676,34 @@ async function handlePhaseTransition(args = []) {
|
|
|
640
676
|
}
|
|
641
677
|
}
|
|
642
678
|
|
|
679
|
+
// Capture the before-image for the echo below (#519). Read once here so a
|
|
680
|
+
// concurrent writer cannot make before/after disagree about their baseline.
|
|
681
|
+
const beforeSnapshot = (() => {
|
|
682
|
+
try {
|
|
683
|
+
const s = manager.read();
|
|
684
|
+
if (!s) return { phase: undefined, status: undefined, entryStatus: undefined };
|
|
685
|
+
const e = Array.isArray(s.phase_history) ? s.phase_history.find(x => x.phase === phase) : undefined;
|
|
686
|
+
return { phase: s.phase, status: s.status, entryStatus: e ? e.status : undefined };
|
|
687
|
+
} catch {
|
|
688
|
+
return { phase: undefined, status: undefined, entryStatus: undefined };
|
|
689
|
+
}
|
|
690
|
+
})();
|
|
691
|
+
|
|
643
692
|
const newState = manager.transitionPhase(phase, status, { outputs });
|
|
644
693
|
|
|
694
|
+
// #519: before/after echo. The #507 failure mode was "the command either
|
|
695
|
+
// never ran or ran without effect" — both look identical in a terminal that
|
|
696
|
+
// prints only a success line. Printing the observed state on both sides
|
|
697
|
+
// makes those two cases distinguishable in the output itself.
|
|
698
|
+
const afterEntry = Array.isArray(newState.phase_history)
|
|
699
|
+
? newState.phase_history.find(e => e.phase === phase)
|
|
700
|
+
: undefined;
|
|
701
|
+
const oldTop = `${beforeSnapshot.phase ?? '-'}/${beforeSnapshot.status ?? '-'}`;
|
|
702
|
+
const newTop = `${newState.phase ?? '-'}/${newState.status ?? '-'}`;
|
|
703
|
+
const oldEntry = beforeSnapshot.entryStatus ?? '(none)';
|
|
704
|
+
const newEntryStatus = afterEntry ? afterEntry.status : '(none)';
|
|
645
705
|
console.log(`✅ Phase ${phase} transitioned to '${status}'`);
|
|
706
|
+
console.log(` before→after phase/status: ${oldTop} → ${newTop} | phase ${phase} entry: ${oldEntry} → ${newEntryStatus}`);
|
|
646
707
|
|
|
647
708
|
// ── Layer 1.5: Auto-trigger Layer 2 reminder on Phase 6 completed ──
|
|
648
709
|
if (phase === 6 && status === 'completed') {
|
|
@@ -12,6 +12,38 @@ const fs = require('fs');
|
|
|
12
12
|
const path = require('path');
|
|
13
13
|
const { migrateState, createInitialState, rollbackState, LEGACY_PHASE_MAP } = require('./sprint-state-migrator');
|
|
14
14
|
|
|
15
|
+
/** Canonical phase order; index = phase - 1. */
|
|
16
|
+
const PHASE_NAMES = ['PREP', 'DESIGN', 'BUILD', 'VERIFY', 'SHIP', 'CLOSE'];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Apply a status change to the phase_history entry for `phase`, creating it on
|
|
20
|
+
* first sight, and stamp started_at/completed_at/duration_seconds.
|
|
21
|
+
*
|
|
22
|
+
* Module-level and pure (it only mutates the passed-in state), extracted from
|
|
23
|
+
* transitionPhase (#519) so that method stays within the long-function budget.
|
|
24
|
+
*/
|
|
25
|
+
function applyHistoryEntry(state, phase, phaseName, status) {
|
|
26
|
+
let entry = state.phase_history.find(e => e.phase === phase);
|
|
27
|
+
if (!entry) {
|
|
28
|
+
entry = { phase, phase_name: phaseName, status };
|
|
29
|
+
state.phase_history.push(entry);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
entry.status = status;
|
|
33
|
+
if (status === 'in_progress' && !entry.started_at) {
|
|
34
|
+
entry.started_at = new Date().toISOString();
|
|
35
|
+
}
|
|
36
|
+
if (status === 'completed' || status === 'skipped') {
|
|
37
|
+
entry.completed_at = new Date().toISOString();
|
|
38
|
+
if (entry.started_at) {
|
|
39
|
+
const startMs = new Date(entry.started_at).getTime();
|
|
40
|
+
const endMs = new Date(entry.completed_at).getTime();
|
|
41
|
+
entry.duration_seconds = Math.round((endMs - startMs) / 1000);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return entry;
|
|
45
|
+
}
|
|
46
|
+
|
|
15
47
|
class SprintStateManager {
|
|
16
48
|
constructor(projectRoot) {
|
|
17
49
|
this.projectRoot = projectRoot;
|
|
@@ -48,59 +80,57 @@ class SprintStateManager {
|
|
|
48
80
|
}
|
|
49
81
|
|
|
50
82
|
write(state) {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
83
|
+
try {
|
|
84
|
+
if (!fs.existsSync(this.stateDir)) {
|
|
85
|
+
fs.mkdirSync(this.stateDir, { recursive: true });
|
|
86
|
+
}
|
|
87
|
+
// Atomic write: write to tmp, then rename
|
|
88
|
+
const tmpFile = this.stateFile + '.tmp';
|
|
89
|
+
fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), 'utf8');
|
|
90
|
+
fs.renameSync(tmpFile, this.stateFile);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
throw new Error(`Failed to write sprint state: ${err.message}`);
|
|
54
93
|
}
|
|
55
|
-
|
|
56
|
-
// Atomic write: write to tmp, then rename
|
|
57
|
-
const tmpFile = this.stateFile + '.tmp';
|
|
58
|
-
fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), 'utf8');
|
|
59
|
-
fs.renameSync(tmpFile, this.stateFile);
|
|
60
94
|
}
|
|
61
95
|
|
|
62
96
|
transitionPhase(phase, status, options = {}) {
|
|
63
97
|
const state = this.read() || createInitialState(this.projectRoot);
|
|
64
98
|
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
let entry = state.phase_history.find(e => e.phase === phase);
|
|
73
|
-
if (!entry) {
|
|
74
|
-
entry = {
|
|
75
|
-
phase,
|
|
76
|
-
phase_name: phaseName,
|
|
77
|
-
status,
|
|
78
|
-
};
|
|
79
|
-
state.phase_history.push(entry);
|
|
80
|
-
}
|
|
99
|
+
// Capture the before-image so the caller can echo a before/after
|
|
100
|
+
// comparison. #519: the same values feed the append-only audit log.
|
|
101
|
+
const beforePhase = state.phase;
|
|
102
|
+
const beforeEntry = Array.isArray(state.phase_history)
|
|
103
|
+
? state.phase_history.find(e => e.phase === phase)
|
|
104
|
+
: undefined;
|
|
105
|
+
const beforeStatus = beforeEntry ? beforeEntry.status : undefined;
|
|
81
106
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
entry.started_at = new Date().toISOString();
|
|
86
|
-
}
|
|
87
|
-
if (status === 'completed' || status === 'skipped') {
|
|
88
|
-
entry.completed_at = new Date().toISOString();
|
|
89
|
-
if (entry.started_at) {
|
|
90
|
-
const startMs = new Date(entry.started_at).getTime();
|
|
91
|
-
const endMs = new Date(entry.completed_at).getTime();
|
|
92
|
-
entry.duration_seconds = Math.round((endMs - startMs) / 1000);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
107
|
+
state.phase = phase;
|
|
108
|
+
const phaseName = PHASE_NAMES[phase - 1];
|
|
109
|
+
applyHistoryEntry(state, phase, phaseName, status);
|
|
95
110
|
|
|
96
|
-
// Update outputs if provided
|
|
97
111
|
if (options.outputs) {
|
|
98
112
|
state.outputs = { ...state.outputs, ...options.outputs };
|
|
99
113
|
}
|
|
100
114
|
|
|
101
|
-
// Write state atomically
|
|
102
115
|
this.write(state);
|
|
103
116
|
|
|
117
|
+
// #519: append-only transition audit. The #507 incident proved that a
|
|
118
|
+
// MISSED transition is invisible in the state file — the pointer drift
|
|
119
|
+
// looked identical to a legitimately-not-yet-run phase. A per-transition
|
|
120
|
+
// record — from/to plus statuses — lets post-mortems distinguish "never
|
|
121
|
+
// ran" from "ran and was overwritten". Best-effort: a logging failure must
|
|
122
|
+
// never break the transition, same contract as the onTransition callback.
|
|
123
|
+
this.appendTransitionAudit({
|
|
124
|
+
phase,
|
|
125
|
+
phase_name: phaseName,
|
|
126
|
+
from_phase: beforePhase,
|
|
127
|
+
// from_status === undefined together with a defined to_status is exactly
|
|
128
|
+
// what proves the phase was reached from nothing — i.e. never ran.
|
|
129
|
+
from_status: beforeStatus,
|
|
130
|
+
to_status: status,
|
|
131
|
+
timestamp: new Date().toISOString(),
|
|
132
|
+
});
|
|
133
|
+
|
|
104
134
|
// Trigger onTransition callback (for auto-render)
|
|
105
135
|
if (typeof options.onTransition === 'function') {
|
|
106
136
|
try {
|
|
@@ -114,6 +144,23 @@ class SprintStateManager {
|
|
|
114
144
|
return state;
|
|
115
145
|
}
|
|
116
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Append one JSON line to .sprint-state/transition-audit.jsonl (#519).
|
|
149
|
+
* Never throws — audit is evidence, not a gate.
|
|
150
|
+
* @param {object} record
|
|
151
|
+
*/
|
|
152
|
+
appendTransitionAudit(record) {
|
|
153
|
+
try {
|
|
154
|
+
if (!fs.existsSync(this.stateDir)) {
|
|
155
|
+
fs.mkdirSync(this.stateDir, { recursive: true });
|
|
156
|
+
}
|
|
157
|
+
const auditFile = path.join(this.stateDir, 'transition-audit.jsonl');
|
|
158
|
+
fs.appendFileSync(auditFile, JSON.stringify(record) + '\n', 'utf8');
|
|
159
|
+
} catch (err) {
|
|
160
|
+
console.warn(`[WARN] transition audit write failed: ${err.message}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
117
164
|
rollback() {
|
|
118
165
|
return rollbackState(this.stateFile, this.backupFile);
|
|
119
166
|
}
|
|
@@ -57,18 +57,9 @@ function migrateState(state, stateDir) {
|
|
|
57
57
|
if (!state.phase_history) { state.phase_history = []; }
|
|
58
58
|
if (!state.isolation) { state.isolation = { worktree_path: null, branch: null }; }
|
|
59
59
|
|
|
60
|
-
state.phase_history = state.phase_history.map(entry =>
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if (mapped) {
|
|
64
|
-
warnings.push(`Migrated legacy phase ${entry.phase} → ${mapped.phase} (${mapped.phase_name})`);
|
|
65
|
-
return { ...entry, phase: mapped.phase, phase_name: mapped.phase_name };
|
|
66
|
-
}
|
|
67
|
-
if (!entry.phase_name && entry.phase >= 1 && entry.phase <= 6) {
|
|
68
|
-
entry.phase_name = PHASE_NAMES[entry.phase - 1];
|
|
69
|
-
}
|
|
70
|
-
return entry;
|
|
71
|
-
});
|
|
60
|
+
state.phase_history = state.phase_history.map(entry =>
|
|
61
|
+
normalizePhaseEntry(entry, warnings),
|
|
62
|
+
);
|
|
72
63
|
|
|
73
64
|
const currentPhaseNum = String(state.phase);
|
|
74
65
|
const currentMapped = LEGACY_PHASE_MAP[currentPhaseNum];
|
|
@@ -91,6 +82,47 @@ function migrateState(state, stateDir) {
|
|
|
91
82
|
return state;
|
|
92
83
|
}
|
|
93
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Migrate and normalize a single phase_history entry.
|
|
87
|
+
*
|
|
88
|
+
* Two passes, in this order:
|
|
89
|
+
* 1. legacy numbering → current numbering via LEGACY_PHASE_MAP;
|
|
90
|
+
* 2. field normalization: phase 1 historically carried `name` while phase 2+
|
|
91
|
+
* carried `phase_name` (transitionPhase always writes `phase_name`).
|
|
92
|
+
* Normalize to `phase_name` as the single canonical field, in BOTH
|
|
93
|
+
* directions so a half-migrated file converges. The legacy `name` is kept
|
|
94
|
+
* (not deleted) so any reader still consulting it keeps working.
|
|
95
|
+
*
|
|
96
|
+
* Ordering matters: the normalization must run for every entry, including those
|
|
97
|
+
* the legacy remap just rewrote — LEGACY_PHASE_MAP covers every phase 1-6, so
|
|
98
|
+
* gating normalization on "not remapped" would skip exactly the legacy files it
|
|
99
|
+
* exists for. (#507 left such a file: phase 1 with `name: 'PREP'`, phase 2+
|
|
100
|
+
* with `phase_name`.)
|
|
101
|
+
*
|
|
102
|
+
* @param {Object} entry - One phase_history entry (possibly legacy-shaped)
|
|
103
|
+
* @param {string[]} warnings - Accumulator for human-readable migration notes
|
|
104
|
+
* @returns {Object} The normalized entry
|
|
105
|
+
*/
|
|
106
|
+
function normalizePhaseEntry(entry, warnings) {
|
|
107
|
+
const phaseNum = String(entry.phase);
|
|
108
|
+
const mapped = LEGACY_PHASE_MAP[phaseNum];
|
|
109
|
+
if (mapped) {
|
|
110
|
+
warnings.push(`Migrated legacy phase ${entry.phase} → ${mapped.phase} (${mapped.phase_name})`);
|
|
111
|
+
entry = { ...entry, phase: mapped.phase, phase_name: mapped.phase_name };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!entry.phase_name && entry.name) {
|
|
115
|
+
entry.phase_name = entry.name;
|
|
116
|
+
}
|
|
117
|
+
if (!entry.phase_name && entry.phase >= 1 && entry.phase <= 6) {
|
|
118
|
+
entry.phase_name = PHASE_NAMES[entry.phase - 1];
|
|
119
|
+
}
|
|
120
|
+
if (!entry.name && entry.phase_name) {
|
|
121
|
+
entry.name = entry.phase_name;
|
|
122
|
+
}
|
|
123
|
+
return entry;
|
|
124
|
+
}
|
|
125
|
+
|
|
94
126
|
/**
|
|
95
127
|
* Create initial sprint state for new sprints.
|
|
96
128
|
*
|
|
@@ -120,17 +152,23 @@ function createInitialState(projectRoot) {
|
|
|
120
152
|
* @param {string} stateDir - Path to .sprint-state directory
|
|
121
153
|
*/
|
|
122
154
|
function logMigrationWarnings(warnings, stateDir) {
|
|
123
|
-
|
|
124
|
-
fs.
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const warningsFile = path.join(stateDir, 'migration-warnings.json');
|
|
128
|
-
const existing = fs.existsSync(warningsFile)
|
|
129
|
-
? JSON.parse(fs.readFileSync(warningsFile, 'utf8'))
|
|
130
|
-
: [];
|
|
155
|
+
try {
|
|
156
|
+
if (!fs.existsSync(stateDir)) {
|
|
157
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
158
|
+
}
|
|
131
159
|
|
|
132
|
-
|
|
133
|
-
|
|
160
|
+
const warningsFile = path.join(stateDir, 'migration-warnings.json');
|
|
161
|
+
const existing = fs.existsSync(warningsFile)
|
|
162
|
+
? JSON.parse(fs.readFileSync(warningsFile, 'utf8'))
|
|
163
|
+
: [];
|
|
164
|
+
|
|
165
|
+
existing.push({ timestamp: new Date().toISOString(), warnings });
|
|
166
|
+
fs.writeFileSync(warningsFile, JSON.stringify(existing, null, 2), 'utf8');
|
|
167
|
+
} catch (err) {
|
|
168
|
+
// Migration must still return the migrated state; a failure to persist the
|
|
169
|
+
// warning log is reported to stderr and never thrown.
|
|
170
|
+
console.warn(`[WARN] could not write migration warnings: ${err.message}`);
|
|
171
|
+
}
|
|
134
172
|
}
|
|
135
173
|
|
|
136
174
|
/**
|
package/mock-policy/AGENTS.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SRC/MOCK-POLICY KNOWLEDGE BASE
|
|
2
2
|
|
|
3
3
|
**Generated:** 2026-10-14
|
|
4
|
-
**Commit:**
|
|
4
|
+
**Commit:** a757d625
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.
|
|
6
|
+
**Version:** 0.25.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.
|