@stigmer/runner 3.12.1 → 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 (62) 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/cas-capture-backend.d.ts +3 -1
  14. package/dist/activities/execute-deep-agent/cas-capture-backend.js +3 -1
  15. package/dist/activities/execute-deep-agent/cas-capture-backend.js.map +1 -1
  16. package/dist/activities/execute-deep-agent/setup.js +8 -0
  17. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  18. package/dist/activities/execute-deep-agent/subagent-wiring.js +16 -9
  19. package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
  20. package/dist/activities/generate-session-subject.d.ts +80 -0
  21. package/dist/activities/generate-session-subject.js +283 -0
  22. package/dist/activities/generate-session-subject.js.map +1 -0
  23. package/dist/client/stigmer-client.d.ts +8 -0
  24. package/dist/client/stigmer-client.js +10 -0
  25. package/dist/client/stigmer-client.js.map +1 -1
  26. package/dist/middleware/approval-gate.d.ts +25 -10
  27. package/dist/middleware/approval-gate.js +33 -14
  28. package/dist/middleware/approval-gate.js.map +1 -1
  29. package/dist/middleware/otel-spans.d.ts +2 -1
  30. package/dist/middleware/otel-spans.js +2 -1
  31. package/dist/middleware/otel-spans.js.map +1 -1
  32. package/dist/runner-manager.js +3 -1
  33. package/dist/runner-manager.js.map +1 -1
  34. package/dist/runner.js +3 -1
  35. package/dist/runner.js.map +1 -1
  36. package/dist/shared/attachment-vision.js +9 -0
  37. package/dist/shared/attachment-vision.js.map +1 -1
  38. package/dist/shared/mcp-manager.js +8 -0
  39. package/dist/shared/mcp-manager.js.map +1 -1
  40. package/package.json +2 -2
  41. package/src/activities/__tests__/generate-session-subject.test.ts +348 -0
  42. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +60 -9
  43. package/src/activities/execute-cursor/__tests__/skill-resolver.test.ts +163 -7
  44. package/src/activities/execute-cursor/capture-flow.ts +5 -4
  45. package/src/activities/execute-cursor/cas-observations.ts +10 -4
  46. package/src/activities/execute-cursor/hook-script.ts +39 -13
  47. package/src/activities/execute-cursor/skill-resolver.ts +98 -31
  48. package/src/activities/execute-deep-agent/cas-capture-backend.ts +3 -1
  49. package/src/activities/execute-deep-agent/setup.ts +8 -0
  50. package/src/activities/execute-deep-agent/subagent-wiring.ts +16 -9
  51. package/src/activities/generate-session-subject.ts +370 -0
  52. package/src/client/stigmer-client.ts +11 -0
  53. package/src/middleware/__tests__/approval-gate.test.ts +130 -1
  54. package/src/middleware/approval-gate.ts +58 -24
  55. package/src/middleware/otel-spans.ts +2 -1
  56. package/src/runner-manager.ts +3 -0
  57. package/src/runner.ts +3 -0
  58. package/src/shared/__tests__/attachment-vision.test.ts +4 -0
  59. package/src/shared/__tests__/mcp-manager.test.ts +7 -2
  60. package/src/shared/attachment-vision.ts +9 -0
  61. package/src/shared/filereview/__tests__/capture.test.ts +48 -0
  62. package/src/shared/mcp-manager.ts +8 -0
@@ -0,0 +1,348 @@
1
+ /**
2
+ * Unit tests for the GenerateSessionSubject activity core.
3
+ *
4
+ * The LLM seam (model registry + chat model) is module-mocked; the backend
5
+ * client is injected through SessionSubjectClient, so every skip branch,
6
+ * the agent-resolution chain, and both fallback classes are pinned without
7
+ * Temporal or network coupling. Behavioral contract mirrors the cloud
8
+ * GenerateSessionSubjectActivityImpl (see the activity header).
9
+ */
10
+
11
+ import { describe, it, expect, vi, beforeEach } from "vitest";
12
+ import { ConnectError, Code } from "@connectrpc/connect";
13
+ import type { AgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
14
+ import type { Session } from "@stigmer/protos/ai/stigmer/agentic/session/v1/api_pb";
15
+ import type { Agent } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
16
+ import type { AgentInstance } from "@stigmer/protos/ai/stigmer/agentic/agentinstance/v1/api_pb";
17
+
18
+ vi.mock("../../shared/model-registry.js", () => ({
19
+ getSummarizationModel: vi.fn(async (primary: string) => primary),
20
+ }));
21
+ vi.mock("../../shared/model-client.js", () => ({
22
+ buildChatModel: vi.fn(),
23
+ }));
24
+ vi.mock("../../shared/llm-backend.js", () => ({
25
+ checkDirectCredentials: vi.fn(() => null),
26
+ }));
27
+ vi.mock("../../shared/llm-proxy.js", () => ({
28
+ tryInferProvider: vi.fn(() => "anthropic"),
29
+ }));
30
+
31
+ import {
32
+ AUTO_CREATED_SUBJECT,
33
+ generateSessionSubject,
34
+ resolveAgentId,
35
+ heuristicSubject,
36
+ cleanSubject,
37
+ buildUserPrompt,
38
+ type SessionSubjectClient,
39
+ type GenerateSessionSubjectOptions,
40
+ } from "../generate-session-subject.js";
41
+ import { buildChatModel } from "../../shared/model-client.js";
42
+ import { checkDirectCredentials } from "../../shared/llm-backend.js";
43
+
44
+ // ─────────────────────────────────────────────────────────────────────────────
45
+ // Fixtures
46
+ // ─────────────────────────────────────────────────────────────────────────────
47
+
48
+ const EXECUTION_ID = "aex_test123";
49
+ const SESSION_ID = "ses_test456";
50
+ const AGENT_ID = "agt_test789";
51
+ const INSTANCE_ID = "ain_test012";
52
+
53
+ function fakeExecution(overrides: Record<string, unknown> = {}): AgentExecution {
54
+ return {
55
+ spec: {
56
+ sessionId: SESSION_ID,
57
+ agentId: AGENT_ID,
58
+ message: "Explain how database indexing works for PostgreSQL",
59
+ ...overrides,
60
+ },
61
+ } as unknown as AgentExecution;
62
+ }
63
+
64
+ function fakeSession(subject: string = AUTO_CREATED_SUBJECT, agentInstanceId = ""): Session {
65
+ return {
66
+ spec: { subject, agentInstanceId },
67
+ } as unknown as Session;
68
+ }
69
+
70
+ function fakeAgent(): Agent {
71
+ return {
72
+ metadata: { name: "test-agent" },
73
+ spec: { description: "A helpful test assistant" },
74
+ } as unknown as Agent;
75
+ }
76
+
77
+ function fakeInstance(agentId: string): AgentInstance {
78
+ return { spec: { agentId } } as unknown as AgentInstance;
79
+ }
80
+
81
+ function notFound(): ConnectError {
82
+ return new ConnectError("not found", Code.NotFound);
83
+ }
84
+
85
+ interface ClientBehavior {
86
+ execution?: AgentExecution | Error;
87
+ session?: Session | Error;
88
+ agent?: Agent | Error;
89
+ instance?: AgentInstance | Error;
90
+ updateError?: Error;
91
+ }
92
+
93
+ function fakeClient(behavior: ClientBehavior = {}) {
94
+ const updated: Array<{ sessionId: string; subject: string }> = [];
95
+ const resolve = <T>(value: T | Error | undefined, fallback: T): Promise<T> => {
96
+ if (value instanceof Error) return Promise.reject(value);
97
+ return Promise.resolve(value ?? fallback);
98
+ };
99
+ const client: SessionSubjectClient = {
100
+ getExecution: vi.fn(() => resolve(behavior.execution, fakeExecution())),
101
+ getSession: vi.fn(() => resolve(behavior.session, fakeSession())),
102
+ getAgent: vi.fn(() => resolve(behavior.agent, fakeAgent())),
103
+ getAgentInstance: vi.fn(() => resolve(behavior.instance, fakeInstance(AGENT_ID))),
104
+ updateSessionSubject: vi.fn((sessionId: string, subject: string) => {
105
+ if (behavior.updateError) return Promise.reject(behavior.updateError);
106
+ updated.push({ sessionId, subject });
107
+ return Promise.resolve(fakeSession(subject));
108
+ }),
109
+ };
110
+ return { client, updated };
111
+ }
112
+
113
+ const OPTIONS: GenerateSessionSubjectOptions = {
114
+ proxyEndpoint: null,
115
+ stigmerToken: null,
116
+ primaryModel: "claude-sonnet-4.5",
117
+ };
118
+
119
+ function mockLlmReturning(content: unknown): void {
120
+ vi.mocked(buildChatModel).mockResolvedValue({
121
+ model: { invoke: vi.fn(async () => ({ content })) },
122
+ } as never);
123
+ }
124
+
125
+ beforeEach(() => {
126
+ vi.mocked(buildChatModel).mockReset();
127
+ vi.mocked(checkDirectCredentials).mockReturnValue(null);
128
+ mockLlmReturning("PostgreSQL B-tree Indexing");
129
+ });
130
+
131
+ // ─────────────────────────────────────────────────────────────────────────────
132
+ // Happy path + persistence
133
+ // ─────────────────────────────────────────────────────────────────────────────
134
+
135
+ describe("generateSessionSubject", () => {
136
+ it("replaces the sentinel subject with the LLM title", async () => {
137
+ const { client, updated } = fakeClient();
138
+ await generateSessionSubject(EXECUTION_ID, client, OPTIONS);
139
+ expect(updated).toEqual([{ sessionId: SESSION_ID, subject: "PostgreSQL B-tree Indexing" }]);
140
+ });
141
+
142
+ it("titles a session whose subject is empty (not just the sentinel)", async () => {
143
+ const { client, updated } = fakeClient({ session: fakeSession("") });
144
+ await generateSessionSubject(EXECUTION_ID, client, OPTIONS);
145
+ expect(updated).toHaveLength(1);
146
+ });
147
+
148
+ it("passes executionId into the proxy header scope for billing attribution", async () => {
149
+ const { client } = fakeClient();
150
+ await generateSessionSubject(EXECUTION_ID, client, {
151
+ ...OPTIONS,
152
+ proxyEndpoint: "https://proxy.example",
153
+ stigmerToken: "tok",
154
+ });
155
+ expect(vi.mocked(buildChatModel)).toHaveBeenCalledWith(
156
+ expect.objectContaining({ headerScope: { executionId: EXECUTION_ID } }),
157
+ );
158
+ });
159
+
160
+ it("swallows a persistence failure (non-critical contract)", async () => {
161
+ const { client } = fakeClient({ updateError: new Error("write refused") });
162
+ await expect(generateSessionSubject(EXECUTION_ID, client, OPTIONS)).resolves.toBeUndefined();
163
+ });
164
+
165
+ // ───────────────────────────────────────────────────────────────────────────
166
+ // Skip branches (cloud-parity semantics)
167
+ // ───────────────────────────────────────────────────────────────────────────
168
+
169
+ it("skips when the subject was already set by a human", async () => {
170
+ const { client, updated } = fakeClient({ session: fakeSession("My renamed chat") });
171
+ await generateSessionSubject(EXECUTION_ID, client, OPTIONS);
172
+ expect(updated).toHaveLength(0);
173
+ expect(vi.mocked(buildChatModel)).not.toHaveBeenCalled();
174
+ });
175
+
176
+ it("skips when the execution has no session id", async () => {
177
+ const { client, updated } = fakeClient({ execution: fakeExecution({ sessionId: "" }) });
178
+ await generateSessionSubject(EXECUTION_ID, client, OPTIONS);
179
+ expect(updated).toHaveLength(0);
180
+ });
181
+
182
+ it("skips when the execution has no user message", async () => {
183
+ const { client, updated } = fakeClient({ execution: fakeExecution({ message: "" }) });
184
+ await generateSessionSubject(EXECUTION_ID, client, OPTIONS);
185
+ expect(updated).toHaveLength(0);
186
+ });
187
+
188
+ it.each([
189
+ ["execution", { execution: notFound() }],
190
+ ["session", { session: notFound() }],
191
+ ["agent", { agent: notFound() }],
192
+ ] as const)("skips (does not throw) when the %s is NOT_FOUND", async (_what, behavior) => {
193
+ const { client, updated } = fakeClient(behavior);
194
+ await expect(generateSessionSubject(EXECUTION_ID, client, OPTIONS)).resolves.toBeUndefined();
195
+ expect(updated).toHaveLength(0);
196
+ });
197
+
198
+ it("propagates non-NOT_FOUND lookup failures as activity failures", async () => {
199
+ const { client } = fakeClient({
200
+ execution: new ConnectError("backend down", Code.Unavailable),
201
+ });
202
+ await expect(generateSessionSubject(EXECUTION_ID, client, OPTIONS)).rejects.toThrow(
203
+ "backend down",
204
+ );
205
+ });
206
+
207
+ // ───────────────────────────────────────────────────────────────────────────
208
+ // Fallbacks
209
+ // ───────────────────────────────────────────────────────────────────────────
210
+
211
+ it("falls back to the heuristic title when the LLM call throws", async () => {
212
+ vi.mocked(buildChatModel).mockRejectedValue(new Error("provider 500"));
213
+ const { client, updated } = fakeClient();
214
+ await generateSessionSubject(EXECUTION_ID, client, OPTIONS);
215
+ expect(updated).toEqual([
216
+ { sessionId: SESSION_ID, subject: "Explain how database indexing works for PostgreSQL" },
217
+ ]);
218
+ });
219
+
220
+ it("falls back to the heuristic title when the LLM returns empty content", async () => {
221
+ mockLlmReturning("");
222
+ const { client, updated } = fakeClient();
223
+ await generateSessionSubject(EXECUTION_ID, client, OPTIONS);
224
+ expect(updated[0]?.subject).toBe("Explain how database indexing works for PostgreSQL");
225
+ });
226
+
227
+ it("falls back to the heuristic in direct mode with no credential path", async () => {
228
+ vi.mocked(checkDirectCredentials).mockReturnValue("ANTHROPIC_API_KEY is missing");
229
+ const { client, updated } = fakeClient();
230
+ await generateSessionSubject(EXECUTION_ID, client, OPTIONS);
231
+ expect(vi.mocked(buildChatModel)).not.toHaveBeenCalled();
232
+ expect(updated[0]?.subject).toBe("Explain how database indexing works for PostgreSQL");
233
+ });
234
+
235
+ it("does NOT run the credential pre-check in proxy mode", async () => {
236
+ vi.mocked(checkDirectCredentials).mockReturnValue("ANTHROPIC_API_KEY is missing");
237
+ const { client, updated } = fakeClient();
238
+ await generateSessionSubject(EXECUTION_ID, client, {
239
+ ...OPTIONS,
240
+ proxyEndpoint: "https://proxy.example",
241
+ });
242
+ expect(updated[0]?.subject).toBe("PostgreSQL B-tree Indexing");
243
+ });
244
+
245
+ it("joins array-of-parts LLM content into a single title", async () => {
246
+ mockLlmReturning([{ type: "text", text: "Postgres " }, { type: "text", text: "Index Tuning" }]);
247
+ const { client, updated } = fakeClient();
248
+ await generateSessionSubject(EXECUTION_ID, client, OPTIONS);
249
+ expect(updated[0]?.subject).toBe("Postgres Index Tuning");
250
+ });
251
+ });
252
+
253
+ // ─────────────────────────────────────────────────────────────────────────────
254
+ // Agent resolution (direct + instance chain)
255
+ // ─────────────────────────────────────────────────────────────────────────────
256
+
257
+ describe("resolveAgentId", () => {
258
+ it("prefers the execution's direct agent_id", async () => {
259
+ const { client } = fakeClient();
260
+ const id = await resolveAgentId(fakeExecution(), fakeSession(), client);
261
+ expect(id).toBe(AGENT_ID);
262
+ expect(client.getAgentInstance).not.toHaveBeenCalled();
263
+ });
264
+
265
+ it("resolves through the session's agent-instance chain", async () => {
266
+ const { client } = fakeClient({ instance: fakeInstance("agt_from_instance") });
267
+ const id = await resolveAgentId(
268
+ fakeExecution({ agentId: "" }),
269
+ fakeSession(AUTO_CREATED_SUBJECT, INSTANCE_ID),
270
+ client,
271
+ );
272
+ expect(id).toBe("agt_from_instance");
273
+ expect(client.getAgentInstance).toHaveBeenCalledWith(INSTANCE_ID);
274
+ });
275
+
276
+ it("returns empty when neither a direct id nor an instance exists", async () => {
277
+ const { client } = fakeClient();
278
+ const id = await resolveAgentId(fakeExecution({ agentId: "" }), fakeSession(), client);
279
+ expect(id).toBe("");
280
+ });
281
+
282
+ it("returns empty when the instance row is NOT_FOUND", async () => {
283
+ const { client } = fakeClient({ instance: notFound() });
284
+ const id = await resolveAgentId(
285
+ fakeExecution({ agentId: "" }),
286
+ fakeSession(AUTO_CREATED_SUBJECT, INSTANCE_ID),
287
+ client,
288
+ );
289
+ expect(id).toBe("");
290
+ });
291
+ });
292
+
293
+ // ─────────────────────────────────────────────────────────────────────────────
294
+ // Title shaping helpers (lockstep with the cloud activity)
295
+ // ─────────────────────────────────────────────────────────────────────────────
296
+
297
+ describe("heuristicSubject", () => {
298
+ it("takes at most the first 7 words", () => {
299
+ expect(heuristicSubject("one two three four five six seven eight nine")).toBe(
300
+ "one two three four five six seven",
301
+ );
302
+ });
303
+
304
+ it("keeps short messages whole", () => {
305
+ expect(heuristicSubject(" fix the build ")).toBe("fix the build");
306
+ });
307
+
308
+ it("truncates to 50 chars with an ellipsis", () => {
309
+ const long = "supercalifragilistic expialidocious antidisestablishmentarianism words";
310
+ const subject = heuristicSubject(long);
311
+ expect(subject.length).toBeLessThanOrEqual(50);
312
+ expect(subject.endsWith("...")).toBe(true);
313
+ });
314
+ });
315
+
316
+ describe("cleanSubject", () => {
317
+ it("strips one layer of wrapping double quotes", () => {
318
+ expect(cleanSubject('"Postgres Index Tuning"')).toBe("Postgres Index Tuning");
319
+ });
320
+
321
+ it("strips one layer of wrapping single quotes", () => {
322
+ expect(cleanSubject("'Postgres Index Tuning'")).toBe("Postgres Index Tuning");
323
+ });
324
+
325
+ it("caps at 50 chars with an ellipsis", () => {
326
+ const cleaned = cleanSubject("x".repeat(80));
327
+ expect(cleaned.length).toBe(50);
328
+ expect(cleaned.endsWith("...")).toBe(true);
329
+ });
330
+
331
+ it("trims whitespace inside stripped quotes", () => {
332
+ expect(cleanSubject('" Postgres "')).toBe("Postgres");
333
+ });
334
+ });
335
+
336
+ describe("buildUserPrompt", () => {
337
+ it("includes the message, agent name, and purpose", () => {
338
+ const prompt = buildUserPrompt("fix my build", "builder", "builds things");
339
+ expect(prompt).toContain('User\'s first message:\n"fix my build"');
340
+ expect(prompt).toContain("Agent: builder");
341
+ expect(prompt).toContain("Agent purpose: builds things");
342
+ expect(prompt.endsWith("Generate the title:")).toBe(true);
343
+ });
344
+
345
+ it("omits the purpose line when the description is empty", () => {
346
+ expect(buildUserPrompt("m", "a", "")).not.toContain("Agent purpose:");
347
+ });
348
+ });
@@ -753,11 +753,47 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
753
753
  expect(obs.secretPaths).toEqual([]);
754
754
  });
755
755
 
756
- it("still gates a gitignored DELETE (no CAS capture path, parity with deep-agent)", async () => {
756
+ it("stages a non-secret gitignored DELETE and allows it (issue #303)", async () => {
757
757
  const h = setup({ captureMode: true, captureIgnored: true, gitignored: ["*.log"] });
758
- expect(h.decide(hookDelete("app.log")).permission).toBe("deny");
758
+ writeFileSync(join(h.root, "app.log"), "DOOMED", "utf-8");
759
+ expect(h.decide(hookDelete("app.log")).permission).toBe("allow");
760
+ // Flowed, not denied — reviewed post-hoc as a DELETE entry, not a pause.
761
+ expect(h.ledger()).toEqual([]);
762
+ const obs = await h.observations();
763
+ expect(obs.secretPaths).toEqual([]);
764
+ expect(obs.captured).toHaveLength(1);
765
+ expect(obs.captured[0].path).toBe("app.log");
766
+ // The pre-delete bytes are staged — the restorable "before" side.
767
+ expect(Buffer.from(obs.captured[0].before!).toString("utf8")).toBe("DOOMED");
768
+ });
769
+
770
+ it("first-touch-wins across write-then-delete: the true pre-turn before survives", async () => {
771
+ const h = setup({ captureMode: true, captureIgnored: true, gitignored: ["*.log"] });
772
+ writeFileSync(join(h.root, "app.log"), "ORIGINAL", "utf-8");
773
+ expect(h.decide(hookWrite("app.log", "REWRITTEN")).permission).toBe("allow");
774
+ // Simulate the write having applied, then a delete later this turn.
775
+ writeFileSync(join(h.root, "app.log"), "REWRITTEN", "utf-8");
776
+ expect(h.decide(hookDelete("app.log")).permission).toBe("allow");
777
+ const obs = await h.observations();
778
+ expect(obs.captured).toHaveLength(1);
779
+ expect(Buffer.from(obs.captured[0].before!).toString("utf8")).toBe("ORIGINAL");
780
+ });
781
+
782
+ it("keeps a SECRET-LIKE gitignored delete on the deny-gate: approvable, never staged (issue #303)", async () => {
783
+ // Unlike a secret write (hard-blocked — its content must never surface), a
784
+ // delete's args expose no secret content, so a human may approve it. Its
785
+ // before-bytes must never enter the sidecar though: staging would both
786
+ // persist the secret bytes and mark the path "secret", making the boundary
787
+ // author a blocking DIFF_UNREVIEWABLE for a merely-gated delete.
788
+ const h = setup({ captureMode: true, captureIgnored: true, gitignored: [".env"] });
789
+ writeFileSync(join(h.root, ".env"), "API_KEY=abc", "utf-8");
790
+ const d = h.decide(hookDelete(".env"));
791
+ expect(d.permission).toBe("deny");
792
+ expect(d.raw).toContain("submitted to the user for approval"); // gated, not blocked
793
+ expect(h.ledger().map((e) => e.kind)).toEqual(["approval"]);
759
794
  const obs = await h.observations();
760
795
  expect(obs.captured).toEqual([]);
796
+ expect(obs.secretPaths).toEqual([]); // no secret marker for a gated delete
761
797
  });
762
798
 
763
799
  it("with captureIgnored OFF, a gitignored write stays denied and stages nothing", async () => {
@@ -768,10 +804,10 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
768
804
  });
769
805
  });
770
806
 
771
- // Slice 2c: a NON-git workspace has no git snapshot, so EVERY file write is
772
- // CAS-staged and flowed for review (not only gitignored ones), a delete stays
773
- // gated (no CAS delete-capture path, parity with the deep-agent), and shell/MCP
774
- // gate as always. The workspace is deliberately NOT git-initialized.
807
+ // Slice 2c: a NON-git workspace has no git snapshot, so EVERY file write and
808
+ // (issue #303) every non-secret delete is CAS-staged and flowed for review
809
+ // not only gitignored ones while shell/MCP gate as always. The workspace is
810
+ // deliberately NOT git-initialized.
775
811
  describe("non-git workspace CAS capture (Slice 2c)", () => {
776
812
  it("stages EVERY write (not just gitignored) and allows it, no denial", async () => {
777
813
  const h = setup({ captureMode: true, captureIgnored: true, gitWorkspace: false });
@@ -806,12 +842,27 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
806
842
  expect(obs.secretPaths).toEqual([".env"]);
807
843
  });
808
844
 
809
- it("still gates a DELETE (deny-gate, parity with deep-agent) and stages nothing", async () => {
845
+ it("stages a DELETE with its pre-delete bytes and allows it (issue #303)", async () => {
810
846
  const h = setup({ captureMode: true, captureIgnored: true, gitWorkspace: false });
811
- expect(h.decide(hookDelete("notes.md")).permission).toBe("deny");
812
- expect(h.ledger()).toHaveLength(1);
847
+ writeFileSync(join(h.root, "notes.md"), "KEEP ME", "utf-8");
848
+ expect(h.decide(hookDelete("notes.md")).permission).toBe("allow");
849
+ expect(h.ledger()).toEqual([]);
850
+ const obs = await h.observations();
851
+ expect(obs.captured).toHaveLength(1);
852
+ expect(obs.captured[0].path).toBe("notes.md");
853
+ expect(Buffer.from(obs.captured[0].before!).toString("utf8")).toBe("KEEP ME");
854
+ });
855
+
856
+ it("keeps a secret-like DELETE gated (approvable) and stages nothing", async () => {
857
+ const h = setup({ captureMode: true, captureIgnored: true, gitWorkspace: false });
858
+ writeFileSync(join(h.root, ".env"), "API_KEY=abc", "utf-8");
859
+ const d = h.decide(hookDelete(".env"));
860
+ expect(d.permission).toBe("deny");
861
+ expect(d.raw).toContain("submitted to the user for approval");
862
+ expect(h.ledger().map((e) => e.kind)).toEqual(["approval"]);
813
863
  const obs = await h.observations();
814
864
  expect(obs.captured).toEqual([]);
865
+ expect(obs.secretPaths).toEqual([]);
815
866
  });
816
867
 
817
868
  it("still gates shell (never git-reversible, never CAS-captured)", async () => {
@@ -1,5 +1,5 @@
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";
@@ -17,6 +17,7 @@ function makeSkillProto(overrides: {
17
17
  description?: string;
18
18
  skillMd?: string;
19
19
  artifactStorageKey?: string;
20
+ versionHash?: string;
20
21
  } = {}) {
21
22
  return {
22
23
  metadata: {
@@ -31,7 +32,7 @@ function makeSkillProto(overrides: {
31
32
  },
32
33
  status: {
33
34
  artifactStorageKey: overrides.artifactStorageKey ?? "",
34
- versionHash: "abc",
35
+ versionHash: overrides.versionHash ?? "abc",
35
36
  },
36
37
  } as any;
37
38
  }
@@ -75,12 +76,14 @@ describe("resolveSkills — artifact extraction", () => {
75
76
  ...clientOverrides,
76
77
  } as any;
77
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
+
78
84
  try {
79
- const result = await resolveSkills(client, refs, {
80
- sessionId,
81
- primaryWorkspaceDir: workspaceDir,
82
- });
83
- return { result, platformDir, client };
85
+ const result = await resolveAgain();
86
+ return { result, platformDir, client, resolveAgain };
84
87
  } catch (err) {
85
88
  // Clean up on failure
86
89
  rmSync(platformDir, { recursive: true, force: true });
@@ -189,6 +192,159 @@ describe("resolveSkills — artifact extraction", () => {
189
192
  }
190
193
  });
191
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
+
192
348
  it("SKILL.md from spec takes precedence over ZIP copy", async () => {
193
349
  const specContent = "# Authoritative SKILL.md from spec";
194
350
  const zipContent = "# Stale SKILL.md from ZIP";
@@ -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