gsd-pi 2.31.1 → 2.31.2

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.
@@ -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 with a PASS verdict, skip (idempotent).
534
- // Non-PASS verdicts (FAIL, surfaced-for-human-review) should block slice
535
- // progression return the slice for re-evaluation (#1231).
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)
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gsd-pi",
3
- "version": "2.31.1",
3
+ "version": "2.31.2",
4
4
  "description": "GSD — Get Shit Done coding agent",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gsd/pi-coding-agent",
3
- "version": "2.31.1",
3
+ "version": "2.31.2",
4
4
  "description": "Coding agent CLI (vendored from pi-mono)",
5
5
  "type": "module",
6
6
  "piConfig": {
package/pkg/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glittercowboy/gsd",
3
- "version": "2.31.1",
3
+ "version": "2.31.2",
4
4
  "piConfig": {
5
5
  "name": "gsd",
6
6
  "configDir": ".gsd"
@@ -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 with a PASS verdict, skip (idempotent).
534
- // Non-PASS verdicts (FAIL, surfaced-for-human-review) should block slice
535
- // progression return the slice for re-evaluation (#1231).
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)
@@ -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