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,103 @@
1
+ // GSD-2 write-gate bootstrap — regression test for required basePath (commit A3)
2
+ //
3
+ // Verifies that persistWriteGateSnapshot / loadWriteGateSnapshot are pinned to
4
+ // the basePath argument and do not silently fall back to process.cwd(). The
5
+ // underlying bug: both functions defaulted `basePath = process.cwd()`, so a
6
+ // persist in cwd-A followed by a chdir to cwd-B and a load (which also
7
+ // defaulted to process.cwd(), now cwd-B) missed the persisted file entirely —
8
+ // the depth-verification state became invisible across cwd boundaries.
9
+
10
+ import { test, describe, before, after } from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { mkdtempSync, rmSync, existsSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+
16
+ import {
17
+ markDepthVerified,
18
+ loadWriteGateSnapshot,
19
+ clearDiscussionFlowState,
20
+ } from "../write-gate.js";
21
+
22
+ // ─── Helpers ────────────────────────────────────────────────────────────────
23
+
24
+ function makeTempDir(): string {
25
+ return mkdtempSync(join(tmpdir(), "wg-basepath-test-"));
26
+ }
27
+
28
+ // Save and restore process.cwd() across tests to avoid cross-test pollution.
29
+ let originalCwd: string;
30
+ before(() => {
31
+ originalCwd = process.cwd();
32
+ });
33
+ after(() => {
34
+ if (process.cwd() !== originalCwd) {
35
+ process.chdir(originalCwd);
36
+ }
37
+ });
38
+
39
+ // ─── Scenario: persist with basePath=A, chdir, load with basePath=A ─────────
40
+ //
41
+ // This is the exact failure mode from the bug: persist used process.cwd() and
42
+ // load used process.cwd(), and they resolved to different directories after a
43
+ // chdir. With the fix, both calls receive an explicit basePath so cwd changes
44
+ // have no effect.
45
+
46
+ describe("write-gate basePath regression", () => {
47
+ let baseDirA: string;
48
+ let baseDirB: string;
49
+
50
+ before(() => {
51
+ baseDirA = makeTempDir();
52
+ baseDirB = makeTempDir();
53
+ });
54
+
55
+ after(() => {
56
+ // Restore cwd before cleanup to avoid issues on Windows.
57
+ process.chdir(originalCwd);
58
+ rmSync(baseDirA, { recursive: true, force: true });
59
+ rmSync(baseDirB, { recursive: true, force: true });
60
+ });
61
+
62
+ test("snapshot persisted to basePath=A is readable after chdir to basePath=B", (t) => {
63
+ // Arrange: enable persistence (the default when env var is not set to "0"/"false").
64
+ const prev = process.env.GSD_PERSIST_WRITE_GATE_STATE;
65
+ t.after(() => {
66
+ if (prev === undefined) {
67
+ delete process.env.GSD_PERSIST_WRITE_GATE_STATE;
68
+ } else {
69
+ process.env.GSD_PERSIST_WRITE_GATE_STATE = prev;
70
+ }
71
+ });
72
+ process.env.GSD_PERSIST_WRITE_GATE_STATE = "1";
73
+
74
+ // Reset state and clear any stale snapshot files from both dirs.
75
+ clearDiscussionFlowState(baseDirA);
76
+ clearDiscussionFlowState(baseDirB);
77
+
78
+ // Act: persist a milestone as depth-verified into baseDirA.
79
+ markDepthVerified("M001", baseDirA);
80
+
81
+ // Confirm the snapshot file was written under baseDirA.
82
+ const snapshotPath = join(baseDirA, ".gsd", "runtime", "write-gate-state.json");
83
+ assert.ok(existsSync(snapshotPath), "snapshot file should exist under baseDirA");
84
+
85
+ // Simulate what happens when cwd changes to a different project root.
86
+ process.chdir(baseDirB);
87
+ assert.notEqual(process.cwd(), baseDirA, "cwd should differ from baseDirA after chdir");
88
+
89
+ // Load snapshot using the explicit baseDirA — must see the persisted state.
90
+ const snapshot = loadWriteGateSnapshot(baseDirA);
91
+ assert.ok(
92
+ snapshot.verifiedDepthMilestones.includes("M001"),
93
+ "loadWriteGateSnapshot(baseDirA) must return the persisted milestone despite cwd being baseDirB",
94
+ );
95
+
96
+ // Loading with baseDirB must NOT see the state from baseDirA.
97
+ const snapshotB = loadWriteGateSnapshot(baseDirB);
98
+ assert.ok(
99
+ !snapshotB.verifiedDepthMilestones.includes("M001"),
100
+ "loadWriteGateSnapshot(baseDirB) must not bleed state from baseDirA",
101
+ );
102
+ });
103
+ });
@@ -63,20 +63,42 @@ const QUEUE_SAFE_TOOLS = new Set([
63
63
  */
64
64
  const BASH_READ_ONLY_RE = /^\s*(cat|head|tail|less|more|wc|file|stat|du|df|which|type|echo|printf|ls|find|grep|rg|awk|sed\b(?!.*-i)|sort|uniq|diff|comm|tr|cut|tee\s+-a\s+\/dev\/null|git\s+(log|show|diff|status|branch|tag|remote|rev-parse|ls-files|blame|shortlog|describe|stash\s+list|config\s+--get|cat-file)|gh\s+(issue|pr|api|repo|release)\s+(view|list|diff|status|checks)|mkdir\s+-p\s+\.gsd|rtk\s|npm\s+run\s+(test|test:\w+|lint|lint:\w+|typecheck|type-check|type-check:\w+|check|verify|audit|outdated|format:check|ci|validate)\b|npm\s+(ls|list|info|view|show|outdated|audit|explain|doctor|ping|--version|-v)\b|npx\s|tsx\s|node\s+(--print|--version|-v\b)|python[23]?\s+(-c\s+'[^']*'|--version|-V\b|-m\s+(pip\s+show|pip\s+list|site))|pip[23]?\s+(show|list|freeze|check|index\s+versions)\b|jq\s|yq\s|curl\s+(-s\b|--silent\b)(?!\s+[^|>]*\s-[oO]\b)(?!\s+[^|>]*\s--output\b)[^|>]*$|openssl\s+(version|x509|s_client)|env\b|printenv\b|true\b|false\b)/;
65
65
 
66
- const verifiedDepthMilestones = new Set<string>();
67
- const verifiedApprovalGates = new Set<string>();
68
- let activeQueuePhase = false;
66
+ interface InMemoryWriteGateState {
67
+ verifiedDepthMilestones: Set<string>;
68
+ verifiedApprovalGates: Set<string>;
69
+ activeQueuePhase: boolean;
70
+ pendingGateId: string | null;
71
+ }
72
+
73
+ function createEmptyWriteGateState(): InMemoryWriteGateState {
74
+ return {
75
+ verifiedDepthMilestones: new Set<string>(),
76
+ verifiedApprovalGates: new Set<string>(),
77
+ activeQueuePhase: false,
78
+ pendingGateId: null,
79
+ };
80
+ }
81
+
82
+ const writeGateStatesByBasePath = new Map<string, InMemoryWriteGateState>();
83
+
84
+ function writeGateStateKey(basePath: string): string {
85
+ return resolve(basePath);
86
+ }
87
+
88
+ function getWriteGateState(basePath: string = process.cwd()): InMemoryWriteGateState {
89
+ const key = writeGateStateKey(basePath);
90
+ let state = writeGateStatesByBasePath.get(key);
91
+ if (!state) {
92
+ state = createEmptyWriteGateState();
93
+ writeGateStatesByBasePath.set(key, state);
94
+ }
95
+ return state;
96
+ }
69
97
 
70
98
  /**
71
- * Discussion gate enforcement state.
72
- *
73
- * When ask_user_questions is called with a recognized gate question ID,
74
- * we track the pending gate. Until the gate is confirmed (user selects the
75
- * first/recommended option), all non-read-only tool calls are blocked.
76
- * This mechanically prevents the model from rationalizing past failed or
77
- * cancelled gate questions.
99
+ * Discussion gate enforcement state is scoped per basePath so multiple
100
+ * workspaces can coexist in the same process without sharing gate state.
78
101
  */
79
- let pendingGateId: string | null = null;
80
102
 
81
103
  /**
82
104
  * Recognized gate question ID patterns.
@@ -119,25 +141,26 @@ function shouldPersistWriteGateSnapshot(env: NodeJS.ProcessEnv = process.env): b
119
141
  return v !== "0" && v !== "false";
120
142
  }
121
143
 
122
- function writeGateSnapshotPath(basePath: string = process.cwd()): string {
144
+ function writeGateSnapshotPath(basePath: string): string {
123
145
  return join(basePath, ".gsd", "runtime", "write-gate-state.json");
124
146
  }
125
147
 
126
- function currentWriteGateSnapshot(): WriteGateSnapshot {
148
+ function currentWriteGateSnapshot(basePath: string = process.cwd()): WriteGateSnapshot {
149
+ const state = getWriteGateState(basePath);
127
150
  return {
128
- verifiedDepthMilestones: [...verifiedDepthMilestones].sort(),
129
- verifiedApprovalGates: [...verifiedApprovalGates].sort(),
130
- activeQueuePhase,
131
- pendingGateId,
151
+ verifiedDepthMilestones: [...state.verifiedDepthMilestones].sort(),
152
+ verifiedApprovalGates: [...state.verifiedApprovalGates].sort(),
153
+ activeQueuePhase: state.activeQueuePhase,
154
+ pendingGateId: state.pendingGateId,
132
155
  };
133
156
  }
134
157
 
135
- function persistWriteGateSnapshot(basePath: string = process.cwd()): void {
158
+ function persistWriteGateSnapshot(basePath: string): void {
136
159
  if (!shouldPersistWriteGateSnapshot()) return;
137
160
  const path = writeGateSnapshotPath(basePath);
138
161
  mkdirSync(join(basePath, ".gsd", "runtime"), { recursive: true });
139
162
  const tempPath = `${path}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
140
- writeFileSync(tempPath, JSON.stringify(currentWriteGateSnapshot(), null, 2), "utf-8");
163
+ writeFileSync(tempPath, JSON.stringify(currentWriteGateSnapshot(basePath), null, 2), "utf-8");
141
164
  try {
142
165
  renameSync(tempPath, path);
143
166
  } catch (err: unknown) {
@@ -152,7 +175,7 @@ function persistWriteGateSnapshot(basePath: string = process.cwd()): void {
152
175
  }
153
176
  }
154
177
 
155
- function clearPersistedWriteGateSnapshot(basePath: string = process.cwd()): void {
178
+ function clearPersistedWriteGateSnapshot(basePath: string): void {
156
179
  if (!shouldPersistWriteGateSnapshot()) return;
157
180
  const path = writeGateSnapshotPath(basePath);
158
181
  try {
@@ -185,32 +208,35 @@ const EMPTY_SNAPSHOT: WriteGateSnapshot = {
185
208
  pendingGateId: null,
186
209
  };
187
210
 
188
- export function loadWriteGateSnapshot(basePath: string = process.cwd()): WriteGateSnapshot {
211
+ export function loadWriteGateSnapshot(basePath: string): WriteGateSnapshot {
189
212
  const path = writeGateSnapshotPath(basePath);
190
213
  if (!existsSync(path)) {
191
214
  // When persist mode is active and the file has been deleted, treat it as a
192
215
  // full state reset so deleting the file clears the HARD BLOCK gate.
193
216
  // In non-persist mode the file is never written, so fall back to in-memory.
194
217
  if (shouldPersistWriteGateSnapshot()) return EMPTY_SNAPSHOT;
195
- return currentWriteGateSnapshot();
218
+ return currentWriteGateSnapshot(basePath);
196
219
  }
197
220
  try {
198
221
  return normalizeWriteGateSnapshot(JSON.parse(readFileSync(path, "utf-8")));
199
222
  } catch {
200
- return currentWriteGateSnapshot();
223
+ return currentWriteGateSnapshot(basePath);
201
224
  }
202
225
  }
203
226
 
204
- export function isDepthVerified(): boolean {
205
- return verifiedDepthMilestones.size > 0;
227
+ export function isDepthVerified(basePath: string = process.cwd()): boolean {
228
+ return getWriteGateState(basePath).verifiedDepthMilestones.size > 0;
206
229
  }
207
230
 
208
231
  /**
209
232
  * Check whether a specific milestone has passed depth verification.
210
233
  */
211
- export function isMilestoneDepthVerified(milestoneId: string | null | undefined): boolean {
234
+ export function isMilestoneDepthVerified(
235
+ milestoneId: string | null | undefined,
236
+ basePath: string = process.cwd(),
237
+ ): boolean {
212
238
  if (!milestoneId) return false;
213
- return verifiedDepthMilestones.has(milestoneId);
239
+ return getWriteGateState(basePath).verifiedDepthMilestones.has(milestoneId);
214
240
  }
215
241
 
216
242
  export function isMilestoneDepthVerifiedInSnapshot(
@@ -221,39 +247,37 @@ export function isMilestoneDepthVerifiedInSnapshot(
221
247
  return snapshot.verifiedDepthMilestones.includes(milestoneId);
222
248
  }
223
249
 
224
- export function isQueuePhaseActive(): boolean {
225
- return activeQueuePhase;
250
+ export function isQueuePhaseActive(basePath: string = process.cwd()): boolean {
251
+ return getWriteGateState(basePath).activeQueuePhase;
226
252
  }
227
253
 
228
- export function setQueuePhaseActive(active: boolean): void {
229
- activeQueuePhase = active;
230
- persistWriteGateSnapshot();
254
+ export function setQueuePhaseActive(active: boolean, basePath: string): void {
255
+ getWriteGateState(basePath).activeQueuePhase = active;
256
+ persistWriteGateSnapshot(basePath);
231
257
  }
232
258
 
233
- export function resetWriteGateState(): void {
234
- verifiedDepthMilestones.clear();
235
- verifiedApprovalGates.clear();
236
- pendingGateId = null;
237
- persistWriteGateSnapshot();
259
+ export function resetWriteGateState(basePath: string): void {
260
+ const state = getWriteGateState(basePath);
261
+ state.verifiedDepthMilestones.clear();
262
+ state.verifiedApprovalGates.clear();
263
+ state.pendingGateId = null;
264
+ persistWriteGateSnapshot(basePath);
238
265
  }
239
266
 
240
- export function clearDiscussionFlowState(): void {
241
- verifiedDepthMilestones.clear();
242
- verifiedApprovalGates.clear();
243
- activeQueuePhase = false;
244
- pendingGateId = null;
245
- clearPersistedWriteGateSnapshot();
267
+ export function clearDiscussionFlowState(basePath: string): void {
268
+ writeGateStatesByBasePath.delete(writeGateStateKey(basePath));
269
+ clearPersistedWriteGateSnapshot(basePath);
246
270
  }
247
271
 
248
272
  export function markDepthVerified(milestoneId?: string | null, basePath: string = process.cwd()): void {
249
273
  if (!milestoneId) return;
250
- verifiedDepthMilestones.add(milestoneId);
274
+ getWriteGateState(basePath).verifiedDepthMilestones.add(milestoneId);
251
275
  persistWriteGateSnapshot(basePath);
252
276
  }
253
277
 
254
278
  export function markApprovalGateVerified(gateId?: string | null, basePath: string = process.cwd()): void {
255
279
  if (!gateId) return;
256
- verifiedApprovalGates.add(gateId);
280
+ getWriteGateState(basePath).verifiedApprovalGates.add(gateId);
257
281
  persistWriteGateSnapshot(basePath);
258
282
  }
259
283
 
@@ -292,27 +316,28 @@ function extractContextMilestoneId(inputPath: string): string | null {
292
316
  /**
293
317
  * Mark a gate as pending (called when ask_user_questions is invoked with a gate ID).
294
318
  */
295
- export function setPendingGate(gateId: string): void {
296
- pendingGateId = gateId;
297
- verifiedApprovalGates.delete(gateId);
319
+ export function setPendingGate(gateId: string, basePath: string): void {
320
+ const state = getWriteGateState(basePath);
321
+ state.pendingGateId = gateId;
322
+ state.verifiedApprovalGates.delete(gateId);
298
323
  const milestoneId = extractDepthVerificationMilestoneId(gateId);
299
- if (milestoneId) verifiedDepthMilestones.delete(milestoneId);
300
- persistWriteGateSnapshot();
324
+ if (milestoneId) state.verifiedDepthMilestones.delete(milestoneId);
325
+ persistWriteGateSnapshot(basePath);
301
326
  }
302
327
 
303
328
  /**
304
329
  * Clear the pending gate (called when the user confirms).
305
330
  */
306
- export function clearPendingGate(): void {
307
- pendingGateId = null;
308
- persistWriteGateSnapshot();
331
+ export function clearPendingGate(basePath: string): void {
332
+ getWriteGateState(basePath).pendingGateId = null;
333
+ persistWriteGateSnapshot(basePath);
309
334
  }
310
335
 
311
336
  /**
312
337
  * Get the currently pending gate, if any.
313
338
  */
314
- export function getPendingGate(): string | null {
315
- return pendingGateId;
339
+ export function getPendingGate(basePath: string = process.cwd()): string | null {
340
+ return getWriteGateState(basePath).pendingGateId;
316
341
  }
317
342
 
318
343
  /**
@@ -8,7 +8,7 @@
8
8
  // Critical invariant: generated markdown must round-trip through
9
9
  // parseDecisionsTable() and parseRequirementsSections() with field fidelity.
10
10
 
11
- import { join, resolve } from 'node:path';
11
+ import { isAbsolute, join, relative, resolve } from 'node:path';
12
12
  import { readFileSync, existsSync, statSync } from 'node:fs';
13
13
  import type { Decision, Requirement } from './types.js';
14
14
  import { resolveGsdRootFile } from './paths.js';
@@ -18,6 +18,8 @@ import { logWarning, logError } from './workflow-logger.js';
18
18
  import { invalidateStateCache } from './state.js';
19
19
  import { clearPathCache } from './paths.js';
20
20
  import { clearParseCache } from './files.js';
21
+ import type { MilestoneScope, GsdWorkspace } from './workspace.js';
22
+ import { createWorkspace, scopeMilestone } from './workspace.js';
21
23
 
22
24
  // ─── Freeform Detection ───────────────────────────────────────────────────
23
25
 
@@ -715,28 +717,104 @@ export interface SaveArtifactOpts {
715
717
  }
716
718
 
717
719
  /**
718
- * Save an artifact to DB and write the corresponding markdown file to disk.
720
+ * Save a root-level artifact (no milestone) to DB and write to disk,
721
+ * routing path construction through workspace.contract.projectGsd directly.
722
+ * Use this instead of saveArtifactToDbByScope when milestone_id is absent.
723
+ */
724
+ export async function saveArtifactToDbForWorkspace(
725
+ workspace: GsdWorkspace,
726
+ opts: SaveArtifactOpts,
727
+ ): Promise<void> {
728
+ try {
729
+ const db = await import('./gsd-db.js');
730
+
731
+ const gsdDir = workspace.contract.projectGsd;
732
+ const fullPath = resolve(gsdDir, opts.path);
733
+
734
+ const rel0 = relative(gsdDir, fullPath);
735
+ if (rel0.startsWith('..') || isAbsolute(rel0)) {
736
+ throw new GSDError(GSD_IO_ERROR, `saveArtifactToDbForWorkspace: path escapes .gsd/ directory: ${opts.path}`);
737
+ }
738
+
739
+ let contentToPersist = opts.content;
740
+ if (opts.artifact_type === 'REQUIREMENTS' && opts.path === 'REQUIREMENTS.md') {
741
+ const activeRequirements = db.getActiveRequirements();
742
+ if (activeRequirements.length === 0) {
743
+ throw new GSDError(GSD_STALE_STATE, 'saveArtifactToDbForWorkspace: REQUIREMENTS final save requires active DB-backed requirements');
744
+ }
745
+ contentToPersist = generateRequirementsMd(activeRequirements);
746
+ }
747
+
748
+ let skipDiskWrite = false;
749
+ if (!isRootCanonicalArtifact(opts) && existsSync(fullPath)) {
750
+ const existingSize = statSync(fullPath).size;
751
+ const newSize = Buffer.byteLength(contentToPersist, 'utf-8');
752
+ if (existingSize > 0 && newSize < existingSize * 0.5) {
753
+ logWarning('projection', `new content (${newSize}B) is <50% of existing projection (${existingSize}B), preserving disk file while DB remains authoritative`, { fn: 'saveArtifactToDbForWorkspace', path: opts.path });
754
+ skipDiskWrite = true;
755
+ }
756
+ }
757
+
758
+ db.insertArtifact({
759
+ path: opts.path,
760
+ artifact_type: opts.artifact_type,
761
+ milestone_id: null,
762
+ slice_id: null,
763
+ task_id: null,
764
+ full_content: contentToPersist,
765
+ });
766
+
767
+ if (!skipDiskWrite) {
768
+ try {
769
+ await saveFile(fullPath, contentToPersist);
770
+ } catch (diskErr) {
771
+ logWarning('projection', 'artifact projection write failed; DB artifact remains committed', { fn: 'saveArtifactToDbForWorkspace', path: opts.path, error: String((diskErr as Error).message) });
772
+ }
773
+ }
774
+ invalidateStateCache();
775
+ clearPathCache();
776
+ clearParseCache();
777
+ } catch (err) {
778
+ logError('manifest', 'saveArtifactToDbForWorkspace failed', { fn: 'saveArtifactToDbForWorkspace', error: String((err as Error).message) });
779
+ throw err;
780
+ }
781
+ }
782
+
783
+ /**
784
+ * Save an artifact to DB and write the corresponding markdown file to disk,
785
+ * routing all path construction through the workspace contract.
786
+ *
719
787
  * The path is relative to .gsd/ (e.g. "milestones/M001/slices/S06/tasks/T01-SUMMARY.md").
720
- * The full file path is computed as basePath + '.gsd/' + path.
788
+ * The full file path is computed as scope.workspace.contract.projectGsd + '/' + path.
721
789
  */
722
- export async function saveArtifactToDb(
790
+ export async function saveArtifactToDbByScope(
791
+ scope: MilestoneScope,
723
792
  opts: SaveArtifactOpts,
724
- basePath: string,
725
793
  ): Promise<void> {
794
+ // Guard: an empty milestoneId produces malformed paths (milestoneDir = join(gsd, "milestones", "")).
795
+ // Callers that have no milestone should use saveArtifactToDbForWorkspace instead.
796
+ if (!scope.milestoneId) {
797
+ throw new GSDError(GSD_IO_ERROR, `saveArtifactToDbByScope: milestoneId is empty — use saveArtifactToDbForWorkspace for root artifacts`);
798
+ }
799
+
726
800
  try {
727
801
  const db = await import('./gsd-db.js');
728
802
 
803
+ // Use contract.projectGsd as the canonical .gsd directory — never a hand-rolled basePath join.
804
+ const gsdDir = scope.workspace.contract.projectGsd;
805
+ const fullPath = resolve(gsdDir, opts.path);
806
+
729
807
  // Guard against path traversal before any reads/writes
730
- const gsdDir = resolve(basePath, '.gsd');
731
- const fullPath = resolve(basePath, '.gsd', opts.path);
732
- if (!fullPath.startsWith(gsdDir)) {
733
- throw new GSDError(GSD_IO_ERROR, `saveArtifactToDb: path escapes .gsd/ directory: ${opts.path}`);
808
+ const rel1 = relative(gsdDir, fullPath);
809
+ if (rel1.startsWith('..') || isAbsolute(rel1)) {
810
+ throw new GSDError(GSD_IO_ERROR, `saveArtifactToDbByScope: path escapes .gsd/ directory: ${opts.path}`);
734
811
  }
812
+
735
813
  let contentToPersist = opts.content;
736
814
  if (opts.artifact_type === 'REQUIREMENTS' && opts.path === 'REQUIREMENTS.md') {
737
815
  const activeRequirements = db.getActiveRequirements();
738
816
  if (activeRequirements.length === 0) {
739
- throw new GSDError(GSD_STALE_STATE, 'saveArtifactToDb: REQUIREMENTS final save requires active DB-backed requirements');
817
+ throw new GSDError(GSD_STALE_STATE, 'saveArtifactToDbByScope: REQUIREMENTS final save requires active DB-backed requirements');
740
818
  }
741
819
  contentToPersist = generateRequirementsMd(activeRequirements);
742
820
  }
@@ -744,16 +822,13 @@ export async function saveArtifactToDb(
744
822
  // Shrinkage guard: if the projection file already exists and the new
745
823
  // content is significantly smaller (<50%), preserve the richer file on
746
824
  // disk, but keep the DB row authoritative with the caller-provided content.
747
- // The disk file is a stale projection until the next explicit render.
748
- // Root canonical artifacts are exempt because their content is rendered
749
- // from canonical DB state, and cleanup/consolidation is often intentionally
750
- // much smaller than a malformed accumulated file.
825
+ // Root canonical artifacts are exempt (rendered from canonical DB state).
751
826
  let skipDiskWrite = false;
752
827
  if (!isRootCanonicalArtifact(opts) && existsSync(fullPath)) {
753
828
  const existingSize = statSync(fullPath).size;
754
829
  const newSize = Buffer.byteLength(contentToPersist, 'utf-8');
755
830
  if (existingSize > 0 && newSize < existingSize * 0.5) {
756
- logWarning('projection', `new content (${newSize}B) is <50% of existing projection (${existingSize}B), preserving disk file while DB remains authoritative`, { fn: 'saveArtifactToDb', path: opts.path });
831
+ logWarning('projection', `new content (${newSize}B) is <50% of existing projection (${existingSize}B), preserving disk file while DB remains authoritative`, { fn: 'saveArtifactToDbByScope', path: opts.path });
757
832
  skipDiskWrite = true;
758
833
  }
759
834
  }
@@ -772,7 +847,7 @@ export async function saveArtifactToDb(
772
847
  try {
773
848
  await saveFile(fullPath, contentToPersist);
774
849
  } catch (diskErr) {
775
- logWarning('projection', 'artifact projection write failed; DB artifact remains committed', { fn: 'saveArtifactToDb', path: opts.path, error: String((diskErr as Error).message) });
850
+ logWarning('projection', 'artifact projection write failed; DB artifact remains committed', { fn: 'saveArtifactToDbByScope', path: opts.path, error: String((diskErr as Error).message) });
776
851
  }
777
852
  }
778
853
  // Invalidate file-read caches so deriveState() sees the updated markdown.
@@ -781,7 +856,28 @@ export async function saveArtifactToDb(
781
856
  clearPathCache();
782
857
  clearParseCache();
783
858
  } catch (err) {
784
- logError('manifest', 'saveArtifactToDb failed', { fn: 'saveArtifactToDb', error: String((err as Error).message) });
859
+ logError('manifest', 'saveArtifactToDbByScope failed', { fn: 'saveArtifactToDbByScope', error: String((err as Error).message) });
785
860
  throw err;
786
861
  }
787
862
  }
863
+
864
+ /**
865
+ * Save an artifact to DB and write the corresponding markdown file to disk.
866
+ * The path is relative to .gsd/ (e.g. "milestones/M001/slices/S06/tasks/T01-SUMMARY.md").
867
+ * The full file path is computed as basePath + '.gsd/' + path.
868
+ *
869
+ * @deprecated Use saveArtifactToDbByScope instead, which routes through the
870
+ * workspace contract for canonical path resolution.
871
+ * TODO(C-future): remove this legacy wrapper once all callers are migrated.
872
+ */
873
+ export async function saveArtifactToDb(
874
+ opts: SaveArtifactOpts,
875
+ basePath: string,
876
+ ): Promise<void> {
877
+ const workspace = createWorkspace(basePath);
878
+ const milestoneId = opts.milestone_id;
879
+ if (milestoneId) {
880
+ return saveArtifactToDbByScope(scopeMilestone(workspace, milestoneId), opts);
881
+ }
882
+ return saveArtifactToDbForWorkspace(workspace, opts);
883
+ }