@sema-agent/core 2.12.0 → 3.0.0

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 (115) hide show
  1. package/dist/agents/agent-transcript-tool.d.ts +1 -0
  2. package/dist/agents/agent-transcript-tool.js +1 -1
  3. package/dist/agents/cascade.d.ts +4 -0
  4. package/dist/agents/cascade.js +54 -9
  5. package/dist/agents/repair-loop.d.ts +2 -0
  6. package/dist/agents/repair-loop.js +101 -2
  7. package/dist/agents/retain-ledger.d.ts +18 -2
  8. package/dist/agents/retain-ledger.js +37 -8
  9. package/dist/agents/roster-store.d.ts +4 -0
  10. package/dist/agents/roster-store.js +22 -1
  11. package/dist/agents/teacher.js +4 -2
  12. package/dist/agents/team.js +7 -4
  13. package/dist/agents/verify.d.ts +15 -2
  14. package/dist/agents/verify.js +36 -1
  15. package/dist/bin/sema-tb.js +2 -2
  16. package/dist/brain/circuit-breaker.d.ts +1 -0
  17. package/dist/brain/circuit-breaker.js +26 -9
  18. package/dist/brain/degrading.js +13 -1
  19. package/dist/brain/failover.js +2 -0
  20. package/dist/brain/repetition.js +10 -1
  21. package/dist/brain/routing.js +5 -1
  22. package/dist/brain/status-sink.js +5 -1
  23. package/dist/brain/stream-engine.js +4 -1
  24. package/dist/brain/tool-call-repair.js +32 -14
  25. package/dist/core/auto-compaction.js +17 -7
  26. package/dist/core/auto-promote.js +2 -1
  27. package/dist/core/checkpoint-store.d.ts +1 -0
  28. package/dist/core/checkpoint-store.js +28 -10
  29. package/dist/core/consolidate-scope.js +4 -3
  30. package/dist/core/context-edit.js +3 -0
  31. package/dist/core/context-guard.js +3 -0
  32. package/dist/core/git-worktree-env.d.ts +6 -1
  33. package/dist/core/git-worktree-env.js +17 -1
  34. package/dist/core/hooks.d.ts +1 -0
  35. package/dist/core/hooks.js +40 -2
  36. package/dist/core/lsp-diagnostics.js +13 -7
  37. package/dist/core/lsp-protocol.d.ts +1 -1
  38. package/dist/core/lsp-protocol.js +20 -8
  39. package/dist/core/lsp-session.d.ts +12 -2
  40. package/dist/core/lsp-session.js +114 -49
  41. package/dist/core/lsp.d.ts +13 -1
  42. package/dist/core/lsp.js +64 -9
  43. package/dist/core/mcp.d.ts +1 -0
  44. package/dist/core/mcp.js +4 -3
  45. package/dist/core/memory-engine/data-plane.js +4 -0
  46. package/dist/core/memory-engine/engine.d.ts +2 -1
  47. package/dist/core/memory-engine/engine.js +82 -7
  48. package/dist/core/memory-engine/file-backend.d.ts +1 -0
  49. package/dist/core/memory-engine/file-backend.js +13 -3
  50. package/dist/core/memory-engine/layout.js +12 -1
  51. package/dist/core/memory.js +3 -0
  52. package/dist/core/permission-rules.d.ts +2 -1
  53. package/dist/core/permission-rules.js +24 -3
  54. package/dist/core/runner/active-skill-scope.js +34 -6
  55. package/dist/core/runner/assemble-result.d.ts +1 -1
  56. package/dist/core/runner/assemble-result.js +0 -1
  57. package/dist/core/runner/prepare-task.js +23 -3
  58. package/dist/core/runner/runtask.js +48 -30
  59. package/dist/core/runner/tool-disclosure.d.ts +1 -0
  60. package/dist/core/runner/tool-disclosure.js +26 -7
  61. package/dist/core/runner/usage-accounting.d.ts +6 -0
  62. package/dist/core/runner/usage-accounting.js +9 -8
  63. package/dist/core/secret-env.js +5 -1
  64. package/dist/core/sensitive-path-policy.js +9 -3
  65. package/dist/core/session-store.js +15 -4
  66. package/dist/core/skill-tool-specifier.d.ts +8 -0
  67. package/dist/core/skill-tool-specifier.js +58 -0
  68. package/dist/core/skills-directory.d.ts +1 -1
  69. package/dist/core/skills-directory.js +16 -4
  70. package/dist/core/store-contracts/session-repo-contract.js +40 -0
  71. package/dist/core/task-registry-monitor.js +7 -1
  72. package/dist/core/tool-policy.js +65 -17
  73. package/dist/core/trace.d.ts +1 -0
  74. package/dist/core/types.d.ts +3 -5
  75. package/dist/core/with-retry.js +0 -1
  76. package/dist/engine/compaction/compaction.d.ts +1 -0
  77. package/dist/engine/compaction/compaction.js +14 -5
  78. package/dist/engine/lsp/frame-decoder.js +6 -3
  79. package/dist/engine/lsp/node-lsp-manager.d.ts +2 -0
  80. package/dist/engine/lsp/node-lsp-manager.js +103 -26
  81. package/dist/engine/lsp/stdio-lsp-transport.js +16 -7
  82. package/dist/index.d.ts +1 -1
  83. package/dist/internal/harness.d.ts +1 -1
  84. package/dist/internal/harness.js +1 -1
  85. package/dist/internal/llm.d.ts +1 -1
  86. package/dist/orchestration/workflow-governance.js +43 -9
  87. package/dist/orchestration/workflow-sandbox-conformance.js +85 -51
  88. package/dist/orchestration/workflow-script-store.js +34 -4
  89. package/dist/orchestration/workflow.js +10 -6
  90. package/dist/prompt-assembly/artifact-store.d.ts +2 -0
  91. package/dist/prompt-assembly/artifact-store.js +39 -24
  92. package/dist/prompt-assembly/assemble.js +38 -7
  93. package/dist/prompt-assembly/epoch.js +19 -2
  94. package/dist/prompt-assembly/event-registry.js +3 -3
  95. package/dist/prompt-assembly/explain.js +1 -1
  96. package/dist/prompt-assembly/tool-catalog.js +16 -1
  97. package/dist/prompt-assembly/turn-snapshot.js +4 -1
  98. package/dist/stores/file/fs-atomic.d.ts +3 -0
  99. package/dist/stores/file/fs-atomic.js +53 -8
  100. package/dist/stores/file/index.d.ts +1 -0
  101. package/dist/stores/file/index.js +7 -0
  102. package/dist/stores/file/session-store.d.ts +2 -0
  103. package/dist/stores/file/session-store.js +28 -1
  104. package/dist/tools/fs/bash-readonly-classifier.d.ts +1 -0
  105. package/dist/tools/fs/bash-readonly-classifier.js +57 -8
  106. package/dist/tools/fs/fs-bash.d.ts +1 -1
  107. package/dist/tools/fs/fs-bash.js +93 -5
  108. package/dist/tools/loop-tick.js +1 -1
  109. package/dist/tools/monitor.js +55 -9
  110. package/dist/tools/scheduler-tools.js +9 -3
  111. package/dist/tools/web.d.ts +8 -2
  112. package/dist/tools/web.js +46 -17
  113. package/dist/tools/worktree.d.ts +2 -0
  114. package/dist/tools/worktree.js +233 -143
  115. package/package.json +5 -2
@@ -47,7 +47,9 @@ const EXIT_DESCRIPTION = "Exit a worktree session created by EnterWorktree and r
47
47
  "- The directory you're in if EnterWorktree was never called\n" +
48
48
  "\n" +
49
49
  "If called outside an EnterWorktree session, the tool is a no-op: it reports that no worktree session is " +
50
- "active and takes no action. Filesystem state is unchanged.\n" +
50
+ "active and takes no action. Filesystem state is unchanged. The one exception is contradictory arguments " +
51
+ "(`keep: true` together with `discardChanges: true`), which are rejected before anything else, with or " +
52
+ "without an active session — that path changes nothing on disk either.\n" +
51
53
  "\n" +
52
54
  "## Behavior\n" +
53
55
  "\n" +
@@ -63,6 +65,16 @@ const EXIT_DESCRIPTION = "Exit a worktree session created by EnterWorktree and r
63
65
  "or the user asked to discard it); it cannot be combined with `keep: true`.\n" +
64
66
  "- Once exited, EnterWorktree can be called again to create a fresh worktree.";
65
67
  const shq = (s) => `'${s.replace(/'/g, "'\\''")}'`;
68
+ function reasonOf(e) {
69
+ if (e instanceof Error)
70
+ return e.message;
71
+ if (typeof e === "object" && e !== null) {
72
+ const m = e.message;
73
+ if (typeof m === "string" && m !== "")
74
+ return m;
75
+ }
76
+ return String(e);
77
+ }
66
78
  async function resolveBaseCommit(env, repoRoot, baseRef) {
67
79
  const rev = async (ref) => {
68
80
  const r = await env.exec(`git rev-parse --verify --quiet ${shq(`${ref}^{commit}`)}`, { cwd: repoRoot });
@@ -123,89 +135,103 @@ export function createWorktreeTools(env, opts) {
123
135
  "Use ExitWorktree to leave it before creating a new worktree, or pass `path` to switch into an " +
124
136
  "existing worktree of this repository.");
125
137
  }
126
- const probe = await env.exec("git rev-parse --is-inside-work-tree", { cwd: repoRoot });
127
- if (!probe.ok || probe.value.exitCode !== 0) {
128
- const detail = probe.ok ? (probe.value.stderr || probe.value.stdout).trim().split("\n")[0] : String(probe.error);
129
- return errorResult(`Error (EnterWorktree): the working directory ${repoRoot} is not a git repository` +
130
- `${detail ? ` (${detail})` : ""}. This deployment has no VCS-agnostic worktree extension point, ` +
131
- "so worktree isolation is unavailable here.");
138
+ if (session.entering === true) {
139
+ return errorResult("Error (EnterWorktree): another EnterWorktree call on this session is already in progress — wait for it to " +
140
+ "finish and then act on its result. Two concurrent entries would leave one worktree untracked and unremovable.");
132
141
  }
133
- if (path !== undefined) {
134
- const candidate = resolve(cwdRef.current, path);
135
- const top = await env.exec("git rev-parse --show-toplevel", { cwd: candidate });
136
- if (!top.ok || top.value.exitCode !== 0) {
137
- return errorResult(`Error (EnterWorktree): ${candidate} is not an accessible git worktree (cannot resolve its top-level directory).`);
138
- }
139
- const target = top.value.stdout.trim();
140
- const list = await env.exec("git worktree list --porcelain", { cwd: repoRoot });
141
- if (!list.ok || list.value.exitCode !== 0) {
142
- return errorResult("Error (EnterWorktree): could not read `git worktree list` for this repository.");
143
- }
144
- const registered = list.value.stdout
145
- .split("\n")
146
- .filter((l) => l.startsWith("worktree "))
147
- .map((l) => l.slice("worktree ".length).trim());
148
- if (!registered.includes(target)) {
149
- return errorResult(`Error (EnterWorktree): ${target} is not a registered worktree of this repository ` +
150
- "(it does not appear in `git worktree list`).");
142
+ if (session.exiting === true) {
143
+ return errorResult("Error (EnterWorktree): an ExitWorktree call on this session is already in progress — wait for it to finish " +
144
+ "before entering another worktree.");
145
+ }
146
+ session.entering = true;
147
+ try {
148
+ const probe = await env.exec("git rev-parse --is-inside-work-tree", { cwd: repoRoot });
149
+ if (!probe.ok || probe.value.exitCode !== 0) {
150
+ const detail = probe.ok ? (probe.value.stderr || probe.value.stdout).trim().split("\n")[0] : String(probe.error);
151
+ return errorResult(`Error (EnterWorktree): the working directory ${repoRoot} is not a git repository` +
152
+ `${detail ? ` (${detail})` : ""}. This deployment has no VCS-agnostic worktree extension point, ` +
153
+ "so worktree isolation is unavailable here.");
151
154
  }
152
- if (target === registered[0]) {
153
- return errorResult(`Error (EnterWorktree): ${target} is the main working tree, not a linked worktree — nothing to enter.`);
155
+ if (path !== undefined) {
156
+ const candidate = resolve(cwdRef.current, path);
157
+ const top = await env.exec("git rev-parse --show-toplevel", { cwd: candidate });
158
+ if (!top.ok || top.value.exitCode !== 0) {
159
+ return errorResult(`Error (EnterWorktree): ${candidate} is not an accessible git worktree (cannot resolve its top-level directory).`);
160
+ }
161
+ const target = top.value.stdout.trim();
162
+ const list = await env.exec("git worktree list --porcelain", { cwd: repoRoot });
163
+ if (!list.ok || list.value.exitCode !== 0) {
164
+ return errorResult("Error (EnterWorktree): could not read `git worktree list` for this repository.");
165
+ }
166
+ const registered = list.value.stdout
167
+ .split("\n")
168
+ .filter((l) => l.startsWith("worktree "))
169
+ .map((l) => l.slice("worktree ".length).trim());
170
+ if (!registered.includes(target)) {
171
+ return errorResult(`Error (EnterWorktree): ${target} is not a registered worktree of this repository ` +
172
+ "(it does not appear in `git worktree list`).");
173
+ }
174
+ if (target === registered[0]) {
175
+ return errorResult(`Error (EnterWorktree): ${target} is the main working tree, not a linked worktree — nothing to enter.`);
176
+ }
177
+ if (target !== repoRoot && !target.startsWith(repoRoot.endsWith("/") ? repoRoot : `${repoRoot}/`)) {
178
+ return errorResult(`Error (EnterWorktree): the worktree ${target} lives outside the task root ${repoRoot}; ` +
179
+ "file containment stays anchored at the task root, so it cannot be entered from this session.");
180
+ }
181
+ const head = await env.exec("git rev-parse HEAD", { cwd: target });
182
+ if (!head.ok || head.value.exitCode !== 0) {
183
+ return errorResult(`Error (EnterWorktree): cannot resolve HEAD of the worktree ${target}.`);
184
+ }
185
+ const previous = session.current;
186
+ const originalCwd = previous ? previous.originalCwd : cwdRef.current;
187
+ session.current = { worktreeDir: target, originalCwd, baseSha: head.value.stdout.trim(), entered: true };
188
+ cwdRef.current = target;
189
+ return {
190
+ content: `Switched into the existing worktree at ${target}${previous ? ` (the previous worktree at ${previous.worktreeDir} was left on disk, untouched)` : ""}. ` +
191
+ "The session's working directory is now this worktree — subsequent commands and relative paths " +
192
+ "operate there. Note this is a cwd switch, not a sandbox: file containment stays anchored at the " +
193
+ "task root. ExitWorktree will restore the original directory but will NOT remove a worktree " +
194
+ "entered via `path`.",
195
+ details: { type: "worktree", action: "enter", worktreeDir: target },
196
+ };
154
197
  }
155
- if (target !== repoRoot && !target.startsWith(repoRoot.endsWith("/") ? repoRoot : `${repoRoot}/`)) {
156
- return errorResult(`Error (EnterWorktree): the worktree ${target} lives outside the task root ${repoRoot}; ` +
157
- "file containment stays anchored at the task root, so it cannot be entered from this session.");
198
+ const base = await resolveBaseCommit(env, repoRoot, baseRef ?? "fresh");
199
+ if ("error" in base)
200
+ return errorResult(`Error (EnterWorktree): ${base.error}.`);
201
+ let createdDir;
202
+ try {
203
+ const { worktreeDir } = await addWorktree(env, {
204
+ repoRoot,
205
+ sessionId: name ?? "worktree",
206
+ commit: base.sha,
207
+ rootEnvAt: (dir) => Object.create(env, {
208
+ cwd: { value: dir, configurable: true },
209
+ destroy: { value: undefined, writable: true, configurable: true },
210
+ }),
211
+ });
212
+ createdDir = worktreeDir;
158
213
  }
159
- const head = await env.exec("git rev-parse HEAD", { cwd: target });
160
- if (!head.ok || head.value.exitCode !== 0) {
161
- return errorResult(`Error (EnterWorktree): cannot resolve HEAD of the worktree ${target}.`);
214
+ catch (e) {
215
+ return errorResult(`Error (EnterWorktree): ${e instanceof Error ? e.message : String(e)}`);
162
216
  }
163
- const previous = session.current;
164
- const originalCwd = previous ? previous.originalCwd : cwdRef.current;
165
- session.current = { worktreeDir: target, originalCwd, baseSha: head.value.stdout.trim(), entered: true };
166
- cwdRef.current = target;
217
+ session.current = {
218
+ worktreeDir: createdDir,
219
+ originalCwd: cwdRef.current,
220
+ baseSha: base.sha,
221
+ };
222
+ cwdRef.current = createdDir;
167
223
  return {
168
- content: `Switched into the existing worktree at ${target}${previous ? ` (the previous worktree at ${previous.worktreeDir} was left on disk, untouched)` : ""}. ` +
169
- "The session's working directory is now this worktree — subsequent commands and relative paths " +
224
+ content: `Created worktree at ${createdDir} (base: ${base.label}${base.note ?? ""}). ` +
225
+ "The session's working directory is now the worktree — subsequent commands and relative paths " +
170
226
  "operate there. Note this is a cwd switch, not a sandbox: file containment stays anchored at the " +
171
- "task root. ExitWorktree will restore the original directory but will NOT remove a worktree " +
172
- "entered via `path`.",
173
- details: { type: "worktree", action: "enter", worktreeDir: target },
227
+ "task root, so the rest of the repository remains reachable. " +
228
+ "Use ExitWorktree to leave (an unchanged worktree is removed; a changed one is kept).",
229
+ details: { type: "worktree", action: "create", worktreeDir: createdDir, base: base.label },
174
230
  };
175
231
  }
176
- const base = await resolveBaseCommit(env, repoRoot, baseRef ?? "fresh");
177
- if ("error" in base)
178
- return errorResult(`Error (EnterWorktree): ${base.error}.`);
179
- let createdDir;
180
- try {
181
- const { worktreeDir } = await addWorktree(env, {
182
- repoRoot,
183
- sessionId: name ?? "worktree",
184
- commit: base.sha,
185
- rootEnvAt: (dir) => Object.create(env, {
186
- cwd: { value: dir, configurable: true },
187
- destroy: { value: undefined, writable: true, configurable: true },
188
- }),
189
- });
190
- createdDir = worktreeDir;
191
- }
192
- catch (e) {
193
- return errorResult(`Error (EnterWorktree): ${e instanceof Error ? e.message : String(e)}`);
232
+ finally {
233
+ session.entering = false;
194
234
  }
195
- session.current = {
196
- worktreeDir: createdDir,
197
- originalCwd: cwdRef.current,
198
- baseSha: base.sha,
199
- };
200
- cwdRef.current = createdDir;
201
- return {
202
- content: `Created worktree at ${createdDir} (base: ${base.label}${base.note ?? ""}). ` +
203
- "The session's working directory is now the worktree — subsequent commands and relative paths " +
204
- "operate there. Note this is a cwd switch, not a sandbox: file containment stays anchored at the " +
205
- "task root, so the rest of the repository remains reachable. " +
206
- "Use ExitWorktree to leave (an unchanged worktree is removed; a changed one is kept).",
207
- details: { type: "worktree", action: "create", worktreeDir: createdDir, base: base.label },
208
- };
209
235
  },
210
236
  });
211
237
  const exit = defineTool({
@@ -239,86 +265,150 @@ export function createWorktreeTools(env, opts) {
239
265
  return ("No-op: there is no active EnterWorktree session to exit. This tool only operates on the " +
240
266
  "worktree created by EnterWorktree in this session; no filesystem changes were made.");
241
267
  }
242
- const active = session.current;
243
- session.current = undefined;
244
- let restoredTo = active.originalCwd;
245
- let restoreNote = "";
246
- const origThere = await env.exists(active.originalCwd);
247
- if (origThere.ok && !origThere.value) {
248
- restoredTo = repoRoot;
249
- restoreNote = ` (the original directory ${active.originalCwd} no longer exists — fell back to the repository root)`;
268
+ if (session.exiting === true) {
269
+ return errorResult("Error (ExitWorktree): an exit of this worktree session is already in progress — wait for it to finish. " +
270
+ "Running a second exit now would race the same removal.");
250
271
  }
251
- cwdRef.current = restoredTo;
252
- if (active.entered) {
253
- const discardNote = discardChanges === true ? " (`discardChanges` does not apply to a worktree this session did not create)" : "";
254
- return {
255
- content: `Exited the worktree session; working directory restored to ${restoredTo}${restoreNote}. ` +
256
- `The worktree at ${active.worktreeDir} was entered via \`path\` (not created by this session) and ` +
257
- `was left on disk, untouched${discardNote}.`,
258
- details: { type: "worktree", action: "exit", worktreeDir: active.worktreeDir, kept: true },
259
- };
272
+ const active = session.current;
273
+ session.exiting = true;
274
+ try {
275
+ const outcome = await runExit(active, { keep, discardChanges, wantsRemove });
276
+ session.current = undefined;
277
+ return outcome;
260
278
  }
261
- const wtThere = await env.exists(active.worktreeDir);
262
- if (wtThere.ok && !wtThere.value) {
263
- await pruneWorktrees(env, repoRoot).catch(() => undefined);
264
- return {
265
- content: `Exited the worktree session; working directory restored to ${restoredTo}${restoreNote}. ` +
266
- `The worktree at ${active.worktreeDir} was already gone from disk (removed externally, not by this ` +
267
- "session — any uncommitted work in it is lost); stale git worktree metadata was pruned.",
268
- details: { type: "worktree", action: "exit", worktreeDir: active.worktreeDir, kept: false },
269
- };
279
+ catch (e) {
280
+ return errorResult(`Error (ExitWorktree): the exit failed before it could complete (${reasonOf(e)}). The worktree session at ` +
281
+ `${active.worktreeDir} is still active and nothing was removed — retry ExitWorktree.`);
270
282
  }
271
- let changed = true;
272
- let changeDetail = "could not verify the worktree state — keeping it to be safe";
273
- const status = await env.exec("git status --porcelain", { cwd: active.worktreeDir });
274
- if (status.ok && status.value.exitCode === 0) {
275
- const dirtyFiles = status.value.stdout.split("\n").filter((l) => l.trim() !== "").length;
276
- const ahead = await env.exec(`git rev-list --count ${shq(`${active.baseSha}..HEAD`)}`, { cwd: active.worktreeDir });
277
- const commits = ahead.ok && ahead.value.exitCode === 0 ? parseInt(ahead.value.stdout.trim(), 10) || 0 : -1;
278
- if (commits >= 0) {
279
- changed = dirtyFiles > 0 || commits > 0;
280
- const parts = [];
281
- if (dirtyFiles > 0)
282
- parts.push(`${dirtyFiles} uncommitted ${dirtyFiles === 1 ? "file" : "files"}`);
283
- if (commits > 0)
284
- parts.push(`${commits} new ${commits === 1 ? "commit" : "commits"}`);
285
- changeDetail = parts.join(" and ");
286
- }
283
+ finally {
284
+ session.exiting = false;
287
285
  }
288
- if (keep === true || (changed && discardChanges !== true)) {
289
- const why = keep === true ? "keep was requested" : `it has ${changeDetail}`;
290
- const removeHint = wantsRemove && keep !== true
291
- ? " To remove it anyway and permanently discard these changes, re-invoke with discard_changes: true."
292
- : "";
286
+ },
287
+ });
288
+ async function runExit(active, opts2) {
289
+ const { keep, discardChanges, wantsRemove } = opts2;
290
+ let restoredTo = active.originalCwd;
291
+ let restoreNote = "";
292
+ const origThere = await env.exists(active.originalCwd);
293
+ if (origThere.ok && !origThere.value) {
294
+ restoredTo = repoRoot;
295
+ restoreNote = ` (the original directory ${active.originalCwd} no longer exists — fell back to the repository root)`;
296
+ }
297
+ else if (!origThere.ok) {
298
+ restoreNote = ` (whether ${active.originalCwd} still exists could not be verified: ${reasonOf(origThere.error)} — the directory was restored as-is)`;
299
+ }
300
+ cwdRef.current = restoredTo;
301
+ if (active.entered) {
302
+ const discardNote = discardChanges === true ? " (`discardChanges` does not apply to a worktree this session did not create)" : "";
303
+ const enteredThere = await env.exists(active.worktreeDir);
304
+ if (enteredThere.ok && !enteredThere.value) {
293
305
  return {
294
306
  content: `Exited the worktree session; working directory restored to ${restoredTo}${restoreNote}. ` +
295
- `The worktree was KEPT at ${active.worktreeDir} (${why}) inspect or merge it there, and remove it ` +
296
- "later with `git worktree remove` if no longer needed." +
297
- removeHint,
298
- details: { type: "worktree", action: "exit", worktreeDir: active.worktreeDir, kept: true },
307
+ `The worktree at ${active.worktreeDir} was entered via \`path\` (not created by this session), so this ` +
308
+ `session never touched it but it is no longer on disk (removed by something else while it was entered)${discardNote}.`,
309
+ details: { type: "worktree", action: "exit", worktreeDir: active.worktreeDir, kept: false },
299
310
  };
300
311
  }
301
- await env.exec(`git worktree remove --force ${shq(active.worktreeDir)}`, { cwd: repoRoot }).catch(() => undefined);
302
- await pruneWorktrees(env, repoRoot).catch(() => undefined);
303
- const stillThere = await env.exists(active.worktreeDir);
304
- const detailForDiscard = changeDetail.startsWith("could not verify")
305
- ? "changes that could not be enumerated"
306
- : changeDetail;
307
- const stateClause = changed ? `had ${detailForDiscard} (discarded — discardChanges was set)` : "had no changes";
308
- if (stillThere.ok && stillThere.value) {
309
- return {
310
- content: `Exited the worktree session; working directory restored to ${restoredTo}${restoreNote}. ` +
311
- `The worktree at ${active.worktreeDir} ${changed ? "was marked for discard" : "had no changes"} but could NOT be removed — it is still on disk; ` +
312
- "remove it manually with `git worktree remove --force` if no longer needed.",
313
- details: { type: "worktree", action: "exit", worktreeDir: active.worktreeDir, kept: true },
314
- };
312
+ const enteredUnverified = enteredThere.ok ? "" : ` (whether it is still on disk could not be verified: ${reasonOf(enteredThere.error)})`;
313
+ return {
314
+ content: `Exited the worktree session; working directory restored to ${restoredTo}${restoreNote}. ` +
315
+ `The worktree at ${active.worktreeDir} was entered via \`path\` (not created by this session) and ` +
316
+ `was left on disk, untouched${enteredUnverified}${discardNote}.`,
317
+ details: {
318
+ type: "worktree",
319
+ action: "exit",
320
+ worktreeDir: active.worktreeDir,
321
+ ...(enteredThere.ok ? { kept: true } : {}),
322
+ },
323
+ };
324
+ }
325
+ const wtThere = await env.exists(active.worktreeDir);
326
+ if (wtThere.ok && !wtThere.value) {
327
+ const pruned = await pruneWorktrees(env, repoRoot);
328
+ return {
329
+ content: `Exited the worktree session; working directory restored to ${restoredTo}${restoreNote}. ` +
330
+ `The worktree at ${active.worktreeDir} was already gone from disk (removed externally, not by this ` +
331
+ "session — any uncommitted work in it is lost); " +
332
+ (pruned.ok
333
+ ? "stale git worktree metadata was pruned."
334
+ : `stale git worktree metadata could NOT be pruned (${pruned.detail}) — run \`git worktree prune\` in ${repoRoot} to clear the leftover registration.`),
335
+ details: {
336
+ type: "worktree",
337
+ action: "exit",
338
+ worktreeDir: active.worktreeDir,
339
+ kept: false,
340
+ ...(pruned.ok ? {} : { metadataPruned: false }),
341
+ },
342
+ };
343
+ }
344
+ let changed = true;
345
+ let changeDetail = "could not verify the worktree state — keeping it to be safe";
346
+ const status = await env.exec("git status --porcelain", { cwd: active.worktreeDir });
347
+ if (status.ok && status.value.exitCode === 0) {
348
+ const dirtyFiles = status.value.stdout.split("\n").filter((l) => l.trim() !== "").length;
349
+ const ahead = await env.exec(`git rev-list --count ${shq(`${active.baseSha}..HEAD`)}`, { cwd: active.worktreeDir });
350
+ const commits = ahead.ok && ahead.value.exitCode === 0 ? parseInt(ahead.value.stdout.trim(), 10) || 0 : -1;
351
+ if (commits >= 0) {
352
+ changed = dirtyFiles > 0 || commits > 0;
353
+ const parts = [];
354
+ if (dirtyFiles > 0)
355
+ parts.push(`${dirtyFiles} uncommitted ${dirtyFiles === 1 ? "file" : "files"}`);
356
+ if (commits > 0)
357
+ parts.push(`${commits} new ${commits === 1 ? "commit" : "commits"}`);
358
+ changeDetail = parts.join(" and ");
315
359
  }
360
+ }
361
+ if (keep === true || (changed && discardChanges !== true)) {
362
+ const why = keep === true ? "keep was requested" : `it has ${changeDetail}`;
363
+ const removeHint = wantsRemove && keep !== true
364
+ ? " To remove it anyway and permanently discard these changes, re-invoke with discard_changes: true."
365
+ : "";
316
366
  return {
317
367
  content: `Exited the worktree session; working directory restored to ${restoredTo}${restoreNote}. ` +
318
- `The worktree at ${active.worktreeDir} ${stateClause} and was removed.`,
319
- details: { type: "worktree", action: "exit", worktreeDir: active.worktreeDir, kept: false },
368
+ `The worktree was KEPT at ${active.worktreeDir} (${why}) — inspect or merge it there, and remove it ` +
369
+ "later with `git worktree remove` if no longer needed." +
370
+ removeHint,
371
+ details: { type: "worktree", action: "exit", worktreeDir: active.worktreeDir, kept: true },
320
372
  };
321
- },
322
- });
373
+ }
374
+ const removeFailure = await env
375
+ .exec(`git worktree remove --force ${shq(active.worktreeDir)}`, { cwd: repoRoot })
376
+ .then((r) => r.ok && r.value.exitCode === 0
377
+ ? undefined
378
+ : r.ok
379
+ ? (r.value.stderr || r.value.stdout || `exit ${r.value.exitCode}`).trim()
380
+ : reasonOf(r.error), (e) => reasonOf(e));
381
+ const pruned = await pruneWorktrees(env, repoRoot);
382
+ const stillThere = await env.exists(active.worktreeDir);
383
+ const removalNote = removeFailure !== undefined ? ` (the removal command reported: ${removeFailure})` : "";
384
+ const pruneNote = pruned.ok ? "" : ` (stale git worktree metadata could NOT be pruned: ${pruned.detail} — run \`git worktree prune\` in ${repoRoot})`;
385
+ const pruneDetail = pruned.ok ? {} : { metadataPruned: false };
386
+ const detailForDiscard = changeDetail.startsWith("could not verify")
387
+ ? "changes that could not be enumerated"
388
+ : changeDetail;
389
+ const stateClause = changed ? `had ${detailForDiscard} (discarded — discardChanges was set)` : "had no changes";
390
+ if (stillThere.ok && stillThere.value) {
391
+ return {
392
+ content: `Exited the worktree session; working directory restored to ${restoredTo}${restoreNote}. ` +
393
+ `The worktree at ${active.worktreeDir} ${changed ? "was marked for discard" : "had no changes"} but could NOT be removed — it is still on disk${removalNote}${pruneNote}; ` +
394
+ "remove it manually with `git worktree remove --force` if no longer needed.",
395
+ details: { type: "worktree", action: "exit", worktreeDir: active.worktreeDir, kept: true, ...pruneDetail },
396
+ };
397
+ }
398
+ if (!stillThere.ok) {
399
+ return {
400
+ content: `Exited the worktree session; working directory restored to ${restoredTo}${restoreNote}. ` +
401
+ `The worktree at ${active.worktreeDir} ${stateClause}, and its removal was attempted but could NOT be verified ` +
402
+ `(${reasonOf(stillThere.error)})${removalNote}${pruneNote} — check ${active.worktreeDir} and remove it manually with ` +
403
+ "`git worktree remove --force` if it is still there.",
404
+ details: { type: "worktree", action: "exit", worktreeDir: active.worktreeDir, ...pruneDetail },
405
+ };
406
+ }
407
+ return {
408
+ content: `Exited the worktree session; working directory restored to ${restoredTo}${restoreNote}. ` +
409
+ `The worktree at ${active.worktreeDir} ${stateClause} and was removed.${removalNote}${pruneNote}`,
410
+ details: { type: "worktree", action: "exit", worktreeDir: active.worktreeDir, kept: false, ...pruneDetail },
411
+ };
412
+ }
323
413
  return [enter, exit];
324
414
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "2.12.0",
3
+ "version": "3.0.0",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -57,7 +57,10 @@
57
57
  "eval": "tsx eval/run.ts",
58
58
  "prepare": "npm run build",
59
59
  "prepublishOnly": "npm run build && npm run test",
60
- "gate:blackbox": "node scripts/run-blackbox-gate.mjs"
60
+ "gate:blackbox": "node scripts/run-blackbox-gate.mjs",
61
+ "gate:ledger": "node scripts/verify-hardening-ledger.mjs",
62
+ "gate:compensation": "node scripts/verify-compensation-registry.mjs",
63
+ "gate:message-branching": "node scripts/verify-no-message-branching.mjs"
61
64
  },
62
65
  "dependencies": {
63
66
  "@modelcontextprotocol/sdk": "1.29.0",