agenttwin 0.3.10 → 0.3.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (21) hide show
  1. package/README.md +7 -1
  2. package/dist/api.js +42 -2
  3. package/dist/cli.js +348 -47
  4. package/dist/dashboard/assets/next/static/chunks/9819-f4092ab03f2f8081.js +1 -0
  5. package/dist/dashboard/assets/next/static/chunks/app/dashboard/page-256847cee82d65f9.js +1 -0
  6. package/dist/dashboard/assets/next/static/chunks/app/layout-936e4569a1d1bf81.js +1 -0
  7. package/dist/dashboard/assets/next/static/chunks/app/v2/dashboard/page-0a9d4a8730592fce.js +1 -0
  8. package/dist/dashboard/assets/next/static/chunks/b468fba8.437b212aae4abcf8.js +2 -0
  9. package/dist/dashboard/assets/next/static/chunks/webpack-7c1fd2c82a2ee8c9.js +1 -0
  10. package/dist/dashboard/assets/next/static/css/dd3fb06005e18149.css +1 -0
  11. package/dist/dashboard/index.html +2 -2
  12. package/dist/gateway.js +42 -2
  13. package/package.json +1 -1
  14. package/dist/dashboard/assets/next/static/chunks/1848-a6938ad74a8e409f.js +0 -1
  15. package/dist/dashboard/assets/next/static/chunks/app/dashboard/page-03aac4c01adcda82.js +0 -1
  16. package/dist/dashboard/assets/next/static/chunks/app/layout-dd5d4772c11d4cbb.js +0 -1
  17. package/dist/dashboard/assets/next/static/chunks/app/v2/dashboard/page-a99a9eeb03937d53.js +0 -1
  18. package/dist/dashboard/assets/next/static/chunks/webpack-351162512698e56b.js +0 -1
  19. package/dist/dashboard/assets/next/static/css/f9c2780fff15fd22.css +0 -1
  20. /package/dist/dashboard/assets/next/static/{puuDlswFkm304y1LsNJ98 → 1OFptRW9eKBlZCmD85D2C}/_buildManifest.js +0 -0
  21. /package/dist/dashboard/assets/next/static/{puuDlswFkm304y1LsNJ98 → 1OFptRW9eKBlZCmD85D2C}/_ssgManifest.js +0 -0
package/README.md CHANGED
@@ -12,7 +12,7 @@ Node.js 22.12+ is required. Run discovery from your project; npx installs the CL
12
12
  npx agenttwin init
13
13
  ```
14
14
 
15
- `init` discovers your repository and opens a persistent control menu. Bare `npx agenttwin` opens the same entry point. Select a reviewed world, inspect every service's support, run correctness checks, explore seeded faults, replay a failure, verify a source-only patch, save a regression, import an incident, or connect your account—all from this menu, without remembering flags or separate command names. `npx agenttwin refresh` updates discovery without executing application code. Existing advanced commands remain available to CI and coding agents.
15
+ `init` discovers and connects your repository, then shows one recommended next step: set up your first test, or run the test you already selected. You can search and page through detected workflows, load a reviewed test file, and view the last result. Fault exploration, replay, patch verification, regressions, CI setup and runtime selection live under Advanced options. Bare `npx agenttwin` opens the same menu. `npx agenttwin refresh` updates discovery without executing application code. Existing commands remain available to CI and coding agents. Selecting a discovered workflow saves your choice and provides a coding-agent preparation request; it does not automatically create or execute a test.
16
16
 
17
17
  Discovery writes the inventory, workflow candidates, `agenttwin.config.json`, `ENVIRONMENT.md`, `REPLICATION.md`, and an offline browser report. A discovered candidate is not an executable specification: the menu accepts a reviewed `agenttwin.world.json` with explicit bundled Node files, synthetic services, invocation and required outcomes. The optional demo is clearly labeled as synthetic. Redirected input and `--yes` only perform discovery; they never start the interactive execution loop. `init --json --dry-run` writes nothing. Interactive init connects and syncs discovery after the first browser approval; saved connections sync without another approval. Noninteractive init never uploads automatically.
18
18
 
@@ -154,3 +154,9 @@ A run receipt's **Fix & retest** tab exports an evidence-based task for your cod
154
154
  The website’s Settings page saves workspace name, default execution limit (1–300 seconds for new templates), automatic refresh, compact lists and reduced motion in this browser. Existing reviewed bundles retain their limits. The account section supports token visibility, connection status, manual workspace refresh and disconnect; tokens are never persisted with preferences.
155
155
 
156
156
  Top-right initials open account actions. The sidebar closes into a full-width layout. See `docs-site/src/dashboard.mdx` for the complete guide. The website/API UI has received additional fixes after npm 0.2.2; the published package remains 0.2.2.
157
+
158
+ ### Workflow discovery boundaries
159
+
160
+ Discovery identifies individual request branches in Fetch/Bun-style URL dispatch, Express-style callbacks, queue workers and exported provider operations. It follows direct local function calls and relative named imports, including aliases, and recognizes provider hosts in literal HTTP calls. Each candidate retains its source entrypoint and evidence. Router containers and pure exported helpers are not treated as complete workflows.
161
+
162
+ These are possible static code paths, not executed or verified business workflows. Dynamic dispatch, computed destinations, external packages, re-exports and runtime-only integrations can remain unresolved. Prepare inputs, operation coverage and correctness checks before running a candidate. Refresh discovery and sync it to update an already-connected dashboard.
package/dist/api.js CHANGED
@@ -1596,7 +1596,7 @@ var challengeStore = (map, key, value, ttlMs) => {
1596
1596
  init_dashboard_transfer();
1597
1597
  import { generateRegistrationOptions, verifyRegistrationResponse, generateAuthenticationOptions, verifyAuthenticationResponse } from "@simplewebauthn/server";
1598
1598
  import { createServer as createServer2 } from "http";
1599
- import { createHash as createHash8, randomBytes as randomBytes6, randomUUID as randomUUID2, timingSafeEqual as timingSafeEqual4 } from "crypto";
1599
+ import { createHash as createHash9, randomBytes as randomBytes6, randomUUID as randomUUID2, timingSafeEqual as timingSafeEqual4 } from "crypto";
1600
1600
  import { mkdir as mkdir7, readFile as readFile12, writeFile as writeFile9, rename, mkdtemp as mkdtemp3, rm as rm4 } from "fs/promises";
1601
1601
  import { resolve as resolve4, join as join13, dirname as dirname2, extname } from "path";
1602
1602
  import { tmpdir as tmpdir3 } from "os";
@@ -4106,8 +4106,43 @@ See run.json for assertions, service state, application output and isolation met
4106
4106
  }
4107
4107
  }
4108
4108
 
4109
+ // src/runtime/analytics.ts
4110
+ import { createHash as createHash8 } from "crypto";
4111
+ function analyticsConfiguration() {
4112
+ const key = process.env.POSTHOG_API_KEY;
4113
+ const host = process.env.POSTHOG_API_HOST || "https://us.i.posthog.com";
4114
+ return { enabled: Boolean(key), host: host.endsWith("/") ? host.slice(0, -1) : host, key: key ?? "" };
4115
+ }
4116
+ var FLUSH_SINK = "/capture/";
4117
+ function captureAnalytics(event) {
4118
+ const config = analyticsConfiguration();
4119
+ if (!config.enabled) return;
4120
+ const body2 = {
4121
+ api_key: config.key,
4122
+ event: event.event,
4123
+ distinct_id: event.distinctId,
4124
+ properties: {
4125
+ $lib: "agenttwin-gateway",
4126
+ $lib_version: "0.1",
4127
+ ...event.properties
4128
+ },
4129
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
4130
+ };
4131
+ const headers = { "content-type": "application/json" };
4132
+ const timeout = AbortSignal.timeout(4e3);
4133
+ void fetch(config.host + FLUSH_SINK, {
4134
+ method: "POST",
4135
+ headers,
4136
+ body: JSON.stringify(body2),
4137
+ signal: timeout
4138
+ }).then((response) => {
4139
+ if (!response.ok) throw new Error("analytics_http_" + response.status);
4140
+ }).catch(() => {
4141
+ });
4142
+ }
4143
+
4109
4144
  // src/runtime/gateway.ts
4110
- var hash2 = (value) => createHash8("sha256").update(value).digest("hex");
4145
+ var hash2 = (value) => createHash9("sha256").update(value).digest("hex");
4111
4146
  function uploadPath(path2) {
4112
4147
  if (!path2 || path2.startsWith("/") || path2.includes("\\") || path2.includes("\0") || path2.split("/").some((p) => !p || p === "." || p === ".." || /^(\.env.*|\.git|\.ssh|\.aws|\.azure|\.config|\.npmrc|\.netrc|credentials.*)$/i.test(p)) || /\.(pem|key|p12|pfx)$/i.test(path2)) throw new Error("Unsafe uploaded file path");
4113
4148
  return path2;
@@ -4227,10 +4262,12 @@ async function startGateway(opts) {
4227
4262
  const { reportPath: _report, evidencePath: _evidence, ...portable } = receipt;
4228
4263
  item.job.receipt = { ...portable, git: item.git, project: item.project, apiJobId: item.job.id, provenance: "client-supplied workflow declaration" };
4229
4264
  item.job.status = "completed";
4265
+ captureAnalytics({ event: "run_completed", distinctId: item.job.accountId, properties: { jobId: item.job.id, ok: true } });
4230
4266
  } catch (error) {
4231
4267
  item.job.status = controllers.get(item.job.id)?.signal.aborted ? "cancelled" : "error";
4232
4268
  const message = error instanceof Error ? error.message : String(error);
4233
4269
  item.job.error = message.split(process.env.E2B_API_KEY ?? "\0").join("[redacted]").replace(/(?:postgresql|redis):\/\/[^\s]+/g, "[test-connection]").slice(0, 2e3);
4270
+ if (item.job.status !== "cancelled") captureAnalytics({ event: "run_failed", distinctId: item.job.accountId, properties: { jobId: item.job.id } });
4234
4271
  } finally {
4235
4272
  try {
4236
4273
  if (workspace) await rm4(workspace, { recursive: true, force: true });
@@ -4451,6 +4488,7 @@ async function startGateway(opts) {
4451
4488
  if (store.accounts.length >= 1e4) throw new Error("account_capacity");
4452
4489
  owner = { id: randomUUID2(), tokenHash: hash2(randomBytes6(32).toString("hex")), label: identity.name + "'s workspace", active: true, usageDay: "", runs: 0, identity: { email: identity.email, name: identity.name }, externalIdentities: [{ provider: identity.provider, issuer: identity.issuer, subject: identity.subject }], ...identity.organization ? { organization: identity.organization } : {} };
4453
4490
  store.accounts.push(owner);
4491
+ captureAnalytics({ event: "account_created", distinctId: owner.id, properties: { method: identity.provider, realm: identity.organization || identity.issuer } });
4454
4492
  }
4455
4493
  if (!owner.active) throw new Error("account_inactive");
4456
4494
  const secret = randomBytes6(32).toString("hex");
@@ -4671,6 +4709,7 @@ async function startGateway(opts) {
4671
4709
  }
4672
4710
  a = { id: randomUUID2(), tokenHash: hash2(randomBytes6(32).toString("hex")), label: workspace, active: true, usageDay: "", runs: 0, identity: { email: parsed.email, name, passwordHash: encoded } };
4673
4711
  store.accounts.push(a);
4712
+ captureAnalytics({ event: "account_created", distinctId: a.id, properties: { method: "email", realm: "password" } });
4674
4713
  } else {
4675
4714
  const matches2 = await passwordMatches(parsed.password, a?.identity?.passwordHash ?? dummyPassword);
4676
4715
  if (!a?.active || !a.identity?.passwordHash || !matches2) {
@@ -5111,6 +5150,7 @@ async function startGateway(opts) {
5111
5150
  account.runs++;
5112
5151
  store.jobs.push(job);
5113
5152
  await save();
5153
+ captureAnalytics({ event: "run_submitted", distinctId: account.id, properties: { jobId: job.id, project } });
5114
5154
  controllers.set(job.id, new AbortController());
5115
5155
  queue.push({ job, spec, files: input.files, git, project });
5116
5156
  schedule();
package/dist/cli.js CHANGED
@@ -1929,7 +1929,7 @@ import { join as join34 } from "path";
1929
1929
  // package.json
1930
1930
  var package_default = {
1931
1931
  name: "agenttwin",
1932
- version: "0.3.10",
1932
+ version: "0.3.12",
1933
1933
  description: "Give your agent a test world before it touches the real one.",
1934
1934
  keywords: [
1935
1935
  "agent",
@@ -6720,15 +6720,15 @@ function factoryProviderBindings(source) {
6720
6720
  constructors.set(specifier.local.name, provider.id);
6721
6721
  }
6722
6722
  }
6723
- const walk = (node, visit, skipFunctions = false) => {
6723
+ const walk2 = (node, visit, skipFunctions = false) => {
6724
6724
  if (!node || typeof node !== "object") return;
6725
6725
  const value = node;
6726
6726
  if (skipFunctions && ["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"].includes(value.type)) return;
6727
6727
  visit(value);
6728
6728
  for (const [key, child] of Object.entries(value)) {
6729
6729
  if (["loc", "start", "end", "comments"].includes(key)) continue;
6730
- if (Array.isArray(child)) child.forEach((item) => walk(item, visit, skipFunctions));
6731
- else if (child && typeof child === "object") walk(child, visit, skipFunctions);
6730
+ if (Array.isArray(child)) child.forEach((item) => walk2(item, visit, skipFunctions));
6731
+ else if (child && typeof child === "object") walk2(child, visit, skipFunctions);
6732
6732
  }
6733
6733
  };
6734
6734
  const factories = /* @__PURE__ */ new Map();
@@ -6737,7 +6737,7 @@ function factoryProviderBindings(source) {
6737
6737
  if (node?.type !== "FunctionDeclaration" || !node.id || node.async || node.generator || node.params.length) continue;
6738
6738
  const providers = /* @__PURE__ */ new Set();
6739
6739
  let unknown = false;
6740
- walk(node.body, (child) => {
6740
+ walk2(node.body, (child) => {
6741
6741
  if (child.type !== "ReturnStatement") return;
6742
6742
  const argument = child.argument;
6743
6743
  if (argument?.type === "NullLiteral") return;
@@ -6749,18 +6749,18 @@ function factoryProviderBindings(source) {
6749
6749
  }
6750
6750
  const declarations = /* @__PURE__ */ new Map();
6751
6751
  const count = (name) => declarations.set(name, (declarations.get(name) ?? 0) + 1);
6752
- walk(ast.program, (node) => {
6752
+ walk2(ast.program, (node) => {
6753
6753
  if (["FunctionDeclaration", "ClassDeclaration"].includes(node.type) && node.id?.name) count(node.id.name);
6754
6754
  if (node.type === "VariableDeclarator" && node.id.type === "Identifier") count(node.id.name);
6755
6755
  if (["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"].includes(node.type)) {
6756
- for (const parameter of node.params ?? []) walk(parameter, (part) => {
6756
+ for (const parameter of node.params ?? []) walk2(parameter, (part) => {
6757
6757
  if (part.type === "Identifier") count(part.name);
6758
6758
  });
6759
6759
  }
6760
6760
  });
6761
6761
  for (const name of factories.keys()) if ((declarations.get(name) ?? 0) > 1) factories.delete(name);
6762
6762
  const ambiguous = /* @__PURE__ */ new Set();
6763
- walk(ast.program, (node) => {
6763
+ walk2(ast.program, (node) => {
6764
6764
  if (node.type !== "VariableDeclarator" || node.id.type !== "Identifier" || node.init?.type !== "CallExpression" || node.init.callee.type !== "Identifier") return;
6765
6765
  const provider = factories.get(node.init.callee.name);
6766
6766
  if (!provider) return;
@@ -6776,6 +6776,195 @@ function factoryProviderBindings(source) {
6776
6776
  init_entrypoints();
6777
6777
  import { createHash as createHash12 } from "crypto";
6778
6778
 
6779
+ // src/inventory/workflow-graph.ts
6780
+ init_catalog();
6781
+ import { parse as parse3 } from "@babel/parser";
6782
+ import { posix } from "path";
6783
+ function walk(node, visit, skipFunctions = false) {
6784
+ if (!node || typeof node !== "object") return;
6785
+ if (skipFunctions && /^(FunctionDeclaration|FunctionExpression|ArrowFunctionExpression)$/.test(node.type)) return;
6786
+ visit(node);
6787
+ for (const [key, child] of Object.entries(node)) {
6788
+ if (["loc", "start", "end", "comments", "tokens"].includes(key)) continue;
6789
+ if (Array.isArray(child)) child.forEach((item) => walk(item, visit, skipFunctions));
6790
+ else if (child && typeof child === "object") walk(child, visit, skipFunctions);
6791
+ }
6792
+ }
6793
+ function workflowGraph(sources, operations, tools) {
6794
+ const modules = /* @__PURE__ */ new Map();
6795
+ const gaps = [];
6796
+ const resolve19 = (path2, specifier) => {
6797
+ const base = posix.normalize(posix.join(posix.dirname(path2), specifier));
6798
+ return [base, ...[".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"].map((ext) => base + ext), base.replace(/\.js$/, ".ts"), ...[".ts", ".js"].map((ext) => base + "/index" + ext)].find((candidate) => sources.has(candidate));
6799
+ };
6800
+ for (const [path2, source] of sources) {
6801
+ if (!/\.[cm]?[jt]sx?$/.test(path2)) continue;
6802
+ let ast;
6803
+ try {
6804
+ ast = parse3(source, { sourceType: "unambiguous", plugins: ["typescript", "jsx"] });
6805
+ } catch {
6806
+ gaps.push(`Workflow syntax could not be parsed: ${path2}`);
6807
+ continue;
6808
+ }
6809
+ const mod = { functions: /* @__PURE__ */ new Map(), imports: /* @__PURE__ */ new Map(), routes: [], ast };
6810
+ modules.set(path2, mod);
6811
+ const unit = (name, node, exported, trigger = false) => ({ path: path2, name, node, exported, trigger, startLine: node.loc.start.line, endLine: node.loc.end.line });
6812
+ for (const item of ast.program.body) {
6813
+ if (item.type === "ImportDeclaration" && item.importKind !== "type" && item.source.value.startsWith(".")) {
6814
+ const target = resolve19(path2, item.source.value);
6815
+ if (target) for (const spec of item.specifiers) {
6816
+ if (spec.type === "ImportSpecifier" && spec.importKind !== "type") mod.imports.set(spec.local.name, { path: target, name: spec.imported.name ?? spec.imported.value });
6817
+ if (spec.type === "ImportDefaultSpecifier") mod.imports.set(spec.local.name, { path: target, name: "default" });
6818
+ }
6819
+ }
6820
+ const exported = item.type === "ExportNamedDeclaration" || item.type === "ExportDefaultDeclaration";
6821
+ const declaration = exported ? item.declaration : item;
6822
+ if (declaration?.type === "FunctionDeclaration") {
6823
+ const name = declaration.id?.name ?? "default";
6824
+ mod.functions.set(name, { ...unit(name, declaration.body, exported), parameters: declaration.params });
6825
+ if (item.type === "ExportDefaultDeclaration") mod.functions.set("default", mod.functions.get(name));
6826
+ }
6827
+ if (declaration?.type === "VariableDeclaration") for (const variable of declaration.declarations) {
6828
+ if (variable.id.type !== "Identifier") continue;
6829
+ const fn = variable.init;
6830
+ if (fn && ["ArrowFunctionExpression", "FunctionExpression"].includes(fn.type)) mod.functions.set(variable.id.name, { ...unit(variable.id.name, fn.body, exported), parameters: fn.params });
6831
+ }
6832
+ }
6833
+ walk(ast.program, (fn) => {
6834
+ if (!["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"].includes(fn.type) || fn.body?.type !== "BlockStatement") return;
6835
+ const paths = /* @__PURE__ */ new Set();
6836
+ const patterns = /* @__PURE__ */ new Map();
6837
+ walk(fn.body, (node) => {
6838
+ if (node.type !== "VariableDeclarator" || node.id.type !== "Identifier") return;
6839
+ if (node.init?.type === "MemberExpression" && node.init.property.name === "pathname") paths.add(node.id.name);
6840
+ const call = node.init;
6841
+ if (call?.type === "CallExpression" && call.callee.type === "MemberExpression" && paths.has(call.callee.object.name) && call.callee.property.name === "match" && call.arguments[0]?.type === "RegExpLiteral") patterns.set(node.id.name, "/" + call.arguments[0].pattern + "/");
6842
+ }, true);
6843
+ if (paths.size === 0) return;
6844
+ walk(fn.body, (node) => {
6845
+ if (node.type !== "IfStatement") return;
6846
+ const routes = /* @__PURE__ */ new Set();
6847
+ const methods = /* @__PURE__ */ new Set();
6848
+ walk(node.test, (test) => {
6849
+ if (test.type === "Identifier" && patterns.has(test.name)) routes.add(patterns.get(test.name));
6850
+ if (test.type !== "BinaryExpression" || !["===", "=="].includes(test.operator)) return;
6851
+ for (const [left, right] of [[test.left, test.right], [test.right, test.left]]) {
6852
+ if (right.type !== "StringLiteral") continue;
6853
+ if (left.type === "Identifier" && paths.has(left.name) && right.value.startsWith("/")) routes.add(right.value);
6854
+ if (left.type === "MemberExpression" && left.property.name === "method" && /^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)$/.test(right.value)) methods.add(right.value);
6855
+ }
6856
+ });
6857
+ let handlesRequest = false;
6858
+ walk(node.consequent, (part) => {
6859
+ if (part.type === "CallExpression" || part.type === "NewExpression" || part.type === "ReturnStatement" && part.argument && part.argument.type !== "NullLiteral") handlesRequest = true;
6860
+ }, true);
6861
+ if (!handlesRequest) return;
6862
+ for (const route of routes) {
6863
+ const method = methods.size === 1 ? [...methods][0] : "HTTP";
6864
+ mod.routes.push({ ...unit(`route:${method}:${route}@${node.loc.start.line}`, node.consequent, false, true), parameters: fn.params });
6865
+ }
6866
+ }, true);
6867
+ });
6868
+ walk(ast.program, (node) => {
6869
+ if (node.type !== "CallExpression" || node.callee.type !== "Identifier" || node.callee.name !== "fetch") return;
6870
+ const url = node.arguments[0];
6871
+ const value = url?.type === "StringLiteral" ? url.value : url?.type === "TemplateLiteral" ? url.quasis[0]?.value.cooked : void 0;
6872
+ if (!value || !/^https?:\/\//.test(value)) return;
6873
+ let host;
6874
+ try {
6875
+ host = new URL(value).hostname;
6876
+ } catch {
6877
+ return;
6878
+ }
6879
+ const provider = PROVIDERS.find((p) => p.signatures.hosts?.some((h2) => host === h2 || host.endsWith("." + h2)));
6880
+ const toolId = provider && tools.get(provider.id);
6881
+ if (!toolId) return;
6882
+ const methodNode = node.arguments[1]?.properties?.find((p) => p.key?.name === "method" || p.key?.value === "method")?.value;
6883
+ const method = methodNode ? methodNode.type === "StringLiteral" ? methodNode.value.toUpperCase() : "UNKNOWN" : "GET";
6884
+ const line = node.loc.start.line;
6885
+ operations.push({ path: path2, line, toolId, kind: method === "GET" || method === "HEAD" ? "reads" : "writes", label: `HTTP ${method} ${host}`, resource: provider.id, ref: `${path2}:${line}` });
6886
+ });
6887
+ }
6888
+ const adjacency = /* @__PURE__ */ new Map();
6889
+ const resolveCalls = (entry) => {
6890
+ const cached = adjacency.get(entry);
6891
+ if (cached) return cached;
6892
+ const found = /* @__PURE__ */ new Set();
6893
+ const boundNames = (nodes) => {
6894
+ const names = /* @__PURE__ */ new Set();
6895
+ for (const node of nodes) walk(node, (part) => {
6896
+ if (part.type === "Identifier") names.add(part.name);
6897
+ });
6898
+ return names;
6899
+ };
6900
+ const scan = (node, shadowed) => {
6901
+ if (!node || typeof node !== "object") return;
6902
+ if (node.type === "FunctionDeclaration") return;
6903
+ if (["ArrowFunctionExpression", "FunctionExpression"].includes(node.type)) {
6904
+ scan(node.body, /* @__PURE__ */ new Set([...shadowed, ...boundNames(node.params)]));
6905
+ return;
6906
+ }
6907
+ if (node.type === "BlockStatement") {
6908
+ shadowed = new Set(shadowed);
6909
+ for (const statement of node.body) {
6910
+ if (statement.type === "VariableDeclaration") for (const declaration of statement.declarations) for (const name of boundNames([declaration.id])) shadowed.add(name);
6911
+ if (statement.type === "FunctionDeclaration" && statement.id) shadowed.add(statement.id.name);
6912
+ }
6913
+ }
6914
+ if (node.type === "CallExpression" && node.callee.type === "Identifier" && !shadowed.has(node.callee.name)) {
6915
+ const mod = modules.get(entry.path);
6916
+ const local = mod.functions.get(node.callee.name);
6917
+ const imported = mod.imports.get(node.callee.name);
6918
+ const target = local ?? (imported ? modules.get(imported.path)?.functions.get(imported.name) : void 0);
6919
+ if (target && (local || target.exported)) found.add(target);
6920
+ }
6921
+ for (const [key, child] of Object.entries(node)) {
6922
+ if (["loc", "start", "end", "comments", "tokens"].includes(key)) continue;
6923
+ if (Array.isArray(child)) child.forEach((item) => scan(item, shadowed));
6924
+ else if (child && typeof child === "object") scan(child, shadowed);
6925
+ }
6926
+ };
6927
+ scan(entry.node, boundNames(entry.parameters ?? []));
6928
+ const result = [...found];
6929
+ adjacency.set(entry, result);
6930
+ return result;
6931
+ };
6932
+ const byPath = /* @__PURE__ */ new Map();
6933
+ for (const operation of operations) {
6934
+ const list2 = byPath.get(operation.path) ?? [];
6935
+ list2.push(operation);
6936
+ byPath.set(operation.path, list2);
6937
+ }
6938
+ const directEffects = /* @__PURE__ */ new Map();
6939
+ const effects = (unit) => {
6940
+ let result = directEffects.get(unit);
6941
+ if (!result) {
6942
+ result = (byPath.get(unit.path) ?? []).filter((op) => op.line >= unit.startLine && op.line <= unit.endLine);
6943
+ directEffects.set(unit, result);
6944
+ }
6945
+ return result;
6946
+ };
6947
+ const entries = [];
6948
+ for (const mod of modules.values()) {
6949
+ for (const entry of [...new Set(mod.functions.values()), ...mod.routes]) {
6950
+ if (!entry.exported && !entry.trigger) continue;
6951
+ if (!entry.trigger && mod.routes.some((route) => route.startLine >= entry.startLine && route.endLine <= entry.endLine)) continue;
6952
+ const visited = /* @__PURE__ */ new Set();
6953
+ const pending = [entry];
6954
+ while (pending.length) {
6955
+ const current = pending.pop();
6956
+ if (visited.has(current)) continue;
6957
+ visited.add(current);
6958
+ pending.push(...resolveCalls(current));
6959
+ }
6960
+ const calls = [...new Set([...visited].flatMap(effects))];
6961
+ if (!entry.trigger && !calls.some((call) => call.kind === "writes" || call.kind === "emits")) continue;
6962
+ entries.push({ ...entry, calls, evidence: [...visited].map((u) => ({ source: "static", ref: `${u.path}:${u.startLine}` })) });
6963
+ }
6964
+ }
6965
+ return { entries, gaps };
6966
+ }
6967
+
6779
6968
  // src/inventory/operations.ts
6780
6969
  var WRITE_VERBS = /* @__PURE__ */ new Set([
6781
6970
  "create",
@@ -7194,32 +7383,47 @@ async function buildRelationships(apps, tools, files, decisions) {
7194
7383
  verified: false
7195
7384
  });
7196
7385
  }
7197
- for (const [path2, text] of sourceText) {
7386
+ const allowedCalls = entrypointCalls.filter((call) => decidedRecords.some((record2) => record2.toolId === call.toolId && record2.kind === call.kind && record2.evidence.some((e) => e.ref === call.ref)));
7387
+ const graph = workflowGraph(sourceText, allowedCalls, new Map(providerTools.map((t) => [t.providerId, t.id])));
7388
+ gaps.push(...graph.gaps);
7389
+ const entries = graph.entries.map((entry) => ({ ...entry, evidence: entry.evidence }));
7390
+ for (const [path2, text] of sourceText) for (const entry of exportedEntrypoints(text)) {
7391
+ if (!/^(route|job):/.test(entry.name)) continue;
7392
+ entries.push({
7393
+ ...entry,
7394
+ path: path2,
7395
+ node: {},
7396
+ exported: false,
7397
+ trigger: true,
7398
+ calls: allowedCalls.filter((call) => call.path === path2 && call.line >= entry.startLine && call.line <= entry.endLine),
7399
+ evidence: [{ source: "static", ref: `${path2}:${entry.startLine}` }]
7400
+ });
7401
+ }
7402
+ for (const entry of entries) {
7403
+ const { path: path2 } = entry;
7198
7404
  const app = appForPath(apps, path2);
7199
- for (const entry of exportedEntrypoints(text)) {
7200
- const calls = entrypointCalls.filter((call) => call.path === path2 && call.line >= entry.startLine && call.line <= entry.endLine && decidedRecords.some((record2) => record2.toolId === call.toolId && record2.kind === call.kind && record2.evidence.some((e) => e.ref === call.ref)));
7201
- if (!calls.some((call) => call.kind === "writes" || call.kind === "emits")) continue;
7202
- const dependencies = [...new Set(calls.map((call) => call.toolId))];
7203
- const evidence = [...new Set(calls.map((call) => call.ref))].map((ref) => ({ source: "static", ref }));
7204
- workflows.push({
7205
- id: "entry:" + createHash12("sha256").update(path2 + ":" + entry.name).digest("hex").slice(0, 20),
7206
- title: entry.name,
7207
- entrypoint: path2 + "#" + entry.name,
7208
- apps: [app.id],
7209
- tools: dependencies,
7210
- steps: dependencies.map((tool2) => ({ app: app.id, tool: tool2, operations: [...new Set(calls.filter((c) => c.toolId === tool2).map((c) => c.label))], evidence })),
7211
- sharedEntities: [...new Set(calls.map((call) => singularize(call.resource)))],
7212
- evidence,
7213
- confidence: "medium",
7214
- risk: "high",
7215
- rank: 0,
7216
- supported: false,
7217
- hypothesis: true,
7218
- linkingSignals: ["callable export or route/job handler containing binding-resolved side effects; invocation and correctness require review"],
7219
- scopeUncertain: true,
7220
- verified: false
7221
- });
7222
- }
7405
+ const calls = entry.calls.filter((call) => !rejected.has(opRelId(appForPath(apps, call.path).id, call.toolId, call.kind)));
7406
+ if (!entry.trigger && !calls.some((call) => call.kind === "writes" || call.kind === "emits")) continue;
7407
+ const dependencies = [...new Set(calls.map((call) => call.toolId))];
7408
+ const evidence = [...new Map([...entry.evidence, ...calls.map((call) => ({ source: "static", ref: call.ref }))].map((ref) => [ref.ref, ref])).values()];
7409
+ workflows.push({
7410
+ id: "entry:" + createHash12("sha256").update(path2 + ":" + entry.name).digest("hex").slice(0, 20),
7411
+ title: entry.name,
7412
+ entrypoint: path2 + "#" + entry.name.replace(/\\/g, "%5C"),
7413
+ apps: [app.id],
7414
+ tools: dependencies,
7415
+ steps: dependencies.map((tool2) => ({ app: app.id, tool: tool2, operations: [...new Set(calls.filter((c) => c.toolId === tool2).map((c) => c.label))], evidence })),
7416
+ sharedEntities: [...new Set(calls.map((call) => singularize(call.resource)))],
7417
+ evidence,
7418
+ confidence: "medium",
7419
+ risk: "high",
7420
+ rank: 0,
7421
+ supported: false,
7422
+ hypothesis: true,
7423
+ linkingSignals: [entry.trigger ? "explicit request or background-job trigger" : "exported operation with statically linked provider effects", "local helper call chains are possible paths, not proof of runtime execution; invocation and correctness require review"],
7424
+ scopeUncertain: true,
7425
+ verified: false
7426
+ });
7223
7427
  }
7224
7428
  if (sourceFiles.length === MAX_FILES3) gaps.push("Source scan is limited to 2500 files; split large repositories into explicit roots. Not every workflow can be proven by static discovery.");
7225
7429
  const finalWorkflows = workflows.filter((w) => !rejected.has(w.id)).map(
@@ -7878,6 +8082,10 @@ function scenariosFor(kind) {
7878
8082
  return [...new Set(list2)];
7879
8083
  }
7880
8084
  function assertionsFor(candidate) {
8085
+ if (candidate.entrypoint) return [
8086
+ `define the expected response/output for ${candidate.entrypoint}`,
8087
+ "review required final state and forbidden effects; discovery does not infer business correctness"
8088
+ ];
7881
8089
  const entity = candidate.sharedEntities[0] ?? "record";
7882
8090
  return [
7883
8091
  `exactly one ${entity} write is applied (no duplicates)`,
@@ -7900,7 +8108,7 @@ function buildWorkflowPlans(candidates, _tools) {
7900
8108
  ),
7901
8109
  sharedEntities: candidate.sharedEntities,
7902
8110
  permissions: permissionsFor(kind, candidate.sharedEntities),
7903
- asyncEvents: eventsFor(kind, candidate.sharedEntities),
8111
+ asyncEvents: candidate.entrypoint ? [] : eventsFor(kind, candidate.sharedEntities),
7904
8112
  failureScenarios: scenariosFor(kind),
7905
8113
  outcomeAssertions: assertionsFor(candidate),
7906
8114
  unknowns: [
@@ -9072,7 +9280,7 @@ async function runInit(opts) {
9072
9280
  // src/commands/control.ts
9073
9281
  import { createInterface as createInterface3 } from "readline/promises";
9074
9282
  import { readFile as readFile23, writeFile as writeFile22 } from "fs/promises";
9075
- import { join as join27, resolve as resolve14 } from "path";
9283
+ import { basename as basename4, join as join27, resolve as resolve14 } from "path";
9076
9284
  init_store();
9077
9285
  init_contracts();
9078
9286
 
@@ -9173,7 +9381,7 @@ var CONTROL_ACTIONS = [
9173
9381
  async function readState(cwd) {
9174
9382
  try {
9175
9383
  const state = JSON.parse(await readFile23(join27(cwd, ".agenttwin/control.json"), "utf8"));
9176
- if (state.schema === "agenttwin/control-v1" && ["world", "exploration", "receipt"].every((key) => state[key] === void 0 || typeof state[key] === "string") && (state.runtime === void 0 || ["local", "hosted", "e2b"].includes(state.runtime))) return state;
9384
+ if (state.schema === "agenttwin/control-v1" && ["world", "exploration", "receipt", "workflowId", "workflowTitle"].every((key) => state[key] === void 0 || typeof state[key] === "string") && (state.runtime === void 0 || ["local", "hosted", "e2b"].includes(state.runtime))) return state;
9177
9385
  } catch {
9178
9386
  }
9179
9387
  return { schema: "agenttwin/control-v1" };
@@ -9188,7 +9396,7 @@ async function runControl(opts) {
9188
9396
  const ask = async (prompt) => (await rl.question(prompt + " ")).trim();
9189
9397
  const save = () => writeFile22(join27(opts.cwd, ".agenttwin/control.json"), JSON.stringify(state, null, 2) + "\n", { mode: 384 });
9190
9398
  const requireWorld = () => {
9191
- if (!state.world) throw new Error("Select a reviewed world file first (option 2). Discovery candidates are not executable specifications.");
9399
+ if (!state.world) throw new Error("Set up a test first. A detected workflow needs application files, test data and expected outcomes before it can run.");
9192
9400
  return state.world;
9193
9401
  };
9194
9402
  const connectAndShare = async () => {
@@ -9199,20 +9407,110 @@ async function runControl(opts) {
9199
9407
  const result = await runSync(opts.cwd);
9200
9408
  log(`Project connected: ${result.tools} services and ${result.workflows} workflow candidates synced. ${result.dashboard}`);
9201
9409
  };
9410
+ const chooseWorkflow = async () => {
9411
+ const inventory = await readInventory(opts.cwd);
9412
+ const workflows = inventory?.workflows ?? [];
9413
+ if (!workflows.length) {
9414
+ log("No workflow candidates found. Refresh discovery in Advanced options, or load an existing test file.");
9415
+ return;
9416
+ }
9417
+ log(`${workflows.length} workflow candidates found. These are code paths, not tested workflows yet.`);
9418
+ const query = (await ask("Find a workflow (for example mail, calendar or billing; Enter shows all):")).toLowerCase();
9419
+ const matches2 = workflows.filter((w) => `${w.title} ${w.entrypoint ?? ""}`.toLowerCase().includes(query));
9420
+ if (!matches2.length) {
9421
+ log("No matching workflows. Try another search.");
9422
+ return;
9423
+ }
9424
+ let offset = 0;
9425
+ while (true) {
9426
+ const page2 = matches2.slice(offset, offset + 10);
9427
+ page2.forEach((w, i) => log(` ${i + 1}. ${w.title}${w.entrypoint ? ` \xB7 ${w.entrypoint}` : ""}`));
9428
+ log(`Showing ${offset + 1}\u2013${offset + page2.length} of ${matches2.length}. n: next page \xB7 p: previous page \xB7 0: back`);
9429
+ const selected = await ask("Choose a workflow [0]:");
9430
+ if (!selected || selected === "0") return;
9431
+ if (selected === "n") {
9432
+ offset = offset + 10 < matches2.length ? offset + 10 : 0;
9433
+ continue;
9434
+ }
9435
+ if (selected === "p") {
9436
+ offset = Math.max(0, offset - 10);
9437
+ continue;
9438
+ }
9439
+ const workflow = /^\d+$/.test(selected) ? page2[Number(selected) - 1] : void 0;
9440
+ if (!workflow) {
9441
+ log("Choose a workflow number from this page.");
9442
+ continue;
9443
+ }
9444
+ state.workflowId = workflow.id;
9445
+ state.workflowTitle = workflow.title;
9446
+ delete state.world;
9447
+ delete state.exploration;
9448
+ delete state.receipt;
9449
+ await save();
9450
+ log(`Selected: ${workflow.title}`);
9451
+ log("Next: prepare its test. AgentTwin cannot yet turn every detected code path into a runnable test automatically.");
9452
+ log("Give your coding agent this request:");
9453
+ log(`Read .agenttwin/AGENT-TASK.md and .agenttwin/inventory.json. Prepare workflow ${JSON.stringify(workflow.id)} (${JSON.stringify(workflow.title)}) as agenttwin.world.json, with bundled application files, supported service operations, synthetic records and reviewed expected outcomes. Report unsupported dependencies; do not replace the application with a demo.`);
9454
+ log("Then return here and choose Load a test file. Selecting a workflow has not run your application.");
9455
+ return;
9456
+ }
9457
+ };
9202
9458
  try {
9203
9459
  try {
9204
9460
  await connectAndShare();
9205
9461
  } catch (error) {
9206
- log(`Connection stopped: ${error instanceof Error ? error.message : String(error)}. Local discovery is saved. Retry with option 9.`);
9462
+ log(`Connection stopped: ${error instanceof Error ? error.message : String(error)}. Local discovery is saved. Retry Connect project in Advanced options.`);
9207
9463
  }
9208
9464
  while (true) {
9209
- log("\nAgentTwin \xB7 your repository control center");
9210
- log(`World: ${state.world ?? "not selected"} \xB7 Runtime: ${state.runtime ?? "local"} \xB7 Exploration: ${state.exploration ?? "not captured"}`);
9211
- CONTROL_ACTIONS.forEach((action, index) => log(` ${index + 1}. ${action}`));
9465
+ log(`
9466
+ AgentTwin \xB7 ${basename4(opts.cwd)}`);
9467
+ if (state.world) log(`Ready to test: ${state.world} \xB7 ${state.runtime ?? "local"}`);
9468
+ else if (state.workflowTitle) log(`Setup needed: ${state.workflowTitle}`);
9469
+ else log("Next: choose a workflow to test.");
9470
+ log(` 1. ${state.world ? "Run my test" : state.workflowTitle ? "Continue test setup" : "Set up my first test"} \u2190 next step`);
9471
+ log(" 2. View last result");
9472
+ log(" 3. Change workflow or test");
9473
+ log(" 4. Advanced options");
9212
9474
  log(" 0. Exit");
9213
- const choice = await ask("Choose an action [0]:");
9214
- if (!choice || choice === "0") break;
9475
+ let choice = await ask("Choose [1]:");
9476
+ if (choice === "0") break;
9215
9477
  try {
9478
+ if (!choice || choice === "1") choice = state.world ? "3" : "setup";
9479
+ else if (choice === "2") choice = "13";
9480
+ else if (choice === "3") choice = "setup";
9481
+ else if (choice === "4") {
9482
+ log("\nAdvanced options");
9483
+ CONTROL_ACTIONS.forEach((action, index) => log(` ${index + 1}. ${action}`));
9484
+ log(" 0. Back");
9485
+ choice = await ask("Advanced action [0]:");
9486
+ if (!choice || choice === "0") continue;
9487
+ if (!/^\d+$/.test(choice) || Number(choice) < 1 || Number(choice) > CONTROL_ACTIONS.length) {
9488
+ log("Choose a listed action.");
9489
+ continue;
9490
+ }
9491
+ } else {
9492
+ log("Choose 1, 2, 3, 4 or 0.");
9493
+ continue;
9494
+ }
9495
+ if (choice === "setup") {
9496
+ log("\nSet up a test");
9497
+ log(" 1. Choose a workflow from my repository");
9498
+ log(" 2. Load a test file I already have");
9499
+ log(" 3. Try a sample test (not my application)");
9500
+ log(" 0. Back");
9501
+ const setup = await ask("Choose [1]:");
9502
+ if (!setup || setup === "1") {
9503
+ await chooseWorkflow();
9504
+ continue;
9505
+ }
9506
+ if (setup === "0") continue;
9507
+ if (setup === "2") choice = "2";
9508
+ else if (setup === "3") choice = "12";
9509
+ else {
9510
+ log("Choose a listed action.");
9511
+ continue;
9512
+ }
9513
+ }
9216
9514
  switch (choice) {
9217
9515
  case "1": {
9218
9516
  const inventory = await readInventory(opts.cwd);
@@ -9220,7 +9518,7 @@ async function runControl(opts) {
9220
9518
  break;
9221
9519
  }
9222
9520
  case "2": {
9223
- let file = await ask("Path to reviewed world or preparation JSON [agenttwin.world.json]:") || "agenttwin.world.json";
9521
+ let file = await ask("Test file [agenttwin.world.json]:") || "agenttwin.world.json";
9224
9522
  const { parseWorld: parseWorld2 } = await Promise.resolve().then(() => (init_spec(), spec_exports));
9225
9523
  let input = JSON.parse(await readFile23(resolve14(opts.cwd, file), "utf8"));
9226
9524
  if (input.schema === "agenttwin/preparation-v1") {
@@ -9232,8 +9530,11 @@ async function runControl(opts) {
9232
9530
  const spec = parseWorld2(input);
9233
9531
  log(`${spec.name}: ${spec.files.length} explicit application files, ${spec.assertions.length} required outcomes, ${(spec.forbiddenEffects?.length ?? 0) + (spec.preservedState?.length ?? 0)} safety checks.`);
9234
9532
  log("Review the listed files, command, synthetic state and checks. Local execution runs application code on this machine.");
9235
- state.world = file;
9533
+ state.world = resolve14(opts.cwd, file);
9534
+ delete state.receipt;
9535
+ delete state.exploration;
9236
9536
  await save();
9537
+ log("Test ready. Choose Run my test to execute it.");
9237
9538
  break;
9238
9539
  }
9239
9540
  case "3": {
@@ -9253,12 +9554,12 @@ async function runControl(opts) {
9253
9554
  const result = await runExploration(opts.cwd, captured.id, { log });
9254
9555
  state.receipt = (result.results.find((r) => r.status !== "passed") ?? result.results[0]).evidencePath;
9255
9556
  await save();
9256
- log(`${result.status.toUpperCase()} \xB7 saved ${result.results.length} trial receipts. Replay with option 5; verify a patch with option 6.`);
9557
+ log(`${result.status.toUpperCase()} \xB7 saved ${result.results.length} trial receipts. Replay or verify a patch in Advanced options.`);
9257
9558
  break;
9258
9559
  }
9259
9560
  case "5":
9260
9561
  case "6": {
9261
- if (!state.exploration) throw new Error("Capture an exploration first (option 4).");
9562
+ if (!state.exploration) throw new Error("Choose Explore seeded faults in Advanced options first.");
9262
9563
  const result = await runExploration(opts.cwd, state.exploration, { patched: choice === "6", log });
9263
9564
  state.receipt = (result.results.find((r) => r.status !== "passed") ?? result.results[0]).evidencePath;
9264
9565
  await save();