@stigmer/runner 3.1.1 → 3.1.2

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 (111) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/attachment-resolver.d.ts +49 -8
  3. package/dist/activities/execute-cursor/attachment-resolver.js +83 -26
  4. package/dist/activities/execute-cursor/attachment-resolver.js.map +1 -1
  5. package/dist/activities/execute-cursor/cas-observations.d.ts +13 -0
  6. package/dist/activities/execute-cursor/cas-observations.js +39 -5
  7. package/dist/activities/execute-cursor/cas-observations.js.map +1 -1
  8. package/dist/activities/execute-cursor/hook-script.js +19 -1
  9. package/dist/activities/execute-cursor/hook-script.js.map +1 -1
  10. package/dist/activities/execute-cursor/index.d.ts +5 -0
  11. package/dist/activities/execute-cursor/index.js +102 -22
  12. package/dist/activities/execute-cursor/index.js.map +1 -1
  13. package/dist/activities/execute-cursor/message-translator.js +10 -1
  14. package/dist/activities/execute-cursor/message-translator.js.map +1 -1
  15. package/dist/activities/execute-cursor/prompt-builder.d.ts +32 -7
  16. package/dist/activities/execute-cursor/prompt-builder.js +38 -20
  17. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  18. package/dist/activities/execute-cursor/skill-resolver.d.ts +1 -10
  19. package/dist/activities/execute-cursor/skill-resolver.js +3 -55
  20. package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
  21. package/dist/activities/execute-cursor/stigmer-link.d.ts +35 -0
  22. package/dist/activities/execute-cursor/stigmer-link.js +73 -0
  23. package/dist/activities/execute-cursor/stigmer-link.js.map +1 -0
  24. package/dist/activities/execute-cursor/workspace-provision.d.ts +3 -2
  25. package/dist/activities/execute-cursor/workspace-provision.js +5 -3
  26. package/dist/activities/execute-cursor/workspace-provision.js.map +1 -1
  27. package/dist/activities/execute-deep-agent/index.js +59 -2
  28. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  29. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +17 -0
  30. package/dist/activities/execute-deep-agent/prompt-builder.js +16 -0
  31. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  32. package/dist/activities/execute-deep-agent/setup.js +22 -16
  33. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  34. package/dist/config.d.ts +7 -0
  35. package/dist/config.js +9 -0
  36. package/dist/config.js.map +1 -1
  37. package/dist/middleware/approval-gate.js +36 -6
  38. package/dist/middleware/approval-gate.js.map +1 -1
  39. package/dist/runner-manager.d.ts +2 -0
  40. package/dist/runner-manager.js +2 -1
  41. package/dist/runner-manager.js.map +1 -1
  42. package/dist/runner.d.ts +2 -0
  43. package/dist/runner.js +2 -1
  44. package/dist/runner.js.map +1 -1
  45. package/dist/shared/artifact-storage.d.ts +32 -0
  46. package/dist/shared/artifact-storage.js +74 -1
  47. package/dist/shared/artifact-storage.js.map +1 -1
  48. package/dist/shared/implement-plan-prompt.d.ts +42 -0
  49. package/dist/shared/implement-plan-prompt.js +66 -0
  50. package/dist/shared/implement-plan-prompt.js.map +1 -0
  51. package/dist/shared/plan-mode-prompt.d.ts +30 -0
  52. package/dist/shared/plan-mode-prompt.js +51 -0
  53. package/dist/shared/plan-mode-prompt.js.map +1 -0
  54. package/dist/shared/status.d.ts +11 -0
  55. package/dist/shared/status.js +12 -2
  56. package/dist/shared/status.js.map +1 -1
  57. package/dist/shared/tool-row.d.ts +30 -1
  58. package/dist/shared/tool-row.js +53 -4
  59. package/dist/shared/tool-row.js.map +1 -1
  60. package/dist/shared/workspace/session-root.d.ts +35 -0
  61. package/dist/shared/workspace/session-root.js +53 -0
  62. package/dist/shared/workspace/session-root.js.map +1 -0
  63. package/dist/shared/workspace/workspace-lock.d.ts +86 -0
  64. package/dist/shared/workspace/workspace-lock.js +187 -0
  65. package/dist/shared/workspace/workspace-lock.js.map +1 -0
  66. package/package.json +7 -5
  67. package/src/__tests__/config.test.ts +8 -0
  68. package/src/activities/__tests__/classify-tool-approvals.test.ts +1 -0
  69. package/src/activities/__tests__/discover-mcp-server.test.ts +1 -0
  70. package/src/activities/execute-cursor/__tests__/attachment-resolver.test.ts +161 -0
  71. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +125 -2
  72. package/src/activities/execute-cursor/__tests__/cas-observations.test.ts +55 -0
  73. package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +28 -0
  74. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +63 -4
  75. package/src/activities/execute-cursor/__tests__/workspace-provision.test.ts +36 -2
  76. package/src/activities/execute-cursor/attachment-resolver.ts +116 -34
  77. package/src/activities/execute-cursor/cas-observations.ts +41 -5
  78. package/src/activities/execute-cursor/hook-script.ts +19 -1
  79. package/src/activities/execute-cursor/index.ts +114 -26
  80. package/src/activities/execute-cursor/message-translator.ts +9 -1
  81. package/src/activities/execute-cursor/prompt-builder.ts +60 -22
  82. package/src/activities/execute-cursor/skill-resolver.ts +3 -59
  83. package/src/activities/execute-cursor/stigmer-link.ts +78 -0
  84. package/src/activities/execute-cursor/workspace-provision.ts +5 -3
  85. package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +7 -2
  86. package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +7 -2
  87. package/src/activities/execute-deep-agent/__tests__/index.test.ts +4 -1
  88. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +88 -0
  89. package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +7 -2
  90. package/src/activities/execute-deep-agent/index.ts +70 -2
  91. package/src/activities/execute-deep-agent/prompt-builder.ts +39 -0
  92. package/src/activities/execute-deep-agent/setup.ts +25 -17
  93. package/src/config.ts +17 -0
  94. package/src/middleware/__tests__/approval-gate.test.ts +77 -12
  95. package/src/middleware/approval-gate.ts +39 -7
  96. package/src/runner-manager.ts +6 -1
  97. package/src/runner.ts +6 -1
  98. package/src/shared/__tests__/artifact-storage.test.ts +111 -1
  99. package/src/shared/__tests__/implement-plan-prompt.test.ts +48 -0
  100. package/src/shared/__tests__/secret-leak-scan.test.ts +105 -0
  101. package/src/shared/__tests__/status.test.ts +7 -2
  102. package/src/shared/__tests__/tool-row.test.ts +136 -1
  103. package/src/shared/artifact-storage.ts +83 -1
  104. package/src/shared/implement-plan-prompt.ts +73 -0
  105. package/src/shared/plan-mode-prompt.ts +51 -0
  106. package/src/shared/status.ts +11 -1
  107. package/src/shared/tool-row.ts +58 -5
  108. package/src/shared/workspace/__tests__/session-root.test.ts +42 -0
  109. package/src/shared/workspace/__tests__/workspace-lock.test.ts +200 -0
  110. package/src/shared/workspace/session-root.ts +60 -0
  111. package/src/shared/workspace/workspace-lock.ts +241 -0
@@ -45,9 +45,9 @@ import { CursorMode } from "@stigmer/protos/ai/stigmer/agentic/session/v1/enum_p
45
45
  import { determineCursorMode, isCloudMode } from "./cursor-mode.js";
46
46
  import { MessageAccumulator, reconcileDeniedToolCalls, clearProvisionalPostDenialNarration, cancelInProgressSubAgentProtos, collapseRedundantToolCallTwins } from "./message-translator.js";
47
47
  import { utcTimestamp, persistStatus, reportSetupProgress, slimStatus } from "../../shared/status.js";
48
- import { collectSubAgentToolCallIds } from "../../shared/tool-row.js";
48
+ import { collectSubAgentToolCallIds, withholdSecretContentFromMessages } from "../../shared/tool-row.js";
49
49
  import { startStallWatchdog, StallTimeoutError, formatStallFailure, type StallWatchdog } from "../../shared/stall-watchdog.js";
50
- import { createArtifactStorage, loadArtifactStorageConfig, type ArtifactStorage } from "../../shared/artifact-storage.js";
50
+ import { resolveUsableArtifactStorage, loadArtifactStorageConfig, type ArtifactStorage } from "../../shared/artifact-storage.js";
51
51
  import { publishPlanArtifact } from "../../shared/plan-artifact.js";
52
52
  import { DeltaEnricher } from "./delta-enricher.js";
53
53
  import { TodoTracker } from "./todo-tracker.js";
@@ -61,11 +61,18 @@ import { backfillMcpServersIfNeeded } from "./connect-backfill.js";
61
61
  import { resolveExecutionEnv } from "./env-resolver.js";
62
62
  import { resolveBlueprint } from "./blueprint-resolver.js";
63
63
  import { buildCursorSubAgentDefinitions } from "./subagent-config.js";
64
- import { resolveSkills, removeStigmerSymlink } from "./skill-resolver.js";
64
+ import { resolveSkills } from "./skill-resolver.js";
65
+ import { removeStigmerSymlink } from "./stigmer-link.js";
65
66
  import { resolveAttachments } from "./attachment-resolver.js";
66
- import { buildEnhancedPrompt, buildReinvocationPrompt } from "./prompt-builder.js";
67
+ import { buildEnhancedPrompt, buildReinvocationPrompt, formatInteractionModePrefix, formatImplementPlanSection } from "./prompt-builder.js";
67
68
  import { installHitlGate, removeHitlGate } from "./workspace-setup.js";
68
69
  import { ensureHitlDir } from "../../shared/workspace/platform-dir.js";
70
+ import {
71
+ acquireWorkspaceLock,
72
+ WorkspaceLockCancelledError,
73
+ WorkspaceLockTimeoutError,
74
+ type ReleaseWorkspaceLock,
75
+ } from "../../shared/workspace/workspace-lock.js";
69
76
  import { LocalWorkspaceBackend } from "../../shared/workspace/local-backend.js";
70
77
  import { buildApprovalState, buildApprovalGrants, emitCursorGrantReceipts, primaryToken, readDenialLedger, reconstructAdjudicatedApprovals, watchDenialLedger } from "./approval-state.js";
71
78
  import { deriveTurnCommandProvenance } from "./command-provenance.js";
@@ -167,27 +174,31 @@ async function executeCursorInner(
167
174
 
168
175
  // Artifact storage for offloading oversized tool outputs (screenshots, giant
169
176
  // dumps) out of the persisted status, and for publishing the plan artifact.
170
- // Created once here so it is available to EVERY persist below. Best-effort:
171
- // if it can't be built (e.g. proxy mode without a token), offload is disabled
172
- // but persistStatus still enforces the aggregate size cap, so persistence can
173
- // never silently blow past the gRPC limit.
174
- let artifactStorage: ArtifactStorage | undefined;
175
- try {
176
- artifactStorage = createArtifactStorage(loadArtifactStorageConfig(config));
177
- } catch (storageErr) {
178
- console.warn(
179
- `ExecuteCursor artifact storage unavailable — tool-output offload disabled ` +
180
- `(aggregate size guard still active): execution=${executionId}, error=${storageErr}`,
181
- );
182
- }
177
+ // Resolved once here so it is available to EVERY persist below. Best-effort via
178
+ // the shared resolver (identical to the deep-agent harness): `undefined` never
179
+ // a throw when there is no working substrate (proxy misconfig OR an unwritable
180
+ // local path). An absent store disables offload (persistStatus still enforces
181
+ // the aggregate size cap) and flips capture mode off (deny-gate fallback).
182
+ const artifactStorage: ArtifactStorage | undefined =
183
+ await resolveUsableArtifactStorage(loadArtifactStorageConfig(config), { executionId });
183
184
  const statusOffload = artifactStorage
184
185
  ? { artifactStorage, executionId }
185
186
  : undefined;
186
187
  // ALL status persistence in this activity flows through `persist`, so the
187
188
  // single size-bounding guard (offload + aggregate elision) is unforgeable and
188
189
  // a future call site cannot accidentally skip it.
189
- const persist = (s: AgentExecutionStatus = status) =>
190
- persistStatus(client, executionId, s, { offload: statusOffload });
190
+ const persist = (s: AgentExecutionStatus = status) => {
191
+ // Never-persist-secret backstop (DD-26 #2): before EVERY persist, withhold
192
+ // content from any built-in write row targeting a secret-like path (top-level
193
+ // + sub-agent). This is the single airtight choke point for the Cursor harness
194
+ // — the deny-gate analog of capture mode's stamping scrub, and the only
195
+ // guarantee under auto_approve_all (where the hook installs no gate). Safe on
196
+ // every call: Cursor sets tool args atomically from the SDK tool_call event
197
+ // (buildToolCallProto), so there is no mid-stream partial-args hazard, and the
198
+ // pass only ever touches secret-like write rows (idempotent, else a no-op).
199
+ withholdSecretContentFromMessages(s.messages, s.subAgentExecutions);
200
+ return persistStatus(client, executionId, s, { offload: statusOffload });
201
+ };
191
202
 
192
203
  let sessionId: string | undefined;
193
204
  let session: import("@stigmer/protos/ai/stigmer/agentic/session/v1/api_pb").Session | undefined;
@@ -234,6 +245,14 @@ async function executeCursorInner(
234
245
  // (issue #173). Runs in the finally, covering every success/error/approval
235
246
  // exit path. Undefined until the gate is installed.
236
247
  let hitlCleanup: (() => Promise<void>) | undefined;
248
+ // Exclusive turn lock on the primary workspace working tree. Held across the
249
+ // ENTIRE tree-mutating window (decision reconcile, HITL gate install, the
250
+ // agent's own writes, candidate capture) so a concurrent execution sharing
251
+ // this directory can never write between this turn's baseline and candidate
252
+ // snapshots — the misattribution that showed another session's file as this
253
+ // turn's change. Released in the finally AFTER hitlCleanup (which still
254
+ // mutates the tree). See shared/workspace/workspace-lock.ts.
255
+ let releaseWorkspaceLock: ReleaseWorkspaceLock | undefined;
237
256
  // Carries model/mode/agentId out to the outer catch so a thrown CursorSdkError
238
257
  // can be classified with the same context as the run.wait() error path.
239
258
  let errorContext = { model: "default", mode: "local", agentId: "" };
@@ -294,6 +313,46 @@ async function executeCursorInner(
294
313
  const changeSetId = `${executionId}:${turnSeq}`;
295
314
  heartbeat();
296
315
 
316
+ // Serialize this turn against every other execution sharing this working
317
+ // tree — sessions declaring the same localPath (or the shared runner root)
318
+ // resolve to ONE directory, and an unserialized concurrent write lands
319
+ // inside this turn's baseline→candidate window, misattributing another
320
+ // session's file to this turn's review. Acquired before ANY tree mutation
321
+ // below (decision reconcile, gate install, agent writes, capture). While
322
+ // another turn holds the lock this surfaces a visible waiting state and
323
+ // heartbeats; a cancel aborts the wait immediately.
324
+ if (primaryWorkspaceDir) {
325
+ try {
326
+ releaseWorkspaceLock = await acquireWorkspaceLock(primaryWorkspaceDir, {
327
+ onWaiting: () => reportSetupProgress(
328
+ client, executionId, "Waiting for workspace — in use by another session",
329
+ ),
330
+ heartbeat,
331
+ signal: Context.current().cancellationSignal,
332
+ timeoutMs: config.workspaceLockTimeoutMs,
333
+ });
334
+ } catch (lockErr) {
335
+ if (lockErr instanceof WorkspaceLockCancelledError) {
336
+ throw new CancelledFailure("Activity cancelled while waiting for the workspace lock");
337
+ }
338
+ if (lockErr instanceof WorkspaceLockTimeoutError) {
339
+ status.phase = ExecutionPhase.EXECUTION_FAILED;
340
+ status.error = lockErr.message;
341
+ status.completedAt = utcTimestamp();
342
+ status.messages.push(create(AgentMessageSchema, {
343
+ type: MessageType.MESSAGE_SYSTEM,
344
+ content: `Execution failed: ${lockErr.message}`,
345
+ timestamp: utcTimestamp(),
346
+ }));
347
+ await persist(status);
348
+ console.warn(`ExecuteCursor workspace lock timeout: execution=${executionId}`);
349
+ return slimStatus(status);
350
+ }
351
+ throw lockErr;
352
+ }
353
+ }
354
+ heartbeat();
355
+
297
356
  // Set OTel baggage so downstream calls carry execution context.
298
357
  try {
299
358
  const { setBaggage, BAGGAGE_EXECUTION_ID, BAGGAGE_SESSION_ID, BAGGAGE_ORG_ID } = await import("../../otel.js");
@@ -511,13 +570,15 @@ async function executeCursorInner(
511
570
  });
512
571
  heartbeat();
513
572
 
514
- // Phase 5b: Resolve attachments
515
- const attachmentResults = await resolveAttachments(
516
- spec.attachments,
573
+ // Phase 5b: Resolve attachments (fail-hard — explicit user inputs; see
574
+ // attachment-resolver.ts). Downloads by storage key through the same
575
+ // artifactStorage resolved for status offload above.
576
+ const attachmentResults = await resolveAttachments(spec.attachments, {
517
577
  sessionId,
518
578
  primaryWorkspaceDir,
519
- config.mode,
520
- );
579
+ mode: config.mode,
580
+ storage: artifactStorage,
581
+ });
521
582
  const attachmentPaths = attachmentResults.map((a) => a.relativePath);
522
583
 
523
584
  // Phase 5b3: Exact-apply approved whole-file writes (HITL "what you approve
@@ -741,6 +802,7 @@ async function executeCursorInner(
741
802
  // Phase 10: Build the prompt
742
803
  const interactionMode = spec.executionConfig?.interactionMode
743
804
  ?? InteractionMode.UNSPECIFIED;
805
+ const buildFromPlan = spec.executionConfig?.buildFromPlan ?? false;
744
806
 
745
807
  const prompt = buildPrompt({
746
808
  resolution,
@@ -755,6 +817,7 @@ async function executeCursorInner(
755
817
  pendingApprovals: adjudicatedApprovals,
756
818
  appliedToolCallIds,
757
819
  interactionMode,
820
+ buildFromPlan,
758
821
  });
759
822
 
760
823
  // Phase 10a: Inject structured output instruction for Cursor harness
@@ -1885,6 +1948,13 @@ async function executeCursorInner(
1885
1948
  );
1886
1949
  }
1887
1950
  }
1951
+
1952
+ // Release the workspace turn lock LAST — hitlCleanup above still mutates
1953
+ // the tree (restores .cursor/hooks.json), and the next queued turn must
1954
+ // not baseline until every mutation of this one has landed. Idempotent
1955
+ // and non-throwing (see workspace-lock.ts), so it can never mask the
1956
+ // turn's real outcome.
1957
+ await releaseWorkspaceLock?.();
1888
1958
  }
1889
1959
  }
1890
1960
 
@@ -1996,6 +2066,11 @@ export interface BuildPromptInput {
1996
2066
  */
1997
2067
  appliedToolCallIds?: ReadonlySet<string>;
1998
2068
  interactionMode?: InteractionMode;
2069
+ /**
2070
+ * The execution is a Build-from-plan turn (spec.execution_config
2071
+ * .build_from_plan): both prompt paths carry the implement-plan directive.
2072
+ */
2073
+ buildFromPlan?: boolean;
1999
2074
  }
2000
2075
 
2001
2076
  /**
@@ -2026,6 +2101,7 @@ export function buildPrompt(input: BuildPromptInput): string {
2026
2101
  workspaceFileRefs,
2027
2102
  attachmentPaths,
2028
2103
  interactionMode,
2104
+ buildFromPlan,
2029
2105
  } = input;
2030
2106
 
2031
2107
  const isHitlReinvocation = approvalDecisions !== undefined && approvalDecisions.size > 0;
@@ -2042,9 +2118,20 @@ export function buildPrompt(input: BuildPromptInput): string {
2042
2118
  }
2043
2119
 
2044
2120
  // A successfully resumed agent carries its own conversation context via the
2045
- // SDK's native store — send the raw user message with no preamble.
2121
+ // SDK's native store — send the raw user message with no preamble. The
2122
+ // exceptions are the per-EXECUTION directives, which never inherit from the
2123
+ // session's first turn: the interaction-mode prefix (a follow-up can switch
2124
+ // Agent→Plan mid-session, and for Cursor the prompt is the only plan-mode
2125
+ // enforcement) and the implement-plan directive (the build turn is usually
2126
+ // a follow-up on a resumed agent).
2046
2127
  if (resolution.reason === "resumed_successfully") {
2047
- return userMessage;
2128
+ const prefixes = [
2129
+ formatInteractionModePrefix(interactionMode),
2130
+ formatImplementPlanSection(buildFromPlan, attachmentPaths),
2131
+ ].filter((p): p is string => p !== undefined);
2132
+ return prefixes.length > 0
2133
+ ? [...prefixes, userMessage].join("\n\n")
2134
+ : userMessage;
2048
2135
  }
2049
2136
 
2050
2137
  // First execution, or a fresh agent created after a resume failure: there is
@@ -2058,6 +2145,7 @@ export function buildPrompt(input: BuildPromptInput): string {
2058
2145
  workspaceFileRefs,
2059
2146
  attachmentPaths,
2060
2147
  interactionMode,
2148
+ buildFromPlan,
2061
2149
  });
2062
2150
  }
2063
2151
 
@@ -50,7 +50,8 @@ import { utcTimestamp } from "../../shared/status.js";
50
50
  import { hideToolCallRow, isToolCallRowHidden } from "../../shared/tool-row.js";
51
51
  import { classifyTool, toolApprovalCategory, type ToolApprovalCategory } from "../../shared/tool-kind.js";
52
52
  import { resolveWorkspacePath } from "../../shared/file-change.js";
53
- import { contentDigest } from "../../shared/file-tools.js";
53
+ import { contentDigest, extractFilePath } from "../../shared/file-tools.js";
54
+ import { isSecretLikePath } from "../../shared/filereview/secret-paths.js";
54
55
  import { buildElidedArgsPreview } from "../../shared/args-preview.js";
55
56
  import type { WorkspaceBackend } from "../../shared/workspace/types.js";
56
57
 
@@ -1631,6 +1632,13 @@ function applyGateInput(
1631
1632
  input: Record<string, unknown> | undefined,
1632
1633
  ): void {
1633
1634
  if (!input) return;
1635
+ // Defense-in-depth (DD-26 #2): never overlay a secret-like write's content into
1636
+ // the persisted approval preview. Normally unreachable — the hook hard-blocks a
1637
+ // secret write and records no ledger input — but if a hook classify failure fell
1638
+ // one through, its content must still never reach args/args_preview. The
1639
+ // Invariant-A backstop is the final net; this closes the path at the source.
1640
+ const gatePath = extractFilePath(input);
1641
+ if (gatePath !== null && isSecretLikePath(gatePath)) return;
1634
1642
  tc.args = input as JsonObject;
1635
1643
  tc.argsPreview = buildElidedArgsPreview(input, SALIENT_ARG_FIELDS);
1636
1644
  // Stamp the content digest from the AUTHORITATIVE captured input, so the
@@ -6,19 +6,25 @@
6
6
  * parameter). On reinvocation, only the approval decisions are sent.
7
7
  *
8
8
  * Sections (in order, conditionally included):
9
- * 1. Agent instructions (persona/character)
10
- * 2. Available skills metadata
11
- * 3. Sub-agent delegation guidance
12
- * 4. Workspace context (multi-root only; single-dir is redundant with SDK cwd)
13
- * 5. Input files / referenced files
14
- * 6. Response rules (only when agent has no custom instructions)
15
- * 7. User's actual message
9
+ * 1. Interaction-mode / implement-plan directives
10
+ * 2. Agent instructions (persona/character)
11
+ * 3. Available skills metadata
12
+ * 4. Sub-agent delegation guidance
13
+ * 5. Workspace context (multi-root only; single-dir is redundant with SDK cwd)
14
+ * 6. Input files / referenced files
15
+ * 7. Response rules (only when agent has no custom instructions)
16
+ * 8. User's actual message
16
17
  */
17
18
 
18
19
  import { resolve } from "node:path";
19
20
  import type { SubAgent } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/spec_pb";
20
21
  import type { PendingApproval } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/approval_pb";
21
22
  import { ApprovalAction, InteractionMode } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
23
+ import { PLAN_MODE_DIRECTIVE } from "../../shared/plan-mode-prompt.js";
24
+ import {
25
+ buildImplementPlanDirective,
26
+ findApprovedPlanPath,
27
+ } from "../../shared/implement-plan-prompt.js";
22
28
 
23
29
  /**
24
30
  * Marker path segments that identify runner-internal directories. A workspace
@@ -47,6 +53,13 @@ export interface EnhancedPromptOptions {
47
53
  workspaceFileRefs: string[];
48
54
  attachmentPaths: string[];
49
55
  interactionMode?: InteractionMode;
56
+ /**
57
+ * The execution is a Build-from-plan turn (spec.execution_config
58
+ * .build_from_plan): inject the implement-plan directive so the model reads
59
+ * the attached approved plan (or falls back to the conversation's plan).
60
+ * The user message itself is just a short label ("Build from plan").
61
+ */
62
+ buildFromPlan?: boolean;
50
63
  }
51
64
 
52
65
  /**
@@ -63,6 +76,14 @@ export function buildEnhancedPrompt(options: EnhancedPromptOptions): string {
63
76
  sections.push(modePrefix);
64
77
  }
65
78
 
79
+ const implementPlan = formatImplementPlanSection(
80
+ options.buildFromPlan,
81
+ options.attachmentPaths,
82
+ );
83
+ if (implementPlan) {
84
+ sections.push(implementPlan);
85
+ }
86
+
66
87
  if (options.instructions) {
67
88
  sections.push(formatInstructions(options.instructions));
68
89
  }
@@ -348,30 +369,47 @@ export function formatReferencedFiles(refs: string[]): string {
348
369
  /**
349
370
  * Returns a system-level directive when the execution is in Plan mode.
350
371
  * Returns `undefined` for Agent mode (default) since no prefix is needed.
372
+ *
373
+ * The directive body is the harness-agnostic {@link PLAN_MODE_DIRECTIVE}
374
+ * (shared with the native harness) wrapped in this harness's XML-tag section
375
+ * framing. For Cursor this prompt IS the plan-mode enforcement — the Cursor
376
+ * SDK exposes no mode parameter — see the shared module's doc comment.
351
377
  */
352
378
  export function formatInteractionModePrefix(
353
379
  mode: InteractionMode | undefined,
354
380
  ): string | undefined {
355
- if (
356
- mode == null ||
357
- mode === InteractionMode.UNSPECIFIED ||
358
- mode === InteractionMode.AGENT
359
- ) {
381
+ if (mode !== InteractionMode.PLAN) {
360
382
  return undefined;
361
383
  }
362
384
 
363
- if (mode === InteractionMode.PLAN) {
364
- return [
365
- "<interaction_mode>",
366
- "IMPORTANT: You are in Plan mode. Analyze the codebase and produce a detailed plan.",
367
- "Do NOT create, edit, or delete any files. Do NOT run commands that modify the filesystem.",
368
- "Only read, search, and analyze. Your output should be analysis, recommendations, and",
369
- "implementation plans not code changes.",
370
- "</interaction_mode>",
371
- ].join("\n");
385
+ return ["<interaction_mode>", PLAN_MODE_DIRECTIVE, "</interaction_mode>"].join(
386
+ "\n",
387
+ );
388
+ }
389
+
390
+ /**
391
+ * Returns the implement-plan directive section for a Build-from-plan
392
+ * execution, or `undefined` otherwise. The directive body is the shared
393
+ * {@link buildImplementPlanDirective} (same words as the native harness)
394
+ * wrapped in this harness's XML-tag framing; the variant is picked by whether
395
+ * the approved plan document is among the resolved attachment paths.
396
+ *
397
+ * Like the interaction-mode prefix, this rides BOTH prompt paths — the fresh
398
+ * enhanced prompt and a resumed turn's prefix — because build_from_plan is
399
+ * per-execution and the build turn is usually a follow-up on a resumed agent.
400
+ */
401
+ export function formatImplementPlanSection(
402
+ buildFromPlan: boolean | undefined,
403
+ attachmentPaths: string[],
404
+ ): string | undefined {
405
+ if (!buildFromPlan) {
406
+ return undefined;
372
407
  }
373
408
 
374
- return undefined;
409
+ const directive = buildImplementPlanDirective(
410
+ findApprovedPlanPath(attachmentPaths),
411
+ );
412
+ return ["<implement_plan>", directive, "</implement_plan>"].join("\n");
375
413
  }
376
414
 
377
415
  export function formatResponseRules(): string {
@@ -5,11 +5,11 @@
5
5
  * - Writes SKILL.md to .stigmer/skills/{name}/SKILL.md
6
6
  * - Downloads and extracts ZIP artifacts (references/, scripts/, etc.)
7
7
  * - Uses a platform-managed directory outside the workspace
8
- * - Creates a symlink from the workspace to the platform dir
8
+ * - Ensures the workspace `.stigmer` symlink (see stigmer-link.ts)
9
9
  * - Returns metadata for prompt injection
10
10
  */
11
11
 
12
- import { mkdir, writeFile, symlink, readlink, unlink, rm, lstat } from "node:fs/promises";
12
+ import { mkdir, writeFile, rm } from "node:fs/promises";
13
13
  import { join, dirname } from "node:path";
14
14
  import type { StigmerClient } from "../../client/stigmer-client.js";
15
15
  import type { Skill } from "@stigmer/protos/ai/stigmer/agentic/skill/v1/api_pb";
@@ -17,8 +17,8 @@ import type { ApiResourceReference } from "@stigmer/protos/ai/stigmer/commons/ap
17
17
  import type { SkillMetadata } from "./prompt-builder.js";
18
18
  import { getPlatformDir } from "../../shared/workspace/platform-dir.js";
19
19
  import { extractZipFileEntries } from "../../shared/zip-extract.js";
20
+ import { ensureStigmerSymlink, STIGMER_LOCAL_STATE_DIR } from "./stigmer-link.js";
20
21
 
21
- const STIGMER_LOCAL_STATE_DIR = ".stigmer";
22
22
  const SKILLS_SUBDIR = "skills";
23
23
 
24
24
  export interface SkillResolverOptions {
@@ -132,62 +132,6 @@ async function writeSkill(
132
132
  };
133
133
  }
134
134
 
135
- /**
136
- * Ensure .stigmer symlink in the workspace points to the platform dir.
137
- *
138
- * The Cursor SDK reads files from the workspace CWD, so we need the
139
- * .stigmer directory to be accessible there. Unlike the Python runner
140
- * which intercepts file paths, Cursor reads directly from the filesystem.
141
- */
142
- async function ensureStigmerSymlink(
143
- workspaceDir: string,
144
- platformDir: string,
145
- ): Promise<void> {
146
- const linkPath = join(workspaceDir, STIGMER_LOCAL_STATE_DIR);
147
-
148
- try {
149
- const existing = await readlink(linkPath);
150
- if (existing === platformDir) return;
151
- await unlink(linkPath);
152
- } catch (err: any) {
153
- if (err.code === "ENOENT") {
154
- // No existing symlink
155
- } else if (err.code === "EINVAL") {
156
- // Exists but is not a symlink — remove the directory
157
- await rm(linkPath, { recursive: true, force: true });
158
- } else {
159
- throw err;
160
- }
161
- }
162
-
163
- await symlink(platformDir, linkPath, "dir");
164
- }
165
-
166
- /**
167
- * Remove the workspace `.stigmer` symlink created by {@link resolveSkills}.
168
- *
169
- * Called in the activity's finally so attaching a real repo leaves no Stigmer
170
- * symlink behind once the turn ends (issue #173); a multi-turn session recreates
171
- * it on the next turn. Only ever removes a SYMLINK — a real `.stigmer` directory
172
- * (which would be the user's own, not ours) is left untouched. Best-effort.
173
- */
174
- export async function removeStigmerSymlink(workspaceDir: string): Promise<void> {
175
- const linkPath = join(workspaceDir, STIGMER_LOCAL_STATE_DIR);
176
- try {
177
- const stat = await lstat(linkPath);
178
- if (stat.isSymbolicLink()) {
179
- await unlink(linkPath);
180
- }
181
- } catch (err: any) {
182
- if (err?.code !== "ENOENT") {
183
- console.warn(
184
- `removeStigmerSymlink: failed to remove ${linkPath} (non-fatal): ` +
185
- `${err instanceof Error ? err.message : err}`,
186
- );
187
- }
188
- }
189
- }
190
-
191
135
  /**
192
136
  * Clean up platform-managed skill directory for a session.
193
137
  */
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The workspace `.stigmer` symlink — the Cursor harness's bridge from the
3
+ * user's workspace to the session's platform-managed directory
4
+ * (~/.stigmer/sessions/{id}/platform/, see shared/workspace/platform-dir.ts).
5
+ *
6
+ * The Cursor SDK reads files from the workspace CWD, so platform-injected
7
+ * content (skills, attachment inputs) must be reachable there; the symlink is
8
+ * how, without ever writing platform files into the user's repo (issue #173).
9
+ * This mechanism is deliberately harness-local: the native harness routes
10
+ * `.stigmer/` paths through its WorkspaceBackend instead and never symlinks.
11
+ *
12
+ * Both resolvers that populate the platform dir (skills, attachments) call
13
+ * {@link ensureStigmerSymlink}; it is idempotent, so either may run first and
14
+ * an agent with only one kind of content still gets the link. The activity's
15
+ * cleanup calls {@link removeStigmerSymlink} so a real repo is left untouched
16
+ * once the turn ends; a multi-turn session recreates the link on the next turn.
17
+ */
18
+
19
+ import { symlink, readlink, unlink, rm, lstat } from "node:fs/promises";
20
+ import { join } from "node:path";
21
+
22
+ /** The workspace-visible name of the platform namespace. */
23
+ export const STIGMER_LOCAL_STATE_DIR = ".stigmer";
24
+
25
+ /**
26
+ * Ensure the workspace `.stigmer` symlink points to the platform dir.
27
+ * Idempotent: an existing correct link is kept; a stale link (or a non-link
28
+ * left behind by an older runner) is replaced.
29
+ */
30
+ export async function ensureStigmerSymlink(
31
+ workspaceDir: string,
32
+ platformDir: string,
33
+ ): Promise<void> {
34
+ const linkPath = join(workspaceDir, STIGMER_LOCAL_STATE_DIR);
35
+
36
+ try {
37
+ const existing = await readlink(linkPath);
38
+ if (existing === platformDir) return;
39
+ await unlink(linkPath);
40
+ } catch (err: any) {
41
+ if (err.code === "ENOENT") {
42
+ // No existing symlink
43
+ } else if (err.code === "EINVAL") {
44
+ // Exists but is not a symlink — remove the directory
45
+ await rm(linkPath, { recursive: true, force: true });
46
+ } else {
47
+ throw err;
48
+ }
49
+ }
50
+
51
+ await symlink(platformDir, linkPath, "dir");
52
+ }
53
+
54
+ /**
55
+ * Remove the workspace `.stigmer` symlink created by
56
+ * {@link ensureStigmerSymlink}.
57
+ *
58
+ * Called in the activity's finally so attaching a real repo leaves no Stigmer
59
+ * symlink behind once the turn ends (issue #173). Only ever removes a
60
+ * SYMLINK — a real `.stigmer` directory (which would be the user's own, not
61
+ * ours) is left untouched. Best-effort.
62
+ */
63
+ export async function removeStigmerSymlink(workspaceDir: string): Promise<void> {
64
+ const linkPath = join(workspaceDir, STIGMER_LOCAL_STATE_DIR);
65
+ try {
66
+ const stat = await lstat(linkPath);
67
+ if (stat.isSymbolicLink()) {
68
+ await unlink(linkPath);
69
+ }
70
+ } catch (err: any) {
71
+ if (err?.code !== "ENOENT") {
72
+ console.warn(
73
+ `removeStigmerSymlink: failed to remove ${linkPath} (non-fatal): ` +
74
+ `${err instanceof Error ? err.message : err}`,
75
+ );
76
+ }
77
+ }
78
+ }
@@ -12,14 +12,16 @@ import type { Session } from "@stigmer/protos/ai/stigmer/agentic/session/v1/api_
12
12
  import { WorkspaceProvisioner } from "../../shared/workspace/provisioner.js";
13
13
  import { LocalWorkspaceBackend } from "../../shared/workspace/local-backend.js";
14
14
  import { ensurePlatformDir } from "../../shared/workspace/platform-dir.js";
15
+ import { resolveSessionWorkspaceRoot } from "../../shared/workspace/session-root.js";
15
16
 
16
17
  /**
17
18
  * Provision the session's workspace entries for a LOCAL Cursor agent.
18
19
  *
19
20
  * Clones git-repo entries (using the user's GITHUB_TOKEN from the resolved
20
21
  * execution environment) and mounts local-path entries, then returns the
21
- * directories the agent should operate in. Falls back to the configured
22
- * workspace root when the session declares no workspace entries.
22
+ * directories the agent should operate in. A session with no workspace
23
+ * entries gets its own empty per-session directory (see session-root.ts)
24
+ * never the shared root, which would leak other sessions' files into it.
23
25
  *
24
26
  * provisionGit is idempotent (it reuses an existing clone), so this is safe
25
27
  * to call on every execution, including multi-turn and HITL reinvocations.
@@ -32,7 +34,7 @@ export async function provisionCursorWorkspace(
32
34
  ): Promise<string[]> {
33
35
  const entries = session.spec?.workspaceEntries ?? [];
34
36
  if (entries.length === 0) {
35
- return [config.workspaceRootDir];
37
+ return [await resolveSessionWorkspaceRoot(config.workspaceRootDir, entries, sessionId)];
36
38
  }
37
39
 
38
40
  const platformDir = await ensurePlatformDir(sessionId);
@@ -49,7 +49,9 @@ import type { SetupResult } from "../setup.js";
49
49
  vi.mock("@temporalio/activity", () => ({
50
50
  Context: {
51
51
  current: () => ({
52
- cancellationSignal: { aborted: false },
52
+ // A real AbortSignal: the workspace-lock wait registers abort listeners
53
+ // on it, which a bare `{ aborted: false }` stub cannot satisfy.
54
+ cancellationSignal: new AbortController().signal,
53
55
  heartbeat: vi.fn(),
54
56
  }),
55
57
  },
@@ -258,7 +260,9 @@ function fakeDurableResumeSetup(): SetupResult {
258
260
  },
259
261
  agent: {},
260
262
  session: { spec: { workspaceEntries: [] } },
261
- workspaceBackend: { rootDir: "/tmp/ws" },
263
+ // Unique per test file: the workspace turn lock keys on this path, and a
264
+ // path shared across files would serialize parallel test workers for real.
265
+ workspaceBackend: { rootDir: "/tmp/stigmer-test-ws-hitl-approve-all" },
262
266
  mcpConnection: null,
263
267
  mergedEnvVars: {},
264
268
  secretKeys: new Set<string>(),
@@ -292,6 +296,7 @@ const baseConfig: Config = {
292
296
  checkpointerProxyEndpoint: "http://localhost:7234",
293
297
  primaryModel: "claude-sonnet",
294
298
  cursorStreamStallTimeoutMs: 180000,
299
+ workspaceLockTimeoutMs: 900000,
295
300
  };
296
301
 
297
302
  describe("ExecuteDeepAgent — APPROVE_ALL durable resume preserves leading thinking + first tool call", () => {
@@ -42,7 +42,9 @@ import type { SetupResult } from "../setup.js";
42
42
  vi.mock("@temporalio/activity", () => ({
43
43
  Context: {
44
44
  current: () => ({
45
- cancellationSignal: { aborted: false },
45
+ // A real AbortSignal: the workspace-lock wait registers abort listeners
46
+ // on it, which a bare `{ aborted: false }` stub cannot satisfy.
47
+ cancellationSignal: new AbortController().signal,
46
48
  heartbeat: vi.fn(),
47
49
  }),
48
50
  },
@@ -213,7 +215,9 @@ function fakeDurableResumeSetup(): SetupResult {
213
215
  },
214
216
  agent: {},
215
217
  session: { spec: { workspaceEntries: [] } },
216
- workspaceBackend: { rootDir: "/tmp/ws" },
218
+ // Unique per test file: the workspace turn lock keys on this path, and a
219
+ // path shared across files would serialize parallel test workers for real.
220
+ workspaceBackend: { rootDir: "/tmp/stigmer-test-ws-hitl-history" },
217
221
  mcpConnection: null,
218
222
  mergedEnvVars: {},
219
223
  secretKeys: new Set<string>(),
@@ -246,6 +250,7 @@ const baseConfig: Config = {
246
250
  checkpointerProxyEndpoint: "http://localhost:7234",
247
251
  primaryModel: "claude-sonnet",
248
252
  cursorStreamStallTimeoutMs: 180000,
253
+ workspaceLockTimeoutMs: 900000,
249
254
  };
250
255
 
251
256
  describe("ExecuteDeepAgent — durable-checkpoint resume preserves history", () => {
@@ -8,7 +8,9 @@ import type { Config } from "../../../config.js";
8
8
  vi.mock("@temporalio/activity", () => ({
9
9
  Context: {
10
10
  current: () => ({
11
- cancellationSignal: { aborted: false },
11
+ // A real AbortSignal: the workspace-lock wait registers abort listeners
12
+ // on it, which a bare `{ aborted: false }` stub cannot satisfy.
13
+ cancellationSignal: new AbortController().signal,
12
14
  heartbeat: vi.fn(),
13
15
  }),
14
16
  },
@@ -45,6 +47,7 @@ describe("ExecuteDeepAgent activity", () => {
45
47
  checkpointerProxyEndpoint: null,
46
48
  primaryModel: "gpt-4.1",
47
49
  cursorStreamStallTimeoutMs: 180000,
50
+ workspaceLockTimeoutMs: 900000,
48
51
  };
49
52
 
50
53
  let activities: ReturnType<typeof createDeepAgentActivities>;