agenttwin 0.2.2 → 0.3.1

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/cli.js CHANGED
@@ -9,6 +9,236 @@ var __export = (target, all) => {
9
9
  __defProp(target, name, { get: all[name], enumerable: true });
10
10
  };
11
11
 
12
+ // src/runtime/git.ts
13
+ import { execFile } from "child_process";
14
+ import { promisify } from "util";
15
+ async function captureGit(cwd, previousHead) {
16
+ const exec = promisify(execFile);
17
+ const git = async (...args) => (await exec("git", args, { cwd, timeout: 5e3, maxBuffer: 1024 * 1024 })).stdout.trim();
18
+ try {
19
+ const head = await git("rev-parse", "HEAD");
20
+ const [branch, total, status, history] = await Promise.all([git("branch", "--show-current"), git("rev-list", "--count", "HEAD"), git("status", "--porcelain", "--untracked-files=no"), git("log", "--since=14 days ago", "-100", "--format=%H %cI")]);
21
+ let changed = await git("diff", "--name-only", "HEAD");
22
+ if (previousHead && /^[a-f0-9]{40,64}$/.test(previousHead) && previousHead !== head) {
23
+ try {
24
+ changed += "\n" + await git("diff", "--name-only", previousHead, head);
25
+ } catch {
26
+ previousHead = void 0;
27
+ }
28
+ }
29
+ return { head, branch: branch || "detached HEAD", totalCommits: Number(total), dirty: !!status, capturedAt: (/* @__PURE__ */ new Date()).toISOString(), commits: history ? history.split("\n").map((line) => {
30
+ const [sha, date] = line.split(" ");
31
+ return { sha, date };
32
+ }) : [], changedFiles: [...new Set(changed.split("\n").filter(Boolean))].slice(0, 1e3), ...previousHead ? { previousHead } : {} };
33
+ } catch {
34
+ return void 0;
35
+ }
36
+ }
37
+ function validateGit(value) {
38
+ if (value === void 0) return void 0;
39
+ const g = value;
40
+ const sha = (s) => typeof s === "string" && /^[a-f0-9]{40,64}$/.test(s);
41
+ const date = (s) => typeof s === "string" && s.length <= 40 && Number.isFinite(Date.parse(s));
42
+ if (!g || !sha(g.head) || g.previousHead !== void 0 && !sha(g.previousHead) || typeof g.branch !== "string" || g.branch.length > 240 || /[\x00-\x1f]/.test(g.branch) || !Number.isSafeInteger(g.totalCommits) || g.totalCommits < 0 || typeof g.dirty !== "boolean" || !date(g.capturedAt) || !Array.isArray(g.commits) || g.commits.length > 100 || g.commits.some((c) => !sha(c.sha) || !date(c.date)) || !Array.isArray(g.changedFiles) || g.changedFiles.length > 1e3 || g.changedFiles.some((p) => typeof p !== "string" || p.length > 500 || p.startsWith("/") || p.includes("\\") || p.split("/").includes("..") || /[\x00-\x1f]/.test(p))) throw new Error("Invalid Git metadata");
43
+ return { head: g.head, branch: g.branch, totalCommits: g.totalCommits, dirty: g.dirty, capturedAt: g.capturedAt, commits: g.commits.map((c) => ({ sha: c.sha, date: c.date })), changedFiles: [...g.changedFiles], ...g.previousHead ? { previousHead: g.previousHead } : {} };
44
+ }
45
+ var init_git = __esm({
46
+ "src/runtime/git.ts"() {
47
+ "use strict";
48
+ }
49
+ });
50
+
51
+ // src/inventory/dashboard-transfer.ts
52
+ function discoveryTransfer(value) {
53
+ const input = value;
54
+ if (!input || input.schema !== "company-inventory/v1" || typeof input.name !== "string" || !Array.isArray(input.tools) || !Array.isArray(input.workflows) || input.tools.length > 1e3 || input.workflows.length > 5e3) throw new Error("Invalid discovery metadata");
55
+ const text = (v, limit = 240) => {
56
+ if (typeof v !== "string" || v.length > limit || /[\x00-\x1f]/.test(v)) throw new Error("Invalid discovery string");
57
+ return v;
58
+ };
59
+ const strings = (v) => {
60
+ if (!Array.isArray(v) || v.length > 1e3) throw new Error("Invalid discovery list");
61
+ return v.map((s) => text(s));
62
+ };
63
+ const entrypoint = (v) => {
64
+ const path = text(v, 500);
65
+ if (path.startsWith("/") || path.includes("\\") || /(^|[\/])\.\.([\/#]|$)/.test(path) || /^[A-Za-z][A-Za-z0-9+.-]*:/.test(path)) throw new Error("Entrypoint must be relative to the project");
66
+ return path;
67
+ };
68
+ const tools = input.tools.map((raw) => {
69
+ const t = raw;
70
+ return { id: text(t.id), title: text(t.title), kind: text(t.kind), category: text(t.category), confidence: text(t.confidence), interface: text(t.interface) };
71
+ });
72
+ const workflows = input.workflows.map((raw) => {
73
+ const w = raw;
74
+ if (!Array.isArray(w.steps) || w.steps.length > 1e3) throw new Error("Invalid workflow steps");
75
+ return { id: text(w.id), title: text(w.title), confidence: text(w.confidence), supported: w.supported === true, verified: false, tools: strings(w.tools), steps: w.steps.map((rawStep) => {
76
+ const step = rawStep;
77
+ return { tool: text(step.tool), operations: strings(step.operations) };
78
+ }), ...w.entrypoint ? { entrypoint: entrypoint(w.entrypoint) } : {} };
79
+ });
80
+ if (new Set(tools.map((t) => t.id)).size !== tools.length || new Set(workflows.map((w) => w.id)).size !== workflows.length) throw new Error("Duplicate discovery IDs");
81
+ const ids = new Set(tools.map((t) => t.id));
82
+ if (workflows.some((w) => w.tools.some((id) => !ids.has(id)) || w.steps.some((s) => !ids.has(s.tool)))) throw new Error("Unknown workflow tool");
83
+ const result = { schema: "company-inventory/v1", name: text(input.name), generatedAt: input.generatedAt ? text(input.generatedAt) : "", tools, workflows, ...input.git !== void 0 ? { git: validateGit(input.git) } : {} };
84
+ if (Buffer.byteLength(JSON.stringify(result)) > 2 * 1024 * 1024) throw new Error("Discovery exceeds 2 MiB");
85
+ return result;
86
+ }
87
+ var init_dashboard_transfer = __esm({
88
+ "src/inventory/dashboard-transfer.ts"() {
89
+ "use strict";
90
+ init_git();
91
+ }
92
+ });
93
+
94
+ // src/inspect/sanitize.ts
95
+ function stripUrlCredentials(value) {
96
+ return value.replace(URL_CREDENTIALS, "$1");
97
+ }
98
+ function sanitizeOutput(value) {
99
+ return stripUrlCredentials(value).replace(TOKENISH, (m) => `${m.slice(0, 4)}***`).replace(EMAIL, "$1***$2");
100
+ }
101
+ function excerpt(value, max = 200) {
102
+ const clean = sanitizeOutput(value).replace(/\s+/g, " ").trim();
103
+ return clean.length > max ? `${clean.slice(0, max)}\u2026` : clean;
104
+ }
105
+ var URL_CREDENTIALS, TOKENISH, EMAIL;
106
+ var init_sanitize = __esm({
107
+ "src/inspect/sanitize.ts"() {
108
+ "use strict";
109
+ URL_CREDENTIALS = /(https?:\/\/)([^/@\s:]+)(:[^/@\s]+)?@/gi;
110
+ TOKENISH = /\b(sk|rk|pk|ghp|gho|ghs|glpat|xox[baprs]|AKIA|AIza)[A-Za-z0-9_-]{6,}\b/g;
111
+ EMAIL = /([A-Za-z0-9._%+-])[A-Za-z0-9._%+-]*(@[A-Za-z0-9.-]+\.[A-Za-z]{2,})/g;
112
+ }
113
+ });
114
+
115
+ // src/commands/dashboard.ts
116
+ import { cp, mkdir, readFile as readFile3, writeFile } from "fs/promises";
117
+ import { basename, join as join3 } from "path";
118
+ import { fileURLToPath } from "url";
119
+ function dashboardInventory(cwd, inventory) {
120
+ return JSON.parse(sanitizeOutput(JSON.stringify({
121
+ schema: "company-inventory/v1",
122
+ generatedAt: inventory.generatedAt,
123
+ name: basename(cwd),
124
+ tools: inventory.tools.map((t) => ({
125
+ id: t.id,
126
+ title: t.title,
127
+ kind: t.kind,
128
+ category: t.category,
129
+ confidence: t.confidence ?? "low",
130
+ interface: t.kind === "runtime" ? "terminal" : "api"
131
+ })),
132
+ workflows: inventory.workflows.map((w) => ({ id: w.id, title: w.title ?? w.id, confidence: w.confidence ?? "low", supported: w.supported, verified: false, ...w.entrypoint ? { entrypoint: w.entrypoint } : {}, tools: w.tools, steps: w.steps.map((s) => ({ tool: s.tool, operations: s.operations })) }))
133
+ })));
134
+ }
135
+ async function writeDashboard(cwd, inventory) {
136
+ const directory = join3(cwd, ".agenttwin", "dashboard");
137
+ let bundle = fileURLToPath(new URL("./dashboard/", import.meta.url));
138
+ try {
139
+ await readFile3(join3(bundle, "index.html"));
140
+ } catch {
141
+ bundle = fileURLToPath(new URL("../../../../site/", import.meta.url));
142
+ }
143
+ await mkdir(join3(directory, "assets", "dashboard"), { recursive: true });
144
+ const html = await readFile3(join3(bundle, bundle.endsWith("/site/") ? "dashboard.html" : "index.html"), "utf8");
145
+ for (const folder of ["dashboard", "icons", "logo", "fonts", "vendor"]) {
146
+ await cp(join3(bundle, "assets", folder), join3(directory, "assets", folder), { recursive: true });
147
+ }
148
+ await cp(join3(bundle, "assets", "brand.css"), join3(directory, "assets", "brand.css"));
149
+ const data = await loadDashboardDiscovery(cwd, inventory);
150
+ const payload = JSON.stringify(data).replace(/</g, "\\u003c");
151
+ const page2 = html.replaceAll('type="module"', "defer").replace('<script src="assets/dashboard/dashboard.js', `<script id="agenttwin-discovery" type="application/json">${payload}</script><script src="assets/dashboard/dashboard.js`).replace(/href="index.html"/g, 'href="../report.html"').replace(/href="docs\/"/g, 'href="../report.html"').replace("Docs \u2197", "Discovery report \u2197").replace("Runtime documentation \u2197", "Coverage report \u2197").replace("Back to website \u2197", "Discovery report \u2197");
152
+ await writeFile(join3(directory, "index.html"), page2, "utf8");
153
+ await writeFile(join3(directory, "assets", "dashboard", "inventory.json"), JSON.stringify(data), "utf8");
154
+ await writeFile(join3(directory, "assets", "dashboard", "runs.json"), '{"runs":[]}', "utf8");
155
+ return join3(directory, "index.html");
156
+ }
157
+ var init_dashboard = __esm({
158
+ "src/commands/dashboard.ts"() {
159
+ "use strict";
160
+ init_session();
161
+ init_sanitize();
162
+ }
163
+ });
164
+
165
+ // src/commands/session.ts
166
+ var session_exports = {};
167
+ __export(session_exports, {
168
+ installedOpenCode: () => installedOpenCode,
169
+ launchSession: () => launchSession,
170
+ loadDashboardDiscovery: () => loadDashboardDiscovery,
171
+ sessionTask: () => sessionTask,
172
+ writeSessionTask: () => writeSessionTask
173
+ });
174
+ import { spawn, spawnSync } from "child_process";
175
+ import { writeFile as writeFile2, readFile as readFile4 } from "fs/promises";
176
+ import { join as join4 } from "path";
177
+ function sessionTask() {
178
+ return `# Prepare this repository for AgentTwin
179
+
180
+ Read project instructions and .agenttwin/inventory.json first. Inspect tracked application source and configuration; skip secrets, .env values, private data, dependencies and build outputs. Use your configured coding-agent provider and normal permission controls.
181
+
182
+ Find distinct business workflows across the repository, including routes, queues, schedules, agent tools and exported functions. Do not cap discovery at a handful of examples. Trace callers and side effects across files. For each candidate record its real relative entrypoint, intent, inputs, dependency operations, expected observable outcome and plausible production failures. Distinguish observed code from hypotheses. Do not claim discovery is exhaustive; report skipped files and unresolved dynamic paths.
183
+
184
+ Write a human-readable .agenttwin/WORKFLOWS.md. For dashboard handoff, write .agenttwin/agent-discovery.json using the metadata-only company-inventory/v1 schema below. Start with the tools in .agenttwin/dashboard/assets/dashboard/inventory.json and retain their IDs; add candidates to workflows. Include no source bodies, credentials, PII or absolute paths. Never mark a candidate verified or executable merely because it was discovered.
185
+
186
+ Example shape (replace these descriptive placeholders with evidence from this repo):
187
+ {"schema":"company-inventory/v1","name":"project","tools":[],"workflows":[{"id":"agent:unique_workflow","title":"Workflow intent","entrypoint":"src/path.ts#function","confidence":"medium","supported":false,"verified":false,"tools":[],"steps":[]}]}
188
+ Each tool must have id, title, kind, category, confidence, interface. Each step must have tool (an existing tool ID) and operations (string array).
189
+
190
+ Before execution, ask the user for missing input, expected outcomes and any required synthetic fixtures. Prepare one real workflow at a time: an executable Node entrypoint plus its required application files, reviewed dependency mappings, synthetic starting state, assertions and an explicit fault when appropriate. Read agenttwin prepare --help and the existing preparation schema rather than guessing fields. The CLI runtime supports bundled Node workflows, bounded Slack operations and explicit custom JSON collections. PostgreSQL/Redis worlds require an explicitly selected cloud runner. Unsupported vendor SDKs, browser and desktop interactions are blockers; never represent generic JSON endpoints as full vendor twins.
191
+
192
+ Do not start Docker or containers locally. Run reviewed supported Node workflows through npx agenttwin run using in-process twins; this executes on the user\u2019s machine and is not a VM security boundary. Do not access production accounts or copy production data. Do not print credentials. Use --runtime hosted or --runtime e2b explicitly only for workflows that require a cloud machine. Never launch a cloud VM merely to analyze code or render dashboard results. Upload application files only after the user reviews the bundle. When a run fails, use the actual run receipt, logs, failed assertions and source digests to propose a fix. Explain uncertainty, edit the approved code, rebuild the bundle and retest using the appropriate runner; never report success without passing evidence.
193
+
194
+ After review, npx agenttwin sync sends discovery, Git metadata and CLI run evidence to the account dashboard using AGENTTWIN_TOKEN (and optional AGENTTWIN_API_URL). Review run evidence before syncing; it includes synthetic state and application output. It does not upload application source. The dashboard will list all candidate workflows and populate the environment map from these findings.
195
+ `;
196
+ }
197
+ function installedOpenCode() {
198
+ return spawnSync("opencode", ["--version"], { stdio: "ignore", timeout: 5e3 }).status === 0;
199
+ }
200
+ async function writeSessionTask(cwd) {
201
+ const path = join4(cwd, ".agenttwin/AGENT-TASK.md");
202
+ await writeFile2(path, sessionTask(), "utf8");
203
+ return path;
204
+ }
205
+ async function launchSession(cwd) {
206
+ if (!installedOpenCode()) throw new Error("OpenCode is not installed. Open .agenttwin/AGENT-TASK.md with your coding agent, or install OpenCode and retry init --agent opencode.");
207
+ const prompt = "Read .agenttwin/AGENT-TASK.md and follow it to discover and prepare this project for AgentTwin. Start by analyzing the repository and presenting workflow findings and missing execution requirements.";
208
+ await new Promise((resolve5, reject) => {
209
+ const child = spawn("opencode", ["--prompt", prompt], { cwd, stdio: "inherit", shell: false });
210
+ child.once("error", reject);
211
+ child.once("exit", (code, signal) => code === 0 ? resolve5() : reject(new Error(`Coding session ended ${signal || `with exit code ${code}`}. Discovery files remain available.`)));
212
+ });
213
+ }
214
+ async function loadDashboardDiscovery(cwd, inventory) {
215
+ const base = discoveryTransfer(dashboardInventory(cwd, inventory));
216
+ let raw;
217
+ try {
218
+ raw = await readFile4(join4(cwd, ".agenttwin/agent-discovery.json"), "utf8");
219
+ } catch (error) {
220
+ if (error.code === "ENOENT") return base;
221
+ throw error;
222
+ }
223
+ if (Buffer.byteLength(raw) > 2 * 1024 * 1024) throw new Error("Agent discovery exceeds 2 MiB");
224
+ const extra = discoveryTransfer(JSON.parse(raw));
225
+ const tools = new Map(base.tools.map((t) => [t.id, t]));
226
+ for (const tool of extra.tools) if (!tools.has(tool.id)) tools.set(tool.id, tool);
227
+ const workflows = new Map(base.workflows.map((w) => [w.id, w]));
228
+ for (const workflow of extra.workflows) if (!workflows.has(workflow.id)) workflows.set(workflow.id, { ...workflow, supported: false, verified: false });
229
+ const result = discoveryTransfer({ ...base, tools: [...tools.values()], workflows: [...workflows.values()] });
230
+ const ids = new Set(result.tools.map((t) => t.id));
231
+ if (result.workflows.some((w) => w.tools.some((id) => !ids.has(id)) || w.steps.some((s) => !ids.has(s.tool)))) throw new Error("Agent discovery references unknown tools. Correct .agenttwin/agent-discovery.json before syncing.");
232
+ return result;
233
+ }
234
+ var init_session = __esm({
235
+ "src/commands/session.ts"() {
236
+ "use strict";
237
+ init_dashboard_transfer();
238
+ init_dashboard();
239
+ }
240
+ });
241
+
12
242
  // src/discovery/questions/answers.ts
13
243
  var answers_exports = {};
14
244
  __export(answers_exports, {
@@ -53,17 +283,56 @@ var init_answers = __esm({
53
283
 
54
284
  // src/cli.ts
55
285
  import { readFileSync as readFileSync3 } from "fs";
56
- import { dirname as dirname4, join as join17 } from "path";
286
+ import { dirname as dirname4, join as join18 } from "path";
57
287
  import { fileURLToPath as fileURLToPath3 } from "url";
58
288
  import { parseArgs } from "util";
59
289
 
60
290
  // src/commands/sync.ts
61
- import { readFile as readFile4 } from "fs/promises";
62
- import { join as join4 } from "path";
291
+ init_git();
292
+ import { readFile as readFile5, writeFile as writeFile3 } from "fs/promises";
293
+ import { join as join5 } from "path";
63
294
 
64
- // src/runtime/remote.ts
65
- import { readFile } from "fs/promises";
295
+ // src/runtime/receipt-sync.ts
296
+ init_git();
297
+ import { readFile, readdir } from "fs/promises";
66
298
  import { join } from "path";
299
+ function portableLocalReceipt(value) {
300
+ const r = value;
301
+ const uuid = (v) => typeof v === "string" && /^[a-f0-9-]{36}$/.test(v);
302
+ if (!r || r.schema !== "agenttwin/run-v1" || r.runtime !== "local" || !uuid(r.runId) || typeof r.name !== "string" || !/^[a-z][a-z0-9_]{0,39}$/.test(r.name) || typeof r.project !== "string" || r.project.length > 240 || /[\x00-\x1f]/.test(r.project) || !["passed", "failed", "error", "incomplete"].includes(r.status) || typeof r.createdAt !== "string" || !Number.isFinite(Date.parse(r.createdAt)) || !Number.isFinite(r.durationMs) || r.durationMs < 0 || !r.application || !Array.isArray(r.application.command) || r.application.command[0] !== "node" || r.application.command.some((v) => typeof v !== "string") || !Array.isArray(r.assertions) || !r.assertions.length || r.assertions.length > 100 || r.assertions.some((a) => typeof a.service !== "string" || typeof a.path !== "string" || typeof a.passed !== "boolean" || !Number.isSafeInteger(a.count) || a.count < 0 || !Number.isSafeInteger(a.actual) || a.actual < 0 || a.passed !== (a.count === a.actual)) || !Array.isArray(r.events) || r.events.length > 5e4 || !r.finalState || typeof r.finalState !== "object" || !r.initialState || typeof r.initialState !== "object" || !r.sourceDigests || typeof r.sourceDigests !== "object" || !/^[a-f0-9]{64}$/.test(r.specDigest)) throw new Error("Invalid CLI receipt");
303
+ if (r.status === "passed" && (r.application.exitCode !== 0 || r.application.timedOut || r.assertions.some((a) => !a.passed) || r.unsupported?.length || r.fault && !r.fault.triggered)) throw new Error("Passed receipt contradicts recorded evidence");
304
+ if (Array.isArray(r.sourceDigests) || Object.keys(r.sourceDigests).length > 100 || Object.entries(r.sourceDigests).some(([path, digest]) => path.startsWith("/") || path.split("/").includes("..") || typeof digest !== "string" || !/^[a-f0-9]{64}$/.test(digest)) || Object.values(r.finalState).some((paths) => !paths || typeof paths !== "object" || Array.isArray(paths) || Object.values(paths).some((rows) => !Array.isArray(rows))) || typeof r.application.stdout !== "string" || typeof r.application.stderr !== "string" || r.application.stdout.length > 65536 || r.application.stderr.length > 65536 || r.events.some((e) => !e || !Number.isSafeInteger(e.sequence) || typeof e.service !== "string" || typeof e.operation !== "string" || typeof e.path !== "string" || typeof e.detail !== "string")) throw new Error("Invalid CLI evidence fields");
305
+ const git = validateGit(r.git);
306
+ const fields = ["schema", "runId", "name", "workflowId", "project", "createdAt", "durationMs", "status", "application", "assertions", "infrastructureAssertions", "fault", "initialState", "finalState", "events", "unsupported", "coverage", "sourceDigests", "specDigest"];
307
+ const result = Object.fromEntries(fields.filter((k) => r[k] !== void 0).map((k) => [k, r[k]]));
308
+ Object.assign(result, { runtime: "local", provenance: "CLI-reported execution; not independently attested", isolation: { kind: "process", network: "host network; not isolated", rootFilesystem: "host-accessible" }, ...git ? { git } : {} });
309
+ if (Buffer.byteLength(JSON.stringify(result)) > 2 * 1024 * 1024) throw new Error("CLI receipt exceeds 2 MiB");
310
+ return result;
311
+ }
312
+ async function syncReceipts(cwd, base, token) {
313
+ let names;
314
+ try {
315
+ names = await readdir(join(cwd, ".agenttwin/runs"));
316
+ } catch (error) {
317
+ if (error.code === "ENOENT") return 0;
318
+ throw error;
319
+ }
320
+ let synced = 0;
321
+ for (const name of names.filter((n) => /^[a-f0-9-]{36}$/.test(n)).slice(-100)) {
322
+ const path = join(cwd, ".agenttwin/runs", name, "run.json");
323
+ const r = JSON.parse(await readFile(path, "utf8"));
324
+ if (r.runtime !== "local") continue;
325
+ const receipt = portableLocalReceipt(r);
326
+ const response = await fetch(base + "/v1/receipts", { method: "POST", headers: { authorization: "Bearer " + token, "content-type": "application/json" }, body: JSON.stringify(receipt), redirect: "error", signal: AbortSignal.timeout(2e4) });
327
+ if (!response.ok) throw new Error("Run evidence sync failed: " + response.status);
328
+ synced++;
329
+ }
330
+ return synced;
331
+ }
332
+
333
+ // src/runtime/remote.ts
334
+ import { readFile as readFile2 } from "fs/promises";
335
+ import { join as join2 } from "path";
67
336
 
68
337
  // src/lib/exit.ts
69
338
  var EXIT = {
@@ -136,14 +405,14 @@ function apiBase(value) {
136
405
  if (url.username || url.password || url.search || url.hash || url.protocol !== "https:" && !(url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname))) throw new CliError("AgentTwin API requires HTTPS (loopback HTTP allowed for development)", { code: EXIT.USAGE });
137
406
  return value.replace(/\/$/, "");
138
407
  }
139
- async function executeRemote(stage, spec, log) {
408
+ async function executeRemote(stage, spec, log, metadata = {}) {
140
409
  const base = apiBase(process.env.AGENTTWIN_API_URL ?? "");
141
410
  const token = process.env.AGENTTWIN_TOKEN;
142
411
  if (!token) throw new CliError("AGENTTWIN_TOKEN is not configured", { code: EXIT.BLOCKED });
143
412
  const files = /* @__PURE__ */ Object.create(null);
144
- for (const file of spec.files) files[file] = (await readFile(join(stage, "app", file))).toString("base64");
413
+ for (const file of spec.files) files[file] = (await readFile2(join2(stage, "app", file))).toString("base64");
145
414
  const headers = { authorization: `Bearer ${token}`, "content-type": "application/json" };
146
- const response = await fetch(`${base}/v1/runs`, { method: "POST", headers, body: JSON.stringify({ spec, files }), redirect: "error", signal: AbortSignal.timeout(15e3) });
415
+ const response = await fetch(`${base}/v1/runs`, { method: "POST", headers, body: JSON.stringify({ spec, files, ...metadata }), redirect: "error", signal: AbortSignal.timeout(15e3) });
147
416
  if (!response.ok) throw new CliError(`AgentTwin API rejected run (${response.status}): ${await response.text()}`, { code: response.status === 401 || response.status === 429 ? EXIT.BLOCKED : EXIT.ERROR });
148
417
  const job = await response.json();
149
418
  if (!/^[a-f0-9-]{36}$/.test(job.id)) throw new CliError("API returned an invalid run ID");
@@ -163,206 +432,303 @@ async function executeRemote(stage, spec, log) {
163
432
  throw new CliError(`Timed out waiting for hosted run ${job.id}; inspect this run in the API before retrying`);
164
433
  }
165
434
 
166
- // src/commands/session.ts
167
- import { spawn, spawnSync } from "child_process";
168
- import { writeFile as writeFile2, readFile as readFile3 } from "fs/promises";
169
- import { join as join3 } from "path";
170
-
171
- // src/inventory/dashboard-transfer.ts
172
- function discoveryTransfer(value) {
173
- const input = value;
174
- if (!input || input.schema !== "company-inventory/v1" || typeof input.name !== "string" || !Array.isArray(input.tools) || !Array.isArray(input.workflows) || input.tools.length > 1e3 || input.workflows.length > 5e3) throw new Error("Invalid discovery metadata");
175
- const text = (v, limit = 240) => {
176
- if (typeof v !== "string" || v.length > limit || /[\x00-\x1f]/.test(v)) throw new Error("Invalid discovery string");
177
- return v;
178
- };
179
- const strings = (v) => {
180
- if (!Array.isArray(v) || v.length > 1e3) throw new Error("Invalid discovery list");
181
- return v.map((s) => text(s));
182
- };
183
- const entrypoint = (v) => {
184
- const path = text(v, 500);
185
- if (path.startsWith("/") || path.includes("\\") || /(^|[\/])\.\.([\/#]|$)/.test(path) || /^[A-Za-z][A-Za-z0-9+.-]*:/.test(path)) throw new Error("Entrypoint must be relative to the project");
186
- return path;
187
- };
188
- const tools = input.tools.map((raw) => {
189
- const t = raw;
190
- return { id: text(t.id), title: text(t.title), kind: text(t.kind), category: text(t.category), confidence: text(t.confidence), interface: text(t.interface) };
191
- });
192
- const workflows = input.workflows.map((raw) => {
193
- const w = raw;
194
- if (!Array.isArray(w.steps) || w.steps.length > 1e3) throw new Error("Invalid workflow steps");
195
- return { id: text(w.id), title: text(w.title), confidence: text(w.confidence), supported: w.supported === true, verified: false, tools: strings(w.tools), steps: w.steps.map((rawStep) => {
196
- const step = rawStep;
197
- return { tool: text(step.tool), operations: strings(step.operations) };
198
- }), ...w.entrypoint ? { entrypoint: entrypoint(w.entrypoint) } : {} };
199
- });
200
- if (new Set(tools.map((t) => t.id)).size !== tools.length || new Set(workflows.map((w) => w.id)).size !== workflows.length) throw new Error("Duplicate discovery IDs");
201
- const ids = new Set(tools.map((t) => t.id));
202
- if (workflows.some((w) => w.tools.some((id) => !ids.has(id)) || w.steps.some((s) => !ids.has(s.tool)))) throw new Error("Unknown workflow tool");
203
- const result = { schema: "company-inventory/v1", name: text(input.name), generatedAt: input.generatedAt ? text(input.generatedAt) : "", tools, workflows };
204
- if (Buffer.byteLength(JSON.stringify(result)) > 2 * 1024 * 1024) throw new Error("Discovery exceeds 2 MiB");
205
- return result;
206
- }
207
-
208
- // src/commands/dashboard.ts
209
- import { cp, mkdir, readFile as readFile2, writeFile } from "fs/promises";
210
- import { basename, join as join2 } from "path";
211
- import { fileURLToPath } from "url";
212
-
213
- // src/inspect/sanitize.ts
214
- var URL_CREDENTIALS = /(https?:\/\/)([^/@\s:]+)(:[^/@\s]+)?@/gi;
215
- var TOKENISH = /\b(sk|rk|pk|ghp|gho|ghs|glpat|xox[baprs]|AKIA|AIza)[A-Za-z0-9_-]{6,}\b/g;
216
- var EMAIL = /([A-Za-z0-9._%+-])[A-Za-z0-9._%+-]*(@[A-Za-z0-9.-]+\.[A-Za-z]{2,})/g;
217
- function stripUrlCredentials(value) {
218
- return value.replace(URL_CREDENTIALS, "$1");
219
- }
220
- function sanitizeOutput(value) {
221
- return stripUrlCredentials(value).replace(TOKENISH, (m) => `${m.slice(0, 4)}***`).replace(EMAIL, "$1***$2");
222
- }
223
- function excerpt(value, max = 200) {
224
- const clean = sanitizeOutput(value).replace(/\s+/g, " ").trim();
225
- return clean.length > max ? `${clean.slice(0, max)}\u2026` : clean;
226
- }
227
-
228
- // src/commands/dashboard.ts
229
- function dashboardInventory(cwd, inventory) {
230
- return JSON.parse(sanitizeOutput(JSON.stringify({
231
- schema: "company-inventory/v1",
232
- generatedAt: inventory.generatedAt,
233
- name: basename(cwd),
234
- tools: inventory.tools.map((t) => ({
235
- id: t.id,
236
- title: t.title,
237
- kind: t.kind,
238
- category: t.category,
239
- confidence: t.confidence ?? "low",
240
- interface: t.kind === "runtime" ? "terminal" : "api"
241
- })),
242
- workflows: inventory.workflows.map((w) => ({ id: w.id, title: w.title ?? w.id, confidence: w.confidence ?? "low", supported: w.supported, verified: false, ...w.entrypoint ? { entrypoint: w.entrypoint } : {}, tools: w.tools, steps: w.steps.map((s) => ({ tool: s.tool, operations: s.operations })) }))
243
- })));
244
- }
245
- async function writeDashboard(cwd, inventory) {
246
- const directory = join2(cwd, ".agenttwin", "dashboard");
247
- let bundle = fileURLToPath(new URL("./dashboard/", import.meta.url));
248
- try {
249
- await readFile2(join2(bundle, "index.html"));
250
- } catch {
251
- bundle = fileURLToPath(new URL("../../../../site/", import.meta.url));
252
- }
253
- await mkdir(join2(directory, "assets", "dashboard"), { recursive: true });
254
- const html = await readFile2(join2(bundle, bundle.endsWith("/site/") ? "dashboard.html" : "index.html"), "utf8");
255
- for (const folder of ["dashboard", "icons", "logo", "fonts"]) {
256
- await cp(join2(bundle, "assets", folder), join2(directory, "assets", folder), { recursive: true });
257
- }
258
- await cp(join2(bundle, "assets", "brand.css"), join2(directory, "assets", "brand.css"));
259
- const data = await loadDashboardDiscovery(cwd, inventory);
260
- const payload = JSON.stringify(data).replace(/</g, "\\u003c");
261
- const page2 = html.replace('type="module"', "defer").replace('<script src="assets/dashboard/dashboard.js', `<script id="agenttwin-discovery" type="application/json">${payload}</script><script src="assets/dashboard/dashboard.js`).replace(/href="index.html"/g, 'href="../report.html"').replace(/href="docs\/"/g, 'href="../report.html"').replace("Docs \u2197", "Discovery report \u2197").replace("Runtime documentation \u2197", "Coverage report \u2197").replace("Back to website \u2197", "Discovery report \u2197");
262
- await writeFile(join2(directory, "index.html"), page2, "utf8");
263
- await writeFile(join2(directory, "assets", "dashboard", "inventory.json"), JSON.stringify(data), "utf8");
264
- await writeFile(join2(directory, "assets", "dashboard", "runs.json"), '{"runs":[]}', "utf8");
265
- return join2(directory, "index.html");
266
- }
267
-
268
- // src/commands/session.ts
269
- function sessionTask() {
270
- return `# Prepare this repository for AgentTwin
271
-
272
- Read project instructions and .agenttwin/inventory.json first. Inspect tracked application source and configuration; skip secrets, .env values, private data, dependencies and build outputs. Use your configured coding-agent provider and normal permission controls.
273
-
274
- Find distinct business workflows across the repository, including routes, queues, schedules, agent tools and exported functions. Do not cap discovery at a handful of examples. Trace callers and side effects across files. For each candidate record its real relative entrypoint, intent, inputs, dependency operations, expected observable outcome and plausible production failures. Distinguish observed code from hypotheses. Do not claim discovery is exhaustive; report skipped files and unresolved dynamic paths.
275
-
276
- Write a human-readable .agenttwin/WORKFLOWS.md. For dashboard handoff, write .agenttwin/agent-discovery.json using the metadata-only company-inventory/v1 schema below. Start with the tools in .agenttwin/dashboard/assets/dashboard/inventory.json and retain their IDs; add candidates to workflows. Include no source bodies, credentials, PII or absolute paths. Never mark a candidate verified or executable merely because it was discovered.
277
-
278
- Example shape (replace these descriptive placeholders with evidence from this repo):
279
- {"schema":"company-inventory/v1","name":"project","tools":[],"workflows":[{"id":"agent:unique_workflow","title":"Workflow intent","entrypoint":"src/path.ts#function","confidence":"medium","supported":false,"verified":false,"tools":[],"steps":[]}]}
280
- Each tool must have id, title, kind, category, confidence, interface. Each step must have tool (an existing tool ID) and operations (string array).
281
-
282
- Before execution, ask the user for missing input, expected outcomes and any required synthetic fixtures. Prepare one real workflow at a time: an executable Node entrypoint plus its required application files, reviewed dependency mappings, synthetic starting state, assertions and an explicit fault when appropriate. Read agenttwin prepare --help and the existing preparation schema rather than guessing fields. The current hosted runtime supports Node, PostgreSQL, Redis, bounded Slack operations and explicit custom JSON collections. Unsupported vendor SDKs, browser and desktop interactions are blockers; never represent generic JSON endpoints as full vendor twins.
283
-
284
- Do not run application workflows, Docker or containers locally. Do not access production accounts or copy production data. Do not print credentials. Execute reviewed workflows only through npx agenttwin run in a fresh hosted sandbox with AgentTwin account credentials or E2B credentials. Upload application files only after the user reviews the bundle. When a run fails, use the actual run receipt, logs, failed assertions and source digests to propose a fix. Explain uncertainty, edit the approved code, rebuild the bundle and retest in a new sandbox; never report success without passing evidence.
285
-
286
- After review, npx agenttwin sync sends discovery metadata to the account dashboard using AGENTTWIN_TOKEN (and optional AGENTTWIN_API_URL). It does not upload application source. The dashboard will list all candidate workflows and populate the environment map from these findings.
287
- `;
288
- }
289
- function installedOpenCode() {
290
- return spawnSync("opencode", ["--version"], { stdio: "ignore", timeout: 5e3 }).status === 0;
291
- }
292
- async function writeSessionTask(cwd) {
293
- const path = join3(cwd, ".agenttwin/AGENT-TASK.md");
294
- await writeFile2(path, sessionTask(), "utf8");
295
- return path;
296
- }
297
- async function launchSession(cwd) {
298
- if (!installedOpenCode()) throw new Error("OpenCode is not installed. Open .agenttwin/AGENT-TASK.md with your coding agent, or install OpenCode and retry init --agent opencode.");
299
- const prompt = "Read .agenttwin/AGENT-TASK.md and follow it to discover and prepare this project for AgentTwin. Start by analyzing the repository and presenting workflow findings and missing execution requirements.";
300
- await new Promise((resolve5, reject) => {
301
- const child = spawn("opencode", ["--prompt", prompt], { cwd, stdio: "inherit", shell: false });
302
- child.once("error", reject);
303
- child.once("exit", (code, signal) => code === 0 ? resolve5() : reject(new Error(`Coding session ended ${signal || `with exit code ${code}`}. Discovery files remain available.`)));
304
- });
305
- }
306
- async function loadDashboardDiscovery(cwd, inventory) {
307
- const base = discoveryTransfer(dashboardInventory(cwd, inventory));
308
- let raw;
309
- try {
310
- raw = await readFile3(join3(cwd, ".agenttwin/agent-discovery.json"), "utf8");
311
- } catch (error) {
312
- if (error.code === "ENOENT") return base;
313
- throw error;
314
- }
315
- if (Buffer.byteLength(raw) > 2 * 1024 * 1024) throw new Error("Agent discovery exceeds 2 MiB");
316
- const extra = discoveryTransfer(JSON.parse(raw));
317
- const tools = new Map(base.tools.map((t) => [t.id, t]));
318
- for (const tool of extra.tools) if (!tools.has(tool.id)) tools.set(tool.id, tool);
319
- const workflows = new Map(base.workflows.map((w) => [w.id, w]));
320
- for (const workflow of extra.workflows) if (!workflows.has(workflow.id)) workflows.set(workflow.id, { ...workflow, supported: false, verified: false });
321
- const result = discoveryTransfer({ ...base, tools: [...tools.values()], workflows: [...workflows.values()] });
322
- const ids = new Set(result.tools.map((t) => t.id));
323
- if (result.workflows.some((w) => w.tools.some((id) => !ids.has(id)) || w.steps.some((s) => !ids.has(s.tool)))) throw new Error("Agent discovery references unknown tools. Correct .agenttwin/agent-discovery.json before syncing.");
324
- return result;
325
- }
326
-
327
435
  // src/commands/sync.ts
436
+ init_session();
328
437
  async function runSync(cwd) {
329
438
  const token = process.env.AGENTTWIN_TOKEN;
330
439
  if (!token || !/^at_user_[a-f0-9]{64}$/.test(token)) throw new Error("Set AGENTTWIN_TOKEN to your individual account token.");
331
440
  const base = apiBase(process.env.AGENTTWIN_API_URL || "https://agenttwin-api.212.56.40.125.sslip.io");
332
- const inventory = JSON.parse(await readFile4(join4(cwd, ".agenttwin/inventory.json"), "utf8"));
441
+ const inventory = JSON.parse(await readFile5(join5(cwd, ".agenttwin/inventory.json"), "utf8"));
333
442
  const payload = await loadDashboardDiscovery(cwd, inventory);
443
+ let previousHead;
444
+ try {
445
+ previousHead = JSON.parse(await readFile5(join5(cwd, ".agenttwin/git.json"), "utf8")).head;
446
+ } catch {
447
+ }
448
+ const git = await captureGit(cwd, previousHead);
449
+ Object.assign(payload, git ? { git } : {});
334
450
  const response = await fetch(base + "/v1/discovery", { method: "POST", headers: { authorization: "Bearer " + token, "content-type": "application/json" }, body: JSON.stringify(payload), redirect: "error", signal: AbortSignal.timeout(2e4) });
335
451
  const result = await response.json();
336
452
  if (!response.ok) throw new Error(result.error || "Discovery sync failed: " + response.status);
337
- return { synced: true, tools: payload.tools.length, workflows: payload.workflows.length, dashboard: "https://agenttwin.212.56.40.125.sslip.io/site/dashboard.html", uploaded: "discovery metadata only; no source files or credentials" };
453
+ if (git) await writeFile3(join5(cwd, ".agenttwin/git.json"), JSON.stringify(git) + "\n", { mode: 384 });
454
+ const receipts = await syncReceipts(cwd, base, token);
455
+ return { receipts, synced: true, tools: payload.tools.length, workflows: payload.workflows.length, dashboard: "https://agenttwin.si/site/dashboard.html", uploaded: "discovery, Git metadata and run evidence; no application source or credentials" };
338
456
  }
339
457
 
340
458
  // src/commands/prepare.ts
341
- import { mkdir as mkdir3, mkdtemp as mkdtemp2, readFile as readFile7, writeFile as writeFile4, rm as rm2 } from "fs/promises";
342
- import { join as join7, resolve as resolve2, dirname as dirname2 } from "path";
459
+ init_git();
460
+ import { mkdir as mkdir3, mkdtemp as mkdtemp2, readFile as readFile8, writeFile as writeFile5, rm as rm2 } from "fs/promises";
461
+ import { join as join8, resolve as resolve2, dirname as dirname2 } from "path";
343
462
  import { tmpdir as tmpdir2 } from "os";
344
463
  import { createHash as createHash2, randomUUID as randomUUID2 } from "crypto";
345
464
 
346
465
  // src/commands/run.ts
347
- import { mkdtemp, mkdir as mkdir2, readFile as readFile6, writeFile as writeFile3, realpath, lstat, rm, copyFile } from "fs/promises";
466
+ import { mkdtemp, mkdir as mkdir2, readFile as readFile7, writeFile as writeFile4, realpath, lstat, rm, copyFile } from "fs/promises";
348
467
  import { tmpdir } from "os";
349
- import { dirname, isAbsolute, join as join6, relative, resolve, sep } from "path";
468
+ import { basename as basename2, dirname, isAbsolute, join as join7, relative, resolve, sep } from "path";
350
469
  import { fileURLToPath as fileURLToPath2 } from "url";
351
470
  import { createHash, randomUUID } from "crypto";
352
471
 
472
+ // src/runtime/local.ts
473
+ import { spawn as spawn2 } from "child_process";
474
+
475
+ // src/runtime/http-world.ts
476
+ import { createServer } from "http";
477
+ import { randomBytes } from "crypto";
478
+ var HttpWorld = class {
479
+ constructor(spec) {
480
+ this.spec = spec;
481
+ }
482
+ spec;
483
+ events = [];
484
+ state = /* @__PURE__ */ Object.create(null);
485
+ credentials = /* @__PURE__ */ Object.create(null);
486
+ unsupported = [];
487
+ servers = [];
488
+ timers = /* @__PURE__ */ new Set();
489
+ fired = false;
490
+ record(service, operation, path, detail) {
491
+ if (this.events.length >= 5e4) return;
492
+ this.events.push({ sequence: this.events.length + 1, service, operation, path, detail });
493
+ }
494
+ async start() {
495
+ for (const service of this.spec.services) {
496
+ const token = `at_world_${randomBytes(24).toString("hex")}`;
497
+ const data = /* @__PURE__ */ Object.create(null);
498
+ this.state[service.name] = data;
499
+ for (const c of service.collections) data[c.path] = structuredClone(c.seed);
500
+ const keys = /* @__PURE__ */ new Map();
501
+ const server = createServer(async (req, res) => {
502
+ const send = (code, body) => {
503
+ if (!res.destroyed) {
504
+ res.writeHead(code, { "content-type": "application/json" });
505
+ res.end(JSON.stringify(body));
506
+ }
507
+ };
508
+ try {
509
+ if (req.headers.authorization !== `Bearer ${token}`) {
510
+ this.record(service.name, "auth-denied", req.url ?? "", "invalid test credential");
511
+ send(401, { error: "invalid_test_credential" });
512
+ return;
513
+ }
514
+ if (this.events.length >= 5e4 || this.unsupported.length >= 1e3) {
515
+ send(429, { error: "world_capacity" });
516
+ return;
517
+ }
518
+ const url = new URL(req.url ?? "/", "http://localhost");
519
+ let path = url.pathname;
520
+ const slack = service.adapter === "slack";
521
+ const slackMethod = path.replace(/^\/api\//, "");
522
+ let payload;
523
+ if (req.method === "POST") {
524
+ const chunks = [];
525
+ let bytes = 0;
526
+ for await (const chunk of req) {
527
+ bytes += chunk.length;
528
+ if (bytes > 1024 * 1024) {
529
+ send(413, { error: "body_too_large" });
530
+ return;
531
+ }
532
+ chunks.push(Buffer.from(chunk));
533
+ }
534
+ const text = Buffer.concat(chunks).toString("utf8");
535
+ let parsed;
536
+ try {
537
+ parsed = slack && req.headers["content-type"]?.startsWith("application/x-www-form-urlencoded") ? Object.fromEntries(new URLSearchParams(text)) : JSON.parse(text);
538
+ } catch {
539
+ send(400, { error: "expected_json_object" });
540
+ return;
541
+ }
542
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
543
+ send(400, { error: "expected_json_object" });
544
+ return;
545
+ }
546
+ payload = parsed;
547
+ }
548
+ if (slack) {
549
+ const params = payload ?? Object.fromEntries(url.searchParams);
550
+ const allowed = slackMethod === "chat.postMessage" ? ["channel", "text"] : slackMethod === "conversations.history" ? ["channel"] : [];
551
+ if (Object.keys(params).some((k) => !allowed.includes(k))) {
552
+ this.unsupported.push(`${service.name}: unsupported parameters for ${slackMethod}`);
553
+ send(501, { ok: false, error: "unsupported_parameters", forwardedToProduction: false });
554
+ return;
555
+ }
556
+ if (slackMethod === "chat.postMessage" && req.method === "POST") path = "/messages";
557
+ else if (slackMethod === "conversations.history" && ["GET", "POST"].includes(req.method ?? "")) path = "/messages";
558
+ else if (slackMethod === "conversations.list" && ["GET", "POST"].includes(req.method ?? "")) path = "/channels";
559
+ else {
560
+ this.unsupported.push(`${service.name}: ${req.method} ${url.pathname}`);
561
+ send(501, { ok: false, error: "unsupported_operation" });
562
+ return;
563
+ }
564
+ }
565
+ if (!Object.hasOwn(data, path) || !["GET", "POST"].includes(req.method ?? "")) {
566
+ this.unsupported.push(`${service.name}: ${req.method} ${path}`);
567
+ send(501, { error: "unsupported_operation", forwardedToProduction: false });
568
+ return;
569
+ }
570
+ if (req.method === "GET" || slack && slackMethod !== "chat.postMessage") {
571
+ this.record(service.name, "read", path, `${data[path].length} records`);
572
+ if (slack && path === "/channels") send(200, { ok: true, channels: data[path], response_metadata: { next_cursor: "" } });
573
+ else if (slack) {
574
+ const channel = payload?.channel ?? url.searchParams.get("channel");
575
+ if (!data["/channels"].some((c) => c.id === channel)) send(200, { ok: false, error: "channel_not_found" });
576
+ else send(200, { ok: true, messages: data[path].filter((m) => m.channel === channel), has_more: false });
577
+ } else send(200, { data: data[path] });
578
+ return;
579
+ }
580
+ const body = payload;
581
+ if (slack) {
582
+ const message = body;
583
+ if (!data["/channels"].some((c) => c.id === message.channel)) {
584
+ send(200, { ok: false, error: "channel_not_found" });
585
+ return;
586
+ }
587
+ if (typeof message.text !== "string" || !message.text) {
588
+ send(200, { ok: false, error: "no_text" });
589
+ return;
590
+ }
591
+ }
592
+ if (data[path].length >= 1e4 || this.events.length >= 5e4) {
593
+ send(429, { error: "world_capacity" });
594
+ return;
595
+ }
596
+ const keyHeader = slack ? void 0 : req.headers["idempotency-key"];
597
+ const key = typeof keyHeader === "string" ? `${path}:${keyHeader}` : void 0;
598
+ const signature = JSON.stringify(body);
599
+ const previous = key ? keys.get(key) : void 0;
600
+ if (previous) {
601
+ if (previous.body !== signature) {
602
+ send(409, { error: "idempotency_conflict" });
603
+ return;
604
+ }
605
+ this.record(service.name, "replay", path, "same-key response replay");
606
+ send(200, previous.record);
607
+ return;
608
+ }
609
+ let index = data[path].length + 1;
610
+ while (data[path].some((r) => r.id === `${service.name}_${index}`)) index++;
611
+ const row = { ...body, id: `${service.name}_${index}` };
612
+ if (slack) row.ts = `${index}.000001`;
613
+ const reply = slack ? { ok: true, channel: row.channel, ts: row.ts, message: row } : row;
614
+ data[path].push(row);
615
+ if (key) keys.set(key, { body: signature, record: row });
616
+ this.record(service.name, "write", path, String(row.id));
617
+ const fault = this.spec.fault;
618
+ if (!this.fired && fault?.service === service.name && fault.path === path) {
619
+ this.fired = true;
620
+ this.record(service.name, "fault", path, `write committed; response delayed ${fault.delayMs}ms`);
621
+ const timer = setTimeout(() => {
622
+ this.timers.delete(timer);
623
+ send(slack ? 200 : 201, reply);
624
+ }, fault.delayMs);
625
+ this.timers.add(timer);
626
+ } else send(slack ? 200 : 201, reply);
627
+ } catch {
628
+ send(500, { error: "world_request_error" });
629
+ }
630
+ });
631
+ server.requestTimeout = 1e4;
632
+ await new Promise((resolve5, reject) => {
633
+ server.once("error", reject);
634
+ server.listen(0, "127.0.0.1", resolve5);
635
+ });
636
+ this.servers.push(server);
637
+ this.credentials[service.name] = { endpoint: `http://127.0.0.1:${server.address().port}`, token };
638
+ }
639
+ return this.credentials;
640
+ }
641
+ assertions() {
642
+ return this.spec.assertions.map((a) => {
643
+ const rows = this.state[a.service][a.path].filter((row) => !a.where || Object.entries(a.where).every(([k, v]) => JSON.stringify(row[k]) === JSON.stringify(v)));
644
+ return { ...a, actual: rows.length, passed: rows.length === a.count };
645
+ });
646
+ }
647
+ get faultTriggered() {
648
+ return this.fired;
649
+ }
650
+ async close() {
651
+ for (const timer of this.timers) clearTimeout(timer);
652
+ this.timers.clear();
653
+ await Promise.all(this.servers.map((server) => new Promise((resolve5) => {
654
+ server.close(() => resolve5());
655
+ server.closeAllConnections();
656
+ })));
657
+ }
658
+ };
659
+
660
+ // src/runtime/local.ts
661
+ async function executeLocal(app, spec, signal) {
662
+ if (spec.postgres || spec.redis) throw new Error("This world needs PostgreSQL or Redis. Use --runtime hosted or --runtime e2b, or prepare a workflow using HTTP twins only. No local containers are started.");
663
+ signal?.throwIfAborted();
664
+ const world = new HttpWorld(spec), started = Date.now();
665
+ try {
666
+ const credentials = await world.start();
667
+ const env = { PATH: process.env.PATH ?? "", NODE_ENV: "test" };
668
+ for (const [name, c] of Object.entries(credentials)) {
669
+ env[`AGENTTWIN_${name.toUpperCase()}_URL`] = c.endpoint;
670
+ env[`AGENTTWIN_${name.toUpperCase()}_TOKEN`] = c.token;
671
+ }
672
+ let stdout = "", stderr = "", timedOut = false;
673
+ const child = spawn2(process.execPath, spec.command.slice(1), { cwd: app, env, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32" });
674
+ child.stdout.on("data", (b) => {
675
+ stdout = (stdout + b.toString()).slice(-65536);
676
+ });
677
+ child.stderr.on("data", (b) => {
678
+ stderr = (stderr + b.toString()).slice(-65536);
679
+ });
680
+ const kill = () => {
681
+ if (child.pid) {
682
+ try {
683
+ process.platform === "win32" ? child.kill("SIGKILL") : process.kill(-child.pid, "SIGKILL");
684
+ } catch {
685
+ }
686
+ }
687
+ };
688
+ const abort = () => kill();
689
+ signal?.addEventListener("abort", abort, { once: true });
690
+ const timer = setTimeout(() => {
691
+ timedOut = true;
692
+ kill();
693
+ }, spec.timeoutSeconds * 1e3);
694
+ const exitCode = await new Promise((resolve5, reject) => {
695
+ child.once("error", reject);
696
+ child.once("close", resolve5);
697
+ }).finally(() => {
698
+ clearTimeout(timer);
699
+ signal?.removeEventListener("abort", abort);
700
+ kill();
701
+ });
702
+ signal?.throwIfAborted();
703
+ await world.close();
704
+ for (const c of Object.values(credentials)) {
705
+ stdout = stdout.split(c.token).join("[test-token]");
706
+ stderr = stderr.split(c.token).join("[test-token]");
707
+ }
708
+ const assertions = world.assertions();
709
+ const status = world.unsupported.length || spec.fault && !world.faultTriggered ? "incomplete" : timedOut || exitCode !== 0 ? "error" : assertions.every((a) => a.passed) ? "passed" : "failed";
710
+ return { schema: "agenttwin/run-v1", name: spec.name, workflowId: spec.workflowId, status, application: { command: spec.command, exitCode, timedOut, stdout, stderr }, assertions, infrastructureAssertions: [], fault: spec.fault ? { ...spec.fault, triggered: world.faultTriggered } : null, initialState: Object.fromEntries(spec.services.map((s) => [s.name, Object.fromEntries(s.collections.map((c) => [c.path, c.seed]))])), finalState: world.state, events: world.events, unsupported: world.unsupported, durationMs: Date.now() - started, coverage: "CLI Node execution with fresh in-process HTTP collections and bounded Slack operations. Not a VM or network/filesystem isolation boundary. No production environment variables inherited; reviewed code can still access the host filesystem and network.", isolation: { kind: "process", productionCredentialsInherited: false, network: "host network; not isolated", rootFilesystem: "host-accessible; explicit files staged in temporary directory" } };
711
+ } finally {
712
+ await world.close();
713
+ }
714
+ }
715
+
716
+ // src/commands/run.ts
717
+ init_git();
718
+
353
719
  // src/runtime/e2b.ts
354
720
  import { Sandbox } from "e2b";
355
- import { readFile as readFile5 } from "fs/promises";
356
- import { randomBytes } from "crypto";
721
+ import { readFile as readFile6 } from "fs/promises";
722
+ import { randomBytes as randomBytes2 } from "crypto";
357
723
  import { homedir } from "os";
358
- import { join as join5 } from "path";
724
+ import { join as join6 } from "path";
359
725
  async function executeE2B(stage, spec, log, signal) {
360
726
  signal?.throwIfAborted();
361
727
  const apiKey = process.env.E2B_API_KEY;
362
728
  if (!apiKey) throw new CliError("E2B_API_KEY is not configured", { code: EXIT.BLOCKED, action: "Set E2B_API_KEY in your shell. It remains on the host and is never passed to the application." });
363
729
  let saved = {};
364
730
  try {
365
- saved = JSON.parse(await readFile5(join5(homedir(), ".agenttwin/runtime.json"), "utf8"));
731
+ saved = JSON.parse(await readFile6(join6(homedir(), ".agenttwin/runtime.json"), "utf8"));
366
732
  } catch {
367
733
  }
368
734
  const template = process.env.E2B_TEMPLATE_ID ?? saved.templateId;
@@ -381,12 +747,12 @@ async function executeE2B(stage, spec, log, signal) {
381
747
  signal?.throwIfAborted();
382
748
  await sandbox.commands.run("mkdir -p /input/app && chmod 755 /input /input/app", { user: "root" });
383
749
  for (const file of ["world.json", "worker.js", ...spec.files.map((f) => `app/${f}`)]) {
384
- await sandbox.files.write(`/input/${file}`, new Uint8Array(await readFile5(join5(stage, file))).buffer, { user: "root" });
750
+ await sandbox.files.write(`/input/${file}`, new Uint8Array(await readFile6(join6(stage, file))).buffer, { user: "root" });
385
751
  }
386
752
  await sandbox.commands.run("chmod -R a+rX /input", { user: "root" });
387
753
  const runtimeEnv = {};
388
754
  if (spec.postgres) {
389
- const password = randomBytes(24).toString("hex");
755
+ const password = randomBytes2(24).toString("hex");
390
756
  secrets.push(password);
391
757
  await sandbox.commands.run("pg_ctlcluster --skip-systemctl-redirect 15 main start -o '-c ssl=off -c listen_addresses=127.0.0.1'", { user: "root" });
392
758
  await sandbox.files.write("/tmp/agenttwin-init.sql", `CREATE ROLE agenttwin LOGIN PASSWORD '${password}';
@@ -400,7 +766,7 @@ CREATE DATABASE agenttwin OWNER agenttwin;
400
766
  }
401
767
  }
402
768
  if (spec.redis) {
403
- const password = randomBytes(24).toString("hex");
769
+ const password = randomBytes2(24).toString("hex");
404
770
  secrets.push(password);
405
771
  await sandbox.files.write("/tmp/agenttwin-redis.conf", `bind 127.0.0.1
406
772
  port 6379
@@ -504,12 +870,12 @@ async function stageApplication(root, files, destination) {
504
870
  const source = await realpath(resolve(canonicalRoot, file));
505
871
  const rel = relative(canonicalRoot, source);
506
872
  if (rel.startsWith(`..${sep}`) || rel === ".." || isAbsolute(rel) || source !== resolve(canonicalRoot, file) || !(await lstat(source)).isFile()) throw new CliError(`Files must be regular files within the project, without symlinks: ${file}`, { code: EXIT.USAGE });
507
- const contents = await readFile6(source);
873
+ const contents = await readFile7(source);
508
874
  bytes += contents.length;
509
875
  if (bytes > 20 * 1024 * 1024) throw new CliError("Application files exceed 20 MiB; supply a smaller built bundle", { code: EXIT.USAGE });
510
- const target = join6(destination, file);
876
+ const target = join7(destination, file);
511
877
  await mkdir2(dirname(target), { recursive: true });
512
- await writeFile3(target, contents, { mode: 420 });
878
+ await writeFile4(target, contents, { mode: 420 });
513
879
  digests[file] = createHash("sha256").update(contents).digest("hex");
514
880
  }
515
881
  return digests;
@@ -518,40 +884,67 @@ async function runWorld(cwd, opts) {
518
884
  const file = resolve(cwd, opts.file ?? "agenttwin.world.json");
519
885
  let spec;
520
886
  try {
521
- spec = parseWorld(JSON.parse(await readFile6(file, "utf8")));
887
+ spec = parseWorld(JSON.parse(await readFile7(file, "utf8")));
522
888
  } catch (error) {
523
889
  throw new CliError(String(error), { code: EXIT.USAGE, action: "Create agenttwin.world.json; run agenttwin example --out ./agenttwin-example for a working example." });
524
890
  }
525
891
  if (spec.workflowId) {
526
892
  let inventory;
527
893
  try {
528
- inventory = JSON.parse(await readFile6(join6(cwd, ".agenttwin/inventory.json"), "utf8"));
894
+ inventory = JSON.parse(await readFile7(join7(cwd, ".agenttwin/inventory.json"), "utf8"));
529
895
  } catch {
530
896
  throw new CliError("Linked workflow needs discovery inventory; run init first", { code: EXIT.BLOCKED });
531
897
  }
532
- if (!inventory.workflows?.some((w) => w.id === spec.workflowId)) throw new CliError("workflowId is absent from the current inventory", { code: EXIT.BLOCKED });
898
+ if (!inventory.workflows?.some((w) => w.id === spec.workflowId)) {
899
+ const { loadDashboardDiscovery: loadDashboardDiscovery2 } = await Promise.resolve().then(() => (init_session(), session_exports));
900
+ const discovery = await loadDashboardDiscovery2(cwd, inventory);
901
+ if (!discovery.workflows.some((w) => w.id === spec.workflowId)) throw new CliError("workflowId is absent from the current inventory", { code: EXIT.BLOCKED });
902
+ }
533
903
  }
534
- const runtime = opts.runtime ?? (process.env.AGENTTWIN_API_URL ? "hosted" : "e2b");
535
- if (!["e2b", "hosted"].includes(runtime)) throw new CliError("runtime must be hosted or e2b; no local application execution", { code: EXIT.USAGE });
904
+ const runtime = opts.runtime ?? "local";
905
+ if (!["local", "e2b", "hosted"].includes(runtime)) throw new CliError("runtime must be local, hosted or e2b", { code: EXIT.USAGE });
536
906
  if (runtime === "e2b" && !process.env.E2B_API_KEY) throw new CliError("E2B_API_KEY is not configured", { code: EXIT.BLOCKED, action: "Configure your E2B key in the shell; it will not be uploaded." });
537
- const stage = await mkdtemp(join6(tmpdir(), "agenttwin-world-"));
907
+ const stage = await mkdtemp(join7(tmpdir(), "agenttwin-world-"));
538
908
  try {
539
- const sourceDigests = await stageApplication(dirname(file), spec.files, join6(stage, "app"));
540
- await writeFile3(join6(stage, "world.json"), JSON.stringify(spec), { mode: 420 });
541
- const worker = join6(dirname(fileURLToPath2(import.meta.url)), "worker.js");
542
- await copyFile(worker, join6(stage, "worker.js"));
909
+ const sourceDigests = await stageApplication(dirname(file), spec.files, join7(stage, "app"));
910
+ await writeFile4(join7(stage, "world.json"), JSON.stringify(spec), { mode: 420 });
911
+ const git = await captureGit(cwd);
912
+ let project;
913
+ try {
914
+ project = JSON.parse(await readFile7(join7(cwd, ".agenttwin/inventory.json"), "utf8")).name ?? basename2(cwd);
915
+ } catch {
916
+ }
917
+ if (runtime === "local") {
918
+ opts.log("Testing through the CLI with in-process service twins. No cloud VM or containers.");
919
+ const report2 = await executeLocal(join7(stage, "app"), spec, opts.signal);
920
+ const runId2 = randomUUID();
921
+ const receipt2 = { ...report2, runId: runId2, runtime, project, git, createdAt: (/* @__PURE__ */ new Date()).toISOString(), sourceDigests, specDigest: createHash("sha256").update(JSON.stringify(spec)).digest("hex"), provenance: "CLI-reported execution" };
922
+ const out2 = resolve(cwd, opts.out ?? `.agenttwin/runs/${runId2}`);
923
+ await mkdir2(out2, { recursive: true });
924
+ const evidencePath = join7(out2, "run.json"), reportPath = join7(out2, "report.md");
925
+ await writeFile4(evidencePath, JSON.stringify(receipt2, null, 2) + "\n", { mode: 384 });
926
+ await writeFile4(reportPath, `# ${spec.name}: ${receipt2.status}
927
+
928
+ Runner: CLI
929
+ Git: ${git?.head ?? "unavailable"}
930
+
931
+ ` + receipt2.assertions.map((a) => `- ${a.passed ? "PASS" : "FAIL"} ${a.service}${a.path}: expected ${a.count}, observed ${a.actual}`).join("\n") + "\n\nSee run.json for activities, fault and initial/final state.\n");
932
+ return { ...receipt2, reportPath, evidencePath };
933
+ }
934
+ const worker = join7(dirname(fileURLToPath2(import.meta.url)), "worker.js");
935
+ await copyFile(worker, join7(stage, "worker.js"));
543
936
  if (runtime === "hosted") {
544
- const receipt2 = await executeRemote(stage, spec, opts.log);
937
+ const receipt2 = await executeRemote(stage, spec, opts.log, { git, project });
545
938
  if (receipt2.schema !== "agenttwin/run-v1" || typeof receipt2.runId !== "string" || !/^[a-f0-9-]{36}$/.test(receipt2.runId) || !["passed", "failed", "error", "incomplete"].includes(String(receipt2.status))) throw new CliError("Invalid hosted receipt");
546
939
  const expectedSpec = createHash("sha256").update(JSON.stringify(spec)).digest("hex");
547
940
  const reportedSources = receipt2.sourceDigests;
548
941
  if (receipt2.specDigest !== expectedSpec || !reportedSources || Object.keys(reportedSources).length !== Object.keys(sourceDigests).length || Object.entries(sourceDigests).some(([path, digest]) => reportedSources[path] !== digest)) throw new CliError("Hosted receipt does not match the submitted source/specification");
549
942
  const out2 = resolve(cwd, opts.out ?? `.agenttwin/runs/${receipt2.runId}`);
550
943
  await mkdir2(out2, { recursive: true });
551
- const evidencePath = join6(out2, "run.json");
552
- const reportPath = join6(out2, "report.md");
553
- await writeFile3(evidencePath, JSON.stringify(receipt2, null, 2) + "\n", { mode: 384 });
554
- await writeFile3(reportPath, `# ${spec.name}: ${receipt2.status}
944
+ const evidencePath = join7(out2, "run.json");
945
+ const reportPath = join7(out2, "report.md");
946
+ await writeFile4(evidencePath, JSON.stringify(receipt2, null, 2) + "\n", { mode: 384 });
947
+ await writeFile4(reportPath, `# ${spec.name}: ${receipt2.status}
555
948
 
556
949
  Run: ${receipt2.runId}
557
950
 
@@ -571,6 +964,8 @@ See run.json for assertions, service state, application output and isolation met
571
964
  const runId = randomUUID();
572
965
  const receipt = {
573
966
  ...report,
967
+ project,
968
+ git,
574
969
  ...spec.workflowId ? { workflowId: spec.workflowId } : {},
575
970
  runId,
576
971
  runtime,
@@ -584,17 +979,18 @@ See run.json for assertions, service state, application output and isolation met
584
979
  };
585
980
  const out = resolve(cwd, opts.out ?? `.agenttwin/runs/${runId}`);
586
981
  await mkdir2(out, { recursive: true });
587
- await writeFile3(join6(out, "run.json"), JSON.stringify(receipt, null, 2) + "\n", { mode: 384 });
588
- await writeFile3(join6(out, "world.json"), JSON.stringify(spec, null, 2) + "\n", { mode: 384 });
982
+ await writeFile4(join7(out, "run.json"), JSON.stringify(receipt, null, 2) + "\n", { mode: 384 });
983
+ await writeFile4(join7(out, "world.json"), JSON.stringify(spec, null, 2) + "\n", { mode: 384 });
589
984
  const summary = [`# ${spec.name}: ${report.status}`, "", `Run: ${runId}`, `Image: ${image}`, "", "## Assertions", "", ...report.assertions.map((a) => `- ${a.passed ? "PASS" : "FAIL"} ${a.service}${a.path}: expected ${a.count}, observed ${a.actual}`), "", "## Infrastructure assertions", "", ...(report.infrastructureAssertions ?? []).map((a) => `- ${a.passed ? "PASS" : "FAIL"} ${a.service}: ${JSON.stringify(a.actual)}`), "", "## Coverage", "", report.coverage, "", "External networking disabled. Only explicitly listed source files were uploaded. New state and tokens are created for each run.", "", "See run.json for application output, faults, events, and initial/final state.", ""].join("\n");
590
- await writeFile3(join6(out, "report.md"), summary);
591
- return { ...receipt, reportPath: join6(out, "report.md"), evidencePath: join6(out, "run.json") };
985
+ await writeFile4(join7(out, "report.md"), summary);
986
+ return { ...receipt, reportPath: join7(out, "report.md"), evidencePath: join7(out, "run.json") };
592
987
  } finally {
593
988
  await rm(stage, { recursive: true, force: true });
594
989
  }
595
990
  }
596
991
 
597
992
  // src/commands/prepare.ts
993
+ init_session();
598
994
  function reviewPreparation(inventory, input) {
599
995
  const fail2 = (message) => {
600
996
  throw new CliError(message, { code: EXIT.USAGE });
@@ -641,36 +1037,36 @@ function reviewPreparation(inventory, input) {
641
1037
  async function runPrepare(cwd, opts) {
642
1038
  if (!opts.file) throw new CliError("prepare requires --file preparation.json", { code: EXIT.USAGE });
643
1039
  const file = resolve2(cwd, opts.file);
644
- const inventory = JSON.parse(await readFile7(join7(cwd, ".agenttwin/inventory.json"), "utf8"));
1040
+ const inventory = JSON.parse(await readFile8(join8(cwd, ".agenttwin/inventory.json"), "utf8"));
645
1041
  const discovered = await loadDashboardDiscovery(cwd, inventory);
646
1042
  const known = new Set(inventory.workflows.map((w) => w.id));
647
1043
  for (const w of discovered.workflows) if (!known.has(w.id)) {
648
1044
  inventory.workflows.push({ ...w, confidence: "low", apps: [], sharedEntities: [], evidence: [{ source: "agent-analysis", ref: ".agenttwin/agent-discovery.json" }], steps: w.steps.map((s) => ({ ...s, app: "", evidence: [] })), risk: "high", rank: 0, supported: false, hypothesis: true, linkingSignals: ["Coding-agent analysis requires user review"], scopeUncertain: true, verified: false });
649
1045
  }
650
1046
  for (const t of discovered.tools) if (!inventory.tools.some((tool) => tool.id === t.id)) throw new Error("Agent-discovered dependencies must be classified in init before preparation. Review the additional tool, then rerun init.");
651
- const { spec, workflow, mappings } = reviewPreparation(inventory, JSON.parse(await readFile7(file, "utf8")));
1047
+ const { spec, workflow, mappings } = reviewPreparation(inventory, JSON.parse(await readFile8(file, "utf8")));
652
1048
  const directory = resolve2(cwd, opts.out ?? `.agenttwin/prepared/${spec.name}-${randomUUID2().slice(0, 8)}`);
653
- const stage = await mkdtemp2(join7(tmpdir2(), "agenttwin-prepare-"));
1049
+ const stage = await mkdtemp2(join8(tmpdir2(), "agenttwin-prepare-"));
654
1050
  try {
655
1051
  const sourceDigests = await stageApplication(dirname2(file), spec.files, stage);
656
1052
  const files = /* @__PURE__ */ Object.create(null);
657
- for (const name of spec.files) files[name] = (await readFile7(join7(stage, name))).toString("base64");
658
- const manifest = { schema: "agenttwin/prepared-v1", workflowId: workflow.id, invocation: spec.command, sourceDigests, specDigest: createHash2("sha256").update(JSON.stringify(spec)).digest("hex"), mappings, provenance: "user-reviewed invocation and operation contracts; discovery does not verify runtime fidelity", createdAt: (/* @__PURE__ */ new Date()).toISOString() };
659
- await writeFile4(join7(stage, "agenttwin.world.json"), JSON.stringify(spec, null, 2) + "\n", { mode: 384 });
660
- await writeFile4(join7(stage, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n", { mode: 384 });
661
- await writeFile4(join7(stage, "agenttwin.bundle.json"), JSON.stringify({ schema: "agenttwin/upload-v1", spec, files, manifest }, null, 2) + "\n", { mode: 384 });
662
- await mkdir3(join7(stage, ".agenttwin"), { recursive: true });
663
- await writeFile4(join7(stage, ".agenttwin/inventory.json"), JSON.stringify({ workflows: [{ id: workflow.id }] }), { mode: 384 });
1053
+ for (const name of spec.files) files[name] = (await readFile8(join8(stage, name))).toString("base64");
1054
+ const manifest = { project: discovered.name, git: await captureGit(cwd), schema: "agenttwin/prepared-v1", workflowId: workflow.id, invocation: spec.command, sourceDigests, specDigest: createHash2("sha256").update(JSON.stringify(spec)).digest("hex"), mappings, provenance: "user-reviewed invocation and operation contracts; discovery does not verify runtime fidelity", createdAt: (/* @__PURE__ */ new Date()).toISOString() };
1055
+ await writeFile5(join8(stage, "agenttwin.world.json"), JSON.stringify(spec, null, 2) + "\n", { mode: 384 });
1056
+ await writeFile5(join8(stage, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n", { mode: 384 });
1057
+ await writeFile5(join8(stage, "agenttwin.bundle.json"), JSON.stringify({ schema: "agenttwin/upload-v1", spec, files, manifest }, null, 2) + "\n", { mode: 384 });
1058
+ await mkdir3(join8(stage, ".agenttwin"), { recursive: true });
1059
+ await writeFile5(join8(stage, ".agenttwin/inventory.json"), JSON.stringify({ name: discovered.name, workflows: [{ id: workflow.id }] }), { mode: 384 });
664
1060
  await mkdir3(dirname2(directory), { recursive: true });
665
1061
  await mkdir3(directory, { mode: 448 });
666
1062
  try {
667
- const { cp: cp2, readdir: readdir2 } = await import("fs/promises");
668
- for (const entry of await readdir2(stage)) await cp2(join7(stage, entry), join7(directory, entry), { recursive: true, force: false, errorOnExist: true });
1063
+ const { cp: cp2, readdir: readdir3 } = await import("fs/promises");
1064
+ for (const entry of await readdir3(stage)) await cp2(join8(stage, entry), join8(directory, entry), { recursive: true, force: false, errorOnExist: true });
669
1065
  } catch (error) {
670
1066
  await rm2(directory, { recursive: true, force: true });
671
1067
  throw error;
672
1068
  }
673
- return { directory, worldPath: join7(directory, "agenttwin.world.json"), bundlePath: join7(directory, "agenttwin.bundle.json"), manifest };
1069
+ return { directory, worldPath: join8(directory, "agenttwin.world.json"), bundlePath: join8(directory, "agenttwin.bundle.json"), manifest };
674
1070
  } finally {
675
1071
  await rm2(stage, { recursive: true, force: true });
676
1072
  }
@@ -678,9 +1074,9 @@ async function runPrepare(cwd, opts) {
678
1074
 
679
1075
  // src/commands/setup.ts
680
1076
  import { Template } from "e2b";
681
- import { mkdir as mkdir4, writeFile as writeFile5 } from "fs/promises";
1077
+ import { mkdir as mkdir4, writeFile as writeFile6 } from "fs/promises";
682
1078
  import { homedir as homedir2 } from "os";
683
- import { join as join8 } from "path";
1079
+ import { join as join9 } from "path";
684
1080
  async function runSetup(log) {
685
1081
  if (!process.env.E2B_API_KEY) throw new CliError("Set E2B_API_KEY before creating your hosted runtime template", { code: EXIT.BLOCKED });
686
1082
  log("Building the AgentTwin template on E2B (Node 22, PostgreSQL 15, Redis). No local containers are used.");
@@ -688,16 +1084,16 @@ async function runSetup(log) {
688
1084
  const result = await Template.build(template, "agenttwin-node-v2", { apiKey: process.env.E2B_API_KEY, cpuCount: 2, memoryMB: 2048, onBuildLogs: (entry) => {
689
1085
  if (!entry.message.includes("[stdout]") && !entry.message.includes("[stderr]")) log(entry.message);
690
1086
  } });
691
- const directory = join8(homedir2(), ".agenttwin");
1087
+ const directory = join9(homedir2(), ".agenttwin");
692
1088
  await mkdir4(directory, { recursive: true, mode: 448 });
693
- const path = join8(directory, "runtime.json");
694
- await writeFile5(path, JSON.stringify({ provider: "e2b", templateId: result.templateId, buildId: result.buildId }, null, 2) + "\n", { mode: 384 });
1089
+ const path = join9(directory, "runtime.json");
1090
+ await writeFile6(path, JSON.stringify({ provider: "e2b", templateId: result.templateId, buildId: result.buildId }, null, 2) + "\n", { mode: 384 });
695
1091
  return { provider: "e2b", templateId: result.templateId, buildId: result.buildId, configPath: path };
696
1092
  }
697
1093
 
698
1094
  // src/commands/example.ts
699
- import { mkdir as mkdir5, writeFile as writeFile6, stat } from "fs/promises";
700
- import { resolve as resolve3, join as join9 } from "path";
1095
+ import { mkdir as mkdir5, writeFile as writeFile7, stat } from "fs/promises";
1096
+ import { resolve as resolve3, join as join10 } from "path";
701
1097
  var exampleSource = `// Replace this file with your own bundled Node workflow.
702
1098
  const safe = process.argv.includes('--safe');
703
1099
  const crm = process.env.AGENTTWIN_CRM_URL;
@@ -742,26 +1138,26 @@ async function runExample(cwd, out) {
742
1138
  const directory = resolve3(cwd, out ?? "agenttwin-example");
743
1139
  if (await stat(directory).then(() => true, () => false)) throw new CliError(`Destination exists: ${directory}`, { code: EXIT.USAGE, action: "Choose a new --out directory." });
744
1140
  await mkdir5(directory, { recursive: true });
745
- await writeFile6(join9(directory, "workflow.mjs"), exampleSource);
746
- await writeFile6(join9(directory, "agenttwin.world.json"), JSON.stringify(exampleWorld(), null, 2) + "\n");
747
- await writeFile6(join9(directory, "safe.world.json"), JSON.stringify(exampleWorld(true), null, 2) + "\n");
748
- await writeFile6(join9(directory, "README.md"), `# Support workflow
1141
+ await writeFile7(join10(directory, "workflow.mjs"), exampleSource);
1142
+ await writeFile7(join10(directory, "agenttwin.world.json"), JSON.stringify(exampleWorld(), null, 2) + "\n");
1143
+ await writeFile7(join10(directory, "safe.world.json"), JSON.stringify(exampleWorld(true), null, 2) + "\n");
1144
+ await writeFile7(join10(directory, "README.md"), `# Support workflow
749
1145
 
750
- Run with agenttwin run --file agenttwin.world.json (expected FAIL), then --file safe.world.json (expected PASS). Requires AGENTTWIN_API_URL and AGENTTWIN_TOKEN, or your own E2B_API_KEY plus a template from agenttwin setup. Each run creates a fresh hosted sandbox and deletes it afterward.
1146
+ Run with agenttwin run --file agenttwin.world.json (expected FAIL), then --file safe.world.json (expected PASS). Runs through the CLI with in-process service twins by default. No VM or containers. Use --runtime hosted or --runtime e2b explicitly only when a cloud machine is needed.
751
1147
 
752
1148
  This is actual application code using a synthetic custom ticket API plus the supported Slack JSON subset. It is not a clone of a CRM vendor. Replace workflow.mjs with your own built code and edit the explicit file list, endpoints, seed records and assertions. No npm install runs in the sandbox; dependencies must be bundled into your application.
753
1149
  `);
754
- return { directory, worldPath: join9(directory, "agenttwin.world.json") };
1150
+ return { directory, worldPath: join10(directory, "agenttwin.world.json") };
755
1151
  }
756
1152
 
757
1153
  // src/commands/init.ts
758
1154
  import { readFileSync as readFileSync2 } from "fs";
759
- import { writeFile as writeFile10 } from "fs/promises";
760
- import { join as join16 } from "path";
1155
+ import { writeFile as writeFile11 } from "fs/promises";
1156
+ import { join as join17 } from "path";
761
1157
 
762
1158
  // src/lib/files.ts
763
- import { readdir, readFile as readFile8, stat as stat2 } from "fs/promises";
764
- import { join as join10, relative as relative2, sep as sep2 } from "path";
1159
+ import { readdir as readdir2, readFile as readFile9, stat as stat2 } from "fs/promises";
1160
+ import { join as join11, relative as relative2, sep as sep2 } from "path";
765
1161
  var IGNORED_DIRS = /* @__PURE__ */ new Set([
766
1162
  ".git",
767
1163
  ".agenttwin",
@@ -812,14 +1208,14 @@ async function walkDir(root, maxFiles, budgetMs) {
812
1208
  const dir = stack.pop();
813
1209
  let entries;
814
1210
  try {
815
- entries = await readdir(dir, { withFileTypes: true });
1211
+ entries = await readdir2(dir, { withFileTypes: true });
816
1212
  } catch {
817
1213
  limits.push(`Directory could not be read: ${relative2(root, dir) || "."}`);
818
1214
  continue;
819
1215
  }
820
1216
  entries.sort((a, b) => a.name.localeCompare(b.name));
821
1217
  for (const entry of entries) {
822
- const full = join10(dir, entry.name);
1218
+ const full = join11(dir, entry.name);
823
1219
  if (entry.isDirectory()) {
824
1220
  if (!shouldIgnoreDir(entry.name)) stack.push(full);
825
1221
  } else if (entry.isFile()) {
@@ -853,7 +1249,7 @@ var RepoFileIndex = class {
853
1249
  }
854
1250
  async exists(path) {
855
1251
  try {
856
- await stat2(join10(this.cwd, path));
1252
+ await stat2(join11(this.cwd, path));
857
1253
  return true;
858
1254
  } catch {
859
1255
  return false;
@@ -862,7 +1258,7 @@ var RepoFileIndex = class {
862
1258
  async read(path, maxBytes = MAX_BYTES) {
863
1259
  const cached = this.cache.contents.get(path);
864
1260
  if (cached !== void 0) return cached;
865
- const full = join10(this.cwd, path);
1261
+ const full = join11(this.cwd, path);
866
1262
  try {
867
1263
  const info2 = await stat2(full);
868
1264
  if (!info2.isFile()) return void 0;
@@ -870,7 +1266,7 @@ var RepoFileIndex = class {
870
1266
  this.cache.limits.push(`File excluded by size limit (${maxBytes} bytes): ${path}`);
871
1267
  return void 0;
872
1268
  }
873
- const text = await readFile8(full, "utf8");
1269
+ const text = await readFile9(full, "utf8");
874
1270
  this.cache.contents.set(path, text);
875
1271
  return text;
876
1272
  } catch {
@@ -1687,10 +2083,10 @@ var ciDetector = {
1687
2083
  };
1688
2084
 
1689
2085
  // src/discovery/detectors/containers.ts
1690
- import { execFile } from "child_process";
2086
+ import { execFile as execFile2 } from "child_process";
1691
2087
  function dockerImages() {
1692
2088
  return new Promise((resolve5) => {
1693
- execFile(
2089
+ execFile2(
1694
2090
  "docker",
1695
2091
  ["ps", "--format", "{{.Image}}"],
1696
2092
  { timeout: 2e3 },
@@ -1730,13 +2126,13 @@ var containerDetector = {
1730
2126
  // src/discovery/detectors/cloud-creds.ts
1731
2127
  import { stat as stat3 } from "fs/promises";
1732
2128
  import { homedir as homedir3 } from "os";
1733
- import { join as join11 } from "path";
2129
+ import { join as join12 } from "path";
1734
2130
  var CREDENTIALS = [
1735
- { path: join11(homedir3(), ".aws", "config"), subject: "aws", title: "AWS" },
1736
- { path: join11(homedir3(), ".aws", "credentials"), subject: "aws", title: "AWS" },
1737
- { path: join11(homedir3(), ".config", "gcloud"), subject: "gcp", title: "Google Cloud" },
1738
- { path: join11(homedir3(), ".azure"), subject: "azure", title: "Azure" },
1739
- { path: join11(homedir3(), ".kube", "config"), subject: "kubernetes", title: "Kubernetes" }
2131
+ { path: join12(homedir3(), ".aws", "config"), subject: "aws", title: "AWS" },
2132
+ { path: join12(homedir3(), ".aws", "credentials"), subject: "aws", title: "AWS" },
2133
+ { path: join12(homedir3(), ".config", "gcloud"), subject: "gcp", title: "Google Cloud" },
2134
+ { path: join12(homedir3(), ".azure"), subject: "azure", title: "Azure" },
2135
+ { path: join12(homedir3(), ".kube", "config"), subject: "kubernetes", title: "Kubernetes" }
1740
2136
  ];
1741
2137
  async function exists(path) {
1742
2138
  try {
@@ -2295,12 +2691,12 @@ var SimpleDetectorRegistry = class {
2295
2691
  };
2296
2692
 
2297
2693
  // src/discovery/passes/runtime.ts
2298
- import { spawn as spawn2 } from "child_process";
2299
- import { createServer } from "http";
2694
+ import { spawn as spawn3 } from "child_process";
2695
+ import { createServer as createServer2 } from "http";
2300
2696
  import { connect } from "net";
2301
2697
  import { readFileSync, rmSync, writeFileSync } from "fs";
2302
2698
  import { tmpdir as tmpdir3 } from "os";
2303
- import { join as join12 } from "path";
2699
+ import { join as join13 } from "path";
2304
2700
  var EGRESS_HOOK = `
2305
2701
  const fs = require('fs');
2306
2702
  const dns = require('dns');
@@ -2342,7 +2738,7 @@ function delay(ms) {
2342
2738
  }
2343
2739
  function startProxy(onHost) {
2344
2740
  return new Promise((resolve5) => {
2345
- const server = createServer((req, res) => {
2741
+ const server = createServer2((req, res) => {
2346
2742
  const host = (req.headers.host ?? "").split(":")[0];
2347
2743
  if (host) onHost(host);
2348
2744
  res.writeHead(502);
@@ -2370,8 +2766,8 @@ function startProxy(onHost) {
2370
2766
  async function observeEgress(opts) {
2371
2767
  const hosts = /* @__PURE__ */ new Set();
2372
2768
  const { server, url } = await startProxy((host) => hosts.add(host));
2373
- const logFile = join12(tmpdir3(), `agenttwin-egress-${process.pid}.log`);
2374
- const hookFile = join12(tmpdir3(), `agenttwin-egress-hook-${process.pid}.cjs`);
2769
+ const logFile = join13(tmpdir3(), `agenttwin-egress-${process.pid}.log`);
2770
+ const hookFile = join13(tmpdir3(), `agenttwin-egress-hook-${process.pid}.cjs`);
2375
2771
  try {
2376
2772
  writeFileSync(hookFile, EGRESS_HOOK, "utf8");
2377
2773
  writeFileSync(logFile, "", "utf8");
@@ -2380,7 +2776,7 @@ async function observeEgress(opts) {
2380
2776
  let child;
2381
2777
  if (opts.run) {
2382
2778
  opts.log(`Observing egress: spawning \`${opts.run}\` through ${url}`);
2383
- child = spawn2(opts.run, {
2779
+ child = spawn3(opts.run, {
2384
2780
  shell: true,
2385
2781
  env: {
2386
2782
  ...process.env,
@@ -3621,8 +4017,8 @@ async function buildInventory({
3621
4017
  }
3622
4018
 
3623
4019
  // src/inventory/store.ts
3624
- import { mkdir as mkdir6, readFile as readFile9, writeFile as writeFile7 } from "fs/promises";
3625
- import { join as join13 } from "path";
4020
+ import { mkdir as mkdir6, readFile as readFile10, writeFile as writeFile8 } from "fs/promises";
4021
+ import { join as join14 } from "path";
3626
4022
  var STORE_DIR = ".agenttwin";
3627
4023
  var STORE_GITIGNORE = [
3628
4024
  "inventory.json",
@@ -3638,24 +4034,24 @@ var STORE_GITIGNORE = [
3638
4034
  ""
3639
4035
  ].join("\n");
3640
4036
  function storeDir(cwd) {
3641
- return join13(cwd, STORE_DIR);
4037
+ return join14(cwd, STORE_DIR);
3642
4038
  }
3643
4039
  function storePath(cwd, name) {
3644
- return join13(storeDir(cwd), name);
4040
+ return join14(storeDir(cwd), name);
3645
4041
  }
3646
4042
  async function ensureStore(cwd) {
3647
4043
  await mkdir6(storeDir(cwd), { recursive: true });
3648
- await writeFile7(storePath(cwd, ".gitignore"), STORE_GITIGNORE, "utf8");
4044
+ await writeFile8(storePath(cwd, ".gitignore"), STORE_GITIGNORE, "utf8");
3649
4045
  }
3650
4046
  async function readJson(path) {
3651
4047
  try {
3652
- return JSON.parse(await readFile9(path, "utf8"));
4048
+ return JSON.parse(await readFile10(path, "utf8"));
3653
4049
  } catch {
3654
4050
  return void 0;
3655
4051
  }
3656
4052
  }
3657
4053
  async function writeJson(path, value) {
3658
- await writeFile7(path, `${JSON.stringify(value, null, 2)}
4054
+ await writeFile8(path, `${JSON.stringify(value, null, 2)}
3659
4055
  `, "utf8");
3660
4056
  }
3661
4057
  var readInventory = (cwd) => readJson(storePath(cwd, "inventory.json"));
@@ -3858,13 +4254,14 @@ var REGISTRY = [
3858
4254
  ];
3859
4255
 
3860
4256
  // src/inspect/runner.ts
3861
- import { spawn as spawn3 } from "child_process";
4257
+ import { spawn as spawn4 } from "child_process";
4258
+ init_sanitize();
3862
4259
  function runCommand(spec, cwd, signal) {
3863
4260
  return new Promise((resolve5) => {
3864
4261
  if (signal.aborted) {
3865
4262
  return resolve5({ code: null, stdout: "", stderr: "", missing: false, timedOut: false, cancelled: true });
3866
4263
  }
3867
- const child = spawn3(spec.cmd, spec.args, {
4264
+ const child = spawn4(spec.cmd, spec.args, {
3868
4265
  cwd,
3869
4266
  stdio: ["ignore", "pipe", "pipe"],
3870
4267
  env: process.env
@@ -4402,12 +4799,12 @@ async function runExport(opts) {
4402
4799
  const plan = await readPlan(opts.cwd);
4403
4800
  const bundle = { schema: "export/v1", inventory, plan: plan ?? null };
4404
4801
  if (opts.out) {
4405
- const { mkdir: mkdir8, writeFile: writeFile11 } = await import("fs/promises");
4406
- const { join: join18 } = await import("path");
4802
+ const { mkdir: mkdir8, writeFile: writeFile12 } = await import("fs/promises");
4803
+ const { join: join19 } = await import("path");
4407
4804
  await mkdir8(opts.out, { recursive: true });
4408
- await writeFile11(join18(opts.out, "inventory.json"), `${JSON.stringify(inventory, null, 2)}
4805
+ await writeFile12(join19(opts.out, "inventory.json"), `${JSON.stringify(inventory, null, 2)}
4409
4806
  `, "utf8");
4410
- if (plan) await writeFile11(join18(opts.out, "plan.json"), `${JSON.stringify(plan, null, 2)}
4807
+ if (plan) await writeFile12(join19(opts.out, "plan.json"), `${JSON.stringify(plan, null, 2)}
4411
4808
  `, "utf8");
4412
4809
  return { schema: "export/v1", out: opts.out, wrote: ["inventory.json", ...plan ? ["plan.json"] : []] };
4413
4810
  }
@@ -4807,16 +5204,16 @@ async function askQuestions(questions) {
4807
5204
  init_answers();
4808
5205
 
4809
5206
  // src/lib/config.ts
4810
- import { readFile as readFile10, writeFile as writeFile8 } from "fs/promises";
4811
- import { join as join14 } from "path";
5207
+ import { readFile as readFile11, writeFile as writeFile9 } from "fs/promises";
5208
+ import { join as join15 } from "path";
4812
5209
  var CONFIG_FILENAME = "agenttwin.config.json";
4813
5210
  var CONFIG_VERSION = 2;
4814
5211
  function configPath(cwd) {
4815
- return join14(cwd, CONFIG_FILENAME);
5212
+ return join15(cwd, CONFIG_FILENAME);
4816
5213
  }
4817
5214
  async function readConfig(cwd) {
4818
5215
  try {
4819
- const text = await readFile10(configPath(cwd), "utf8");
5216
+ const text = await readFile11(configPath(cwd), "utf8");
4820
5217
  return JSON.parse(text);
4821
5218
  } catch {
4822
5219
  return void 0;
@@ -4852,17 +5249,22 @@ function buildConfigFromInventory(inventory, existing) {
4852
5249
  }
4853
5250
  async function writeConfig(cwd, config, _options = {}) {
4854
5251
  const path = configPath(cwd);
4855
- await writeFile8(path, `${JSON.stringify(config, null, 2)}
5252
+ await writeFile9(path, `${JSON.stringify(config, null, 2)}
4856
5253
  `, "utf8");
4857
5254
  return { path };
4858
5255
  }
4859
5256
 
5257
+ // src/commands/init.ts
5258
+ init_dashboard();
5259
+ init_session();
5260
+
4860
5261
  // src/commands/preview.ts
4861
- import { mkdir as mkdir7, readFile as readFile11, writeFile as writeFile9 } from "fs/promises";
4862
- import { dirname as dirname3, join as join15, resolve as resolve4 } from "path";
5262
+ import { mkdir as mkdir7, readFile as readFile12, writeFile as writeFile10 } from "fs/promises";
5263
+ import { dirname as dirname3, join as join16, resolve as resolve4 } from "path";
4863
5264
 
4864
5265
  // src/preview/html.ts
4865
- import { randomBytes as randomBytes2 } from "crypto";
5266
+ init_sanitize();
5267
+ import { randomBytes as randomBytes3 } from "crypto";
4866
5268
  function escapeHtml(value) {
4867
5269
  return sanitizeOutput(String(value ?? "")).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
4868
5270
  }
@@ -4888,7 +5290,7 @@ const feedback=$('feedback-form');
4888
5290
  if(feedback)feedback.addEventListener('submit',event=>{event.preventDefault();const note=$('review-note').value.trim();if(!note){$('feedback-status').textContent='Add a note before downloading.';return}download('agenttwin-feedback.json',{schema:'preview-feedback/v1',createdAt:new Date().toISOString(),rating:$('review-rating').value,note,source:'local-browser-report'});$('feedback-status').textContent='Downloaded locally. Review the file, then share it with the person who invited you. Nothing was sent automatically.'});
4889
5291
  `;
4890
5292
  function page(title, content, data, navigation) {
4891
- const nonce = randomBytes2(16).toString("base64");
5293
+ const nonce = randomBytes3(16).toString("base64");
4892
5294
  const json = JSON.stringify(data).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
4893
5295
  return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; base-uri 'none'; form-action 'none'; connect-src 'none'"><title>${h(title)} \xB7 AgentTwin</title><style nonce="${nonce}">${STYLE}</style></head><body><header><strong>agenttwin</strong><span class="badge">Local preview</span></header><nav>${navigation.map(([id, label]) => `<a href="#${h(id)}">${h(label)}</a>`).join("")}<a href="#feedback">Leave feedback</a></nav><main>${content}${feedbackSection()}<footer>This report runs locally. No telemetry, remote fonts, or automatic uploads. Discovery results can contain internal system names; review before sharing.</footer></main><script type="application/json" id="report-data" nonce="${nonce}">${json}</script><script nonce="${nonce}">${SCRIPT}</script></body></html>`;
4894
5296
  }
@@ -4916,16 +5318,17 @@ function renderBrowserReport(inventory, plan) {
4916
5318
  }
4917
5319
 
4918
5320
  // src/preview/refund.ts
4919
- import { createServer as createServer2 } from "http";
4920
- import { randomBytes as randomBytes3 } from "crypto";
5321
+ import { createServer as createServer3 } from "http";
5322
+ import { randomBytes as randomBytes4 } from "crypto";
4921
5323
 
4922
5324
  // src/commands/preview.ts
5325
+ init_sanitize();
4923
5326
  async function writeBrowserReport(cwd, inventory, plan, out) {
4924
5327
  const path = out ? resolve4(cwd, out) : storePath(cwd, "report.html");
4925
5328
  await mkdir7(dirname3(path), { recursive: true });
4926
5329
  const cleanInventory = JSON.parse(sanitizeOutput(JSON.stringify(inventory)));
4927
5330
  const cleanPlan = JSON.parse(sanitizeOutput(JSON.stringify(plan)));
4928
- await writeFile9(path, renderBrowserReport(cleanInventory, cleanPlan), "utf8");
5331
+ await writeFile10(path, renderBrowserReport(cleanInventory, cleanPlan), "utf8");
4929
5332
  return path;
4930
5333
  }
4931
5334
  async function runReport(cwd, out) {
@@ -4940,13 +5343,13 @@ async function runFeedback(cwd, note) {
4940
5343
  const path = storePath(cwd, "feedback.json");
4941
5344
  let entries = [];
4942
5345
  try {
4943
- const existing = JSON.parse(await readFile11(path, "utf8"));
5346
+ const existing = JSON.parse(await readFile12(path, "utf8"));
4944
5347
  entries = Array.isArray(existing.items) ? existing.items : [];
4945
5348
  } catch (error) {
4946
5349
  if (error.code !== "ENOENT") throw new CliError("Existing feedback could not be read; it was not overwritten");
4947
5350
  }
4948
5351
  entries.push({ createdAt: (/* @__PURE__ */ new Date()).toISOString(), note: sanitizeOutput(note.trim()) });
4949
- await writeFile9(path, `${JSON.stringify({ schema: "preview-feedback/v1", items: entries }, null, 2)}
5352
+ await writeFile10(path, `${JSON.stringify({ schema: "preview-feedback/v1", items: entries }, null, 2)}
4950
5353
  `, "utf8");
4951
5354
  return { schema: "feedback-result/v1", saved: true, uploaded: false, path, notes: entries.length };
4952
5355
  }
@@ -5099,8 +5502,8 @@ async function runInit(opts) {
5099
5502
  await writeInventory(opts.cwd, finalInventory);
5100
5503
  await writePlan(opts.cwd, plan);
5101
5504
  await writeConfig(opts.cwd, config, { force: opts.force });
5102
- await writeFile10(join16(opts.cwd, "ENVIRONMENT.md"), renderEnvironment(finalInventory), "utf8");
5103
- await writeFile10(join16(opts.cwd, "REPLICATION.md"), renderReplication(finalInventory, plan), "utf8");
5505
+ await writeFile11(join17(opts.cwd, "ENVIRONMENT.md"), renderEnvironment(finalInventory), "utf8");
5506
+ await writeFile11(join17(opts.cwd, "REPLICATION.md"), renderReplication(finalInventory, plan), "utf8");
5104
5507
  reportPath = await writeBrowserReport(opts.cwd, finalInventory, plan);
5105
5508
  dashboardPath = await writeDashboard(opts.cwd, finalInventory);
5106
5509
  wrote = true;
@@ -5164,7 +5567,7 @@ async function runInit(opts) {
5164
5567
  function readVersion() {
5165
5568
  const here = dirname4(fileURLToPath3(import.meta.url));
5166
5569
  const pkg = JSON.parse(
5167
- readFileSync3(join17(here, "..", "package.json"), "utf8")
5570
+ readFileSync3(join18(here, "..", "package.json"), "utf8")
5168
5571
  );
5169
5572
  return pkg.version ?? "0.0.0";
5170
5573
  }
@@ -5179,9 +5582,9 @@ function helpText() {
5179
5582
  bold("Commands"),
5180
5583
  ` ${"setup".padEnd(12)}Build your hosted E2B runtime template (once per account)`,
5181
5584
  ` ${"prepare".padEnd(12)}Bundle a reviewed discovered workflow (--file preparation.json)`,
5182
- ` ${"run".padEnd(12)}Execute your Node workflow in a hosted E2B sandbox (--file world.json)`,
5585
+ ` ${"run".padEnd(12)}Test your Node workflow through the CLI (--file world.json)`,
5183
5586
  ` ${"example".padEnd(12)}Write an editable multi-service workflow (--out directory)`,
5184
- ` ${"sync".padEnd(12)}Send discovery metadata to your account dashboard`,
5587
+ ` ${"sync".padEnd(12)}Sync discovery, Git metadata and CLI test results to your dashboard`,
5185
5588
  ` ${"init".padEnd(12)}Discover and write agenttwin.config.json + reports`,
5186
5589
  ` ${"discover".padEnd(12)}Build the company inventory (writes .agenttwin/inventory.json)`,
5187
5590
  ` ${"decide".padEnd(12)}Apply structured decisions (--file or --stdin)`,
@@ -5203,7 +5606,7 @@ function helpText() {
5203
5606
  ` ${"--yes / --non-interactive".padEnd(20)}Never prompt`,
5204
5607
  ` ${"--dry-run".padEnd(20)}init: discover without writing any files`,
5205
5608
  ` ${"--out <path>".padEnd(20)}run/example: output directory; report: HTML file`,
5206
- ` ${"--runtime <hosted|e2b>".padEnd(20)}run: hosted API or direct E2B; defaults to configured API, otherwise E2B`,
5609
+ ` ${"--runtime <local|hosted|e2b>".padEnd(20)}run: local CLI by default; hosted API or E2B when a cloud VM is needed`,
5207
5610
  ` ${"-v, --version".padEnd(20)}Print the installed version`,
5208
5611
  ` ${"-h, --help".padEnd(20)}Show this help`
5209
5612
  ].join("\n");
@@ -5318,7 +5721,7 @@ Run: agenttwin run --file "${result.worldPath}"`);
5318
5721
  }
5319
5722
  case "sync": {
5320
5723
  const result = await runSync(cwd);
5321
- printResult(result, `Synced ${result.tools} resources and ${result.workflows} workflow candidates.
5724
+ printResult(result, `Synced ${result.tools} resources, ${result.workflows} workflow candidates and ${result.receipts} CLI run receipts.
5322
5725
  ${result.dashboard}
5323
5726
  ${result.uploaded}`);
5324
5727
  return;
@@ -5357,7 +5760,7 @@ ${result.uploaded}`);
5357
5760
  result,
5358
5761
  [
5359
5762
  `Discovered ${counts.tools} tools (${counts.classified} classified) \xB7 ${counts.unclassified} unclassified \xB7 ${counts.withTwin} with twins`,
5360
- `Wrote ${join17(".agenttwin", "inventory.json")}`,
5763
+ `Wrote ${join18(".agenttwin", "inventory.json")}`,
5361
5764
  result.blocked.length > 0 ? `Blocked: ${result.blocked.join("; ")}` : "",
5362
5765
  result.opportunities.length > 0 ? `Opportunities: ${result.opportunities.join("; ")}` : "",
5363
5766
  result.reconciliation.splits.length > 0 ? `Splits: ${result.reconciliation.splits.map((s) => s.from).join(", ")}` : ""
@@ -5380,7 +5783,7 @@ ${result.uploaded}`);
5380
5783
  printResult(
5381
5784
  result,
5382
5785
  `Plan: ${result.plan.items.length} tools \xB7 ${Object.entries(byStrategy).map(([k, v]) => `${k} ${v}`).join(", ")}
5383
- Wrote ${join17(".agenttwin", "plan.json")}`
5786
+ Wrote ${join18(".agenttwin", "plan.json")}`
5384
5787
  );
5385
5788
  return;
5386
5789
  }