@stigmer/runner 3.12.0 → 3.12.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 (97) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/capture-flow.d.ts +5 -4
  3. package/dist/activities/execute-cursor/capture-flow.js +5 -4
  4. package/dist/activities/execute-cursor/capture-flow.js.map +1 -1
  5. package/dist/activities/execute-cursor/cas-observations.d.ts +10 -4
  6. package/dist/activities/execute-cursor/cas-observations.js +10 -4
  7. package/dist/activities/execute-cursor/cas-observations.js.map +1 -1
  8. package/dist/activities/execute-cursor/hook-script.js +39 -13
  9. package/dist/activities/execute-cursor/hook-script.js.map +1 -1
  10. package/dist/activities/execute-cursor/skill-resolver.d.ts +8 -0
  11. package/dist/activities/execute-cursor/skill-resolver.js +77 -26
  12. package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
  13. package/dist/activities/execute-deep-agent/attachment-injector.d.ts +23 -8
  14. package/dist/activities/execute-deep-agent/attachment-injector.js +104 -105
  15. package/dist/activities/execute-deep-agent/attachment-injector.js.map +1 -1
  16. package/dist/activities/execute-deep-agent/cas-capture-backend.d.ts +3 -1
  17. package/dist/activities/execute-deep-agent/cas-capture-backend.js +3 -1
  18. package/dist/activities/execute-deep-agent/cas-capture-backend.js.map +1 -1
  19. package/dist/activities/execute-deep-agent/prompt-builder.js +11 -1
  20. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  21. package/dist/activities/execute-deep-agent/setup.js +15 -3
  22. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  23. package/dist/activities/execute-deep-agent/subagent-wiring.js +16 -9
  24. package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
  25. package/dist/activities/generate-session-subject.d.ts +80 -0
  26. package/dist/activities/generate-session-subject.js +283 -0
  27. package/dist/activities/generate-session-subject.js.map +1 -0
  28. package/dist/client/stigmer-client.d.ts +8 -0
  29. package/dist/client/stigmer-client.js +10 -0
  30. package/dist/client/stigmer-client.js.map +1 -1
  31. package/dist/middleware/approval-gate.d.ts +25 -10
  32. package/dist/middleware/approval-gate.js +33 -14
  33. package/dist/middleware/approval-gate.js.map +1 -1
  34. package/dist/middleware/otel-spans.d.ts +2 -1
  35. package/dist/middleware/otel-spans.js +2 -1
  36. package/dist/middleware/otel-spans.js.map +1 -1
  37. package/dist/middleware/path-normalization.d.ts +15 -2
  38. package/dist/middleware/path-normalization.js +39 -5
  39. package/dist/middleware/path-normalization.js.map +1 -1
  40. package/dist/runner-manager.js +3 -1
  41. package/dist/runner-manager.js.map +1 -1
  42. package/dist/runner.js +3 -1
  43. package/dist/runner.js.map +1 -1
  44. package/dist/shared/attachment-vision.js +9 -0
  45. package/dist/shared/attachment-vision.js.map +1 -1
  46. package/dist/shared/mcp-enabled-tools.d.ts +6 -2
  47. package/dist/shared/mcp-enabled-tools.js +6 -2
  48. package/dist/shared/mcp-enabled-tools.js.map +1 -1
  49. package/dist/shared/mcp-manager.js +8 -0
  50. package/dist/shared/mcp-manager.js.map +1 -1
  51. package/dist/shared/plan-mode-permissions.d.ts +46 -10
  52. package/dist/shared/plan-mode-permissions.js +56 -12
  53. package/dist/shared/plan-mode-permissions.js.map +1 -1
  54. package/dist/shared/zip-extract.d.ts +24 -6
  55. package/dist/shared/zip-extract.js +31 -90
  56. package/dist/shared/zip-extract.js.map +1 -1
  57. package/dist/shared/zip-structure.d.ts +61 -0
  58. package/dist/shared/zip-structure.js +128 -0
  59. package/dist/shared/zip-structure.js.map +1 -0
  60. package/package.json +2 -2
  61. package/src/__test-utils__/zip-fixtures.ts +206 -0
  62. package/src/activities/__tests__/generate-session-subject.test.ts +348 -0
  63. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +60 -9
  64. package/src/activities/execute-cursor/__tests__/skill-resolver.test.ts +166 -49
  65. package/src/activities/execute-cursor/capture-flow.ts +5 -4
  66. package/src/activities/execute-cursor/cas-observations.ts +10 -4
  67. package/src/activities/execute-cursor/hook-script.ts +39 -13
  68. package/src/activities/execute-cursor/skill-resolver.ts +98 -31
  69. package/src/activities/execute-deep-agent/__tests__/attachment-injector.test.ts +165 -126
  70. package/src/activities/execute-deep-agent/__tests__/plan-mode-path-normalization.test.ts +246 -37
  71. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +7 -2
  72. package/src/activities/execute-deep-agent/__tests__/subagent-plan-mode-permissions.test.ts +35 -4
  73. package/src/activities/execute-deep-agent/attachment-injector.ts +146 -142
  74. package/src/activities/execute-deep-agent/cas-capture-backend.ts +3 -1
  75. package/src/activities/execute-deep-agent/prompt-builder.ts +11 -1
  76. package/src/activities/execute-deep-agent/setup.ts +15 -3
  77. package/src/activities/execute-deep-agent/subagent-wiring.ts +16 -9
  78. package/src/activities/generate-session-subject.ts +370 -0
  79. package/src/client/stigmer-client.ts +11 -0
  80. package/src/middleware/__tests__/approval-gate.test.ts +130 -1
  81. package/src/middleware/__tests__/path-normalization.test.ts +29 -3
  82. package/src/middleware/approval-gate.ts +58 -24
  83. package/src/middleware/otel-spans.ts +2 -1
  84. package/src/middleware/path-normalization.ts +42 -5
  85. package/src/runner-manager.ts +3 -0
  86. package/src/runner.ts +3 -0
  87. package/src/shared/__tests__/attachment-vision.test.ts +4 -0
  88. package/src/shared/__tests__/mcp-manager.test.ts +7 -2
  89. package/src/shared/__tests__/plan-mode-permissions.test.ts +58 -0
  90. package/src/shared/__tests__/zip-extract.test.ts +106 -89
  91. package/src/shared/attachment-vision.ts +9 -0
  92. package/src/shared/filereview/__tests__/capture.test.ts +48 -0
  93. package/src/shared/mcp-enabled-tools.ts +6 -2
  94. package/src/shared/mcp-manager.ts +8 -0
  95. package/src/shared/plan-mode-permissions.ts +59 -12
  96. package/src/shared/zip-extract.ts +35 -117
  97. package/src/shared/zip-structure.ts +181 -0
@@ -1,8 +1,9 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
- import { mkdtempSync, readFileSync, existsSync, rmSync } from "node:fs";
2
+ import { mkdtempSync, readFileSync, existsSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
5
  import { resolveSkills } from "../skill-resolver.js";
6
+ import { buildZip } from "../../../__test-utils__/zip-fixtures.js";
6
7
 
7
8
  // ─── Helpers ─────────────────────────────────────────────────────────────
8
9
 
@@ -10,52 +11,13 @@ function makeTempDir(prefix: string): string {
10
11
  return mkdtempSync(join(tmpdir(), prefix));
11
12
  }
12
13
 
13
- /**
14
- * Build a minimal stored (method 0) ZIP archive for testing.
15
- */
16
- function buildStoredZip(files: { name: string; content: string }[]): Uint8Array {
17
- const parts: Uint8Array[] = [];
18
-
19
- for (const file of files) {
20
- const nameBytes = new TextEncoder().encode(file.name);
21
- const contentBytes = new TextEncoder().encode(file.content);
22
- const isDir = file.name.endsWith("/");
23
-
24
- const header = new ArrayBuffer(30);
25
- const view = new DataView(header);
26
- view.setUint32(0, 0x04034b50, true);
27
- view.setUint16(4, 20, true);
28
- view.setUint16(6, 0, true);
29
- view.setUint16(8, 0, true);
30
- view.setUint16(10, 0, true);
31
- view.setUint16(12, 0, true);
32
- view.setUint32(14, 0, true);
33
- view.setUint32(18, isDir ? 0 : contentBytes.length, true);
34
- view.setUint32(22, isDir ? 0 : contentBytes.length, true);
35
- view.setUint16(26, nameBytes.length, true);
36
- view.setUint16(28, 0, true);
37
-
38
- parts.push(new Uint8Array(header));
39
- parts.push(nameBytes);
40
- if (!isDir) parts.push(contentBytes);
41
- }
42
-
43
- const totalLength = parts.reduce((sum, p) => sum + p.length, 0);
44
- const result = new Uint8Array(totalLength);
45
- let offset = 0;
46
- for (const part of parts) {
47
- result.set(part, offset);
48
- offset += part.length;
49
- }
50
- return result;
51
- }
52
-
53
14
  function makeSkillProto(overrides: {
54
15
  name?: string;
55
16
  slug?: string;
56
17
  description?: string;
57
18
  skillMd?: string;
58
19
  artifactStorageKey?: string;
20
+ versionHash?: string;
59
21
  } = {}) {
60
22
  return {
61
23
  metadata: {
@@ -70,7 +32,7 @@ function makeSkillProto(overrides: {
70
32
  },
71
33
  status: {
72
34
  artifactStorageKey: overrides.artifactStorageKey ?? "",
73
- versionHash: "abc",
35
+ versionHash: overrides.versionHash ?? "abc",
74
36
  },
75
37
  } as any;
76
38
  }
@@ -114,12 +76,14 @@ describe("resolveSkills — artifact extraction", () => {
114
76
  ...clientOverrides,
115
77
  } as any;
116
78
 
79
+ // Re-run resolution for the same session — the mount-cache tests model
80
+ // one session's successive executions, which share the platform dir.
81
+ const resolveAgain = () =>
82
+ resolveSkills(client, refs, { sessionId, primaryWorkspaceDir: workspaceDir });
83
+
117
84
  try {
118
- const result = await resolveSkills(client, refs, {
119
- sessionId,
120
- primaryWorkspaceDir: workspaceDir,
121
- });
122
- return { result, platformDir, client };
85
+ const result = await resolveAgain();
86
+ return { result, platformDir, client, resolveAgain };
123
87
  } catch (err) {
124
88
  // Clean up on failure
125
89
  rmSync(platformDir, { recursive: true, force: true });
@@ -141,7 +105,7 @@ describe("resolveSkills — artifact extraction", () => {
141
105
  const skillMd = "# Garden Design Makeover\n\nSee [references/database-schema.md](references/database-schema.md)";
142
106
  const schemaContent = "# Database Schema\n\nTable definitions here.";
143
107
 
144
- const artifact = buildStoredZip([
108
+ const artifact = buildZip([
145
109
  { name: "SKILL.md", content: skillMd },
146
110
  { name: "references/", content: "" },
147
111
  { name: "references/database-schema.md", content: schemaContent },
@@ -228,11 +192,164 @@ describe("resolveSkills — artifact extraction", () => {
228
192
  }
229
193
  });
230
194
 
195
+ it("re-resolving an unchanged version skips the artifact download (mount cache hit)", async () => {
196
+ const artifact = buildZip([
197
+ { name: "SKILL.md", content: "# Cached" },
198
+ { name: "references/guide.md", content: "guide" },
199
+ ]);
200
+ const getSkillByReference = vi.fn().mockResolvedValue(
201
+ makeSkillProto({
202
+ name: "cached-skill",
203
+ slug: "cached-skill",
204
+ skillMd: "# Cached",
205
+ artifactStorageKey: "artifacts/cached.zip",
206
+ versionHash: "hash-v1",
207
+ }),
208
+ );
209
+ const getSkillArtifact = vi.fn().mockResolvedValue({ artifact });
210
+
211
+ const { result, platformDir, client, resolveAgain } = await resolveWithMockClient(
212
+ [makeRef("cached-skill")],
213
+ { getSkillByReference, getSkillArtifact },
214
+ );
215
+
216
+ try {
217
+ expect(result).toHaveLength(1);
218
+ expect(client.getSkillArtifact).toHaveBeenCalledTimes(1);
219
+
220
+ const second = await resolveAgain();
221
+ expect(second).toHaveLength(1);
222
+ expect(second[0].name).toBe("cached-skill");
223
+ // Metadata was re-fetched (latest-version freshness)...
224
+ expect(client.getSkillByReference).toHaveBeenCalledTimes(2);
225
+ // ...but the artifact transfer and rewrite were skipped.
226
+ expect(client.getSkillArtifact).toHaveBeenCalledTimes(1);
227
+
228
+ const skillDir = join(platformDir, "skills", "cached-skill");
229
+ expect(readFileSync(join(skillDir, "references", "guide.md"), "utf-8")).toBe("guide");
230
+ } finally {
231
+ cleanupPlatformDir(platformDir);
232
+ }
233
+ });
234
+
235
+ it("re-downloads on version change and clears stale files from the old mount", async () => {
236
+ const v1 = makeSkillProto({
237
+ name: "evolving-skill",
238
+ slug: "evolving-skill",
239
+ skillMd: "# V1",
240
+ artifactStorageKey: "artifacts/v1.zip",
241
+ versionHash: "hash-v1",
242
+ });
243
+ const v2 = makeSkillProto({
244
+ name: "evolving-skill",
245
+ slug: "evolving-skill",
246
+ skillMd: "# V2",
247
+ artifactStorageKey: "artifacts/v2.zip",
248
+ versionHash: "hash-v2",
249
+ });
250
+ const artifactV1 = buildZip([{ name: "references/removed-in-v2.md", content: "old" }]);
251
+ const artifactV2 = buildZip([{ name: "references/new-in-v2.md", content: "new" }]);
252
+
253
+ const getSkillByReference = vi.fn().mockResolvedValueOnce(v1).mockResolvedValueOnce(v2);
254
+ const getSkillArtifact = vi
255
+ .fn()
256
+ .mockResolvedValueOnce({ artifact: artifactV1 })
257
+ .mockResolvedValueOnce({ artifact: artifactV2 });
258
+
259
+ const { platformDir, client, resolveAgain } = await resolveWithMockClient(
260
+ [makeRef("evolving-skill")],
261
+ { getSkillByReference, getSkillArtifact },
262
+ );
263
+
264
+ try {
265
+ const skillDir = join(platformDir, "skills", "evolving-skill");
266
+ expect(readFileSync(join(skillDir, "references", "removed-in-v2.md"), "utf-8")).toBe("old");
267
+
268
+ await resolveAgain();
269
+ expect(client.getSkillArtifact).toHaveBeenCalledTimes(2);
270
+ expect(readFileSync(join(skillDir, "SKILL.md"), "utf-8")).toBe("# V2");
271
+ expect(readFileSync(join(skillDir, "references", "new-in-v2.md"), "utf-8")).toBe("new");
272
+ // The v1-only file must not linger in the v2 mount (the stale-file leak).
273
+ expect(existsSync(join(skillDir, "references", "removed-in-v2.md"))).toBe(false);
274
+ } finally {
275
+ cleanupPlatformDir(platformDir);
276
+ }
277
+ });
278
+
279
+ it("does not cache a SKILL.md-only fallback — the next execution retries the download", async () => {
280
+ const proto = makeSkillProto({
281
+ name: "retry-skill",
282
+ slug: "retry-skill",
283
+ skillMd: "# Retry",
284
+ artifactStorageKey: "artifacts/retry.zip",
285
+ versionHash: "hash-v1",
286
+ });
287
+ const artifact = buildZip([{ name: "references/late.md", content: "finally" }]);
288
+ const getSkillByReference = vi.fn().mockResolvedValue(proto);
289
+ const getSkillArtifact = vi
290
+ .fn()
291
+ .mockRejectedValueOnce(new Error("Network timeout"))
292
+ .mockResolvedValueOnce({ artifact });
293
+
294
+ const { result, platformDir, client, resolveAgain } = await resolveWithMockClient(
295
+ [makeRef("retry-skill")],
296
+ { getSkillByReference, getSkillArtifact },
297
+ );
298
+
299
+ try {
300
+ // First pass degraded to SKILL.md only.
301
+ expect(result).toHaveLength(1);
302
+ const skillDir = join(platformDir, "skills", "retry-skill");
303
+ expect(existsSync(join(skillDir, "references"))).toBe(false);
304
+
305
+ // Second pass retries and completes the mount.
306
+ await resolveAgain();
307
+ expect(client.getSkillArtifact).toHaveBeenCalledTimes(2);
308
+ expect(readFileSync(join(skillDir, "references", "late.md"), "utf-8")).toBe("finally");
309
+
310
+ // Third pass is a cache hit.
311
+ await resolveAgain();
312
+ expect(client.getSkillArtifact).toHaveBeenCalledTimes(2);
313
+ } finally {
314
+ cleanupPlatformDir(platformDir);
315
+ }
316
+ });
317
+
318
+ it("treats a corrupted mount marker as stale and remounts", async () => {
319
+ const artifact = buildZip([{ name: "references/guide.md", content: "guide" }]);
320
+ const getSkillByReference = vi.fn().mockResolvedValue(
321
+ makeSkillProto({
322
+ name: "tampered-skill",
323
+ slug: "tampered-skill",
324
+ skillMd: "# Tampered",
325
+ artifactStorageKey: "artifacts/tampered.zip",
326
+ versionHash: "hash-v1",
327
+ }),
328
+ );
329
+ const getSkillArtifact = vi.fn().mockResolvedValue({ artifact });
330
+
331
+ const { platformDir, client, resolveAgain } = await resolveWithMockClient(
332
+ [makeRef("tampered-skill")],
333
+ { getSkillByReference, getSkillArtifact },
334
+ );
335
+
336
+ try {
337
+ const skillDir = join(platformDir, "skills", "tampered-skill");
338
+ writeFileSync(join(skillDir, ".stigmer-mount.json"), "not json {");
339
+
340
+ await resolveAgain();
341
+ expect(client.getSkillArtifact).toHaveBeenCalledTimes(2);
342
+ expect(readFileSync(join(skillDir, "references", "guide.md"), "utf-8")).toBe("guide");
343
+ } finally {
344
+ cleanupPlatformDir(platformDir);
345
+ }
346
+ });
347
+
231
348
  it("SKILL.md from spec takes precedence over ZIP copy", async () => {
232
349
  const specContent = "# Authoritative SKILL.md from spec";
233
350
  const zipContent = "# Stale SKILL.md from ZIP";
234
351
 
235
- const artifact = buildStoredZip([
352
+ const artifact = buildZip([
236
353
  { name: "SKILL.md", content: zipContent },
237
354
  { name: "references/data.md", content: "data" },
238
355
  ]);
@@ -112,10 +112,11 @@ export function captureBaselineToLedger(opts: {
112
112
  * bytes withheld from durable storage.
113
113
  *
114
114
  * `deniedTokens` are the identities the hook gated this turn (shell/MCP, or a
115
- * gitignored delete). A streamed file-edit row whose identity is in that set is
116
- * left for the deny-gate reconcile path it did NOT flow. A flowed gitignored
117
- * write is NOT in that set (the hook allowed it), so it is stamped like any
118
- * other flowed edit and its captured delta surfaces as a CAS entry in the set.
115
+ * secret-like delete non-secret CAS deletes flow since issue #303). A streamed
116
+ * file-edit row whose identity is in that set is left for the deny-gate
117
+ * reconcile path it did NOT flow. A flowed gitignored write or delete is NOT
118
+ * in that set (the hook allowed it), so it is stamped like any other flowed
119
+ * edit and its captured delta surfaces as a CAS entry in the set.
119
120
  *
120
121
  * `hitlDir`/`storage` are omitted only by callers with no artifact storage
121
122
  * (captureIgnored off); the CAS half is then skipped and this is a git-only
@@ -1,5 +1,6 @@
1
1
  /**
2
- * The Cursor harness's disk-backed observer for gitignored file writes the
2
+ * The Cursor harness's disk-backed observer for CAS-owned file mutations
3
+ * gitignored / non-git writes, and (issue #303) pre-delete byte capture — the
3
4
  * out-of-process analog of the deep-agent `CasCaptureFilesystemBackend`
4
5
  * ({@link ../execute-deep-agent/cas-capture-backend.js}).
5
6
  *
@@ -185,14 +186,19 @@ export function buildSecretClassifyScript(): string {
185
186
 
186
187
  /**
187
188
  * Build the standalone Node.js script the hook runs to observe a single
188
- * gitignored write — the disk-backed mirror of `CasCaptureFilesystemBackend`'s
189
- * `recordBefore` plus the DD-E secret gate.
189
+ * CAS-owned mutation — the disk-backed mirror of `CasCaptureFilesystemBackend`'s
190
+ * `recordBefore` plus the DD-E secret gate. Staging is mutation-agnostic: it
191
+ * records the PRE-mutation bytes, so the hook runs it for a write/edit and —
192
+ * issue #303 — for a delete (whose before-bytes exist only until the tool runs;
193
+ * the boundary later reads after=null and authors the DELETE). The hook's
194
+ * delete arm pre-classifies secrets and never invokes this for a secret-like
195
+ * delete (a gated delete must not leave a "secret" marker in the sidecar).
190
196
  *
191
197
  * Invoked as `printf %s "$SALIENT" | node -e '<this>' <workspaceRoot> <obsDir>`
192
198
  * — the (arbitrary) salient path arrives on stdin so no argv escaping is needed;
193
199
  * the two absolute paths ride argv. Prints one token on stdout for the hook:
194
200
  * - `captured` — non-secret: staged the pre-turn bytes (first-touch-wins), the
195
- * hook then ALLOWS the write to flow;
201
+ * hook then ALLOWS the mutation to flow;
196
202
  * - `secret` — secret-like: recorded a content-less marker, the hook then
197
203
  * DENIES with the security message (nothing is written);
198
204
  * - `error` — the path escaped the workspace or a filesystem error occurred,
@@ -574,9 +574,8 @@ if echo "$STATE" | grep -q '"captureIgnored":true'; then
574
574
  fi
575
575
  # gitWorkspace selects the capture substrate (Slice 2c). Default true when the
576
576
  # key is absent (older state files). When false the workspace is NOT a git tree:
577
- # there is no git snapshot, so EVERY file write is CAS-staged below (not only
578
- # gitignored ones), the git-tracked flow arm is skipped, and a delete stays gated
579
- # (no CAS delete-capture path, parity with the deep-agent).
577
+ # there is no git snapshot, so EVERY file write/delete is CAS-staged below (not
578
+ # only gitignored ones) and the git-tracked flow arm is skipped.
580
579
  GIT_WORKSPACE=true
581
580
  if echo "$STATE" | grep -q '"gitWorkspace":false'; then
582
581
  GIT_WORKSPACE=false
@@ -597,9 +596,10 @@ fi
597
596
  # secret-like one must be hard-blocked in every mode. WHICH writes are CAS-owned
598
597
  # depends on the substrate: in a git tree it is only the GITIGNORED writes (git
599
598
  # captures the tracked ones); in a NON-GIT workspace it is EVERY write (there is
600
- # no git snapshot). Only a built-in write/edit (category "write") takes this arm;
601
- # deletes and shell/MCP stay on the deny-gate below (parity with the deep-agent
602
- # approval gate). The staging runs on the runner's own Node binary (the
599
+ # no git snapshot). Only a built-in write/edit (category "write") takes THIS arm;
600
+ # deletes take their own staging arm just below (issue #303), shell/MCP stay on
601
+ # the deny-gate (parity with the deep-agent approval gate). The staging runs on
602
+ # the runner's own Node binary (the
603
603
  # disk-backed mirror of CasCaptureFilesystemBackend.recordBefore): the salient
604
604
  # path rides stdin (no argv escaping), the workspace root and the per-turn
605
605
  # cas-observations dir ride argv. "captured" -> allow (apply-then-review);
@@ -629,6 +629,32 @@ if [ "$CAPTURE_IGNORED" = "true" ] && [ "$CATEGORY" = "write" ] && [ -n "$SALIEN
629
629
  fi
630
630
  fi
631
631
 
632
+ # --- Capture mode: observe CAS-owned deletes for review (issue #303) --------
633
+ # The delete twin of the write arm above — a non-secret CAS-owned delete stages
634
+ # its before-bytes (the one moment they still exist on disk) and flows; the
635
+ # turn boundary then reads after=null and authors a reviewable, restorable
636
+ # DELETE entry. One deliberate difference from the write arm: the path is
637
+ # classified for secret-likeness BEFORE any staging touches the sidecar. A
638
+ # secret-like delete must stay on the deny-gate below where a human may still
639
+ # approve it (its args expose no secret content, unlike a write) — staging it
640
+ # would both persist the secret before-bytes and mark the sidecar "secret",
641
+ # making the boundary author a blocking DIFF_UNREVIEWABLE for a merely-gated
642
+ # delete. Every failure mode (secret, classify error, staging error) falls
643
+ # through to the deny-gate rather than denying here, so grants and leases keep
644
+ # applying to a gated delete exactly as before this arm existed; the eventual
645
+ # resolution records its own ledger kind.
646
+ if [ "$CAPTURE_IGNORED" = "true" ] && [ "$CATEGORY" = "delete" ] && [ -n "$SALIENT" ] && { [ "$GIT_WORKSPACE" = "false" ] || __stigmer_is_gitignored "$SALIENT"; }; then
647
+ DELETE_SECRET_RESULT=$(printf '%s' "$SALIENT" | ELECTRON_RUN_AS_NODE=1 "$NODE_BIN" -e '${secretClassifyScript}' 2>/dev/null || echo error)
648
+ if [ "$DELETE_SECRET_RESULT" = "ok" ]; then
649
+ OBS_DIR="$(dirname "$STATE_FILE")/${CAS_OBSERVATIONS_DIRNAME}"
650
+ OBS_RESULT=$(printf '%s' "$SALIENT" | ELECTRON_RUN_AS_NODE=1 "$NODE_BIN" -e '${observationStagingScript}' "$GIT_ROOT" "$OBS_DIR" 2>/dev/null || echo error)
651
+ if [ "$OBS_RESULT" = "captured" ]; then
652
+ echo '{"permission":"allow"}'
653
+ exit 0
654
+ fi
655
+ fi
656
+ fi
657
+
632
658
  # --- 1a. MCP capability manifest: enabled_tools (issue #350) ---
633
659
  # Runs BEFORE the auto-approve-all shortcut and every grant check because
634
660
  # enabled_tools is a capability manifest, not an approval gate: no bypass may
@@ -700,13 +726,13 @@ if [ -n "$CATEGORY" ]; then
700
726
  # Capture mode (GIT tree only): a git-tracked file mutation (write/edit/delete)
701
727
  # flows freely — the runner captures the whole change set with git at the turn
702
728
  # boundary and gates it per-file for review. A gitignored path is invisible to
703
- # that git snapshot: a non-secret gitignored WRITE was already handled above
704
- # (staged + allowed, or hard-blocked) when captureIgnored is on; here it only
705
- # reaches the deny-gate when captureIgnored is off (no artifact storage) or it is
706
- # a gitignored DELETE (no CAS capture path, parity with deep-agent). shell
707
- # (category "shell") never takes this branch and stays gated as always. In a
708
- # NON-GIT workspace this arm is skipped entirely (there is no git diff): writes
709
- # were CAS-staged above and a delete falls through to the deny-gate.
729
+ # that git snapshot: a non-secret gitignored WRITE or DELETE was already
730
+ # handled above (staged + allowed, hard-blocked, or left to fall through) when
731
+ # captureIgnored is on; here it only reaches the deny-gate when captureIgnored
732
+ # is off (no artifact storage), or it is a secret-like DELETE (approvable, but
733
+ # never staged — issue #303). shell (category "shell") never takes this branch
734
+ # and stays gated as always. In a NON-GIT workspace this arm is skipped
735
+ # entirely (there is no git diff): writes and deletes were CAS-staged above.
710
736
  if [ "$CAPTURE_MODE" = "true" ] && [ "$GIT_WORKSPACE" = "true" ] && { [ "$CATEGORY" = "write" ] || [ "$CATEGORY" = "delete" ]; }; then
711
737
  if ! __stigmer_is_gitignored "$SALIENT"; then
712
738
  echo '{"permission":"allow"}'
@@ -7,9 +7,17 @@
7
7
  * - Uses a platform-managed directory outside the workspace
8
8
  * - Ensures the workspace `.stigmer` symlink (see stigmer-link.ts)
9
9
  * - Returns metadata for prompt injection
10
+ *
11
+ * The mount is cached by the skill's content-addressed version hash
12
+ * (stigmer/stigmer#672): metadata is fetched on every execution (that keeps
13
+ * latest-version freshness), but the artifact download and file rewrite are
14
+ * skipped when the mounted content's hash already matches. The session's
15
+ * platform dir survives across executions, so on an active session every
16
+ * message after the first pays a metadata read instead of a full artifact
17
+ * transfer.
10
18
  */
11
19
 
12
- import { mkdir, writeFile, rm } from "node:fs/promises";
20
+ import { mkdir, readFile, writeFile, rm } from "node:fs/promises";
13
21
  import { join, dirname } from "node:path";
14
22
  import type { StigmerClient } from "../../client/stigmer-client.js";
15
23
  import type { Skill } from "@stigmer/protos/ai/stigmer/agentic/skill/v1/api_pb";
@@ -21,6 +29,25 @@ import { ensureStigmerSymlink, STIGMER_LOCAL_STATE_DIR } from "../../shared/work
21
29
 
22
30
  const SKILLS_SUBDIR = "skills";
23
31
 
32
+ /**
33
+ * Marker recording what a skill's mount directory currently holds. Written
34
+ * LAST, after every file of the mount landed — a crash mid-write leaves no
35
+ * marker, so the next execution remounts instead of trusting a partial tree.
36
+ */
37
+ const MOUNT_MARKER_FILE = ".stigmer-mount.json";
38
+
39
+ interface MountMarker {
40
+ /** Content-addressed version hash (`Skill.status.version_hash`) of the mounted content. */
41
+ versionHash: string;
42
+ /**
43
+ * Whether the artifact's files are part of the mount. `false` when the
44
+ * skill has no artifact OR when the download failed and the mount fell
45
+ * back to SKILL.md only — the latter makes the next execution retry the
46
+ * download rather than cache the degraded mount.
47
+ */
48
+ artifactMounted: boolean;
49
+ }
50
+
24
51
  export interface SkillResolverOptions {
25
52
  sessionId: string;
26
53
  primaryWorkspaceDir: string;
@@ -61,11 +88,34 @@ export async function resolveSkills(
61
88
  for (const ref of skillRefs) {
62
89
  try {
63
90
  const skill = await client.getSkillByReference(ref);
91
+ const spec = skill.spec;
92
+ if (!spec?.skillMd) {
93
+ console.warn(`[resolveSkills] skill ${ref.org}/${ref.slug} fetched but had no skillMd content`);
94
+ continue;
95
+ }
96
+
97
+ const name = spec.name || skill.metadata?.slug || "unknown";
98
+ const skillDir = join(skillsDir, name);
99
+ const versionHash = skill.status?.versionHash ?? "";
100
+ const wantsArtifact = Boolean(skill.status?.artifactStorageKey);
101
+ const meta: SkillMetadata = {
102
+ name,
103
+ description: spec.description || `Skill: ${name}`,
104
+ path: join(STIGMER_LOCAL_STATE_DIR, SKILLS_SUBDIR, name, "SKILL.md"),
105
+ };
106
+
107
+ if (versionHash !== "" && (await mountIsFresh(skillDir, versionHash, wantsArtifact))) {
108
+ results.push(meta);
109
+ console.log(
110
+ `[resolveSkills] mount cache hit: ${name} (version ${versionHash.slice(0, 12)}) — skipping artifact transfer`,
111
+ );
112
+ continue;
113
+ }
64
114
 
65
115
  let artifactBytes: Uint8Array | undefined;
66
- if (skill.status?.artifactStorageKey) {
116
+ if (wantsArtifact) {
67
117
  try {
68
- const resp = await client.getSkillArtifact(skill.status.artifactStorageKey);
118
+ const resp = await client.getSkillArtifact(skill.status!.artifactStorageKey);
69
119
  if (resp.artifact && resp.artifact.length > 0) {
70
120
  artifactBytes = resp.artifact;
71
121
  }
@@ -77,13 +127,9 @@ export async function resolveSkills(
77
127
  }
78
128
  }
79
129
 
80
- const meta = await writeSkill(skill, skillsDir, options.primaryWorkspaceDir, artifactBytes);
81
- if (meta) {
82
- results.push(meta);
83
- console.log(`[resolveSkills] wrote skill: ${meta.name} -> ${meta.path}`);
84
- } else {
85
- console.warn(`[resolveSkills] skill ${ref.org}/${ref.slug} fetched but had no skillMd content`);
86
- }
130
+ await writeSkillMount(skill, skillDir, artifactBytes);
131
+ results.push(meta);
132
+ console.log(`[resolveSkills] wrote skill: ${name} -> ${meta.path}`);
87
133
  } catch (err) {
88
134
  console.warn(
89
135
  `[resolveSkills] failed to resolve skill ${ref.org}/${ref.slug}: ${err instanceof Error ? err.message : err}`,
@@ -98,24 +144,47 @@ export async function resolveSkills(
98
144
  return results;
99
145
  }
100
146
 
101
- async function writeSkill(
147
+ /**
148
+ * Whether the mount at `skillDir` already holds this version's content.
149
+ *
150
+ * Fresh means: the marker's hash matches AND the mount isn't a degraded
151
+ * SKILL.md-only fallback when the skill does carry an artifact. Any read or
152
+ * parse failure counts as stale — the remount is the safe default.
153
+ */
154
+ async function mountIsFresh(skillDir: string, versionHash: string, wantsArtifact: boolean): Promise<boolean> {
155
+ try {
156
+ const raw = await readFile(join(skillDir, MOUNT_MARKER_FILE), "utf-8");
157
+ const marker = JSON.parse(raw) as Partial<MountMarker>;
158
+ return marker.versionHash === versionHash && (marker.artifactMounted === true || !wantsArtifact);
159
+ } catch {
160
+ return false;
161
+ }
162
+ }
163
+
164
+ /**
165
+ * (Re)write a skill's mount directory from scratch.
166
+ *
167
+ * The directory is removed first so files deleted between versions don't
168
+ * linger in the mount, then SKILL.md and the artifact files are written, and
169
+ * the marker is stamped LAST (see MOUNT_MARKER_FILE for the crash-safety
170
+ * contract). SKILL.md always comes from `spec.skillMd` — the server's
171
+ * authoritative copy — never from the zip; both the zip's SKILL.md and any
172
+ * stray marker-named entry are excluded from extraction so the mount's
173
+ * ownership of those two files is unconditional.
174
+ */
175
+ async function writeSkillMount(
102
176
  skill: Skill,
103
- skillsDir: string,
104
- workspaceDir: string,
105
- artifactBytes?: Uint8Array,
106
- ): Promise<SkillMetadata | null> {
107
- const spec = skill.spec;
108
- if (!spec?.skillMd) return null;
109
-
110
- const name = spec.name || skill.metadata?.slug || "unknown";
111
- const skillDir = join(skillsDir, name);
177
+ skillDir: string,
178
+ artifactBytes: Uint8Array | undefined,
179
+ ): Promise<void> {
180
+ await rm(skillDir, { recursive: true, force: true });
112
181
  await mkdir(skillDir, { recursive: true });
113
182
 
114
- const skillMdPath = join(skillDir, "SKILL.md");
115
- await writeFile(skillMdPath, spec.skillMd, "utf-8");
183
+ await writeFile(join(skillDir, "SKILL.md"), skill.spec!.skillMd, "utf-8");
116
184
 
117
- if (artifactBytes && artifactBytes.length > 0) {
118
- const entries = await extractZipFileEntries(artifactBytes, { exclude: ["SKILL.md"] });
185
+ const artifactMounted = artifactBytes !== undefined && artifactBytes.length > 0;
186
+ if (artifactMounted) {
187
+ const entries = await extractZipFileEntries(artifactBytes, { exclude: ["SKILL.md", MOUNT_MARKER_FILE] });
119
188
  for (const entry of entries) {
120
189
  const filePath = join(skillDir, entry.path);
121
190
  await mkdir(dirname(filePath), { recursive: true });
@@ -123,13 +192,11 @@ async function writeSkill(
123
192
  }
124
193
  }
125
194
 
126
- const relativePath = join(STIGMER_LOCAL_STATE_DIR, SKILLS_SUBDIR, name, "SKILL.md");
127
-
128
- return {
129
- name,
130
- description: spec.description || `Skill: ${name}`,
131
- path: relativePath,
132
- };
195
+ const versionHash = skill.status?.versionHash ?? "";
196
+ if (versionHash !== "") {
197
+ const marker: MountMarker = { versionHash, artifactMounted };
198
+ await writeFile(join(skillDir, MOUNT_MARKER_FILE), JSON.stringify(marker), "utf-8");
199
+ }
133
200
  }
134
201
 
135
202
  /**