gsd-pi 2.82.0-dev.725028083 → 2.82.0-dev.ed17d078d
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.
- package/dist/resources/.managed-resources-content-hash +1 -1
- package/dist/resources/extensions/gsd/auto/orchestrator.js +113 -6
- package/dist/resources/extensions/gsd/auto.js +121 -30
- package/dist/resources/extensions/gsd/md-importer.js +1 -1
- package/dist/resources/extensions/gsd/migrate/command.js +5 -0
- package/dist/resources/extensions/gsd/migrate/preview.js +9 -0
- package/dist/resources/extensions/gsd/migrate/transformer.js +51 -4
- package/dist/resources/extensions/gsd/migrate/writer.js +11 -1
- package/dist/tsconfig.extensions.tsbuildinfo +1 -1
- package/dist/web/standalone/.next/BUILD_ID +1 -1
- package/dist/web/standalone/.next/app-path-routes-manifest.json +17 -17
- package/dist/web/standalone/.next/build-manifest.json +2 -2
- package/dist/web/standalone/.next/prerender-manifest.json +3 -3
- package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.html +1 -1
- package/dist/web/standalone/.next/server/app/index.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app-paths-manifest.json +17 -17
- package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
- package/dist/web/standalone/.next/server/pages/404.html +1 -1
- package/dist/web/standalone/.next/server/pages/500.html +1 -1
- package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
- package/package.json +1 -1
- package/packages/pi-tui/dist/tui.d.ts.map +1 -1
- package/packages/pi-tui/dist/tui.js +5 -0
- package/packages/pi-tui/dist/tui.js.map +1 -1
- package/packages/pi-tui/src/tui.ts +6 -0
- package/packages/pi-tui/tsconfig.tsbuildinfo +1 -1
- package/src/resources/extensions/gsd/auto/contracts.ts +46 -11
- package/src/resources/extensions/gsd/auto/orchestrator.ts +118 -6
- package/src/resources/extensions/gsd/auto.ts +129 -31
- package/src/resources/extensions/gsd/md-importer.ts +1 -1
- package/src/resources/extensions/gsd/migrate/command.ts +5 -0
- package/src/resources/extensions/gsd/migrate/preview.ts +10 -0
- package/src/resources/extensions/gsd/migrate/transformer.ts +58 -4
- package/src/resources/extensions/gsd/migrate/writer.ts +14 -1
- package/src/resources/extensions/gsd/tests/auto-orchestrator.test.ts +408 -4
- package/src/resources/extensions/gsd/tests/auto-runtime-state.test.ts +4 -4
- package/src/resources/extensions/gsd/tests/integration/migrate-command.test.ts +48 -3
- package/src/resources/extensions/gsd/tests/migrate-transformer.test.ts +5 -1
- package/src/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +6 -1
- /package/dist/web/standalone/.next/static/{KDRTXR-22LPCsa80X9dey → YEvjuT-fsFfYQhDSWtueS}/_buildManifest.js +0 -0
- /package/dist/web/standalone/.next/static/{KDRTXR-22LPCsa80X9dey → YEvjuT-fsFfYQhDSWtueS}/_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 {
|
|
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
|
-
|
|
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
|
+
});
|
|
@@ -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: "
|
|
16
|
-
async advance() { return { kind: "
|
|
17
|
-
async resume() { return { kind: "
|
|
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'),
|
|
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
|
-
|
|
@@ -14,7 +14,7 @@ import { parseSummary } from '../files.ts';
|
|
|
14
14
|
import { deriveState } from '../state.ts';
|
|
15
15
|
import { invalidateAllCaches } from '../cache.ts';
|
|
16
16
|
import { ensureDbOpen } from '../bootstrap/dynamic-tools.ts';
|
|
17
|
-
import { closeDatabase, getAllMilestones } from '../gsd-db.ts';
|
|
17
|
+
import { closeDatabase, getAllMilestones, getArtifact } from '../gsd-db.ts';
|
|
18
18
|
import { importWrittenMigrationToDb } from '../migrate/command.ts';
|
|
19
19
|
import type {
|
|
20
20
|
GSDProject,
|
|
@@ -336,7 +336,12 @@ test('Scenario 2: Fully complete project — deriveState phase', async () => {
|
|
|
336
336
|
assert.deepStrictEqual(preview.taskCompletionPct, 100, 'complete: preview taskCompletionPct');
|
|
337
337
|
assert.deepStrictEqual(preview.requirements.total, 0, 'complete: preview requirements total');
|
|
338
338
|
|
|
339
|
+
const imported = await importWrittenMigrationToDb(base, preview);
|
|
340
|
+
assert.ok(imported.artifacts >= 6, 'complete: imports generated milestone artifacts');
|
|
341
|
+
assert.ok(getArtifact('milestones/M001/M001-VALIDATION.md') !== null, 'complete: M001-VALIDATION.md imported as artifact');
|
|
342
|
+
assert.ok(getArtifact('milestones/M001/M001-SUMMARY.md') !== null, 'complete: M001-SUMMARY.md imported as artifact');
|
|
339
343
|
} finally {
|
|
344
|
+
closeDatabase();
|
|
340
345
|
rmSync(base, { recursive: true, force: true });
|
|
341
346
|
}
|
|
342
347
|
});
|
|
File without changes
|
|
File without changes
|