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
@@ -60,7 +60,7 @@ export function registerHooks(pi, ecosystemHandlers) {
60
60
  const { initHealthWidget } = await import("../health-widget.js");
61
61
  initHealthWidget(ctx);
62
62
  }
63
- resetWriteGateState();
63
+ resetWriteGateState(process.cwd());
64
64
  resetToolCallLoopGuard();
65
65
  approvalQuestionAbortInFlight = false;
66
66
  await resetAskUserQuestionsTurnCache();
@@ -109,10 +109,10 @@ export function registerHooks(pi, ecosystemHandlers) {
109
109
  pi.on("session_switch", async (_event, ctx) => {
110
110
  initNotificationStore(process.cwd());
111
111
  installNotifyInterceptor(ctx);
112
- resetWriteGateState();
112
+ resetWriteGateState(process.cwd());
113
113
  resetToolCallLoopGuard();
114
114
  await resetAskUserQuestionsTurnCache();
115
- clearDiscussionFlowState();
115
+ clearDiscussionFlowState(process.cwd());
116
116
  await syncServiceTierStatus(ctx);
117
117
  await applyDisabledModelProviderPolicy(ctx);
118
118
  // Skip MCP auto-prep when running inside an auto-worktree. The worktree
@@ -137,13 +137,14 @@ export function registerHooks(pi, ecosystemHandlers) {
137
137
  // Wait for ecosystem loader to finish (no-op after first turn).
138
138
  const { getEcosystemReadyPromise } = await import("../ecosystem/loader.js");
139
139
  await getEcosystemReadyPromise();
140
+ const beforeAgentBasePath = process.cwd();
140
141
  const pendingApprovalGate = getPendingGate();
141
142
  if (pendingApprovalGate && isExplicitApprovalResponse(event.prompt, pendingApprovalGate)) {
142
- markApprovalGateVerified(pendingApprovalGate);
143
+ markApprovalGateVerified(pendingApprovalGate, beforeAgentBasePath);
143
144
  const milestoneId = extractDepthVerificationMilestoneId(pendingApprovalGate);
144
145
  if (milestoneId)
145
- markDepthVerified(milestoneId);
146
- clearPendingGate();
146
+ markDepthVerified(milestoneId, beforeAgentBasePath);
147
+ clearPendingGate(beforeAgentBasePath);
147
148
  }
148
149
  // GSD's own context injection (existing behavior — unchanged).
149
150
  const { buildBeforeAgentStartResult } = await import("./system-context.js");
@@ -318,7 +319,7 @@ export function registerHooks(pi, ecosystemHandlers) {
318
319
  return;
319
320
  const gateId = approvalGateIdForUnit(unitType, unitId);
320
321
  if (gateId)
321
- setPendingGate(gateId);
322
+ setPendingGate(gateId, process.cwd());
322
323
  approvalQuestionAbortInFlight = true;
323
324
  ctx.ui.notify(`${unitType}${unitId ? ` ${unitId}` : ""} is waiting for your approval - pausing before more tool calls run.`, "info");
324
325
  // The pending gate set above blocks subsequent non-read-only tool calls
@@ -360,7 +361,7 @@ export function registerHooks(pi, ecosystemHandlers) {
360
361
  const questions = event.input?.questions ?? [];
361
362
  const questionId = questions.find((question) => typeof question?.id === "string" && isGateQuestionId(question.id))?.id;
362
363
  if (typeof questionId === "string") {
363
- setPendingGate(questionId);
364
+ setPendingGate(questionId, discussionBasePath);
364
365
  }
365
366
  }
366
367
  // ── Discussion gate enforcement: block tool calls while gate is pending ──
@@ -501,7 +502,8 @@ export function registerHooks(pi, ecosystemHandlers) {
501
502
  const toolName = canonicalToolName(event.toolName);
502
503
  if (toolName !== "ask_user_questions")
503
504
  return;
504
- const milestoneId = await getDiscussionMilestoneIdFor(process.cwd());
505
+ const basePath = process.cwd();
506
+ const milestoneId = await getDiscussionMilestoneIdFor(basePath);
505
507
  const queueActive = isQueuePhaseActive();
506
508
  const details = event.details;
507
509
  // ── Discussion gate enforcement: handle gate question responses ──
@@ -533,11 +535,11 @@ export function registerHooks(pi, ecosystemHandlers) {
533
535
  if (pendingQuestion) {
534
536
  const answer = details.response?.answers?.[currentPendingGate];
535
537
  if (isDepthConfirmationAnswer(answer?.selected, pendingQuestion.options)) {
536
- markApprovalGateVerified(currentPendingGate);
538
+ markApprovalGateVerified(currentPendingGate, basePath);
537
539
  const milestoneIdFromGate = extractDepthVerificationMilestoneId(currentPendingGate);
538
540
  if (milestoneIdFromGate)
539
- markDepthVerified(milestoneIdFromGate);
540
- clearPendingGate();
541
+ markDepthVerified(milestoneIdFromGate, basePath);
542
+ clearPendingGate(basePath);
541
543
  }
542
544
  }
543
545
  }
@@ -553,9 +555,9 @@ export function registerHooks(pi, ecosystemHandlers) {
553
555
  if (isDepthConfirmationAnswer(answer?.selected, question.options)) {
554
556
  if (currentPendingGate && question.id !== currentPendingGate)
555
557
  break;
556
- markApprovalGateVerified(question.id);
557
- markDepthVerified(inferredMilestoneId);
558
- clearPendingGate();
558
+ markApprovalGateVerified(question.id, basePath);
559
+ markDepthVerified(inferredMilestoneId, basePath);
560
+ clearPendingGate(basePath);
559
561
  }
560
562
  break;
561
563
  }
@@ -564,7 +566,6 @@ export function registerHooks(pi, ecosystemHandlers) {
564
566
  return;
565
567
  if (!milestoneId)
566
568
  return;
567
- const basePath = process.cwd();
568
569
  const milestoneDir = resolveMilestonePath(basePath, milestoneId);
569
570
  if (!milestoneDir)
570
571
  return;
@@ -55,19 +55,31 @@ const QUEUE_SAFE_TOOLS = new Set([
55
55
  * true / false — shell no-ops / test exit codes
56
56
  */
57
57
  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)/;
58
- const verifiedDepthMilestones = new Set();
59
- const verifiedApprovalGates = new Set();
60
- let activeQueuePhase = false;
58
+ function createEmptyWriteGateState() {
59
+ return {
60
+ verifiedDepthMilestones: new Set(),
61
+ verifiedApprovalGates: new Set(),
62
+ activeQueuePhase: false,
63
+ pendingGateId: null,
64
+ };
65
+ }
66
+ const writeGateStatesByBasePath = new Map();
67
+ function writeGateStateKey(basePath) {
68
+ return resolve(basePath);
69
+ }
70
+ function getWriteGateState(basePath = process.cwd()) {
71
+ const key = writeGateStateKey(basePath);
72
+ let state = writeGateStatesByBasePath.get(key);
73
+ if (!state) {
74
+ state = createEmptyWriteGateState();
75
+ writeGateStatesByBasePath.set(key, state);
76
+ }
77
+ return state;
78
+ }
61
79
  /**
62
- * Discussion gate enforcement state.
63
- *
64
- * When ask_user_questions is called with a recognized gate question ID,
65
- * we track the pending gate. Until the gate is confirmed (user selects the
66
- * first/recommended option), all non-read-only tool calls are blocked.
67
- * This mechanically prevents the model from rationalizing past failed or
68
- * cancelled gate questions.
80
+ * Discussion gate enforcement state is scoped per basePath so multiple
81
+ * workspaces can coexist in the same process without sharing gate state.
69
82
  */
70
- let pendingGateId = null;
71
83
  /**
72
84
  * Recognized gate question ID patterns.
73
85
  * These appear in discuss.md (depth/requirements/roadmap).
@@ -99,24 +111,25 @@ function shouldPersistWriteGateSnapshot(env = process.env) {
99
111
  const v = env.GSD_PERSIST_WRITE_GATE_STATE;
100
112
  return v !== "0" && v !== "false";
101
113
  }
102
- function writeGateSnapshotPath(basePath = process.cwd()) {
114
+ function writeGateSnapshotPath(basePath) {
103
115
  return join(basePath, ".gsd", "runtime", "write-gate-state.json");
104
116
  }
105
- function currentWriteGateSnapshot() {
117
+ function currentWriteGateSnapshot(basePath = process.cwd()) {
118
+ const state = getWriteGateState(basePath);
106
119
  return {
107
- verifiedDepthMilestones: [...verifiedDepthMilestones].sort(),
108
- verifiedApprovalGates: [...verifiedApprovalGates].sort(),
109
- activeQueuePhase,
110
- pendingGateId,
120
+ verifiedDepthMilestones: [...state.verifiedDepthMilestones].sort(),
121
+ verifiedApprovalGates: [...state.verifiedApprovalGates].sort(),
122
+ activeQueuePhase: state.activeQueuePhase,
123
+ pendingGateId: state.pendingGateId,
111
124
  };
112
125
  }
113
- function persistWriteGateSnapshot(basePath = process.cwd()) {
126
+ function persistWriteGateSnapshot(basePath) {
114
127
  if (!shouldPersistWriteGateSnapshot())
115
128
  return;
116
129
  const path = writeGateSnapshotPath(basePath);
117
130
  mkdirSync(join(basePath, ".gsd", "runtime"), { recursive: true });
118
131
  const tempPath = `${path}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
119
- writeFileSync(tempPath, JSON.stringify(currentWriteGateSnapshot(), null, 2), "utf-8");
132
+ writeFileSync(tempPath, JSON.stringify(currentWriteGateSnapshot(basePath), null, 2), "utf-8");
120
133
  try {
121
134
  renameSync(tempPath, path);
122
135
  }
@@ -132,7 +145,7 @@ function persistWriteGateSnapshot(basePath = process.cwd()) {
132
145
  }
133
146
  }
134
147
  }
135
- function clearPersistedWriteGateSnapshot(basePath = process.cwd()) {
148
+ function clearPersistedWriteGateSnapshot(basePath) {
136
149
  if (!shouldPersistWriteGateSnapshot())
137
150
  return;
138
151
  const path = writeGateSnapshotPath(basePath);
@@ -164,7 +177,7 @@ const EMPTY_SNAPSHOT = {
164
177
  activeQueuePhase: false,
165
178
  pendingGateId: null,
166
179
  };
167
- export function loadWriteGateSnapshot(basePath = process.cwd()) {
180
+ export function loadWriteGateSnapshot(basePath) {
168
181
  const path = writeGateSnapshotPath(basePath);
169
182
  if (!existsSync(path)) {
170
183
  // When persist mode is active and the file has been deleted, treat it as a
@@ -172,61 +185,59 @@ export function loadWriteGateSnapshot(basePath = process.cwd()) {
172
185
  // In non-persist mode the file is never written, so fall back to in-memory.
173
186
  if (shouldPersistWriteGateSnapshot())
174
187
  return EMPTY_SNAPSHOT;
175
- return currentWriteGateSnapshot();
188
+ return currentWriteGateSnapshot(basePath);
176
189
  }
177
190
  try {
178
191
  return normalizeWriteGateSnapshot(JSON.parse(readFileSync(path, "utf-8")));
179
192
  }
180
193
  catch {
181
- return currentWriteGateSnapshot();
194
+ return currentWriteGateSnapshot(basePath);
182
195
  }
183
196
  }
184
- export function isDepthVerified() {
185
- return verifiedDepthMilestones.size > 0;
197
+ export function isDepthVerified(basePath = process.cwd()) {
198
+ return getWriteGateState(basePath).verifiedDepthMilestones.size > 0;
186
199
  }
187
200
  /**
188
201
  * Check whether a specific milestone has passed depth verification.
189
202
  */
190
- export function isMilestoneDepthVerified(milestoneId) {
203
+ export function isMilestoneDepthVerified(milestoneId, basePath = process.cwd()) {
191
204
  if (!milestoneId)
192
205
  return false;
193
- return verifiedDepthMilestones.has(milestoneId);
206
+ return getWriteGateState(basePath).verifiedDepthMilestones.has(milestoneId);
194
207
  }
195
208
  export function isMilestoneDepthVerifiedInSnapshot(snapshot, milestoneId) {
196
209
  if (!milestoneId)
197
210
  return false;
198
211
  return snapshot.verifiedDepthMilestones.includes(milestoneId);
199
212
  }
200
- export function isQueuePhaseActive() {
201
- return activeQueuePhase;
213
+ export function isQueuePhaseActive(basePath = process.cwd()) {
214
+ return getWriteGateState(basePath).activeQueuePhase;
202
215
  }
203
- export function setQueuePhaseActive(active) {
204
- activeQueuePhase = active;
205
- persistWriteGateSnapshot();
216
+ export function setQueuePhaseActive(active, basePath) {
217
+ getWriteGateState(basePath).activeQueuePhase = active;
218
+ persistWriteGateSnapshot(basePath);
206
219
  }
207
- export function resetWriteGateState() {
208
- verifiedDepthMilestones.clear();
209
- verifiedApprovalGates.clear();
210
- pendingGateId = null;
211
- persistWriteGateSnapshot();
220
+ export function resetWriteGateState(basePath) {
221
+ const state = getWriteGateState(basePath);
222
+ state.verifiedDepthMilestones.clear();
223
+ state.verifiedApprovalGates.clear();
224
+ state.pendingGateId = null;
225
+ persistWriteGateSnapshot(basePath);
212
226
  }
213
- export function clearDiscussionFlowState() {
214
- verifiedDepthMilestones.clear();
215
- verifiedApprovalGates.clear();
216
- activeQueuePhase = false;
217
- pendingGateId = null;
218
- clearPersistedWriteGateSnapshot();
227
+ export function clearDiscussionFlowState(basePath) {
228
+ writeGateStatesByBasePath.delete(writeGateStateKey(basePath));
229
+ clearPersistedWriteGateSnapshot(basePath);
219
230
  }
220
231
  export function markDepthVerified(milestoneId, basePath = process.cwd()) {
221
232
  if (!milestoneId)
222
233
  return;
223
- verifiedDepthMilestones.add(milestoneId);
234
+ getWriteGateState(basePath).verifiedDepthMilestones.add(milestoneId);
224
235
  persistWriteGateSnapshot(basePath);
225
236
  }
226
237
  export function markApprovalGateVerified(gateId, basePath = process.cwd()) {
227
238
  if (!gateId)
228
239
  return;
229
- verifiedApprovalGates.add(gateId);
240
+ getWriteGateState(basePath).verifiedApprovalGates.add(gateId);
230
241
  persistWriteGateSnapshot(basePath);
231
242
  }
232
243
  export function isApprovalGateVerifiedInSnapshot(snapshot, gateId) {
@@ -258,26 +269,27 @@ function extractContextMilestoneId(inputPath) {
258
269
  /**
259
270
  * Mark a gate as pending (called when ask_user_questions is invoked with a gate ID).
260
271
  */
261
- export function setPendingGate(gateId) {
262
- pendingGateId = gateId;
263
- verifiedApprovalGates.delete(gateId);
272
+ export function setPendingGate(gateId, basePath) {
273
+ const state = getWriteGateState(basePath);
274
+ state.pendingGateId = gateId;
275
+ state.verifiedApprovalGates.delete(gateId);
264
276
  const milestoneId = extractDepthVerificationMilestoneId(gateId);
265
277
  if (milestoneId)
266
- verifiedDepthMilestones.delete(milestoneId);
267
- persistWriteGateSnapshot();
278
+ state.verifiedDepthMilestones.delete(milestoneId);
279
+ persistWriteGateSnapshot(basePath);
268
280
  }
269
281
  /**
270
282
  * Clear the pending gate (called when the user confirms).
271
283
  */
272
- export function clearPendingGate() {
273
- pendingGateId = null;
274
- persistWriteGateSnapshot();
284
+ export function clearPendingGate(basePath) {
285
+ getWriteGateState(basePath).pendingGateId = null;
286
+ persistWriteGateSnapshot(basePath);
275
287
  }
276
288
  /**
277
289
  * Get the currently pending gate, if any.
278
290
  */
279
- export function getPendingGate() {
280
- return pendingGateId;
291
+ export function getPendingGate(basePath = process.cwd()) {
292
+ return getWriteGateState(basePath).pendingGateId;
281
293
  }
282
294
  /**
283
295
  * Check whether a tool call should be blocked because a discussion gate
@@ -7,7 +7,7 @@
7
7
  //
8
8
  // Critical invariant: generated markdown must round-trip through
9
9
  // parseDecisionsTable() and parseRequirementsSections() with field fidelity.
10
- import { resolve } from 'node:path';
10
+ import { isAbsolute, relative, resolve } from 'node:path';
11
11
  import { readFileSync, existsSync, statSync } from 'node:fs';
12
12
  import { resolveGsdRootFile } from './paths.js';
13
13
  import { saveFile } from './files.js';
@@ -16,6 +16,7 @@ import { logWarning, logError } from './workflow-logger.js';
16
16
  import { invalidateStateCache } from './state.js';
17
17
  import { clearPathCache } from './paths.js';
18
18
  import { clearParseCache } from './files.js';
19
+ import { createWorkspace, scopeMilestone } from './workspace.js';
19
20
  // ─── Freeform Detection ───────────────────────────────────────────────────
20
21
  /**
21
22
  * Detect whether a DECISIONS.md file is in canonical table format
@@ -614,40 +615,102 @@ export async function updateRequirementInDb(id, updates, basePath) {
614
615
  }
615
616
  }
616
617
  /**
617
- * Save an artifact to DB and write the corresponding markdown file to disk.
618
+ * Save a root-level artifact (no milestone) to DB and write to disk,
619
+ * routing path construction through workspace.contract.projectGsd directly.
620
+ * Use this instead of saveArtifactToDbByScope when milestone_id is absent.
621
+ */
622
+ export async function saveArtifactToDbForWorkspace(workspace, opts) {
623
+ try {
624
+ const db = await import('./gsd-db.js');
625
+ const gsdDir = workspace.contract.projectGsd;
626
+ const fullPath = resolve(gsdDir, opts.path);
627
+ const rel0 = relative(gsdDir, fullPath);
628
+ if (rel0.startsWith('..') || isAbsolute(rel0)) {
629
+ throw new GSDError(GSD_IO_ERROR, `saveArtifactToDbForWorkspace: path escapes .gsd/ directory: ${opts.path}`);
630
+ }
631
+ let contentToPersist = opts.content;
632
+ if (opts.artifact_type === 'REQUIREMENTS' && opts.path === 'REQUIREMENTS.md') {
633
+ const activeRequirements = db.getActiveRequirements();
634
+ if (activeRequirements.length === 0) {
635
+ throw new GSDError(GSD_STALE_STATE, 'saveArtifactToDbForWorkspace: REQUIREMENTS final save requires active DB-backed requirements');
636
+ }
637
+ contentToPersist = generateRequirementsMd(activeRequirements);
638
+ }
639
+ let skipDiskWrite = false;
640
+ if (!isRootCanonicalArtifact(opts) && existsSync(fullPath)) {
641
+ const existingSize = statSync(fullPath).size;
642
+ const newSize = Buffer.byteLength(contentToPersist, 'utf-8');
643
+ if (existingSize > 0 && newSize < existingSize * 0.5) {
644
+ 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 });
645
+ skipDiskWrite = true;
646
+ }
647
+ }
648
+ db.insertArtifact({
649
+ path: opts.path,
650
+ artifact_type: opts.artifact_type,
651
+ milestone_id: null,
652
+ slice_id: null,
653
+ task_id: null,
654
+ full_content: contentToPersist,
655
+ });
656
+ if (!skipDiskWrite) {
657
+ try {
658
+ await saveFile(fullPath, contentToPersist);
659
+ }
660
+ catch (diskErr) {
661
+ logWarning('projection', 'artifact projection write failed; DB artifact remains committed', { fn: 'saveArtifactToDbForWorkspace', path: opts.path, error: String(diskErr.message) });
662
+ }
663
+ }
664
+ invalidateStateCache();
665
+ clearPathCache();
666
+ clearParseCache();
667
+ }
668
+ catch (err) {
669
+ logError('manifest', 'saveArtifactToDbForWorkspace failed', { fn: 'saveArtifactToDbForWorkspace', error: String(err.message) });
670
+ throw err;
671
+ }
672
+ }
673
+ /**
674
+ * Save an artifact to DB and write the corresponding markdown file to disk,
675
+ * routing all path construction through the workspace contract.
676
+ *
618
677
  * The path is relative to .gsd/ (e.g. "milestones/M001/slices/S06/tasks/T01-SUMMARY.md").
619
- * The full file path is computed as basePath + '.gsd/' + path.
678
+ * The full file path is computed as scope.workspace.contract.projectGsd + '/' + path.
620
679
  */
621
- export async function saveArtifactToDb(opts, basePath) {
680
+ export async function saveArtifactToDbByScope(scope, opts) {
681
+ // Guard: an empty milestoneId produces malformed paths (milestoneDir = join(gsd, "milestones", "")).
682
+ // Callers that have no milestone should use saveArtifactToDbForWorkspace instead.
683
+ if (!scope.milestoneId) {
684
+ throw new GSDError(GSD_IO_ERROR, `saveArtifactToDbByScope: milestoneId is empty — use saveArtifactToDbForWorkspace for root artifacts`);
685
+ }
622
686
  try {
623
687
  const db = await import('./gsd-db.js');
688
+ // Use contract.projectGsd as the canonical .gsd directory — never a hand-rolled basePath join.
689
+ const gsdDir = scope.workspace.contract.projectGsd;
690
+ const fullPath = resolve(gsdDir, opts.path);
624
691
  // Guard against path traversal before any reads/writes
625
- const gsdDir = resolve(basePath, '.gsd');
626
- const fullPath = resolve(basePath, '.gsd', opts.path);
627
- if (!fullPath.startsWith(gsdDir)) {
628
- throw new GSDError(GSD_IO_ERROR, `saveArtifactToDb: path escapes .gsd/ directory: ${opts.path}`);
692
+ const rel1 = relative(gsdDir, fullPath);
693
+ if (rel1.startsWith('..') || isAbsolute(rel1)) {
694
+ throw new GSDError(GSD_IO_ERROR, `saveArtifactToDbByScope: path escapes .gsd/ directory: ${opts.path}`);
629
695
  }
630
696
  let contentToPersist = opts.content;
631
697
  if (opts.artifact_type === 'REQUIREMENTS' && opts.path === 'REQUIREMENTS.md') {
632
698
  const activeRequirements = db.getActiveRequirements();
633
699
  if (activeRequirements.length === 0) {
634
- throw new GSDError(GSD_STALE_STATE, 'saveArtifactToDb: REQUIREMENTS final save requires active DB-backed requirements');
700
+ throw new GSDError(GSD_STALE_STATE, 'saveArtifactToDbByScope: REQUIREMENTS final save requires active DB-backed requirements');
635
701
  }
636
702
  contentToPersist = generateRequirementsMd(activeRequirements);
637
703
  }
638
704
  // Shrinkage guard: if the projection file already exists and the new
639
705
  // content is significantly smaller (<50%), preserve the richer file on
640
706
  // disk, but keep the DB row authoritative with the caller-provided content.
641
- // The disk file is a stale projection until the next explicit render.
642
- // Root canonical artifacts are exempt because their content is rendered
643
- // from canonical DB state, and cleanup/consolidation is often intentionally
644
- // much smaller than a malformed accumulated file.
707
+ // Root canonical artifacts are exempt (rendered from canonical DB state).
645
708
  let skipDiskWrite = false;
646
709
  if (!isRootCanonicalArtifact(opts) && existsSync(fullPath)) {
647
710
  const existingSize = statSync(fullPath).size;
648
711
  const newSize = Buffer.byteLength(contentToPersist, 'utf-8');
649
712
  if (existingSize > 0 && newSize < existingSize * 0.5) {
650
- 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 });
713
+ 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 });
651
714
  skipDiskWrite = true;
652
715
  }
653
716
  }
@@ -665,7 +728,7 @@ export async function saveArtifactToDb(opts, basePath) {
665
728
  await saveFile(fullPath, contentToPersist);
666
729
  }
667
730
  catch (diskErr) {
668
- logWarning('projection', 'artifact projection write failed; DB artifact remains committed', { fn: 'saveArtifactToDb', path: opts.path, error: String(diskErr.message) });
731
+ logWarning('projection', 'artifact projection write failed; DB artifact remains committed', { fn: 'saveArtifactToDbByScope', path: opts.path, error: String(diskErr.message) });
669
732
  }
670
733
  }
671
734
  // Invalidate file-read caches so deriveState() sees the updated markdown.
@@ -675,7 +738,24 @@ export async function saveArtifactToDb(opts, basePath) {
675
738
  clearParseCache();
676
739
  }
677
740
  catch (err) {
678
- logError('manifest', 'saveArtifactToDb failed', { fn: 'saveArtifactToDb', error: String(err.message) });
741
+ logError('manifest', 'saveArtifactToDbByScope failed', { fn: 'saveArtifactToDbByScope', error: String(err.message) });
679
742
  throw err;
680
743
  }
681
744
  }
745
+ /**
746
+ * Save an artifact to DB and write the corresponding markdown file to disk.
747
+ * The path is relative to .gsd/ (e.g. "milestones/M001/slices/S06/tasks/T01-SUMMARY.md").
748
+ * The full file path is computed as basePath + '.gsd/' + path.
749
+ *
750
+ * @deprecated Use saveArtifactToDbByScope instead, which routes through the
751
+ * workspace contract for canonical path resolution.
752
+ * TODO(C-future): remove this legacy wrapper once all callers are migrated.
753
+ */
754
+ export async function saveArtifactToDb(opts, basePath) {
755
+ const workspace = createWorkspace(basePath);
756
+ const milestoneId = opts.milestone_id;
757
+ if (milestoneId) {
758
+ return saveArtifactToDbByScope(scopeMilestone(workspace, milestoneId), opts);
759
+ }
760
+ return saveArtifactToDbForWorkspace(workspace, opts);
761
+ }
@@ -0,0 +1,155 @@
1
+ // Delegation policy — codifies which GSD MCP tools are safe to run as
2
+ // background sub-agents while the foreground /gsd flow continues. Verdicts
3
+ // are derived from the round-1 and round-2 evaluations recorded in this
4
+ // branch's PR description; the rationale field on each entry preserves
5
+ // the reason so future changes have to revisit the analysis explicitly.
6
+ //
7
+ // Default-deny: unknown tools are never backgroundable.
8
+ //
9
+ // ─── Tool-name vs unit-type namespaces ───────────────────────────────────
10
+ // Entries are keyed by canonical MCP tool name (`gsd_*`). The optional
11
+ // `unitType` field is a *secondary* index for the dispatcher's convenience
12
+ // — it bridges this policy to `auto-dispatch.ts`' `DispatchAction.unitType`
13
+ // values. The two namespaces are not 1:1:
14
+ //
15
+ // - Some tools have no corresponding unit type (e.g. `gsd_doctor`,
16
+ // `gsd_plan_task`) and intentionally omit `unitType`.
17
+ // - Some unit types share a tool — e.g. `execute-task`, `execute-task-simple`,
18
+ // and `reactive-execute` all invoke `gsd_execute`. The current shape
19
+ // allows only one `unitType` per entry, so those units fall through to
20
+ // `getVerdictByUnitType() === null` (→ `backgroundable: false`) even
21
+ // though `gsd_execute` itself is GOOD. This is the intended default-deny
22
+ // posture until a future PR wires actual background dispatch and
23
+ // decides whether each unit-level orchestration is safe — the unit
24
+ // wraps a prompt, harness setup, and post-processing on top of the
25
+ // tool, and the tool's safety doesn't transfer automatically.
26
+ //
27
+ // Auto-dispatch produces 20 distinct unit types; only 5 are explicitly
28
+ // classified here. The other 15 default-deny:
29
+ // complete-milestone, complete-slice, discuss-milestone, discuss-project,
30
+ // discuss-requirements, execute-task, execute-task-simple, gate-evaluate,
31
+ // reactive-execute, refine-slice, research-decision, research-milestone,
32
+ // research-project, research-slice, rewrite-docs, run-uat
33
+ //
34
+ // Adding a `unitType` mapping (or a future `unitTypes: string[]`) to an
35
+ // existing entry is the place to lift any of these out of default-deny
36
+ // when the analysis has been done.
37
+ const POLICY = {
38
+ gsd_plan_slice: {
39
+ toolName: "gsd_plan_slice",
40
+ unitType: "plan-slice",
41
+ verdict: "good",
42
+ rationale: "Self-contained, no user prompts, atomic DB tx; existing slice-parallel-orchestrator pattern transfers cleanly.",
43
+ constraints: [
44
+ "Lock the slice from further user discussion once dispatched (context is frozen at dispatch time).",
45
+ "Foreground must not derive state for that slice while the transaction is in flight.",
46
+ "Foreground must await background completion before any tool reads the planned tasks/gates.",
47
+ ],
48
+ },
49
+ gsd_execute: {
50
+ toolName: "gsd_execute",
51
+ // No `unitType` set on purpose — the underlying tool is safe, but the
52
+ // unit-level orchestrations that invoke it (`execute-task`,
53
+ // `execute-task-simple`, `reactive-execute`) wrap additional prompt and
54
+ // harness work whose safety is a separate analysis. Default-deny those
55
+ // units until that analysis is recorded; adding `unitType` here would
56
+ // promote them silently.
57
+ verdict: "good",
58
+ rationale: "No DB writes; UUID-isolated stdout/stderr/meta files; existing reactive-execute parallel-subagent precedent.",
59
+ },
60
+ gsd_validate_milestone: {
61
+ toolName: "gsd_validate_milestone",
62
+ unitType: "validate-milestone",
63
+ verdict: "good",
64
+ rationale: "Verdict pre-computed by parallel reviewers; atomic DB tx plus isolated VALIDATION.md write; no user interaction.",
65
+ },
66
+ gsd_reassess_roadmap: {
67
+ toolName: "gsd_reassess_roadmap",
68
+ unitType: "reassess-roadmap",
69
+ verdict: "good",
70
+ rationale: "Narrower mutation scope than plan_milestone; structural guards prevent modification of completed slices.",
71
+ },
72
+ gsd_doctor: {
73
+ toolName: "gsd_doctor",
74
+ verdict: "risky",
75
+ rationale: "Diagnostic-only mode (fix=false) is safe to background; fix=true writes STATE.md/ROADMAP.md without session-lock coordination and can race the foreground flow.",
76
+ constraints: [
77
+ "Background only with fix=false (diagnostic-only).",
78
+ "Apply fixes synchronously, only when no foreground unit is dispatched.",
79
+ ],
80
+ },
81
+ gsd_plan_milestone: {
82
+ toolName: "gsd_plan_milestone",
83
+ unitType: "plan-milestone",
84
+ verdict: "risky",
85
+ rationale: "Inputs require CONTEXT.md from discuss-milestone, so initial questioning is already done by the time it can start; TOCTOU guards and projection coherence make concurrency unsafe.",
86
+ },
87
+ gsd_replan_slice: {
88
+ toolName: "gsd_replan_slice",
89
+ unitType: "replan-slice",
90
+ verdict: "risky",
91
+ rationale: "Blocks the replanning→executing state transition on a gate that waits for S##-REPLAN.md; background failure leaves the flow stuck.",
92
+ },
93
+ gsd_plan_task: {
94
+ toolName: "gsd_plan_task",
95
+ verdict: "no",
96
+ rationale: "plan-slice prompt explicitly forbids calling gsd_plan_task separately; per-task granularity multiplies manifest writes and projection re-renders with no payoff.",
97
+ },
98
+ };
99
+ // Alias map keyed on the secondary name; resolves to the canonical entry above.
100
+ // Sourced from packages/mcp-server/src/workflow-tools.ts alias registrations
101
+ // (gsd_milestone_validate, gsd_roadmap_reassess, gsd_slice_replan, gsd_task_plan).
102
+ const ALIASES = {
103
+ gsd_milestone_validate: "gsd_validate_milestone",
104
+ gsd_roadmap_reassess: "gsd_reassess_roadmap",
105
+ gsd_slice_replan: "gsd_replan_slice",
106
+ gsd_task_plan: "gsd_plan_task",
107
+ };
108
+ function resolveCanonical(name) {
109
+ return ALIASES[name] ?? name;
110
+ }
111
+ export function getDelegationVerdict(toolName) {
112
+ return POLICY[resolveCanonical(toolName)] ?? null;
113
+ }
114
+ export function isBackgroundable(toolName) {
115
+ const entry = getDelegationVerdict(toolName);
116
+ return entry?.verdict === "good";
117
+ }
118
+ export function listBackgroundableTools() {
119
+ return Object.values(POLICY)
120
+ .filter((entry) => entry.verdict === "good")
121
+ .map((entry) => entry.toolName)
122
+ .sort();
123
+ }
124
+ export function getVerdictByUnitType(unitType) {
125
+ for (const entry of Object.values(POLICY)) {
126
+ if (entry.unitType === unitType)
127
+ return entry;
128
+ }
129
+ return null;
130
+ }
131
+ /**
132
+ * Annotates a dispatch action in place with `backgroundable: true` when its
133
+ * unitType has a `good` verdict in the policy. Stop/skip actions pass through
134
+ * unchanged. Default-deny: unknown unit types resolve to `false`.
135
+ *
136
+ * **Mutation contract.** The `backgroundable` field is written directly onto
137
+ * the passed action object. This is intentional — every dispatch path in
138
+ * `auto-dispatch.ts` constructs a fresh action object per `where(ctx)` /
139
+ * `evaluateDispatch(ctx)` invocation, so in-place mutation cannot leak across
140
+ * dispatch cycles. Future dispatch rules MUST follow that convention: never
141
+ * cache or share `DispatchAction` objects across calls. If you need to cache,
142
+ * either freeze the cached object (`Object.freeze`) and clone on read, or
143
+ * stop calling `annotateBackgroundable` on the shared instance. The annotator
144
+ * always recomputes from the policy on every call (no internal cache), so
145
+ * repeated invocations on the same object will overwrite stale values
146
+ * deterministically — see the `annotateBackgroundable recomputes on each call`
147
+ * test for the contract pin.
148
+ */
149
+ export function annotateBackgroundable(action) {
150
+ if (action.action !== "dispatch")
151
+ return action;
152
+ const verdict = getVerdictByUnitType(action.unitType);
153
+ action.backgroundable = verdict?.verdict === "good";
154
+ return action;
155
+ }