gsd-pi 2.66.1-dev.9a14d3d → 2.66.1-dev.e700a1b

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 (91) hide show
  1. package/dist/resources/extensions/ask-user-questions.js +79 -11
  2. package/dist/resources/extensions/gsd/auto-model-selection.js +11 -2
  3. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +49 -2
  4. package/dist/resources/extensions/gsd/bootstrap/write-gate.js +111 -0
  5. package/dist/resources/extensions/gsd/codebase-generator.js +4 -0
  6. package/dist/resources/extensions/gsd/detection.js +6 -0
  7. package/dist/resources/extensions/gsd/index.js +1 -1
  8. package/dist/resources/extensions/gsd/prompts/complete-milestone.md +1 -1
  9. package/dist/resources/extensions/gsd/prompts/discuss-prepared.md +7 -7
  10. package/dist/resources/extensions/gsd/prompts/discuss.md +3 -3
  11. package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +2 -2
  12. package/dist/resources/extensions/gsd/prompts/guided-discuss-slice.md +3 -1
  13. package/dist/resources/extensions/gsd/prompts/rethink.md +6 -2
  14. package/dist/resources/extensions/gsd/prompts/system.md +1 -1
  15. package/dist/resources/extensions/gsd/prompts/triage-captures.md +1 -1
  16. package/dist/resources/extensions/gsd/prompts/worktree-merge.md +3 -1
  17. package/dist/resources/extensions/remote-questions/manager.js +8 -0
  18. package/dist/web/standalone/.next/BUILD_ID +1 -1
  19. package/dist/web/standalone/.next/app-path-routes-manifest.json +19 -19
  20. package/dist/web/standalone/.next/build-manifest.json +2 -2
  21. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  22. package/dist/web/standalone/.next/server/app/_global-error.html +2 -2
  23. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  24. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  25. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  31. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/index.html +1 -1
  39. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app-paths-manifest.json +19 -19
  46. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  47. package/dist/web/standalone/.next/server/pages/500.html +2 -2
  48. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  49. package/package.json +1 -1
  50. package/packages/pi-coding-agent/dist/core/retry-handler.d.ts +4 -3
  51. package/packages/pi-coding-agent/dist/core/retry-handler.d.ts.map +1 -1
  52. package/packages/pi-coding-agent/dist/core/retry-handler.js +6 -5
  53. package/packages/pi-coding-agent/dist/core/retry-handler.js.map +1 -1
  54. package/packages/pi-coding-agent/dist/core/retry-handler.test.js +19 -0
  55. package/packages/pi-coding-agent/dist/core/retry-handler.test.js.map +1 -1
  56. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/provider-display-name.test.d.ts +2 -0
  57. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/provider-display-name.test.d.ts.map +1 -0
  58. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/provider-display-name.test.js +15 -0
  59. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/provider-display-name.test.js.map +1 -0
  60. package/packages/pi-coding-agent/dist/modes/interactive/components/model-selector.d.ts +1 -0
  61. package/packages/pi-coding-agent/dist/modes/interactive/components/model-selector.d.ts.map +1 -1
  62. package/packages/pi-coding-agent/dist/modes/interactive/components/model-selector.js +9 -2
  63. package/packages/pi-coding-agent/dist/modes/interactive/components/model-selector.js.map +1 -1
  64. package/packages/pi-coding-agent/src/core/retry-handler.test.ts +22 -0
  65. package/packages/pi-coding-agent/src/core/retry-handler.ts +6 -5
  66. package/packages/pi-coding-agent/src/modes/interactive/components/__tests__/provider-display-name.test.ts +16 -0
  67. package/packages/pi-coding-agent/src/modes/interactive/components/model-selector.ts +11 -2
  68. package/src/resources/extensions/ask-user-questions.ts +103 -11
  69. package/src/resources/extensions/gsd/auto-model-selection.ts +11 -2
  70. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +57 -2
  71. package/src/resources/extensions/gsd/bootstrap/write-gate.ts +128 -0
  72. package/src/resources/extensions/gsd/codebase-generator.ts +4 -0
  73. package/src/resources/extensions/gsd/detection.ts +6 -0
  74. package/src/resources/extensions/gsd/index.ts +6 -0
  75. package/src/resources/extensions/gsd/prompts/complete-milestone.md +1 -1
  76. package/src/resources/extensions/gsd/prompts/discuss-prepared.md +7 -7
  77. package/src/resources/extensions/gsd/prompts/discuss.md +3 -3
  78. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +2 -2
  79. package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +3 -1
  80. package/src/resources/extensions/gsd/prompts/rethink.md +6 -2
  81. package/src/resources/extensions/gsd/prompts/system.md +1 -1
  82. package/src/resources/extensions/gsd/prompts/triage-captures.md +1 -1
  83. package/src/resources/extensions/gsd/prompts/worktree-merge.md +3 -1
  84. package/src/resources/extensions/gsd/tests/auto-model-selection.test.ts +21 -7
  85. package/src/resources/extensions/gsd/tests/codebase-generator.test.ts +22 -0
  86. package/src/resources/extensions/gsd/tests/detection.test.ts +37 -0
  87. package/src/resources/extensions/gsd/tests/remote-questions.test.ts +98 -0
  88. package/src/resources/extensions/gsd/tests/write-gate.test.ts +156 -0
  89. package/src/resources/extensions/remote-questions/manager.ts +9 -0
  90. /package/dist/web/standalone/.next/static/{UR2XjRqocvGBpbjt8dHCS → TCMOrUQ1suscla6CiwNya}/_buildManifest.js +0 -0
  91. /package/dist/web/standalone/.next/static/{UR2XjRqocvGBpbjt8dHCS → TCMOrUQ1suscla6CiwNya}/_ssgManifest.js +0 -0
@@ -143,17 +143,17 @@ test("resolvePreferredModelConfig keeps explicit phase models as the ceiling", (
143
143
 
144
144
  // ─── resolveModelId tests ─────────────────────────────────────────────────
145
145
 
146
- test("resolveModelId: bare ID resolves to anthropic over claude-code when session is claude-code (#2905)", () => {
146
+ test("resolveModelId: bare ID resolves to claude-code when session is claude-code (#3772)", () => {
147
147
  const availableModels = [
148
148
  { id: "claude-sonnet-4-6", provider: "anthropic" },
149
149
  { id: "claude-sonnet-4-6", provider: "claude-code" },
150
150
  ];
151
151
 
152
- // Bug: when currentProvider is "claude-code", bare ID "claude-sonnet-4-6"
153
- // resolves to claude-code/claude-sonnet-4-6 instead of anthropic/claude-sonnet-4-6
152
+ // When currentProvider is "claude-code" (set by startup migration for subscription
153
+ // users), bare IDs must resolve to claude-code to avoid the third-party block (#3772).
154
154
  const result = resolveModelId("claude-sonnet-4-6", availableModels, "claude-code");
155
155
  assert.ok(result, "should resolve a model");
156
- assert.equal(result.provider, "anthropic", "bare ID must resolve to anthropic, not claude-code");
156
+ assert.equal(result.provider, "claude-code", "bare ID must resolve to claude-code when session provider is claude-code");
157
157
  });
158
158
 
159
159
  test("resolveModelId: bare ID still prefers current provider when it is a first-class API provider", () => {
@@ -227,14 +227,28 @@ test("model change notify in selectAndApplyModel is gated behind verbose flag",
227
227
  );
228
228
  });
229
229
 
230
- test("resolveModelId: anthropic wins over claude-code regardless of list order", () => {
230
+ test("resolveModelId: anthropic wins over claude-code when session provider is not claude-code", () => {
231
231
  const availableModels = [
232
232
  { id: "claude-sonnet-4-6", provider: "claude-code" },
233
233
  { id: "claude-sonnet-4-6", provider: "anthropic" },
234
234
  ];
235
235
 
236
- // Even when claude-code appears first in the list, anthropic should win
236
+ // When the session is NOT on claude-code, bare IDs should resolve to
237
+ // the canonical anthropic provider (original #2905 behavior preserved).
238
+ const result = resolveModelId("claude-sonnet-4-6", availableModels, undefined);
239
+ assert.ok(result, "should resolve a model");
240
+ assert.equal(result.provider, "anthropic", "anthropic must win when session is not claude-code");
241
+ });
242
+
243
+ test("resolveModelId: claude-code wins when session is claude-code regardless of list order", () => {
244
+ const availableModels = [
245
+ { id: "claude-sonnet-4-6", provider: "claude-code" },
246
+ { id: "claude-sonnet-4-6", provider: "anthropic" },
247
+ ];
248
+
249
+ // When session provider is claude-code (subscription user migration), it must
250
+ // win regardless of candidate ordering to avoid the third-party block (#3772).
237
251
  const result = resolveModelId("claude-sonnet-4-6", availableModels, "claude-code");
238
252
  assert.ok(result, "should resolve a model");
239
- assert.equal(result.provider, "anthropic", "anthropic must win over claude-code regardless of list order");
253
+ assert.equal(result.provider, "claude-code", "claude-code must win when it is the session provider");
240
254
  });
@@ -138,6 +138,28 @@ test("generateCodebaseMap: excludes .gsd/ files", () => {
138
138
  }
139
139
  });
140
140
 
141
+ test("generateCodebaseMap: excludes .claude/ and other tool directories", () => {
142
+ const base = makeTmpRepo();
143
+ try {
144
+ addFile(base, "src/main.ts");
145
+ addFile(base, ".claude/CLAUDE.md");
146
+ addFile(base, ".claude/memory/user.md");
147
+ addFile(base, ".plans/plan.md");
148
+ addFile(base, ".cursor/settings.json");
149
+ addFile(base, ".vscode/settings.json");
150
+
151
+ const result = generateCodebaseMap(base);
152
+ assert.ok(result.content.includes("`src/main.ts`"), "should include src/main.ts");
153
+ assert.ok(!result.content.includes("CLAUDE.md"), "should exclude .claude/ files");
154
+ assert.ok(!result.content.includes("user.md"), "should exclude .claude/memory/ files");
155
+ assert.ok(!result.content.includes(".plans"), "should exclude .plans/ files");
156
+ assert.ok(!result.content.includes(".cursor"), "should exclude .cursor/ files");
157
+ assert.ok(!result.content.includes(".vscode"), "should exclude .vscode/ files");
158
+ } finally {
159
+ cleanup(base);
160
+ }
161
+ });
162
+
141
163
  test("generateCodebaseMap: excludes binary and lock files", () => {
142
164
  const base = makeTmpRepo();
143
165
  try {
@@ -17,6 +17,7 @@ import {
17
17
  detectProjectState,
18
18
  detectV1Planning,
19
19
  detectProjectSignals,
20
+ scanProjectFiles,
20
21
  } from "../detection.ts";
21
22
 
22
23
  function makeTempDir(prefix: string): string {
@@ -1188,3 +1189,39 @@ test("detectProjectSignals: Spring Boot settings-defined catalog accessor emits
1188
1189
  cleanup(dir);
1189
1190
  }
1190
1191
  });
1192
+
1193
+ // ─── scanProjectFiles: RECURSIVE_SCAN_IGNORED_DIRS ──────────────────────
1194
+
1195
+ test("scanProjectFiles: excludes .claude, .gsd, .planning, .plans, .cursor, .vscode directories", () => {
1196
+ const dir = makeTempDir("scan-ignore-dotdirs");
1197
+ try {
1198
+ // Create project files that should be included
1199
+ mkdirSync(join(dir, "src"), { recursive: true });
1200
+ writeFileSync(join(dir, "src", "main.ts"), "// main\n", "utf-8");
1201
+ writeFileSync(join(dir, "README.md"), "# Project\n", "utf-8");
1202
+
1203
+ // Create tool directories that should be excluded
1204
+ const excludedDirs = [".claude", ".gsd", ".planning", ".plans", ".cursor", ".vscode"];
1205
+ for (const d of excludedDirs) {
1206
+ mkdirSync(join(dir, d), { recursive: true });
1207
+ writeFileSync(join(dir, d, "config.json"), "{}\n", "utf-8");
1208
+ }
1209
+ // Nested .claude directory
1210
+ mkdirSync(join(dir, ".claude", "memory"), { recursive: true });
1211
+ writeFileSync(join(dir, ".claude", "memory", "user.md"), "# Memory\n", "utf-8");
1212
+
1213
+ const files = scanProjectFiles(dir);
1214
+
1215
+ // Should include project files
1216
+ assert.ok(files.includes("src/main.ts"), "should include src/main.ts");
1217
+ assert.ok(files.includes("README.md"), "should include README.md");
1218
+
1219
+ // Should exclude all tool directories
1220
+ for (const d of excludedDirs) {
1221
+ const hasExcluded = files.some((f) => f.startsWith(`${d}/`));
1222
+ assert.ok(!hasExcluded, `should exclude ${d}/ directory but found: ${files.filter((f) => f.startsWith(`${d}/`)).join(", ")}`);
1223
+ }
1224
+ } finally {
1225
+ cleanup(dir);
1226
+ }
1227
+ });
@@ -760,6 +760,104 @@ test("ask-user-questions source-level: tryRemoteQuestions is called before the h
760
760
  );
761
761
  });
762
762
 
763
+ // ═══════════════════════════════════════════════════════════════════════════
764
+ // Race model tests (#3810) — local TUI races against remote channel
765
+ // ═══════════════════════════════════════════════════════════════════════════
766
+
767
+ test("ask-user-questions source-level: raceRemoteAndLocal function exists", () => {
768
+ const src = readFileSync(
769
+ join(__dirname, "..", "..", "ask-user-questions.ts"),
770
+ "utf-8",
771
+ );
772
+ assert.ok(
773
+ src.includes("async function raceRemoteAndLocal("),
774
+ "raceRemoteAndLocal helper should exist for racing local TUI against remote channel",
775
+ );
776
+ });
777
+
778
+ test("ask-user-questions source-level: race path uses isRemoteConfigured for routing", () => {
779
+ const src = readFileSync(
780
+ join(__dirname, "..", "..", "ask-user-questions.ts"),
781
+ "utf-8",
782
+ );
783
+ assert.ok(
784
+ src.includes("isRemoteConfigured()"),
785
+ "execute() should call isRemoteConfigured() for lightweight routing decision",
786
+ );
787
+ });
788
+
789
+ test("ask-user-questions source-level: race path checks both hasRemote and ctx.hasUI", () => {
790
+ // Regression: #3810 — the race should only activate when BOTH remote and local UI
791
+ // are available. Headless mode should still use remote-only, and no-remote should
792
+ // use local-only.
793
+ const src = readFileSync(
794
+ join(__dirname, "..", "..", "ask-user-questions.ts"),
795
+ "utf-8",
796
+ );
797
+ assert.ok(
798
+ src.includes("hasRemote && ctx.hasUI"),
799
+ "Race path should require both remote configured and local UI available",
800
+ );
801
+ assert.ok(
802
+ src.includes("hasRemote && !ctx.hasUI"),
803
+ "Headless path should handle remote-only when no local UI",
804
+ );
805
+ });
806
+
807
+ test("ask-user-questions source-level: race treats remote timeout as non-win", () => {
808
+ // Regression: the whole point of the race is that a remote timeout should NOT
809
+ // block the local TUI. The race helper must filter out timed_out results.
810
+ const src = readFileSync(
811
+ join(__dirname, "..", "..", "ask-user-questions.ts"),
812
+ "utf-8",
813
+ );
814
+ const raceFnStart = src.indexOf("async function raceRemoteAndLocal(");
815
+ const raceFnEnd = src.indexOf("\n}", raceFnStart);
816
+ const raceFnBody = src.slice(raceFnStart, raceFnEnd);
817
+ assert.ok(
818
+ raceFnBody.includes("timed_out"),
819
+ "raceRemoteAndLocal should check for timed_out in remote results",
820
+ );
821
+ assert.ok(
822
+ raceFnBody.includes("details?.error"),
823
+ "raceRemoteAndLocal should check for error in remote results",
824
+ );
825
+ });
826
+
827
+ test("ask-user-questions source-level: race uses AbortController to cancel loser", () => {
828
+ const src = readFileSync(
829
+ join(__dirname, "..", "..", "ask-user-questions.ts"),
830
+ "utf-8",
831
+ );
832
+ assert.ok(
833
+ src.includes("new AbortController()"),
834
+ "Race path should create an AbortController for cancellation",
835
+ );
836
+ assert.ok(
837
+ src.includes("controller.abort()"),
838
+ "raceRemoteAndLocal should abort the controller to cancel the losing side",
839
+ );
840
+ });
841
+
842
+ test("manager source-level: isRemoteConfigured export exists", () => {
843
+ const src = readFileSync(
844
+ join(__dirname, "..", "..", "remote-questions", "manager.ts"),
845
+ "utf-8",
846
+ );
847
+ assert.ok(
848
+ src.includes("export function isRemoteConfigured()"),
849
+ "manager.ts should export isRemoteConfigured for lightweight config checking",
850
+ );
851
+ // Must delegate to resolveRemoteConfig — no separate config parsing
852
+ const fnStart = src.indexOf("export function isRemoteConfigured()");
853
+ const fnEnd = src.indexOf("\n}", fnStart);
854
+ const fnBody = src.slice(fnStart, fnEnd);
855
+ assert.ok(
856
+ fnBody.includes("resolveRemoteConfig()"),
857
+ "isRemoteConfigured should delegate to resolveRemoteConfig",
858
+ );
859
+ });
860
+
763
861
  test("config source-level: removeProviderToken uses auth.remove not auth.set with empty key", () => {
764
862
  const commandSrc = readFileSync(
765
863
  join(__dirname, "..", "..", "remote-questions", "remote-command.ts"),
@@ -195,6 +195,162 @@ test('write-gate: markDepthVerified unblocks queue-mode writes when milestoneId
195
195
  clearDiscussionFlowState();
196
196
  });
197
197
 
198
+ // ═══════════════════════════════════════════════════════════════════════
199
+ // Discussion gate enforcement tests (pending gate mechanism)
200
+ // ═══════════════════════════════════════════════════════════════════════
201
+
202
+ import {
203
+ isGateQuestionId,
204
+ shouldBlockPendingGate,
205
+ shouldBlockPendingGateBash,
206
+ setPendingGate,
207
+ clearPendingGate,
208
+ getPendingGate,
209
+ } from '../bootstrap/write-gate.ts';
210
+
211
+ // ─── Scenario 19: isGateQuestionId recognizes all gate patterns ──
212
+
213
+ test('write-gate: isGateQuestionId recognizes all gate patterns', () => {
214
+ assert.strictEqual(isGateQuestionId('layer1_scope_gate'), true);
215
+ assert.strictEqual(isGateQuestionId('layer2_architecture_gate'), true);
216
+ assert.strictEqual(isGateQuestionId('layer3_error_gate'), true);
217
+ assert.strictEqual(isGateQuestionId('layer4_quality_gate'), true);
218
+ assert.strictEqual(isGateQuestionId('depth_verification'), true);
219
+ assert.strictEqual(isGateQuestionId('depth_verification_M002'), true);
220
+ assert.strictEqual(isGateQuestionId('my_layer1_scope_gate_question'), true);
221
+ // Non-gate question IDs
222
+ assert.strictEqual(isGateQuestionId('project_intent'), false);
223
+ assert.strictEqual(isGateQuestionId('feature_priority'), false);
224
+ assert.strictEqual(isGateQuestionId(''), false);
225
+ });
226
+
227
+ // ─── Scenario 20: setPendingGate / getPendingGate / clearPendingGate lifecycle ──
228
+
229
+ test('write-gate: pending gate lifecycle (set, get, clear)', () => {
230
+ clearDiscussionFlowState();
231
+ assert.strictEqual(getPendingGate(), null, 'starts null');
232
+
233
+ setPendingGate('layer1_scope_gate');
234
+ assert.strictEqual(getPendingGate(), 'layer1_scope_gate', 'set correctly');
235
+
236
+ clearPendingGate();
237
+ assert.strictEqual(getPendingGate(), null, 'cleared correctly');
238
+
239
+ // clearDiscussionFlowState also clears pending gate
240
+ setPendingGate('layer2_architecture_gate');
241
+ clearDiscussionFlowState();
242
+ assert.strictEqual(getPendingGate(), null, 'clearDiscussionFlowState clears pending gate');
243
+ });
244
+
245
+ // ─── Scenario 21: shouldBlockPendingGate blocks non-safe tools when gate is pending ──
246
+
247
+ test('write-gate: shouldBlockPendingGate blocks write/edit during pending gate', () => {
248
+ clearDiscussionFlowState();
249
+ setPendingGate('layer1_scope_gate');
250
+
251
+ // write should be blocked during discussion
252
+ const writeResult = shouldBlockPendingGate('write', 'M001', false);
253
+ assert.strictEqual(writeResult.block, true, 'write should be blocked');
254
+ assert.ok(writeResult.reason!.includes('layer1_scope_gate'), 'reason mentions the gate');
255
+
256
+ // edit should be blocked
257
+ const editResult = shouldBlockPendingGate('edit', 'M001', false);
258
+ assert.strictEqual(editResult.block, true, 'edit should be blocked');
259
+
260
+ // gsd tools should be blocked
261
+ const gsdResult = shouldBlockPendingGate('gsd_plan_milestone', 'M001', false);
262
+ assert.strictEqual(gsdResult.block, true, 'gsd tools should be blocked');
263
+
264
+ clearDiscussionFlowState();
265
+ });
266
+
267
+ // ─── Scenario 22: shouldBlockPendingGate allows safe tools when gate is pending ──
268
+
269
+ test('write-gate: shouldBlockPendingGate allows read-only and ask_user_questions during pending gate', () => {
270
+ clearDiscussionFlowState();
271
+ setPendingGate('layer1_scope_gate');
272
+
273
+ // ask_user_questions is always safe (model needs to re-ask)
274
+ assert.strictEqual(shouldBlockPendingGate('ask_user_questions', 'M001').block, false);
275
+ // read-only tools are safe
276
+ assert.strictEqual(shouldBlockPendingGate('read', 'M001').block, false);
277
+ assert.strictEqual(shouldBlockPendingGate('grep', 'M001').block, false);
278
+ assert.strictEqual(shouldBlockPendingGate('glob', 'M001').block, false);
279
+ assert.strictEqual(shouldBlockPendingGate('ls', 'M001').block, false);
280
+
281
+ clearDiscussionFlowState();
282
+ });
283
+
284
+ // ─── Scenario 23: shouldBlockPendingGate does not block outside discussion ──
285
+
286
+ test('write-gate: shouldBlockPendingGate does not block outside discussion', () => {
287
+ clearDiscussionFlowState();
288
+ setPendingGate('layer1_scope_gate');
289
+
290
+ // No milestoneId and no queue phase — not in discussion
291
+ const result = shouldBlockPendingGate('write', null, false);
292
+ assert.strictEqual(result.block, false, 'should not block outside discussion');
293
+
294
+ clearDiscussionFlowState();
295
+ });
296
+
297
+ // ─── Scenario 24: shouldBlockPendingGate blocks in queue mode ──
298
+
299
+ test('write-gate: shouldBlockPendingGate blocks in queue mode when gate is pending', () => {
300
+ clearDiscussionFlowState();
301
+ setQueuePhaseActive(true);
302
+ setPendingGate('depth_verification');
303
+
304
+ const result = shouldBlockPendingGate('write', null, true);
305
+ assert.strictEqual(result.block, true, 'should block in queue mode');
306
+
307
+ clearDiscussionFlowState();
308
+ });
309
+
310
+ // ─── Scenario 25: shouldBlockPendingGateBash allows read-only commands ──
311
+
312
+ test('write-gate: shouldBlockPendingGateBash allows read-only commands during pending gate', () => {
313
+ clearDiscussionFlowState();
314
+ setPendingGate('layer2_architecture_gate');
315
+
316
+ assert.strictEqual(shouldBlockPendingGateBash('cat file.txt', 'M001').block, false);
317
+ assert.strictEqual(shouldBlockPendingGateBash('git log --oneline', 'M001').block, false);
318
+ assert.strictEqual(shouldBlockPendingGateBash('grep -r pattern .', 'M001').block, false);
319
+ assert.strictEqual(shouldBlockPendingGateBash('ls -la', 'M001').block, false);
320
+
321
+ clearDiscussionFlowState();
322
+ });
323
+
324
+ // ─── Scenario 26: shouldBlockPendingGateBash blocks mutating commands ──
325
+
326
+ test('write-gate: shouldBlockPendingGateBash blocks mutating commands during pending gate', () => {
327
+ clearDiscussionFlowState();
328
+ setPendingGate('layer2_architecture_gate');
329
+
330
+ const result = shouldBlockPendingGateBash('npm run build', 'M001');
331
+ assert.strictEqual(result.block, true, 'mutating bash should be blocked');
332
+ assert.ok(result.reason!.includes('layer2_architecture_gate'));
333
+
334
+ clearDiscussionFlowState();
335
+ });
336
+
337
+ // ─── Scenario 27: no pending gate means no blocking ──
338
+
339
+ test('write-gate: no pending gate means no blocking', () => {
340
+ clearDiscussionFlowState();
341
+
342
+ assert.strictEqual(shouldBlockPendingGate('write', 'M001').block, false);
343
+ assert.strictEqual(shouldBlockPendingGateBash('npm run build', 'M001').block, false);
344
+ });
345
+
346
+ // ─── Scenario 28: resetWriteGateState clears pending gate ──
347
+
348
+ test('write-gate: resetWriteGateState clears pending gate', () => {
349
+ setPendingGate('layer3_error_gate');
350
+ resetWriteGateState();
351
+ assert.strictEqual(getPendingGate(), null);
352
+ });
353
+
198
354
  // ─── Standard options fixture used across depth confirmation tests ──
199
355
 
200
356
  const STANDARD_OPTIONS = [
@@ -24,6 +24,15 @@ interface QuestionInput {
24
24
  allowMultiple?: boolean;
25
25
  }
26
26
 
27
+ /**
28
+ * Check whether a remote channel is configured without triggering any
29
+ * side effects (no HTTP requests, no prompt records). Used by the race
30
+ * logic to decide routing before committing to a remote dispatch.
31
+ */
32
+ export function isRemoteConfigured(): boolean {
33
+ return resolveRemoteConfig() !== null;
34
+ }
35
+
27
36
  export async function tryRemoteQuestions(
28
37
  questions: QuestionInput[],
29
38
  signal?: AbortSignal,