sentinelayer-cli 0.46.5 → 0.48.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 (133) hide show
  1. package/README.md +47 -3
  2. package/package.json +12 -7
  3. package/skills/omar-gate-setup/SKILL.md +103 -0
  4. package/skills/senti-agent-identity/SKILL.md +80 -0
  5. package/skills/senti-judge/SKILL.md +139 -0
  6. package/skills/senti-room-notifications/SKILL.md +73 -0
  7. package/skills/senti-room-notifications/listen-wrapper.cjs +150 -0
  8. package/skills/senti-room-notifications/package.json +1 -0
  9. package/skills/senti-room-notifications/test-classifier.js +109 -0
  10. package/skills/senti-room-notifications/test-receipt.js +54 -0
  11. package/skills/sentinelayer-cli/SKILL.md +222 -0
  12. package/src/agents/devtestbot/runner.js +22 -11
  13. package/src/agents/devtestbot/tool.js +24 -11
  14. package/src/agents/jules/fix-cycle.js +7 -1
  15. package/src/agents/jules/tools/runtime-audit.js +17 -12
  16. package/src/agents/shared-tools/dispatch-core.js +10 -5
  17. package/src/ai/aidenid.js +4 -2
  18. package/src/ai/domain-target-store.js +11 -5
  19. package/src/ai/identity-store.js +11 -5
  20. package/src/ai/site-store.js +8 -4
  21. package/src/audit/orchestrator.js +21 -14
  22. package/src/audit/package.js +6 -4
  23. package/src/audit/persona-loop.js +7 -0
  24. package/src/audit/replay.js +4 -2
  25. package/src/auth/gate.js +32 -1
  26. package/src/auth/session-store.js +37 -0
  27. package/src/board/evidence-resolvers.js +3 -4
  28. package/src/cli.js +23 -3
  29. package/src/commands/ai/identity-lifecycle.js +30 -26
  30. package/src/commands/ai/provision-governance.js +32 -24
  31. package/src/commands/ai/shared.js +6 -4
  32. package/src/commands/audit.js +14 -12
  33. package/src/commands/board.js +2 -2
  34. package/src/commands/chat.js +8 -6
  35. package/src/commands/judge.js +125 -0
  36. package/src/commands/legacy-args.js +1 -0
  37. package/src/commands/mcp.js +45 -52
  38. package/src/commands/omargate.js +2 -1
  39. package/src/commands/plugin.js +11 -3
  40. package/src/commands/review.js +9 -4
  41. package/src/commands/scan.js +7 -7
  42. package/src/commands/session.js +516 -30
  43. package/src/commands/skills.js +45 -0
  44. package/src/commands/watch.js +9 -7
  45. package/src/config/output-filesystem.js +156 -0
  46. package/src/config/schema.js +4 -2
  47. package/src/config/service.js +40 -4
  48. package/src/cost/history.js +15 -9
  49. package/src/daemon/artifact-lineage.js +15 -12
  50. package/src/daemon/assignment-ledger.js +31 -25
  51. package/src/daemon/ast-drift.js +7 -3
  52. package/src/daemon/budget-governor.js +26 -20
  53. package/src/daemon/error-worker.js +28 -22
  54. package/src/daemon/fix-cycle.js +5 -4
  55. package/src/daemon/hybrid-mapper.js +19 -16
  56. package/src/daemon/ingest-refresh.js +4 -0
  57. package/src/daemon/jira-lifecycle.js +21 -17
  58. package/src/daemon/operator-control.js +18 -15
  59. package/src/daemon/reliability-lane.js +18 -15
  60. package/src/daemon/scope-engine.js +12 -8
  61. package/src/daemon/watchdog.js +15 -12
  62. package/src/engram/build-state.js +24 -3
  63. package/src/engram/capability-index.js +367 -0
  64. package/src/engram/compile-graph.js +1063 -0
  65. package/src/engram/contracts/action-receipt.js +82 -0
  66. package/src/engram/contracts/capability-index.js +259 -0
  67. package/src/engram/contracts/capability.js +377 -0
  68. package/src/engram/contracts/content-hash.js +8 -0
  69. package/src/engram/contracts/evidence.js +258 -0
  70. package/src/engram/contracts/graph.js +594 -0
  71. package/src/engram/contracts/identity.js +8 -0
  72. package/src/engram/contracts/index.js +149 -0
  73. package/src/engram/contracts/media-asset.js +85 -0
  74. package/src/engram/contracts/money.js +249 -0
  75. package/src/engram/contracts/source-snapshot.js +57 -0
  76. package/src/engram/contracts/task-context-pack.js +114 -0
  77. package/src/engram/contracts/temporal-segment.js +72 -0
  78. package/src/engram/contracts/versions.js +96 -0
  79. package/src/engram/extract-html.js +692 -0
  80. package/src/engram/graph-store.js +877 -0
  81. package/src/engram/index.js +98 -14
  82. package/src/engram/ingest-site.js +132 -0
  83. package/src/engram/live-state.js +1180 -0
  84. package/src/engram/namespace.js +34 -1
  85. package/src/engram/retrieve-site.js +753 -0
  86. package/src/engram/safe-reader.js +597 -0
  87. package/src/engram/source-intake.js +251 -0
  88. package/src/engram/store.js +227 -9
  89. package/src/ingest/engine.js +21 -28
  90. package/src/judge/verdict.js +305 -0
  91. package/src/legacy-cli.js +96 -19
  92. package/src/mcp/bridge-inputs.js +25 -4
  93. package/src/mcp/cli-command-tools.js +70 -0
  94. package/src/mcp/registry.js +17 -13
  95. package/src/mcp/session-stdio-server.js +297 -19
  96. package/src/memory/blackboard.js +5 -4
  97. package/src/net/gh-api-path.js +35 -9
  98. package/src/net/test-egress-guard.js +16 -4
  99. package/src/plugin/manifest.js +12 -8
  100. package/src/review/ai-review.js +110 -10
  101. package/src/review/investor-dd-devtestbot.js +17 -6
  102. package/src/review/investor-dd-orchestrator.js +21 -13
  103. package/src/review/local-review.js +26 -16
  104. package/src/review/omargate-cache.js +5 -5
  105. package/src/review/omargate-orchestrator.js +89 -17
  106. package/src/review/replay.js +9 -4
  107. package/src/review/report.js +22 -13
  108. package/src/review/typesafe-severity.js +201 -0
  109. package/src/session/admission-auth.js +34 -7
  110. package/src/session/admission.js +110 -15
  111. package/src/session/attachment-summary.js +77 -0
  112. package/src/session/edit-summary.js +19 -0
  113. package/src/session/invitations.js +65 -1
  114. package/src/session/mentions.js +17 -9
  115. package/src/session/message-edits.js +5 -4
  116. package/src/session/preview.js +10 -2
  117. package/src/session/recap.js +3 -2
  118. package/src/session/room-files.js +519 -0
  119. package/src/session/route-classes.js +22 -8
  120. package/src/session/setup-guides.js +4 -0
  121. package/src/session/standup.js +192 -0
  122. package/src/session/sync.js +229 -10
  123. package/src/session/tickets.js +41 -0
  124. package/src/session/transcript.js +2 -1
  125. package/src/session/wake/resolve-target.js +7 -3
  126. package/src/skills/listener-runtime.cjs +92 -0
  127. package/src/skills/service.js +207 -0
  128. package/src/swarm/factory.js +5 -5
  129. package/src/swarm/pentest.js +9 -9
  130. package/src/swarm/report.js +4 -4
  131. package/src/swarm/runtime.js +15 -12
  132. package/src/swarm/scenario-dsl.js +7 -5
  133. package/src/telemetry/ledger.js +16 -7
@@ -207,6 +207,47 @@ export async function ticketEvents(
207
207
  });
208
208
  }
209
209
 
210
+ /** Create with an explicit acting agent claim; the API authorizes it, never a createdBy body. */
211
+ export async function createTicket(sessionId, {
212
+ title, body = "", agentId = "", assignee = "", sourceSequenceId = null,
213
+ idempotencyKey = `ticket-create-${randomUUID()}`, targetPath = process.cwd(),
214
+ resolveAuthSession = resolveActiveAuthSession, requestMutation = requestJsonMutation,
215
+ } = {}) {
216
+ const cleanTitle = normalizeString(title), cleanAgent = normalizeString(agentId);
217
+ if (!cleanTitle || [...cleanTitle].length > 200 || typeof body !== "string" || [...body].length > 8000) throw new Error("Ticket title/body exceeds its limits.");
218
+ if (cleanAgent && !/^[a-zA-Z0-9._:-]{1,64}$/.test(cleanAgent)) throw new Error("Invalid agent identity.");
219
+ if (sourceSequenceId !== null && (!Number.isSafeInteger(sourceSequenceId) || sourceSequenceId < 1)) throw new Error("Invalid source sequence.");
220
+ if (typeof idempotencyKey !== "string" || !idempotencyKey.trim() || idempotencyKey.length > 128) throw new Error("Invalid idempotency key.");
221
+ const { credential, apiUrl } = await auth(targetPath, resolveAuthSession);
222
+ try {
223
+ const ticket = await mutate(ticketsUrl(apiUrl, sessionId), { credential, key: idempotencyKey, operation: "create", requestMutation,
224
+ body: { title: cleanTitle, body, ...(cleanAgent ? { agentId: cleanAgent } : {}),
225
+ ...(normalizeString(assignee) ? { assignee: normalizeString(assignee) } : {}),
226
+ ...(sourceSequenceId !== null ? { sourceSequenceId } : {}) },
227
+ });
228
+ if (typeof ticket?.id !== "string" || !ticket.id.trim()) {
229
+ throw new Error("The API returned no usable ticket identity; creation may have committed.");
230
+ }
231
+ // The public API projects server-authored creator/sourceMessage. Legacy
232
+ // createdBy and a top-level sourceSequenceId do not establish that provenance.
233
+ // Keep the returned ticket and retry key even when confirmation is incomplete.
234
+ const creator = ticket?.creator;
235
+ const source = ticket?.sourceMessage;
236
+ const provenance = {
237
+ agentRequested: cleanAgent || null,
238
+ agentRecorded: Boolean(cleanAgent) && creator?.kind === "agent" && creator.id === cleanAgent,
239
+ sourceRequested: sourceSequenceId,
240
+ sourceRecorded: sourceSequenceId !== null && source?.sequenceId === sourceSequenceId
241
+ && typeof source.id === "string" && /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(source.id)
242
+ && Number.isSafeInteger(source.revision) && source.revision >= 1,
243
+ };
244
+ return { ticket, idempotencyKey, provenance };
245
+ } catch (error) {
246
+ // Retain the logical write's retry key even if the server committed but the response was lost.
247
+ throw new Error(`${error.message || "Ticket creation failed"} (retry with --idempotency-key ${idempotencyKey})`, { cause: error });
248
+ }
249
+ }
250
+
210
251
  async function mutate(url, { credential, key, operation, body, requestMutation }) {
211
252
  return checkedTransport(requestMutation)(url, {
212
253
  method: "POST",
@@ -20,6 +20,7 @@
20
20
 
21
21
  import { PERSONA_VISUALS, ORCHESTRATOR_VISUALS } from "../agents/persona-visuals.js";
22
22
  import { buildSessionUsageLedger } from "./pricing-ledger.js";
23
+ import { appendAttachmentSummary } from "./attachment-summary.js";
23
24
 
24
25
  /**
25
26
  * Avatar map for client families (the OUTSIDE-the-persona-set agents
@@ -720,7 +721,7 @@ export function buildTranscriptMarkdown({
720
721
  profile,
721
722
  });
722
723
  const ts = eventTimestamp(event);
723
- const body = eventBody(event);
724
+ const body = appendAttachmentSummary(eventBody(event), { sessionId, ...event });
724
725
 
725
726
  if (SYSTEM_EVENT_KINDS.has(kind)) {
726
727
  const hint = body || kind.replace(/_/g, " ");
@@ -17,6 +17,7 @@
17
17
  // here is small enough to own.
18
18
 
19
19
  import { messageRevision, sessionMessageEditSnapshot, sessionMessageRoutingEvent } from "../message-edits.js";
20
+ import { formatAttachmentSummary } from "../attachment-summary.js";
20
21
 
21
22
  const BROADCAST_RECIPIENTS = new Set(["*", "all", "broadcast", "everyone", "anyone", "agents", "all-agents"]);
22
23
  const DEFAULT_WAKE_EVENT_TYPES = new Set(["session_message", "help_request", "session_message_edited"]);
@@ -100,9 +101,12 @@ function defaultFormatMessage(event, agentId) {
100
101
  const author = agentIdOf(event) || "unknown";
101
102
  const text = event?.payload?.message ?? event?.message ?? "";
102
103
  const body = typeof text === "string" ? text : "";
103
- const head = `Senti wake for ${agentId}: ${event.event === "session_message_edited" ? "edited message" : "new message"} from ${author}.`;
104
- const combined = body ? `${head}\n\n${body}` : head;
105
- return combined.length > MAX_WAKE_MESSAGE_CHARS ? combined.slice(0, MAX_WAKE_MESSAGE_CHARS) : combined;
104
+ const head = `Senti wake for ${agentId}: ${event.event === "session_message_edited" ? "edited message" : "new message"} from ${author}.`
105
+ .slice(0, MAX_WAKE_MESSAGE_CHARS);
106
+ const attachmentBudget = Math.max(0, MAX_WAKE_MESSAGE_CHARS - head.length - 2);
107
+ const attachments = formatAttachmentSummary(event).slice(0, attachmentBudget);
108
+ const bodyBudget = Math.max(0, MAX_WAKE_MESSAGE_CHARS - head.length - 2 - (attachments ? attachments.length + 2 : 0));
109
+ return [head, body.slice(0, bodyBudget), attachments].filter(Boolean).join("\n\n");
106
110
  }
107
111
 
108
112
  /**
@@ -0,0 +1,92 @@
1
+ const { spawn, execFile } = require("node:child_process");
2
+ const readline = require("node:readline");
3
+
4
+ // Process lifecycle for the bundled notification wrapper. Commands use argv, never a shell.
5
+ function runListener({ me: ME, sessionId: SID, cliPath: CLI, cwd: CWD, pollCwd: POLL_CWD, wake: WAKE = null, classifier }) {
6
+ const { deliver, observe, rememberMine, newerThanDelivered, flushBroadcasts } = classifier;
7
+ const { hadReceiptAtBoot } = classifier.state();
8
+ let restarts = 0;
9
+ let startedAt = 0;
10
+
11
+ function wanted(who) {
12
+ if (who === ME) return false;
13
+ return WAKE === null || WAKE.has(who);
14
+ }
15
+
16
+ function handle(line) {
17
+ let e;
18
+ try { e = JSON.parse(line); } catch {
19
+ if (/error|Error|refused|403|401|stopped|wedged/i.test(line)) console.log("[raw] " + line.slice(0, 300));
20
+ return;
21
+ }
22
+ const ev = e.event || "";
23
+ const who = (e.agent && e.agent.id) || e.agentId || "?";
24
+ if (who !== ME && (ev === "session_message" || ev === "session_reply")) observe(e.ts);
25
+ if (who === ME) rememberMine(e.sequenceId);
26
+ if (!wanted(who)) return;
27
+ const p = e.payload || {};
28
+ if (ev === "session_message" || ev === "session_reply") {
29
+ const text = (p.message || p.note || "").replace(/\s+/g, " ");
30
+ deliver(ev, who, e.ts, e.sequenceId, p.replyToSequenceId || p.targetSequenceId, text);
31
+ } else if (ev === "daemon_alert" && p.alert && /human|directive|mention|assign|lock|stuck/i.test(p.alert) && (!p.targetAgentId || p.targetAgentId === ME)) {
32
+ console.log(`[alert] ${p.alert} ${p.targetAgentId || ""} ${(p.message || "").slice(0, 200)}`);
33
+ }
34
+ }
35
+
36
+ function start() {
37
+ startedAt = Date.now();
38
+ const p = spawn(process.execPath, [CLI, "session", "listen", "--session", SID, "--agent", ME,
39
+ "--active-interval", "15", "--active-window", "600", "--interval", "15", "--presence-interval", "60",
40
+ "--display-name", ME, "--force", "--from-now"],
41
+ { cwd: CWD, env: process.env, stdio: ["pipe", "pipe", "pipe"] });
42
+ readline.createInterface({ input: p.stdout }).on("line", handle);
43
+ readline.createInterface({ input: p.stderr }).on("line", (l) => {
44
+ if (/error|Error|refused|403|401|wedged|circuit/i.test(l)) console.log("[listener-stderr] " + l.slice(0, 300));
45
+ });
46
+ p.on("exit", (code, sig) => {
47
+ const wasHealthy = Date.now() - startedAt > 60000;
48
+ if (wasHealthy) restarts = 0;
49
+ restarts++;
50
+ if (!wasHealthy && (restarts === 2 || restarts % 25 === 0)) console.log(`[listener-flapping] code=${code} sig=${sig} rapid-restarts=${restarts}`);
51
+ if (restarts > 500) { console.log("[listener-giving-up] too many rapid restarts"); process.exit(1); }
52
+ setTimeout(start, 5000);
53
+ });
54
+ }
55
+ start();
56
+
57
+ const seenReplies = new Set();
58
+ let repliesPrimed = false;
59
+ function pollReplies() {
60
+ execFile(process.execPath, [CLI, "session", "read", SID, "--remote", "--agent", ME, "--tail", "40", "--json"],
61
+ { cwd: POLL_CWD, env: process.env, maxBuffer: 16 * 1024 * 1024 }, (err, stdout) => {
62
+ if (err) { console.log("[reply-poll-error] " + String(err.message).slice(0, 200)); return; }
63
+ let j; try { j = JSON.parse(stdout); } catch { return; }
64
+ const items = [];
65
+ for (const e of j.events || []) if (e.event === "session_reply" || e.event === "session_message")
66
+ items.push({ key: (e.ts || "") + "|" + ((e.payload && (e.payload.targetSequenceId || e.payload.replyToSequenceId)) || ""), who: (e.agent && e.agent.id) || "?", ts: e.ts, target: e.payload && (e.payload.targetSequenceId || e.payload.replyToSequenceId), text: (e.payload && (e.payload.message || e.payload.note)) || "" });
67
+ for (const a of j.recentHumanActivity || [])
68
+ items.push({ key: (a.createdAt || "") + "|" + (a.targetSequenceId || ""), who: a.actorId || "human", ts: a.createdAt, target: a.targetSequenceId, text: a.note || "" });
69
+ for (const it of items) {
70
+ if (seenReplies.has(it.key)) continue;
71
+ seenReplies.add(it.key);
72
+ if (it.who !== ME) observe(it.ts);
73
+ if (!repliesPrimed && !(hadReceiptAtBoot && newerThanDelivered(it.ts))) continue;
74
+ if (!wanted(it.who)) continue;
75
+ deliver("session_reply", it.who, it.ts, null, it.target, String(it.text).replace(/\s+/g, " "));
76
+ }
77
+ repliesPrimed = true;
78
+ });
79
+ }
80
+ pollReplies();
81
+ setInterval(pollReplies, 20000);
82
+ console.log(`[listener-started] ${ME} listening on ${SID.slice(0, 8)} (presence heartbeats on; reply-poll every 20s)`);
83
+
84
+ function flushAndExit(sig) {
85
+ try { flushBroadcasts(); } catch {}
86
+ process.exit(sig === "SIGINT" ? 130 : 0);
87
+ }
88
+ process.on("SIGTERM", () => flushAndExit("SIGTERM"));
89
+ process.on("SIGINT", () => flushAndExit("SIGINT"));
90
+ }
91
+
92
+ module.exports = { runListener };
@@ -0,0 +1,207 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import fs from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { setTimeout as delay } from "node:timers/promises";
8
+ import { parse } from "yaml";
9
+
10
+ const BUNDLE_ROOT = fileURLToPath(new URL("../../skills/", import.meta.url));
11
+ const SKILL_NAMES = ["senti-agent-identity", "senti-room-notifications", "sentinelayer-cli", "senti-judge", "omar-gate-setup"];
12
+
13
+ function sha256(value) {
14
+ return createHash("sha256").update(value).digest("hex");
15
+ }
16
+
17
+ async function statOrNull(filePath) {
18
+ try {
19
+ return await fs.lstat(filePath);
20
+ } catch (error) {
21
+ if (error.code === "ENOENT") return null;
22
+ throw error;
23
+ }
24
+ }
25
+
26
+ function unsafePath(filePath) {
27
+ return Object.assign(new Error(`Expected a regular file or directory, not a link: ${filePath}`), {
28
+ code: "SKILLS_UNSAFE_PATH",
29
+ });
30
+ }
31
+
32
+ async function readTree(directory) {
33
+ const rootStat = await statOrNull(directory);
34
+ if (!rootStat) return null;
35
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw unsafePath(directory);
36
+ const files = [];
37
+ async function visit(relative) {
38
+ const entries = await fs.readdir(path.join(directory, relative), { withFileTypes: true });
39
+ entries.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
40
+ for (const entry of entries) {
41
+ const name = relative ? `${relative}/${entry.name}` : entry.name;
42
+ const fullPath = path.join(directory, name);
43
+ if (entry.isDirectory()) await visit(name);
44
+ else if (entry.isFile()) files.push({ name, content: await fs.readFile(fullPath) });
45
+ else throw unsafePath(fullPath);
46
+ }
47
+ }
48
+ await visit("");
49
+ return files;
50
+ }
51
+
52
+ function describe(files) {
53
+ const document = files.find((file) => file.name === "SKILL.md")?.content.toString("utf8") || "";
54
+ const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(document);
55
+ let version = null;
56
+ try {
57
+ const value = frontmatter ? parse(frontmatter[1])?.metadata?.version : null;
58
+ if (typeof value === "string") version = value;
59
+ } catch {
60
+ // Locally edited frontmatter can be invalid; its content hash still reports the change.
61
+ }
62
+ return {
63
+ version,
64
+ hash: sha256(JSON.stringify(files.map((file) => [file.name, sha256(file.content)]))),
65
+ };
66
+ }
67
+
68
+ function resolveDirectory(dir) {
69
+ if (dir === undefined) return path.join(os.homedir(), ".claude", "skills");
70
+ if (typeof dir !== "string" || !dir.trim()) throw new Error("--dir must be a nonempty path.");
71
+ return path.resolve(dir);
72
+ }
73
+
74
+ // Check every component below the chosen destination, including dangling symlinks.
75
+ async function inspectPath(root, destination) {
76
+ const relative = path.relative(root, destination);
77
+ if (relative.startsWith(`..${path.sep}`) || relative === ".." || path.isAbsolute(relative)) {
78
+ throw unsafePath(destination);
79
+ }
80
+ const components = relative ? relative.split(path.sep) : [];
81
+ let current = root;
82
+ for (let index = 0; index <= components.length; index += 1) {
83
+ const stat = await statOrNull(current);
84
+ if (stat && (stat.isSymbolicLink() || (index < components.length && !stat.isDirectory()))) {
85
+ throw unsafePath(current);
86
+ }
87
+ if (index === components.length) return stat;
88
+ current = path.join(current, components[index]);
89
+ }
90
+ }
91
+
92
+ function selectNames(names) {
93
+ if (names === undefined || names === null || (Array.isArray(names) && names.length === 0)) return SKILL_NAMES;
94
+ const wanted = (Array.isArray(names) ? names : [names]).map((name) => String(name).trim());
95
+ const unknown = wanted.filter((name) => !SKILL_NAMES.includes(name));
96
+ if (unknown.length) {
97
+ throw Object.assign(new Error(`Unknown skill: ${unknown.join(", ")}. Bundled skills: ${SKILL_NAMES.join(", ")}`), {
98
+ code: "SKILLS_UNKNOWN",
99
+ });
100
+ }
101
+ // Bundle order, each once.
102
+ return SKILL_NAMES.filter((name) => wanted.includes(name));
103
+ }
104
+
105
+ async function loadBundle(names = SKILL_NAMES) {
106
+ return Promise.all(names.map(async (name) => {
107
+ const files = await readTree(path.join(BUNDLE_ROOT, name));
108
+ if (!files?.some((file) => file.name === "SKILL.md")) {
109
+ throw new Error(`Bundled skill is missing SKILL.md: ${name}`);
110
+ }
111
+ const metadata = describe(files);
112
+ if (!metadata.version) throw new Error(`Bundled skill is missing a version: ${name}`);
113
+ return { name, files, ...metadata };
114
+ }));
115
+ }
116
+
117
+ async function replaceFile(source, destination) {
118
+ const deadline = Date.now() + 1000;
119
+ for (;;) {
120
+ try {
121
+ await fs.rename(source, destination);
122
+ return;
123
+ } catch (error) {
124
+ if (!["EPERM", "EBUSY"].includes(error.code) || Date.now() >= deadline) throw error;
125
+ await delay(25);
126
+ }
127
+ }
128
+ }
129
+
130
+ export async function listSkills({ dir } = {}) {
131
+ const directory = resolveDirectory(dir);
132
+ const skills = [];
133
+ for (const bundle of await loadBundle()) {
134
+ const destination = path.join(directory, bundle.name);
135
+ let installed = null;
136
+ let error = null;
137
+ try {
138
+ await inspectPath(directory, destination);
139
+ const files = await readTree(destination);
140
+ if (files) installed = describe(files);
141
+ } catch (caught) {
142
+ error = caught.message;
143
+ }
144
+ skills.push({
145
+ name: bundle.name,
146
+ bundled: { version: bundle.version, hash: bundle.hash },
147
+ installed,
148
+ status: error ? "invalid" : !installed ? "not-installed" :
149
+ installed.hash === bundle.hash ? "installed" : "modified",
150
+ ...(error ? { error } : {}),
151
+ });
152
+ }
153
+ return { directory, skills };
154
+ }
155
+
156
+ export async function installSkills({ dir, force = false, names } = {}) {
157
+ const directory = resolveDirectory(dir);
158
+ // Named skills only: a local edit to one skill never blocks installing another.
159
+ const bundles = await loadBundle(selectNames(names));
160
+ const plan = [];
161
+ const conflicts = [];
162
+ // Preflight the entire bundle so a conflict in the last skill cannot leave a partial install.
163
+ for (const bundle of bundles) {
164
+ for (const file of bundle.files) {
165
+ const destination = path.join(directory, bundle.name, file.name);
166
+ const stat = await inspectPath(directory, destination);
167
+ if (stat && !stat.isFile()) throw unsafePath(destination);
168
+ const unchanged = stat ? (await fs.readFile(destination)).equals(file.content) : false;
169
+ if (stat && !unchanged && !force) conflicts.push(destination);
170
+ plan.push({ bundle, file, destination, unchanged, exists: Boolean(stat) });
171
+ }
172
+ }
173
+ if (conflicts.length) {
174
+ throw Object.assign(new Error(`Local skill files differ; use --force to replace them:\n${conflicts.join("\n")}`), {
175
+ code: "SKILLS_CONFLICT", files: conflicts,
176
+ });
177
+ }
178
+ for (const item of plan) {
179
+ if (item.unchanged) continue;
180
+ await fs.mkdir(path.dirname(item.destination), { recursive: true });
181
+ await inspectPath(directory, item.destination);
182
+ if (!item.exists) {
183
+ // Exclusive creation also protects a file created after preflight.
184
+ await fs.copyFile(path.join(BUNDLE_ROOT, item.bundle.name, item.file.name), item.destination, constants.COPYFILE_EXCL);
185
+ } else {
186
+ const temporary = path.join(path.dirname(item.destination), `.skill-${randomUUID()}.tmp`);
187
+ try {
188
+ await fs.writeFile(temporary, item.file.content, { flag: "wx" });
189
+ // Replace the directory entry rather than modifying another file through a hard link.
190
+ await replaceFile(temporary, item.destination);
191
+ } finally {
192
+ await fs.rm(temporary, { force: true });
193
+ }
194
+ }
195
+ }
196
+ return {
197
+ directory,
198
+ skills: bundles.map((bundle) => {
199
+ const entries = plan.filter((item) => item.bundle === bundle);
200
+ return {
201
+ name: bundle.name, version: bundle.version, hash: bundle.hash,
202
+ written: entries.filter((item) => !item.unchanged).length,
203
+ unchanged: entries.filter((item) => item.unchanged).length,
204
+ };
205
+ }),
206
+ };
207
+ }
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import fsp from "node:fs/promises";
3
3
  import path from "node:path";
4
4
 
5
- import { resolveOutputRoot } from "../config/service.js";
5
+ import { resolveOutputContext } from "../config/service.js";
6
6
 
7
7
  const SCHEMA_VERSION = 1;
8
8
  const DEFAULT_GLOBAL_BUDGET = Object.freeze({
@@ -341,7 +341,7 @@ export async function writeSwarmPlanArtifacts({
341
341
  throw new Error("plan is required.");
342
342
  }
343
343
 
344
- const outputRoot = await resolveOutputRoot({
344
+ const { outputRoot, outputFs } = await resolveOutputContext({
345
345
  cwd: path.resolve(String(plan.targetPath || ".")),
346
346
  outputDirOverride: outputDir,
347
347
  env,
@@ -350,9 +350,9 @@ export async function writeSwarmPlanArtifacts({
350
350
  const runDirectory = path.join(outputRoot, "swarms", plan.runId);
351
351
  const planJsonPath = path.join(runDirectory, "SWARM_PLAN.json");
352
352
  const planMarkdownPath = path.join(runDirectory, "SWARM_PLAN.md");
353
- await fsp.mkdir(runDirectory, { recursive: true });
354
- await fsp.writeFile(planJsonPath, `${JSON.stringify(plan, null, 2)}\n`, "utf-8");
355
- await fsp.writeFile(planMarkdownPath, `${renderSwarmPlanMarkdown(plan).trim()}\n`, "utf-8");
353
+ await outputFs.mkdir(runDirectory, { recursive: true });
354
+ await outputFs.writeFile(planJsonPath, `${JSON.stringify(plan, null, 2)}\n`, "utf-8");
355
+ await outputFs.writeFile(planMarkdownPath, `${renderSwarmPlanMarkdown(plan).trim()}\n`, "utf-8");
356
356
 
357
357
  return {
358
358
  outputRoot,
@@ -3,7 +3,7 @@ import fsp from "node:fs/promises";
3
3
  import path from "node:path";
4
4
 
5
5
  import { getTargetById } from "../ai/domain-target-store.js";
6
- import { resolveOutputRoot } from "../config/service.js";
6
+ import { resolveOutputContext } from "../config/service.js";
7
7
 
8
8
  const PENTEST_SCHEMA_VERSION = 1;
9
9
 
@@ -679,7 +679,7 @@ export async function runSwarmPentest({
679
679
  }
680
680
  const parsedTargetUrl = parseTargetUrl(targetUrl);
681
681
 
682
- const outputRoot = await resolveOutputRoot({
682
+ const { outputRoot, outputFs } = await resolveOutputContext({
683
683
  cwd: normalizedTargetPath,
684
684
  outputDirOverride: outputDir,
685
685
  env,
@@ -747,8 +747,8 @@ export async function runSwarmPentest({
747
747
  const cleanupContractPath = path.join(pentestDirectory, "CLEANUP_CONTRACT.json");
748
748
  const reportJsonPath = path.join(pentestDirectory, "PENTEST_REPORT.json");
749
749
  const reportMarkdownPath = path.join(pentestDirectory, "PENTEST_REPORT.md");
750
- await fsp.mkdir(pentestDirectory, { recursive: true });
751
- await fsp.writeFile(
750
+ await outputFs.mkdir(pentestDirectory, { recursive: true });
751
+ await outputFs.writeFile(
752
752
  requestPlanPath,
753
753
  `${JSON.stringify(
754
754
  {
@@ -826,7 +826,7 @@ export async function runSwarmPentest({
826
826
  };
827
827
 
828
828
  await runCheckAtIndex(0);
829
- await fsp.writeFile(
829
+ await outputFs.writeFile(
830
830
  auditLogPath,
831
831
  auditEntries.length > 0 ? `${auditEntries.map((entry) => JSON.stringify(entry)).join("\n")}\n` : "",
832
832
  "utf-8"
@@ -856,7 +856,7 @@ export async function runSwarmPentest({
856
856
  },
857
857
  ],
858
858
  };
859
- await fsp.writeFile(identityIsolationPath, `${JSON.stringify(identityIsolation, null, 2)}\n`, "utf-8");
859
+ await outputFs.writeFile(identityIsolationPath, `${JSON.stringify(identityIsolation, null, 2)}\n`, "utf-8");
860
860
 
861
861
  const cleanupContract = {
862
862
  schemaVersion: 1,
@@ -868,7 +868,7 @@ export async function runSwarmPentest({
868
868
  cleanupRequested: true,
869
869
  cleanupTaskId: `cleanup-${runId}`,
870
870
  };
871
- await fsp.writeFile(cleanupContractPath, `${JSON.stringify(cleanupContract, null, 2)}\n`, "utf-8");
871
+ await outputFs.writeFile(cleanupContractPath, `${JSON.stringify(cleanupContract, null, 2)}\n`, "utf-8");
872
872
 
873
873
  const findings = [];
874
874
  for (const entry of results) {
@@ -922,8 +922,8 @@ export async function runSwarmPentest({
922
922
  reportMarkdownPath,
923
923
  };
924
924
 
925
- await fsp.writeFile(reportJsonPath, `${JSON.stringify(report, null, 2)}\n`, "utf-8");
926
- await fsp.writeFile(reportMarkdownPath, `${renderPentestReportMarkdown(report).trim()}\n`, "utf-8");
925
+ await outputFs.writeFile(reportJsonPath, `${JSON.stringify(report, null, 2)}\n`, "utf-8");
926
+ await outputFs.writeFile(reportMarkdownPath, `${renderPentestReportMarkdown(report).trim()}\n`, "utf-8");
927
927
 
928
928
  return {
929
929
  ...report,
@@ -1,7 +1,7 @@
1
1
  import fsp from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
- import { resolveOutputRoot } from "../config/service.js";
4
+ import { resolveOutputContext } from "../config/service.js";
5
5
  import { loadSwarmDashboardSnapshot, resolveSwarmRuntimeFiles } from "./dashboard.js";
6
6
 
7
7
  function normalizeString(value) {
@@ -109,7 +109,7 @@ export async function buildSwarmExecutionReport({
109
109
  homeDir,
110
110
  });
111
111
 
112
- const outputRoot = await resolveOutputRoot({
112
+ const { outputRoot, outputFs } = await resolveOutputContext({
113
113
  cwd: path.resolve(String(targetPath || ".")),
114
114
  outputDirOverride: outputDir,
115
115
  env,
@@ -152,7 +152,7 @@ export async function buildSwarmExecutionReport({
152
152
  reportMarkdownPath,
153
153
  };
154
154
 
155
- await fsp.writeFile(reportJsonPath, `${JSON.stringify(report, null, 2)}\n`, "utf-8");
156
- await fsp.writeFile(reportMarkdownPath, `${buildReportMarkdown(report).trim()}\n`, "utf-8");
155
+ await outputFs.writeFile(reportJsonPath, `${JSON.stringify(report, null, 2)}\n`, "utf-8");
156
+ await outputFs.writeFile(reportMarkdownPath, `${buildReportMarkdown(report).trim()}\n`, "utf-8");
157
157
  return report;
158
158
  }
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import fsp from "node:fs/promises";
3
3
  import path from "node:path";
4
4
 
5
- import { resolveOutputRoot } from "../config/service.js";
5
+ import { resolveOutputContext } from "../config/service.js";
6
6
  import { evaluateBudget } from "../cost/budget.js";
7
7
  import { estimateTokens } from "../cost/tokenizer.js";
8
8
 
@@ -121,7 +121,7 @@ function createEvent({
121
121
  };
122
122
  }
123
123
 
124
- async function executePlaywrightAction({ page, action, runDirectory, usage, runId, step }) {
124
+ async function executePlaywrightAction({ page, action, runDirectory, usage, runId, step, outputFs }) {
125
125
  const type = action.type;
126
126
  const metadata = {
127
127
  action: type,
@@ -195,14 +195,15 @@ async function executePlaywrightAction({ page, action, runDirectory, usage, runI
195
195
  });
196
196
  }
197
197
  if (type === "screenshot") {
198
- const outputPath = action.path
198
+ const requestedOutputPath = action.path
199
199
  ? path.resolve(runDirectory, action.path)
200
200
  : path.join(runDirectory, "runtime", `step-${String(step).padStart(3, "0")}.png`);
201
- await fsp.mkdir(path.dirname(outputPath), { recursive: true });
202
- await page.screenshot({
203
- path: outputPath,
201
+ const outputPath = await outputFs.assertFilePath(requestedOutputPath);
202
+ await outputFs.mkdir(path.dirname(outputPath), { recursive: true });
203
+ const screenshot = await page.screenshot({
204
204
  fullPage: true,
205
205
  });
206
+ await outputFs.writeFile(outputPath, screenshot);
206
207
  metadata.path = toPosixPath(path.relative(runDirectory, outputPath));
207
208
  usage.toolCalls += 1;
208
209
  usage.outputTokens += estimateTokens(`screenshot:${metadata.path}`);
@@ -268,7 +269,7 @@ async function writeRuntimeArtifacts({
268
269
  env,
269
270
  homeDir,
270
271
  } = {}) {
271
- const outputRoot = await resolveOutputRoot({
272
+ const { outputRoot, outputFs } = await resolveOutputContext({
272
273
  cwd: path.resolve(String(summary.targetPath || ".")),
273
274
  outputDirOverride: outputDir,
274
275
  env,
@@ -279,8 +280,8 @@ async function writeRuntimeArtifacts({
279
280
  const runtimeJsonPath = path.join(runtimeDirectory, "SWARM_RUNTIME.json");
280
281
  const runtimeMarkdownPath = path.join(runtimeDirectory, "SWARM_RUNTIME.md");
281
282
  const runtimeEventsPath = path.join(runtimeDirectory, "events.ndjson");
282
- await fsp.mkdir(runtimeDirectory, { recursive: true });
283
- await fsp.writeFile(runtimeEventsPath, `${events.map((event) => JSON.stringify(event)).join("\n")}\n`, "utf-8");
283
+ await outputFs.mkdir(runtimeDirectory, { recursive: true });
284
+ await outputFs.writeFile(runtimeEventsPath, `${events.map((event) => JSON.stringify(event)).join("\n")}\n`, "utf-8");
284
285
 
285
286
  const fullSummary = {
286
287
  ...summary,
@@ -292,8 +293,8 @@ async function writeRuntimeArtifacts({
292
293
  runtimeEventsPath,
293
294
  };
294
295
 
295
- await fsp.writeFile(runtimeJsonPath, `${JSON.stringify(fullSummary, null, 2)}\n`, "utf-8");
296
- await fsp.writeFile(runtimeMarkdownPath, `${buildRuntimeMarkdown(fullSummary).trim()}\n`, "utf-8");
296
+ await outputFs.writeFile(runtimeJsonPath, `${JSON.stringify(fullSummary, null, 2)}\n`, "utf-8");
297
+ await outputFs.writeFile(runtimeMarkdownPath, `${buildRuntimeMarkdown(fullSummary).trim()}\n`, "utf-8");
297
298
 
298
299
  return fullSummary;
299
300
  }
@@ -323,7 +324,7 @@ export async function runSwarmRuntime({
323
324
  const normalizedTargetPath = path.resolve(String(targetPath || plan.targetPath || "."));
324
325
  const normalizedMaxSteps = normalizeMaxSteps(maxSteps);
325
326
  const runId = createRuntimeRunId();
326
- const resolvedOutputRoot = await resolveOutputRoot({
327
+ const { outputRoot: resolvedOutputRoot, outputFs } = await resolveOutputContext({
327
328
  cwd: normalizedTargetPath,
328
329
  outputDirOverride: outputDir,
329
330
  env,
@@ -463,6 +464,7 @@ export async function runSwarmRuntime({
463
464
  const result = await runner(toolInput, {
464
465
  targetPath: normalizedTargetPath,
465
466
  outputRoot: resolvedOutputRoot,
467
+ outputFs,
466
468
  runId: toolInput.runId,
467
469
  execute: Boolean(execute),
468
470
  env,
@@ -583,6 +585,7 @@ export async function runSwarmRuntime({
583
585
  page,
584
586
  action,
585
587
  runDirectory: runtimeRunDirectory,
588
+ outputFs,
586
589
  usage,
587
590
  runId,
588
591
  step,
@@ -1,5 +1,6 @@
1
1
  import fsp from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { resolveOutputContext } from "../config/service.js";
3
4
 
4
5
  const SUPPORTED_ACTIONS = new Set(["goto", "click", "fill", "wait", "screenshot"]);
5
6
 
@@ -256,15 +257,16 @@ export async function writeScenarioTemplate({
256
257
  startUrl = "https://example.com",
257
258
  } = {}) {
258
259
  const normalizedTargetPath = path.resolve(String(targetPath || "."));
259
- const resolvedOutputFile = normalizeString(outputFile)
260
- ? path.resolve(normalizedTargetPath, outputFile)
261
- : path.join(normalizedTargetPath, ".sentinelayer", "scenarios", `${scenarioId}.sls`);
260
+ const { outputRoot: resolvedOutputFile, outputFs } = await resolveOutputContext({
261
+ cwd: normalizedTargetPath,
262
+ outputDirOverride: normalizeString(outputFile) || path.join(".sentinelayer", "scenarios", `${scenarioId}.sls`),
263
+ });
262
264
  const content = renderScenarioTemplate({
263
265
  scenarioId,
264
266
  startUrl,
265
267
  });
266
- await fsp.mkdir(path.dirname(resolvedOutputFile), { recursive: true });
267
- await fsp.writeFile(resolvedOutputFile, `${content.trim()}\n`, "utf-8");
268
+ await outputFs.mkdir(path.dirname(resolvedOutputFile), { recursive: true });
269
+ await outputFs.writeFile(resolvedOutputFile, `${content.trim()}\n`, "utf-8");
268
270
  return {
269
271
  filePath: resolvedOutputFile,
270
272
  content,