atris 3.57.3 → 3.58.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/README.md +12 -1
  2. package/atris/skills/clawhub/member-runtime/SKILL.md +9 -9
  3. package/atris/skills/create-member/SKILL.md +16 -13
  4. package/atris/skills/design/SKILL.md +27 -4
  5. package/atris/skills/engines/SKILL.md +57 -13
  6. package/atris/skills/x-search/SKILL.md +13 -5
  7. package/atris/skills/youtube/SKILL.md +39 -8
  8. package/atris/team/brainstormer/MEMBER.md +2 -2
  9. package/atris/team/executor/MEMBER.md +3 -3
  10. package/atris/team/launcher/MEMBER.md +4 -4
  11. package/atris/team/navigator/MEMBER.md +3 -3
  12. package/atris/team/researcher/MEMBER.md +2 -2
  13. package/atris/team/validator/MEMBER.md +1 -1
  14. package/atris.md +14 -0
  15. package/ax +157 -24
  16. package/bin/atris.js +79 -31
  17. package/commands/aeo.js +4 -4
  18. package/commands/agents.js +1 -1
  19. package/commands/align.js +12 -12
  20. package/commands/analytics.js +3 -3
  21. package/commands/app.js +5 -5
  22. package/commands/ask.js +175 -0
  23. package/commands/auth.js +7 -7
  24. package/commands/autoland.js +29 -5
  25. package/commands/autopilot-front.js +6 -6
  26. package/commands/autopilot.js +90 -90
  27. package/commands/avail.js +5 -5
  28. package/commands/bench.js +63 -1
  29. package/commands/browse.js +2 -2
  30. package/commands/business.js +12 -12
  31. package/commands/card.js +2 -2
  32. package/commands/clean.js +13 -13
  33. package/commands/close.js +297 -15
  34. package/commands/compile.js +13 -13
  35. package/commands/computer.js +94 -27
  36. package/commands/console.js +5 -5
  37. package/commands/decide.js +1 -1
  38. package/commands/deck.js +9 -9
  39. package/commands/drive.js +4 -4
  40. package/commands/engine.js +173 -19
  41. package/commands/errors.js +3 -3
  42. package/commands/experiments.js +18 -1
  43. package/commands/feed.js +2 -2
  44. package/commands/feedback.js +4 -4
  45. package/commands/fleet.js +12 -12
  46. package/commands/game.js +1 -1
  47. package/commands/guide.js +49 -0
  48. package/commands/improve.js +19 -17
  49. package/commands/init.js +67 -19
  50. package/commands/interview.js +7 -7
  51. package/commands/land.js +40 -15
  52. package/commands/learn.js +12 -12
  53. package/commands/lesson.js +2 -2
  54. package/commands/log.js +1 -1
  55. package/commands/loops.js +4 -4
  56. package/commands/member.js +75 -50
  57. package/commands/mission.js +97 -98
  58. package/commands/one-lap.js +1 -1
  59. package/commands/pack.js +74 -8
  60. package/commands/plugin.js +6 -6
  61. package/commands/probe.js +9 -9
  62. package/commands/pull.js +35 -35
  63. package/commands/pulse.js +1 -1
  64. package/commands/push.js +21 -21
  65. package/commands/rainmaker.js +1 -1
  66. package/commands/recap.js +7 -7
  67. package/commands/reel.js +2 -2
  68. package/commands/release.js +3 -3
  69. package/commands/report.js +1 -1
  70. package/commands/review.js +5 -5
  71. package/commands/revisions.js +1 -1
  72. package/commands/run.js +1 -1
  73. package/commands/scout.js +3 -3
  74. package/commands/security-review.js +7 -7
  75. package/commands/serve.js +9 -9
  76. package/commands/setup.js +1 -1
  77. package/commands/sign.js +4 -4
  78. package/commands/signup.js +4 -4
  79. package/commands/site.js +8 -3
  80. package/commands/skill.js +6 -6
  81. package/commands/slop.js +18 -18
  82. package/commands/social.js +3 -3
  83. package/commands/soul.js +14 -14
  84. package/commands/spaceship.js +1 -1
  85. package/commands/status.js +2 -2
  86. package/commands/strings.js +13 -13
  87. package/commands/study.js +4 -4
  88. package/commands/sync.js +8 -8
  89. package/commands/task.js +50 -25
  90. package/commands/teach.js +2 -2
  91. package/commands/team.js +1 -1
  92. package/commands/theme.js +2 -2
  93. package/commands/tree.js +34 -0
  94. package/commands/truth.js +7 -7
  95. package/commands/verify.js +12 -12
  96. package/commands/visualize.js +2 -2
  97. package/commands/workflow.js +97 -40
  98. package/commands/worktree.js +57 -1
  99. package/commands/write.js +9 -9
  100. package/commands/x-search.js +304 -15
  101. package/commands/xp.js +6 -3
  102. package/commands/youtube.js +916 -65
  103. package/lib/accept-verify-gate.js +3 -3
  104. package/lib/activity-stream.js +1 -1
  105. package/lib/apply-gate.js +13 -0
  106. package/lib/auto-accept-certified.js +28 -12
  107. package/lib/autoland.js +6 -6
  108. package/lib/bench/engines.js +59 -8
  109. package/lib/bench/report.js +1 -1
  110. package/lib/bench/runner.js +204 -5
  111. package/lib/bench/tree-render.js +76 -0
  112. package/lib/card.js +1 -1
  113. package/lib/chat-log-scan.js +1 -1
  114. package/lib/claude-boot-block.js +2 -0
  115. package/lib/deck-compose.js +1 -1
  116. package/lib/deck-history.js +2 -2
  117. package/lib/deck-layout.js +1 -1
  118. package/lib/deck-review.js +5 -5
  119. package/lib/deck-schema.js +1 -1
  120. package/lib/default-verifier.js +1 -1
  121. package/lib/developer-api.js +3 -0
  122. package/lib/engine-ask.js +66 -9
  123. package/lib/engine-registry.js +15 -2
  124. package/lib/file-ops.js +1 -1
  125. package/lib/fleet.js +71 -35
  126. package/lib/html-render.js +1 -1
  127. package/lib/intents.js +172 -0
  128. package/lib/journal.js +1 -1
  129. package/lib/known-commands.js +2 -2
  130. package/lib/land-green.js +364 -0
  131. package/lib/lesson-contradiction.js +2 -2
  132. package/lib/lesson-preflight.js +1 -1
  133. package/lib/manifest.js +5 -5
  134. package/lib/member-alive.js +9 -5
  135. package/lib/memory-view.js +3 -3
  136. package/lib/mission-protected-lane.js +1 -1
  137. package/lib/mission-root.js +2 -2
  138. package/lib/orb-context.js +10 -10
  139. package/lib/pack-capabilities.js +861 -3
  140. package/lib/permission-grants.js +2 -2
  141. package/lib/policy-lessons.js +6 -6
  142. package/lib/pulse.js +8 -8
  143. package/lib/receipt-evidence.js +1 -1
  144. package/lib/reel.js +1 -1
  145. package/lib/review-integrity.js +1 -1
  146. package/lib/runner-command.js +35 -5
  147. package/lib/scorecard.js +4 -4
  148. package/lib/security-scan.js +5 -5
  149. package/lib/self-drive.js +1 -1
  150. package/lib/site-publish.js +372 -0
  151. package/lib/site.js +1 -1
  152. package/lib/slides-deck.js +2 -2
  153. package/lib/state-detection.js +2 -2
  154. package/lib/sync-telemetry.js +2 -2
  155. package/lib/task-db.js +17 -10
  156. package/lib/task-explanation.js +1 -1
  157. package/lib/task-proof.js +2 -2
  158. package/lib/task-receipt.js +3 -3
  159. package/lib/theme.js +12 -12
  160. package/lib/todo-fallback.js +3 -3
  161. package/lib/todo-sections.js +1 -1
  162. package/lib/tree-hash.js +79 -0
  163. package/lib/usage.js +1 -1
  164. package/lib/wish-delegate.js +1 -1
  165. package/lib/workspace-safety.js +4 -4
  166. package/lib/workspace-scaffold.js +3 -3
  167. package/package.json +1 -1
  168. package/scripts/det/concierge-bakeoff.sh +26 -0
  169. package/scripts/det/ytquote-repair.js +0 -1
  170. package/scripts/det/ytrail-race.js +0 -5
  171. package/scripts/member-operate.mjs +52 -14
  172. package/scripts/outbound-artifact-gate.js +0 -3
  173. package/utils/auth.js +0 -3
  174. package/utils/config.js +0 -1
  175. package/scripts/det/checklist-score.js +0 -191
@@ -57,7 +57,7 @@ function placeholderIssue(command) {
57
57
 
58
58
  function nonFalsifyingIssue(command) {
59
59
  if (NON_FALSIFYING_COMMANDS.some((re) => re.test(command.trim()))) {
60
- return `\`${command.trim()}\` cannot fail for the reason this task exists it passes on a commit that deletes the work`;
60
+ return `\`${command.trim()}\` cannot fail for the reason this task exists, it passes on a commit that deletes the work`;
61
61
  }
62
62
  return '';
63
63
  }
@@ -83,8 +83,8 @@ function evaluateAcceptVerify(task, workspaceRoot, { cache = null } = {}) {
83
83
  // Two different questions got fused here at first: "can this fail?" and "is this
84
84
  // safe to execute at accept time?". Only the first is this gate's business.
85
85
  // Refusing everything outside the execution allow-list flagged 455 accepted tasks,
86
- // and sampling them found ordinary verifiers `npm run type-check`, `npx vitest
87
- // run` sitting beside real prose. A gate that blocks real work gets switched off.
86
+ // and sampling them found ordinary verifiers: `npm run type-check`, `npx vitest
87
+ // run` sitting beside real prose. A gate that blocks real work gets switched off.
88
88
  // So: refuse what provably cannot fail, run what can be run safely, and mark the
89
89
  // rest unchecked rather than pretend a judgment we cannot make.
90
90
  const parsed = parseVerifyCommand(command);
@@ -3,7 +3,7 @@
3
3
  // Activity stream: normalize the workspace's receipt channels into ONE
4
4
  // time-ordered feed of what the agent actually did, plus a heartbeat summary.
5
5
  // The task board shows a static inventory of tasks; this turns the same data
6
- // into "what happened, in order" the stream you watch for hours.
6
+ // into "what happened, in order": the stream you watch for hours.
7
7
 
8
8
  const TS_KEYS = ['ts', 'at', 'created_at', 'accepted_at', 'timestamp', 'updated_at'];
9
9
 
package/lib/apply-gate.js CHANGED
@@ -95,8 +95,21 @@ function ensureApply({
95
95
  return required ? 2 : 0;
96
96
  }
97
97
 
98
+ function ephemeralApplyMessage(kind) {
99
+ const label = String(kind || '').trim() || 'result';
100
+ return `next: write one apply (change + receipt) for this ${label}`;
101
+ }
102
+
103
+ function hintEphemeralApply(output, kind) {
104
+ const print = typeof output === 'function' ? output : (line = '') => console.error(line);
105
+ print(ephemeralApplyMessage(kind));
106
+ return 0;
107
+ }
108
+
98
109
  module.exports = {
99
110
  applySlug,
100
111
  applySidecarRel,
101
112
  ensureApply,
113
+ ephemeralApplyMessage,
114
+ hintEphemeralApply,
102
115
  };
@@ -11,6 +11,18 @@ const { policyLessonsForFiles, readPolicyLessons } = require('./policy-lessons')
11
11
  const { skillEvalGate } = require('./skill-eval-gate');
12
12
 
13
13
  const AGENT_CERTIFICATION_REVIEW_PASSES = 2;
14
+
15
+ // Verifier wall clock. The old flat 120s ceiling was measured against this
16
+ // workspace's real suites: the largest single test file needs ~123s and
17
+ // `npm run test` needs ~268s, so honest full-suite proofs could never land.
18
+ // Default is now 10 minutes with an ATRIS_VERIFY_TIMEOUT_MS override; agents
19
+ // should still prefer fast single-file verifiers, but a slow honest check
20
+ // must time out as "too slow", never silently fail as "unproven".
21
+ const DEFAULT_VERIFY_TIMEOUT_MS = 10 * 60 * 1000;
22
+ function verifyTimeoutMs() {
23
+ const raw = Number(process.env.ATRIS_VERIFY_TIMEOUT_MS);
24
+ return Number.isFinite(raw) && raw > 0 ? Math.min(raw, 60 * 60 * 1000) : DEFAULT_VERIFY_TIMEOUT_MS;
25
+ }
14
26
  // Kept for compat with older callers/tests; the pass-count landing lane it
15
27
  // once powered is gone. Passes alone never land work, an independent
16
28
  // reviewer does.
@@ -451,7 +463,7 @@ function isAutoCertifyVerifyCommandAllowed(verify) {
451
463
 
452
464
  // Re-entrancy guard: every verify child carries ATRIS_VERIFY_IN_PROGRESS=1
453
465
  // (see verifyCommandEnv). A verify that shells back into the CLI and reaches
454
- // another verify would otherwise recurse without bound that cycle
466
+ // another verify would otherwise recurse without bound, that cycle
455
467
  // fork-bombed the fleet on 2026-07-29 when a stored pytest called
456
468
  // `atris task status` and the read path re-ran the verify. Refuse loudly.
457
469
  const VERIFY_IN_PROGRESS_ENV = 'ATRIS_VERIFY_IN_PROGRESS';
@@ -497,7 +509,7 @@ function runVerifyCommand(verify, workspaceRoot) {
497
509
  env: verifyCommandEnv(parsed.env),
498
510
  shell: false,
499
511
  encoding: 'utf8',
500
- timeout: 120000,
512
+ timeout: verifyTimeoutMs(),
501
513
  });
502
514
  // "I could not run the check" is not "the check failed". Collapsing the two
503
515
  // let a broken harness read as broken code: under cron's PATH every
@@ -526,7 +538,7 @@ function runVerifyCommand(verify, workspaceRoot) {
526
538
  }
527
539
 
528
540
  // Verifiers run with shell:false, so the child gets no PATH resolution beyond
529
- // whatever the parent inherited. Cron has no Homebrew PATH that is why every
541
+ // whatever the parent inherited. Cron has no Homebrew PATH, that is why every
530
542
  // cron entry hardcodes an absolute node path. `npm`, `npx` and friends live in
531
543
  // the same bin directory as the node binary currently executing, so seeding
532
544
  // PATH with it makes recorded checks runnable from cron with zero config: no
@@ -540,11 +552,15 @@ function verifyCommandEnv(extraEnv) {
540
552
  base.PATH = current ? `${binDir}${path.delimiter}${current}` : binDir;
541
553
  }
542
554
  base[VERIFY_IN_PROGRESS_ENV] = '1';
555
+ // A verifier that inherits a test-runner context joins the outer runner as
556
+ // a child and reports instant exit 0 without running anything. Strip it so
557
+ // recorded checks are real runs, not phantom passes.
558
+ delete base.NODE_TEST_CONTEXT;
543
559
  return base;
544
560
  }
545
561
 
546
- // ENOENT: the binary is not on PATH at all. ETIMEDOUT: the 120s ceiling cut it
547
- // off before it could reach a verdict. Neither is evidence about the diff.
562
+ // ENOENT: the binary is not on PATH at all. ETIMEDOUT: the verify ceiling cut
563
+ // it off before it could reach a verdict. Neither is evidence about the diff.
548
564
  function unrunnableVerifyReason(result) {
549
565
  const code = result && result.error ? result.error.code : null;
550
566
  if (code === 'ENOENT') return 'command_not_found';
@@ -567,7 +583,7 @@ function runVerifyCommandCached(verify, workspaceRoot, cache = null) {
567
583
 
568
584
  // Pre-land hygiene: dead exports used to surface only AFTER landing, when the
569
585
  // full suite's repo-hygiene ratchet went red on master (lesson:
570
- // engine-dead-exports engines export every internal helper, the task's own
586
+ // engine-dead-exports: engines export every internal helper, the task's own
571
587
  // verify command stays green, and the breakage lands). In repos that carry the
572
588
  // ratchet (test/repo-hygiene.test.js), run the same detector before landing so
573
589
  // the gate refuses the work instead of master discovering it. Memoized per
@@ -602,7 +618,7 @@ function repoHygieneGate(workspaceRoot) {
602
618
  }
603
619
  : { ok: true };
604
620
  } catch (err) {
605
- // "I could not run the detector" is not a verdict on the work same
621
+ // "I could not run the detector" is not a verdict on the work, same
606
622
  // rule as verify_unrunnable, but hygiene is a repo-wide ratchet the
607
623
  // suite still enforces, so failing open here only delays the red.
608
624
  result = { ok: true, skipped: true, error: String((err && err.message) || err).slice(0, 200) };
@@ -948,7 +964,7 @@ function receiptEvidenceBlock(task, ref) {
948
964
  }
949
965
 
950
966
  // Authors declare protected lanes in the task text, not the tag field. Match
951
- // only explicit, deliberate declarations an unanchored "security" would
967
+ // only explicit, deliberate declarations, an unanchored "security" would
952
968
  // snag every task that merely mentions the word and wedge the loop shut,
953
969
  // which is its own failure mode. Kept narrow on purpose.
954
970
  const PROTECTED_LANE_PHRASES = [
@@ -979,7 +995,7 @@ function declaredProtectedLane(task) {
979
995
 
980
996
  // Only consulted when a task carries no tag at all, so this never overrides an
981
997
  // author who did classify their work. Terms are concrete nouns from the denied
982
- // lanes things you can only be touching on purpose. Deliberately excludes
998
+ // lanes: things you can only be touching on purpose. Deliberately excludes
983
999
  // broad words like "auth", "user", or "api" that appear in ordinary frontend
984
1000
  // work and would hold the whole queue.
985
1001
  const PROTECTED_LANE_TERMS = [
@@ -1051,12 +1067,12 @@ function evaluateAutoAccept(task, options = {}) {
1051
1067
  }
1052
1068
 
1053
1069
  // accept-all: the protected lanes above are the only human gate. No
1054
- // certification, pass-count, reviewer, or proof-quality bar but work
1070
+ // certification, pass-count, reviewer, or proof-quality bar, but work
1055
1071
  // is never marked done against evidence it isn't: a proof naming an
1056
1072
  // unmerged draft PR still blocks, and a recorded check that FAILS still
1057
1073
  // blocks (absence of a check does not).
1058
1074
  if (acceptAll) {
1059
- // In this lane the tag IS the safety system the only human gate. But
1075
+ // In this lane the tag IS the safety system, the only human gate. But
1060
1076
  // most rows carry no tag at all (13 of 16 in the 2026-07-25 sweep), so
1061
1077
  // refusing every untagged task would wedge the loop shut, which is the
1062
1078
  // same outage as failing open, just quieter. Instead: when there is no
@@ -1096,7 +1112,7 @@ function evaluateAutoAccept(task, options = {}) {
1096
1112
  if (verify && executeVerify) {
1097
1113
  const verifyResult = runVerifyCommandCached(verify, task.workspace_root || process.cwd(), verifyCache);
1098
1114
  // A check that runs and fails blocks. So does a check whose worktree
1099
- // is gone otherwise the daily reap converts "has a failing check"
1115
+ // is gone, otherwise the daily reap converts "has a failing check"
1100
1116
  // into "lands unchecked" the morning after it clears the worktree.
1101
1117
  // A check that merely isn't in the runnable allowlist counts as no
1102
1118
  // check at all.
package/lib/autoland.js CHANGED
@@ -69,7 +69,7 @@ function writeJson(file, value) {
69
69
  }
70
70
 
71
71
  // Default is ON: agents are good enough that a growing review queue is
72
- // latency, not safety the denied tags keep the irreversible lanes human.
72
+ // latency, not safety. The denied tags keep the irreversible lanes human.
73
73
  // A missing policy file means "on, owner inferred"; only an explicit
74
74
  // `atris autoland off` (enabled: false) turns it off.
75
75
  function inferOwner(root) {
@@ -157,7 +157,7 @@ function heartbeatStatusText(root, policy) {
157
157
  : ageHours < 1 ? 'under an hour' : `${Math.floor(ageHours)}h`;
158
158
  // Evidence first: a receipt from the last couple of hours proves the loop is
159
159
  // alive no matter what the policy file remembers. The hourly cron makes two
160
- // missed hours an outage rather than jitter and a heartbeat that has gone
160
+ // missed hours an outage rather than jitter, and a heartbeat that has gone
161
161
  // quiet is the single most useful thing this line can say, because that is
162
162
  // the state nobody notices.
163
163
  if (ageHours !== null && ageHours <= HEARTBEAT_LIVE_HOURS) return `running hourly (last tick ${ageText} ago)`;
@@ -236,7 +236,7 @@ function waitingHours(task, now = Date.now()) {
236
236
  return Math.floor((now - certifiedAtMs(task)) / 3600000);
237
237
  }
238
238
 
239
- // Tasks that are certified and pending — i.e. finished work sitting on the
239
+ // Tasks that are certified and pending, finished work sitting on the
240
240
  // human side of the fence, whatever the reason.
241
241
  function waitingOnHuman(tasks, now = Date.now()) {
242
242
  return (tasks || [])
@@ -508,7 +508,7 @@ function dejargon(line, options = {}) {
508
508
  // The clarity gate in one call: strip what an operator can't act on (flag
509
509
  // dashes, task ids, snake_case) and close the sentence on a whole clause, so
510
510
  // any title or line borrowed from the queue reaches a human actionable at a
511
- // glance. One sentence in, one operator-ready sentence out content always
511
+ // glance. One sentence in, one operator-ready sentence out. Content always
512
512
  // ships, the gate only changes how it reads.
513
513
  function clarify(text, max = 160) {
514
514
  return finishThought(dejargon(text), max);
@@ -538,7 +538,7 @@ function memberTrustLabel(member, root) {
538
538
 
539
539
  // Short enough for one iMessage, plain enough to read walking to the car.
540
540
  // Every line answers: what happened, how big, how we know. It names the work
541
- // because "7 things landed" tells the score, not the story and it always
541
+ // because "7 things landed" tells the score, not the story, and it always
542
542
  // ends with what's waiting and what to do next, because those are the only
543
543
  // lines that ask anything of the reader.
544
544
  function composeDigest({ accepted, waiting, protectedWaiting = [], waitingWishes = [], landed, project, nextMoves = [], acceptAll = false, reapError = null, janitor = null, landingSweep = null, fullStory = false, root = process.cwd() }) {
@@ -620,7 +620,7 @@ function composeDigest({ accepted, waiting, protectedWaiting = [], waitingWishes
620
620
  }
621
621
  // nextMoves: array of {title, owner}, or {moves, unexplained} where
622
622
  // unexplained counts queue items whose sentence carries no operator why.
623
- // Those are counted, not shown a raw title the reader can't act on is
623
+ // Those are counted, not shown. A raw title the reader can't act on is
624
624
  // noise here and a writing bug at its source.
625
625
  const nextInfo = Array.isArray(nextMoves)
626
626
  ? { moves: nextMoves, unexplained: 0 }
@@ -9,6 +9,9 @@ const ENGINE_NAMES = Object.freeze([
9
9
  'cursor',
10
10
  'claude',
11
11
  'atris-fast',
12
+ 'agy',
13
+ 'opencode',
14
+ 'devin',
12
15
  'null',
13
16
  'solution',
14
17
  ]);
@@ -18,8 +21,13 @@ const ENGINE_BINS = Object.freeze({
18
21
  cursor: 'cursor-agent',
19
22
  claude: 'claude',
20
23
  'atris-fast': 'atris',
24
+ agy: 'agy',
25
+ opencode: 'opencode',
26
+ devin: 'devin',
21
27
  });
22
28
 
29
+ const AGY_HEADLESS_NOTE = 'You are running headless with edit permission already granted. Apply changes directly and never ask for confirmation; finish the task fully, then report.';
30
+
23
31
  function normalizeEngineName(name) {
24
32
  const normalized = String(name || '').trim();
25
33
  if (!normalized) return '';
@@ -29,23 +37,30 @@ function normalizeEngineName(name) {
29
37
  return normalized;
30
38
  }
31
39
 
32
- function commandAvailable(command) {
40
+ function commandAvailable(command, env = process.env) {
33
41
  const result = spawnSync('/bin/sh', ['-c', `command -v ${command}`], {
34
42
  encoding: 'utf8',
35
- env: process.env,
43
+ env,
36
44
  });
37
45
  return result.status === 0 && Boolean(String(result.stdout || '').trim());
38
46
  }
39
47
 
40
48
  function spawnEngine(command, args, workspaceDir, timeoutMs, options = {}) {
49
+ // Two hermetic-workspace traps (verified live 2026-09-04): engines that
50
+ // read PWD from the environment (opencode) edited the real repo fixture
51
+ // instead of the temp workspace, and engines that read a piped stdin as
52
+ // the prompt (devin) exited 0 in 3s having done nothing. Point PWD at the
53
+ // workspace and close stdin unless the caller supplies input.
41
54
  const result = spawnSync(command, args, {
42
55
  cwd: workspaceDir,
43
56
  input: options.input,
57
+ stdio: options.input === undefined ? ['ignore', 'pipe', 'pipe'] : undefined,
44
58
  encoding: 'utf8',
45
59
  timeout: Number(timeoutMs || 300000),
46
60
  maxBuffer: Number(options.maxBuffer || 16 * 1024 * 1024),
47
61
  env: {
48
62
  ...process.env,
63
+ PWD: workspaceDir,
49
64
  ...(options.env || {}),
50
65
  },
51
66
  });
@@ -53,14 +68,15 @@ function spawnEngine(command, args, workspaceDir, timeoutMs, options = {}) {
53
68
  status: result.status === null ? 1 : result.status,
54
69
  stdout: result.stdout || '',
55
70
  stderr: result.error ? String(result.error.message || result.error) : (result.stderr || ''),
71
+ timedOut: Boolean(result.error && result.error.code === 'ETIMEDOUT'),
56
72
  };
57
73
  }
58
74
 
59
75
  function realEngineAdapter(name, bin, runArgs) {
60
76
  return {
61
77
  name,
62
- available(workspaceDir) {
63
- if (!commandAvailable(bin)) {
78
+ available(workspaceDir, env) {
79
+ if (!commandAvailable(bin, env)) {
64
80
  return { available: false, reason: `${bin} not found` };
65
81
  }
66
82
  if (name === 'atris-fast' && !fs.existsSync(path.join(workspaceDir, 'atris'))) {
@@ -68,8 +84,8 @@ function realEngineAdapter(name, bin, runArgs) {
68
84
  }
69
85
  return { available: true, reason: 'available' };
70
86
  },
71
- run(promptText, workspaceDir, timeoutMs) {
72
- return spawnEngine(bin, runArgs(promptText, workspaceDir), workspaceDir, timeoutMs);
87
+ run(promptText, workspaceDir, timeoutMs, env) {
88
+ return spawnEngine(bin, runArgs(promptText, workspaceDir), workspaceDir, timeoutMs, { env });
73
89
  },
74
90
  };
75
91
  }
@@ -95,14 +111,15 @@ function solutionEngineAdapter(solutionPath) {
95
111
  }
96
112
  return { available: true, reason: 'available' };
97
113
  },
98
- run(_promptText, workspaceDir, timeoutMs) {
99
- return spawnEngine('/bin/sh', [solutionPath], workspaceDir, timeoutMs);
114
+ run(_promptText, workspaceDir, timeoutMs, env) {
115
+ return spawnEngine('/bin/sh', [solutionPath], workspaceDir, timeoutMs, { env });
100
116
  },
101
117
  };
102
118
  }
103
119
 
104
120
  function getEngineAdapter(name, options = {}) {
105
121
  const normalized = normalizeEngineName(name);
122
+ const model = options.model ? String(options.model).trim() : '';
106
123
  if (normalized === 'null') return nullEngineAdapter();
107
124
  if (normalized === 'solution') return solutionEngineAdapter(options.solutionPath);
108
125
  if (normalized === 'codex') {
@@ -124,6 +141,40 @@ function getEngineAdapter(name, options = {}) {
124
141
  '--dangerously-skip-permissions',
125
142
  ]);
126
143
  }
144
+ if (normalized === 'agy') {
145
+ // --add-dir is mandatory: without it agy edits its own scratch folder.
146
+ // The headless note is mandatory too: with every auto-approve flag on,
147
+ // gemini still stops to ask "may I edit?" and exits 0 having changed
148
+ // nothing (verified on 3.7 and 3.8, 2026-09-04). One line fixes it.
149
+ return realEngineAdapter('agy', ENGINE_BINS.agy, (promptText, workspaceDir) => [
150
+ '--mode',
151
+ 'accept-edits',
152
+ '--dangerously-skip-permissions',
153
+ '--add-dir',
154
+ workspaceDir,
155
+ ...(model ? ['--model', model] : []),
156
+ '-p',
157
+ `${AGY_HEADLESS_NOTE}\n\n${promptText}`,
158
+ ]);
159
+ }
160
+ if (normalized === 'opencode') {
161
+ return realEngineAdapter('opencode', ENGINE_BINS.opencode, (promptText) => [
162
+ 'run',
163
+ '--auto',
164
+ ...(model ? ['-m', model] : []),
165
+ promptText,
166
+ ]);
167
+ }
168
+ if (normalized === 'devin') {
169
+ return realEngineAdapter('devin', ENGINE_BINS.devin, (promptText) => [
170
+ '-p',
171
+ '--permission-mode',
172
+ 'dangerous',
173
+ ...(model ? ['--model', model] : []),
174
+ '--',
175
+ promptText,
176
+ ]);
177
+ }
127
178
  if (normalized === 'atris-fast') {
128
179
  return realEngineAdapter('atris-fast', ENGINE_BINS['atris-fast'], (promptText) => [
129
180
  'chat',
@@ -64,7 +64,7 @@ function buildBenchReport(options = {}) {
64
64
 
65
65
  const byEngine = new Map();
66
66
  for (const record of records) {
67
- const engineName = record.engine || 'unknown';
67
+ const engineName = (record.engine || 'unknown') + (record.model ? `@${record.model}` : '');
68
68
  if (!byEngine.has(engineName)) byEngine.set(engineName, []);
69
69
  byEngine.get(engineName).push(record);
70
70
  }