gsd-pi 2.78.1-dev.b6a389b66 → 2.78.1-dev.d8826a445

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 (155) hide show
  1. package/dist/resources/.managed-resources-content-hash +1 -1
  2. package/dist/resources/extensions/gsd/auto/phases.js +7 -2
  3. package/dist/resources/extensions/gsd/auto/session.js +3 -0
  4. package/dist/resources/extensions/gsd/auto-dispatch.js +3 -2
  5. package/dist/resources/extensions/gsd/auto-post-unit.js +7 -1
  6. package/dist/resources/extensions/gsd/auto-worktree.js +185 -40
  7. package/dist/resources/extensions/gsd/auto.js +62 -1
  8. package/dist/resources/extensions/gsd/bootstrap/agent-end-recovery.js +1 -1
  9. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +17 -16
  10. package/dist/resources/extensions/gsd/bootstrap/write-gate.js +67 -55
  11. package/dist/resources/extensions/gsd/db-writer.js +96 -16
  12. package/dist/resources/extensions/gsd/delegation-policy.js +155 -0
  13. package/dist/resources/extensions/gsd/gsd-db.js +194 -0
  14. package/dist/resources/extensions/gsd/guided-flow-queue.js +1 -1
  15. package/dist/resources/extensions/gsd/guided-flow.js +117 -25
  16. package/dist/resources/extensions/gsd/metrics.js +287 -1
  17. package/dist/resources/extensions/gsd/paths.js +79 -8
  18. package/dist/resources/extensions/gsd/prompts/complete-slice.md +4 -4
  19. package/dist/resources/extensions/gsd/prompts/execute-task.md +3 -3
  20. package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +8 -1
  21. package/dist/resources/extensions/gsd/prompts/guided-discuss-project.md +22 -7
  22. package/dist/resources/extensions/gsd/prompts/guided-discuss-requirements.md +6 -2
  23. package/dist/resources/extensions/gsd/prompts/guided-discuss-slice.md +8 -1
  24. package/dist/resources/extensions/gsd/templates/project.md +10 -0
  25. package/dist/resources/extensions/gsd/workflow-mcp.js +2 -2
  26. package/dist/resources/extensions/gsd/workspace.js +59 -0
  27. package/dist/resources/extensions/gsd/worktree-resolver.js +15 -2
  28. package/dist/resources/extensions/gsd/write-intercept.js +3 -3
  29. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  30. package/dist/web/standalone/.next/BUILD_ID +1 -1
  31. package/dist/web/standalone/.next/app-path-routes-manifest.json +10 -10
  32. package/dist/web/standalone/.next/build-manifest.json +2 -2
  33. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  34. package/dist/web/standalone/.next/required-server-files.json +1 -1
  35. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  36. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  44. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  48. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app/index.html +1 -1
  52. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  53. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  54. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  55. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  56. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  57. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  58. package/dist/web/standalone/.next/server/app-paths-manifest.json +10 -10
  59. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  60. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  61. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  62. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  63. package/dist/web/standalone/server.js +1 -1
  64. package/package.json +1 -1
  65. package/packages/mcp-server/README.md +2 -11
  66. package/packages/mcp-server/dist/remote-questions.d.ts +27 -0
  67. package/packages/mcp-server/dist/remote-questions.d.ts.map +1 -1
  68. package/packages/mcp-server/dist/remote-questions.js +28 -0
  69. package/packages/mcp-server/dist/remote-questions.js.map +1 -1
  70. package/packages/mcp-server/dist/server.d.ts +28 -0
  71. package/packages/mcp-server/dist/server.d.ts.map +1 -1
  72. package/packages/mcp-server/dist/server.js +94 -4
  73. package/packages/mcp-server/dist/server.js.map +1 -1
  74. package/packages/mcp-server/dist/workflow-tools.js.map +1 -1
  75. package/packages/mcp-server/src/mcp-server.test.ts +226 -0
  76. package/packages/mcp-server/src/remote-questions.test.ts +103 -0
  77. package/packages/mcp-server/src/remote-questions.ts +35 -0
  78. package/packages/mcp-server/src/server.ts +129 -6
  79. package/packages/mcp-server/src/workflow-tools.ts +1 -1
  80. package/packages/mcp-server/tsconfig.tsbuildinfo +1 -1
  81. package/src/resources/extensions/gsd/auto/phases.ts +8 -2
  82. package/src/resources/extensions/gsd/auto/session.ts +4 -0
  83. package/src/resources/extensions/gsd/auto-dispatch.ts +10 -2
  84. package/src/resources/extensions/gsd/auto-post-unit.ts +8 -1
  85. package/src/resources/extensions/gsd/auto-worktree.ts +225 -47
  86. package/src/resources/extensions/gsd/auto.ts +79 -1
  87. package/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +1 -1
  88. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +17 -17
  89. package/src/resources/extensions/gsd/bootstrap/tests/write-gate-basepath.test.ts +103 -0
  90. package/src/resources/extensions/gsd/bootstrap/write-gate.ts +80 -55
  91. package/src/resources/extensions/gsd/db-writer.ts +113 -17
  92. package/src/resources/extensions/gsd/delegation-policy.ts +197 -0
  93. package/src/resources/extensions/gsd/gsd-db.ts +184 -0
  94. package/src/resources/extensions/gsd/guided-flow-queue.ts +1 -1
  95. package/src/resources/extensions/gsd/guided-flow.ts +154 -25
  96. package/src/resources/extensions/gsd/metrics.ts +321 -1
  97. package/src/resources/extensions/gsd/paths.ts +67 -8
  98. package/src/resources/extensions/gsd/prompts/complete-slice.md +4 -4
  99. package/src/resources/extensions/gsd/prompts/execute-task.md +3 -3
  100. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +8 -1
  101. package/src/resources/extensions/gsd/prompts/guided-discuss-project.md +22 -7
  102. package/src/resources/extensions/gsd/prompts/guided-discuss-requirements.md +6 -2
  103. package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +8 -1
  104. package/src/resources/extensions/gsd/templates/project.md +10 -0
  105. package/src/resources/extensions/gsd/tests/auto-discuss-milestone-deadlock-4973.test.ts +14 -14
  106. package/src/resources/extensions/gsd/tests/auto-session-scope.test.ts +331 -0
  107. package/src/resources/extensions/gsd/tests/auto-worktree-registry.test.ts +176 -0
  108. package/src/resources/extensions/gsd/tests/db-writer-path-containment.test.ts +152 -0
  109. package/src/resources/extensions/gsd/tests/db-writer-root-artifact.test.ts +221 -0
  110. package/src/resources/extensions/gsd/tests/db-writer-scope.test.ts +230 -0
  111. package/src/resources/extensions/gsd/tests/delegation-policy.test.ts +151 -0
  112. package/src/resources/extensions/gsd/tests/dispatch-backgroundable-annotation.test.ts +55 -0
  113. package/src/resources/extensions/gsd/tests/draft-promotion.test.ts +3 -23
  114. package/src/resources/extensions/gsd/tests/gate-1b-orphan-discrimination.test.ts +193 -0
  115. package/src/resources/extensions/gsd/tests/gate-1b-recovery-bound-corrections.test.ts +246 -0
  116. package/src/resources/extensions/gsd/tests/gate-1b-recovery-bound.test.ts +218 -0
  117. package/src/resources/extensions/gsd/tests/gsd-db-failed-open-restore.test.ts +117 -0
  118. package/src/resources/extensions/gsd/tests/gsd-db-workspace-scope.test.ts +226 -0
  119. package/src/resources/extensions/gsd/tests/gsd-root-canonical.test.ts +66 -0
  120. package/src/resources/extensions/gsd/tests/gsd-root-home-guard.test.ts +68 -5
  121. package/src/resources/extensions/gsd/tests/guided-flow-prompt-consolidation.test.ts +4 -4
  122. package/src/resources/extensions/gsd/tests/integration/workspace-collapse-integration.test.ts +371 -0
  123. package/src/resources/extensions/gsd/tests/metrics-atomic-merge.test.ts +222 -0
  124. package/src/resources/extensions/gsd/tests/metrics-lock-hardening.test.ts +400 -0
  125. package/src/resources/extensions/gsd/tests/metrics-lock-not-acquired.test.ts +141 -0
  126. package/src/resources/extensions/gsd/tests/metrics-lock-retry-sleep.test.ts +287 -0
  127. package/src/resources/extensions/gsd/tests/metrics-prune-cache-invalidation.test.ts +149 -0
  128. package/src/resources/extensions/gsd/tests/metrics-scope.test.ts +378 -0
  129. package/src/resources/extensions/gsd/tests/originalbase-path-comparison.test.ts +329 -0
  130. package/src/resources/extensions/gsd/tests/path-cache-decoupled.test.ts +209 -0
  131. package/src/resources/extensions/gsd/tests/path-normalization-unified.test.ts +175 -0
  132. package/src/resources/extensions/gsd/tests/paths-cache.test.ts +170 -0
  133. package/src/resources/extensions/gsd/tests/pending-autostart-scope.test.ts +120 -0
  134. package/src/resources/extensions/gsd/tests/prompt-contracts.test.ts +150 -7
  135. package/src/resources/extensions/gsd/tests/ready-phrase-no-files-4573.test.ts +74 -0
  136. package/src/resources/extensions/gsd/tests/register-hooks-depth-verification.test.ts +28 -16
  137. package/src/resources/extensions/gsd/tests/resume-missing-worktree-warning.test.ts +209 -0
  138. package/src/resources/extensions/gsd/tests/sync-layer-scope.test.ts +453 -0
  139. package/src/resources/extensions/gsd/tests/teardown-chdir-failure-clears-registry.test.ts +162 -0
  140. package/src/resources/extensions/gsd/tests/teardown-cleanup-parity.test.ts +102 -0
  141. package/src/resources/extensions/gsd/tests/teardown-failure-clears-registry.test.ts +186 -0
  142. package/src/resources/extensions/gsd/tests/tool-invocation-error-loop-break.test.ts +1 -1
  143. package/src/resources/extensions/gsd/tests/validator-scope-parity.test.ts +239 -0
  144. package/src/resources/extensions/gsd/tests/workflow-mcp.test.ts +2 -2
  145. package/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +9 -15
  146. package/src/resources/extensions/gsd/tests/workspace.test.ts +190 -0
  147. package/src/resources/extensions/gsd/tests/write-gate-predicates.test.ts +35 -35
  148. package/src/resources/extensions/gsd/tests/write-gate.test.ts +67 -52
  149. package/src/resources/extensions/gsd/tests/write-intercept.test.ts +1 -1
  150. package/src/resources/extensions/gsd/workflow-mcp.ts +2 -2
  151. package/src/resources/extensions/gsd/workspace.ts +95 -0
  152. package/src/resources/extensions/gsd/worktree-resolver.ts +16 -2
  153. package/src/resources/extensions/gsd/write-intercept.ts +3 -3
  154. /package/dist/web/standalone/.next/static/{HahrZrc_Xn4wumj0O1Ydp → AT5qi39nKXkdmQIOIoh0f}/_buildManifest.js +0 -0
  155. /package/dist/web/standalone/.next/static/{HahrZrc_Xn4wumj0O1Ydp → AT5qi39nKXkdmQIOIoh0f}/_ssgManifest.js +0 -0
@@ -0,0 +1,378 @@
1
+ // GSD-2 + metrics-scope.test.ts: tests for scope-aware metrics variants (C6)
2
+
3
+ import { describe, test, beforeEach, afterEach } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import {
6
+ mkdtempSync,
7
+ mkdirSync,
8
+ readFileSync,
9
+ rmSync,
10
+ realpathSync,
11
+ writeFileSync,
12
+ } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { tmpdir } from "node:os";
15
+ import { spawnSync } from "node:child_process";
16
+
17
+ import {
18
+ initMetrics,
19
+ resetMetrics,
20
+ getLedger,
21
+ snapshotUnitMetrics,
22
+ initMetricsByScope,
23
+ getLedgerByScope,
24
+ resetMetricsByScope,
25
+ snapshotUnitMetricsByScope,
26
+ type MetricsLedger,
27
+ type UnitMetrics,
28
+ } from "../metrics.js";
29
+ import { createWorkspace, scopeMilestone } from "../workspace.js";
30
+
31
+ // ─── Helpers ────────────────────────────────────────────────────────────────
32
+
33
+ function makeProjectDir(): string {
34
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), "gsd-metrics-scope-")));
35
+ mkdirSync(join(dir, ".gsd"), { recursive: true });
36
+ return dir;
37
+ }
38
+
39
+ function mockCtx(messages: any[] = []): any {
40
+ const entries = messages.map((msg, i) => ({
41
+ type: "message",
42
+ id: `entry-${i}`,
43
+ parentId: i > 0 ? `entry-${i - 1}` : null,
44
+ timestamp: new Date().toISOString(),
45
+ message: msg,
46
+ }));
47
+ return {
48
+ sessionManager: { getEntries: () => entries },
49
+ model: { id: "test-model" },
50
+ };
51
+ }
52
+
53
+ function assistantMsg(input = 1000, output = 500): any {
54
+ return {
55
+ role: "assistant",
56
+ content: [{ type: "text", text: "done" }],
57
+ usage: {
58
+ input,
59
+ output,
60
+ cacheRead: 0,
61
+ cacheWrite: 0,
62
+ totalTokens: input + output,
63
+ cost: { total: 0.01 },
64
+ },
65
+ };
66
+ }
67
+
68
+ // ─── Tests ──────────────────────────────────────────────────────────────────
69
+
70
+ describe("ByScope variant writes to the same path as legacy variant", () => {
71
+ let projectDir: string;
72
+
73
+ beforeEach(() => {
74
+ projectDir = makeProjectDir();
75
+ resetMetrics();
76
+ });
77
+
78
+ afterEach(() => {
79
+ resetMetrics();
80
+ rmSync(projectDir, { recursive: true, force: true });
81
+ });
82
+
83
+ test("metrics.json written by snapshotUnitMetrics matches path used by snapshotUnitMetricsByScope", () => {
84
+ const ws = createWorkspace(projectDir);
85
+ const scope = scopeMilestone(ws, "M001");
86
+
87
+ const ctx = mockCtx([assistantMsg()]);
88
+ const startedAt = Date.now() - 5000;
89
+
90
+ // Write via legacy path
91
+ initMetrics(projectDir);
92
+ snapshotUnitMetrics(ctx, "execute-task", "M001/S01/T01", startedAt, "test-model");
93
+ resetMetrics();
94
+
95
+ // Read via scope path
96
+ initMetricsByScope(scope);
97
+ const scopedLedger = getLedgerByScope(scope);
98
+ assert.ok(scopedLedger, "scoped ledger should load the same metrics.json");
99
+ assert.equal(scopedLedger!.units.length, 1, "should see the unit written by legacy path");
100
+ assert.equal(scopedLedger!.units[0].id, "M001/S01/T01");
101
+ resetMetricsByScope(scope);
102
+ });
103
+
104
+ test("snapshotUnitMetricsByScope writes to the same metrics.json as the legacy path", () => {
105
+ const ws = createWorkspace(projectDir);
106
+ const scope = scopeMilestone(ws, "M001");
107
+ const ctx = mockCtx([assistantMsg()]);
108
+ const startedAt = Date.now() - 5000;
109
+
110
+ // Write via scope path (no initMetrics called)
111
+ snapshotUnitMetricsByScope(scope, ctx, "execute-task", "M001/S01/T01", startedAt, "test-model");
112
+ resetMetricsByScope(scope);
113
+
114
+ // Read via legacy path
115
+ initMetrics(projectDir);
116
+ const legacyLedger = getLedger();
117
+ assert.ok(legacyLedger, "legacy path should read what the scope variant wrote");
118
+ assert.equal(legacyLedger!.units.length, 1);
119
+ assert.equal(legacyLedger!.units[0].id, "M001/S01/T01");
120
+ resetMetrics();
121
+ });
122
+ });
123
+
124
+ describe("ByScope variant is pinned to scope — cwd-drift does not move write target", () => {
125
+ let projectDir: string;
126
+
127
+ beforeEach(() => {
128
+ projectDir = makeProjectDir();
129
+ resetMetrics();
130
+ });
131
+
132
+ afterEach(() => {
133
+ resetMetrics();
134
+ rmSync(projectDir, { recursive: true, force: true });
135
+ });
136
+
137
+ test("write target is the scope's projectRoot regardless of process.cwd()", () => {
138
+ const ws = createWorkspace(projectDir);
139
+ const scope = scopeMilestone(ws, "M001");
140
+ const ctx = mockCtx([assistantMsg()]);
141
+ const startedAt = Date.now() - 3000;
142
+
143
+ // Record projectRoot before writing
144
+ const expectedMetricsPath = join(ws.projectRoot, ".gsd", "metrics.json");
145
+
146
+ snapshotUnitMetricsByScope(scope, ctx, "execute-task", "M001/S01/T01", startedAt, "test-model");
147
+
148
+ // Verify the file was written to the expected location
149
+ const raw = readFileSync(expectedMetricsPath, "utf-8");
150
+ const parsed: MetricsLedger = JSON.parse(raw);
151
+ assert.equal(parsed.units.length, 1);
152
+ assert.equal(parsed.units[0].id, "M001/S01/T01");
153
+
154
+ resetMetricsByScope(scope);
155
+ });
156
+
157
+ test("two scopes for different projectRoots write to separate metrics.json files", () => {
158
+ const projectDir2 = makeProjectDir();
159
+ try {
160
+ const ws1 = createWorkspace(projectDir);
161
+ const ws2 = createWorkspace(projectDir2);
162
+ const scope1 = scopeMilestone(ws1, "M001");
163
+ const scope2 = scopeMilestone(ws2, "M002");
164
+
165
+ const ctx = mockCtx([assistantMsg()]);
166
+ const startedAt = Date.now() - 3000;
167
+
168
+ snapshotUnitMetricsByScope(scope1, ctx, "execute-task", "M001/S01/T01", startedAt, "test-model");
169
+ snapshotUnitMetricsByScope(scope2, ctx, "execute-task", "M002/S01/T01", startedAt, "test-model");
170
+
171
+ const metrics1 = JSON.parse(
172
+ readFileSync(join(ws1.projectRoot, ".gsd", "metrics.json"), "utf-8"),
173
+ ) as MetricsLedger;
174
+ const metrics2 = JSON.parse(
175
+ readFileSync(join(ws2.projectRoot, ".gsd", "metrics.json"), "utf-8"),
176
+ ) as MetricsLedger;
177
+
178
+ assert.equal(metrics1.units.length, 1);
179
+ assert.equal(metrics1.units[0].id, "M001/S01/T01");
180
+ assert.equal(metrics2.units.length, 1);
181
+ assert.equal(metrics2.units[0].id, "M002/S01/T01");
182
+
183
+ resetMetricsByScope(scope1);
184
+ resetMetricsByScope(scope2);
185
+ } finally {
186
+ rmSync(projectDir2, { recursive: true, force: true });
187
+ }
188
+ });
189
+ });
190
+
191
+ describe("ByScope works without calling initMetrics", () => {
192
+ let projectDir: string;
193
+
194
+ beforeEach(() => {
195
+ projectDir = makeProjectDir();
196
+ // Deliberately do NOT call initMetrics / resetMetrics
197
+ });
198
+
199
+ afterEach(() => {
200
+ resetMetrics();
201
+ rmSync(projectDir, { recursive: true, force: true });
202
+ });
203
+
204
+ test("snapshotUnitMetricsByScope succeeds without initMetrics having been called", () => {
205
+ const ws = createWorkspace(projectDir);
206
+ const scope = scopeMilestone(ws, "M001");
207
+ const ctx = mockCtx([assistantMsg()]);
208
+
209
+ // Confirm singleton was never initialized
210
+ assert.equal(getLedger(), null, "module singleton should be null — initMetrics was never called");
211
+
212
+ const unit = snapshotUnitMetricsByScope(
213
+ scope,
214
+ ctx,
215
+ "execute-task",
216
+ "M001/S01/T01",
217
+ Date.now() - 2000,
218
+ "test-model",
219
+ );
220
+ assert.ok(unit, "snapshotUnitMetricsByScope should return a unit");
221
+ assert.equal(unit!.id, "M001/S01/T01");
222
+
223
+ // Verify on disk
224
+ const raw = readFileSync(join(projectDir, ".gsd", "metrics.json"), "utf-8");
225
+ const parsed: MetricsLedger = JSON.parse(raw);
226
+ assert.equal(parsed.units.length, 1);
227
+
228
+ resetMetricsByScope(scope);
229
+ });
230
+
231
+ test("initMetricsByScope succeeds without initMetrics having been called", () => {
232
+ const ws = createWorkspace(projectDir);
233
+ const scope = scopeMilestone(ws, "M001");
234
+
235
+ assert.equal(getLedger(), null);
236
+
237
+ initMetricsByScope(scope);
238
+ const l = getLedgerByScope(scope);
239
+ assert.ok(l, "getLedgerByScope should return a ledger after initMetricsByScope");
240
+ assert.equal(l!.version, 1);
241
+ assert.equal(l!.units.length, 0);
242
+
243
+ resetMetricsByScope(scope);
244
+ });
245
+ });
246
+
247
+ describe("ByScope atomic write-merge — concurrent writers do not clobber", () => {
248
+ let projectDir: string;
249
+
250
+ beforeEach(() => {
251
+ projectDir = makeProjectDir();
252
+ resetMetrics();
253
+ });
254
+
255
+ afterEach(() => {
256
+ resetMetrics();
257
+ rmSync(projectDir, { recursive: true, force: true });
258
+ });
259
+
260
+ // Worker script: same lock+merge semantics as saveLedger, written in plain CJS
261
+ // so it can run as a child process without loading the full extension tree.
262
+ const MERGE_WORKER = `
263
+ const { openSync, closeSync, unlinkSync, existsSync, readFileSync, mkdirSync, renameSync } = require('node:fs');
264
+ const { dirname } = require('node:path');
265
+ const { randomBytes } = require('node:crypto');
266
+
267
+ const metricsPath = process.env.GSD_SCOPE_METRICS_PATH;
268
+ const milestoneId = process.env.GSD_SCOPE_MILESTONE_ID;
269
+ const lockPath = metricsPath + '.lock';
270
+
271
+ function acquireLock(lp, ms) {
272
+ const deadline = Date.now() + ms;
273
+ while (Date.now() < deadline) {
274
+ try { const fd = openSync(lp, 'wx'); closeSync(fd); return true; }
275
+ catch { const w = Date.now() + Math.min(50, deadline - Date.now()); while (Date.now() < w) {} }
276
+ }
277
+ return false;
278
+ }
279
+ function releaseLock(lp) { try { unlinkSync(lp); } catch {} }
280
+ function saveAtomic(fp, data) {
281
+ mkdirSync(dirname(fp), { recursive: true });
282
+ const tmp = fp + '.tmp.' + randomBytes(4).toString('hex');
283
+ require('node:fs').writeFileSync(tmp, JSON.stringify(data, null, 2) + '\\n', 'utf-8');
284
+ renameSync(tmp, fp);
285
+ }
286
+ function dedup(units) {
287
+ const m = new Map();
288
+ for (const u of units) {
289
+ const k = u.type + '\\0' + u.id + '\\0' + u.startedAt;
290
+ const e = m.get(k);
291
+ if (!e || u.finishedAt > e.finishedAt) m.set(k, u);
292
+ }
293
+ return Array.from(m.values());
294
+ }
295
+
296
+ const unit = {
297
+ type: 'execute-task', id: milestoneId + '/S01/T01', model: 'test',
298
+ startedAt: 1000, finishedAt: Date.now(),
299
+ tokens: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, total: 15 },
300
+ cost: 0.001, toolCalls: 0, assistantMessages: 1, userMessages: 1,
301
+ };
302
+ const workerLedger = { version: 1, projectStartedAt: 1000, units: [unit] };
303
+
304
+ const acquired = acquireLock(lockPath, 5000);
305
+ try {
306
+ let diskUnits = [];
307
+ if (existsSync(metricsPath)) {
308
+ try { const p = JSON.parse(readFileSync(metricsPath, 'utf-8')); if (p && Array.isArray(p.units)) diskUnits = p.units; } catch {}
309
+ }
310
+ saveAtomic(metricsPath, { ...workerLedger, units: dedup([...diskUnits, ...workerLedger.units]) });
311
+ } finally {
312
+ if (acquired) releaseLock(lockPath);
313
+ }
314
+ `;
315
+
316
+ function spawnMergeWorker(metricsPath: string, milestoneId: string): void {
317
+ const result = spawnSync(process.execPath, ["-e", MERGE_WORKER], {
318
+ env: {
319
+ ...process.env,
320
+ GSD_SCOPE_METRICS_PATH: metricsPath,
321
+ GSD_SCOPE_MILESTONE_ID: milestoneId,
322
+ },
323
+ encoding: "utf-8",
324
+ timeout: 10_000,
325
+ });
326
+ if (result.error) throw result.error;
327
+ if (result.status !== 0) {
328
+ throw new Error(`Worker for ${milestoneId} failed:\n${result.stderr}`);
329
+ }
330
+ }
331
+
332
+ test("snapshotUnitMetricsByScope preserves a pre-existing entry written by a concurrent worker", () => {
333
+ const ws = createWorkspace(projectDir);
334
+ const scope = scopeMilestone(ws, "M002");
335
+ const metricsPath = join(ws.projectRoot, ".gsd", "metrics.json");
336
+
337
+ // Simulate a concurrent worker that already wrote M001's entry to disk
338
+ spawnMergeWorker(metricsPath, "M001");
339
+
340
+ // Now write M002 via scope variant — must preserve M001's entry
341
+ const ctx = mockCtx([assistantMsg()]);
342
+ snapshotUnitMetricsByScope(
343
+ scope,
344
+ ctx,
345
+ "execute-task",
346
+ "M002/S01/T01",
347
+ Date.now() - 2000,
348
+ "test-model",
349
+ );
350
+
351
+ const raw = readFileSync(metricsPath, "utf-8");
352
+ const parsed: MetricsLedger = JSON.parse(raw);
353
+ assert.equal(parsed.units.length, 2, "both M001 and M002 units must be in metrics.json");
354
+
355
+ const ids = parsed.units.map((u: UnitMetrics) => u.id);
356
+ assert.ok(ids.some((id) => id.startsWith("M001")), "M001 unit must be preserved");
357
+ assert.ok(ids.some((id) => id.startsWith("M002")), "M002 unit must be present");
358
+
359
+ resetMetricsByScope(scope);
360
+ });
361
+
362
+ test("idempotent ByScope snapshot does not duplicate units on disk", () => {
363
+ const ws = createWorkspace(projectDir);
364
+ const scope = scopeMilestone(ws, "M001");
365
+ const ctx = mockCtx([assistantMsg()]);
366
+ const startedAt = Date.now() - 3000;
367
+ const metricsPath = join(ws.projectRoot, ".gsd", "metrics.json");
368
+
369
+ // Snapshot twice with same type+id+startedAt
370
+ snapshotUnitMetricsByScope(scope, ctx, "execute-task", "M001/S01/T01", startedAt, "test-model");
371
+ snapshotUnitMetricsByScope(scope, ctx, "execute-task", "M001/S01/T01", startedAt, "test-model");
372
+
373
+ const parsed: MetricsLedger = JSON.parse(readFileSync(metricsPath, "utf-8"));
374
+ assert.equal(parsed.units.length, 1, "duplicate snapshots must not create duplicate entries");
375
+
376
+ resetMetricsByScope(scope);
377
+ });
378
+ });