@stigmer/runner 3.1.3 → 3.1.5
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.
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/execute-cursor/attachment-resolver.js +1 -1
- package/dist/activities/execute-cursor/attachment-resolver.js.map +1 -1
- package/dist/activities/execute-cursor/capture-flow.d.ts +26 -15
- package/dist/activities/execute-cursor/capture-flow.js +56 -18
- package/dist/activities/execute-cursor/capture-flow.js.map +1 -1
- package/dist/activities/execute-cursor/command-provenance.d.ts +11 -25
- package/dist/activities/execute-cursor/command-provenance.js +25 -115
- package/dist/activities/execute-cursor/command-provenance.js.map +1 -1
- package/dist/activities/execute-cursor/index.js +297 -478
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/skill-resolver.js +1 -1
- package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
- package/dist/activities/execute-cursor/todo-tracker.d.ts +6 -1
- package/dist/activities/execute-cursor/todo-tracker.js +15 -43
- package/dist/activities/execute-cursor/todo-tracker.js.map +1 -1
- package/dist/activities/execute-cursor/turn-stream.d.ts +141 -0
- package/dist/activities/execute-cursor/turn-stream.js +249 -0
- package/dist/activities/execute-cursor/turn-stream.js.map +1 -0
- package/dist/activities/execute-deep-agent/command-provenance.d.ts +61 -0
- package/dist/activities/execute-deep-agent/command-provenance.js +72 -0
- package/dist/activities/execute-deep-agent/command-provenance.js.map +1 -0
- package/dist/activities/execute-deep-agent/index.js +88 -20
- package/dist/activities/execute-deep-agent/index.js.map +1 -1
- package/dist/activities/execute-deep-agent/status-builder.js +8 -1
- package/dist/activities/execute-deep-agent/status-builder.js.map +1 -1
- package/dist/activities/execute-deep-agent/v3-status-builder.js +12 -1
- package/dist/activities/execute-deep-agent/v3-status-builder.js.map +1 -1
- package/dist/activities/execute-deep-agent/writeback-coordinator.js +5 -1
- package/dist/activities/execute-deep-agent/writeback-coordinator.js.map +1 -1
- package/dist/otel.js +10 -0
- package/dist/otel.js.map +1 -1
- package/dist/shared/filereview/cas-progress.d.ts +63 -0
- package/dist/shared/filereview/cas-progress.js +128 -0
- package/dist/shared/filereview/cas-progress.js.map +1 -0
- package/dist/shared/filereview/cas-substrate.d.ts +29 -0
- package/dist/shared/filereview/cas-substrate.js +46 -21
- package/dist/shared/filereview/cas-substrate.js.map +1 -1
- package/dist/shared/filereview/command-provenance.d.ts +93 -0
- package/dist/shared/filereview/command-provenance.js +132 -0
- package/dist/shared/filereview/command-provenance.js.map +1 -0
- package/dist/shared/filereview/git-substrate.d.ts +9 -3
- package/dist/shared/filereview/git-substrate.js +4 -0
- package/dist/shared/filereview/git-substrate.js.map +1 -1
- package/dist/shared/filereview/index.d.ts +4 -3
- package/dist/shared/filereview/index.js +3 -2
- package/dist/shared/filereview/index.js.map +1 -1
- package/dist/shared/filereview/progress.d.ts +105 -34
- package/dist/shared/filereview/progress.js +96 -34
- package/dist/shared/filereview/progress.js.map +1 -1
- package/dist/shared/plan-mode-prompt.d.ts +9 -0
- package/dist/shared/plan-mode-prompt.js +18 -0
- package/dist/shared/plan-mode-prompt.js.map +1 -1
- package/dist/shared/todos.d.ts +56 -0
- package/dist/shared/todos.js +98 -0
- package/dist/shared/todos.js.map +1 -0
- package/dist/shared/tool-row.d.ts +16 -0
- package/dist/shared/tool-row.js +31 -0
- package/dist/shared/tool-row.js.map +1 -1
- package/dist/shared/workspace/git-identity.d.ts +36 -0
- package/dist/shared/workspace/git-identity.js +39 -0
- package/dist/shared/workspace/git-identity.js.map +1 -0
- package/dist/shared/workspace/local-backend.d.ts +6 -2
- package/dist/shared/workspace/local-backend.js +6 -2
- package/dist/shared/workspace/local-backend.js.map +1 -1
- package/dist/shared/workspace/stigmer-link.d.ts +54 -0
- package/dist/shared/workspace/stigmer-link.js +92 -0
- package/dist/shared/workspace/stigmer-link.js.map +1 -0
- package/dist/shared/workspace/types.d.ts +6 -4
- package/package.json +2 -2
- package/src/__tests__/otel-turn-span.test.ts +61 -0
- package/src/activities/execute-cursor/__tests__/progress-substrate.test.ts +169 -0
- package/src/activities/execute-cursor/__tests__/turn-stream.test.ts +349 -0
- package/src/activities/execute-cursor/attachment-resolver.ts +1 -1
- package/src/activities/execute-cursor/capture-flow.ts +71 -25
- package/src/activities/execute-cursor/command-provenance.ts +25 -120
- package/src/activities/execute-cursor/index.ts +345 -505
- package/src/activities/execute-cursor/skill-resolver.ts +1 -1
- package/src/activities/execute-cursor/todo-tracker.ts +17 -59
- package/src/activities/execute-cursor/turn-stream.ts +418 -0
- package/src/activities/execute-deep-agent/__tests__/command-provenance.test.ts +252 -0
- package/src/activities/execute-deep-agent/__tests__/status-builder.test.ts +78 -0
- package/src/activities/execute-deep-agent/__tests__/v3-status-builder.test.ts +105 -1
- package/src/activities/execute-deep-agent/__tests__/writeback-coordinator.test.ts +30 -3
- package/src/activities/execute-deep-agent/command-provenance.ts +102 -0
- package/src/activities/execute-deep-agent/index.ts +107 -20
- package/src/activities/execute-deep-agent/status-builder.ts +9 -0
- package/src/activities/execute-deep-agent/v3-status-builder.ts +13 -0
- package/src/activities/execute-deep-agent/writeback-coordinator.ts +5 -1
- package/src/otel.ts +8 -0
- package/src/shared/__tests__/todos.test.ts +216 -0
- package/src/shared/filereview/__tests__/cas-progress.test.ts +228 -0
- package/src/shared/filereview/__tests__/cas-substrate.test.ts +66 -0
- package/src/shared/filereview/__tests__/command-provenance.test.ts +252 -0
- package/src/shared/filereview/__tests__/progress.test.ts +112 -10
- package/src/shared/filereview/cas-progress.ts +170 -0
- package/src/shared/filereview/cas-substrate.ts +69 -24
- package/src/shared/filereview/command-provenance.ts +180 -0
- package/src/shared/filereview/git-substrate.ts +13 -3
- package/src/shared/filereview/index.ts +15 -1
- package/src/shared/filereview/progress.ts +171 -47
- package/src/shared/plan-mode-prompt.ts +18 -0
- package/src/shared/todos.ts +126 -0
- package/src/shared/tool-row.ts +34 -0
- package/src/shared/workspace/__tests__/git-identity.test.ts +124 -0
- package/src/shared/workspace/__tests__/stigmer-link.test.ts +173 -0
- package/src/shared/workspace/git-identity.ts +41 -0
- package/src/shared/workspace/local-backend.ts +6 -2
- package/src/shared/workspace/stigmer-link.ts +97 -0
- package/src/shared/workspace/types.ts +6 -4
- package/dist/activities/execute-cursor/stigmer-link.d.ts +0 -35
- package/dist/activities/execute-cursor/stigmer-link.js +0 -73
- package/dist/activities/execute-cursor/stigmer-link.js.map +0 -1
- package/src/activities/execute-cursor/stigmer-link.ts +0 -78
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import {
|
|
7
|
+
AGENT_GIT_AUTHOR_NAME,
|
|
8
|
+
AGENT_GIT_AUTHOR_EMAIL,
|
|
9
|
+
AGENT_GIT_IDENTITY_FLAGS,
|
|
10
|
+
gitCommitAsAgent,
|
|
11
|
+
} from "../git-identity.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Environment that approximates the cloud sandbox: no global/system gitconfig,
|
|
15
|
+
* no GIT_AUTHOR / GIT_COMMITTER variables. In the real sandbox a bare
|
|
16
|
+
* `git commit` here fails with "Author identity unknown" (hostname-based
|
|
17
|
+
* auto-detection yields an invalid ident inside the pod); on a developer
|
|
18
|
+
* machine git may still auto-detect from username + hostname, so these tests
|
|
19
|
+
* assert the fix's guarantee — the pinned identity always wins — rather than
|
|
20
|
+
* the environment-dependent failure mode.
|
|
21
|
+
*/
|
|
22
|
+
function identitylessEnv(): NodeJS.ProcessEnv {
|
|
23
|
+
const env = { ...process.env };
|
|
24
|
+
delete env.GIT_AUTHOR_NAME;
|
|
25
|
+
delete env.GIT_AUTHOR_EMAIL;
|
|
26
|
+
delete env.GIT_COMMITTER_NAME;
|
|
27
|
+
delete env.GIT_COMMITTER_EMAIL;
|
|
28
|
+
env.GIT_CONFIG_GLOBAL = "/dev/null";
|
|
29
|
+
env.GIT_CONFIG_SYSTEM = "/dev/null";
|
|
30
|
+
return env;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function sh(
|
|
34
|
+
cwd: string,
|
|
35
|
+
command: string,
|
|
36
|
+
envOverrides: Record<string, string> = {},
|
|
37
|
+
): Promise<string> {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
execFile(
|
|
40
|
+
"sh",
|
|
41
|
+
["-c", command],
|
|
42
|
+
{ cwd, env: { ...identitylessEnv(), ...envOverrides } },
|
|
43
|
+
(err, stdout, stderr) => {
|
|
44
|
+
if (err) reject(new Error(`Command failed: ${command}\n${stderr || err.message}`));
|
|
45
|
+
else resolve(stdout);
|
|
46
|
+
},
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("gitCommitAsAgent (real repo, no ambient identity)", () => {
|
|
52
|
+
let repo: string;
|
|
53
|
+
|
|
54
|
+
beforeEach(async () => {
|
|
55
|
+
repo = mkdtempSync(join(tmpdir(), "git-identity-test-"));
|
|
56
|
+
await sh(repo, "git init -q");
|
|
57
|
+
writeFileSync(join(repo, "notes.md"), "# Project Notes\n");
|
|
58
|
+
await sh(repo, "git add -A");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
rmSync(repo, { recursive: true, force: true });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* `user.useConfigOnly=true` (injected via git's config env) forbids the
|
|
67
|
+
* username+hostname auto-detection that a developer machine would otherwise
|
|
68
|
+
* fall back to — deterministically reproducing the sandbox condition where
|
|
69
|
+
* git has no usable identity.
|
|
70
|
+
*/
|
|
71
|
+
const NO_AUTODETECT_ENV = {
|
|
72
|
+
GIT_CONFIG_COUNT: "1",
|
|
73
|
+
GIT_CONFIG_KEY_0: "user.useConfigOnly",
|
|
74
|
+
GIT_CONFIG_VALUE_0: "true",
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
it("reproduces the cloud failure: a bare commit has no identity", async () => {
|
|
78
|
+
await expect(
|
|
79
|
+
sh(repo, 'git commit -m "agent changes (1)"', NO_AUTODETECT_ENV),
|
|
80
|
+
).rejects.toThrow(/user\.email|user\.name|Author identity unknown|empty ident/);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("the agent identity satisfies git even when auto-detection is forbidden", async () => {
|
|
84
|
+
await sh(repo, gitCommitAsAgent("agent changes (1)"), NO_AUTODETECT_ENV);
|
|
85
|
+
|
|
86
|
+
const author = await sh(repo, "git log -1 --format='%an <%ae>'");
|
|
87
|
+
expect(author.trim()).toBe(
|
|
88
|
+
`${AGENT_GIT_AUTHOR_NAME} <${AGENT_GIT_AUTHOR_EMAIL}>`,
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("commits successfully with the agent identity", async () => {
|
|
93
|
+
await sh(repo, gitCommitAsAgent("agent changes (1)"));
|
|
94
|
+
|
|
95
|
+
const author = await sh(repo, "git log -1 --format='%an <%ae>'");
|
|
96
|
+
expect(author.trim()).toBe(
|
|
97
|
+
`${AGENT_GIT_AUTHOR_NAME} <${AGENT_GIT_AUTHOR_EMAIL}>`,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const committer = await sh(repo, "git log -1 --format='%cn <%ce>'");
|
|
101
|
+
expect(committer.trim()).toBe(
|
|
102
|
+
`${AGENT_GIT_AUTHOR_NAME} <${AGENT_GIT_AUTHOR_EMAIL}>`,
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const message = await sh(repo, "git log -1 --format=%s");
|
|
106
|
+
expect(message.trim()).toBe("agent changes (1)");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("never mutates the repository's git config", async () => {
|
|
110
|
+
await sh(repo, gitCommitAsAgent("agent changes (1)"));
|
|
111
|
+
|
|
112
|
+
// `git config --local` exits non-zero when the key is unset.
|
|
113
|
+
await expect(sh(repo, "git config --local user.name")).rejects.toThrow();
|
|
114
|
+
await expect(sh(repo, "git config --local user.email")).rejects.toThrow();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("AGENT_GIT_IDENTITY_FLAGS", () => {
|
|
119
|
+
it("pins both name and email via per-command -c flags", () => {
|
|
120
|
+
expect(AGENT_GIT_IDENTITY_FLAGS).toBe(
|
|
121
|
+
`-c user.name='${AGENT_GIT_AUTHOR_NAME}' -c user.email='${AGENT_GIT_AUTHOR_EMAIL}'`,
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mkdtemp, mkdir, rm, writeFile, readFile, lstat, readlink } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { FilesystemBackend } from "deepagents";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
ensureStigmerSymlink,
|
|
9
|
+
removeStigmerSymlink,
|
|
10
|
+
STIGMER_LOCAL_STATE_DIR,
|
|
11
|
+
} from "../stigmer-link.js";
|
|
12
|
+
import { LocalWorkspaceBackend } from "../local-backend.js";
|
|
13
|
+
import { injectAttachments } from "../../../activities/execute-deep-agent/attachment-injector.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The `.stigmer` symlink is the bridge that makes platform-mounted content
|
|
17
|
+
* (approved plan, skills, attachment inputs) readable by the agent's file
|
|
18
|
+
* tools in BOTH harnesses. The lifecycle tests pin the ownership contract
|
|
19
|
+
* (ensure replaces, remove is symlink-only); the convergence tests pin the
|
|
20
|
+
* end-to-end physics the native harness depends on: content written through
|
|
21
|
+
* the platform-routing LocalWorkspaceBackend must be readable at the same
|
|
22
|
+
* `.stigmer/…` path through a stock deepagents FilesystemBackend rooted at
|
|
23
|
+
* the workspace — which is exactly what broke when the native harness had no
|
|
24
|
+
* symlink (the "plan file doesn't exist" bug).
|
|
25
|
+
*/
|
|
26
|
+
describe("stigmer-link", () => {
|
|
27
|
+
let workspaceDir: string;
|
|
28
|
+
let platformDir: string;
|
|
29
|
+
|
|
30
|
+
beforeEach(async () => {
|
|
31
|
+
workspaceDir = await mkdtemp(join(tmpdir(), "stigmer-link-ws-"));
|
|
32
|
+
platformDir = await mkdtemp(join(tmpdir(), "stigmer-link-platform-"));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(async () => {
|
|
36
|
+
await rm(workspaceDir, { recursive: true, force: true });
|
|
37
|
+
await rm(platformDir, { recursive: true, force: true });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const linkPath = () => join(workspaceDir, STIGMER_LOCAL_STATE_DIR);
|
|
41
|
+
|
|
42
|
+
describe("ensureStigmerSymlink", () => {
|
|
43
|
+
it("creates the workspace symlink pointing at the platform dir", async () => {
|
|
44
|
+
await ensureStigmerSymlink(workspaceDir, platformDir);
|
|
45
|
+
|
|
46
|
+
expect((await lstat(linkPath())).isSymbolicLink()).toBe(true);
|
|
47
|
+
expect(await readlink(linkPath())).toBe(platformDir);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("is idempotent for a correct existing link", async () => {
|
|
51
|
+
await ensureStigmerSymlink(workspaceDir, platformDir);
|
|
52
|
+
await ensureStigmerSymlink(workspaceDir, platformDir);
|
|
53
|
+
|
|
54
|
+
expect(await readlink(linkPath())).toBe(platformDir);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("re-points a stale link at the current platform dir", async () => {
|
|
58
|
+
const stalePlatform = await mkdtemp(join(tmpdir(), "stigmer-link-stale-"));
|
|
59
|
+
try {
|
|
60
|
+
await ensureStigmerSymlink(workspaceDir, stalePlatform);
|
|
61
|
+
await ensureStigmerSymlink(workspaceDir, platformDir);
|
|
62
|
+
|
|
63
|
+
expect(await readlink(linkPath())).toBe(platformDir);
|
|
64
|
+
} finally {
|
|
65
|
+
await rm(stalePlatform, { recursive: true, force: true });
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("replaces a real .stigmer directory (platform owns the name)", async () => {
|
|
70
|
+
// The ownership sharp edge, pinned deliberately: a non-symlink
|
|
71
|
+
// `.stigmer` (left behind by an older runner) must never shadow the
|
|
72
|
+
// platform mount. See the module header for why this is safe.
|
|
73
|
+
await mkdir(join(linkPath(), "old"), { recursive: true });
|
|
74
|
+
await writeFile(join(linkPath(), "old", "stale.txt"), "stale");
|
|
75
|
+
|
|
76
|
+
await ensureStigmerSymlink(workspaceDir, platformDir);
|
|
77
|
+
|
|
78
|
+
expect((await lstat(linkPath())).isSymbolicLink()).toBe(true);
|
|
79
|
+
expect(await readlink(linkPath())).toBe(platformDir);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("removeStigmerSymlink", () => {
|
|
84
|
+
it("removes the symlink and leaves the platform dir intact", async () => {
|
|
85
|
+
await writeFile(join(platformDir, "keep.txt"), "keep");
|
|
86
|
+
await ensureStigmerSymlink(workspaceDir, platformDir);
|
|
87
|
+
|
|
88
|
+
await removeStigmerSymlink(workspaceDir);
|
|
89
|
+
|
|
90
|
+
await expect(lstat(linkPath())).rejects.toMatchObject({ code: "ENOENT" });
|
|
91
|
+
expect(await readFile(join(platformDir, "keep.txt"), "utf-8")).toBe("keep");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("leaves a real .stigmer directory untouched (symlink-only removal)", async () => {
|
|
95
|
+
await mkdir(linkPath(), { recursive: true });
|
|
96
|
+
await writeFile(join(linkPath(), "user-owned.txt"), "mine");
|
|
97
|
+
|
|
98
|
+
await removeStigmerSymlink(workspaceDir);
|
|
99
|
+
|
|
100
|
+
expect(await readFile(join(linkPath(), "user-owned.txt"), "utf-8")).toBe("mine");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("is a no-op when no link exists", async () => {
|
|
104
|
+
await expect(removeStigmerSymlink(workspaceDir)).resolves.toBeUndefined();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ── Write/read convergence (the native-harness bug, pinned) ─────────
|
|
109
|
+
|
|
110
|
+
describe("platform write / agent read convergence", () => {
|
|
111
|
+
/** Read through the agent's actual file-tool backend. */
|
|
112
|
+
function agentRead(path: string): Promise<{ content?: string; error?: string }> {
|
|
113
|
+
const agentBackend = new FilesystemBackend({ rootDir: workspaceDir });
|
|
114
|
+
return agentBackend.read(path) as Promise<{ content?: string; error?: string }>;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
it("approved plan injected via injectAttachments is readable by the deepagents backend", async () => {
|
|
118
|
+
const planFileName = "notes_ab12cd34.plan.md";
|
|
119
|
+
const planText = "# The Approved Plan\n\nStep 1: do the thing.\n";
|
|
120
|
+
const uploadPath = join(platformDir, "upload-src.md");
|
|
121
|
+
await writeFile(uploadPath, planText);
|
|
122
|
+
|
|
123
|
+
// Write path: exactly what the build turn does — the attachment is
|
|
124
|
+
// materialized through the platform-routing backend at the mount path
|
|
125
|
+
// the implement-plan directive will tell the model to read.
|
|
126
|
+
const workspaceBackend = new LocalWorkspaceBackend(workspaceDir, platformDir);
|
|
127
|
+
const injected = await injectAttachments({
|
|
128
|
+
backend: workspaceBackend,
|
|
129
|
+
attachments: [{
|
|
130
|
+
filename: planFileName,
|
|
131
|
+
storageKey: "",
|
|
132
|
+
mountPath: `.stigmer/inputs/${planFileName}`,
|
|
133
|
+
contentType: "text/markdown",
|
|
134
|
+
extract: false,
|
|
135
|
+
localPath: uploadPath,
|
|
136
|
+
$typeName: "ai.stigmer.agentic.agentexecution.v1.Attachment",
|
|
137
|
+
} as any],
|
|
138
|
+
storage: undefined,
|
|
139
|
+
isLocalMode: true,
|
|
140
|
+
});
|
|
141
|
+
expect(injected.map((f) => f.path)).toEqual([`.stigmer/inputs/${planFileName}`]);
|
|
142
|
+
|
|
143
|
+
// Without the symlink the agent cannot see the plan — the bug this
|
|
144
|
+
// module fixes for the native harness. Pinned so the link stays
|
|
145
|
+
// load-bearing in reviewers' and agents' mental models.
|
|
146
|
+
const before = await agentRead(`.stigmer/inputs/${planFileName}`);
|
|
147
|
+
expect(before.content ?? "").not.toContain("The Approved Plan");
|
|
148
|
+
|
|
149
|
+
await ensureStigmerSymlink(workspaceDir, platformDir);
|
|
150
|
+
|
|
151
|
+
const after = await agentRead(`.stigmer/inputs/${planFileName}`);
|
|
152
|
+
expect(after.error).toBeUndefined();
|
|
153
|
+
expect(after.content).toContain("The Approved Plan");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("skill written through the platform-routing backend is readable at its prompt location", async () => {
|
|
157
|
+
// Same physical write the skill-writer performs for
|
|
158
|
+
// `.stigmer/skills/{name}/SKILL.md` — the path the system prompt tells
|
|
159
|
+
// the model to read to activate the skill.
|
|
160
|
+
const workspaceBackend = new LocalWorkspaceBackend(workspaceDir, platformDir);
|
|
161
|
+
await workspaceBackend.writeFile(
|
|
162
|
+
".stigmer/skills/my-skill/SKILL.md",
|
|
163
|
+
"# My Skill\n\nDo skillful things.\n",
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
await ensureStigmerSymlink(workspaceDir, platformDir);
|
|
167
|
+
|
|
168
|
+
const result = await agentRead(".stigmer/skills/my-skill/SKILL.md");
|
|
169
|
+
expect(result.error).toBeUndefined();
|
|
170
|
+
expect(result.content).toContain("My Skill");
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -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
|
|
10
|
-
*
|
|
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
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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;
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The workspace `.stigmer` symlink — the Cursor harness's bridge from the
|
|
3
|
-
* user's workspace to the session's platform-managed directory
|
|
4
|
-
* (~/.stigmer/sessions/{id}/platform/, see shared/workspace/platform-dir.ts).
|
|
5
|
-
*
|
|
6
|
-
* The Cursor SDK reads files from the workspace CWD, so platform-injected
|
|
7
|
-
* content (skills, attachment inputs) must be reachable there; the symlink is
|
|
8
|
-
* how, without ever writing platform files into the user's repo (issue #173).
|
|
9
|
-
* This mechanism is deliberately harness-local: the native harness routes
|
|
10
|
-
* `.stigmer/` paths through its WorkspaceBackend instead and never symlinks.
|
|
11
|
-
*
|
|
12
|
-
* Both resolvers that populate the platform dir (skills, attachments) call
|
|
13
|
-
* {@link ensureStigmerSymlink}; it is idempotent, so either may run first and
|
|
14
|
-
* an agent with only one kind of content still gets the link. The activity's
|
|
15
|
-
* cleanup calls {@link removeStigmerSymlink} so a real repo is left untouched
|
|
16
|
-
* once the turn ends; a multi-turn session recreates the link on the next turn.
|
|
17
|
-
*/
|
|
18
|
-
/** The workspace-visible name of the platform namespace. */
|
|
19
|
-
export declare const STIGMER_LOCAL_STATE_DIR = ".stigmer";
|
|
20
|
-
/**
|
|
21
|
-
* Ensure the workspace `.stigmer` symlink points to the platform dir.
|
|
22
|
-
* Idempotent: an existing correct link is kept; a stale link (or a non-link
|
|
23
|
-
* left behind by an older runner) is replaced.
|
|
24
|
-
*/
|
|
25
|
-
export declare function ensureStigmerSymlink(workspaceDir: string, platformDir: string): Promise<void>;
|
|
26
|
-
/**
|
|
27
|
-
* Remove the workspace `.stigmer` symlink created by
|
|
28
|
-
* {@link ensureStigmerSymlink}.
|
|
29
|
-
*
|
|
30
|
-
* Called in the activity's finally so attaching a real repo leaves no Stigmer
|
|
31
|
-
* symlink behind once the turn ends (issue #173). Only ever removes a
|
|
32
|
-
* SYMLINK — a real `.stigmer` directory (which would be the user's own, not
|
|
33
|
-
* ours) is left untouched. Best-effort.
|
|
34
|
-
*/
|
|
35
|
-
export declare function removeStigmerSymlink(workspaceDir: string): Promise<void>;
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The workspace `.stigmer` symlink — the Cursor harness's bridge from the
|
|
3
|
-
* user's workspace to the session's platform-managed directory
|
|
4
|
-
* (~/.stigmer/sessions/{id}/platform/, see shared/workspace/platform-dir.ts).
|
|
5
|
-
*
|
|
6
|
-
* The Cursor SDK reads files from the workspace CWD, so platform-injected
|
|
7
|
-
* content (skills, attachment inputs) must be reachable there; the symlink is
|
|
8
|
-
* how, without ever writing platform files into the user's repo (issue #173).
|
|
9
|
-
* This mechanism is deliberately harness-local: the native harness routes
|
|
10
|
-
* `.stigmer/` paths through its WorkspaceBackend instead and never symlinks.
|
|
11
|
-
*
|
|
12
|
-
* Both resolvers that populate the platform dir (skills, attachments) call
|
|
13
|
-
* {@link ensureStigmerSymlink}; it is idempotent, so either may run first and
|
|
14
|
-
* an agent with only one kind of content still gets the link. The activity's
|
|
15
|
-
* cleanup calls {@link removeStigmerSymlink} so a real repo is left untouched
|
|
16
|
-
* once the turn ends; a multi-turn session recreates the link on the next turn.
|
|
17
|
-
*/
|
|
18
|
-
import { symlink, readlink, unlink, rm, lstat } from "node:fs/promises";
|
|
19
|
-
import { join } from "node:path";
|
|
20
|
-
/** The workspace-visible name of the platform namespace. */
|
|
21
|
-
export const STIGMER_LOCAL_STATE_DIR = ".stigmer";
|
|
22
|
-
/**
|
|
23
|
-
* Ensure the workspace `.stigmer` symlink points to the platform dir.
|
|
24
|
-
* Idempotent: an existing correct link is kept; a stale link (or a non-link
|
|
25
|
-
* left behind by an older runner) is replaced.
|
|
26
|
-
*/
|
|
27
|
-
export async function ensureStigmerSymlink(workspaceDir, platformDir) {
|
|
28
|
-
const linkPath = join(workspaceDir, STIGMER_LOCAL_STATE_DIR);
|
|
29
|
-
try {
|
|
30
|
-
const existing = await readlink(linkPath);
|
|
31
|
-
if (existing === platformDir)
|
|
32
|
-
return;
|
|
33
|
-
await unlink(linkPath);
|
|
34
|
-
}
|
|
35
|
-
catch (err) {
|
|
36
|
-
if (err.code === "ENOENT") {
|
|
37
|
-
// No existing symlink
|
|
38
|
-
}
|
|
39
|
-
else if (err.code === "EINVAL") {
|
|
40
|
-
// Exists but is not a symlink — remove the directory
|
|
41
|
-
await rm(linkPath, { recursive: true, force: true });
|
|
42
|
-
}
|
|
43
|
-
else {
|
|
44
|
-
throw err;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
await symlink(platformDir, linkPath, "dir");
|
|
48
|
-
}
|
|
49
|
-
/**
|
|
50
|
-
* Remove the workspace `.stigmer` symlink created by
|
|
51
|
-
* {@link ensureStigmerSymlink}.
|
|
52
|
-
*
|
|
53
|
-
* Called in the activity's finally so attaching a real repo leaves no Stigmer
|
|
54
|
-
* symlink behind once the turn ends (issue #173). Only ever removes a
|
|
55
|
-
* SYMLINK — a real `.stigmer` directory (which would be the user's own, not
|
|
56
|
-
* ours) is left untouched. Best-effort.
|
|
57
|
-
*/
|
|
58
|
-
export async function removeStigmerSymlink(workspaceDir) {
|
|
59
|
-
const linkPath = join(workspaceDir, STIGMER_LOCAL_STATE_DIR);
|
|
60
|
-
try {
|
|
61
|
-
const stat = await lstat(linkPath);
|
|
62
|
-
if (stat.isSymbolicLink()) {
|
|
63
|
-
await unlink(linkPath);
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
catch (err) {
|
|
67
|
-
if (err?.code !== "ENOENT") {
|
|
68
|
-
console.warn(`removeStigmerSymlink: failed to remove ${linkPath} (non-fatal): ` +
|
|
69
|
-
`${err instanceof Error ? err.message : err}`);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
//# sourceMappingURL=stigmer-link.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"stigmer-link.js","sourceRoot":"","sources":["../../../src/activities/execute-cursor/stigmer-link.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAC;AACxE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAEjC,4DAA4D;AAC5D,MAAM,CAAC,MAAM,uBAAuB,GAAG,UAAU,CAAC;AAElD;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,oBAAoB,CACxC,YAAoB,EACpB,WAAmB;IAEnB,MAAM,QAAQ,GAAG,IAAI,CAAC,YAAY,EAAE,uBAAuB,CAAC,CAAC;IAE7D,IAAI,CAAC;QACH,MAAM,QAAQ,GAAG,MAAM,QAAQ,CAAC,QAAQ,CAAC,CAAC;QAC1C,IAAI,QAAQ,KAAK,WAAW;YAAE,OAAO;QACrC,MAAM,MAAM,CAAC,QAAQ,CAAC,CAAC;IACzB,CAAC;IAAC,OAAO,GAAQ,EAAE,CAAC;QAClB,IAAI,GAAG,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;YAC1B,sBAAsB;QACxB,CAAC;aAAM,IAAI,GAAG,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;YACjC,qDAAqD;YACrD,MAAM,EAAE,CAAC,QAAQ,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;QACvD,CAAC;aAAM,CAAC;YACN,MAAM,GAAG,CAAC;QACZ,CAAC;IACH,CAAC;IAED,MAAM,OAAO,CAAC,WAAW,EAAE,QAAQ,EAAE,KAAK,CAAC,CAAC;AAC9C,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,CAAC,KAAK,UAAU,oBAAoB,CAAC,YAAoB;IAC7D,MAAM,QAAQ,GAAG,IAAI,CAAC,YAAY,EAAE,uBAAuB,CAAC,CAAC;IAC7D,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,MAAM,KAAK,CAAC,QAAQ,CAAC,CAAC;QACnC,IAAI,IAAI,CAAC,cAAc,EAAE,EAAE,CAAC;YAC1B,MAAM,MAAM,CAAC,QAAQ,CAAC,CAAC;QACzB,CAAC;IACH,CAAC;IAAC,OAAO,GAAQ,EAAE,CAAC;QAClB,IAAI,GAAG,EAAE,IAAI,KAAK,QAAQ,EAAE,CAAC;YAC3B,OAAO,CAAC,IAAI,CACV,0CAA0C,QAAQ,gBAAgB;gBAClE,GAAG,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,EAAE,CAC9C,CAAC;QACJ,CAAC;IACH,CAAC;AACH,CAAC"}
|
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The workspace `.stigmer` symlink — the Cursor harness's bridge from the
|
|
3
|
-
* user's workspace to the session's platform-managed directory
|
|
4
|
-
* (~/.stigmer/sessions/{id}/platform/, see shared/workspace/platform-dir.ts).
|
|
5
|
-
*
|
|
6
|
-
* The Cursor SDK reads files from the workspace CWD, so platform-injected
|
|
7
|
-
* content (skills, attachment inputs) must be reachable there; the symlink is
|
|
8
|
-
* how, without ever writing platform files into the user's repo (issue #173).
|
|
9
|
-
* This mechanism is deliberately harness-local: the native harness routes
|
|
10
|
-
* `.stigmer/` paths through its WorkspaceBackend instead and never symlinks.
|
|
11
|
-
*
|
|
12
|
-
* Both resolvers that populate the platform dir (skills, attachments) call
|
|
13
|
-
* {@link ensureStigmerSymlink}; it is idempotent, so either may run first and
|
|
14
|
-
* an agent with only one kind of content still gets the link. The activity's
|
|
15
|
-
* cleanup calls {@link removeStigmerSymlink} so a real repo is left untouched
|
|
16
|
-
* once the turn ends; a multi-turn session recreates the link on the next turn.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import { symlink, readlink, unlink, rm, lstat } from "node:fs/promises";
|
|
20
|
-
import { join } from "node:path";
|
|
21
|
-
|
|
22
|
-
/** The workspace-visible name of the platform namespace. */
|
|
23
|
-
export const STIGMER_LOCAL_STATE_DIR = ".stigmer";
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Ensure the workspace `.stigmer` symlink points to the platform dir.
|
|
27
|
-
* Idempotent: an existing correct link is kept; a stale link (or a non-link
|
|
28
|
-
* left behind by an older runner) is replaced.
|
|
29
|
-
*/
|
|
30
|
-
export async function ensureStigmerSymlink(
|
|
31
|
-
workspaceDir: string,
|
|
32
|
-
platformDir: string,
|
|
33
|
-
): Promise<void> {
|
|
34
|
-
const linkPath = join(workspaceDir, STIGMER_LOCAL_STATE_DIR);
|
|
35
|
-
|
|
36
|
-
try {
|
|
37
|
-
const existing = await readlink(linkPath);
|
|
38
|
-
if (existing === platformDir) return;
|
|
39
|
-
await unlink(linkPath);
|
|
40
|
-
} catch (err: any) {
|
|
41
|
-
if (err.code === "ENOENT") {
|
|
42
|
-
// No existing symlink
|
|
43
|
-
} else if (err.code === "EINVAL") {
|
|
44
|
-
// Exists but is not a symlink — remove the directory
|
|
45
|
-
await rm(linkPath, { recursive: true, force: true });
|
|
46
|
-
} else {
|
|
47
|
-
throw err;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
await symlink(platformDir, linkPath, "dir");
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Remove the workspace `.stigmer` symlink created by
|
|
56
|
-
* {@link ensureStigmerSymlink}.
|
|
57
|
-
*
|
|
58
|
-
* Called in the activity's finally so attaching a real repo leaves no Stigmer
|
|
59
|
-
* symlink behind once the turn ends (issue #173). Only ever removes a
|
|
60
|
-
* SYMLINK — a real `.stigmer` directory (which would be the user's own, not
|
|
61
|
-
* ours) is left untouched. Best-effort.
|
|
62
|
-
*/
|
|
63
|
-
export async function removeStigmerSymlink(workspaceDir: string): Promise<void> {
|
|
64
|
-
const linkPath = join(workspaceDir, STIGMER_LOCAL_STATE_DIR);
|
|
65
|
-
try {
|
|
66
|
-
const stat = await lstat(linkPath);
|
|
67
|
-
if (stat.isSymbolicLink()) {
|
|
68
|
-
await unlink(linkPath);
|
|
69
|
-
}
|
|
70
|
-
} catch (err: any) {
|
|
71
|
-
if (err?.code !== "ENOENT") {
|
|
72
|
-
console.warn(
|
|
73
|
-
`removeStigmerSymlink: failed to remove ${linkPath} (non-fatal): ` +
|
|
74
|
-
`${err instanceof Error ? err.message : err}`,
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
}
|