gsd-pi 2.31.0 → 2.31.1-dev.d338017
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/dist/resources/extensions/gsd/auto-dispatch.ts +19 -19
- package/dist/resources/extensions/gsd/auto-prompts.ts +5 -12
- package/dist/resources/extensions/gsd/auto-supervisor.ts +2 -0
- package/dist/resources/extensions/gsd/session-lock.ts +19 -1
- package/dist/resources/extensions/gsd/tests/run-uat.test.ts +50 -0
- package/package.json +1 -1
- package/packages/pi-coding-agent/package.json +1 -1
- package/pkg/package.json +1 -1
- package/src/resources/extensions/gsd/auto-dispatch.ts +19 -19
- package/src/resources/extensions/gsd/auto-prompts.ts +5 -12
- package/src/resources/extensions/gsd/auto-supervisor.ts +2 -0
- package/src/resources/extensions/gsd/session-lock.ts +19 -1
- package/src/resources/extensions/gsd/tests/run-uat.test.ts +50 -0
|
@@ -104,25 +104,6 @@ const DISPATCH_RULES: DispatchRule[] = [
|
|
|
104
104
|
};
|
|
105
105
|
},
|
|
106
106
|
},
|
|
107
|
-
{
|
|
108
|
-
name: "run-uat (post-completion)",
|
|
109
|
-
match: async ({ state, mid, basePath, prefs }) => {
|
|
110
|
-
const needsRunUat = await checkNeedsRunUat(basePath, mid, state, prefs);
|
|
111
|
-
if (!needsRunUat) return null;
|
|
112
|
-
const { sliceId, uatType } = needsRunUat;
|
|
113
|
-
const uatFile = resolveSliceFile(basePath, mid, sliceId, "UAT")!;
|
|
114
|
-
const uatContent = await loadFile(uatFile);
|
|
115
|
-
return {
|
|
116
|
-
action: "dispatch",
|
|
117
|
-
unitType: "run-uat",
|
|
118
|
-
unitId: `${mid}/${sliceId}`,
|
|
119
|
-
prompt: await buildRunUatPrompt(
|
|
120
|
-
mid, sliceId, relSliceFile(basePath, mid, sliceId, "UAT"), uatContent ?? "", basePath,
|
|
121
|
-
),
|
|
122
|
-
pauseAfterDispatch: uatType !== "artifact-driven",
|
|
123
|
-
};
|
|
124
|
-
},
|
|
125
|
-
},
|
|
126
107
|
{
|
|
127
108
|
name: "uat-verdict-gate (non-PASS blocks progression)",
|
|
128
109
|
match: async ({ mid, basePath, prefs }) => {
|
|
@@ -152,6 +133,25 @@ const DISPATCH_RULES: DispatchRule[] = [
|
|
|
152
133
|
return null;
|
|
153
134
|
},
|
|
154
135
|
},
|
|
136
|
+
{
|
|
137
|
+
name: "run-uat (post-completion)",
|
|
138
|
+
match: async ({ state, mid, basePath, prefs }) => {
|
|
139
|
+
const needsRunUat = await checkNeedsRunUat(basePath, mid, state, prefs);
|
|
140
|
+
if (!needsRunUat) return null;
|
|
141
|
+
const { sliceId, uatType } = needsRunUat;
|
|
142
|
+
const uatFile = resolveSliceFile(basePath, mid, sliceId, "UAT")!;
|
|
143
|
+
const uatContent = await loadFile(uatFile);
|
|
144
|
+
return {
|
|
145
|
+
action: "dispatch",
|
|
146
|
+
unitType: "run-uat",
|
|
147
|
+
unitId: `${mid}/${sliceId}`,
|
|
148
|
+
prompt: await buildRunUatPrompt(
|
|
149
|
+
mid, sliceId, relSliceFile(basePath, mid, sliceId, "UAT"), uatContent ?? "", basePath,
|
|
150
|
+
),
|
|
151
|
+
pauseAfterDispatch: uatType !== "artifact-driven",
|
|
152
|
+
};
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
155
|
{
|
|
156
156
|
name: "reassess-roadmap (post-completion)",
|
|
157
157
|
match: async ({ state, mid, midTitle, basePath, prefs }) => {
|
|
@@ -530,21 +530,14 @@ export async function checkNeedsRunUat(
|
|
|
530
530
|
const uatContent = await loadFile(uatFile);
|
|
531
531
|
if (!uatContent) return null;
|
|
532
532
|
|
|
533
|
-
// If UAT result already exists
|
|
534
|
-
//
|
|
535
|
-
//
|
|
533
|
+
// If a UAT result already exists, the UAT unit has already run and must not
|
|
534
|
+
// be re-dispatched. PASS means progression can continue; any non-PASS verdict
|
|
535
|
+
// must be handled by the dispatch table's verdict gate, which stops progression
|
|
536
|
+
// with a human-action message instead of replaying the same run-uat unit.
|
|
536
537
|
const uatResultFile = resolveSliceFile(base, mid, sid, "UAT-RESULT");
|
|
537
538
|
if (uatResultFile) {
|
|
538
539
|
const resultContent = await loadFile(uatResultFile);
|
|
539
|
-
if (resultContent)
|
|
540
|
-
const verdictMatch = resultContent.match(/verdict:\s*([\w-]+)/i);
|
|
541
|
-
const verdict = verdictMatch?.[1]?.toLowerCase();
|
|
542
|
-
if (verdict === "pass" || verdict === "passed") return null; // PASS — skip
|
|
543
|
-
// Non-PASS verdict exists — don't re-run UAT, but don't advance either.
|
|
544
|
-
// Return null here since the UAT already ran; the dispatch table's
|
|
545
|
-
// complete-slice rule should check the verdict before advancing.
|
|
546
|
-
// For now, returning the slice signals it still needs attention.
|
|
547
|
-
}
|
|
540
|
+
if (resultContent) return null;
|
|
548
541
|
}
|
|
549
542
|
|
|
550
543
|
// Classify UAT type; unknown type → treat as human-experience (human review)
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { clearLock } from "./crash-recovery.js";
|
|
8
|
+
import { releaseSessionLock } from "./session-lock.js";
|
|
8
9
|
import { nativeHasChanges } from "./native-git-bridge.js";
|
|
9
10
|
|
|
10
11
|
// ─── SIGTERM Handling ─────────────────────────────────────────────────────────
|
|
@@ -23,6 +24,7 @@ export function registerSigtermHandler(
|
|
|
23
24
|
): () => void {
|
|
24
25
|
if (previousHandler) process.off("SIGTERM", previousHandler);
|
|
25
26
|
const handler = () => {
|
|
27
|
+
releaseSessionLock(currentBasePath);
|
|
26
28
|
clearLock(currentBasePath);
|
|
27
29
|
process.exit(0);
|
|
28
30
|
};
|
|
@@ -51,6 +51,9 @@ let _lockedPath: string | null = null;
|
|
|
51
51
|
/** Our PID at lock acquisition time. */
|
|
52
52
|
let _lockPid: number = 0;
|
|
53
53
|
|
|
54
|
+
/** Set to true when proper-lockfile fires onCompromised (mtime drift, sleep, etc.). */
|
|
55
|
+
let _lockCompromised: boolean = false;
|
|
56
|
+
|
|
54
57
|
const LOCK_FILE = "auto.lock";
|
|
55
58
|
|
|
56
59
|
function lockPath(basePath: string): string {
|
|
@@ -102,13 +105,22 @@ export function acquireSessionLock(basePath: string): SessionLockResult {
|
|
|
102
105
|
|
|
103
106
|
const release = lockfile.lockSync(gsdDir, {
|
|
104
107
|
realpath: false,
|
|
105
|
-
stale:
|
|
108
|
+
stale: 1_800_000, // 30 minutes — safe for laptop sleep / long event loop stalls
|
|
106
109
|
update: 10_000, // Update lock mtime every 10s to prove liveness
|
|
110
|
+
onCompromised: () => {
|
|
111
|
+
// proper-lockfile detected mtime drift (system sleep, event loop stall, etc.).
|
|
112
|
+
// Default handler throws inside setTimeout — an uncaught exception that crashes
|
|
113
|
+
// or corrupts process state. Instead, set a flag so validateSessionLock() can
|
|
114
|
+
// detect the compromise gracefully on the next dispatch cycle.
|
|
115
|
+
_lockCompromised = true;
|
|
116
|
+
_releaseFunction = null;
|
|
117
|
+
},
|
|
107
118
|
});
|
|
108
119
|
|
|
109
120
|
_releaseFunction = release;
|
|
110
121
|
_lockedPath = basePath;
|
|
111
122
|
_lockPid = process.pid;
|
|
123
|
+
_lockCompromised = false;
|
|
112
124
|
|
|
113
125
|
// Safety net: clean up lock dir on process exit if _releaseFunction
|
|
114
126
|
// wasn't called (e.g., normal exit after clean completion) (#1245).
|
|
@@ -233,6 +245,11 @@ export function updateSessionLock(
|
|
|
233
245
|
* This is called periodically during the dispatch loop.
|
|
234
246
|
*/
|
|
235
247
|
export function validateSessionLock(basePath: string): boolean {
|
|
248
|
+
// Lock was compromised by proper-lockfile (mtime drift from sleep, stall, etc.)
|
|
249
|
+
if (_lockCompromised) {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
|
|
236
253
|
// If we have an OS-level lock, we're still the owner
|
|
237
254
|
if (_releaseFunction && _lockedPath === basePath) {
|
|
238
255
|
return true;
|
|
@@ -284,6 +301,7 @@ export function releaseSessionLock(basePath: string): void {
|
|
|
284
301
|
|
|
285
302
|
_lockedPath = null;
|
|
286
303
|
_lockPid = 0;
|
|
304
|
+
_lockCompromised = false;
|
|
287
305
|
}
|
|
288
306
|
|
|
289
307
|
/**
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// (a)–(j) extractUatType classification (17 assertions from T01)
|
|
7
7
|
// (k) run-uat prompt template loading and content integrity (8 assertions)
|
|
8
8
|
// (l) dispatch precondition assertions via resolveSliceFile (4 assertions)
|
|
9
|
+
// (m) stale replay guard: existing UAT-RESULT never re-dispatches (2 assertions)
|
|
9
10
|
|
|
10
11
|
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
11
12
|
import { join, dirname } from 'node:path';
|
|
@@ -14,6 +15,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
14
15
|
|
|
15
16
|
import { extractUatType } from '../files.ts';
|
|
16
17
|
import { resolveSliceFile } from '../paths.ts';
|
|
18
|
+
import { checkNeedsRunUat } from '../auto-prompts.ts';
|
|
17
19
|
import { createTestContext } from './test-helpers.ts';
|
|
18
20
|
|
|
19
21
|
// ─── Worktree-aware prompt loader ──────────────────────────────────────────
|
|
@@ -308,6 +310,54 @@ async function main(): Promise<void> {
|
|
|
308
310
|
}
|
|
309
311
|
}
|
|
310
312
|
|
|
313
|
+
// ─── (m) stale replay guard: existing UAT-RESULT never re-dispatches ─────
|
|
314
|
+
console.log('\n── (m) stale replay guard');
|
|
315
|
+
|
|
316
|
+
{
|
|
317
|
+
const base = createFixtureBase();
|
|
318
|
+
try {
|
|
319
|
+
const roadmapDir = join(base, '.gsd', 'milestones', 'M001');
|
|
320
|
+
mkdirSync(roadmapDir, { recursive: true });
|
|
321
|
+
writeFileSync(
|
|
322
|
+
join(roadmapDir, 'M001-ROADMAP.md'),
|
|
323
|
+
[
|
|
324
|
+
'# M001: Test roadmap',
|
|
325
|
+
'',
|
|
326
|
+
'## Slices',
|
|
327
|
+
'',
|
|
328
|
+
'- [x] **S01: First slice** `risk:low` `depends:[]`',
|
|
329
|
+
'- [ ] **S02: Next slice** `risk:low` `depends:[S01]`',
|
|
330
|
+
'',
|
|
331
|
+
'## Boundary Map',
|
|
332
|
+
'',
|
|
333
|
+
].join('\n'),
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
writeSliceFile(base, 'M001', 'S01', 'UAT', makeUatContent('artifact-driven'));
|
|
337
|
+
writeSliceFile(base, 'M001', 'S01', 'UAT-RESULT', '---\nverdict: surfaced-for-human-review\n---\n');
|
|
338
|
+
|
|
339
|
+
const state = {
|
|
340
|
+
activeMilestone: { id: 'M001', title: 'Test roadmap' },
|
|
341
|
+
activeSlice: { id: 'S02', title: 'Next slice' },
|
|
342
|
+
activeTask: null,
|
|
343
|
+
phase: 'planning',
|
|
344
|
+
recentDecisions: [],
|
|
345
|
+
blockers: [],
|
|
346
|
+
nextAction: 'Plan S02',
|
|
347
|
+
registry: [],
|
|
348
|
+
} as const;
|
|
349
|
+
|
|
350
|
+
const result = await checkNeedsRunUat(base, 'M001', state as any, { uat_dispatch: true } as any);
|
|
351
|
+
assertEq(
|
|
352
|
+
result,
|
|
353
|
+
null,
|
|
354
|
+
'existing UAT-RESULT with non-PASS verdict does not re-dispatch run-uat; verdict gate owns blocking',
|
|
355
|
+
);
|
|
356
|
+
} finally {
|
|
357
|
+
cleanup(base);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
311
361
|
report();
|
|
312
362
|
}
|
|
313
363
|
|
package/package.json
CHANGED
package/pkg/package.json
CHANGED
|
@@ -104,25 +104,6 @@ const DISPATCH_RULES: DispatchRule[] = [
|
|
|
104
104
|
};
|
|
105
105
|
},
|
|
106
106
|
},
|
|
107
|
-
{
|
|
108
|
-
name: "run-uat (post-completion)",
|
|
109
|
-
match: async ({ state, mid, basePath, prefs }) => {
|
|
110
|
-
const needsRunUat = await checkNeedsRunUat(basePath, mid, state, prefs);
|
|
111
|
-
if (!needsRunUat) return null;
|
|
112
|
-
const { sliceId, uatType } = needsRunUat;
|
|
113
|
-
const uatFile = resolveSliceFile(basePath, mid, sliceId, "UAT")!;
|
|
114
|
-
const uatContent = await loadFile(uatFile);
|
|
115
|
-
return {
|
|
116
|
-
action: "dispatch",
|
|
117
|
-
unitType: "run-uat",
|
|
118
|
-
unitId: `${mid}/${sliceId}`,
|
|
119
|
-
prompt: await buildRunUatPrompt(
|
|
120
|
-
mid, sliceId, relSliceFile(basePath, mid, sliceId, "UAT"), uatContent ?? "", basePath,
|
|
121
|
-
),
|
|
122
|
-
pauseAfterDispatch: uatType !== "artifact-driven",
|
|
123
|
-
};
|
|
124
|
-
},
|
|
125
|
-
},
|
|
126
107
|
{
|
|
127
108
|
name: "uat-verdict-gate (non-PASS blocks progression)",
|
|
128
109
|
match: async ({ mid, basePath, prefs }) => {
|
|
@@ -152,6 +133,25 @@ const DISPATCH_RULES: DispatchRule[] = [
|
|
|
152
133
|
return null;
|
|
153
134
|
},
|
|
154
135
|
},
|
|
136
|
+
{
|
|
137
|
+
name: "run-uat (post-completion)",
|
|
138
|
+
match: async ({ state, mid, basePath, prefs }) => {
|
|
139
|
+
const needsRunUat = await checkNeedsRunUat(basePath, mid, state, prefs);
|
|
140
|
+
if (!needsRunUat) return null;
|
|
141
|
+
const { sliceId, uatType } = needsRunUat;
|
|
142
|
+
const uatFile = resolveSliceFile(basePath, mid, sliceId, "UAT")!;
|
|
143
|
+
const uatContent = await loadFile(uatFile);
|
|
144
|
+
return {
|
|
145
|
+
action: "dispatch",
|
|
146
|
+
unitType: "run-uat",
|
|
147
|
+
unitId: `${mid}/${sliceId}`,
|
|
148
|
+
prompt: await buildRunUatPrompt(
|
|
149
|
+
mid, sliceId, relSliceFile(basePath, mid, sliceId, "UAT"), uatContent ?? "", basePath,
|
|
150
|
+
),
|
|
151
|
+
pauseAfterDispatch: uatType !== "artifact-driven",
|
|
152
|
+
};
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
155
|
{
|
|
156
156
|
name: "reassess-roadmap (post-completion)",
|
|
157
157
|
match: async ({ state, mid, midTitle, basePath, prefs }) => {
|
|
@@ -530,21 +530,14 @@ export async function checkNeedsRunUat(
|
|
|
530
530
|
const uatContent = await loadFile(uatFile);
|
|
531
531
|
if (!uatContent) return null;
|
|
532
532
|
|
|
533
|
-
// If UAT result already exists
|
|
534
|
-
//
|
|
535
|
-
//
|
|
533
|
+
// If a UAT result already exists, the UAT unit has already run and must not
|
|
534
|
+
// be re-dispatched. PASS means progression can continue; any non-PASS verdict
|
|
535
|
+
// must be handled by the dispatch table's verdict gate, which stops progression
|
|
536
|
+
// with a human-action message instead of replaying the same run-uat unit.
|
|
536
537
|
const uatResultFile = resolveSliceFile(base, mid, sid, "UAT-RESULT");
|
|
537
538
|
if (uatResultFile) {
|
|
538
539
|
const resultContent = await loadFile(uatResultFile);
|
|
539
|
-
if (resultContent)
|
|
540
|
-
const verdictMatch = resultContent.match(/verdict:\s*([\w-]+)/i);
|
|
541
|
-
const verdict = verdictMatch?.[1]?.toLowerCase();
|
|
542
|
-
if (verdict === "pass" || verdict === "passed") return null; // PASS — skip
|
|
543
|
-
// Non-PASS verdict exists — don't re-run UAT, but don't advance either.
|
|
544
|
-
// Return null here since the UAT already ran; the dispatch table's
|
|
545
|
-
// complete-slice rule should check the verdict before advancing.
|
|
546
|
-
// For now, returning the slice signals it still needs attention.
|
|
547
|
-
}
|
|
540
|
+
if (resultContent) return null;
|
|
548
541
|
}
|
|
549
542
|
|
|
550
543
|
// Classify UAT type; unknown type → treat as human-experience (human review)
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { clearLock } from "./crash-recovery.js";
|
|
8
|
+
import { releaseSessionLock } from "./session-lock.js";
|
|
8
9
|
import { nativeHasChanges } from "./native-git-bridge.js";
|
|
9
10
|
|
|
10
11
|
// ─── SIGTERM Handling ─────────────────────────────────────────────────────────
|
|
@@ -23,6 +24,7 @@ export function registerSigtermHandler(
|
|
|
23
24
|
): () => void {
|
|
24
25
|
if (previousHandler) process.off("SIGTERM", previousHandler);
|
|
25
26
|
const handler = () => {
|
|
27
|
+
releaseSessionLock(currentBasePath);
|
|
26
28
|
clearLock(currentBasePath);
|
|
27
29
|
process.exit(0);
|
|
28
30
|
};
|
|
@@ -51,6 +51,9 @@ let _lockedPath: string | null = null;
|
|
|
51
51
|
/** Our PID at lock acquisition time. */
|
|
52
52
|
let _lockPid: number = 0;
|
|
53
53
|
|
|
54
|
+
/** Set to true when proper-lockfile fires onCompromised (mtime drift, sleep, etc.). */
|
|
55
|
+
let _lockCompromised: boolean = false;
|
|
56
|
+
|
|
54
57
|
const LOCK_FILE = "auto.lock";
|
|
55
58
|
|
|
56
59
|
function lockPath(basePath: string): string {
|
|
@@ -102,13 +105,22 @@ export function acquireSessionLock(basePath: string): SessionLockResult {
|
|
|
102
105
|
|
|
103
106
|
const release = lockfile.lockSync(gsdDir, {
|
|
104
107
|
realpath: false,
|
|
105
|
-
stale:
|
|
108
|
+
stale: 1_800_000, // 30 minutes — safe for laptop sleep / long event loop stalls
|
|
106
109
|
update: 10_000, // Update lock mtime every 10s to prove liveness
|
|
110
|
+
onCompromised: () => {
|
|
111
|
+
// proper-lockfile detected mtime drift (system sleep, event loop stall, etc.).
|
|
112
|
+
// Default handler throws inside setTimeout — an uncaught exception that crashes
|
|
113
|
+
// or corrupts process state. Instead, set a flag so validateSessionLock() can
|
|
114
|
+
// detect the compromise gracefully on the next dispatch cycle.
|
|
115
|
+
_lockCompromised = true;
|
|
116
|
+
_releaseFunction = null;
|
|
117
|
+
},
|
|
107
118
|
});
|
|
108
119
|
|
|
109
120
|
_releaseFunction = release;
|
|
110
121
|
_lockedPath = basePath;
|
|
111
122
|
_lockPid = process.pid;
|
|
123
|
+
_lockCompromised = false;
|
|
112
124
|
|
|
113
125
|
// Safety net: clean up lock dir on process exit if _releaseFunction
|
|
114
126
|
// wasn't called (e.g., normal exit after clean completion) (#1245).
|
|
@@ -233,6 +245,11 @@ export function updateSessionLock(
|
|
|
233
245
|
* This is called periodically during the dispatch loop.
|
|
234
246
|
*/
|
|
235
247
|
export function validateSessionLock(basePath: string): boolean {
|
|
248
|
+
// Lock was compromised by proper-lockfile (mtime drift from sleep, stall, etc.)
|
|
249
|
+
if (_lockCompromised) {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
|
|
236
253
|
// If we have an OS-level lock, we're still the owner
|
|
237
254
|
if (_releaseFunction && _lockedPath === basePath) {
|
|
238
255
|
return true;
|
|
@@ -284,6 +301,7 @@ export function releaseSessionLock(basePath: string): void {
|
|
|
284
301
|
|
|
285
302
|
_lockedPath = null;
|
|
286
303
|
_lockPid = 0;
|
|
304
|
+
_lockCompromised = false;
|
|
287
305
|
}
|
|
288
306
|
|
|
289
307
|
/**
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// (a)–(j) extractUatType classification (17 assertions from T01)
|
|
7
7
|
// (k) run-uat prompt template loading and content integrity (8 assertions)
|
|
8
8
|
// (l) dispatch precondition assertions via resolveSliceFile (4 assertions)
|
|
9
|
+
// (m) stale replay guard: existing UAT-RESULT never re-dispatches (2 assertions)
|
|
9
10
|
|
|
10
11
|
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
11
12
|
import { join, dirname } from 'node:path';
|
|
@@ -14,6 +15,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
14
15
|
|
|
15
16
|
import { extractUatType } from '../files.ts';
|
|
16
17
|
import { resolveSliceFile } from '../paths.ts';
|
|
18
|
+
import { checkNeedsRunUat } from '../auto-prompts.ts';
|
|
17
19
|
import { createTestContext } from './test-helpers.ts';
|
|
18
20
|
|
|
19
21
|
// ─── Worktree-aware prompt loader ──────────────────────────────────────────
|
|
@@ -308,6 +310,54 @@ async function main(): Promise<void> {
|
|
|
308
310
|
}
|
|
309
311
|
}
|
|
310
312
|
|
|
313
|
+
// ─── (m) stale replay guard: existing UAT-RESULT never re-dispatches ─────
|
|
314
|
+
console.log('\n── (m) stale replay guard');
|
|
315
|
+
|
|
316
|
+
{
|
|
317
|
+
const base = createFixtureBase();
|
|
318
|
+
try {
|
|
319
|
+
const roadmapDir = join(base, '.gsd', 'milestones', 'M001');
|
|
320
|
+
mkdirSync(roadmapDir, { recursive: true });
|
|
321
|
+
writeFileSync(
|
|
322
|
+
join(roadmapDir, 'M001-ROADMAP.md'),
|
|
323
|
+
[
|
|
324
|
+
'# M001: Test roadmap',
|
|
325
|
+
'',
|
|
326
|
+
'## Slices',
|
|
327
|
+
'',
|
|
328
|
+
'- [x] **S01: First slice** `risk:low` `depends:[]`',
|
|
329
|
+
'- [ ] **S02: Next slice** `risk:low` `depends:[S01]`',
|
|
330
|
+
'',
|
|
331
|
+
'## Boundary Map',
|
|
332
|
+
'',
|
|
333
|
+
].join('\n'),
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
writeSliceFile(base, 'M001', 'S01', 'UAT', makeUatContent('artifact-driven'));
|
|
337
|
+
writeSliceFile(base, 'M001', 'S01', 'UAT-RESULT', '---\nverdict: surfaced-for-human-review\n---\n');
|
|
338
|
+
|
|
339
|
+
const state = {
|
|
340
|
+
activeMilestone: { id: 'M001', title: 'Test roadmap' },
|
|
341
|
+
activeSlice: { id: 'S02', title: 'Next slice' },
|
|
342
|
+
activeTask: null,
|
|
343
|
+
phase: 'planning',
|
|
344
|
+
recentDecisions: [],
|
|
345
|
+
blockers: [],
|
|
346
|
+
nextAction: 'Plan S02',
|
|
347
|
+
registry: [],
|
|
348
|
+
} as const;
|
|
349
|
+
|
|
350
|
+
const result = await checkNeedsRunUat(base, 'M001', state as any, { uat_dispatch: true } as any);
|
|
351
|
+
assertEq(
|
|
352
|
+
result,
|
|
353
|
+
null,
|
|
354
|
+
'existing UAT-RESULT with non-PASS verdict does not re-dispatch run-uat; verdict gate owns blocking',
|
|
355
|
+
);
|
|
356
|
+
} finally {
|
|
357
|
+
cleanup(base);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
311
361
|
report();
|
|
312
362
|
}
|
|
313
363
|
|