gsd-pi 2.79.0-dev.5c910bb05 → 2.79.0-dev.ece5fd8ba

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 (81) hide show
  1. package/dist/resources/.managed-resources-content-hash +1 -1
  2. package/dist/resources/extensions/gsd/auto/phases.js +6 -1
  3. package/dist/resources/extensions/gsd/auto-artifact-paths.js +2 -2
  4. package/dist/resources/extensions/gsd/auto-dispatch.js +2 -0
  5. package/dist/resources/extensions/gsd/auto-recovery.js +18 -3
  6. package/dist/resources/extensions/gsd/auto-start.js +3 -2
  7. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +8 -8
  8. package/dist/resources/extensions/gsd/bootstrap/write-gate.js +8 -8
  9. package/dist/resources/extensions/gsd/guided-flow.js +40 -0
  10. package/dist/resources/extensions/gsd/paths.js +5 -1
  11. package/dist/resources/extensions/gsd/tools/workflow-tool-executors.js +45 -4
  12. package/dist/resources/extensions/gsd/uok/audit.js +23 -9
  13. package/dist/resources/extensions/gsd/uok/contracts.js +69 -1
  14. package/dist/resources/extensions/gsd/uok/dispatch-envelope.js +3 -0
  15. package/dist/resources/extensions/gsd/uok/loop-adapter.js +48 -33
  16. package/dist/resources/extensions/gsd/uok/timeline.js +125 -0
  17. package/dist/resources/extensions/shared/gsd-phase-state.js +45 -3
  18. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  19. package/dist/web/standalone/.next/BUILD_ID +1 -1
  20. package/dist/web/standalone/.next/app-path-routes-manifest.json +15 -15
  21. package/dist/web/standalone/.next/build-manifest.json +2 -2
  22. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  23. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  24. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  25. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  32. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/index.html +1 -1
  40. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app-paths-manifest.json +15 -15
  47. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  48. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  49. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  50. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  51. package/package.json +1 -1
  52. package/packages/mcp-server/src/workflow-tools.test.ts +13 -2
  53. package/src/resources/extensions/gsd/auto/phases.ts +6 -1
  54. package/src/resources/extensions/gsd/auto-artifact-paths.ts +2 -2
  55. package/src/resources/extensions/gsd/auto-dispatch.ts +1 -0
  56. package/src/resources/extensions/gsd/auto-recovery.ts +17 -3
  57. package/src/resources/extensions/gsd/auto-start.ts +3 -2
  58. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +11 -8
  59. package/src/resources/extensions/gsd/bootstrap/tests/write-gate-shouldblock-basepath.test.ts +97 -0
  60. package/src/resources/extensions/gsd/bootstrap/write-gate.ts +8 -4
  61. package/src/resources/extensions/gsd/guided-flow.ts +47 -0
  62. package/src/resources/extensions/gsd/paths.ts +6 -1
  63. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +108 -1
  64. package/src/resources/extensions/gsd/tests/check-auto-start-pending-gate.test.ts +203 -0
  65. package/src/resources/extensions/gsd/tests/check-auto-start-ready-guard.test.ts +148 -0
  66. package/src/resources/extensions/gsd/tests/deep-planning-mode-dispatch.test.ts +42 -0
  67. package/src/resources/extensions/gsd/tests/deep-project-auto-loop.test.ts +63 -2
  68. package/src/resources/extensions/gsd/tests/execute-summary-save-empty-project.test.ts +109 -0
  69. package/src/resources/extensions/gsd/tests/uok-contracts.test.ts +109 -1
  70. package/src/resources/extensions/gsd/tests/uok-loop-adapter-writer.test.ts +98 -0
  71. package/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +36 -7
  72. package/src/resources/extensions/gsd/tools/workflow-tool-executors.ts +47 -4
  73. package/src/resources/extensions/gsd/uok/audit.ts +25 -9
  74. package/src/resources/extensions/gsd/uok/contracts.ts +105 -0
  75. package/src/resources/extensions/gsd/uok/dispatch-envelope.ts +4 -0
  76. package/src/resources/extensions/gsd/uok/loop-adapter.ts +60 -45
  77. package/src/resources/extensions/gsd/uok/timeline.ts +158 -0
  78. package/src/resources/extensions/shared/gsd-phase-state.ts +56 -3
  79. package/src/resources/extensions/shared/tests/gsd-phase-state.test.ts +43 -1
  80. /package/dist/web/standalone/.next/static/{DSZPSz1kgrF8zPIrV_AMD → TzEVJ1Lh8vbez4n4Q9TqQ}/_buildManifest.js +0 -0
  81. /package/dist/web/standalone/.next/static/{DSZPSz1kgrF8zPIrV_AMD → TzEVJ1Lh8vbez4n4Q9TqQ}/_ssgManifest.js +0 -0
@@ -0,0 +1,148 @@
1
+ // GSD-2 + Regression tests for checkAutoStartAfterDiscuss "ready" notify guard (R3b)
2
+ //
3
+ // Belt-and-suspenders: even when CONTEXT.md and STATE.md exist on disk, the
4
+ // "Milestone X ready." success notify must not fire when the milestone DB row
5
+ // is absent. Otherwise the user sees "ready" and then /gsd reports
6
+ // "No Active Milestone" because the milestone was never registered.
7
+
8
+ import { describe, test, beforeEach, afterEach } from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, realpathSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { tmpdir } from "node:os";
13
+
14
+ import {
15
+ checkAutoStartAfterDiscuss,
16
+ setPendingAutoStart,
17
+ clearPendingAutoStart,
18
+ } from "../guided-flow.ts";
19
+ import { drainLogs } from "../workflow-logger.ts";
20
+ import {
21
+ openDatabase,
22
+ closeDatabase,
23
+ insertMilestone,
24
+ } from "../gsd-db.ts";
25
+ import {
26
+ clearDiscussionFlowState,
27
+ clearPendingGate,
28
+ } from "../bootstrap/write-gate.ts";
29
+
30
+ interface MockCapture {
31
+ notifies: Array<{ msg: string; level: string }>;
32
+ messages: Array<{ payload: any; options: any }>;
33
+ }
34
+
35
+ function mkCapture(): MockCapture {
36
+ return { notifies: [], messages: [] };
37
+ }
38
+
39
+ function mkCtx(cap: MockCapture): any {
40
+ return {
41
+ ui: {
42
+ notify: (msg: string, level: string) => {
43
+ cap.notifies.push({ msg, level });
44
+ },
45
+ },
46
+ };
47
+ }
48
+
49
+ function mkPi(cap: MockCapture): any {
50
+ return {
51
+ sendMessage: (payload: any, options: any) => {
52
+ cap.messages.push({ payload, options });
53
+ },
54
+ setActiveTools: () => undefined,
55
+ getActiveTools: () => [],
56
+ };
57
+ }
58
+
59
+ function mkBase(): string {
60
+ // realpathSync to normalize the macOS /var → /private/var symlink so the
61
+ // basePath we pass matches what the workspace projectRoot resolves to.
62
+ const base = realpathSync(mkdtempSync(join(tmpdir(), "gsd-ready-guard-")));
63
+ mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true });
64
+ writeFileSync(
65
+ join(base, ".gsd", "milestones", "M001", "M001-CONTEXT.md"),
66
+ "# M001: Ready Guard Test\n\nContext.\n",
67
+ );
68
+ writeFileSync(
69
+ join(base, ".gsd", "STATE.md"),
70
+ "# State\n\nactive: M001\n",
71
+ );
72
+ return base;
73
+ }
74
+
75
+ describe("checkAutoStartAfterDiscuss ready-notify DB guard (R3b)", () => {
76
+ let base: string;
77
+ let cap: MockCapture;
78
+
79
+ beforeEach(() => {
80
+ clearPendingAutoStart();
81
+ drainLogs();
82
+ });
83
+
84
+ afterEach(() => {
85
+ closeDatabase();
86
+ clearPendingAutoStart();
87
+ if (base) {
88
+ try { clearDiscussionFlowState(base); } catch { /* */ }
89
+ try { clearPendingGate(base); } catch { /* */ }
90
+ rmSync(base, { recursive: true, force: true });
91
+ }
92
+ });
93
+
94
+ test("does not announce 'ready' when the milestone DB row is absent", () => {
95
+ base = mkBase();
96
+ // Open a fresh in-memory DB but DO NOT insertMilestone for M001.
97
+ openDatabase(":memory:");
98
+
99
+ cap = mkCapture();
100
+ setPendingAutoStart(base, {
101
+ basePath: base,
102
+ milestoneId: "M001",
103
+ ctx: mkCtx(cap),
104
+ pi: mkPi(cap),
105
+ });
106
+
107
+ const result = checkAutoStartAfterDiscuss();
108
+ assert.equal(result, false, "must return false when DB row missing");
109
+
110
+ // No success "ready" notify
111
+ const successReady = cap.notifies.find(
112
+ (n) => n.level === "success" && /ready\.?$/i.test(n.msg),
113
+ );
114
+ assert.equal(successReady, undefined, "must not announce 'ready' when DB row missing");
115
+
116
+ // An error notify must explain the missing DB row
117
+ const errorNotify = cap.notifies.find((n) => n.level === "error");
118
+ assert.ok(errorNotify, "must emit an error notify when the DB row is missing");
119
+ assert.match(
120
+ errorNotify!.msg,
121
+ /no DB row exists/i,
122
+ "error notify must mention the missing DB row",
123
+ );
124
+ assert.match(errorNotify!.msg, /M001/, "error notify must mention the milestone id");
125
+ });
126
+
127
+ test("announces 'ready' when DB row exists", () => {
128
+ base = mkBase();
129
+ openDatabase(":memory:");
130
+ insertMilestone({ id: "M001", title: "Ready Guard Test", status: "active" });
131
+
132
+ cap = mkCapture();
133
+ setPendingAutoStart(base, {
134
+ basePath: base,
135
+ milestoneId: "M001",
136
+ ctx: mkCtx(cap),
137
+ pi: mkPi(cap),
138
+ });
139
+
140
+ const result = checkAutoStartAfterDiscuss();
141
+ assert.equal(result, true, "must return true on the happy path");
142
+
143
+ const successReady = cap.notifies.find(
144
+ (n) => n.level === "success" && /Milestone\s+M001\s+ready/i.test(n.msg),
145
+ );
146
+ assert.ok(successReady, "must announce 'Milestone M001 ready.' on success");
147
+ });
148
+ });
@@ -14,6 +14,7 @@ import {
14
14
  DISPATCH_RULES,
15
15
  getDeepStageGate,
16
16
  hasPendingDeepStage,
17
+ resolveDispatch,
17
18
  setResearchProjectPromptBuilderForTest,
18
19
  type DispatchContext,
19
20
  } from "../auto-dispatch.ts";
@@ -248,6 +249,11 @@ function writeCapturedDeepPrefs(base: string): void {
248
249
  );
249
250
  }
250
251
 
252
+ function writeSkippedProjectResearchDecision(base: string): void {
253
+ mkdirSync(join(base, ".gsd", "runtime"), { recursive: true });
254
+ writeFileSync(join(base, ".gsd", "runtime", "research-decision.json"), JSON.stringify({ decision: "skip" }));
255
+ }
256
+
251
257
  function makeCtx(
252
258
  basePath: string,
253
259
  prefs: GSDPreferences | undefined,
@@ -785,6 +791,42 @@ test("Deep mode: research-project DOES dispatch when only 3 of 4 research files
785
791
  assert.ok(result && result.action === "dispatch", "any missing dimension must trigger re-run");
786
792
  });
787
793
 
794
+ test("Deep mode: queued milestone without CONTEXT.md routes to milestone research after project setup", async (t) => {
795
+ const base = makeIsolatedBaseWithCleanup(t);
796
+
797
+ writeCapturedDeepPrefs(base);
798
+ writeValidProject(base);
799
+ writeValidRequirements(base);
800
+ writeSkippedProjectResearchDecision(base);
801
+
802
+ const prefs = { planning_depth: "deep" } as GSDPreferences;
803
+ const result = await resolveDispatch(makeCtx(base, prefs));
804
+
805
+ assert.equal(result.action, "dispatch");
806
+ if (result.action === "dispatch") {
807
+ assert.equal(result.unitType, "research-milestone");
808
+ assert.equal(result.unitId, "M001");
809
+ }
810
+ });
811
+
812
+ test("Deep mode: queued milestone without CONTEXT.md can route directly to milestone planning", async (t) => {
813
+ const base = makeIsolatedBaseWithCleanup(t);
814
+
815
+ writeCapturedDeepPrefs(base);
816
+ writeValidProject(base);
817
+ writeValidRequirements(base);
818
+ writeSkippedProjectResearchDecision(base);
819
+
820
+ const prefs = { planning_depth: "deep", phases: { skip_research: true } } as GSDPreferences;
821
+ const result = await resolveDispatch(makeCtx(base, prefs));
822
+
823
+ assert.equal(result.action, "dispatch");
824
+ if (result.action === "dispatch") {
825
+ assert.equal(result.unitType, "plan-milestone");
826
+ assert.equal(result.unitId, "M001");
827
+ }
828
+ });
829
+
788
830
  // ─── centralized deep-stage gate ─────────────────────────────────────────
789
831
 
790
832
  test("Deep mode gate reports the earliest missing section", (t) => {
@@ -1,6 +1,6 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { execFileSync } from "node:child_process";
5
5
  import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
@@ -23,6 +23,11 @@ import {
23
23
  showSmartEntry,
24
24
  startDeepProjectSetupForeground,
25
25
  } from "../guided-flow.ts";
26
+ import {
27
+ closeDatabase,
28
+ insertMilestone,
29
+ openDatabase,
30
+ } from "../gsd-db.ts";
26
31
  import type { GSDPreferences } from "../preferences.ts";
27
32
  import type { GSDState } from "../types.ts";
28
33
 
@@ -342,6 +347,62 @@ test("deep project setup: pre-dispatch can run before the first milestone exists
342
347
  }
343
348
  });
344
349
 
350
+ test("deep project setup: bootstrap continues queued M002 without milestone context", async () => {
351
+ const base = makeRepo();
352
+ try {
353
+ writeCapturedDeepPrefs(base);
354
+ writeValidProjectAndRequirements(base);
355
+ mkdirSync(join(base, ".gsd", "runtime"), { recursive: true });
356
+ writeFileSync(join(base, ".gsd", "runtime", "research-decision.json"), '{"decision":"skip"}\n');
357
+
358
+ openDatabase(join(base, ".gsd", "gsd.db"));
359
+ insertMilestone({ id: "M001", title: "First milestone", status: "complete" });
360
+ insertMilestone({ id: "M002", title: "Second milestone", status: "queued" });
361
+ closeDatabase();
362
+
363
+ const messages: unknown[] = [];
364
+ const pi = {
365
+ ...makePi(messages),
366
+ getThinkingLevel: () => "medium",
367
+ };
368
+ const s = new AutoSession();
369
+ const ready = await bootstrapAutoSession(
370
+ s,
371
+ makeCtx(`queued-${randomUUID()}`) as any,
372
+ pi as any,
373
+ base,
374
+ false,
375
+ false,
376
+ {
377
+ shouldUseWorktreeIsolation: () => false,
378
+ registerSigtermHandler: () => {},
379
+ lockBase: () => base,
380
+ buildResolver: () => ({}) as any,
381
+ },
382
+ {
383
+ classification: "none",
384
+ lock: null,
385
+ pausedSession: null,
386
+ state: null,
387
+ recovery: null,
388
+ recoveryPrompt: null,
389
+ recoveryToolCallCount: 0,
390
+ artifactSatisfied: false,
391
+ hasResumableDiskState: false,
392
+ isBootstrapCrash: false,
393
+ },
394
+ );
395
+
396
+ assert.equal(ready, true);
397
+ assert.equal(s.active, true);
398
+ assert.equal(s.currentMilestoneId, "M002");
399
+ assert.equal(messages.length, 0, "queued deep milestone must not re-enter smart new-milestone discussion");
400
+ } finally {
401
+ try { closeDatabase(); } catch {}
402
+ rmSync(base, { recursive: true, force: true });
403
+ }
404
+ });
405
+
345
406
  test("deep project setup: pre-dispatch takes precedence over an existing draft milestone", async () => {
346
407
  const base = makeBase();
347
408
  try {
@@ -1020,7 +1081,7 @@ test("deep project setup: research-project blocker placeholder is a file, not th
1020
1081
  const base = makeBase();
1021
1082
  try {
1022
1083
  const expectedPath = resolveExpectedArtifactPath("research-project", "PROJECT-RESEARCH", base);
1023
- assert.equal(expectedPath, join(base, ".gsd", "research", "PROJECT-RESEARCH-BLOCKER.md"));
1084
+ assert.equal(expectedPath, join(realpathSync(base), ".gsd", "research", "PROJECT-RESEARCH-BLOCKER.md"));
1024
1085
 
1025
1086
  mkdirSync(join(base, ".gsd", "research"), { recursive: true });
1026
1087
  const diagnosis = writeBlockerPlaceholder(
@@ -0,0 +1,109 @@
1
+ // gsd-2 / execute-summary-save PROJECT registration hard-fail tests
2
+ import test from "node:test";
3
+ import assert from "node:assert/strict";
4
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { tmpdir } from "node:os";
7
+ import { randomUUID } from "node:crypto";
8
+
9
+ import { openDatabase, closeDatabase, getAllMilestones } from "../gsd-db.ts";
10
+ import { markApprovalGateVerified, clearDiscussionFlowState } from "../bootstrap/write-gate.ts";
11
+ import { executeSummarySave } from "../tools/workflow-tool-executors.ts";
12
+
13
+ function makeTmpBase(): string {
14
+ const base = join(tmpdir(), `gsd-summary-save-empty-project-${randomUUID()}`);
15
+ mkdirSync(join(base, ".gsd"), { recursive: true });
16
+ return base;
17
+ }
18
+
19
+ function cleanup(base: string): void {
20
+ try { rmSync(base, { recursive: true, force: true }); } catch { /* swallow */ }
21
+ }
22
+
23
+ function openTestDb(base: string): void {
24
+ openDatabase(join(base, ".gsd", "gsd.db"));
25
+ }
26
+
27
+ async function inProjectDir<T>(dir: string, fn: () => Promise<T>): Promise<T> {
28
+ const originalCwd = process.cwd();
29
+ try {
30
+ process.chdir(dir);
31
+ return await fn();
32
+ } finally {
33
+ process.chdir(originalCwd);
34
+ }
35
+ }
36
+
37
+ function setupBase(t: { after: (fn: () => void) => void }): string {
38
+ const base = makeTmpBase();
39
+ // Force deep planning so the root-artifact guard requires a verified approval gate,
40
+ // matching the production flow that surfaces the regression.
41
+ writeFileSync(join(base, ".gsd", "PREFERENCES.md"), "---\nplanning_depth: deep\n---\n");
42
+ openTestDb(base);
43
+ markApprovalGateVerified("depth_verification_project_confirm", base);
44
+ t.after(() => {
45
+ clearDiscussionFlowState(base);
46
+ closeDatabase();
47
+ cleanup(base);
48
+ });
49
+ return base;
50
+ }
51
+
52
+ test("executeSummarySave returns isError when PROJECT.md content has zero parseable milestone lines", async (t) => {
53
+ const base = setupBase(t);
54
+
55
+ const content = [
56
+ "# Project",
57
+ "",
58
+ "## What This Is",
59
+ "",
60
+ "Bad-separator regression fixture.",
61
+ "",
62
+ "## Milestone Sequence",
63
+ "",
64
+ // Wrong separator: " : " instead of em-dash / -- / - → MILESTONE_LINE_RE matches zero lines.
65
+ "- [ ] M001: Foundation : Establish the first runnable slice.",
66
+ "",
67
+ "## Next Section",
68
+ "",
69
+ "Trailing prose with no list bullets so MILESTONE_LINE_RE cannot bridge across lines.",
70
+ "",
71
+ ].join("\n");
72
+
73
+ const result = await inProjectDir(base, () => executeSummarySave({
74
+ artifact_type: "PROJECT",
75
+ content,
76
+ }, base));
77
+
78
+ assert.equal(result.isError, true);
79
+ assert.equal(result.details.error, "milestone_registration_empty_parse");
80
+ assert.match(result.content[0].text, /zero parseable milestone lines/);
81
+ assert.equal(getAllMilestones().length, 0);
82
+ });
83
+
84
+ test("executeSummarySave registers milestones when PROJECT.md uses canonical em-dash format", async (t) => {
85
+ const base = setupBase(t);
86
+
87
+ const content = [
88
+ "# Project",
89
+ "",
90
+ "## What This Is",
91
+ "",
92
+ "Canonical milestone-sequence fixture.",
93
+ "",
94
+ "## Milestone Sequence",
95
+ "",
96
+ "- [ ] M001: Foo — bar",
97
+ "- [ ] M002: Baz — qux",
98
+ "",
99
+ ].join("\n");
100
+
101
+ const result = await inProjectDir(base, () => executeSummarySave({
102
+ artifact_type: "PROJECT",
103
+ content,
104
+ }, base));
105
+
106
+ assert.notEqual(result.isError, true);
107
+ assert.deepEqual(result.details.registeredMilestones, ["M001", "M002"]);
108
+ assert.equal(getAllMilestones().length, 2);
109
+ });
@@ -1,5 +1,10 @@
1
+ // GSD2 UOK Contract Versioning and DB Authority Tests
2
+
1
3
  import test from "node:test";
2
4
  import assert from "node:assert/strict";
5
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
3
8
 
4
9
  import type {
5
10
  AuditEventEnvelope,
@@ -11,8 +16,17 @@ import type {
11
16
  WriteRecord,
12
17
  WriterToken,
13
18
  } from "../uok/contracts.ts";
14
- import { buildAuditEnvelope } from "../uok/audit.ts";
19
+ import {
20
+ CURRENT_UOK_CONTRACT_VERSION,
21
+ normalizeAuditEvent,
22
+ validateAuditEvent,
23
+ validateDispatchEnvelope,
24
+ validateTurnResult,
25
+ } from "../uok/contracts.ts";
26
+ import { buildAuditEnvelope, emitUokAuditEvent } from "../uok/audit.ts";
15
27
  import { buildDispatchEnvelope, explainDispatch } from "../uok/dispatch-envelope.ts";
28
+ import { buildTurnTimeline } from "../uok/timeline.ts";
29
+ import { _getAdapter, closeDatabase, openDatabase } from "../gsd-db.ts";
16
30
 
17
31
  test("uok contracts serialize/deserialize turn envelopes", () => {
18
32
  const contract: TurnContract = {
@@ -37,6 +51,7 @@ test("uok contracts serialize/deserialize turn envelopes", () => {
37
51
  };
38
52
 
39
53
  const result: TurnResult = {
54
+ version: CURRENT_UOK_CONTRACT_VERSION,
40
55
  traceId: contract.traceId,
41
56
  turnId: contract.turnId,
42
57
  iteration: contract.iteration,
@@ -56,8 +71,10 @@ test("uok contracts serialize/deserialize turn envelopes", () => {
56
71
 
57
72
  const roundTrip = JSON.parse(JSON.stringify(result)) as TurnResult;
58
73
  assert.equal(roundTrip.turnId, "turn-1");
74
+ assert.equal(roundTrip.version, CURRENT_UOK_CONTRACT_VERSION);
59
75
  assert.equal(roundTrip.gateResults?.[0]?.gateId, "Q3");
60
76
  assert.equal(roundTrip.phaseResults.length, 3);
77
+ assert.equal(validateTurnResult(roundTrip).ok, true);
61
78
  });
62
79
 
63
80
  test("uok contracts include required DAG node kinds", () => {
@@ -84,9 +101,11 @@ test("uok audit envelope includes trace/turn/causality fields", () => {
84
101
  });
85
102
 
86
103
  assert.equal(event.traceId, "trace-xyz");
104
+ assert.equal(event.version, CURRENT_UOK_CONTRACT_VERSION);
87
105
  assert.equal(event.turnId, "turn-xyz");
88
106
  assert.equal(event.causedBy, "turn-start");
89
107
  assert.equal(event.payload.status, "completed");
108
+ assert.equal(validateAuditEvent(event).ok, true);
90
109
  });
91
110
 
92
111
  test("uok dispatch envelope carries scheduler reason and constraints", () => {
@@ -107,9 +126,98 @@ test("uok dispatch envelope carries scheduler reason and constraints", () => {
107
126
  });
108
127
 
109
128
  assert.equal(envelope.nodeKind, "unit");
129
+ assert.equal(envelope.version, CURRENT_UOK_CONTRACT_VERSION);
110
130
  assert.equal(envelope.reason.reasonCode, "dependency");
111
131
  assert.deepEqual(envelope.constraints?.dependsOn, ["plan-gate"]);
112
132
  assert.ok(explainDispatch(envelope).includes("execute-task M001/S01/T01"));
133
+ assert.equal(validateDispatchEnvelope(envelope).ok, true);
134
+ });
135
+
136
+ test("uok contracts normalize legacy records without losing payload fields", () => {
137
+ const legacy = {
138
+ eventId: "event-legacy",
139
+ traceId: "trace-legacy",
140
+ category: "orchestration",
141
+ type: "turn-result",
142
+ ts: new Date().toISOString(),
143
+ payload: { status: "completed", extra: "preserved" },
144
+ } as AuditEventEnvelope;
145
+
146
+ const normalized = normalizeAuditEvent(legacy);
147
+ assert.equal(normalized.version, "0");
148
+ assert.equal(normalized.payload.extra, "preserved");
149
+ assert.equal(validateAuditEvent(legacy).ok, true);
150
+ });
151
+
152
+ test("uok audit emission writes DB as authoritative before jsonl projection", (t) => {
153
+ const basePath = mkdtempSync(join(tmpdir(), "gsd-uok-db-audit-"));
154
+ mkdirSync(join(basePath, ".gsd"), { recursive: true });
155
+ t.after(() => {
156
+ closeDatabase();
157
+ rmSync(basePath, { recursive: true, force: true });
158
+ });
159
+
160
+ assert.equal(openDatabase(join(basePath, ".gsd", "gsd.db")), true);
161
+ emitUokAuditEvent(
162
+ basePath,
163
+ buildAuditEnvelope({
164
+ traceId: "trace-db",
165
+ turnId: "turn-db",
166
+ category: "orchestration",
167
+ type: "turn-start",
168
+ payload: { unitType: "execute-task" },
169
+ }),
170
+ );
171
+
172
+ const row = _getAdapter()!.prepare(
173
+ "SELECT payload_json FROM audit_events WHERE trace_id = 'trace-db' AND turn_id = 'turn-db'",
174
+ ).get() as { payload_json: string } | undefined;
175
+ assert.ok(row, "DB audit row should be written");
176
+ assert.equal(JSON.parse(row.payload_json).contractVersion, CURRENT_UOK_CONTRACT_VERSION);
177
+
178
+ const projection = readFileSync(join(basePath, ".gsd", "audit", "events.jsonl"), "utf-8");
179
+ assert.ok(projection.includes("trace-db"), "jsonl projection should still be written");
180
+ });
181
+
182
+ test("uok timeline prefers DB records over jsonl projection when DB is available", (t) => {
183
+ const basePath = mkdtempSync(join(tmpdir(), "gsd-uok-timeline-"));
184
+ const auditDir = join(basePath, ".gsd", "audit");
185
+ mkdirSync(auditDir, { recursive: true });
186
+ writeFileSync(
187
+ join(auditDir, "events.jsonl"),
188
+ `${JSON.stringify({
189
+ version: CURRENT_UOK_CONTRACT_VERSION,
190
+ eventId: "jsonl-only",
191
+ traceId: "trace-timeline",
192
+ turnId: "turn-timeline",
193
+ category: "orchestration",
194
+ type: "jsonl-projection",
195
+ ts: "2026-01-01T00:00:00.000Z",
196
+ payload: {},
197
+ })}\n`,
198
+ );
199
+ t.after(() => {
200
+ closeDatabase();
201
+ rmSync(basePath, { recursive: true, force: true });
202
+ });
203
+
204
+ assert.equal(openDatabase(join(basePath, ".gsd", "gsd.db")), true);
205
+ emitUokAuditEvent(
206
+ basePath,
207
+ buildAuditEnvelope({
208
+ traceId: "trace-timeline",
209
+ turnId: "turn-timeline",
210
+ category: "orchestration",
211
+ type: "db-authoritative",
212
+ payload: {},
213
+ }),
214
+ );
215
+
216
+ const timeline = buildTurnTimeline(basePath, { traceId: "trace-timeline", turnId: "turn-timeline" });
217
+ assert.equal(timeline.authoritative, "db");
218
+ assert.equal(timeline.degraded, false);
219
+ assert.ok(timeline.entries.some((entry) => entry.type === "db-authoritative"));
220
+ assert.equal(timeline.entries.some((entry) => entry.type === "jsonl-projection"), false);
113
221
  });
114
222
 
115
223
  test("uok writer records serialize sequence metadata", () => {
@@ -63,3 +63,101 @@ test("uok turn observer adds writer sequence metadata to audit events", (t) => {
63
63
  assert.equal(payloads[1]?.writeSequence, 2);
64
64
  assert.equal(typeof payloads[0]?.writerTokenId, "string");
65
65
  });
66
+
67
+ test("uok turn observer releases writer token when validation throws", (t) => {
68
+ const basePath = mkdtempSync(join(tmpdir(), "gsd-uok-loop-writer-throw-"));
69
+ resetWriterTokensForTests();
70
+ t.after(() => {
71
+ resetWriterTokensForTests();
72
+ rmSync(basePath, { recursive: true, force: true });
73
+ });
74
+
75
+ const observer = createTurnObserver({
76
+ basePath,
77
+ gitAction: "status-only",
78
+ gitPush: false,
79
+ enableAudit: false,
80
+ enableGitops: false,
81
+ });
82
+
83
+ observer.onTurnStart({
84
+ basePath,
85
+ traceId: "trace-throw",
86
+ turnId: "turn-throw",
87
+ iteration: 1,
88
+ unitType: "execute-task",
89
+ unitId: "M001/S01/T01",
90
+ startedAt: new Date().toISOString(),
91
+ });
92
+ assert.equal(hasActiveWriterToken(basePath, "turn-throw"), true);
93
+
94
+ // Invalid payload (missing required fields like status/finishedAt) should
95
+ // trigger validateTurnResult to fail and throw.
96
+ assert.throws(() => {
97
+ observer.onTurnResult({
98
+ traceId: "trace-throw",
99
+ turnId: "turn-throw",
100
+ // @ts-expect-error intentionally invalid for test
101
+ iteration: "not-a-number",
102
+ unitType: "execute-task",
103
+ unitId: "M001/S01/T01",
104
+ status: "completed",
105
+ failureClass: "none",
106
+ phaseResults: [],
107
+ startedAt: new Date().toISOString(),
108
+ finishedAt: new Date().toISOString(),
109
+ });
110
+ }, /Invalid UOK turn result/);
111
+
112
+ // Cleanup must run in finally — token released, no leaked state.
113
+ assert.equal(hasActiveWriterToken(basePath, "turn-throw"), false);
114
+ });
115
+
116
+ test("uok turn observer falls back to cached phaseResults when result.phaseResults is missing", (t) => {
117
+ const basePath = mkdtempSync(join(tmpdir(), "gsd-uok-loop-writer-missing-"));
118
+ resetWriterTokensForTests();
119
+ t.after(() => {
120
+ resetWriterTokensForTests();
121
+ rmSync(basePath, { recursive: true, force: true });
122
+ });
123
+
124
+ const observer = createTurnObserver({
125
+ basePath,
126
+ gitAction: "status-only",
127
+ gitPush: false,
128
+ enableAudit: false,
129
+ enableGitops: false,
130
+ });
131
+
132
+ observer.onTurnStart({
133
+ basePath,
134
+ traceId: "trace-missing",
135
+ turnId: "turn-missing",
136
+ iteration: 1,
137
+ unitType: "execute-task",
138
+ unitId: "M001/S01/T01",
139
+ startedAt: new Date().toISOString(),
140
+ });
141
+
142
+ // Without the Array.isArray guard, accessing result.phaseResults.length on a
143
+ // payload where phaseResults is undefined would throw TypeError before
144
+ // validateTurnResult could surface a structured error. The guard must defer
145
+ // to the cached phaseResults fallback so the turn completes cleanly.
146
+ assert.doesNotThrow(() => {
147
+ observer.onTurnResult({
148
+ traceId: "trace-missing",
149
+ turnId: "turn-missing",
150
+ iteration: 1,
151
+ unitType: "execute-task",
152
+ unitId: "M001/S01/T01",
153
+ status: "completed",
154
+ failureClass: "none",
155
+ // @ts-expect-error intentionally missing for test
156
+ phaseResults: undefined,
157
+ startedAt: new Date().toISOString(),
158
+ finishedAt: new Date().toISOString(),
159
+ });
160
+ });
161
+
162
+ assert.equal(hasActiveWriterToken(basePath, "turn-missing"), false);
163
+ });