atris 3.38.0 → 3.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/AGENTS.md +25 -6
  2. package/atris/PERSONA.md +8 -4
  3. package/atris.md +7 -0
  4. package/ax +2 -1
  5. package/bin/atris.js +31 -6
  6. package/commands/agent-spawn.js +13 -11
  7. package/commands/autoland.js +28 -77
  8. package/commands/bench.js +10 -12
  9. package/commands/business.js +345 -0
  10. package/commands/chat-scan.js +5 -7
  11. package/commands/codex-goal.js +8 -10
  12. package/commands/computer.js +20 -0
  13. package/commands/console.js +19 -3
  14. package/commands/decide.js +166 -0
  15. package/commands/deck.js +1 -4
  16. package/commands/drill.js +14 -24
  17. package/commands/engine.js +196 -8
  18. package/commands/gm.js +8 -6
  19. package/commands/harvest.js +1 -4
  20. package/commands/init.js +23 -3
  21. package/commands/land.js +8 -14
  22. package/commands/launchpad.js +1 -14
  23. package/commands/lifecycle.js +5 -5
  24. package/commands/log.js +55 -5
  25. package/commands/member.js +558 -574
  26. package/commands/mission.js +456 -237
  27. package/commands/pack.js +2746 -164
  28. package/commands/play.js +6 -4
  29. package/commands/probe.js +2 -2
  30. package/commands/pulse.js +15 -16
  31. package/commands/release.js +10 -9
  32. package/commands/router.js +5 -4
  33. package/commands/site-deploy.js +885 -0
  34. package/commands/site.js +11 -2
  35. package/commands/slop.js +14 -2
  36. package/commands/stream.js +4 -18
  37. package/commands/task.js +880 -558
  38. package/commands/taste.js +101 -0
  39. package/commands/team.js +176 -3
  40. package/commands/vercel.js +4 -2
  41. package/commands/voice.js +195 -0
  42. package/commands/watch.js +1 -22
  43. package/commands/wiki.js +1 -4
  44. package/commands/workflow.js +2 -2
  45. package/commands/worktree.js +1 -14
  46. package/commands/xp.js +27 -24
  47. package/lib/accept-verify-gate.js +5 -1
  48. package/lib/arg-parser.js +41 -0
  49. package/lib/auto-accept-certified.js +116 -1
  50. package/lib/autoland.js +66 -0
  51. package/lib/bench/runner.js +19 -1
  52. package/lib/context-gatherer.js +7 -1
  53. package/lib/engine-registry.js +141 -20
  54. package/lib/falsifier-probe.js +84 -0
  55. package/lib/fleet.js +65 -15
  56. package/lib/git-spawn.js +15 -0
  57. package/lib/json-file.js +37 -0
  58. package/lib/known-commands.js +2 -2
  59. package/lib/lesson-preflight.js +146 -0
  60. package/lib/loop-doctor.js +0 -2
  61. package/lib/mission-human-asks.js +28 -0
  62. package/lib/mission-protected-lane.js +4 -1
  63. package/lib/official-cli-integration.js +47 -2
  64. package/lib/orb-context.js +8 -1
  65. package/lib/pack-capabilities.js +685 -0
  66. package/lib/router-brain.js +51 -1
  67. package/lib/runner-command.js +0 -6
  68. package/lib/self-drive.js +44 -13
  69. package/lib/task-db.js +137 -3
  70. package/lib/task-decision.js +50 -0
  71. package/lib/taste-lessons.js +153 -0
  72. package/lib/tool-result-encode.js +17 -1
  73. package/lib/voice-gate.js +66 -0
  74. package/lib/wish-audit.js +1 -1
  75. package/lib/wish-delegate.js +1 -1
  76. package/lib/zip.js +95 -7
  77. package/package.json +2 -1
  78. package/templates/business-starter/persona.md +9 -0
package/commands/xp.js CHANGED
@@ -5,6 +5,7 @@ const os = require('os');
5
5
  const path = require('path');
6
6
  const crypto = require('crypto');
7
7
  const { spawnSync } = require('child_process');
8
+ const { hasFlag: hasExactFlag } = require('../lib/arg-parser');
8
9
 
9
10
  const DEFAULT_GRAPH_DAYS = 365;
10
11
  const MAX_SYNC_GRAPH_DAYS = 370;
@@ -277,7 +278,8 @@ function currentForm(payload) {
277
278
  };
278
279
  }
279
280
 
280
- function readFlag(args, name, fallback = null) {
281
+ // Preserve the existing rule that any inline value wins over a split value.
282
+ function readInlineFirstFlag(args, name, fallback = null) {
281
283
  const inline = args.find(arg => arg.startsWith(`${name}=`));
282
284
  if (inline) {
283
285
  return inline.slice(name.length + 1);
@@ -291,7 +293,7 @@ function readFlag(args, name, fallback = null) {
291
293
 
292
294
  function readFirstFlag(args, names, fallback = null) {
293
295
  for (const name of names) {
294
- const value = readFlag(args, name, null);
296
+ const value = readInlineFirstFlag(args, name, null);
295
297
  if (value !== null && value !== undefined && value !== '') return value;
296
298
  }
297
299
  return fallback;
@@ -313,8 +315,9 @@ function readFlagValues(args, names) {
313
315
  return values.filter(Boolean);
314
316
  }
315
317
 
316
- function hasFlag(args, name) {
317
- return args.includes(name) || args.some(arg => arg.startsWith(`${name}=`));
318
+ // This command treats --name=value as flag presence as well as a standalone flag.
319
+ function hasFlagOrValue(args, name) {
320
+ return hasExactFlag(args, name) || args.some(arg => arg.startsWith(`${name}=`));
318
321
  }
319
322
 
320
323
  function levelFromXp(careerXp) {
@@ -1040,7 +1043,7 @@ function buildCareerXpProjection(receipts, workspace, integrity = {}) {
1040
1043
  }
1041
1044
 
1042
1045
  function collectLocalXpProjectionState(args = [], { write = true } = {}) {
1043
- const workspace = path.resolve(readFlag(args, '--workspace', defaultXpWorkspace()));
1046
+ const workspace = path.resolve(readInlineFirstFlag(args, '--workspace', defaultXpWorkspace()));
1044
1047
  const episodePath = path.join(workspace, TASK_EPISODES_FILE);
1045
1048
  const receiptsPath = path.join(workspace, CAREER_XP_RECEIPTS_FILE);
1046
1049
  const projectionPath = path.join(workspace, CAREER_XP_PROJECTION_FILE);
@@ -1170,7 +1173,7 @@ function defaultAllSearchRoots(args = []) {
1170
1173
  if (explicitRoots.length) return uniquePaths(explicitRoots);
1171
1174
 
1172
1175
  const roots = [];
1173
- const workspace = readFlag(args, '--workspace', null);
1176
+ const workspace = readInlineFirstFlag(args, '--workspace', null);
1174
1177
  if (workspace) roots.push(workspace);
1175
1178
  roots.push(process.cwd());
1176
1179
  roots.push(path.join(os.homedir(), 'arena'));
@@ -1575,7 +1578,7 @@ function codexHasThreadsTable(dbPath) {
1575
1578
  }
1576
1579
 
1577
1580
  function readCodexGoalsForSession(args, workspace, sinceMs, untilMs) {
1578
- const explicitDb = readFlag(args, '--codex-state', readFlag(args, '--state', process.env.CODEX_STATE_DB || ''));
1581
+ const explicitDb = readInlineFirstFlag(args, '--codex-state', readInlineFirstFlag(args, '--state', process.env.CODEX_STATE_DB || ''));
1579
1582
  const dbPath = explicitDb
1580
1583
  ? path.resolve(expandHome(explicitDb))
1581
1584
  : path.resolve(fs.existsSync(CODEX_GOALS_FILE) ? CODEX_GOALS_FILE : CODEX_STATE_FILE);
@@ -1592,7 +1595,7 @@ function readCodexGoalsForSession(args, workspace, sinceMs, untilMs) {
1592
1595
  }
1593
1596
  }
1594
1597
 
1595
- const threadId = readFlag(args, '--thread', process.env.CODEX_THREAD_ID || '');
1598
+ const threadId = readInlineFirstFlag(args, '--thread', process.env.CODEX_THREAD_ID || '');
1596
1599
  const clauses = [];
1597
1600
  if (threadId) clauses.push(`tg.thread_id = ${sqlString(threadId)}`);
1598
1601
  const workspaceCandidates = workspacePathCandidates(workspace);
@@ -1632,9 +1635,9 @@ LIMIT 25
1632
1635
  }
1633
1636
 
1634
1637
  function buildCareerXpSessionCapsule(args = []) {
1635
- const workspace = path.resolve(readFlag(args, '--workspace', defaultXpWorkspace()));
1636
- const sinceInput = readFlag(args, '--since', 'today');
1637
- const untilInput = readFlag(args, '--until', null);
1638
+ const workspace = path.resolve(readInlineFirstFlag(args, '--workspace', defaultXpWorkspace()));
1639
+ const sinceInput = readInlineFirstFlag(args, '--since', 'today');
1640
+ const untilInput = readInlineFirstFlag(args, '--until', null);
1638
1641
  const since = parseSessionBoundary(sinceInput, startOfToday());
1639
1642
  const until = parseSessionBoundary(untilInput, new Date());
1640
1643
  const sinceMs = since.getTime();
@@ -1643,7 +1646,7 @@ function buildCareerXpSessionCapsule(args = []) {
1643
1646
  throw new Error('--since must be before --until');
1644
1647
  }
1645
1648
 
1646
- const writeEnabled = !hasFlag(args, '--no-write') && !hasFlag(args, '--dry-run');
1649
+ const writeEnabled = !hasFlagOrValue(args, '--no-write') && !hasFlagOrValue(args, '--dry-run');
1647
1650
  const projectionState = collectLocalXpProjectionState(['--workspace', workspace], { write: writeEnabled });
1648
1651
  const projection = projectionState.projection;
1649
1652
  const receiptsPath = path.join(workspace, CAREER_XP_RECEIPTS_FILE);
@@ -1675,7 +1678,7 @@ function buildCareerXpSessionCapsule(args = []) {
1675
1678
  });
1676
1679
  const windowXp = acceptedReceipts.reduce((sum, receipt) => sum + asNumber(receipt.xp), 0);
1677
1680
  const afterTotalXp = asNumber(projection.total_xp);
1678
- const missionFlag = readFlag(args, '--mission', null);
1681
+ const missionFlag = readInlineFirstFlag(args, '--mission', null);
1679
1682
  const episodeGoals = readJsonl(path.join(workspace, TASK_EPISODES_FILE), { allowPartialTail: true })
1680
1683
  .filter(episode => inWindow(episode?.created_at, sinceMs, untilMs))
1681
1684
  .map(episode => episode.goal);
@@ -1890,8 +1893,8 @@ function syncScopeFields(attribution = {}) {
1890
1893
  }
1891
1894
 
1892
1895
  function publicAgentXpOverride(args = []) {
1893
- if (hasFlag(args, '--public')) return true;
1894
- if (hasFlag(args, '--private') || hasFlag(args, '--internal')) return false;
1896
+ if (hasFlagOrValue(args, '--public')) return true;
1897
+ if (hasFlagOrValue(args, '--private') || hasFlagOrValue(args, '--internal')) return false;
1895
1898
  return null;
1896
1899
  }
1897
1900
 
@@ -1946,9 +1949,9 @@ function syncPlayer(args, projection) {
1946
1949
  }
1947
1950
 
1948
1951
  function buildAgentXpSyncPacket(args = []) {
1949
- const localMode = hasFlag(args, '--local') || hasFlag(args, '--workspace') || hasFlag(args, '--operator');
1952
+ const localMode = hasFlagOrValue(args, '--local') || hasFlagOrValue(args, '--workspace') || hasFlagOrValue(args, '--operator');
1950
1953
  const projectionArgs = args.filter(arg => !['--dry-run', '--no-post', '--packet', '--public', '--private', '--internal'].includes(arg));
1951
- const projection = hasFlag(args, '--all') || !localMode
1954
+ const projection = hasFlagOrValue(args, '--all') || !localMode
1952
1955
  ? collectAllLocalXpProjection(projectionArgs)
1953
1956
  : collectLocalXpProjection(projectionArgs);
1954
1957
  const player = syncPlayer(args, projection);
@@ -2086,10 +2089,10 @@ function buildAgentXpSyncPacket(args = []) {
2086
2089
 
2087
2090
  async function syncAgentXp(args = []) {
2088
2091
  const preview = buildAgentXpSyncPacket(args);
2089
- const dryRun = hasFlag(args, '--dry-run') || hasFlag(args, '--no-post') || hasFlag(args, '--packet');
2092
+ const dryRun = hasFlagOrValue(args, '--dry-run') || hasFlagOrValue(args, '--no-post') || hasFlagOrValue(args, '--packet');
2090
2093
  if (dryRun) return preview;
2091
2094
 
2092
- const token = readFlag(args, '--token', process.env.ATRIS_AGENTXP_SYNC_TOKEN || process.env.AGENTXP_SYNC_TOKEN || '');
2095
+ const token = readInlineFirstFlag(args, '--token', process.env.ATRIS_AGENTXP_SYNC_TOKEN || process.env.AGENTXP_SYNC_TOKEN || '');
2093
2096
  const envToken = process.env.ATRIS_TOKEN && process.env.ATRIS_TOKEN.trim() ? process.env.ATRIS_TOKEN.trim() : '';
2094
2097
  const options = {
2095
2098
  method: 'POST',
@@ -2298,11 +2301,11 @@ async function xpCommand(...args) {
2298
2301
  const commandArgs = args.slice(1);
2299
2302
  let payload;
2300
2303
  try {
2301
- const explicitLocal = hasFlag(commandArgs, '--local')
2302
- || hasFlag(commandArgs, '--workspace')
2303
- || hasFlag(commandArgs, '--operator');
2304
+ const explicitLocal = hasFlagOrValue(commandArgs, '--local')
2305
+ || hasFlagOrValue(commandArgs, '--workspace')
2306
+ || hasFlagOrValue(commandArgs, '--operator');
2304
2307
  const accountStatus = (subcommand === 'status' || subcommand === 'card') && !explicitLocal;
2305
- payload = hasFlag(commandArgs, '--all') || accountStatus
2308
+ payload = hasFlagOrValue(commandArgs, '--all') || accountStatus
2306
2309
  ? collectAllLocalXpProjection(commandArgs)
2307
2310
  : collectLocalXpProjection(commandArgs);
2308
2311
  } catch (error) {
@@ -2324,7 +2327,7 @@ async function xpCommand(...args) {
2324
2327
  }
2325
2328
 
2326
2329
  const jsonMode = args.includes('--json');
2327
- const localMode = hasFlag(args, '--local') || hasFlag(args, '--workspace') || hasFlag(args, '--operator');
2330
+ const localMode = hasFlagOrValue(args, '--local') || hasFlagOrValue(args, '--workspace') || hasFlagOrValue(args, '--operator');
2328
2331
  if (localMode) {
2329
2332
  let payload;
2330
2333
  try {
@@ -101,12 +101,16 @@ function evaluateAcceptVerify(task, workspaceRoot, { cache = null } = {}) {
101
101
 
102
102
  const result = runVerifyCommandCached(command, workspaceRoot, cache);
103
103
  if (!result.ok) {
104
+ const diffCheck = /^git\s+diff\s+--check\b/i.test(command.trim());
105
+ const fixHint = diffCheck
106
+ ? '; trailing whitespace in markdown is auto-fixable: npm run audit:markdown-whitespace -- --fix'
107
+ : '';
104
108
  return {
105
109
  ok: false,
106
110
  reason: result.reason || 'verify_failed',
107
111
  detail: result.reason === 'verify_workdir_missing' || result.reason === 'verify_worktree_missing'
108
112
  ? 'the stored verify command points at a directory that no longer exists'
109
- : `the stored verify command did not pass (${result.reason || 'nonzero exit'})`,
113
+ : `the stored verify command did not pass (${result.reason || 'nonzero exit'})${fixHint}`,
110
114
  command,
111
115
  ran: true,
112
116
  exit_code: typeof result.status === 'number' ? result.status : null,
@@ -0,0 +1,41 @@
1
+ 'use strict';
2
+
3
+ function hasFlag(args, name) {
4
+ return args.includes(name);
5
+ }
6
+
7
+ function unquote(value) {
8
+ const text = String(value);
9
+ if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) {
10
+ return text.slice(1, -1);
11
+ }
12
+ return text;
13
+ }
14
+
15
+ function readFlag(args, name, fallback = '') {
16
+ const prefix = `${name}=`;
17
+ for (let i = 0; i < args.length; i += 1) {
18
+ const arg = String(args[i]);
19
+ if (arg === name && args[i + 1] && !String(args[i + 1]).startsWith('--')) {
20
+ return unquote(args[i + 1]);
21
+ }
22
+ if (arg.startsWith(prefix)) return unquote(arg.slice(prefix.length));
23
+ }
24
+ return fallback;
25
+ }
26
+
27
+ function readIntFlag(args, name, fallback = null) {
28
+ const raw = readFlag(args, name, '');
29
+ if (!raw) return fallback;
30
+ const value = Number.parseInt(raw, 10);
31
+ return Number.isFinite(value) ? value : null;
32
+ }
33
+
34
+ function readNumberFlag(args, name, fallback = null) {
35
+ const raw = readFlag(args, name, '');
36
+ if (!raw) return fallback;
37
+ const value = Number(raw);
38
+ return Number.isFinite(value) ? value : null;
39
+ }
40
+
41
+ module.exports = { hasFlag, readFlag, readIntFlag, readNumberFlag };
@@ -13,7 +13,7 @@ const AGENT_CERTIFICATION_REVIEW_PASSES = 2;
13
13
  // once powered is gone. Passes alone never land work, an independent
14
14
  // reviewer does.
15
15
  const AUTO_ACCEPT_HIGH_CONFIDENCE_PASSES = 3;
16
- const DENIED_TAGS = new Set(['billing', 'deploy', 'feedback', 'security', 'customer', 'external']);
16
+ const DENIED_TAGS = new Set(['billing', 'money', 'payments', 'deploy', 'feedback', 'security', 'customer', 'external']);
17
17
 
18
18
  const SIMPLE_VERIFY_TOKEN_RE = /^[a-zA-Z0-9_./:@=+-]+$/;
19
19
  const GIT_WORKTREE_PATH_RE = /^[a-zA-Z0-9_./@=+-]+$/;
@@ -447,7 +447,41 @@ function isAutoCertifyVerifyCommandAllowed(verify) {
447
447
  return parseVerifyCommand(verify).ok;
448
448
  }
449
449
 
450
+ // Re-entrancy guard: every verify child carries ATRIS_VERIFY_IN_PROGRESS=1
451
+ // (see verifyCommandEnv). A verify that shells back into the CLI and reaches
452
+ // another verify would otherwise recurse without bound — that cycle
453
+ // fork-bombed the fleet on 2026-07-29 when a stored pytest called
454
+ // `atris task status` and the read path re-ran the verify. Refuse loudly.
455
+ const VERIFY_IN_PROGRESS_ENV = 'ATRIS_VERIFY_IN_PROGRESS';
456
+
457
+ // Spawn cap: a single CLI process has no legitimate reason to run dozens of
458
+ // verifies. Beyond the cap something is looping; refuse and report instead
459
+ // of silently bounding.
460
+ const VERIFY_SPAWN_CAP = 25;
461
+ let verifySpawnCount = 0;
462
+
463
+ function verifyRefusal(unrunnableCause, extra) {
464
+ return {
465
+ ok: false,
466
+ reason: 'verify_unrunnable',
467
+ unrunnable_cause: unrunnableCause,
468
+ alarm: true,
469
+ ...(extra || {}),
470
+ };
471
+ }
472
+
450
473
  function runVerifyCommand(verify, workspaceRoot) {
474
+ if (process.env[VERIFY_IN_PROGRESS_ENV]) {
475
+ return verifyRefusal('verify_reentrant', {
476
+ detail: `${VERIFY_IN_PROGRESS_ENV} is set: a verify is already running up the call chain; refusing to nest another one`,
477
+ });
478
+ }
479
+ if (verifySpawnCount >= VERIFY_SPAWN_CAP) {
480
+ return verifyRefusal('verify_spawn_cap', {
481
+ detail: `this process already ran ${verifySpawnCount} verifies (cap ${VERIFY_SPAWN_CAP}); refusing more`,
482
+ });
483
+ }
484
+ verifySpawnCount += 1;
451
485
  const parsed = parseVerifyCommand(verify);
452
486
  if (!parsed.ok) return parsed;
453
487
  const cwdCheck = validateCommandCwd(parsed, workspaceRoot);
@@ -503,6 +537,7 @@ function verifyCommandEnv(extraEnv) {
503
537
  if (binDir && !alreadyPresent) {
504
538
  base.PATH = current ? `${binDir}${path.delimiter}${current}` : binDir;
505
539
  }
540
+ base[VERIFY_IN_PROGRESS_ENV] = '1';
506
541
  return base;
507
542
  }
508
543
 
@@ -528,6 +563,63 @@ function runVerifyCommandCached(verify, workspaceRoot, cache = null) {
528
563
  return { ...result, reused: false };
529
564
  }
530
565
 
566
+ // Pre-land hygiene: dead exports used to surface only AFTER landing, when the
567
+ // full suite's repo-hygiene ratchet went red on master (lesson:
568
+ // engine-dead-exports — engines export every internal helper, the task's own
569
+ // verify command stays green, and the breakage lands). In repos that carry the
570
+ // ratchet (test/repo-hygiene.test.js), run the same detector before landing so
571
+ // the gate refuses the work instead of master discovering it. Memoized per
572
+ // root: one scan covers every task in a sweep.
573
+ const repoHygieneCache = new Map();
574
+
575
+ function repoHygieneGate(workspaceRoot) {
576
+ const root = path.resolve(workspaceRoot || process.cwd());
577
+ if (repoHygieneCache.has(root)) return repoHygieneCache.get(root);
578
+ let result = { ok: true, skipped: true };
579
+ if (fs.existsSync(path.join(root, 'test', 'repo-hygiene.test.js'))) {
580
+ try {
581
+ const { findDeadCode, findOrphanedExports, listJsFiles } = require('../commands/slop');
582
+ const dead = findDeadCode(root).dead;
583
+ const files = ['commands', 'lib'].flatMap((d) => listJsFiles(path.join(root, d)));
584
+ const orphans = findOrphanedExports(root, files, listJsFiles(root));
585
+ const offenders = [
586
+ ...dead.map((f) => path.relative(root, f)),
587
+ ...orphans.map((o) => `${path.relative(root, o.file)} → ${o.name}`),
588
+ ];
589
+ result = offenders.length
590
+ ? {
591
+ ok: false,
592
+ reason: 'dead_exports',
593
+ offenders: offenders.slice(0, 12),
594
+ message: 'this work leaves code or exports nothing uses, so the full test suite fails right after landing; delete the unused pieces and land again.',
595
+ }
596
+ : { ok: true };
597
+ } catch (err) {
598
+ // "I could not run the detector" is not a verdict on the work — same
599
+ // rule as verify_unrunnable, but hygiene is a repo-wide ratchet the
600
+ // suite still enforces, so failing open here only delays the red.
601
+ result = { ok: true, skipped: true, error: String((err && err.message) || err).slice(0, 200) };
602
+ }
603
+ }
604
+ repoHygieneCache.set(root, result);
605
+ return result;
606
+ }
607
+
608
+ // Shared by both landing lanes so the check cannot ship in one dispatch branch
609
+ // and silently skip the sibling (lesson: parallel-paths-drift).
610
+ function hygieneBlockResult(task, ref) {
611
+ const hygiene = repoHygieneGate(task.workspace_root || process.cwd());
612
+ if (hygiene.ok) return null;
613
+ return {
614
+ eligible: false,
615
+ ref,
616
+ reason: hygiene.reason,
617
+ message: hygiene.message,
618
+ offenders: hygiene.offenders,
619
+ next_action: 'run `node --test test/repo-hygiene.test.js` in the workspace, delete what it names, then re-certify',
620
+ };
621
+ }
622
+
531
623
  function strictVerifyMissingResult(ref) {
532
624
  return {
533
625
  eligible: false,
@@ -721,6 +813,19 @@ function evaluateAutoAccept(task, options = {}) {
721
813
  if (trustTier === 'probation' && !reviewIntegrity.hasIndependentReview(task)) {
722
814
  return { eligible: false, ref, reason: 'probation_needs_review' };
723
815
  }
816
+ // The read-only status path (executeVerify:false) must apply the same
817
+ // command allowlist the landing gate applies, or status promises a landing
818
+ // the gate will refuse every hour: five rows read "lands itself" for a
819
+ // whole night while every tick skipped them as verify_command_not_allowed.
820
+ // Sits after the probation gate so it reports the gate's own refusal
821
+ // order, not a new one.
822
+ if (verify && !executeVerify && !isAutoCertifyVerifyCommandAllowed(verify)) {
823
+ return { eligible: false, ref, reason: 'verify_command_not_allowed', verify };
824
+ }
825
+ if (executeVerify) {
826
+ const hygieneBlock = hygieneBlockResult(task, ref);
827
+ if (hygieneBlock) return hygieneBlock;
828
+ }
724
829
  return {
725
830
  eligible: true,
726
831
  ref,
@@ -788,9 +893,18 @@ function evaluateAutoAccept(task, options = {}) {
788
893
  if (!verifyResult.ok) {
789
894
  return { eligible: false, ref, reason: verifyResult.reason, verify, ...verifyResult };
790
895
  }
896
+ } else if (!isAutoCertifyVerifyCommandAllowed(verify)) {
897
+ // Same truth rule as above: the read-only path may not promise a
898
+ // landing the allowlist will refuse.
899
+ return { eligible: false, ref, reason: 'verify_command_not_allowed', verify };
791
900
  }
792
901
  }
793
902
 
903
+ if (executeVerify) {
904
+ const hygieneBlock = hygieneBlockResult(task, ref);
905
+ if (hygieneBlock) return hygieneBlock;
906
+ }
907
+
794
908
  return {
795
909
  eligible: true,
796
910
  ref,
@@ -812,6 +926,7 @@ module.exports = {
812
926
  isAutoCertifyVerifyCommandAllowed,
813
927
  isAgentCertified,
814
928
  parseVerifyCommand,
929
+ repoHygieneGate,
815
930
  runVerifyCommand,
816
931
  runVerifyCommandCached,
817
932
  };
package/lib/autoland.js CHANGED
@@ -120,6 +120,66 @@ function liveAcceptAuthorization(root = process.cwd()) {
120
120
  return { ok: true, actor, policy: 'autoland', strictVerify: policy.strict_verify !== false };
121
121
  }
122
122
 
123
+ // Tick receipts are local file reads and prove the loop ran. A receipt from
124
+ // the last couple of hours means the hourly pass is alive; older or missing
125
+ // means surfaces must not promise "next tick" landing.
126
+ const HEARTBEAT_LIVE_HOURS = 2;
127
+ const STALE_HEARTBEAT_LANDING = 'once the hourly heartbeat runs (start one with atris autoland tick)';
128
+
129
+ function lastTickAgeHours(root) {
130
+ try {
131
+ const runsDir = path.join(root, 'atris', 'runs');
132
+ const newest = fs.readdirSync(runsDir)
133
+ .filter((f) => f.startsWith('autoland-tick-') && f.endsWith('.json'))
134
+ .sort()
135
+ .pop();
136
+ if (!newest) return null;
137
+ const stamp = fs.statSync(path.join(runsDir, newest)).mtimeMs;
138
+ return (Date.now() - stamp) / 3_600_000;
139
+ } catch {
140
+ return null;
141
+ }
142
+ }
143
+
144
+ function heartbeatIsLive(root) {
145
+ const ageHours = lastTickAgeHours(root);
146
+ return ageHours !== null && ageHours <= HEARTBEAT_LIVE_HOURS;
147
+ }
148
+
149
+ function heartbeatLiveness(root, policy) {
150
+ return typeof policy?.heartbeat_installed === 'boolean' ? policy.heartbeat_installed : null;
151
+ }
152
+
153
+ function heartbeatStatusText(root, policy) {
154
+ const ageHours = lastTickAgeHours(root);
155
+ const ageText = ageHours === null
156
+ ? null
157
+ : ageHours < 1 ? 'under an hour' : `${Math.floor(ageHours)}h`;
158
+ // Evidence first: a receipt from the last couple of hours proves the loop is
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
161
+ // quiet is the single most useful thing this line can say, because that is
162
+ // the state nobody notices.
163
+ if (ageHours !== null && ageHours <= HEARTBEAT_LIVE_HOURS) return `running hourly (last tick ${ageText} ago)`;
164
+ if (ageHours !== null) return `SILENT - last tick ${ageText} ago; run atris autoland tick`;
165
+ const installed = heartbeatLiveness(root, policy);
166
+ if (installed === false) return 'not installed - run atris autoland on';
167
+ if (installed === true) return 'installed, but no tick has ever run - run atris autoland tick';
168
+ return 'unknown - run atris autoland on to check and repair';
169
+ }
170
+
171
+ // Operator-facing schedule: only promise "next tick" when a recent receipt
172
+ // proves the loop is alive. Otherwise name the honest gate.
173
+ function whenAutolandLands(root) {
174
+ return heartbeatIsLive(root) ? 'on the next tick' : STALE_HEARTBEAT_LANDING;
175
+ }
176
+
177
+ function certifiedWorkLandsPhrase(root) {
178
+ return heartbeatIsLive(root)
179
+ ? 'certified work lands itself'
180
+ : `certified work lands ${STALE_HEARTBEAT_LANDING}`;
181
+ }
182
+
123
183
  function cronMarker(root) {
124
184
  const slug = path.basename(root).replace(/[^a-zA-Z0-9]+/g, '_').toUpperCase();
125
185
  return `ATRIS_AUTOLAND_${slug}`;
@@ -654,8 +714,10 @@ function sendImessage(root, to, text) {
654
714
  module.exports = {
655
715
  DEFAULT_ALARM_HOURS,
656
716
  DEFAULT_DIGEST_HOUR,
717
+ STALE_HEARTBEAT_LANDING,
657
718
  acceptedInLastDay,
658
719
  buildCronLine,
720
+ certifiedWorkLandsPhrase,
659
721
  clarify,
660
722
  composeAlarm,
661
723
  composeDigest,
@@ -665,6 +727,9 @@ module.exports = {
665
727
  digestLine,
666
728
  dejargon,
667
729
  explainResult,
730
+ heartbeatIsLive,
731
+ heartbeatLiveness,
732
+ heartbeatStatusText,
668
733
  installCron,
669
734
  liveAcceptAuthorization,
670
735
  markAlerted,
@@ -684,6 +749,7 @@ module.exports = {
684
749
  statePath,
685
750
  uninstallCron,
686
751
  waitingOnHuman,
752
+ whenAutolandLands,
687
753
  writePolicy,
688
754
  writeState,
689
755
  };
@@ -219,6 +219,22 @@ async function runSetup(spec, ctx) {
219
219
  await setup(ctx);
220
220
  }
221
221
 
222
+ // The one live prompt gate (lesson: benchmark-prompt-paths): receipt tests run
223
+ // the solution/null engines, which never read the prompt, so a malformed
224
+ // prompt.md sails through green dry-runs and only a paid real-engine run finds
225
+ // it. Every prompt an engine receives flows through here, and the tests assert
226
+ // real task prompts against the same function.
227
+ function readTaskPrompt(spec) {
228
+ const text = fs.readFileSync(spec.promptPath, 'utf8');
229
+ if (!text.trim() || text.trim().length < 20) {
230
+ throw new BenchInfraError(`${spec.id}: prompt.md is empty or too short to brief an engine`);
231
+ }
232
+ if (/\{\{[^}]*\}\}|<%|\uFFFD|\u0000/.test(text)) {
233
+ throw new BenchInfraError(`${spec.id}: prompt.md carries unresolved template or encoding artifacts`);
234
+ }
235
+ return text;
236
+ }
237
+
222
238
  function formatEngineFailure(engineName, result) {
223
239
  const stdout = String(result && result.stdout ? result.stdout : '').trim();
224
240
  const stderr = String(result && result.stderr ? result.stderr : '').trim();
@@ -256,7 +272,7 @@ async function runAgentTaskSpec(spec, options = {}) {
256
272
  }
257
273
 
258
274
  await runSetup(spec, ctx);
259
- const promptText = fs.readFileSync(spec.promptPath, 'utf8');
275
+ const promptText = readTaskPrompt(spec);
260
276
  const engineResult = await withTimeout(Promise.resolve(engine.run(promptText, ctx.workspace, timeoutMs)), spec, timeoutMs);
261
277
  if (!engineResult || engineResult.status !== 0) {
262
278
  throw new Error(formatEngineFailure(options.engine, engineResult));
@@ -493,8 +509,10 @@ module.exports = {
493
509
  BenchInfraError,
494
510
  DEFAULT_PACK,
495
511
  findPython,
512
+ loadTaskSpecs,
496
513
  packMetadata,
497
514
  readResultRecords,
515
+ readTaskPrompt,
498
516
  runBench,
499
517
  summarizeTasks,
500
518
  taskMetadata,
@@ -28,7 +28,13 @@ function hasContextProfile(root = process.cwd()) {
28
28
  function compactText(value, max = 160) {
29
29
  const text = String(value || '').replace(/\s+/g, ' ').trim();
30
30
  if (!text) return '';
31
- return text.length > max ? `${text.slice(0, Math.max(0, max - 3)).trim()}...` : text;
31
+ if (text.length <= max) return text;
32
+ const slice = text.slice(0, Math.max(0, max - 3));
33
+ const lastSpace = slice.lastIndexOf(' ');
34
+ // Cut on a word boundary so a title never ends on half a word ("...an autonomo...").
35
+ // Fall back to the hard slice only when a single word already fills the limit.
36
+ const body = lastSpace > 0 ? slice.slice(0, lastSpace) : slice;
37
+ return `${body.replace(/[\s,;:.!?-]+$/, '')}...`;
32
38
  }
33
39
 
34
40
  function inferDomain(answer) {