@stigmer/runner 3.0.8-dev.20260612100207 → 3.0.8-dev.20260613041848

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 (31) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/approval-state.d.ts +25 -13
  3. package/dist/activities/execute-cursor/approval-state.js +33 -24
  4. package/dist/activities/execute-cursor/approval-state.js.map +1 -1
  5. package/dist/activities/execute-cursor/hook-script.d.ts +16 -1
  6. package/dist/activities/execute-cursor/hook-script.js +50 -1
  7. package/dist/activities/execute-cursor/hook-script.js.map +1 -1
  8. package/dist/activities/execute-cursor/index.js +60 -12
  9. package/dist/activities/execute-cursor/index.js.map +1 -1
  10. package/dist/activities/execute-cursor/session-lifecycle.js +6 -0
  11. package/dist/activities/execute-cursor/session-lifecycle.js.map +1 -1
  12. package/dist/activities/execute-cursor/skill-resolver.d.ts +9 -0
  13. package/dist/activities/execute-cursor/skill-resolver.js +24 -1
  14. package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
  15. package/dist/activities/execute-cursor/workspace-setup.d.ts +79 -21
  16. package/dist/activities/execute-cursor/workspace-setup.js +171 -51
  17. package/dist/activities/execute-cursor/workspace-setup.js.map +1 -1
  18. package/dist/shared/workspace/platform-dir.d.ts +26 -5
  19. package/dist/shared/workspace/platform-dir.js +38 -7
  20. package/dist/shared/workspace/platform-dir.js.map +1 -1
  21. package/package.json +2 -2
  22. package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +4 -3
  23. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +51 -2
  24. package/src/activities/execute-cursor/__tests__/workspace-setup.test.ts +204 -0
  25. package/src/activities/execute-cursor/approval-state.ts +33 -24
  26. package/src/activities/execute-cursor/hook-script.ts +54 -1
  27. package/src/activities/execute-cursor/index.ts +65 -16
  28. package/src/activities/execute-cursor/session-lifecycle.ts +6 -0
  29. package/src/activities/execute-cursor/skill-resolver.ts +26 -1
  30. package/src/activities/execute-cursor/workspace-setup.ts +228 -51
  31. package/src/shared/workspace/platform-dir.ts +41 -7
@@ -1,75 +1,252 @@
1
1
  /**
2
- * Writes Cursor hooks configuration and scripts to the workspace.
2
+ * Installs and tears down the Cursor HITL approval gate around an agent turn.
3
3
  *
4
- * Before creating or resuming a Cursor Agent, the cursor-runner writes:
5
- * 1. .cursor/hooks.json — declares the preToolUse hook
6
- * 2. .cursor/hooks/stigmer-approval.sh — the hook script
7
- * 3. .cursor/hooks/stigmer-approval-state.json — approval state for the hook
8
- * 4. .cursor/hooks/stigmer-denials.jsonl — per-turn denial ledger (reset here,
9
- * appended by the hook on each deny, read back by the activity)
4
+ * The gate has two surfaces, kept deliberately separate (issue #173):
10
5
  *
11
- * This setup enables the durable HITL model: the hook denies tools that need
12
- * approval and records each denial to the ledger; the activity reads the ledger,
13
- * marks the gated tool calls WAITING_APPROVAL (the backend then projects
14
- * pending_approvals from that status), and returns to the workflow. On
15
- * reinvocation the state file is updated with the approved tools so the hook
16
- * allows them.
6
+ * 1. Runner-owned artifacts — the hook script, approval-state file, and denial
7
+ * ledger — live in the session's HITL directory OUTSIDE the user's workspace
8
+ * (`~/.stigmer/sessions/{id}/hitl/`). They never touch the attached repo.
9
+ *
10
+ * 2. Workspace surface — a single `.cursor/hooks.json` written into the
11
+ * workspace, because the Cursor SDK only loads project hooks from that
12
+ * hard-coded path. It is kept minimal, MERGED with any pre-existing user
13
+ * hooks.json, points at the hook script by ABSOLUTE path (so multi-root IDE
14
+ * windows can always find it instead of failing closed), and is RESTORED to
15
+ * its original content when the turn ends.
16
+ *
17
+ * Why this shape. The previous design wrote all four files into the workspace
18
+ * with a repo-relative hook command and never cleaned up. For a local-folder
19
+ * workspace (the user's real repo, often open in their Cursor IDE) that gated
20
+ * the user's own IDE, ingested the IDE's tool calls into the denial ledger,
21
+ * failed closed in multi-root windows (relative path → exit 127), and left the
22
+ * gate behind after the session. Relocating the artifacts, scoping the hook to
23
+ * the runner's own process (see hook-script.ts), and restoring hooks.json after
24
+ * every turn together leave the user's repo and tooling untouched.
25
+ *
26
+ * Durability model: the install runs before every agent create/resume (and on
27
+ * every HITL reinvocation / Temporal activity retry); the teardown runs in the
28
+ * activity's finally. Each turn snapshots and restores independently, so the
29
+ * repo is byte-identical between turns. If a crash skips teardown, the leftover
30
+ * hooks.json is inert: the scope guard allows every invocation once the runner
31
+ * PID is gone, and the relocated artifacts are not in the repo.
17
32
  */
18
33
 
19
- import { writeFile, mkdir, chmod } from "node:fs/promises";
34
+ import { writeFile, readFile, mkdir, chmod, rm } from "node:fs/promises";
20
35
  import { join } from "node:path";
21
36
  import { generateHookScript } from "./hook-script.js";
22
37
  import { writeApprovalStateFile, resetDenialLedger, type ApprovalStateFile } from "./approval-state.js";
23
38
 
24
- const HOOKS_DIR = ".cursor";
25
- const HOOKS_SCRIPTS_DIR = ".cursor/hooks";
39
+ const CURSOR_DIR = ".cursor";
26
40
  const HOOKS_CONFIG_FILE = "hooks.json";
27
41
  const HOOK_SCRIPT_FILE = "stigmer-approval.sh";
28
42
 
43
+ /** preToolUse hook timeout (seconds) — the script is a quick local decision. */
44
+ const HOOK_TIMEOUT_SECONDS = 10;
45
+
46
+ /**
47
+ * Handle returned by {@link installHitlGate}, consumed by {@link removeHitlGate}
48
+ * to restore the workspace to its pre-turn state.
49
+ */
50
+ export interface HitlGateHandle {
51
+ /** Absolute path of the workspace hooks.json this turn manages. */
52
+ hooksJsonPath: string;
53
+ /**
54
+ * Content to restore on teardown: the workspace's original hooks.json bytes
55
+ * (with any stale Stigmer entry stripped), or null when no hooks.json existed
56
+ * before this turn (in which case teardown deletes the file).
57
+ */
58
+ restoreTo: string | null;
59
+ }
60
+
29
61
  /**
30
- * Write the complete hooks setup to the workspace directory.
62
+ * Install the HITL approval gate for one agent turn.
31
63
  *
32
- * Creates or overwrites:
33
- * - .cursor/hooks.json (hooks configuration)
34
- * - .cursor/hooks/stigmer-approval.sh (hook script, executable)
35
- * - .cursor/hooks/stigmer-approval-state.json (approval state)
64
+ * Writes the runner-owned artifacts into {@link hitlDir} and installs the merged
65
+ * `.cursor/hooks.json` into {@link workspaceRoot}, returning a handle that
66
+ * {@link removeHitlGate} uses to restore the workspace afterward.
36
67
  */
37
- export async function writeHooksToWorkspace(
38
- workspaceRoot: string,
39
- approvalState: ApprovalStateFile,
40
- ): Promise<void> {
41
- const hooksDir = join(workspaceRoot, HOOKS_DIR);
42
- const scriptsDir = join(workspaceRoot, HOOKS_SCRIPTS_DIR);
43
- await mkdir(hooksDir, { recursive: true });
44
- await mkdir(scriptsDir, { recursive: true });
68
+ export async function installHitlGate(params: {
69
+ workspaceRoot: string;
70
+ hitlDir: string;
71
+ approvalState: ApprovalStateFile;
72
+ runnerPid: number;
73
+ }): Promise<HitlGateHandle> {
74
+ const { workspaceRoot, hitlDir, approvalState, runnerPid } = params;
45
75
 
46
- const stateFilePath = await writeApprovalStateFile(workspaceRoot, approvalState);
76
+ const scriptPath = await writeHitlArtifacts(hitlDir, approvalState, runnerPid);
77
+ return installWorkspaceHook(workspaceRoot, scriptPath);
78
+ }
47
79
 
48
- // Reset the denial ledger for this turn (co-located with the state-file write
49
- // so the runner only reads denials produced by the current run, even across
50
- // HITL reinvocations on the durable workspace and Temporal activity retries).
51
- const ledgerFilePath = await resetDenialLedger(workspaceRoot);
80
+ /**
81
+ * Restore the workspace to its pre-turn state.
82
+ *
83
+ * Best-effort and never throws: a teardown failure must not fail the execution,
84
+ * and a leftover hooks.json is inert anyway (see the module doc).
85
+ */
86
+ export async function removeHitlGate(handle: HitlGateHandle): Promise<void> {
87
+ try {
88
+ if (handle.restoreTo === null) {
89
+ await rm(handle.hooksJsonPath, { force: true });
90
+ } else {
91
+ await writeFile(handle.hooksJsonPath, handle.restoreTo, "utf-8");
92
+ }
93
+ } catch (err) {
94
+ console.warn(
95
+ `removeHitlGate: failed to restore ${handle.hooksJsonPath} (non-fatal): ` +
96
+ `${err instanceof Error ? err.message : err}`,
97
+ );
98
+ }
99
+ }
52
100
 
53
- const hookScriptPath = join(scriptsDir, HOOK_SCRIPT_FILE);
54
- await writeFile(hookScriptPath, generateHookScript(stateFilePath, ledgerFilePath), "utf-8");
55
- await chmod(hookScriptPath, 0o755);
101
+ /**
102
+ * Write the runner-owned gate artifacts (approval-state file, fresh denial
103
+ * ledger, hook script) into the session HITL directory and return the absolute
104
+ * hook-script path. The script is regenerated every turn so the current runner
105
+ * PID and the current state-file/ledger paths are always baked in.
106
+ */
107
+ async function writeHitlArtifacts(
108
+ hitlDir: string,
109
+ approvalState: ApprovalStateFile,
110
+ runnerPid: number,
111
+ ): Promise<string> {
112
+ await mkdir(hitlDir, { recursive: true });
56
113
 
57
- const hooksConfig = {
58
- version: 1,
59
- hooks: {
60
- preToolUse: [
61
- {
62
- command: `.cursor/hooks/${HOOK_SCRIPT_FILE}`,
63
- timeout: 10,
64
- failClosed: true,
65
- },
66
- ],
67
- },
68
- };
114
+ const stateFilePath = await writeApprovalStateFile(hitlDir, approvalState);
115
+ // Reset the denial ledger for this turn so the runner only reads denials
116
+ // produced by the current run, even across HITL reinvocations on the durable
117
+ // HITL directory and Temporal activity retries.
118
+ const ledgerFilePath = await resetDenialLedger(hitlDir);
69
119
 
120
+ const hookScriptPath = join(hitlDir, HOOK_SCRIPT_FILE);
70
121
  await writeFile(
71
- join(hooksDir, HOOKS_CONFIG_FILE),
72
- JSON.stringify(hooksConfig, null, 2),
122
+ hookScriptPath,
123
+ generateHookScript(stateFilePath, ledgerFilePath, runnerPid),
73
124
  "utf-8",
74
125
  );
126
+ await chmod(hookScriptPath, 0o755);
127
+
128
+ return hookScriptPath;
129
+ }
130
+
131
+ /**
132
+ * Snapshot the workspace's existing `.cursor/hooks.json`, write a merged config
133
+ * that adds our preToolUse entry (absolute script path) while preserving the
134
+ * user's own hooks, and return the handle for restoration.
135
+ */
136
+ async function installWorkspaceHook(
137
+ workspaceRoot: string,
138
+ scriptPath: string,
139
+ ): Promise<HitlGateHandle> {
140
+ const cursorDir = join(workspaceRoot, CURSOR_DIR);
141
+ const hooksJsonPath = join(cursorDir, HOOKS_CONFIG_FILE);
142
+
143
+ let originalRaw: string | null = null;
144
+ try {
145
+ originalRaw = await readFile(hooksJsonPath, "utf-8");
146
+ } catch {
147
+ originalRaw = null;
148
+ }
149
+
150
+ const { merged, restoreTo } = buildMergedConfig(originalRaw, scriptPath);
151
+
152
+ await mkdir(cursorDir, { recursive: true });
153
+ await writeFile(hooksJsonPath, merged, "utf-8");
154
+
155
+ return { hooksJsonPath, restoreTo };
156
+ }
157
+
158
+ /**
159
+ * The preToolUse entry the gate installs. Absolute `command` so the hook is
160
+ * found regardless of which workspace root a multi-root IDE resolves against.
161
+ */
162
+ function buildHookEntry(scriptPath: string): Record<string, unknown> {
163
+ return { command: scriptPath, timeout: HOOK_TIMEOUT_SECONDS, failClosed: true };
164
+ }
165
+
166
+ /**
167
+ * Identify a preToolUse entry the gate itself wrote (in this or a prior,
168
+ * crash-leftover turn) so a re-install never duplicates it and a restore strips
169
+ * it. Matched by the unmistakable HITL script path, never by a user's own hook.
170
+ */
171
+ function isStigmerHookEntry(entry: unknown): boolean {
172
+ if (!entry || typeof entry !== "object") return false;
173
+ const command = (entry as { command?: unknown }).command;
174
+ return (
175
+ typeof command === "string" &&
176
+ command.includes("/.stigmer/sessions/") &&
177
+ command.endsWith(`/${HOOK_SCRIPT_FILE}`)
178
+ );
179
+ }
180
+
181
+ const STANDALONE_CONFIG = (scriptPath: string): string =>
182
+ JSON.stringify(
183
+ { version: 1, hooks: { preToolUse: [buildHookEntry(scriptPath)] } },
184
+ null,
185
+ 2,
186
+ );
187
+
188
+ /**
189
+ * Compute the merged hooks.json to write for this turn and the content to
190
+ * restore afterward.
191
+ *
192
+ * - No existing file → write our standalone config; restore by deleting (null).
193
+ * - Existing, parseable file → append our entry to `hooks.preToolUse`,
194
+ * preserving every other hook type and field; restore the user's original
195
+ * bytes. Any stale Stigmer entry from a prior crashed turn is stripped from
196
+ * BOTH the merged config (no duplicate) and the restore target (self-healing).
197
+ * - Existing, unparseable file → replace for the turn with our standalone
198
+ * config; restore the user's exact original bytes (we never "fix" their file).
199
+ *
200
+ * Exported for unit testing — this is the load-bearing data transformation.
201
+ */
202
+ export function buildMergedConfig(
203
+ originalRaw: string | null,
204
+ scriptPath: string,
205
+ ): { merged: string; restoreTo: string | null } {
206
+ if (originalRaw === null) {
207
+ return { merged: STANDALONE_CONFIG(scriptPath), restoreTo: null };
208
+ }
209
+
210
+ let parsed: unknown;
211
+ try {
212
+ parsed = JSON.parse(originalRaw);
213
+ } catch {
214
+ return { merged: STANDALONE_CONFIG(scriptPath), restoreTo: originalRaw };
215
+ }
216
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
217
+ return { merged: STANDALONE_CONFIG(scriptPath), restoreTo: originalRaw };
218
+ }
219
+
220
+ const root = parsed as Record<string, unknown>;
221
+ const hooks =
222
+ root.hooks && typeof root.hooks === "object" && !Array.isArray(root.hooks)
223
+ ? (root.hooks as Record<string, unknown>)
224
+ : {};
225
+ const existingPreToolUse = Array.isArray(hooks.preToolUse) ? hooks.preToolUse : [];
226
+ const userEntries = existingPreToolUse.filter((e) => !isStigmerHookEntry(e));
227
+ const strippedStale = userEntries.length !== existingPreToolUse.length;
228
+
229
+ const version = typeof root.version === "number" ? root.version : 1;
230
+
231
+ const merged = JSON.stringify(
232
+ {
233
+ ...root,
234
+ version,
235
+ hooks: { ...hooks, preToolUse: [...userEntries, buildHookEntry(scriptPath)] },
236
+ },
237
+ null,
238
+ 2,
239
+ );
240
+
241
+ // Restore the user's exact original bytes — unless we stripped a stale Stigmer
242
+ // entry, in which case restore the cleaned form so our leftover never lingers.
243
+ const restoreTo = strippedStale
244
+ ? JSON.stringify(
245
+ { ...root, version, hooks: { ...hooks, preToolUse: userEntries } },
246
+ null,
247
+ 2,
248
+ )
249
+ : originalRaw;
250
+
251
+ return { merged, restoreTo };
75
252
  }
@@ -1,11 +1,15 @@
1
1
  /**
2
2
  * Session-scoped platform directory management.
3
3
  *
4
- * Each agent execution session gets a dedicated platform directory at
5
- * `~/.stigmer/sessions/{sessionId}/platform/` for storing skills,
6
- * attachments, and other platform-managed files. This directory is
7
- * separate from the user's workspace — the agent sees it via the
8
- * `.stigmer/` virtual namespace through WorkspaceBackend routing.
4
+ * Each agent execution session gets a dedicated directory tree under
5
+ * `~/.stigmer/sessions/{sessionId}/`:
6
+ * - `platform/` — skills, attachments, and other platform-managed files the
7
+ * agent sees via the `.stigmer/` virtual namespace (WorkspaceBackend routing).
8
+ * - `hitl/` — the HITL approval gate's runtime artifacts (hook script, approval
9
+ * state, denial ledger). These live OUTSIDE the user's workspace so attaching
10
+ * a real repo never leaves Stigmer files behind in it (see issue #173); only
11
+ * a minimal, transient `.cursor/hooks.json` referencing the absolute script
12
+ * path is written into the workspace itself.
9
13
  *
10
14
  * This module replaces the duplicated `getPlatformDir` helpers in
11
15
  * execute-cursor/skill-resolver.ts and execute-cursor/attachment-resolver.ts.
@@ -15,14 +19,19 @@ import { join } from "node:path";
15
19
  import { mkdir } from "node:fs/promises";
16
20
  import { homedir } from "node:os";
17
21
 
22
+ /** Root of a session's runner-owned directory tree (outside the workspace). */
23
+ function getSessionDir(sessionId: string): string {
24
+ const home = process.env.HOME || process.env.USERPROFILE || homedir();
25
+ return join(home, ".stigmer", "sessions", sessionId);
26
+ }
27
+
18
28
  /**
19
29
  * Compute the platform directory path for a session.
20
30
  *
21
31
  * Pure function — does not create the directory or perform I/O.
22
32
  */
23
33
  export function getPlatformDir(sessionId: string): string {
24
- const home = process.env.HOME || process.env.USERPROFILE || homedir();
25
- return join(home, ".stigmer", "sessions", sessionId, "platform");
34
+ return join(getSessionDir(sessionId), "platform");
26
35
  }
27
36
 
28
37
  /**
@@ -36,3 +45,28 @@ export async function ensurePlatformDir(sessionId: string): Promise<string> {
36
45
  await mkdir(dir, { recursive: true });
37
46
  return dir;
38
47
  }
48
+
49
+ /**
50
+ * Compute the HITL directory path for a session — the runner-owned home for the
51
+ * approval gate's hook script, approval-state file, and denial ledger.
52
+ *
53
+ * Kept separate from the workspace so the gate's machinery never pollutes the
54
+ * user's repo: the workspace only ever holds a transient `.cursor/hooks.json`
55
+ * that points here by absolute path. Pure function — performs no I/O.
56
+ */
57
+ export function getHitlDir(sessionId: string): string {
58
+ return join(getSessionDir(sessionId), "hitl");
59
+ }
60
+
61
+ /**
62
+ * Ensure the HITL directory exists and return its path.
63
+ *
64
+ * Creates the full directory tree if it does not exist. Idempotent —
65
+ * safe to call multiple times for the same session (including across HITL
66
+ * reinvocations and Temporal activity retries).
67
+ */
68
+ export async function ensureHitlDir(sessionId: string): Promise<string> {
69
+ const dir = getHitlDir(sessionId);
70
+ await mkdir(dir, { recursive: true });
71
+ return dir;
72
+ }