@opengsd/gsd-pi 1.1.1-dev.2034b16 → 1.1.1-dev.2de7ea0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/dist/cli.js +3 -2
  2. package/dist/help-text.js +10 -6
  3. package/dist/resources/.managed-resources-content-hash +1 -1
  4. package/dist/resources/extensions/browser-tools/engine/managed-gsd-browser.js +495 -0
  5. package/dist/resources/extensions/browser-tools/engine/selection.js +16 -0
  6. package/dist/resources/extensions/browser-tools/extension-manifest.json +2 -2
  7. package/dist/resources/extensions/browser-tools/index.js +57 -9
  8. package/dist/resources/extensions/browser-tools/package.json +5 -1
  9. package/dist/resources/extensions/gsd/auto-post-unit.js +21 -3
  10. package/dist/resources/extensions/gsd/auto-prompts.js +15 -6
  11. package/dist/resources/extensions/gsd/bootstrap/db-tools.js +2 -2
  12. package/dist/resources/extensions/gsd/browser-evidence.js +29 -2
  13. package/dist/resources/extensions/gsd/commands/handlers/ops.js +2 -2
  14. package/dist/resources/extensions/gsd/commands-handlers.js +76 -11
  15. package/dist/resources/extensions/gsd/commands-mcp-status.js +2 -1
  16. package/dist/resources/extensions/gsd/docs/preferences-reference.md +8 -0
  17. package/dist/resources/extensions/gsd/doctor-runtime-checks.js +2 -2
  18. package/dist/resources/extensions/gsd/mcp-project-config.js +9 -76
  19. package/dist/resources/extensions/gsd/post-unit-hooks.js +9 -0
  20. package/dist/resources/extensions/gsd/preferences-validation.js +39 -0
  21. package/dist/resources/extensions/gsd/prompt-loader.js +7 -0
  22. package/dist/resources/extensions/gsd/prompts/run-uat.md +40 -22
  23. package/dist/resources/extensions/gsd/prompts/validate-milestone.md +3 -3
  24. package/dist/resources/extensions/gsd/rule-registry.js +428 -52
  25. package/dist/resources/extensions/gsd/tools/validate-milestone.js +46 -16
  26. package/dist/resources/extensions/gsd/tools/workflow-tool-executors.js +29 -14
  27. package/dist/resources/extensions/gsd/verdict-parser.js +59 -15
  28. package/dist/resources/extensions/shared/gsd-browser-cli.js +145 -0
  29. package/dist/rtk.d.ts +7 -1
  30. package/dist/rtk.js +27 -11
  31. package/dist/update-check.d.ts +15 -1
  32. package/dist/update-check.js +87 -12
  33. package/dist/update-cmd.d.ts +1 -0
  34. package/dist/update-cmd.js +53 -2
  35. package/dist/web/standalone/.next/BUILD_ID +1 -1
  36. package/dist/web/standalone/.next/app-path-routes-manifest.json +6 -6
  37. package/dist/web/standalone/.next/build-manifest.json +2 -2
  38. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  39. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  40. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  48. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  52. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  53. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  54. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  55. package/dist/web/standalone/.next/server/app/api/update/route.js +1 -1
  56. package/dist/web/standalone/.next/server/app/index.html +1 -1
  57. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  58. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  59. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  60. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  61. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  62. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  63. package/dist/web/standalone/.next/server/app-paths-manifest.json +6 -6
  64. package/dist/web/standalone/.next/server/chunks/8357.js +1 -1
  65. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  66. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  67. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  68. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  69. package/package.json +3 -2
  70. package/packages/cloud-mcp-gateway/package.json +2 -2
  71. package/packages/contracts/package.json +1 -1
  72. package/packages/daemon/package.json +4 -4
  73. package/packages/gsd-agent-core/dist/session/agent-session-compaction.d.ts +2 -0
  74. package/packages/gsd-agent-core/dist/session/agent-session-compaction.d.ts.map +1 -1
  75. package/packages/gsd-agent-core/dist/session/agent-session-compaction.js +8 -2
  76. package/packages/gsd-agent-core/dist/session/agent-session-compaction.js.map +1 -1
  77. package/packages/gsd-agent-core/package.json +5 -5
  78. package/packages/gsd-agent-modes/package.json +7 -7
  79. package/packages/mcp-server/dist/remote-questions.d.ts.map +1 -1
  80. package/packages/mcp-server/dist/remote-questions.js +23 -9
  81. package/packages/mcp-server/dist/remote-questions.js.map +1 -1
  82. package/packages/mcp-server/dist/workflow-tools.js +1 -1
  83. package/packages/mcp-server/dist/workflow-tools.js.map +1 -1
  84. package/packages/mcp-server/package.json +3 -3
  85. package/packages/native/package.json +1 -1
  86. package/packages/pi-agent-core/package.json +1 -1
  87. package/packages/pi-ai/dist/models.generated.d.ts +17 -0
  88. package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
  89. package/packages/pi-ai/dist/models.generated.js +19 -2
  90. package/packages/pi-ai/dist/models.generated.js.map +1 -1
  91. package/packages/pi-ai/package.json +1 -1
  92. package/packages/pi-coding-agent/package.json +7 -7
  93. package/packages/pi-tui/package.json +1 -1
  94. package/packages/rpc-client/package.json +2 -2
  95. package/pkg/package.json +1 -1
  96. package/src/resources/extensions/browser-tools/engine/managed-gsd-browser.ts +579 -0
  97. package/src/resources/extensions/browser-tools/engine/selection.ts +19 -0
  98. package/src/resources/extensions/browser-tools/extension-manifest.json +2 -2
  99. package/src/resources/extensions/browser-tools/index.ts +60 -9
  100. package/src/resources/extensions/browser-tools/package.json +5 -1
  101. package/src/resources/extensions/browser-tools/tests/browser-engine-selection.test.mjs +35 -0
  102. package/src/resources/extensions/browser-tools/tests/managed-gsd-browser-tools.test.mjs +33 -0
  103. package/src/resources/extensions/gsd/auto-post-unit.ts +28 -2
  104. package/src/resources/extensions/gsd/auto-prompts.ts +16 -6
  105. package/src/resources/extensions/gsd/bootstrap/db-tools.ts +2 -2
  106. package/src/resources/extensions/gsd/browser-evidence.ts +26 -2
  107. package/src/resources/extensions/gsd/commands/handlers/ops.ts +2 -2
  108. package/src/resources/extensions/gsd/commands-handlers.ts +76 -11
  109. package/src/resources/extensions/gsd/commands-mcp-status.ts +2 -1
  110. package/src/resources/extensions/gsd/docs/preferences-reference.md +8 -0
  111. package/src/resources/extensions/gsd/doctor-runtime-checks.ts +2 -2
  112. package/src/resources/extensions/gsd/mcp-project-config.ts +13 -78
  113. package/src/resources/extensions/gsd/post-unit-hooks.ts +14 -1
  114. package/src/resources/extensions/gsd/preferences-validation.ts +36 -0
  115. package/src/resources/extensions/gsd/prompt-loader.ts +8 -0
  116. package/src/resources/extensions/gsd/prompts/run-uat.md +40 -22
  117. package/src/resources/extensions/gsd/prompts/validate-milestone.md +3 -3
  118. package/src/resources/extensions/gsd/rule-registry.ts +558 -58
  119. package/src/resources/extensions/gsd/rule-types.ts +2 -0
  120. package/src/resources/extensions/gsd/tests/browser-evidence.test.ts +142 -0
  121. package/src/resources/extensions/gsd/tests/complete-milestone-excerpt.test.ts +30 -0
  122. package/src/resources/extensions/gsd/tests/doctor-runtime-checks.test.ts +27 -0
  123. package/src/resources/extensions/gsd/tests/integration/auto-recovery.test.ts +4 -4
  124. package/src/resources/extensions/gsd/tests/integration/run-uat.test.ts +66 -10
  125. package/src/resources/extensions/gsd/tests/mcp-project-config.test.ts +32 -0
  126. package/src/resources/extensions/gsd/tests/mcp-status.test.ts +2 -0
  127. package/src/resources/extensions/gsd/tests/post-unit-hooks.test.ts +157 -0
  128. package/src/resources/extensions/gsd/tests/post-unit-retry-on-orchestrator-bridge.test.ts +179 -0
  129. package/src/resources/extensions/gsd/tests/preferences.test.ts +29 -0
  130. package/src/resources/extensions/gsd/tests/prompt-contracts.test.ts +22 -1
  131. package/src/resources/extensions/gsd/tests/prompt-loader-extension-dir.test.ts +14 -0
  132. package/src/resources/extensions/gsd/tests/rule-registry.test.ts +75 -0
  133. package/src/resources/extensions/gsd/tests/validate-milestone-prompt-verification-classes.test.ts +6 -3
  134. package/src/resources/extensions/gsd/tests/validate-milestone-write-order.test.ts +133 -0
  135. package/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +74 -0
  136. package/src/resources/extensions/gsd/tools/validate-milestone.ts +46 -15
  137. package/src/resources/extensions/gsd/tools/workflow-tool-executors.ts +31 -14
  138. package/src/resources/extensions/gsd/types.ts +63 -0
  139. package/src/resources/extensions/gsd/verdict-parser.ts +54 -13
  140. package/src/resources/extensions/shared/gsd-browser-cli.ts +172 -0
  141. /package/dist/web/standalone/.next/static/{StOMnvtgGiBHrBOZJZ1Gr → JdwzU6IGLVBZPf84PIaJQ}/_buildManifest.js +0 -0
  142. /package/dist/web/standalone/.next/static/{StOMnvtgGiBHrBOZJZ1Gr → JdwzU6IGLVBZPf84PIaJQ}/_ssgManifest.js +0 -0
@@ -8,6 +8,7 @@ import { mock } from "node:test";
8
8
  import { postUnitPostVerification, type PostUnitContext } from "../auto-post-unit.ts";
9
9
  import { AutoSession } from "../auto/session.ts";
10
10
  import { checkPostUnitHooks, resetHookState, resolveHookArtifactPath } from "../post-unit-hooks.ts";
11
+ import { emitJournalEvent } from "../journal.ts";
11
12
  import { _clearGsdRootCache } from "../paths.ts";
12
13
  import { invalidateAllCaches } from "../cache.ts";
13
14
 
@@ -28,6 +29,43 @@ post_unit_hooks:
28
29
  writeFileSync(join(basePath, ".gsd", "PREFERENCES.md"), content, "utf-8");
29
30
  }
30
31
 
32
+ function writeFailingHookPreferences(basePath: string): void {
33
+ const content = `---
34
+ post_unit_hooks:
35
+ - name: review-arbiter
36
+ after:
37
+ - execute-task
38
+ prompt: Review {taskId}
39
+ artifact: REVIEW-DEBATE.md
40
+ max_cycles: 1
41
+ enabled: true
42
+ - name: follow-up-review
43
+ after:
44
+ - execute-task
45
+ prompt: Follow-up review {taskId}
46
+ enabled: true
47
+ ---
48
+ `;
49
+ writeFileSync(join(basePath, ".gsd", "PREFERENCES.md"), content, "utf-8");
50
+ }
51
+
52
+ function writeBlockingPreferences(basePath: string): void {
53
+ const content = `---
54
+ post_unit_hooks:
55
+ - name: review-arbiter
56
+ after:
57
+ - execute-task
58
+ prompt: Review {taskId}
59
+ agent: arbiter
60
+ artifact: REVIEW-DEBATE.md
61
+ criticality: blocking
62
+ max_cycles: 2
63
+ enabled: true
64
+ ---
65
+ `;
66
+ writeFileSync(join(basePath, ".gsd", "PREFERENCES.md"), content, "utf-8");
67
+ }
68
+
31
69
  test("post-unit retry_on marks trigger unit as retry in orchestrator before redispatch", async () => {
32
70
  const originalCwd = process.cwd();
33
71
  const base = mkdtempSync(join(tmpdir(), "gsd-post-unit-retry-"));
@@ -91,3 +129,144 @@ test("post-unit retry_on marks trigger unit as retry in orchestrator before redi
91
129
  rmSync(base, { recursive: true, force: true });
92
130
  }
93
131
  });
132
+
133
+ test("failed post-unit hook pauses auto-mode even when its artifact exists", async () => {
134
+ const originalCwd = process.cwd();
135
+ const base = mkdtempSync(join(tmpdir(), "gsd-post-unit-hook-failed-"));
136
+ const taskDir = join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks");
137
+ mkdirSync(taskDir, { recursive: true });
138
+
139
+ try {
140
+ process.chdir(base);
141
+ _clearGsdRootCache();
142
+ invalidateAllCaches();
143
+ resetHookState();
144
+ writeFailingHookPreferences(base);
145
+
146
+ const hookDispatch = checkPostUnitHooks("execute-task", "M001/S01/T01", base);
147
+ assert.equal(hookDispatch?.hookName, "review-arbiter");
148
+
149
+ const artifactPath = resolveHookArtifactPath(base, "M001/S01/T01", "REVIEW-DEBATE.md");
150
+ writeFileSync(artifactPath, "partial review", "utf-8");
151
+ emitJournalEvent(base, {
152
+ ts: "2026-06-03T12:00:00.000Z",
153
+ flowId: "flow-hook-failed",
154
+ seq: 3,
155
+ eventType: "unit-end",
156
+ data: {
157
+ unitType: "hook/review-arbiter",
158
+ unitId: "M001/S01/T01",
159
+ status: "cancelled",
160
+ artifactVerified: false,
161
+ },
162
+ });
163
+
164
+ const pauseAuto = mock.fn(async () => {});
165
+ const notifications: string[] = [];
166
+ const s = new AutoSession();
167
+ s.basePath = base;
168
+ s.active = true;
169
+ s.currentUnit = { type: "hook/review-arbiter", id: "M001/S01/T01", startedAt: Date.now() };
170
+
171
+ const pctx: PostUnitContext = {
172
+ s,
173
+ ctx: {
174
+ ui: {
175
+ notify: (message: string) => { notifications.push(message); },
176
+ setStatus: () => {},
177
+ setWidget: () => {},
178
+ setFooter: () => {},
179
+ },
180
+ model: { id: "test-model" },
181
+ } as any,
182
+ pi: { sendMessage: async () => {}, setModel: async () => true } as any,
183
+ buildSnapshotOpts: () => ({}),
184
+ lockBase: () => base,
185
+ stopAuto: async () => {},
186
+ pauseAuto,
187
+ updateProgressWidget: () => {},
188
+ };
189
+
190
+ const result = await postUnitPostVerification(pctx);
191
+ assert.equal(result, "stopped");
192
+ assert.equal(pauseAuto.mock.callCount(), 1);
193
+ assert.ok(
194
+ notifications.some(message => message.includes("Post-unit hook review-arbiter failed")),
195
+ "pause notification should explain the failed hook",
196
+ );
197
+ } finally {
198
+ process.chdir(originalCwd);
199
+ resetHookState();
200
+ invalidateAllCaches();
201
+ _clearGsdRootCache();
202
+ rmSync(base, { recursive: true, force: true });
203
+ }
204
+ });
205
+
206
+ test("post-unit blocking gate pauses auto-mode on needs-attention verdict", async () => {
207
+ const originalCwd = process.cwd();
208
+ const base = mkdtempSync(join(tmpdir(), "gsd-post-unit-gate-"));
209
+ const taskDir = join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks");
210
+ mkdirSync(taskDir, { recursive: true });
211
+
212
+ try {
213
+ process.chdir(base);
214
+ _clearGsdRootCache();
215
+ invalidateAllCaches();
216
+ resetHookState();
217
+ writeBlockingPreferences(base);
218
+
219
+ const hookDispatch = checkPostUnitHooks("execute-task", "M001/S01/T01", base);
220
+ assert.ok(hookDispatch, "hook should dispatch for execute-task");
221
+
222
+ const artifactPath = resolveHookArtifactPath(base, "M001/S01/T01", "REVIEW-DEBATE.md");
223
+ writeFileSync(artifactPath, "---\nverdict: needs-attention\n---\n\nManual review required.\n", "utf-8");
224
+
225
+ const pauseAuto = mock.fn(async () => {});
226
+ const s = new AutoSession();
227
+ s.basePath = base;
228
+ s.active = true;
229
+ s.currentUnit = { type: "hook/review-arbiter", id: "M001/S01/T01", startedAt: Date.now() };
230
+ s.orchestration = {
231
+ start: async () => ({ kind: "started" }),
232
+ advance: async () => ({ kind: "stopped", reason: "unused" }),
233
+ completeActiveUnit: async () => {},
234
+ retryActiveUnit: async () => {},
235
+ resume: async () => ({ kind: "resumed" }),
236
+ stop: async (reason: string) => ({ kind: "stopped", reason }),
237
+ getStatus: () => ({ phase: "running", transitionCount: 0 }),
238
+ };
239
+
240
+ const notifications: string[] = [];
241
+ const pctx: PostUnitContext = {
242
+ s,
243
+ ctx: {
244
+ ui: {
245
+ notify: (message: string) => { notifications.push(message); },
246
+ setStatus: () => {},
247
+ setWidget: () => {},
248
+ setFooter: () => {},
249
+ },
250
+ model: { id: "test-model" },
251
+ } as any,
252
+ pi: { sendMessage: async () => {}, setModel: async () => true } as any,
253
+ buildSnapshotOpts: () => ({}),
254
+ lockBase: () => base,
255
+ stopAuto: async () => {},
256
+ pauseAuto,
257
+ updateProgressWidget: () => {},
258
+ };
259
+
260
+ const result = await postUnitPostVerification(pctx);
261
+ assert.equal(result, "stopped");
262
+ assert.equal(pauseAuto.mock.callCount(), 1);
263
+ assert.match(notifications.join("\n"), /Post-unit gate "review-arbiter" blocked execute-task M001\/S01\/T01/);
264
+ assert.match(notifications.join("\n"), /\/gsd status/);
265
+ } finally {
266
+ process.chdir(originalCwd);
267
+ resetHookState();
268
+ invalidateAllCaches();
269
+ _clearGsdRootCache();
270
+ rmSync(base, { recursive: true, force: true });
271
+ }
272
+ });
@@ -561,6 +561,35 @@ test("post-unit hook max_cycles clamping via validatePreferences", () => {
561
561
  assert.equal(p4.post_unit_hooks![0].max_cycles, 3, "valid value passes through");
562
562
  });
563
563
 
564
+ test("post-unit hook criticality and on_block validation", () => {
565
+ const base = { name: "h", after: ["execute-task"], prompt: "do something", artifact: "REVIEW.md" };
566
+
567
+ const { preferences, errors } = validatePreferences({
568
+ post_unit_hooks: [{
569
+ ...base,
570
+ criticality: "blocking",
571
+ on_block: { action: "retry-unit", artifact: "NEEDS-REWORK.md" },
572
+ }],
573
+ } as any);
574
+ assert.equal(errors.length, 0);
575
+ assert.equal(preferences.post_unit_hooks![0].criticality, "blocking");
576
+ assert.deepEqual(preferences.post_unit_hooks![0].on_block, {
577
+ action: "retry-unit",
578
+ artifact: "NEEDS-REWORK.md",
579
+ });
580
+
581
+ const missingArtifact = validatePreferences({
582
+ post_unit_hooks: [{ name: "blocking", after: ["execute-task"], prompt: "do something", criticality: "blocking" }],
583
+ } as any);
584
+ assert.match(missingArtifact.errors.join("\n"), /criticality blocking requires artifact/);
585
+ assert.equal(missingArtifact.preferences.post_unit_hooks, undefined);
586
+
587
+ const invalidAction = validatePreferences({
588
+ post_unit_hooks: [{ ...base, on_block: { action: "teleport" } }],
589
+ } as any);
590
+ assert.match(invalidAction.errors.join("\n"), /invalid on_block action/);
591
+ });
592
+
564
593
  test("pre-dispatch hook action validation via validatePreferences", () => {
565
594
  const base = { name: "h", before: ["execute-task"] };
566
595
 
@@ -2,6 +2,7 @@ import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { readFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
+ import { RUN_UAT_WORKFLOW_TOOL_NAMES } from "../tool-presentation-plan.ts";
5
6
 
6
7
  const promptsDir = join(process.cwd(), "src/resources/extensions/gsd/prompts");
7
8
  const templatesDir = join(process.cwd(), "src/resources/extensions/gsd/templates");
@@ -25,11 +26,15 @@ test("reactive-execute prompt keeps task summaries with subagents and avoids bat
25
26
  test("run-uat prompt branches on dynamic UAT mode and supports runtime evidence", () => {
26
27
  const prompt = readPrompt("run-uat");
27
28
  assert.match(prompt, /\*\*Detected UAT mode:\*\*\s*`\{\{uatType\}\}`/);
28
- assert.match(prompt, /uatType:\s*\{\{uatType\}\}/);
29
+ assert.match(prompt, /uatType:\s*"\{\{uatType\}\}"/);
30
+ assert.match(prompt, /gsd_uat_result_save/);
31
+ assert.match(prompt, /presentedTools/);
32
+ assert.match(prompt, /blockedTools/);
29
33
  assert.match(prompt, /live-runtime/);
30
34
  assert.match(prompt, /browser\/runtime\/network/i);
31
35
  assert.match(prompt, /NEEDS-HUMAN/);
32
36
  assert.doesNotMatch(prompt, /uatType:\s*artifact-driven/);
37
+ assert.doesNotMatch(prompt, /Call `gsd_summary_save`/);
33
38
  });
34
39
 
35
40
  test("run-uat prompt lists canonical gsd_uat_exec intent values", () => {
@@ -42,6 +47,22 @@ test("run-uat prompt lists canonical gsd_uat_exec intent values", () => {
42
47
  assert.match(prompt, /do not use `artifact`, `runtime`, or `human-follow-up` as `intent`/i);
43
48
  });
44
49
 
50
+ test("run-uat prompt gives the complete UAT result-save presentation contract", () => {
51
+ const prompt = readPrompt("run-uat");
52
+ assert.match(prompt, /Call `gsd_uat_result_save` once after all checks are complete/);
53
+ assert.doesNotMatch(prompt, /Call `gsd_summary_save` with `artifact_type: "ASSESSMENT"`/);
54
+
55
+ for (const toolName of RUN_UAT_WORKFLOW_TOOL_NAMES) {
56
+ assert.ok(prompt.includes(`"${toolName}"`), `prompt should include required presented tool ${toolName}`);
57
+ }
58
+
59
+ for (const toolName of ["gsd_exec", "gsd_summary_save", "gsd_save_gate_result"] as const) {
60
+ assert.ok(prompt.includes(`name: "${toolName}"`), `prompt should include blocked tool ${toolName}`);
61
+ }
62
+
63
+ assert.ok(prompt.includes("forbidden during run-uat"), "prompt should explain blocked run-uat tools");
64
+ });
65
+
45
66
  test("workflow-start prompt defaults to autonomy instead of per-phase confirmation", () => {
46
67
  const prompt = readPrompt("workflow-start");
47
68
  assert.match(prompt, /Keep moving by default/i);
@@ -22,6 +22,20 @@ test("resolveExtensionDirFromCandidates prefers user-local dir when both trees a
22
22
  assert.equal(resolved, agentDir);
23
23
  });
24
24
 
25
+ test("resolveExtensionDirFromCandidates prefers source checkout dir when both trees are valid", () => {
26
+ const moduleDir = "/repo/src/resources/extensions/gsd";
27
+ const agentDir = "/home/user/.gsd/agent/extensions/gsd";
28
+ const paths = new Set<string>([
29
+ join(moduleDir, "prompts"),
30
+ join(moduleDir, "templates", "task-summary.md"),
31
+ join(agentDir, "prompts"),
32
+ join(agentDir, "templates", "task-summary.md"),
33
+ ]);
34
+
35
+ const resolved = resolveExtensionDirFromCandidates(moduleDir, agentDir, makeExists(paths));
36
+ assert.equal(resolved, moduleDir);
37
+ });
38
+
25
39
  test("resolveExtensionDirFromCandidates rejects module dir missing task-summary template", () => {
26
40
  const moduleDir = "/npm/global/gsd";
27
41
  const agentDir = "/home/user/.gsd/agent/extensions/gsd";
@@ -8,6 +8,7 @@ import { test, describe, beforeEach } from "node:test";
8
8
  import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
9
9
  import { tmpdir } from "node:os";
10
10
  import { join } from "node:path";
11
+ import { emitJournalEvent } from "../journal.ts";
11
12
  import {
12
13
  RuleRegistry,
13
14
  getRegistry,
@@ -16,6 +17,7 @@ import {
16
17
  resetRegistry,
17
18
  convertDispatchRules,
18
19
  getOrCreateRegistry,
20
+ resolveHookArtifactPath,
19
21
  } from "../rule-registry.ts";
20
22
  import type { UnifiedRule } from "../rule-types.ts";
21
23
  import type { DispatchAction, DispatchContext } from "../auto-dispatch.ts";
@@ -443,6 +445,79 @@ describe("RuleRegistry", () => {
443
445
  }
444
446
  });
445
447
 
448
+ test("failed hook completion with an artifact does not dequeue the next hook", () => {
449
+ const originalGsdHome = process.env.GSD_HOME;
450
+ const projectRoot = mkdtempSync(join(tmpdir(), "gsd-hook-failed-"));
451
+ const tempGsdHome = mkdtempSync(join(tmpdir(), "gsd-hook-home-"));
452
+ const unitId = "M001/S01/T01";
453
+
454
+ try {
455
+ mkdirSync(join(projectRoot, ".gsd", "milestones", "M001", "slices", "S01", "tasks"), { recursive: true });
456
+ writeFileSync(
457
+ join(projectRoot, ".gsd", "PREFERENCES.md"),
458
+ [
459
+ "---",
460
+ "version: 1",
461
+ "post_unit_hooks:",
462
+ " - name: review-arbiter",
463
+ " after: [execute-task]",
464
+ " prompt: Review {taskId}",
465
+ " artifact: REVIEW.md",
466
+ " max_cycles: 1",
467
+ " - name: follow-up-review",
468
+ " after: [execute-task]",
469
+ " prompt: Follow-up review {taskId}",
470
+ "---",
471
+ ].join("\n"),
472
+ "utf-8",
473
+ );
474
+ process.env.GSD_HOME = tempGsdHome;
475
+
476
+ const registry = new RuleRegistry([]);
477
+ const firstHook = registry.evaluatePostUnit("execute-task", unitId, projectRoot);
478
+ assert.equal(firstHook?.hookName, "review-arbiter");
479
+
480
+ writeFileSync(
481
+ resolveHookArtifactPath(projectRoot, unitId, "REVIEW.md"),
482
+ "partial review output",
483
+ "utf-8",
484
+ );
485
+ emitJournalEvent(projectRoot, {
486
+ ts: "2026-06-03T12:00:00.000Z",
487
+ flowId: "flow-hook-failed",
488
+ seq: 3,
489
+ eventType: "unit-end",
490
+ data: {
491
+ unitType: "hook/review-arbiter",
492
+ unitId,
493
+ status: "cancelled",
494
+ artifactVerified: false,
495
+ errorContext: {
496
+ message: "Provider error: Stream ended without finish_reason",
497
+ category: "provider",
498
+ },
499
+ },
500
+ });
501
+
502
+ const nextHook = registry.evaluatePostUnit("hook/review-arbiter", unitId, projectRoot);
503
+ assert.equal(nextHook, null, "failed hook must not allow follow-up hook dispatch");
504
+ const failure = registry.consumeHookFailure();
505
+ assert.equal(failure?.hookName, "review-arbiter");
506
+ assert.match(failure?.reason ?? "", /status cancelled/);
507
+
508
+ const resumedRegistry = new RuleRegistry([]);
509
+ resumedRegistry.restoreState(projectRoot);
510
+ const resumedHook = resumedRegistry.evaluatePostUnit("execute-task", unitId, projectRoot);
511
+ assert.equal(resumedHook, null, "resumed hook evaluation must not skip failed hook artifact");
512
+ assert.equal(resumedRegistry.consumeHookFailure()?.hookName, "review-arbiter");
513
+ } finally {
514
+ if (originalGsdHome === undefined) delete process.env.GSD_HOME;
515
+ else process.env.GSD_HOME = originalGsdHome;
516
+ rmSync(projectRoot, { recursive: true, force: true });
517
+ rmSync(tempGsdHome, { recursive: true, force: true });
518
+ }
519
+ });
520
+
446
521
  // ── matchedRule provenance (S02 journal support) ───────────────────
447
522
 
448
523
  test("evaluateDispatch result includes matchedRule on dispatch match", async () => {
@@ -11,11 +11,14 @@ const prompt = readFileSync(promptPath, "utf-8");
11
11
 
12
12
  test("validate-milestone reviewer C requires canonical verification class names", () => {
13
13
  assert.match(prompt, /\*\*Reviewer C[\s\S]*Verification Classes/i);
14
- assert.match(prompt, /exact class names [`']?Contract[`']?, [`']?Integration[`']?, [`']?Operational[`']?, and [`']?UAT[`']?/i);
14
+ assert.match(prompt, /must be exactly `Contract`, `Integration`, `Operational`, or `UAT`/i);
15
+ assert.match(prompt, /Preserve every planned non-empty class row/i);
16
+ assert.match(prompt, /first cell of each row must be exactly `Contract`, `Integration`, `Operational`, or `UAT`/i);
15
17
  assert.match(prompt, /If no verification classes were planned, say that explicitly/i);
16
18
  });
17
19
 
18
20
  test("validate-milestone prompt routes verification class analysis into verificationClasses", () => {
19
- assert.match(prompt, /pass it in `verificationClasses`/i);
20
- assert.match(prompt, /Extract the `Verification Classes` subsection from Reviewer C and pass it verbatim in `verificationClasses`/);
21
+ assert.match(prompt, /pass a complete canonical table in `verificationClasses`/i);
22
+ assert.match(prompt, /If Reviewer C omitted a planned class, reconstruct the missing row/i);
23
+ assert.match(prompt, /Do not call `gsd_validate_milestone` with a partial `verificationClasses` table/i);
21
24
  });
@@ -180,6 +180,35 @@ describe("handleValidateMilestone write ordering (#2725)", () => {
180
180
  assert.equal(row, undefined, "assessment row should not be written when verification classes are invalid");
181
181
  });
182
182
 
183
+ it("reports all missing planned verification class rows at once", async () => {
184
+ base = makeTmpBase();
185
+ const dbPath = join(base, ".gsd", "gsd.db");
186
+ openDatabase(dbPath);
187
+ insertMilestone({
188
+ id: "M001",
189
+ planning: {
190
+ verificationContract: "Contract command exits 0",
191
+ verificationOperational: "Process lifecycle proof",
192
+ verificationUat: "Browser-observable UAT proof",
193
+ },
194
+ });
195
+ insertSlice({ id: "S01", milestoneId: "M001" });
196
+
197
+ const result = await handleValidateMilestone(
198
+ { ...VALID_PARAMS, verificationClasses: "| Check | Result |\n| --- | --- |\n| Generic verification | PASS |" },
199
+ base,
200
+ );
201
+ assert.ok("error" in result, "expected validation to fail");
202
+ assert.match(result.error, /canonical rows "Contract", "Operational", "UAT"/);
203
+ assert.match(result.error, /planned contract, operational, uat verification/);
204
+
205
+ const adapter = _getAdapter()!;
206
+ const row = adapter.prepare(
207
+ `SELECT status FROM assessments WHERE milestone_id = 'M001' AND scope = 'milestone-validation'`,
208
+ ).get() as { status: string } | undefined;
209
+ assert.equal(row, undefined, "assessment row should not be written when verification classes are invalid");
210
+ });
211
+
183
212
  it("accepts verificationClasses when planned Operational class is present", async () => {
184
213
  base = makeTmpBase();
185
214
  const dbPath = join(base, ".gsd", "gsd.db");
@@ -406,6 +435,110 @@ describe("handleValidateMilestone write ordering (#2725)", () => {
406
435
  assert.equal(result.verdict, "pass");
407
436
  });
408
437
 
438
+ it("keeps pass when browser-like criteria are verified by runtime-executable UAT", async () => {
439
+ base = makeTmpBase();
440
+ const dbPath = join(base, ".gsd", "gsd.db");
441
+ openDatabase(dbPath);
442
+ insertMilestone({
443
+ id: "M001",
444
+ planning: {
445
+ successCriteria: [
446
+ "Clicking Mark All Complete sets all todos completed",
447
+ "Reload keeps completed state",
448
+ ],
449
+ verificationUat: "Run the Node.js DOM-state script against the static app source.",
450
+ },
451
+ });
452
+ insertSlice({
453
+ id: "S01",
454
+ milestoneId: "M001",
455
+ // Uses localhost so hasBrowserRequiredText returns true and the gate is
456
+ // actually triggered before the runtime evidence bypasses it.
457
+ demo: "Visit localhost:3000 to verify DOM state after clicking Mark All Complete.",
458
+ });
459
+ const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01");
460
+ mkdirSync(sliceDir, { recursive: true });
461
+ writeFileSync(
462
+ join(sliceDir, "S01-ASSESSMENT.md"),
463
+ [
464
+ "---",
465
+ "sliceId: S01",
466
+ "uatType: runtime-executable",
467
+ "verdict: PASS",
468
+ "attempt: 1",
469
+ "---",
470
+ "# UAT Result - S01",
471
+ "",
472
+ "## Checks",
473
+ "",
474
+ "| Check | Mode | Result | Evidence | Notes |",
475
+ "|-------|------|--------|----------|-------|",
476
+ "| DOM-state script | runtime | PASS | gsd_uat_exec:.gsd/evidence/uat/M001/S01/dom-state.json | Runtime assertion verified completed state and reload persistence. |",
477
+ "",
478
+ ].join("\n"),
479
+ "utf-8",
480
+ );
481
+
482
+ const result = await handleValidateMilestone(
483
+ {
484
+ ...VALID_PARAMS,
485
+ verificationClasses:
486
+ `${VALID_PARAMS.verificationClasses}\n| UAT | Runtime executable UAT verified static-app behavior. |`,
487
+ },
488
+ base,
489
+ );
490
+
491
+ assert.ok(!("error" in result), `unexpected error: ${"error" in result ? result.error : ""}`);
492
+ assert.equal(result.verdict, "pass");
493
+ });
494
+
495
+ it("downgrades to needs-attention when only one of two browser-requiring slices has runtime evidence", async () => {
496
+ base = makeTmpBase();
497
+ const dbPath = join(base, ".gsd", "gsd.db");
498
+ openDatabase(dbPath);
499
+ insertMilestone({ id: "M001" });
500
+ insertSlice({
501
+ id: "S01",
502
+ milestoneId: "M001",
503
+ demo: "Visit localhost:3000 to verify DOM state.",
504
+ });
505
+ insertSlice({
506
+ id: "S02",
507
+ milestoneId: "M001",
508
+ demo: "Visit localhost:3000 to confirm persistence after reload.",
509
+ });
510
+ // S01 has runtime-executable evidence; S02 has none.
511
+ mkdirSync(join(base, ".gsd", "milestones", "M001", "slices", "S01"), { recursive: true });
512
+ writeFileSync(
513
+ join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-ASSESSMENT.md"),
514
+ [
515
+ "---",
516
+ "sliceId: S01",
517
+ "uatType: runtime-executable",
518
+ "verdict: PASS",
519
+ "---",
520
+ "| DOM check | runtime | PASS | gsd_uat_exec:.gsd/evidence/uat/M001/S01/dom.json | Verified. |",
521
+ "",
522
+ ].join("\n"),
523
+ "utf-8",
524
+ );
525
+
526
+ const result = await handleValidateMilestone(
527
+ {
528
+ ...VALID_PARAMS,
529
+ verificationClasses: `${VALID_PARAMS.verificationClasses}\n| UAT | S01 runtime verified; S02 still needs evidence. |`,
530
+ },
531
+ base,
532
+ );
533
+
534
+ assert.ok(!("error" in result), `unexpected error: ${"error" in result ? result.error : ""}`);
535
+ assert.equal(
536
+ result.verdict,
537
+ "needs-attention",
538
+ "S01 runtime evidence must not bypass the gate for S02 which has browser requirements but no evidence",
539
+ );
540
+ });
541
+
409
542
  it("ignores slice full_uat_md planning text for browser requirement detection", async () => {
410
543
  base = makeTmpBase();
411
544
  const dbPath = join(base, ".gsd", "gsd.db");
@@ -643,6 +643,80 @@ test("executeUatResultSave accepts gsd_uat_exec evidence written in a milestone
643
643
  }
644
644
  });
645
645
 
646
+ test("executeUatResultSave rejects artifact-driven PASS with human follow-up checks", async () => {
647
+ const base = makeTmpBase();
648
+ const worktree = join(base, ".gsd", "worktrees", "M001");
649
+ const evidenceId = "uat-artifact-nonautomatable";
650
+ const worktreeExecDir = join(worktree, ".gsd", "exec");
651
+ try {
652
+ openTestDb(base);
653
+ seedMilestone("M001", "Milestone One");
654
+ seedSlice("M001", "S01", "complete");
655
+ mkdirSync(worktreeExecDir, { recursive: true });
656
+ writeFileSync(
657
+ join(worktreeExecDir, `${evidenceId}.meta.json`),
658
+ JSON.stringify({
659
+ id: evidenceId,
660
+ metadata: {
661
+ kind: "uat_exec",
662
+ milestoneId: "M001",
663
+ sliceId: "S01",
664
+ checkId: "UAT-01",
665
+ intent: "uat-artifact-check",
666
+ },
667
+ }),
668
+ "utf-8",
669
+ );
670
+
671
+ const result = await inProjectDir(worktree, () => executeUatResultSave({
672
+ milestoneId: "M001",
673
+ sliceId: "S01",
674
+ uatType: "artifact-driven",
675
+ verdict: "PASS",
676
+ checks: [
677
+ {
678
+ id: "UAT-01",
679
+ description: "Static contract passes",
680
+ mode: "artifact",
681
+ result: "PASS",
682
+ evidence: [{ kind: "gsd_uat_exec", ref: evidenceId }],
683
+ notes: "Artifact check passed.",
684
+ },
685
+ {
686
+ id: "UAT-02",
687
+ description: "Browser polish is deferred to the next slice",
688
+ mode: "human-follow-up",
689
+ result: "NEEDS-HUMAN",
690
+ notes: "Out of scope for this artifact-driven UAT.",
691
+ nonAutomatable: true,
692
+ },
693
+ ],
694
+ presentation: {
695
+ surface: "mcp",
696
+ presentedTools: [
697
+ "gsd_uat_exec",
698
+ "gsd_uat_result_save",
699
+ "gsd_resume",
700
+ "gsd_milestone_status",
701
+ "gsd_journal_query",
702
+ ],
703
+ blockedTools: [
704
+ { name: "gsd_exec", reason: "forbidden during run-uat" },
705
+ { name: "gsd_summary_save", reason: "forbidden during run-uat" },
706
+ { name: "gsd_save_gate_result", reason: "forbidden during run-uat" },
707
+ ],
708
+ },
709
+ notes: "UAT passed; non-automatable browser polish is deferred.",
710
+ }, worktree));
711
+
712
+ assert.equal(result.isError, true);
713
+ assert.match(String(result.content[0]?.text), /artifact-driven UAT cannot PASS with human-only checks/);
714
+ } finally {
715
+ closeDatabase();
716
+ cleanup(base);
717
+ }
718
+ });
719
+
646
720
  test("executeSliceComplete coerces string enrichment entries and writes summary/UAT artifacts", async () => {
647
721
  const base = makeTmpBase();
648
722
  try {