gsd-pi 2.82.0-dev.725028083 → 2.82.0-dev.c22380fc3

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 (87) hide show
  1. package/dist/resources/.managed-resources-content-hash +1 -1
  2. package/dist/resources/extensions/gsd/auto/orchestrator.js +113 -6
  3. package/dist/resources/extensions/gsd/auto.js +128 -52
  4. package/dist/resources/extensions/gsd/md-importer.js +1 -1
  5. package/dist/resources/extensions/gsd/migrate/command.js +5 -0
  6. package/dist/resources/extensions/gsd/migrate/preview.js +9 -0
  7. package/dist/resources/extensions/gsd/migrate/transformer.js +51 -4
  8. package/dist/resources/extensions/gsd/migrate/writer.js +11 -1
  9. package/dist/resources/extensions/gsd/tools/workflow-tool-executors.js +119 -0
  10. package/dist/resources/extensions/gsd/worktree-lifecycle.js +21 -2
  11. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  12. package/dist/web/standalone/.next/BUILD_ID +1 -1
  13. package/dist/web/standalone/.next/app-path-routes-manifest.json +14 -14
  14. package/dist/web/standalone/.next/build-manifest.json +2 -2
  15. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  16. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  17. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  18. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  19. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  20. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  21. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  22. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  23. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  24. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  25. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/index.html +1 -1
  33. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app-paths-manifest.json +14 -14
  40. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  41. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  42. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  43. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  44. package/package.json +1 -1
  45. package/packages/contracts/dist/rpc.test.js +7 -0
  46. package/packages/contracts/dist/rpc.test.js.map +1 -1
  47. package/packages/contracts/dist/workflow.d.ts +21 -0
  48. package/packages/contracts/dist/workflow.d.ts.map +1 -1
  49. package/packages/contracts/dist/workflow.js +24 -0
  50. package/packages/contracts/dist/workflow.js.map +1 -1
  51. package/packages/contracts/src/rpc.test.ts +8 -0
  52. package/packages/contracts/src/workflow.ts +24 -0
  53. package/packages/mcp-server/README.md +13 -4
  54. package/packages/mcp-server/dist/workflow-tools.d.ts +0 -3
  55. package/packages/mcp-server/dist/workflow-tools.d.ts.map +1 -1
  56. package/packages/mcp-server/dist/workflow-tools.js +80 -0
  57. package/packages/mcp-server/dist/workflow-tools.js.map +1 -1
  58. package/packages/mcp-server/src/workflow-tools.test.ts +22 -0
  59. package/packages/mcp-server/src/workflow-tools.ts +168 -0
  60. package/packages/mcp-server/tsconfig.tsbuildinfo +1 -1
  61. package/packages/pi-coding-agent/tsconfig.tsbuildinfo +1 -1
  62. package/packages/pi-tui/dist/tui.d.ts.map +1 -1
  63. package/packages/pi-tui/dist/tui.js +5 -0
  64. package/packages/pi-tui/dist/tui.js.map +1 -1
  65. package/packages/pi-tui/src/tui.ts +6 -0
  66. package/packages/pi-tui/tsconfig.tsbuildinfo +1 -1
  67. package/packages/rpc-client/tsconfig.tsbuildinfo +1 -1
  68. package/src/resources/extensions/gsd/auto/contracts.ts +46 -11
  69. package/src/resources/extensions/gsd/auto/orchestrator.ts +118 -6
  70. package/src/resources/extensions/gsd/auto.ts +136 -51
  71. package/src/resources/extensions/gsd/md-importer.ts +1 -1
  72. package/src/resources/extensions/gsd/migrate/command.ts +5 -0
  73. package/src/resources/extensions/gsd/migrate/preview.ts +10 -0
  74. package/src/resources/extensions/gsd/migrate/transformer.ts +58 -4
  75. package/src/resources/extensions/gsd/migrate/writer.ts +14 -1
  76. package/src/resources/extensions/gsd/tests/auto-orchestrator.test.ts +408 -4
  77. package/src/resources/extensions/gsd/tests/auto-paused-ui-cleanup.test.ts +6 -5
  78. package/src/resources/extensions/gsd/tests/auto-runtime-state.test.ts +4 -4
  79. package/src/resources/extensions/gsd/tests/integration/migrate-command.test.ts +48 -3
  80. package/src/resources/extensions/gsd/tests/migrate-transformer.test.ts +5 -1
  81. package/src/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +6 -1
  82. package/src/resources/extensions/gsd/tests/state-corruption-2945.test.ts +6 -0
  83. package/src/resources/extensions/gsd/tests/worktree-lifecycle.test.ts +25 -0
  84. package/src/resources/extensions/gsd/tools/workflow-tool-executors.ts +135 -0
  85. package/src/resources/extensions/gsd/worktree-lifecycle.ts +20 -2
  86. /package/dist/web/standalone/.next/static/{KDRTXR-22LPCsa80X9dey → Wop3A7KRGyR06H3rla_1-}/_buildManifest.js +0 -0
  87. /package/dist/web/standalone/.next/static/{KDRTXR-22LPCsa80X9dey → Wop3A7KRGyR06H3rla_1-}/_ssgManifest.js +0 -0
@@ -4,9 +4,14 @@
4
4
  import test from "node:test";
5
5
  import assert from "node:assert/strict";
6
6
 
7
- import { createAutoOrchestrator } from "../auto/orchestrator.js";
7
+ import { createAutoOrchestrator, STUCK_WINDOW_SIZE } from "../auto/orchestrator.js";
8
8
  import type { AutoOrchestratorDeps } from "../auto/contracts.js";
9
9
  import type { GSDState } from "../types.js";
10
+ import { createWiredDispatchAdapter } from "../auto.js";
11
+ import { resolveDispatch, type DispatchContext } from "../auto-dispatch.js";
12
+ import { RuleRegistry, setRegistry, resetRegistry } from "../rule-registry.js";
13
+ import type { UnifiedRule } from "../rule-types.js";
14
+ import { supportsStructuredQuestions } from "../workflow-mcp.js";
10
15
 
11
16
  function makeState(): GSDState {
12
17
  return {
@@ -62,9 +67,13 @@ function makeDeps(overrides: Partial<AutoOrchestratorDeps> = {}): { deps: AutoOr
62
67
  async cleanupOnStop() { calls.push("worktree.cleanup"); },
63
68
  },
64
69
  health: {
70
+ checkResourcesStale() {
71
+ calls.push("health.stale");
72
+ return null;
73
+ },
65
74
  async preAdvanceGate() {
66
75
  calls.push("health.pre");
67
- return { allow: true };
76
+ return { kind: "pass" };
68
77
  },
69
78
  async postAdvanceRecord() { calls.push("health.post"); },
70
79
  },
@@ -75,6 +84,9 @@ function makeDeps(overrides: Partial<AutoOrchestratorDeps> = {}): { deps: AutoOr
75
84
  notifications: {
76
85
  async notifyLifecycle(event) { calls.push(`notify:${event.name}`); },
77
86
  },
87
+ uokGate: {
88
+ async emit(input) { calls.push(`gate:${input.gateId}:${input.outcome}`); },
89
+ },
78
90
  };
79
91
 
80
92
  return { deps: { ...deps, ...overrides }, calls };
@@ -87,6 +99,7 @@ test("start() advances and records active unit", async () => {
87
99
  const result = await orchestrator.start({ basePath: "/tmp/project", trigger: "manual" });
88
100
 
89
101
  assert.equal(result.kind, "advanced");
102
+ assert.deepEqual(result.unit, { unitType: "execute-task", unitId: "T01" });
90
103
  const status = orchestrator.getStatus();
91
104
  assert.equal(status.phase, "running");
92
105
  assert.deepEqual(status.activeUnit, { unitType: "execute-task", unitId: "T01" });
@@ -95,9 +108,10 @@ test("start() advances and records active unit", async () => {
95
108
  });
96
109
 
97
110
  test("advance() returns blocked when health gate denies", async () => {
98
- const { deps } = makeDeps({
111
+ const { deps, calls } = makeDeps({
99
112
  health: {
100
- async preAdvanceGate() { return { allow: false, reason: "doctor-block" }; },
113
+ checkResourcesStale: () => null,
114
+ async preAdvanceGate() { return { kind: "fail", reason: "doctor-block" }; },
101
115
  async postAdvanceRecord() {},
102
116
  },
103
117
  });
@@ -107,6 +121,69 @@ test("advance() returns blocked when health gate denies", async () => {
107
121
 
108
122
  assert.equal(result.kind, "blocked");
109
123
  assert.equal(result.reason, "doctor-block");
124
+ assert.equal(result.action, "pause");
125
+ assert.ok(calls.includes("gate:pre-dispatch-health-gate:manual-attention"));
126
+ });
127
+
128
+ test("advance() returns blocked stop when resources are stale", async () => {
129
+ const { deps, calls } = makeDeps({
130
+ health: {
131
+ checkResourcesStale: () => "resources changed since session start",
132
+ async preAdvanceGate() { return { kind: "pass" }; },
133
+ async postAdvanceRecord() {},
134
+ },
135
+ });
136
+ const orchestrator = createAutoOrchestrator(deps);
137
+
138
+ const result = await orchestrator.advance();
139
+
140
+ assert.equal(result.kind, "blocked");
141
+ assert.equal(result.reason, "resources changed since session start");
142
+ assert.equal(result.action, "stop");
143
+ assert.ok(calls.includes("gate:resource-version-guard:fail"));
144
+ assert.ok(!calls.includes("health.pre"));
145
+ assert.ok(!calls.includes("state.reconcile"));
146
+ });
147
+
148
+ test("advance() continues past pre-dispatch health gate when it throws", async () => {
149
+ const { deps, calls } = makeDeps({
150
+ health: {
151
+ checkResourcesStale: () => null,
152
+ async preAdvanceGate() { return { kind: "threw", error: new Error("boom") }; },
153
+ async postAdvanceRecord() {},
154
+ },
155
+ });
156
+ const orchestrator = createAutoOrchestrator(deps);
157
+
158
+ const result = await orchestrator.advance();
159
+
160
+ assert.equal(result.kind, "advanced");
161
+ assert.ok(calls.includes("gate:pre-dispatch-health-gate:manual-attention"));
162
+ assert.ok(calls.includes("state.reconcile"));
163
+ assert.ok(calls.includes("dispatch.decide"));
164
+ });
165
+
166
+ test("advance() forwards fixesApplied into pre-dispatch-health-gate pass findings", async () => {
167
+ let observed = "";
168
+ const { deps } = makeDeps({
169
+ health: {
170
+ checkResourcesStale: () => null,
171
+ async preAdvanceGate() { return { kind: "pass", fixesApplied: ["fix-a", "fix-b"] }; },
172
+ async postAdvanceRecord() {},
173
+ },
174
+ uokGate: {
175
+ async emit(input) {
176
+ if (input.gateId === "pre-dispatch-health-gate" && input.outcome === "pass") {
177
+ observed = input.findings ?? "";
178
+ }
179
+ },
180
+ },
181
+ });
182
+ const orchestrator = createAutoOrchestrator(deps);
183
+
184
+ await orchestrator.advance();
185
+
186
+ assert.equal(observed, "fix-a, fix-b");
110
187
  });
111
188
 
112
189
  test("advance() follows the ADR-015 invariant sequence before journaling advance", async () => {
@@ -116,9 +193,13 @@ test("advance() follows the ADR-015 invariant sequence before journaling advance
116
193
  const result = await orchestrator.advance();
117
194
 
118
195
  assert.equal(result.kind, "advanced");
196
+ assert.deepEqual(result.unit, { unitType: "execute-task", unitId: "T01" });
119
197
  assert.deepEqual(calls, [
120
198
  "runtime.lock",
199
+ "health.stale",
200
+ "gate:resource-version-guard:pass",
121
201
  "health.pre",
202
+ "gate:pre-dispatch-health-gate:pass",
122
203
  "state.reconcile",
123
204
  "dispatch.decide",
124
205
  "tool.compile",
@@ -144,6 +225,7 @@ test("advance() blocks before dispatch when State Reconciliation blocks", async
144
225
 
145
226
  assert.equal(result.kind, "blocked");
146
227
  assert.equal(result.reason, "state drift blocked");
228
+ assert.equal(result.action, "pause");
147
229
  assert.ok(!calls.includes("dispatch.decide"));
148
230
  assert.ok(calls.includes("journal:advance-blocked"));
149
231
  });
@@ -163,6 +245,7 @@ test("advance() blocks before Runtime persistence when Tool Contract fails", asy
163
245
 
164
246
  assert.equal(result.kind, "blocked");
165
247
  assert.equal(result.reason, "unknown Unit");
248
+ assert.equal(result.action, "pause");
166
249
  assert.ok(!calls.includes("worktree.prepare"));
167
250
  assert.ok(!calls.includes("journal:advance"));
168
251
  assert.ok(calls.includes("journal:advance-blocked"));
@@ -185,6 +268,7 @@ test("advance() blocks before Runtime persistence when Worktree Safety fails", a
185
268
 
186
269
  assert.equal(result.kind, "blocked");
187
270
  assert.equal(result.reason, "worktree invalid");
271
+ assert.equal(result.action, "pause");
188
272
  assert.ok(!calls.includes("journal:advance"));
189
273
  assert.ok(!calls.includes("worktree.sync"));
190
274
  assert.ok(calls.includes("journal:advance-blocked"));
@@ -232,8 +316,10 @@ test("advance() is idempotent for the same active unit", async () => {
232
316
  const second = await orchestrator.advance();
233
317
 
234
318
  assert.equal(first.kind, "advanced");
319
+ assert.deepEqual(first.unit, { unitType: "execute-task", unitId: "T01" });
235
320
  assert.equal(second.kind, "blocked");
236
321
  assert.equal(second.reason, "idempotent advance: unit already active");
322
+ assert.equal(second.action, "stop");
237
323
 
238
324
  const prepareCalls = calls.filter((c) => c === "worktree.prepare").length;
239
325
  assert.equal(prepareCalls, 1);
@@ -468,3 +554,321 @@ test("stop() cleans up worktree and transitions to stopped", async () => {
468
554
  assert.ok(calls.includes("journal:stop"));
469
555
  assert.ok(calls.includes("notify:stop"));
470
556
  });
557
+
558
+ // ────────────────────────────────────────────────────────────────────────
559
+ // Stuck-loop ring buffer (issue #5787)
560
+ // ────────────────────────────────────────────────────────────────────────
561
+
562
+ test("STUCK_WINDOW_SIZE matches the legacy auto/phases.ts constant", () => {
563
+ assert.equal(STUCK_WINDOW_SIZE, 6);
564
+ });
565
+
566
+ test("stuck-loop: empty ring on a freshly constructed orchestrator advances normally", async () => {
567
+ const { deps } = makeDeps();
568
+ const orchestrator = createAutoOrchestrator(deps);
569
+
570
+ const result = await orchestrator.advance();
571
+
572
+ assert.equal(result.kind, "advanced");
573
+ });
574
+
575
+ test("stuck-loop: partial fill of mixed units does not block", async () => {
576
+ // Alternate A/B for STUCK_WINDOW_SIZE rounds. No single key saturates the
577
+ // window, so neither idempotency nor stuck-loop should fire.
578
+ let i = 0;
579
+ const sequence = ["A", "B", "A", "B", "A", "B"];
580
+ const { deps } = makeDeps({
581
+ dispatch: {
582
+ async decideNextUnit() {
583
+ const id = sequence[i++ % sequence.length];
584
+ return { unitType: "execute-task", unitId: id, reason: "ready", preconditions: [] };
585
+ },
586
+ },
587
+ });
588
+ const orchestrator = createAutoOrchestrator(deps);
589
+
590
+ for (let round = 0; round < STUCK_WINDOW_SIZE; round++) {
591
+ const result = await orchestrator.advance();
592
+ assert.equal(result.kind, "advanced", `round ${round} should advance, got ${result.kind}`);
593
+ }
594
+ });
595
+
596
+ test("stuck-loop: ring saturated with same unit blocks with action 'stop' and stuck-loop reason", async () => {
597
+ // Dispatch picks the same unit every time. The first advance succeeds.
598
+ // Calls 2..STUCK_WINDOW_SIZE-1 are idempotency-blocked while the ring fills.
599
+ // The STUCK_WINDOW_SIZE'th call sees a saturated ring and returns stuck-loop.
600
+ const { deps } = makeDeps();
601
+ const orchestrator = createAutoOrchestrator(deps);
602
+
603
+ const results: Awaited<ReturnType<typeof orchestrator.advance>>[] = [];
604
+ for (let i = 0; i < STUCK_WINDOW_SIZE; i++) {
605
+ results.push(await orchestrator.advance());
606
+ }
607
+
608
+ // First call advances.
609
+ assert.equal(results[0].kind, "advanced");
610
+
611
+ // Intermediate calls are blocked by idempotency (not stuck-loop yet).
612
+ for (let i = 1; i < STUCK_WINDOW_SIZE - 1; i++) {
613
+ const r = results[i];
614
+ assert.equal(r.kind, "blocked", `round ${i} should be blocked`);
615
+ if (r.kind !== "blocked") return;
616
+ assert.equal(r.reason, "idempotent advance: unit already active");
617
+ assert.equal(r.action, "stop");
618
+ }
619
+
620
+ // The final call (ring now holds STUCK_WINDOW_SIZE copies) returns stuck-loop.
621
+ const last = results[STUCK_WINDOW_SIZE - 1];
622
+ assert.equal(last.kind, "blocked");
623
+ if (last.kind !== "blocked") return;
624
+ assert.equal(last.action, "stop");
625
+ assert.equal(last.reason, `stuck-loop: execute-task:T01 picked ${STUCK_WINDOW_SIZE} times`);
626
+ });
627
+
628
+ test("stuck-loop: idempotency block continues to fire with its own reason before saturation", async () => {
629
+ // Two identical calls should produce idempotent (not stuck-loop). Ensures the
630
+ // existing idempotency block is not absorbed by the new check.
631
+ const { deps } = makeDeps();
632
+ const orchestrator = createAutoOrchestrator(deps);
633
+
634
+ const first = await orchestrator.advance();
635
+ const second = await orchestrator.advance();
636
+
637
+ assert.equal(first.kind, "advanced");
638
+ assert.equal(second.kind, "blocked");
639
+ assert.equal(second.reason, "idempotent advance: unit already active");
640
+ assert.equal(second.action, "stop");
641
+ });
642
+
643
+ test("stuck-loop: start() resets the ring so a fresh saturation cycle is required", async () => {
644
+ // Fill the ring to one short of saturation, then start() — the ring should
645
+ // be cleared, and the next advance must succeed instead of going stuck.
646
+ const { deps } = makeDeps();
647
+ const orchestrator = createAutoOrchestrator(deps);
648
+
649
+ for (let i = 0; i < STUCK_WINDOW_SIZE - 1; i++) {
650
+ await orchestrator.advance();
651
+ }
652
+
653
+ const restarted = await orchestrator.start({ basePath: "/tmp/project", trigger: "manual" });
654
+ assert.equal(restarted.kind, "advanced");
655
+
656
+ // Immediately after start(), the next advance is idempotent (one element in
657
+ // ring), not stuck-loop, confirming the ring was reset.
658
+ const next = await orchestrator.advance();
659
+ assert.equal(next.kind, "blocked");
660
+ assert.equal(next.reason, "idempotent advance: unit already active");
661
+ });
662
+
663
+ test("stuck-loop: resume() resets the ring", async () => {
664
+ const { deps } = makeDeps();
665
+ const orchestrator = createAutoOrchestrator(deps);
666
+
667
+ for (let i = 0; i < STUCK_WINDOW_SIZE - 1; i++) {
668
+ await orchestrator.advance();
669
+ }
670
+
671
+ const resumed = await orchestrator.resume();
672
+ assert.equal(resumed.kind, "advanced");
673
+
674
+ const next = await orchestrator.advance();
675
+ assert.equal(next.kind, "blocked");
676
+ assert.equal(next.reason, "idempotent advance: unit already active");
677
+ });
678
+
679
+ test("stuck-loop: stop() resets the ring", async () => {
680
+ const { deps } = makeDeps();
681
+ const orchestrator = createAutoOrchestrator(deps);
682
+
683
+ for (let i = 0; i < STUCK_WINDOW_SIZE - 1; i++) {
684
+ await orchestrator.advance();
685
+ }
686
+
687
+ const stopped = await orchestrator.stop("user-request");
688
+ assert.equal(stopped.kind, "stopped");
689
+
690
+ // Ring is cleared by stop(). A subsequent advance is a fresh first-touch.
691
+ const next = await orchestrator.advance();
692
+ assert.equal(next.kind, "advanced");
693
+ });
694
+
695
+ test("stuck-loop: journal records the stuck-loop reason on advance-blocked", async () => {
696
+ const { deps, calls } = makeDeps();
697
+ const orchestrator = createAutoOrchestrator(deps);
698
+
699
+ for (let i = 0; i < STUCK_WINDOW_SIZE; i++) {
700
+ await orchestrator.advance();
701
+ }
702
+
703
+ assert.ok(calls.includes("journal:advance-blocked"));
704
+ });
705
+
706
+ // ─── #5789 parity: wired dispatch adapter mirrors runDispatch's resolveDispatch call ───
707
+
708
+ test("wired DispatchAdapter forwards session-derived dispatch inputs identically to runDispatch", async () => {
709
+ const stateSnapshot = makeState();
710
+
711
+ // Install a capturing registry so we observe the DispatchContext both code paths
712
+ // build, and force a deterministic dispatch action so the parity assertion is
713
+ // about *inputs*, not rule evaluation.
714
+ const captured: DispatchContext[] = [];
715
+ const captureRule: UnifiedRule = {
716
+ name: "test-capture",
717
+ when: "dispatch",
718
+ evaluation: "first-match",
719
+ where: async (ctx: DispatchContext) => {
720
+ captured.push(ctx);
721
+ return {
722
+ action: "dispatch" as const,
723
+ unitType: "execute-task",
724
+ unitId: "T01",
725
+ prompt: "parity-fixture",
726
+ };
727
+ },
728
+ then: (r: unknown) => r,
729
+ };
730
+ setRegistry(new RuleRegistry([captureRule]));
731
+
732
+ try {
733
+ // Mock ExtensionContext + ExtensionAPI with the surface the wired adapter touches.
734
+ const fakeModelRegistry = {
735
+ getAll: () => [],
736
+ getProviderAuthMode: (_provider: string) => "apiKey" as const,
737
+ };
738
+ const ctx = {
739
+ model: {
740
+ provider: "anthropic",
741
+ baseUrl: "https://api.anthropic.com",
742
+ contextWindow: 200_000,
743
+ },
744
+ modelRegistry: fakeModelRegistry,
745
+ } as any;
746
+ const pi = {
747
+ getActiveTools: () => ["read_file", "write_file"],
748
+ } as any;
749
+ const basePath = "/tmp/parity-fixture";
750
+
751
+ // Path A — wired adapter (what createWiredAutoOrchestrationModule uses).
752
+ const adapter = createWiredDispatchAdapter(ctx, pi, basePath);
753
+ const adapterResult = await adapter.decideNextUnit({ stateSnapshot });
754
+
755
+ // Path B — direct resolveDispatch call mirroring phases.ts:runDispatch.
756
+ // Inline the same derivations runDispatch uses so any drift here is a parity break.
757
+ const prefs = undefined; // loadEffectiveGSDPreferences returns null for /tmp/parity-fixture.
758
+ const provider = ctx.model?.provider;
759
+ const authMode = provider && typeof ctx.modelRegistry?.getProviderAuthMode === "function"
760
+ ? ctx.modelRegistry.getProviderAuthMode(provider)
761
+ : undefined;
762
+ const activeTools = typeof pi.getActiveTools === "function" ? pi.getActiveTools() : [];
763
+ const structuredQuestionsAvailable: "true" | "false" =
764
+ prefs !== undefined && (prefs as { planning_depth?: string }).planning_depth === "deep"
765
+ ? "false"
766
+ : supportsStructuredQuestions(activeTools, {
767
+ authMode,
768
+ baseUrl: ctx.model?.baseUrl,
769
+ })
770
+ ? "true"
771
+ : "false";
772
+
773
+ const builtDirectCtx: DispatchContext = {
774
+ basePath,
775
+ mid: stateSnapshot.activeMilestone!.id,
776
+ midTitle: stateSnapshot.activeMilestone!.title,
777
+ state: stateSnapshot,
778
+ prefs,
779
+ structuredQuestionsAvailable,
780
+ sessionContextWindow: ctx.model?.contextWindow,
781
+ sessionProvider: ctx.model?.provider,
782
+ modelRegistry: ctx.modelRegistry,
783
+ };
784
+ const directAction = await resolveDispatch(builtDirectCtx);
785
+
786
+ // Two contexts captured: one per resolveDispatch call.
787
+ assert.equal(captured.length, 2, "expected two captured dispatch contexts");
788
+ const [adapterCtx, directCtx] = captured;
789
+
790
+ // Parity assertion: session-derived fields are identical.
791
+ assert.equal(adapterCtx.structuredQuestionsAvailable, directCtx.structuredQuestionsAvailable);
792
+ assert.equal(adapterCtx.sessionContextWindow, directCtx.sessionContextWindow);
793
+ assert.equal(adapterCtx.sessionProvider, directCtx.sessionProvider);
794
+ assert.equal(adapterCtx.modelRegistry, directCtx.modelRegistry);
795
+ assert.equal(adapterCtx.basePath, directCtx.basePath);
796
+ assert.equal(adapterCtx.mid, directCtx.mid);
797
+ assert.equal(adapterCtx.midTitle, directCtx.midTitle);
798
+
799
+ // Dispatch action equality: both flows reach the same dispatch decision.
800
+ assert.ok(adapterResult);
801
+ assert.equal(adapterResult.unitType, "execute-task");
802
+ assert.equal(adapterResult.unitId, "T01");
803
+ assert.equal(adapterResult.reason, "test-capture");
804
+ assert.equal(directAction.action, "dispatch");
805
+ if (directAction.action === "dispatch") {
806
+ assert.equal(directAction.unitType, adapterResult.unitType);
807
+ assert.equal(directAction.unitId, adapterResult.unitId);
808
+ assert.equal(directAction.matchedRule, adapterResult.reason);
809
+ }
810
+ } finally {
811
+ resetRegistry();
812
+ }
813
+ });
814
+
815
+ test("wired DispatchAdapter prefers caller-supplied dispatch inputs over ctx-derived values", async () => {
816
+ const stateSnapshot = makeState();
817
+ const captured: DispatchContext[] = [];
818
+ const captureRule: UnifiedRule = {
819
+ name: "test-capture-overrides",
820
+ when: "dispatch",
821
+ evaluation: "first-match",
822
+ where: async (ctx: DispatchContext) => {
823
+ captured.push(ctx);
824
+ return {
825
+ action: "dispatch" as const,
826
+ unitType: "execute-task",
827
+ unitId: "T01",
828
+ prompt: "override-fixture",
829
+ };
830
+ },
831
+ then: (r: unknown) => r,
832
+ };
833
+ setRegistry(new RuleRegistry([captureRule]));
834
+
835
+ try {
836
+ const ctxModelRegistry = {
837
+ getAll: () => [],
838
+ getProviderAuthMode: (_provider: string) => "apiKey" as const,
839
+ };
840
+ const overrideModelRegistry = {
841
+ getAll: () => [],
842
+ getProviderAuthMode: (_provider: string) => "oauth" as const,
843
+ };
844
+ const ctx = {
845
+ model: {
846
+ provider: "anthropic",
847
+ baseUrl: "https://api.anthropic.com",
848
+ contextWindow: 200_000,
849
+ },
850
+ modelRegistry: ctxModelRegistry,
851
+ } as any;
852
+ const pi = {
853
+ getActiveTools: () => [],
854
+ } as any;
855
+ const adapter = createWiredDispatchAdapter(ctx, pi, "/tmp/parity-fixture");
856
+
857
+ const result = await adapter.decideNextUnit({
858
+ stateSnapshot,
859
+ structuredQuestionsAvailable: "true",
860
+ sessionContextWindow: 500_000,
861
+ sessionProvider: "openai",
862
+ modelRegistry: overrideModelRegistry,
863
+ });
864
+
865
+ assert.ok(result);
866
+ assert.equal(captured.length, 1, "expected one captured dispatch context");
867
+ assert.equal(captured[0].structuredQuestionsAvailable, "true");
868
+ assert.equal(captured[0].sessionContextWindow, 500_000);
869
+ assert.equal(captured[0].sessionProvider, "openai");
870
+ assert.equal(captured[0].modelRegistry, overrideModelRegistry);
871
+ } finally {
872
+ resetRegistry();
873
+ }
874
+ });
@@ -159,14 +159,15 @@ test("cleanupAfterLoopExit keeps cleanup best-effort when lifecycle restore thro
159
159
  const previousCwd = process.cwd();
160
160
  let restoreCalls = 0;
161
161
  // ADR-016 phase 3 (#5693): the real `restoreToProjectRoot` assigns
162
- // `s.basePath = s.originalBasePath` BEFORE any throwable work
162
+ // `s.basePath = s.originalBasePath` AND chdir's BEFORE any throwable work
163
163
  // (rebuildGitService, cache invalidation). Mirror that ordering in the
164
- // mock so the throw scenario reflects production: basePath is restored
165
- // even when the verb throws partway through.
164
+ // mock so the throw scenario reflects production: basePath and cwd are
165
+ // restored even when the verb throws partway through.
166
166
  t.mock.method(WorktreeLifecycle.prototype, "restoreToProjectRoot", function (this: WorktreeLifecycle) {
167
167
  restoreCalls += 1;
168
- (this as unknown as { s: { basePath: string; originalBasePath: string } })
169
- .s.basePath = (this as unknown as { s: { originalBasePath: string } }).s.originalBasePath;
168
+ const sRef = this as unknown as { s: { basePath: string; originalBasePath: string } };
169
+ sRef.s.basePath = sRef.s.originalBasePath;
170
+ try { process.chdir(sRef.s.basePath); } catch { /* mirror real verb's best-effort */ }
170
171
  throw new Error("restore failed");
171
172
  });
172
173
 
@@ -12,10 +12,10 @@ test("getAutoRuntimeSnapshot includes orchestration phase when available", () =>
12
12
  autoSession.active = true;
13
13
  autoSession.basePath = "/tmp/project";
14
14
  autoSession.orchestration = {
15
- async start() { return { kind: "advanced" as const }; },
16
- async advance() { return { kind: "advanced" as const }; },
17
- async resume() { return { kind: "advanced" as const }; },
18
- async stop() { return { kind: "stopped" as const }; },
15
+ async start() { return { kind: "stopped" as const, reason: "test" }; },
16
+ async advance() { return { kind: "stopped" as const, reason: "test" }; },
17
+ async resume() { return { kind: "stopped" as const, reason: "test" }; },
18
+ async stop() { return { kind: "stopped" as const, reason: "test" }; },
19
19
  getStatus() {
20
20
  return { phase: "running" as const, transitionCount: 3, lastTransitionAt: 123 };
21
21
  },
@@ -14,7 +14,9 @@ import {
14
14
  generatePreview,
15
15
  writeGSDDirectory,
16
16
  } from '../../migrate/index.ts';
17
+ import { importWrittenMigrationToDb } from '../../migrate/command.ts';
17
18
  import { deriveState } from '../../state.ts';
19
+ import { closeDatabase, getDecisionById, getRequirementCounts } from '../../gsd-db.ts';
18
20
  import { describe, test, beforeEach, afterEach } from 'node:test';
19
21
  import assert from 'node:assert/strict';
20
22
 
@@ -52,6 +54,16 @@ const SAMPLE_REQUIREMENTS = `# Requirements
52
54
  - Description: Output matches GSD format.
53
55
  `;
54
56
 
57
+ const SAMPLE_REQUIREMENTS_LEGACY_IDS = `# Requirements
58
+
59
+ ## Active
60
+
61
+ - [ ] **CORE-PIPELINE**: Pipeline must work end-to-end.
62
+ - [ ] **OUTPUT-FORMAT**: Output matches GSD format.
63
+ - [ ] **IMPORT-DB**: Migration imports requirements into the DB.
64
+ - [ ] **STATUS-WIDGET**: Status can query migrated requirements.
65
+ `;
66
+
55
67
  const SAMPLE_STATE = `# State
56
68
 
57
69
  **Current Phase:** 20-features
@@ -166,14 +178,14 @@ Depends on foundation work.
166
178
  </context>
167
179
  `;
168
180
 
169
- function createCompleteFixture(): string {
181
+ function createCompleteFixture(requirementsContent: string = SAMPLE_REQUIREMENTS): string {
170
182
  const base = mkdtempSync(join(tmpdir(), 'gsd-cmd-test-'));
171
183
  const planning = join(base, '.planning');
172
184
  mkdirSync(planning, { recursive: true });
173
185
 
174
186
  writeFileSync(join(planning, 'PROJECT.md'), SAMPLE_PROJECT);
175
187
  writeFileSync(join(planning, 'ROADMAP.md'), SAMPLE_ROADMAP);
176
- writeFileSync(join(planning, 'REQUIREMENTS.md'), SAMPLE_REQUIREMENTS);
188
+ writeFileSync(join(planning, 'REQUIREMENTS.md'), requirementsContent);
177
189
  writeFileSync(join(planning, 'STATE.md'), SAMPLE_STATE);
178
190
  writeFileSync(join(planning, 'config.json'), SAMPLE_CONFIG);
179
191
 
@@ -303,6 +315,7 @@ test('Full pipeline: parse → transform → preview → write → deriveState',
303
315
  const expectedTaskPct = totalTasks > 0 ? Math.round((doneTasks / totalTasks) * 100) : 0;
304
316
  assert.deepStrictEqual(preview.sliceCompletionPct, expectedSlicePct, 'pipeline: preview sliceCompletionPct');
305
317
  assert.deepStrictEqual(preview.taskCompletionPct, expectedTaskPct, 'pipeline: preview taskCompletionPct');
318
+ assert.deepStrictEqual(preview.decisions.total, 1, 'pipeline: preview decisions total');
306
319
 
307
320
  // Requirements in preview
308
321
  assert.deepStrictEqual(preview.requirements.active, 1, 'pipeline: preview requirements active');
@@ -342,6 +355,39 @@ test('Full pipeline: parse → transform → preview → write → deriveState',
342
355
  }
343
356
  });
344
357
 
358
+ test('Full pipeline: legacy requirement IDs import into DB with canonical IDs', async () => {
359
+ const base = createCompleteFixture(SAMPLE_REQUIREMENTS_LEGACY_IDS);
360
+ const writeTarget = mkdtempSync(join(tmpdir(), 'gsd-cmd-legacy-reqs-'));
361
+ try {
362
+ const parsed = await parsePlanningDirectory(join(base, '.planning'));
363
+ const project = transformToGSD(parsed);
364
+ const preview = generatePreview(project);
365
+
366
+ assert.deepStrictEqual(
367
+ project.requirements.map((req) => req.id),
368
+ ['R001', 'R002', 'R003', 'R004'],
369
+ 'legacy-reqs: transform assigns canonical R IDs',
370
+ );
371
+ assert.ok(
372
+ project.requirements[0]?.description.includes('Legacy ID: CORE-PIPELINE'),
373
+ 'legacy-reqs: original ID survives in migrated requirement content',
374
+ );
375
+
376
+ await writeGSDDirectory(project, writeTarget);
377
+ const imported = await importWrittenMigrationToDb(writeTarget, preview);
378
+ const counts = getRequirementCounts();
379
+
380
+ assert.deepStrictEqual(imported.decisions, 1, 'legacy-reqs: DB import includes migrated decisions');
381
+ assert.deepStrictEqual(imported.requirements, 4, 'legacy-reqs: DB import count matches preview');
382
+ assert.ok(getDecisionById('D001') !== null, 'legacy-reqs: migrated decision is queryable');
383
+ assert.deepStrictEqual(counts.total, 4, 'legacy-reqs: DB stores all migrated requirements');
384
+ } finally {
385
+ closeDatabase();
386
+ rmSync(base, { recursive: true, force: true });
387
+ rmSync(writeTarget, { recursive: true, force: true });
388
+ }
389
+ });
390
+
345
391
  // ─── Test 6: .gsd/ exists detection ────────────────────────────────────
346
392
 
347
393
  test('.gsd/ exists detection', () => {
@@ -357,4 +403,3 @@ test('.gsd/ exists detection', () => {
357
403
  rmSync(base, { recursive: true, force: true });
358
404
  }
359
405
  });
360
-
@@ -497,6 +497,7 @@ test('Scenario 11: Requirements edge cases', () => {
497
497
  makeRequirement('', 'Another No ID', 'validated'),
498
498
  makeRequirement('R005', 'Has ID', 'something-weird'),
499
499
  makeRequirement('R006', 'Deferred One', 'DEFERRED'),
500
+ makeRequirement('AUTH-7', 'Legacy ID', 'active'),
500
501
  ],
501
502
  phases: {
502
503
  '1-req-edge': makePhase('1-req-edge', 1, 'req-edge'),
@@ -510,6 +511,8 @@ test('Scenario 11: Requirements edge cases', () => {
510
511
  assert.deepStrictEqual(result.requirements[2]?.id, 'R005', 'req-edge: existing id preserved');
511
512
  assert.deepStrictEqual(result.requirements[2]?.status, 'active', 'req-edge: unknown status normalized to active');
512
513
  assert.deepStrictEqual(result.requirements[3]?.status, 'deferred', 'req-edge: uppercase DEFERRED normalized');
514
+ assert.deepStrictEqual(result.requirements[4]?.id, 'R003', 'req-edge: non-R legacy id gets next canonical id');
515
+ assert.ok(result.requirements[4]?.description.includes('Legacy ID: AUTH-7'), 'req-edge: original legacy id is preserved in description');
513
516
  });
514
517
 
515
518
  // ─── Scenario 12: Vision derivation ────────────────────────────────────────
@@ -553,6 +556,8 @@ test('Scenario 13: Decisions content', () => {
553
556
  const result = transformToGSD(project);
554
557
 
555
558
  assert.ok(result.decisionsContent.includes('decision-01'), 'decisions: extracts key-decisions from summaries');
559
+ assert.ok(result.decisionsContent.includes('| D001 |'), 'decisions: writes DB-importable decision ID');
560
+ assert.ok(result.decisionsContent.includes('| # | When | Scope | Decision | Choice | Rationale | Revisable? | Made By |'), 'decisions: writes canonical table header');
556
561
  });
557
562
 
558
563
  // ─── Scenario 14: No undefined values in output ───────────────────────────
@@ -616,4 +621,3 @@ test('Scenario 15: Empty research', () => {
616
621
  });
617
622
 
618
623
  // ─── Results ───────────────────────────────────────────────────────────────
619
-