@stigmer/runner 3.1.4 → 3.1.6

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 (74) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/attachment-resolver.js +1 -1
  3. package/dist/activities/execute-cursor/attachment-resolver.js.map +1 -1
  4. package/dist/activities/execute-cursor/index.js +40 -2
  5. package/dist/activities/execute-cursor/index.js.map +1 -1
  6. package/dist/activities/execute-cursor/skill-resolver.js +1 -1
  7. package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
  8. package/dist/activities/execute-cursor/workspace-provision.d.ts +23 -4
  9. package/dist/activities/execute-cursor/workspace-provision.js +17 -6
  10. package/dist/activities/execute-cursor/workspace-provision.js.map +1 -1
  11. package/dist/activities/execute-deep-agent/index.js +39 -6
  12. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  13. package/dist/activities/execute-deep-agent/inline-publisher.d.ts +1 -1
  14. package/dist/activities/execute-deep-agent/post-stream.d.ts +1 -1
  15. package/dist/activities/execute-deep-agent/streaming-side-effects.d.ts +1 -1
  16. package/dist/activities/execute-deep-agent/streaming-terminal.d.ts +1 -1
  17. package/dist/activities/execute-deep-agent/streaming.d.ts +1 -1
  18. package/dist/activities/execute-deep-agent/v3-status-builder.d.ts +1 -1
  19. package/dist/shared/execution-status-writer.d.ts +31 -0
  20. package/dist/shared/execution-status-writer.js +43 -0
  21. package/dist/shared/execution-status-writer.js.map +1 -0
  22. package/dist/shared/filereview/git-substrate.js +4 -0
  23. package/dist/shared/filereview/git-substrate.js.map +1 -1
  24. package/dist/shared/workspace/git-identity.d.ts +36 -0
  25. package/dist/shared/workspace/git-identity.js +39 -0
  26. package/dist/shared/workspace/git-identity.js.map +1 -0
  27. package/dist/shared/workspace/local-backend.d.ts +6 -2
  28. package/dist/shared/workspace/local-backend.js +6 -2
  29. package/dist/shared/workspace/local-backend.js.map +1 -1
  30. package/dist/shared/workspace/stigmer-link.d.ts +54 -0
  31. package/dist/shared/workspace/stigmer-link.js +92 -0
  32. package/dist/shared/workspace/stigmer-link.js.map +1 -0
  33. package/dist/shared/workspace/types.d.ts +6 -4
  34. package/dist/shared/workspace/writeback-coordinator.d.ts +120 -0
  35. package/dist/shared/workspace/writeback-coordinator.js +393 -0
  36. package/dist/shared/workspace/writeback-coordinator.js.map +1 -0
  37. package/package.json +2 -2
  38. package/src/activities/execute-cursor/__tests__/workspace-provision.test.ts +23 -15
  39. package/src/activities/execute-cursor/attachment-resolver.ts +1 -1
  40. package/src/activities/execute-cursor/index.ts +42 -2
  41. package/src/activities/execute-cursor/skill-resolver.ts +1 -1
  42. package/src/activities/execute-cursor/workspace-provision.ts +39 -7
  43. package/src/activities/execute-deep-agent/__tests__/post-stream.test.ts +1 -1
  44. package/src/activities/execute-deep-agent/index.ts +44 -6
  45. package/src/activities/execute-deep-agent/inline-publisher.ts +1 -1
  46. package/src/activities/execute-deep-agent/post-stream.ts +1 -1
  47. package/src/activities/execute-deep-agent/status-builder.ts +1 -1
  48. package/src/activities/execute-deep-agent/streaming-side-effects.ts +1 -1
  49. package/src/activities/execute-deep-agent/streaming-terminal.ts +1 -1
  50. package/src/activities/execute-deep-agent/streaming.ts +1 -1
  51. package/src/activities/execute-deep-agent/v3-status-builder.ts +1 -1
  52. package/src/shared/execution-status-writer.ts +56 -0
  53. package/src/shared/filereview/git-substrate.ts +4 -0
  54. package/src/shared/workspace/__tests__/git-identity.test.ts +124 -0
  55. package/src/shared/workspace/__tests__/stigmer-link.test.ts +173 -0
  56. package/src/shared/workspace/__tests__/writeback-coordinator.integration.test.ts +230 -0
  57. package/src/shared/workspace/__tests__/writeback-coordinator.test.ts +477 -0
  58. package/src/shared/workspace/git-identity.ts +41 -0
  59. package/src/shared/workspace/local-backend.ts +6 -2
  60. package/src/shared/workspace/stigmer-link.ts +97 -0
  61. package/src/shared/workspace/types.ts +6 -4
  62. package/src/{activities/execute-deep-agent → shared/workspace}/writeback-coordinator.ts +209 -93
  63. package/dist/activities/execute-cursor/stigmer-link.d.ts +0 -35
  64. package/dist/activities/execute-cursor/stigmer-link.js +0 -73
  65. package/dist/activities/execute-cursor/stigmer-link.js.map +0 -1
  66. package/dist/activities/execute-deep-agent/execution-status-writer.d.ts +0 -17
  67. package/dist/activities/execute-deep-agent/execution-status-writer.js +0 -9
  68. package/dist/activities/execute-deep-agent/execution-status-writer.js.map +0 -1
  69. package/dist/activities/execute-deep-agent/writeback-coordinator.d.ts +0 -71
  70. package/dist/activities/execute-deep-agent/writeback-coordinator.js +0 -295
  71. package/dist/activities/execute-deep-agent/writeback-coordinator.js.map +0 -1
  72. package/src/activities/execute-cursor/stigmer-link.ts +0 -78
  73. package/src/activities/execute-deep-agent/__tests__/writeback-coordinator.test.ts +0 -399
  74. package/src/activities/execute-deep-agent/execution-status-writer.ts +0 -19
@@ -0,0 +1,477 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { create } from "@bufbuild/protobuf";
3
+ import { AgentExecutionStatusSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
4
+ import { WorkspaceWriteBackPhase } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/writeback_pb";
5
+ import { GitWriteBackMode } from "@stigmer/protos/ai/stigmer/agentic/session/v1/enum_pb";
6
+ import {
7
+ statusProtoWriter,
8
+ type ExecutionStatusWriter,
9
+ } from "../../execution-status-writer.js";
10
+ import {
11
+ WriteBackCoordinator,
12
+ parseGithubRepo,
13
+ } from "../writeback-coordinator.js";
14
+ import type { WorkspaceBackend, ProvisionResult } from "../types.js";
15
+ import { SourceType } from "../types.js";
16
+ import {
17
+ AGENT_GIT_AUTHOR_NAME,
18
+ AGENT_GIT_AUTHOR_EMAIL,
19
+ } from "../git-identity.js";
20
+
21
+ const SESSION_ID = "ses-01test";
22
+ const SESSION_BRANCH = `stigmer/${SESSION_ID}`;
23
+
24
+ // The shared proto-backed writer — the same implementation the Cursor harness
25
+ // wires in, and upsert-compatible with the deep-agent StatusBuilder.
26
+ function makeStatusBuilder(): ExecutionStatusWriter {
27
+ return statusProtoWriter(create(AgentExecutionStatusSchema, {}));
28
+ }
29
+
30
+ function makeProvisionResult(overrides: Partial<ProvisionResult> = {}): ProvisionResult {
31
+ return {
32
+ rootDir: "/workspace/my-app",
33
+ sourceType: SourceType.GIT_REPO,
34
+ consumedKeys: [],
35
+ workspaceDescription: "test",
36
+ entryName: "my-app",
37
+ gitMetadata: {
38
+ // Token-stripped, as extractGitMetadata always records it.
39
+ repoUrl: "https://github.com/acme/my-app.git",
40
+ branch: "main",
41
+ baseCommit: "abc123",
42
+ gitCredentialsConfigured: true,
43
+ },
44
+ ...overrides,
45
+ };
46
+ }
47
+
48
+ function makeWorkspaceEntry(name: string, writeBackMode: GitWriteBackMode = GitWriteBackMode.GIT_WRITE_BACK_BRANCH_AND_PR) {
49
+ return {
50
+ name,
51
+ source: {
52
+ source: {
53
+ case: "gitRepo" as const,
54
+ value: { writeBackMode },
55
+ },
56
+ },
57
+ $typeName: "ai.stigmer.agentic.session.v1.WorkspaceEntry" as const,
58
+ } as any;
59
+ }
60
+
61
+ function mockWorkspaceBackend(responses: Record<string, string> = {}): WorkspaceBackend {
62
+ const defaultResponses: Record<string, string> = {
63
+ "git diff --stat": " 1 file changed, 1 insertion(+)",
64
+ "git diff --cached --stat": "",
65
+ "git ls-files --others --exclude-standard": "",
66
+ // Fresh clone on the base branch: the session branch exists nowhere yet.
67
+ "git branch --show-current": "main",
68
+ "git rev-parse --verify --quiet": "",
69
+ "git ls-remote --heads origin": "",
70
+ "git checkout": "",
71
+ "git add -A": "",
72
+ "commit -m": "",
73
+ "git rev-parse HEAD": "abc123def456",
74
+ "git push": "",
75
+ "git diff --stat main...HEAD": " 1 file changed",
76
+ ...responses,
77
+ };
78
+
79
+ return {
80
+ rootDir: "/workspace",
81
+ execute: vi.fn(async (cmd: string) => {
82
+ for (const [pattern, response] of Object.entries(defaultResponses)) {
83
+ if (cmd.includes(pattern)) return response;
84
+ }
85
+ return "";
86
+ }),
87
+ readFile: vi.fn(),
88
+ writeFile: vi.fn(),
89
+ writeFileBuffer: vi.fn(),
90
+ exists: vi.fn(),
91
+ };
92
+ }
93
+
94
+ function makeCoordinator(opts: {
95
+ sb: ExecutionStatusWriter;
96
+ backend?: WorkspaceBackend;
97
+ executionId?: string;
98
+ sessionId?: string;
99
+ githubToken?: string;
100
+ provisionResults?: ProvisionResult[];
101
+ workspaceEntries?: any[];
102
+ }): WriteBackCoordinator {
103
+ return new WriteBackCoordinator({
104
+ statusWriter: opts.sb,
105
+ executionId: opts.executionId ?? "exec-12345678rest",
106
+ sessionId: opts.sessionId ?? SESSION_ID,
107
+ githubToken: opts.githubToken ?? "ghp_plumbed_token",
108
+ provisionResults: opts.provisionResults ?? [makeProvisionResult()],
109
+ workspaceEntries: opts.workspaceEntries ?? [makeWorkspaceEntry("my-app")],
110
+ workspaceBackend: opts.backend ?? mockWorkspaceBackend(),
111
+ });
112
+ }
113
+
114
+ /**
115
+ * URL-aware GitHub API mock: listing open PRs for the head branch returns
116
+ * `openPrs`; creating a PR returns `created`. Records calls for assertions.
117
+ */
118
+ function mockGithubApi(opts: {
119
+ openPrs?: Array<{ html_url: string; number: number }>;
120
+ created?: { html_url: string; number: number };
121
+ createStatus?: number;
122
+ } = {}) {
123
+ const calls: Array<{ url: string; method: string; headers: Record<string, string> }> = [];
124
+ globalThis.fetch = vi.fn(async (url: any, init?: any) => {
125
+ const method = init?.method ?? "GET";
126
+ calls.push({ url: String(url), method, headers: init?.headers ?? {} });
127
+ if (method === "GET") {
128
+ return new Response(JSON.stringify(opts.openPrs ?? []), { status: 200 });
129
+ }
130
+ const status = opts.createStatus ?? 201;
131
+ const body = status < 300
132
+ ? JSON.stringify(opts.created ?? { html_url: "https://github.com/acme/my-app/pull/42", number: 42 })
133
+ : JSON.stringify({ message: "Forbidden" });
134
+ return new Response(body, { status });
135
+ }) as typeof fetch;
136
+ return calls;
137
+ }
138
+
139
+ const originalFetch = globalThis.fetch;
140
+
141
+ describe("WriteBackCoordinator", () => {
142
+ let sb: ExecutionStatusWriter;
143
+
144
+ beforeEach(() => {
145
+ sb = makeStatusBuilder();
146
+ mockGithubApi();
147
+ });
148
+
149
+ afterEach(() => {
150
+ globalThis.fetch = originalFetch;
151
+ });
152
+
153
+ // ── Eligibility ─────────────────────────────────────────────────────
154
+
155
+ it("filters out non-git workspace entries", () => {
156
+ const coord = makeCoordinator({
157
+ sb,
158
+ provisionResults: [makeProvisionResult({ sourceType: SourceType.LOCAL_PATH })],
159
+ });
160
+ expect(coord.hasEligibleEntries).toBe(false);
161
+ });
162
+
163
+ it("filters out entries without git credentials", () => {
164
+ const coord = makeCoordinator({
165
+ sb,
166
+ provisionResults: [makeProvisionResult({
167
+ gitMetadata: {
168
+ repoUrl: "https://github.com/acme/my-app.git",
169
+ branch: "main",
170
+ baseCommit: "abc",
171
+ gitCredentialsConfigured: false,
172
+ },
173
+ })],
174
+ });
175
+ expect(coord.hasEligibleEntries).toBe(false);
176
+ });
177
+
178
+ it("accepts entries with UNSPECIFIED write-back mode (platform decides)", () => {
179
+ const coord = makeCoordinator({
180
+ sb,
181
+ workspaceEntries: [makeWorkspaceEntry("my-app", GitWriteBackMode.GIT_WRITE_BACK_MODE_UNSPECIFIED)],
182
+ });
183
+ expect(coord.hasEligibleEntries).toBe(true);
184
+ });
185
+
186
+ // ── Full cycle ──────────────────────────────────────────────────────
187
+
188
+ it("performs full cycle on the SESSION branch: branch -> commit -> push -> PR", async () => {
189
+ const backend = mockWorkspaceBackend();
190
+ const coord = makeCoordinator({ sb, backend });
191
+
192
+ await coord.onFileModified("src/main.ts");
193
+
194
+ const wbs = sb.currentStatus.workspaceWriteBacks;
195
+ expect(wbs).toHaveLength(1);
196
+ expect(wbs[0].branchName).toBe(SESSION_BRANCH);
197
+ expect(wbs[0].baseBranch).toBe("main");
198
+ expect(wbs[0].commitSha).toBe("abc123def456");
199
+ expect(wbs[0].pullRequestUrl).toBe("https://github.com/acme/my-app/pull/42");
200
+ expect(wbs[0].pullRequestNumber).toBe(42);
201
+ expect(wbs[0].phase).toBe(WorkspaceWriteBackPhase.WORKSPACE_WRITE_BACK_PR_CREATED);
202
+ expect(wbs[0].error).toBe("");
203
+
204
+ const commands = (backend.execute as ReturnType<typeof vi.fn>).mock.calls
205
+ .map((c: any) => c[0] as string);
206
+ expect(commands.some((c: string) => c.includes(`git checkout -b ${SESSION_BRANCH}`))).toBe(true);
207
+ expect(commands.some((c: string) => c.includes("git add -A"))).toBe(true);
208
+ expect(commands.some((c: string) => c.includes("commit -m"))).toBe(true);
209
+ expect(commands.some((c: string) => c.includes(`git push -u origin ${SESSION_BRANCH}`))).toBe(true);
210
+ });
211
+
212
+ it("commits with the agent identity pinned via -c flags and the execution id in the message", async () => {
213
+ const backend = mockWorkspaceBackend();
214
+ const coord = makeCoordinator({ sb, backend, executionId: "exec-12345678rest" });
215
+
216
+ await coord.onFileModified("src/main.ts");
217
+
218
+ const commands = (backend.execute as ReturnType<typeof vi.fn>).mock.calls
219
+ .map((c: any) => c[0] as string);
220
+ const commitCommand = commands.find((c: string) => c.includes("git") && c.includes("commit -m"));
221
+ expect(commitCommand,
222
+ "commit must not depend on ambient git identity — the cloud sandbox has none",
223
+ ).toBeDefined();
224
+ expect(commitCommand).toContain(`-c user.name='${AGENT_GIT_AUTHOR_NAME}'`);
225
+ expect(commitCommand).toContain(`-c user.email='${AGENT_GIT_AUTHOR_EMAIL}'`);
226
+ expect(commitCommand).toContain('commit -m "agent changes (exec-12345678rest)"');
227
+ });
228
+
229
+ it("skips when there are no changes", async () => {
230
+ const backend = mockWorkspaceBackend({
231
+ "git diff --stat": "",
232
+ "git ls-files --others --exclude-standard": "",
233
+ });
234
+ const coord = makeCoordinator({ sb, backend });
235
+
236
+ await coord.onFileModified("src/main.ts");
237
+
238
+ expect(sb.currentStatus.workspaceWriteBacks).toHaveLength(0);
239
+ });
240
+
241
+ // ── Session-branch idempotency ──────────────────────────────────────
242
+
243
+ it("on second call, commits to the existing branch without re-creating", async () => {
244
+ const backend = mockWorkspaceBackend();
245
+ const coord = makeCoordinator({ sb, backend });
246
+
247
+ await coord.onFileModified("first.ts");
248
+ await coord.onFileModified("second.ts");
249
+
250
+ const commands = (backend.execute as ReturnType<typeof vi.fn>).mock.calls
251
+ .map((c: any) => c[0] as string);
252
+ const checkoutCalls = commands.filter((c: string) => c.includes("git checkout -b"));
253
+ expect(checkoutCalls).toHaveLength(1);
254
+
255
+ const pushCalls = commands.filter((c: string) => c.includes("git push"));
256
+ expect(pushCalls.length).toBeGreaterThanOrEqual(2);
257
+ });
258
+
259
+ it("a later execution reuses the session branch HEAD already sits on (no checkout)", async () => {
260
+ // Turn 2 of the same session: the previous cycle left HEAD on the branch.
261
+ const backend = mockWorkspaceBackend({
262
+ "git branch --show-current": SESSION_BRANCH,
263
+ });
264
+ const coord = makeCoordinator({ sb, backend, executionId: "exec-turn2" });
265
+
266
+ await coord.onFileModified("src/more.ts");
267
+
268
+ const commands = (backend.execute as ReturnType<typeof vi.fn>).mock.calls
269
+ .map((c: any) => c[0] as string);
270
+ expect(commands.some((c: string) => c.includes("git checkout"))).toBe(false);
271
+ expect(commands.some((c: string) => c.includes(`git push -u origin ${SESSION_BRANCH}`))).toBe(true);
272
+ });
273
+
274
+ it("checks out the existing local session branch instead of creating it", async () => {
275
+ const backend = mockWorkspaceBackend({
276
+ "git branch --show-current": "main",
277
+ "git rev-parse --verify --quiet": "deadbeef",
278
+ });
279
+ const coord = makeCoordinator({ sb, backend });
280
+
281
+ await coord.onFileModified("src/main.ts");
282
+
283
+ const commands = (backend.execute as ReturnType<typeof vi.fn>).mock.calls
284
+ .map((c: any) => c[0] as string);
285
+ expect(commands.some((c: string) =>
286
+ c.includes(`git checkout ${SESSION_BRANCH}`) && !c.includes("-b"),
287
+ )).toBe(true);
288
+ expect(commands.some((c: string) => c.includes("git checkout -b"))).toBe(false);
289
+ });
290
+
291
+ it("after a re-provision, fetches and tracks the remote session branch", async () => {
292
+ const backend = mockWorkspaceBackend({
293
+ "git branch --show-current": "main",
294
+ "git rev-parse --verify --quiet": "",
295
+ "git ls-remote --heads origin": `deadbeef\trefs/heads/${SESSION_BRANCH}`,
296
+ });
297
+ const coord = makeCoordinator({ sb, backend });
298
+
299
+ await coord.onFileModified("src/main.ts");
300
+
301
+ const commands = (backend.execute as ReturnType<typeof vi.fn>).mock.calls
302
+ .map((c: any) => c[0] as string);
303
+ expect(commands.some((c: string) => c.includes(`git fetch origin ${SESSION_BRANCH}`))).toBe(true);
304
+ expect(commands.some((c: string) =>
305
+ c.includes(`git checkout -b ${SESSION_BRANCH} origin/${SESSION_BRANCH}`),
306
+ )).toBe(true);
307
+ });
308
+
309
+ it("adopts an already-open PR for the session branch instead of creating a duplicate", async () => {
310
+ const calls = mockGithubApi({
311
+ openPrs: [{ html_url: "https://github.com/acme/my-app/pull/7", number: 7 }],
312
+ });
313
+ const coord = makeCoordinator({ sb });
314
+
315
+ await coord.onFileModified("src/main.ts");
316
+
317
+ const wbs = sb.currentStatus.workspaceWriteBacks;
318
+ expect(wbs[0].pullRequestNumber).toBe(7);
319
+ expect(wbs[0].pullRequestUrl).toBe("https://github.com/acme/my-app/pull/7");
320
+ expect(wbs[0].phase).toBe(WorkspaceWriteBackPhase.WORKSPACE_WRITE_BACK_PR_CREATED);
321
+ expect(calls.filter((c) => c.method === "POST")).toHaveLength(0);
322
+ const listCall = calls.find((c) => c.method === "GET");
323
+ expect(listCall?.url).toContain("state=open");
324
+ expect(listCall?.url).toContain(encodeURIComponent(`acme:${SESSION_BRANCH}`));
325
+ });
326
+
327
+ it("uses the plumbed token for the GitHub API (never the repo URL or process.env)", async () => {
328
+ const calls = mockGithubApi();
329
+ const coord = makeCoordinator({ sb, githubToken: "ghp_plumbed_token" });
330
+
331
+ await coord.onFileModified("src/main.ts");
332
+
333
+ expect(calls.length).toBeGreaterThan(0);
334
+ for (const call of calls) {
335
+ expect(call.headers["Authorization"]).toBe("Bearer ghp_plumbed_token");
336
+ }
337
+ });
338
+
339
+ // ── Failure semantics ───────────────────────────────────────────────
340
+
341
+ it("sets FAILED phase on a commit/push error", async () => {
342
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
343
+ const backend = mockWorkspaceBackend();
344
+ (backend.execute as ReturnType<typeof vi.fn>).mockImplementation(async (cmd: string) => {
345
+ if (cmd.includes("git diff --stat") && !cmd.includes("...HEAD")) return " changed";
346
+ if (cmd.includes("git diff --cached")) return "";
347
+ if (cmd.includes("git branch --show-current")) return "main";
348
+ if (cmd.includes("commit -m")) throw new Error("commit failed: lock");
349
+ return "";
350
+ });
351
+ const coord = makeCoordinator({ sb, backend });
352
+
353
+ await coord.onFileModified("src/main.ts");
354
+
355
+ const wbs = sb.currentStatus.workspaceWriteBacks;
356
+ expect(wbs).toHaveLength(1);
357
+ expect(wbs[0].phase).toBe(WorkspaceWriteBackPhase.WORKSPACE_WRITE_BACK_FAILED);
358
+ expect(wbs[0].error).toContain("commit failed");
359
+ warnSpy.mockRestore();
360
+ });
361
+
362
+ it("reports PUSHED with the PR error when PR creation fails after a successful push", async () => {
363
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
364
+ mockGithubApi({ createStatus: 403 });
365
+ const coord = makeCoordinator({ sb });
366
+
367
+ await coord.onFileModified("src/main.ts");
368
+
369
+ const wbs = sb.currentStatus.workspaceWriteBacks;
370
+ expect(wbs).toHaveLength(1);
371
+ expect(wbs[0].phase,
372
+ "a pushed branch must never be reported FAILED for a PR-step error",
373
+ ).toBe(WorkspaceWriteBackPhase.WORKSPACE_WRITE_BACK_PUSHED);
374
+ expect(wbs[0].branchName).toBe(SESSION_BRANCH);
375
+ expect(wbs[0].commitSha).toBe("abc123def456");
376
+ expect(wbs[0].error).toContain("GitHub API error");
377
+ warnSpy.mockRestore();
378
+ });
379
+
380
+ it("reports PUSHED with an actionable error when no GitHub token is available", async () => {
381
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
382
+ const coord = makeCoordinator({ sb, githubToken: "" });
383
+
384
+ await coord.onFileModified("src/main.ts");
385
+
386
+ const wbs = sb.currentStatus.workspaceWriteBacks;
387
+ expect(wbs).toHaveLength(1);
388
+ expect(wbs[0].phase).toBe(WorkspaceWriteBackPhase.WORKSPACE_WRITE_BACK_PUSHED);
389
+ expect(wbs[0].error).toContain("No GitHub token");
390
+ expect(globalThis.fetch).not.toHaveBeenCalled();
391
+ warnSpy.mockRestore();
392
+ });
393
+
394
+ // ── finalize / path resolution ──────────────────────────────────────
395
+
396
+ it("finalize catches remaining uncommitted changes", async () => {
397
+ const coord = makeCoordinator({ sb });
398
+
399
+ await coord.finalize();
400
+
401
+ expect(sb.currentStatus.workspaceWriteBacks).toHaveLength(1);
402
+ expect(sb.currentStatus.workspaceWriteBacks[0].phase).toBe(
403
+ WorkspaceWriteBackPhase.WORKSPACE_WRITE_BACK_PR_CREATED,
404
+ );
405
+ });
406
+
407
+ it("resolves path to single entry without prefix matching", async () => {
408
+ const coord = makeCoordinator({ sb });
409
+
410
+ await coord.onFileModified("any/path/file.ts");
411
+
412
+ expect(sb.currentStatus.workspaceWriteBacks).toHaveLength(1);
413
+ });
414
+
415
+ it("resolves path to correct entry in multi-entry workspace", async () => {
416
+ const coord = makeCoordinator({
417
+ sb,
418
+ provisionResults: [
419
+ makeProvisionResult({ entryName: "frontend", rootDir: "/workspace/frontend" }),
420
+ makeProvisionResult({ entryName: "backend", rootDir: "/workspace/backend" }),
421
+ ],
422
+ workspaceEntries: [
423
+ makeWorkspaceEntry("frontend"),
424
+ makeWorkspaceEntry("backend"),
425
+ ],
426
+ });
427
+
428
+ await coord.onFileModified("frontend/src/app.tsx");
429
+
430
+ const wbs = sb.currentStatus.workspaceWriteBacks;
431
+ expect(wbs).toHaveLength(1);
432
+ expect(wbs[0].workspaceEntryName).toBe("frontend");
433
+ });
434
+
435
+ it("ignores paths outside eligible entries in multi-entry mode", async () => {
436
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
437
+ const coord = makeCoordinator({
438
+ sb,
439
+ provisionResults: [
440
+ makeProvisionResult({ entryName: "fe", rootDir: "/workspace/fe" }),
441
+ makeProvisionResult({ entryName: "be", rootDir: "/workspace/be" }),
442
+ ],
443
+ workspaceEntries: [makeWorkspaceEntry("fe"), makeWorkspaceEntry("be")],
444
+ });
445
+
446
+ await coord.onFileModified("unknown/file.ts");
447
+ expect(sb.currentStatus.workspaceWriteBacks).toHaveLength(0);
448
+ warnSpy.mockRestore();
449
+ });
450
+ });
451
+
452
+ describe("parseGithubRepo", () => {
453
+ it("parses HTTPS URL with .git suffix", () => {
454
+ const result = parseGithubRepo("https://github.com/acme/my-app.git");
455
+ expect(result).toEqual({ owner: "acme", repo: "my-app" });
456
+ });
457
+
458
+ it("parses HTTPS URL without .git suffix", () => {
459
+ const result = parseGithubRepo("https://github.com/acme/my-app");
460
+ expect(result).toEqual({ owner: "acme", repo: "my-app" });
461
+ });
462
+
463
+ it("parses HTTPS URL with token", () => {
464
+ const result = parseGithubRepo("https://ghp_token@github.com/acme/my-app.git");
465
+ expect(result).toEqual({ owner: "acme", repo: "my-app" });
466
+ });
467
+
468
+ it("parses SSH URL", () => {
469
+ const result = parseGithubRepo("git@github.com:acme/my-app.git");
470
+ expect(result).toEqual({ owner: "acme", repo: "my-app" });
471
+ });
472
+
473
+ it("throws for non-GitHub URLs", () => {
474
+ expect(() => parseGithubRepo("https://gitlab.com/acme/my-app.git"))
475
+ .toThrow("Cannot parse GitHub owner/repo");
476
+ });
477
+ });
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The public git identity for commits the agent authors on the user's behalf.
3
+ *
4
+ * Write-back commits (branch/commit/push/PR — see WriteBackCoordinator) land
5
+ * on real branches in the user's repository, so they carry a dedicated,
6
+ * recognizable agent identity in every mode — local and cloud alike. This is
7
+ * deliberate: a commit authored by the agent should never be silently
8
+ * attributed to whatever identity happens to live in the host's `~/.gitconfig`
9
+ * (local), and the cloud sandbox has no identity at all, which makes a bare
10
+ * `git commit` fail with "Author identity unknown".
11
+ *
12
+ * Distinct from the *internal* snapshot identity in
13
+ * `../filereview/git-substrate.ts` (`stigmer-runner <runner@stigmer.local>`):
14
+ * those commit objects are never on a branch and never pushed — they exist
15
+ * only to pin trees against GC. This identity is the public-facing one that
16
+ * appears in `git log`, on GitHub, and in pull requests.
17
+ */
18
+ export const AGENT_GIT_AUTHOR_NAME = "Stigmer Agent";
19
+ export const AGENT_GIT_AUTHOR_EMAIL = "noreply@stigmer.ai";
20
+
21
+ /**
22
+ * `git -c` config flags that pin the agent identity for a single command.
23
+ *
24
+ * Per-command `-c` flags (rather than repo-local or global `git config`) keep
25
+ * the identity deterministic in every environment without mutating the user's
26
+ * repository config — the same never-mutate reasoning documented on
27
+ * `SNAPSHOT_IDENTITY_ENV` in `git-substrate.ts`. Env-var injection is not an
28
+ * option here: `WorkspaceBackend.execute()` takes no env parameter, and
29
+ * widening that contract for one caller would be a larger change than the
30
+ * problem deserves.
31
+ */
32
+ export const AGENT_GIT_IDENTITY_FLAGS =
33
+ `-c user.name='${AGENT_GIT_AUTHOR_NAME}' -c user.email='${AGENT_GIT_AUTHOR_EMAIL}'`;
34
+
35
+ /**
36
+ * Build a `git commit` command that is always authored by the agent identity,
37
+ * regardless of what identity (if any) the surrounding environment provides.
38
+ */
39
+ export function gitCommitAsAgent(commitMsg: string): string {
40
+ return `git ${AGENT_GIT_IDENTITY_FLAGS} commit -m "${commitMsg}"`;
41
+ }
@@ -6,8 +6,12 @@
6
6
  * sandbox) backend will be added in Phase 3.
7
7
  *
8
8
  * When `platformDir` is provided, paths under `.stigmer/` are
9
- * transparently routed to the platform directory. The agent sees a
10
- * unified namespace; the workspace directory is never polluted.
9
+ * transparently routed to the platform directory, keeping platform files
10
+ * out of the workspace tree. This routing serves the RUNNER's own I/O
11
+ * (skill/attachment materialization, `execute` command rewriting) — the
12
+ * agent's file tools do not go through this backend. Agent-visible reads
13
+ * of `.stigmer/…` reach the same physical files through the per-turn
14
+ * workspace symlink instead (see shared/workspace/stigmer-link.ts).
11
15
  */
12
16
 
13
17
  import { execFile } from "node:child_process";
@@ -0,0 +1,97 @@
1
+ /**
2
+ * The workspace `.stigmer` symlink — the bridge from the session's workspace
3
+ * to its platform-managed directory (~/.stigmer/sessions/{id}/platform/, see
4
+ * platform-dir.ts), shared by BOTH harnesses.
5
+ *
6
+ * Platform-injected content (skills, attachment inputs, the approved plan)
7
+ * physically lives in the platform dir, outside the workspace, so a real repo
8
+ * is never polluted with Stigmer files (issue #173). But the agent reads from
9
+ * the workspace: the Cursor SDK resolves paths against the workspace CWD, and
10
+ * the native (deepagents) harness's file tools resolve against a
11
+ * `FilesystemBackend` rooted at the workspace. The symlink is what makes the
12
+ * `.stigmer/…` paths those agents are prompted with (skill locations,
13
+ * `.stigmer/inputs/…` attachments) actually resolve.
14
+ *
15
+ * Lifecycle contract (same for both harnesses):
16
+ * - Created per turn, under the workspace turn lock — the link is a
17
+ * working-tree mutation, and re-pointing it while another session's turn is
18
+ * running on a shared tree would redirect that turn's reads mid-flight.
19
+ * Cursor creates it in its skill/attachment resolvers (which run after lock
20
+ * acquisition); the native harness creates it right after acquiring the
21
+ * lock (execute-deep-agent/index.ts).
22
+ * - Removed at turn end ({@link removeStigmerSymlink} in the activity's
23
+ * cleanup), so a real repo is left untouched once the turn ends; a
24
+ * multi-turn session recreates the link on the next turn.
25
+ * - {@link ensureStigmerSymlink} is idempotent, so multiple populaters may
26
+ * run in any order within a turn.
27
+ *
28
+ * OWNERSHIP SHARP EDGE: the platform owns the `.stigmer` name inside a
29
+ * workspace. {@link ensureStigmerSymlink} REPLACES a real (non-symlink)
30
+ * `.stigmer` directory it finds there — deliberate, so a directory left
31
+ * behind by an older runner can never shadow the platform mount. (A user's
32
+ * own `.stigmer` content is unreachable anyway: the runner's
33
+ * `LocalWorkspaceBackend` routes every `.stigmer/…` path to the platform
34
+ * dir.) Turn-end removal is the conservative half: it only ever removes a
35
+ * SYMLINK.
36
+ */
37
+
38
+ import { symlink, readlink, unlink, rm, lstat } from "node:fs/promises";
39
+ import { join } from "node:path";
40
+
41
+ /** The workspace-visible name of the platform namespace. */
42
+ export const STIGMER_LOCAL_STATE_DIR = ".stigmer";
43
+
44
+ /**
45
+ * Ensure the workspace `.stigmer` symlink points to the platform dir.
46
+ * Idempotent: an existing correct link is kept; a stale link (or a non-link
47
+ * left behind by an older runner) is replaced.
48
+ */
49
+ export async function ensureStigmerSymlink(
50
+ workspaceDir: string,
51
+ platformDir: string,
52
+ ): Promise<void> {
53
+ const linkPath = join(workspaceDir, STIGMER_LOCAL_STATE_DIR);
54
+
55
+ try {
56
+ const existing = await readlink(linkPath);
57
+ if (existing === platformDir) return;
58
+ await unlink(linkPath);
59
+ } catch (err: any) {
60
+ if (err.code === "ENOENT") {
61
+ // No existing symlink
62
+ } else if (err.code === "EINVAL") {
63
+ // Exists but is not a symlink — remove the directory
64
+ await rm(linkPath, { recursive: true, force: true });
65
+ } else {
66
+ throw err;
67
+ }
68
+ }
69
+
70
+ await symlink(platformDir, linkPath, "dir");
71
+ }
72
+
73
+ /**
74
+ * Remove the workspace `.stigmer` symlink created by
75
+ * {@link ensureStigmerSymlink}.
76
+ *
77
+ * Called in the activity's cleanup so attaching a real repo leaves no Stigmer
78
+ * symlink behind once the turn ends (issue #173). Only ever removes a
79
+ * SYMLINK — a real `.stigmer` directory (which would be the user's own, not
80
+ * ours) is left untouched. Best-effort.
81
+ */
82
+ export async function removeStigmerSymlink(workspaceDir: string): Promise<void> {
83
+ const linkPath = join(workspaceDir, STIGMER_LOCAL_STATE_DIR);
84
+ try {
85
+ const stat = await lstat(linkPath);
86
+ if (stat.isSymbolicLink()) {
87
+ await unlink(linkPath);
88
+ }
89
+ } catch (err: any) {
90
+ if (err?.code !== "ENOENT") {
91
+ console.warn(
92
+ `removeStigmerSymlink: failed to remove ${linkPath} (non-fatal): ` +
93
+ `${err instanceof Error ? err.message : err}`,
94
+ );
95
+ }
96
+ }
97
+ }
@@ -53,10 +53,12 @@ export class WorkspaceProvisionError extends Error {
53
53
  * commands route through the Daytona sandbox proxy.
54
54
  *
55
55
  * When `platformDir` is set, paths under `.stigmer/` are transparently
56
- * routed to the platform directory instead of the workspace root. This
57
- * keeps platform files (skills, inputs) physically separate from the
58
- * user's workspace while presenting a unified `.stigmer/` namespace to
59
- * the agent.
56
+ * routed to the platform directory instead of the workspace root, keeping
57
+ * platform files (skills, inputs) physically separate from the user's
58
+ * workspace. The routing covers the RUNNER's own reads/writes through
59
+ * this interface; the agent's file tools resolve against the workspace
60
+ * root and see the same files through the per-turn `.stigmer` symlink
61
+ * (see stigmer-link.ts).
60
62
  */
61
63
  export interface WorkspaceBackend {
62
64
  readonly rootDir: string;