@opengsd/gsd-pi 1.3.0-dev.65546769 → 1.3.0-dev.eed73bea

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 (183) hide show
  1. package/dist/resources/.managed-resources-content-hash +1 -1
  2. package/dist/resources/extensions/claude-code-cli/stream-adapter.js +11 -2
  3. package/dist/resources/extensions/google-cli/stream-adapter.js +82 -15
  4. package/dist/resources/extensions/gsd/auto/orchestrator.js +12 -3
  5. package/dist/resources/extensions/gsd/auto-dispatch.js +17 -14
  6. package/dist/resources/extensions/gsd/auto-prompts.js +43 -12
  7. package/dist/resources/extensions/gsd/auto-recovery.js +13 -6
  8. package/dist/resources/extensions/gsd/bootstrap/agent-end-recovery.js +103 -13
  9. package/dist/resources/extensions/gsd/bootstrap/db-tools.js +6 -1
  10. package/dist/resources/extensions/gsd/bootstrap/exec-tools.js +2 -0
  11. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +8 -3
  12. package/dist/resources/extensions/gsd/bootstrap/system-context.js +46 -19
  13. package/dist/resources/extensions/gsd/bootstrap/tool-call-loop-guard.js +75 -1
  14. package/dist/resources/extensions/gsd/bootstrap/write-gate.js +1 -1
  15. package/dist/resources/extensions/gsd/commands-context.js +19 -1
  16. package/dist/resources/extensions/gsd/commands-prefs-wizard.js +16 -10
  17. package/dist/resources/extensions/gsd/commands-worktree.js +12 -10
  18. package/dist/resources/extensions/gsd/dashboard-overlay.js +32 -3
  19. package/dist/resources/extensions/gsd/db/queries.js +60 -0
  20. package/dist/resources/extensions/gsd/doctor-providers.js +92 -8
  21. package/dist/resources/extensions/gsd/exec-sandbox.js +45 -9
  22. package/dist/resources/extensions/gsd/forensics.js +2 -32
  23. package/dist/resources/extensions/gsd/git-service.js +4 -4
  24. package/dist/resources/extensions/gsd/guided-flow-queue.js +59 -5
  25. package/dist/resources/extensions/gsd/health-widget.js +55 -29
  26. package/dist/resources/extensions/gsd/markdown-renderer.js +6 -2
  27. package/dist/resources/extensions/gsd/memory-consolidation-scanner.js +44 -21
  28. package/dist/resources/extensions/gsd/milestone-implementation-evidence.js +26 -20
  29. package/dist/resources/extensions/gsd/quick.js +45 -2
  30. package/dist/resources/extensions/gsd/session-forensics.js +11 -1
  31. package/dist/resources/extensions/gsd/state-reconciliation/drift/stale-render.js +52 -3
  32. package/dist/resources/extensions/gsd/tools/complete-slice.js +34 -3
  33. package/dist/resources/extensions/gsd/tools/complete-task.js +78 -16
  34. package/dist/resources/extensions/gsd/tools/exec-tool.js +7 -2
  35. package/dist/resources/extensions/gsd/unit-context-composer.js +23 -7
  36. package/dist/resources/extensions/gsd/unit-registry.js +25 -3
  37. package/dist/resources/extensions/gsd/unmerged-milestone-guard.js +33 -3
  38. package/dist/resources/extensions/gsd/validation-block-guard.js +9 -4
  39. package/dist/resources/extensions/gsd/workspace-git-preflight.js +30 -1
  40. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  41. package/dist/web/standalone/.next/BUILD_ID +1 -1
  42. package/dist/web/standalone/.next/app-path-routes-manifest.json +14 -14
  43. package/dist/web/standalone/.next/build-manifest.json +3 -3
  44. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  45. package/dist/web/standalone/.next/react-loadable-manifest.json +1 -1
  46. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  47. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  48. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  52. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  53. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  54. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  55. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  56. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  57. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  58. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  59. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  60. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  61. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  62. package/dist/web/standalone/.next/server/app/api/visualizer/route.js +1 -1
  63. package/dist/web/standalone/.next/server/app/index.html +1 -1
  64. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  65. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  66. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  67. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  68. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  69. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  70. package/dist/web/standalone/.next/server/app-paths-manifest.json +14 -14
  71. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  72. package/dist/web/standalone/.next/server/middleware-react-loadable-manifest.js +1 -1
  73. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  74. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  75. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  76. package/dist/web/standalone/.next/static/chunks/{796.e0bdc932325d7e03.js → 796.3976108148518f7d.js} +3 -3
  77. package/dist/web/standalone/.next/static/chunks/{webpack-f46ea08200a0227e.js → webpack-7c1d97e39be2da11.js} +1 -1
  78. package/package.json +1 -1
  79. package/packages/cloud-mcp-gateway/package.json +2 -2
  80. package/packages/contracts/dist/workflow.d.ts +1 -0
  81. package/packages/contracts/dist/workflow.d.ts.map +1 -1
  82. package/packages/contracts/dist/workflow.js +2 -0
  83. package/packages/contracts/dist/workflow.js.map +1 -1
  84. package/packages/contracts/package.json +1 -1
  85. package/packages/daemon/package.json +4 -4
  86. package/packages/gsd-agent-core/package.json +5 -5
  87. package/packages/gsd-agent-modes/dist/modes/interactive/controllers/chat-controller.d.ts.map +1 -1
  88. package/packages/gsd-agent-modes/dist/modes/interactive/controllers/chat-controller.js +21 -9
  89. package/packages/gsd-agent-modes/dist/modes/interactive/controllers/chat-controller.js.map +1 -1
  90. package/packages/gsd-agent-modes/package.json +7 -7
  91. package/packages/mcp-server/README.md +1 -1
  92. package/packages/mcp-server/dist/server.d.ts +1 -1
  93. package/packages/mcp-server/dist/server.d.ts.map +1 -1
  94. package/packages/mcp-server/dist/server.js +3 -3
  95. package/packages/mcp-server/dist/server.js.map +1 -1
  96. package/packages/mcp-server/dist/workflow-tools.d.ts +13 -1
  97. package/packages/mcp-server/dist/workflow-tools.d.ts.map +1 -1
  98. package/packages/mcp-server/dist/workflow-tools.js +34 -20
  99. package/packages/mcp-server/dist/workflow-tools.js.map +1 -1
  100. package/packages/mcp-server/package.json +4 -4
  101. package/packages/native/package.json +1 -1
  102. package/packages/pi-agent-core/package.json +1 -1
  103. package/packages/pi-ai/package.json +1 -1
  104. package/packages/pi-coding-agent/package.json +7 -7
  105. package/packages/pi-tui/package.json +2 -2
  106. package/packages/rpc-client/package.json +2 -2
  107. package/pkg/package.json +1 -1
  108. package/src/resources/extensions/claude-code-cli/stream-adapter.ts +20 -2
  109. package/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts +80 -0
  110. package/src/resources/extensions/google-cli/stream-adapter.ts +106 -19
  111. package/src/resources/extensions/gsd/auto/orchestrator.ts +25 -11
  112. package/src/resources/extensions/gsd/auto-dispatch.ts +18 -17
  113. package/src/resources/extensions/gsd/auto-prompts.ts +54 -12
  114. package/src/resources/extensions/gsd/auto-recovery.ts +13 -6
  115. package/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +125 -12
  116. package/src/resources/extensions/gsd/bootstrap/db-tools.ts +6 -1
  117. package/src/resources/extensions/gsd/bootstrap/exec-tools.ts +2 -0
  118. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +9 -3
  119. package/src/resources/extensions/gsd/bootstrap/system-context.ts +52 -18
  120. package/src/resources/extensions/gsd/bootstrap/tool-call-loop-guard.ts +82 -1
  121. package/src/resources/extensions/gsd/bootstrap/write-gate.ts +1 -1
  122. package/src/resources/extensions/gsd/commands-context.ts +18 -1
  123. package/src/resources/extensions/gsd/commands-prefs-wizard.ts +14 -9
  124. package/src/resources/extensions/gsd/commands-worktree.ts +12 -10
  125. package/src/resources/extensions/gsd/dashboard-overlay.ts +32 -3
  126. package/src/resources/extensions/gsd/db/queries.ts +79 -0
  127. package/src/resources/extensions/gsd/doctor-providers.ts +103 -9
  128. package/src/resources/extensions/gsd/exec-sandbox.ts +49 -9
  129. package/src/resources/extensions/gsd/forensics.ts +2 -33
  130. package/src/resources/extensions/gsd/git-service.ts +5 -5
  131. package/src/resources/extensions/gsd/guided-flow-queue.ts +82 -4
  132. package/src/resources/extensions/gsd/health-widget.ts +69 -32
  133. package/src/resources/extensions/gsd/markdown-renderer.ts +6 -1
  134. package/src/resources/extensions/gsd/memory-consolidation-scanner.ts +51 -19
  135. package/src/resources/extensions/gsd/milestone-implementation-evidence.ts +35 -21
  136. package/src/resources/extensions/gsd/quick.ts +43 -2
  137. package/src/resources/extensions/gsd/session-forensics.ts +11 -1
  138. package/src/resources/extensions/gsd/state-reconciliation/drift/stale-render.ts +76 -8
  139. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +111 -1
  140. package/src/resources/extensions/gsd/tests/commands-context.test.ts +26 -0
  141. package/src/resources/extensions/gsd/tests/commands-worktree-clean.test.ts +80 -0
  142. package/src/resources/extensions/gsd/tests/complete-slice.test.ts +11 -0
  143. package/src/resources/extensions/gsd/tests/complete-task-rollback-evidence.test.ts +48 -8
  144. package/src/resources/extensions/gsd/tests/complete-task.test.ts +75 -0
  145. package/src/resources/extensions/gsd/tests/dashboard-overlay.test.ts +55 -2
  146. package/src/resources/extensions/gsd/tests/dispatch-rule-coverage.test.ts +26 -1
  147. package/src/resources/extensions/gsd/tests/doctor-forensics-db-open-regression.test.ts +70 -2
  148. package/src/resources/extensions/gsd/tests/doctor-providers.test.ts +107 -0
  149. package/src/resources/extensions/gsd/tests/exec-graceful-kill.test.ts +38 -0
  150. package/src/resources/extensions/gsd/tests/exec-tool.test.ts +45 -1
  151. package/src/resources/extensions/gsd/tests/forensics-error-filter.test.ts +88 -0
  152. package/src/resources/extensions/gsd/tests/guided-discuss-milestone-prompt-rendering.test.ts +42 -0
  153. package/src/resources/extensions/gsd/tests/health-widget.test.ts +268 -3
  154. package/src/resources/extensions/gsd/tests/integration/git-service.test.ts +119 -1
  155. package/src/resources/extensions/gsd/tests/integration/queue-active-milestone-context-budget.test.ts +93 -0
  156. package/src/resources/extensions/gsd/tests/integration/quick-branch-lifecycle.test.ts +56 -9
  157. package/src/resources/extensions/gsd/tests/knowledge-cold-start.test.ts +14 -0
  158. package/src/resources/extensions/gsd/tests/memory-consolidation-scanner.test.ts +78 -0
  159. package/src/resources/extensions/gsd/tests/orchestrator-logs.test.ts +43 -1
  160. package/src/resources/extensions/gsd/tests/parallel-research-dispatch.test.ts +26 -0
  161. package/src/resources/extensions/gsd/tests/pipeline-variant-dispatch.test.ts +1 -1
  162. package/src/resources/extensions/gsd/tests/prefs-wizard-coverage.test.ts +54 -1
  163. package/src/resources/extensions/gsd/tests/provider-errors.test.ts +195 -1
  164. package/src/resources/extensions/gsd/tests/read-uat-gate-verdict.test.ts +185 -0
  165. package/src/resources/extensions/gsd/tests/register-hooks-depth-verification.test.ts +87 -0
  166. package/src/resources/extensions/gsd/tests/state-reconciliation-drift.test.ts +76 -0
  167. package/src/resources/extensions/gsd/tests/tool-call-loop-guard.test.ts +68 -0
  168. package/src/resources/extensions/gsd/tests/tool-param-optionality.test.ts +26 -0
  169. package/src/resources/extensions/gsd/tests/unit-context-composer.test.ts +193 -14
  170. package/src/resources/extensions/gsd/tests/unmerged-milestone-guard.test.ts +25 -0
  171. package/src/resources/extensions/gsd/tests/validation-block-guard.test.ts +79 -0
  172. package/src/resources/extensions/gsd/tests/verify-artifact-tightened.test.ts +66 -0
  173. package/src/resources/extensions/gsd/tests/workspace-git-preflight.test.ts +151 -2
  174. package/src/resources/extensions/gsd/tools/complete-slice.ts +30 -3
  175. package/src/resources/extensions/gsd/tools/complete-task.ts +86 -16
  176. package/src/resources/extensions/gsd/tools/exec-tool.ts +7 -3
  177. package/src/resources/extensions/gsd/unit-context-composer.ts +33 -7
  178. package/src/resources/extensions/gsd/unit-registry.ts +25 -3
  179. package/src/resources/extensions/gsd/unmerged-milestone-guard.ts +41 -5
  180. package/src/resources/extensions/gsd/validation-block-guard.ts +13 -7
  181. package/src/resources/extensions/gsd/workspace-git-preflight.ts +31 -0
  182. /package/dist/web/standalone/.next/static/{BTKtGFF1Y-hvVJEGhBRo9 → SzEuqWX37DR9MEpEuQjP1}/_buildManifest.js +0 -0
  183. /package/dist/web/standalone/.next/static/{BTKtGFF1Y-hvVJEGhBRo9 → SzEuqWX37DR9MEpEuQjP1}/_ssgManifest.js +0 -0
@@ -16,6 +16,7 @@ import { execSync } from "node:child_process";
16
16
 
17
17
  import { captureIntegrationBranch, getCurrentBranch } from "../../worktree.ts";
18
18
  import { readIntegrationBranch, QUICK_BRANCH_RE } from "../../git-service.ts";
19
+ import { disableDebug, enableDebug, getDebugCounters } from "../../debug-logger.ts";
19
20
 
20
21
  function run(command: string, cwd: string): string {
21
22
  return execSync(command, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
@@ -205,16 +206,25 @@ test('cleanupQuickBranch: recovers from disk state (cross-session)', async () =>
205
206
  test('cleanupQuickBranch: no-op without pending state', async () => {
206
207
  const repo = createTestRepo();
207
208
  const origCwd = process.cwd();
208
- process.chdir(repo);
209
-
210
- const { cleanupQuickBranch } = await import("../../quick.ts");
211
- const result = cleanupQuickBranch();
212
-
213
- assert.ok(!result, "returns false when no pending state");
214
- assert.deepStrictEqual(getCurrentBranch(repo), "main", "stays on main");
209
+ try {
210
+ process.chdir(repo);
211
+ enableDebug(repo);
215
212
 
216
- process.chdir(origCwd);
217
- rmSync(repo, { recursive: true, force: true });
213
+ const { cleanupQuickBranch } = await import("../../quick.ts");
214
+ const result = cleanupQuickBranch();
215
+ const firstGitInvocations = getDebugCounters().gitInvocations;
216
+ const secondResult = cleanupQuickBranch();
217
+
218
+ assert.ok(!result, "returns false when no pending state");
219
+ assert.ok(!secondResult, "still returns false when no pending state");
220
+ assert.deepStrictEqual(getDebugCounters().gitInvocations, firstGitInvocations,
221
+ "cached no-state cleanup does not re-run git branch inference");
222
+ assert.deepStrictEqual(getCurrentBranch(repo), "main", "stays on main");
223
+ } finally {
224
+ disableDebug();
225
+ process.chdir(origCwd);
226
+ rmSync(repo, { recursive: true, force: true });
227
+ }
218
228
  });
219
229
 
220
230
  test('cleanupQuickBranch: infers return state from current gsd/quick branch', async () => {
@@ -240,6 +250,43 @@ test('cleanupQuickBranch: infers return state from current gsd/quick branch', as
240
250
  }
241
251
  });
242
252
 
253
+ // ═══════════════════════════════════════════════════════════════════════
254
+ // cleanupQuickBranch: stale miss invalidated after mid-session branch switch
255
+ // ═══════════════════════════════════════════════════════════════════════
256
+ test('cleanupQuickBranch: clears stale miss when branch switches to gsd/quick mid-session', async () => {
257
+ const repo = createTestRepo();
258
+ const origCwd = process.cwd();
259
+ try {
260
+ // Create a quick branch with real product work (so inference finds a diff)
261
+ run("git checkout -b gsd/quick/3-stale-miss", repo);
262
+ writeFileSync(join(repo, "stale.txt"), "stale miss test\n");
263
+ run("git add stale.txt", repo);
264
+ run('git commit -m "test: stale miss"', repo);
265
+ // Return to main so the first cleanupQuickBranch call records a miss
266
+ run("git checkout main", repo);
267
+
268
+ process.chdir(repo);
269
+ const { cleanupQuickBranch } = await import("../../quick.ts");
270
+
271
+ // First call: on main with no disk state → miss recorded (keyed to "main")
272
+ const result1 = cleanupQuickBranch();
273
+ assert.ok(!result1, "first call (on main) returns false — miss recorded");
274
+
275
+ // Simulate mid-session external branch switch to the stranded quick branch
276
+ run("git checkout gsd/quick/3-stale-miss", repo);
277
+
278
+ // Second call: branch changed from the recorded miss, so cache is invalidated
279
+ // and inferQuickReturnFromBranch runs — cleanup must succeed
280
+ const result2 = cleanupQuickBranch();
281
+ assert.ok(result2, "second call returns true after mid-session switch to quick branch");
282
+ assert.deepStrictEqual(getCurrentBranch(repo), "main",
283
+ "cleanup merged back to main after stale-miss invalidation");
284
+ } finally {
285
+ process.chdir(origCwd);
286
+ rmSync(repo, { recursive: true, force: true });
287
+ }
288
+ });
289
+
243
290
  // ═══════════════════════════════════════════════════════════════════════
244
291
  // End-to-end: quick branch does NOT contaminate integration branch
245
292
  // ═══════════════════════════════════════════════════════════════════════
@@ -136,8 +136,10 @@ test("#896 startup maintenance skips repeated sentinel work in one session", asy
136
136
  closeDatabase();
137
137
  assert.equal(isDbAvailable(), false);
138
138
 
139
+ let sessionId = "session-one";
139
140
  const ctx = {
140
141
  projectRoot: base,
142
+ sessionManager: { getSessionId: () => sessionId },
141
143
  ui: { notify: () => undefined },
142
144
  } as unknown as ExtensionContext;
143
145
 
@@ -190,6 +192,18 @@ test("#896 startup maintenance skips repeated sentinel work in one session", asy
190
192
  .prepare("SELECT COUNT(*) AS count FROM memories WHERE structured_fields LIKE '%\"sourceKnowledgeId\":\"P002\"%'")
191
193
  .get() as { count: number };
192
194
  assert.equal(secondPass.count, 0, "second startup in same session should not re-run KNOWLEDGE.md sentinel backfill");
195
+
196
+ sessionId = "session-two";
197
+ await buildBeforeAgentStartResult(
198
+ { prompt: "Inspect project knowledge in a new session", systemPrompt: "base system prompt" },
199
+ ctx,
200
+ );
201
+ await _flushDeferredContextMaintenanceForTest(base);
202
+
203
+ const nextSessionPass = adapter
204
+ .prepare("SELECT COUNT(*) AS count FROM memories WHERE structured_fields LIKE '%\"sourceKnowledgeId\":\"P002\"%'")
205
+ .get() as { count: number };
206
+ assert.equal(nextSessionPass.count, 1, "startup maintenance should run once for a later session");
193
207
  });
194
208
 
195
209
  test("#896 later turns reopen the project DB after startup maintenance is complete", async (t) => {
@@ -16,6 +16,7 @@ import {
16
16
  openDatabase,
17
17
  closeDatabase,
18
18
  insertDecision,
19
+ _getAdapter,
19
20
  } from "../gsd-db.ts";
20
21
  import { createMemory } from "../memory-store.ts";
21
22
  import {
@@ -271,6 +272,83 @@ test("scanConsolidationGaps combines decisions and KNOWLEDGE.md gaps in summary"
271
272
  }
272
273
  });
273
274
 
275
+ test("scanConsolidationGaps collects memory source markers with one memories scan", () => {
276
+ const base = makeTmpBase();
277
+ try {
278
+ insertDecision({
279
+ id: "D001",
280
+ when_context: "2026-01-01",
281
+ scope: "M001",
282
+ decision: "Unmigrated decision",
283
+ choice: "A",
284
+ rationale: "x",
285
+ revisable: "yes",
286
+ made_by: "agent",
287
+ superseded_by: null,
288
+ });
289
+ insertDecision({
290
+ id: "D002",
291
+ when_context: "2026-01-02",
292
+ scope: "M001",
293
+ decision: "Migrated decision",
294
+ choice: "B",
295
+ rationale: "y",
296
+ revisable: "yes",
297
+ made_by: "agent",
298
+ superseded_by: null,
299
+ });
300
+ createMemory({
301
+ category: "architecture",
302
+ content: "Migrated decision Chose: B. Rationale: y.",
303
+ scope: "M001",
304
+ structuredFields: { sourceDecisionId: "D002" },
305
+ });
306
+ createMemory({
307
+ category: "pattern",
308
+ content: "Migrated knowledge pattern",
309
+ scope: "project",
310
+ structuredFields: { sourceKnowledgeId: "P001" },
311
+ });
312
+ writeKnowledgeMd(
313
+ base,
314
+ `## Patterns
315
+
316
+ | # | Pattern | Where | Notes |
317
+ |---|---------|-------|-------|
318
+ | P001 | Migrated knowledge pattern | scanner | covered |
319
+ | P002 | Unmigrated knowledge pattern | scanner | gap |
320
+ `,
321
+ );
322
+
323
+ const adapter = _getAdapter();
324
+ assert.ok(adapter);
325
+ const originalPrepare = adapter.prepare.bind(adapter);
326
+ const memorySql: string[] = [];
327
+ adapter.prepare = (sql: string) => {
328
+ if (sql.includes("FROM memories")) memorySql.push(sql);
329
+ return originalPrepare(sql);
330
+ };
331
+
332
+ const report = scanConsolidationGaps(base);
333
+ assert.equal(report.decisions.migrated, 1);
334
+ assert.equal(report.decisions.unmigrated, 1);
335
+ assert.equal(report.knowledge.migrated, 1);
336
+ assert.equal(report.knowledge.unmigrated, 1);
337
+ assert.equal(
338
+ memorySql.filter((sql) => sql.includes("SELECT structured_fields FROM memories")).length,
339
+ 1,
340
+ "source marker detection should read memories once",
341
+ );
342
+ assert.equal(
343
+ memorySql.some((sql) => sql.includes("LIKE :pattern")),
344
+ false,
345
+ "scanner must not reintroduce per-row LIKE marker probes",
346
+ );
347
+ } finally {
348
+ cleanup(base);
349
+ }
350
+ });
351
+
274
352
  // ─── reportConsolidationGaps ───────────────────────────────────────────────
275
353
 
276
354
  test("reportConsolidationGaps emits a notification + warning when gaps exist", () => {
@@ -36,6 +36,7 @@ import {
36
36
  import { resolveExpectedArtifactPath } from "../auto-artifact-paths.ts";
37
37
  import { AutoSession } from "../auto/session.ts";
38
38
  import { acquireSessionLock, releaseSessionLock } from "../session-lock.ts";
39
+ import { clearGSDPreferencesCache } from "../preferences.ts";
39
40
  import { invalidateAllCaches } from "../cache.ts";
40
41
  import { invalidateStateCache } from "../state.ts";
41
42
  import {
@@ -63,12 +64,15 @@ interface FixtureOptions {
63
64
  dispatch?: UnifiedRule["where"];
64
65
  /** Drop the gate_runs table after seeding so emitUokGate's insertGateRun throws (:538 path). */
65
66
  dropGateRuns?: boolean;
67
+ /** Write project preferences that disable UOK gate telemetry. */
68
+ disableUokGates?: boolean;
66
69
  }
67
70
 
68
71
  interface Fixture {
69
72
  base: string;
70
73
  session: AutoSession;
71
74
  orchestrator: ReturnType<typeof createAutoOrchestrator>;
75
+ getAvailableCalls(): number;
72
76
  cleanup(): void;
73
77
  }
74
78
 
@@ -82,6 +86,7 @@ function makeFixture(opts: FixtureOptions = {}): Fixture {
82
86
 
83
87
  invalidateAllCaches();
84
88
  invalidateStateCache();
89
+ clearGSDPreferencesCache();
85
90
  openDatabase(join(base, ".gsd", "gsd.db"));
86
91
  insertMilestone({ id: "M001", title: "Milestone", status: "active" });
87
92
  insertSlice({ id: "S01", milestoneId: "M001", title: "Slice", status: "active", risk: "low", depends: [], demo: "", sequence: 1 });
@@ -100,6 +105,13 @@ function makeFixture(opts: FixtureOptions = {}): Fixture {
100
105
  join(sliceDir, "S01-PLAN.md"),
101
106
  ["# S01: Slice", "", "**Goal:** g", "**Demo:** d", "", "## Tasks", "", "- [ ] **T01: Task** `est:1h`", ""].join("\n"),
102
107
  );
108
+ if (opts.disableUokGates) {
109
+ writeFileSync(
110
+ join(base, ".gsd", "PREFERENCES.md"),
111
+ "---\nuok:\n gates:\n enabled: false\n---\n",
112
+ );
113
+ clearGSDPreferencesCache();
114
+ }
103
115
 
104
116
  acquireSessionLock(base);
105
117
 
@@ -112,8 +124,19 @@ function makeFixture(opts: FixtureOptions = {}): Fixture {
112
124
  session.currentMilestoneId = "M001";
113
125
  session.resourceVersionOnStart = null;
114
126
 
127
+ let getAvailableCalls = 0;
115
128
  const ctx: OrchestratorContext = {
116
- ctx: { model: {}, modelRegistry: { getAll: () => [], getAvailable: () => [] }, ui: { notify() {} } } as never,
129
+ ctx: {
130
+ model: {},
131
+ modelRegistry: {
132
+ getAll: () => [],
133
+ getAvailable: () => {
134
+ getAvailableCalls += 1;
135
+ return [];
136
+ },
137
+ },
138
+ ui: { notify() {} },
139
+ } as never,
117
140
  pi: { getActiveTools: () => [] } as never,
118
141
  dispatchBasePath: base,
119
142
  runtimeBasePath: base,
@@ -140,8 +163,10 @@ function makeFixture(opts: FixtureOptions = {}): Fixture {
140
163
  base,
141
164
  session,
142
165
  orchestrator,
166
+ getAvailableCalls: () => getAvailableCalls,
143
167
  cleanup() {
144
168
  resetRegistry();
169
+ clearGSDPreferencesCache();
145
170
  try { releaseSessionLock(base); } catch { /* */ }
146
171
  try { closeDatabase(); } catch { /* */ }
147
172
  try { rmSync(base, { recursive: true, force: true }); } catch { /* */ }
@@ -210,6 +235,23 @@ test("advance() logs an engine warning when the uok gate emit fails (orchestrato
210
235
  assert.ok(uokFail!.context?.gateId, "the failing gate id must be captured in context");
211
236
  });
212
237
 
238
+ test("advance() resolves disabled uok gate flags once before gate emission", async (t) => {
239
+ const f = makeFixture({ dropGateRuns: true, disableUokGates: true });
240
+ t.after(() => f.cleanup());
241
+
242
+ const { logs } = await captureLogs(() => f.orchestrator.advance());
243
+
244
+ assert.equal(
245
+ logs.some((e) => e.component === "engine" && /uok gate emit failed/u.test(e.message)),
246
+ false,
247
+ "disabled uok gates must not construct the runner or write gate rows",
248
+ );
249
+ assert.ok(
250
+ f.getAvailableCalls() <= 2,
251
+ `uok gate preferences should be resolved once per advance, not once per emitted gate (getAvailable calls: ${f.getAvailableCalls()})`,
252
+ );
253
+ });
254
+
213
255
  // orchestrator.ts:637 — mergePendingCompleteMilestone catches a
214
256
  // rebuildMarkdownProjectionsFromDb failure after the system-owned milestone
215
257
  // merge and logs `markdown projection rebuild after settlement merge failed`.
@@ -145,6 +145,32 @@ describe("parallel-research-slices dispatch rule", () => {
145
145
  }
146
146
  });
147
147
 
148
+ test("dispatches parallel research for S01 even when milestone research exists", async () => {
149
+ writeRoadmap(base, "M001", [
150
+ { id: "S01", title: "Alpha" },
151
+ { id: "S02", title: "Beta" },
152
+ ]);
153
+ writeFileSync(
154
+ join(base, ".gsd", "milestones", "M001", "M001-RESEARCH.md"),
155
+ "# Milestone Research\n",
156
+ "utf-8",
157
+ );
158
+
159
+ const action = await resolveDispatch({
160
+ basePath: base,
161
+ mid: "M001",
162
+ midTitle: "Parallel Research Milestone",
163
+ state: baseState(),
164
+ prefs: undefined,
165
+ });
166
+
167
+ assert.equal(action.action, "dispatch");
168
+ if (action.action === "dispatch") {
169
+ assert.equal(action.unitType, "research-slice");
170
+ assert.equal(action.unitId, "M001/parallel-research");
171
+ }
172
+ });
173
+
148
174
  test("does not dispatch parallel research with only one ready slice", async () => {
149
175
  writeRoadmap(base, "M001", [{ id: "S01", title: "Alpha" }]);
150
176
 
@@ -22,7 +22,7 @@ import { invalidateAllCaches } from "../cache.ts";
22
22
  import type { GSDState } from "../types.ts";
23
23
 
24
24
  const PARALLEL_RESEARCH_RULE = "planning (multiple slices need research) → parallel-research-slices";
25
- const SINGLE_RESEARCH_RULE = "planning (no research, not S01) → research-slice";
25
+ const SINGLE_RESEARCH_RULE = "planning (no research) → research-slice";
26
26
  const VALIDATE_RULE = "validating-milestone → validate-milestone";
27
27
 
28
28
  // ─── Fixture helpers ──────────────────────────────────────────────────────
@@ -6,7 +6,12 @@ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { tmpdir } from "node:os";
8
8
 
9
- import { buildCategorySummaries, handlePrefsWizard } from "../commands-prefs-wizard.ts";
9
+ import {
10
+ buildCategorySummaries,
11
+ handlePrefsWizard,
12
+ serializePreferencesToFrontmatter,
13
+ } from "../commands-prefs-wizard.ts";
14
+ import { parsePreferencesMarkdown, validatePreferences } from "../preferences.ts";
10
15
  import { KNOWN_PREFERENCE_KEYS } from "../preferences-types.ts";
11
16
 
12
17
  const PREF_SAMPLE_VALUES: Record<string, unknown> = {
@@ -103,6 +108,54 @@ const PREF_SAMPLE_VALUES: Record<string, unknown> = {
103
108
  },
104
109
  };
105
110
 
111
+ test("prefs serializer preserves nested hook on_block objects", () => {
112
+ const frontmatter = serializePreferencesToFrontmatter({
113
+ post_unit_hooks: [{
114
+ name: "plan-review",
115
+ after: ["execute-task"],
116
+ prompt: "write PLAN-REVIEW.md",
117
+ artifact: "PLAN-REVIEW.md",
118
+ criticality: "blocking",
119
+ on_block: { action: "pause" },
120
+ }],
121
+ });
122
+
123
+ assert.doesNotMatch(frontmatter, /\[object Object\]/);
124
+ assert.ok(frontmatter.includes(" on_block:\n action: pause\n"));
125
+
126
+ const parsed = parsePreferencesMarkdown(`---\n${frontmatter}---\n`);
127
+ assert.notEqual(parsed, null);
128
+
129
+ const { errors, preferences } = validatePreferences(parsed!);
130
+ assert.deepEqual(errors, []);
131
+ assert.deepEqual(preferences.post_unit_hooks?.[0]?.on_block, { action: "pause" });
132
+ });
133
+
134
+ test("prefs serializer correctly indents nested object when it is the first array-item property", () => {
135
+ // on_block is the FIRST property — exercises the first-key-is-object branch
136
+ const frontmatter = serializePreferencesToFrontmatter({
137
+ post_unit_hooks: [{
138
+ on_block: { action: "pause" }, // first property — exercises the first-key-is-object branch
139
+ name: "gate", // required fields come after so on_block stays first
140
+ after: ["execute-task"],
141
+ prompt: "review output",
142
+ }],
143
+ });
144
+
145
+ assert.doesNotMatch(frontmatter, /\[object Object\]/);
146
+ // Children of on_block must be indented deeper than on_block itself (not siblings)
147
+ assert.ok(
148
+ frontmatter.includes(" - on_block:\n action: pause\n"),
149
+ `Expected on_block children indented as nested YAML, got:\n${frontmatter}`,
150
+ );
151
+ // Sanity: parse + validate round-trip
152
+ const parsed = parsePreferencesMarkdown(`---\n${frontmatter}---\n`);
153
+ assert.notEqual(parsed, null);
154
+ const { errors, preferences } = validatePreferences(parsed!);
155
+ assert.deepEqual(errors, []);
156
+ assert.deepEqual(preferences.post_unit_hooks?.[0]?.on_block, { action: "pause" });
157
+ });
158
+
106
159
  test("prefs wizard save path preserves every known preference key", async () => {
107
160
  const missingSamples = [...KNOWN_PREFERENCE_KEYS].filter((key) => !(key in PREF_SAMPLE_VALUES));
108
161
  assert.deepEqual(missingSamples, [], "test fixture must cover every known preference key");
@@ -7,7 +7,7 @@
7
7
 
8
8
  import test from "node:test";
9
9
  import assert from "node:assert/strict";
10
- import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
10
+ import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
11
11
  import { tmpdir } from "node:os";
12
12
  import { join } from "node:path";
13
13
  import { classifyError, isTransient, isTransientNetworkError } from "../error-classifier.ts";
@@ -26,6 +26,10 @@ import { _buildCancelledUnitStopReason } from "../auto/phase-helpers.ts";
26
26
  import { _classifyZeroToolProviderMessageForTest } from "../auto/unit-phase.ts";
27
27
  import { autoSession } from "../auto-runtime-state.ts";
28
28
  import { getNextFallbackModel } from "../preferences.ts";
29
+ import { clearGuidedUnitContext, getGuidedUnitContext, setGuidedUnitContext } from "../guided-unit-context.ts";
30
+ import { initNotificationStore, readNotifications, _resetNotificationStore } from "../notification-store.ts";
31
+ import { installNotifyInterceptor } from "../bootstrap/notify-interceptor.ts";
32
+ import { extractTrace } from "../session-forensics.ts";
29
33
  // Zero-import module — imported by path rather than through the package
30
34
  // barrel to avoid pulling the full AgentSession / @gsd/pi-ai dep graph into
31
35
  // this unit test (see #4837).
@@ -488,6 +492,63 @@ test("pauseAutoForProviderError falls back to indefinite pause when not rate lim
488
492
  ]);
489
493
  });
490
494
 
495
+ test("agent_end retries when empty errorMessage has stream failure in content (#956)", async () => {
496
+ const originalSetTimeout = globalThis.setTimeout;
497
+ const notifications: Array<{ message: string; level?: string }> = [];
498
+ const sendMessageCalls: unknown[][] = [];
499
+ const timers: Array<{ fn: () => void; delay: number }> = [];
500
+
501
+ resetTransientRetryState();
502
+ autoSession.reset();
503
+ // handleAgentEnd returns at the isAutoActive() guard unless auto-mode is
504
+ // active. Set the minimum fields needed to reach the stopReason === "error"
505
+ // branch without requiring a real DB or worktree.
506
+ autoSession.active = true;
507
+ autoSession.currentUnit = { type: "execute-task", id: "M001/S01/T01", startedAt: Date.now() };
508
+
509
+ globalThis.setTimeout = ((fn: () => void, delay?: number) => {
510
+ timers.push({ fn, delay: delay ?? 0 });
511
+ return 0 as unknown as ReturnType<typeof setTimeout>;
512
+ }) as typeof setTimeout;
513
+
514
+ try {
515
+ await handleAgentEnd({
516
+ sendMessage: (...args: unknown[]) => {
517
+ sendMessageCalls.push(args);
518
+ },
519
+ } as any, {
520
+ messages: [{
521
+ role: "assistant",
522
+ stopReason: "error",
523
+ errorMessage: "",
524
+ content: [{ type: "text", text: "API Error: stream idle timeout - partial response received" }],
525
+ }],
526
+ } as any, {
527
+ model: { provider: "openai-codex", id: "gpt-5.5" },
528
+ ui: {
529
+ notify(message: string, level?: "info" | "warning" | "error" | "success") {
530
+ notifications.push({ message, level });
531
+ },
532
+ },
533
+ } as any);
534
+
535
+ assert.equal(timers.length, 1, "empty errorMessage stream failures should use the network retry path");
536
+ assert.equal(timers[0].delay, 3_000);
537
+ assert.deepEqual(notifications[0], {
538
+ message: "Network error on gpt-5.5: API Error: stream idle timeout - partial response received. Retry 1/2 in 3s...",
539
+ level: "warning",
540
+ });
541
+
542
+ timers[0].fn();
543
+ assert.equal(sendMessageCalls.length, 1);
544
+ assert.deepEqual(sendMessageCalls[0][1], { triggerTurn: true });
545
+ } finally {
546
+ globalThis.setTimeout = originalSetTimeout;
547
+ resetTransientRetryState();
548
+ autoSession.reset();
549
+ }
550
+ });
551
+
491
552
  test("rate-limit agent_end walks past unavailable fallback models before pausing (#716 follow-up)", async () => {
492
553
  const originalCwd = process.cwd();
493
554
  const originalSetTimeout = globalThis.setTimeout;
@@ -648,6 +709,139 @@ test("does not suppress deleted-worktree provider errors outside terminal comple
648
709
  assert.equal(event.message.stopReason, "error");
649
710
  });
650
711
 
712
+ test("manual guided discuss provider error records warning and activity marker (#944)", async () => {
713
+ const originalCwd = process.cwd();
714
+ const base = mkdtempSync(join(tmpdir(), "gsd-manual-discuss-error-"));
715
+ const notifications: Array<{ message: string; level?: string }> = [];
716
+ const sendMessageCalls: unknown[][] = [];
717
+
718
+ try {
719
+ autoSession.reset();
720
+ mkdirSync(join(base, ".git"), { recursive: true });
721
+ mkdirSync(join(base, ".gsd"), { recursive: true });
722
+ process.chdir(base);
723
+ initNotificationStore(base);
724
+
725
+ // Use base as the guided context path so gsdRoot(base) hits the fast path
726
+ // (.gsd exists at base directly) and doesn't need git to walk up from a
727
+ // subdirectory — the empty .git folder is not a real repo and git resolution
728
+ // from a child directory would return the wrong .gsd path.
729
+ setGuidedUnitContext(base, "discuss-slice");
730
+
731
+ const ctx = {
732
+ model: { provider: "openai-codex", id: "gpt-5.1-codex" },
733
+ modelRegistry: { getAvailable: () => [] },
734
+ ui: {
735
+ notify(message: string, level?: "info" | "warning" | "error" | "success") {
736
+ notifications.push({ message, level });
737
+ },
738
+ },
739
+ } as any;
740
+ installNotifyInterceptor(ctx);
741
+
742
+ const pi = {
743
+ sendMessage: (...args: unknown[]) => {
744
+ sendMessageCalls.push(args);
745
+ },
746
+ } as any;
747
+
748
+ await handleAgentEnd(pi, {
749
+ messages: [{
750
+ role: "assistant",
751
+ stopReason: "error",
752
+ errorMessage: "",
753
+ content: [{ type: "text", text: "stream idle timeout while saving summary" }],
754
+ }],
755
+ } as any, ctx);
756
+
757
+ assert.deepEqual(sendMessageCalls, [], "manual discuss terminal errors must not auto-retry or redispatch");
758
+ assert.equal(getGuidedUnitContext(base), null, "guided unit context must still be cleared after the turn");
759
+ assert.equal(notifications.length, 1);
760
+ assert.equal(notifications[0]?.level, "warning");
761
+ assert.match(notifications[0]?.message ?? "", /Manual \/gsd discuss discuss-slice ended with a provider error/);
762
+ assert.match(notifications[0]?.message ?? "", /openai-codex\/gpt-5\.1-codex/);
763
+ assert.match(notifications[0]?.message ?? "", /stream idle timeout/);
764
+
765
+ const persisted = readNotifications(base);
766
+ assert.equal(persisted.length, 1, "wrapped notify should persist exactly one warning notification");
767
+ assert.equal(persisted[0]?.severity, "warning");
768
+ assert.equal(persisted[0]?.source, "notify");
769
+
770
+ const activityDir = join(base, ".gsd", "activity");
771
+ const files = readdirSync(activityDir).filter((file) => file.endsWith(".jsonl"));
772
+ assert.equal(files.length, 1, "manual guided provider error should write one activity marker");
773
+ const entries = readFileSync(join(activityDir, files[0]!), "utf-8")
774
+ .split("\n")
775
+ .filter(Boolean)
776
+ .map((line) => JSON.parse(line));
777
+ const trace = extractTrace(entries);
778
+ assert.equal(trace.errors.length, 1);
779
+ assert.match(trace.errors[0] ?? "", /discuss-slice/);
780
+ assert.match(trace.errors[0] ?? "", /openai-codex\/gpt-5\.1-codex/);
781
+ assert.match(trace.errors[0] ?? "", /stream idle timeout/);
782
+ } finally {
783
+ clearGuidedUnitContext();
784
+ _resetNotificationStore();
785
+ autoSession.reset();
786
+ process.chdir(originalCwd);
787
+ rmSync(base, { recursive: true, force: true });
788
+ }
789
+ });
790
+
791
+ test("manual guided discuss user-cancel is not treated as a provider error (#944)", async () => {
792
+ const originalCwd = process.cwd();
793
+ const base = mkdtempSync(join(tmpdir(), "gsd-manual-discuss-cancel-"));
794
+ const guidedBase = join(base, "slice-work");
795
+ const notifications: Array<{ message: string; level?: string }> = [];
796
+
797
+ try {
798
+ autoSession.reset();
799
+ mkdirSync(join(base, ".git"), { recursive: true });
800
+ mkdirSync(join(base, ".gsd"), { recursive: true });
801
+ mkdirSync(guidedBase, { recursive: true });
802
+ process.chdir(base);
803
+ initNotificationStore(base);
804
+
805
+ setGuidedUnitContext(guidedBase, "discuss-slice");
806
+
807
+ const ctx = {
808
+ model: { provider: "anthropic", id: "claude-opus-4" },
809
+ modelRegistry: { getAvailable: () => [] },
810
+ ui: {
811
+ notify(message: string, level?: "info" | "warning" | "error" | "success") {
812
+ notifications.push({ message, level });
813
+ },
814
+ },
815
+ } as any;
816
+ installNotifyInterceptor(ctx);
817
+
818
+ const pi = { sendMessage: () => {} } as any;
819
+
820
+ await handleAgentEnd(pi, {
821
+ messages: [{
822
+ role: "assistant",
823
+ stopReason: "error",
824
+ errorMessage: "Request aborted by user",
825
+ content: [],
826
+ }],
827
+ } as any, ctx);
828
+
829
+ assert.deepEqual(notifications, [], "user-cancel stopReason=error must not emit a provider-error warning");
830
+ assert.equal(getGuidedUnitContext(guidedBase), null, "context must be cleared even for user-cancel");
831
+
832
+ // No activity directory should have been created
833
+ let activityExists = false;
834
+ try { readdirSync(join(base, ".gsd", "activity")); activityExists = true; } catch { /* expected */ }
835
+ assert.equal(activityExists, false, "user-cancel must not write an activity error marker");
836
+ } finally {
837
+ clearGuidedUnitContext();
838
+ _resetNotificationStore();
839
+ autoSession.reset();
840
+ process.chdir(originalCwd);
841
+ rmSync(base, { recursive: true, force: true });
842
+ }
843
+ });
844
+
651
845
  // ── resumeAutoAfterProviderDelay ────────────────────────────────────────────
652
846
 
653
847
  test("resumeAutoAfterProviderDelay restarts paused auto-mode from the recorded base path", async () => {