atris 3.37.1 → 3.40.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 (164) hide show
  1. package/AGENTS.md +25 -6
  2. package/README.md +2 -0
  3. package/atris/PERSONA.md +11 -11
  4. package/atris.md +17 -2
  5. package/ax +160 -34
  6. package/bin/atris.js +78 -6
  7. package/commands/activate.js +16 -3
  8. package/commands/agent-spawn.js +13 -18
  9. package/commands/autoland.js +76 -47
  10. package/commands/autopilot-front.js +0 -4
  11. package/commands/autopilot.js +0 -12
  12. package/commands/bench.js +10 -12
  13. package/commands/brain.js +0 -7
  14. package/commands/brief.js +0 -2
  15. package/commands/business-sync.js +0 -1
  16. package/commands/business.js +452 -12
  17. package/commands/chat-scan.js +5 -7
  18. package/commands/clean.js +31 -2
  19. package/commands/close.js +46 -11
  20. package/commands/cloud.js +0 -1
  21. package/commands/codex-goal.js +8 -12
  22. package/commands/computer.js +6 -5
  23. package/commands/console.js +64 -11
  24. package/commands/decide.js +166 -0
  25. package/commands/deck.js +30 -12
  26. package/commands/dream.js +0 -7
  27. package/commands/drill.js +14 -25
  28. package/commands/engine.js +206 -26
  29. package/commands/experiments.js +0 -4
  30. package/commands/fleet-report.js +12 -4
  31. package/commands/game.js +261 -0
  32. package/commands/gm.js +17 -7
  33. package/commands/goal.js +0 -6
  34. package/commands/harvest.js +1 -5
  35. package/commands/improve.js +29 -8
  36. package/commands/init.js +41 -37
  37. package/commands/integrations.js +0 -7
  38. package/commands/land.js +77 -20
  39. package/commands/launchpad.js +1 -16
  40. package/commands/lesson.js +17 -7
  41. package/commands/lifecycle.js +5 -5
  42. package/commands/log.js +55 -5
  43. package/commands/loop-front.js +0 -2
  44. package/commands/loops.js +6 -5
  45. package/commands/meet.js +0 -2
  46. package/commands/member.js +622 -573
  47. package/commands/mission.js +790 -314
  48. package/commands/next.js +0 -1
  49. package/commands/now.js +0 -12
  50. package/commands/one-lap.js +8 -8
  51. package/commands/orb.js +0 -4
  52. package/commands/pack-craft.js +0 -1
  53. package/commands/pack.js +3599 -424
  54. package/commands/play.js +6 -6
  55. package/commands/probe.js +12 -6
  56. package/commands/pulse.js +88 -64
  57. package/commands/push.js +0 -4
  58. package/commands/radar.js +14 -11
  59. package/commands/recover.js +340 -0
  60. package/commands/release.js +10 -9
  61. package/commands/report.js +0 -1
  62. package/commands/router.js +320 -0
  63. package/commands/run-front.js +0 -2
  64. package/commands/search.js +0 -3
  65. package/commands/site-deploy.js +870 -0
  66. package/commands/site.js +11 -2
  67. package/commands/skill.js +7 -2
  68. package/commands/slop.js +237 -7
  69. package/commands/status.js +6 -0
  70. package/commands/stream.js +4 -20
  71. package/commands/study.js +0 -3
  72. package/commands/sync-checkout.js +51 -0
  73. package/commands/sync.js +11 -40
  74. package/commands/task.js +999 -595
  75. package/commands/taste.js +101 -0
  76. package/commands/teach.js +86 -0
  77. package/commands/team.js +83 -3
  78. package/commands/unknowns.js +0 -10
  79. package/commands/update.js +0 -1
  80. package/commands/vercel.js +4 -2
  81. package/commands/visualize.js +0 -5
  82. package/commands/voice.js +195 -0
  83. package/commands/watch.js +1 -28
  84. package/commands/wiki.js +42 -15
  85. package/commands/wish.js +0 -1
  86. package/commands/workflow.js +2 -2
  87. package/commands/worktree.js +88 -28
  88. package/commands/xp.js +28 -30
  89. package/commands/youtube.js +0 -2
  90. package/lib/accept-verify-gate.js +126 -0
  91. package/lib/activity-stream.js +0 -2
  92. package/lib/arg-parser.js +41 -0
  93. package/lib/auto-accept-certified.js +261 -3
  94. package/lib/autoland.js +66 -3
  95. package/lib/ax-auto-lane.js +68 -7
  96. package/lib/bench/context.js +0 -2
  97. package/lib/bench/report.js +0 -1
  98. package/lib/bench/runner.js +18 -11
  99. package/lib/brief-ledger.js +0 -6
  100. package/lib/business-simulate.js +382 -0
  101. package/lib/chat-log-scan.js +0 -5
  102. package/lib/checkout-sync.js +52 -0
  103. package/lib/claude-boot-block.js +48 -0
  104. package/lib/cloud-mission.js +0 -6
  105. package/lib/codex-flight.js +0 -1
  106. package/lib/conductor-artifacts.js +25 -0
  107. package/lib/context-gatherer.js +7 -2
  108. package/lib/deck-compose.js +0 -1
  109. package/lib/deck-history.js +0 -1
  110. package/lib/deck-layout.js +0 -5
  111. package/lib/deck-review.js +0 -7
  112. package/lib/deck-schema.js +0 -1
  113. package/lib/default-verifier.js +0 -2
  114. package/lib/engine-registry.js +157 -28
  115. package/lib/experiments/daily.js +0 -6
  116. package/lib/falsifier-probe.js +84 -0
  117. package/lib/fleet.js +275 -42
  118. package/lib/functional-owner.js +0 -3
  119. package/lib/git-spawn.js +15 -0
  120. package/lib/inspect-fields.js +0 -1
  121. package/lib/json-file.js +37 -0
  122. package/lib/known-commands.js +2 -2
  123. package/lib/learnings.js +0 -2
  124. package/lib/lesson-preflight.js +146 -0
  125. package/lib/loop-doctor.js +11 -19
  126. package/lib/manifest.js +0 -2
  127. package/lib/member-alive.js +0 -1
  128. package/lib/member-switches.js +0 -5
  129. package/lib/mission-artifact.js +0 -1
  130. package/lib/mission-human-asks.js +28 -0
  131. package/lib/mission-protected-lane.js +296 -0
  132. package/lib/mission-room.js +0 -2
  133. package/lib/mission-runtime-loop.js +0 -1
  134. package/lib/next-moves.js +0 -18
  135. package/lib/official-cli-integration.js +47 -6
  136. package/lib/orb-context.js +14 -4
  137. package/lib/orb-scorecard.js +0 -1
  138. package/lib/outbound-send-gate.js +0 -6
  139. package/lib/pack-capabilities.js +685 -0
  140. package/lib/permission-grants.js +0 -6
  141. package/lib/policy-lessons.js +0 -2
  142. package/lib/pulse.js +68 -15
  143. package/lib/review-integrity.js +0 -2
  144. package/lib/router-brain.js +51 -3
  145. package/lib/runner-command.js +0 -10
  146. package/lib/runtime-bootstrap.js +0 -1
  147. package/lib/scorecard.js +0 -1
  148. package/lib/self-drive.js +45 -14
  149. package/lib/state-detection.js +0 -6
  150. package/lib/task-db.js +137 -7
  151. package/lib/task-decision.js +50 -0
  152. package/lib/taste-lessons.js +153 -0
  153. package/lib/team-presence.js +0 -1
  154. package/lib/tool-result-encode.js +17 -1
  155. package/lib/voice-gate.js +129 -2
  156. package/lib/wiki.js +152 -9
  157. package/lib/wish-audit.js +21 -7
  158. package/lib/wish-delegate.js +27 -21
  159. package/lib/wish-stats.js +0 -5
  160. package/lib/wish-store.js +0 -1
  161. package/lib/workspace-safety.js +0 -2
  162. package/lib/zip.js +95 -8
  163. package/package.json +2 -1
  164. package/templates/business-starter/persona.md +9 -0
@@ -0,0 +1,126 @@
1
+ 'use strict';
2
+
3
+ // The accept gate used to check that a task's verify field was non-empty, never
4
+ // that it held something that could fail. An audit on 2026-07-26 found 131 of 802
5
+ // accepted proofs (16.3%) were unfalsifiable at the moment they were signed off:
6
+ // 54 an unfilled template sentence, 55 a bare `git diff --check`, 21 a path into a
7
+ // deleted worktree, 1 a bare file path.
8
+ //
9
+ // This module answers one question before a task may be marked done: does the
10
+ // stored verify command actually run, and does it pass? Parsing and execution reuse
11
+ // lib/auto-accept-certified.js so there is one allow-list, not two.
12
+
13
+ const { parseVerifyCommand, runVerifyCommandCached } = require('./auto-accept-certified');
14
+
15
+ // Template text shipped in task scaffolding. Present verbatim in 54 accepted proofs.
16
+ const TEMPLATE_PLACEHOLDERS = [
17
+ 'concrete command, file, receipt, or verifier evidence',
18
+ 'command, file, receipt, or verifier evidence',
19
+ '<command>',
20
+ 'tbd',
21
+ 'n/a',
22
+ 'none',
23
+ ];
24
+
25
+ // Commands that execute cleanly but cannot fail for the reason the task exists.
26
+ // `git diff --check` reports whitespace and conflict markers; it is green on a
27
+ // commit that deletes the feature. 55 accepted proofs stored exactly this.
28
+ const NON_FALSIFYING_COMMANDS = [
29
+ /^git\s+diff\s+--check\s*$/i,
30
+ /^git\s+status\s*$/i,
31
+ /^git\s+log\b/i,
32
+ /^ls\b/i,
33
+ /^true\s*$/i,
34
+ ];
35
+
36
+ function storedVerifyCommand(task) {
37
+ const metadata = (task && task.metadata) || {};
38
+ const candidates = [metadata.verify, metadata.latest_agent_verify];
39
+ for (const candidate of candidates) {
40
+ const text = String(candidate || '').trim();
41
+ if (text) return text;
42
+ }
43
+ return '';
44
+ }
45
+
46
+ function placeholderIssue(command) {
47
+ const lower = command.toLowerCase().trim();
48
+ if (TEMPLATE_PLACEHOLDERS.some((p) => lower === p || lower.startsWith(p))) {
49
+ return 'the verify field holds unfilled template text, not a command';
50
+ }
51
+ // A bare path is a receipt, not a check. `/tmp/x/proof.json` was one of the 131.
52
+ if (/^[./~]/.test(command) && !/\s/.test(command)) {
53
+ return 'the verify field holds a file path, not a command that can fail';
54
+ }
55
+ return '';
56
+ }
57
+
58
+ function nonFalsifyingIssue(command) {
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`;
61
+ }
62
+ return '';
63
+ }
64
+
65
+ // Returns { ok, reason, detail, command, ran, exit_code }.
66
+ // ok:true means the stored command parsed, ran, and exited 0.
67
+ function evaluateAcceptVerify(task, workspaceRoot, { cache = null } = {}) {
68
+ const command = storedVerifyCommand(task);
69
+ // A task with no stored verify is out of scope here. Every one of the 131
70
+ // unfalsifiable proofs HAD a command; the defect is a stored check that cannot
71
+ // fail, not a missing one. Requiring a verify on every accept is a separate,
72
+ // larger policy change and blocking it here would only get this gate switched off.
73
+ if (!command) {
74
+ return { ok: true, reason: 'no_verify_command', detail: '', command: '', ran: false, unchecked: true };
75
+ }
76
+
77
+ const placeholder = placeholderIssue(command);
78
+ if (placeholder) return { ok: false, reason: 'verify_placeholder', detail: placeholder, command, ran: false };
79
+
80
+ const hollow = nonFalsifyingIssue(command);
81
+ if (hollow) return { ok: false, reason: 'verify_not_falsifying', detail: hollow, command, ran: false };
82
+
83
+ // Two different questions got fused here at first: "can this fail?" and "is this
84
+ // safe to execute at accept time?". Only the first is this gate's business.
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.
88
+ // So: refuse what provably cannot fail, run what can be run safely, and mark the
89
+ // rest unchecked rather than pretend a judgment we cannot make.
90
+ const parsed = parseVerifyCommand(command);
91
+ if (!parsed.ok) {
92
+ return {
93
+ ok: true,
94
+ reason: 'verify_not_runnable_here',
95
+ detail: `the stored verify command cannot be safely executed at accept time (${parsed.reason || 'unknown'}); it was not checked`,
96
+ command,
97
+ ran: false,
98
+ unchecked: true,
99
+ };
100
+ }
101
+
102
+ const result = runVerifyCommandCached(command, workspaceRoot, cache);
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
+ : '';
108
+ return {
109
+ ok: false,
110
+ reason: result.reason || 'verify_failed',
111
+ detail: result.reason === 'verify_workdir_missing' || result.reason === 'verify_worktree_missing'
112
+ ? 'the stored verify command points at a directory that no longer exists'
113
+ : `the stored verify command did not pass (${result.reason || 'nonzero exit'})${fixHint}`,
114
+ command,
115
+ ran: true,
116
+ exit_code: typeof result.status === 'number' ? result.status : null,
117
+ };
118
+ }
119
+
120
+ return { ok: true, reason: 'verify_passed', detail: '', command, ran: true, exit_code: 0 };
121
+ }
122
+
123
+ module.exports = {
124
+ storedVerifyCommand,
125
+ evaluateAcceptVerify,
126
+ };
@@ -154,13 +154,11 @@ function buildHeartbeat(pulseReceipts, now = Date.now()) {
154
154
  }
155
155
 
156
156
  module.exports = {
157
- pickTs,
158
157
  clip,
159
158
  normalizePulse,
160
159
  normalizeScorecard,
161
160
  normalizeTaskEpisode,
162
161
  normalizeXp,
163
- normalizeMissionEvent,
164
162
  buildActivityStream,
165
163
  buildHeartbeat,
166
164
  };
@@ -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);
@@ -458,11 +492,29 @@ function runVerifyCommand(verify, workspaceRoot) {
458
492
  if (!pythonPathCheck.ok) return pythonPathCheck;
459
493
  const result = spawnSync(parsed.argv[0], parsed.argv.slice(1), {
460
494
  cwd: cwdCheck.cwd,
461
- env: parsed.env ? { ...process.env, ...parsed.env } : process.env,
495
+ env: verifyCommandEnv(parsed.env),
462
496
  shell: false,
463
497
  encoding: 'utf8',
464
498
  timeout: 120000,
465
499
  });
500
+ // "I could not run the check" is not "the check failed". Collapsing the two
501
+ // let a broken harness read as broken code: under cron's PATH every
502
+ // `npm run ...` verifier returned ENOENT, mapped to verify_failed, and 16
503
+ // landable tasks were skipped silently every hour for 20 days. A verifier
504
+ // the harness cannot execute must be loud and must never look like a
505
+ // verdict on the work.
506
+ const unrunnable = unrunnableVerifyReason(result);
507
+ if (unrunnable) {
508
+ return {
509
+ ok: false,
510
+ reason: 'verify_unrunnable',
511
+ unrunnable_cause: unrunnable,
512
+ alarm: true,
513
+ command: parsed.argv.join(' '),
514
+ status: result.status,
515
+ stderr: String(result.stderr || '').slice(0, 400),
516
+ };
517
+ }
466
518
  return {
467
519
  ok: result.status === 0,
468
520
  reason: result.status === 0 ? 'verify_passed' : 'verify_failed',
@@ -471,6 +523,37 @@ function runVerifyCommand(verify, workspaceRoot) {
471
523
  };
472
524
  }
473
525
 
526
+ // Verifiers run with shell:false, so the child gets no PATH resolution beyond
527
+ // whatever the parent inherited. Cron has no Homebrew PATH — that is why every
528
+ // cron entry hardcodes an absolute node path. `npm`, `npx` and friends live in
529
+ // the same bin directory as the node binary currently executing, so seeding
530
+ // PATH with it makes recorded checks runnable from cron with zero config: no
531
+ // crontab PATH line to keep in sync, no new env var.
532
+ function verifyCommandEnv(extraEnv) {
533
+ const base = extraEnv ? { ...process.env, ...extraEnv } : { ...process.env };
534
+ const binDir = path.dirname(process.execPath);
535
+ const current = String(base.PATH || '');
536
+ const alreadyPresent = current.split(path.delimiter).includes(binDir);
537
+ if (binDir && !alreadyPresent) {
538
+ base.PATH = current ? `${binDir}${path.delimiter}${current}` : binDir;
539
+ }
540
+ base[VERIFY_IN_PROGRESS_ENV] = '1';
541
+ return base;
542
+ }
543
+
544
+ // ENOENT: the binary is not on PATH at all. ETIMEDOUT: the 120s ceiling cut it
545
+ // off before it could reach a verdict. Neither is evidence about the diff.
546
+ function unrunnableVerifyReason(result) {
547
+ const code = result && result.error ? result.error.code : null;
548
+ if (code === 'ENOENT') return 'command_not_found';
549
+ if (code === 'ETIMEDOUT' || result.signal === 'SIGTERM') return 'verify_timed_out';
550
+ if (code) return String(code).toLowerCase();
551
+ // spawnSync reports a null status with no error object when the child was
552
+ // killed outright; treat that as unrunnable rather than a silent failure.
553
+ if (result && result.status === null) return 'no_exit_status';
554
+ return null;
555
+ }
556
+
474
557
  function runVerifyCommandCached(verify, workspaceRoot, cache = null) {
475
558
  if (!(cache instanceof Map)) return runVerifyCommand(verify, workspaceRoot);
476
559
  const key = `${path.resolve(workspaceRoot || process.cwd())}\u0000${String(verify || '').trim()}`;
@@ -480,6 +563,63 @@ function runVerifyCommandCached(verify, workspaceRoot, cache = null) {
480
563
  return { ...result, reused: false };
481
564
  }
482
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
+
483
623
  function strictVerifyMissingResult(ref) {
484
624
  return {
485
625
  eligible: false,
@@ -507,6 +647,61 @@ function receiptEvidenceBlock(task, ref) {
507
647
  return null;
508
648
  }
509
649
 
650
+ // Authors declare protected lanes in the task text, not the tag field. Match
651
+ // only explicit, deliberate declarations — an unanchored "security" would
652
+ // snag every task that merely mentions the word and wedge the loop shut,
653
+ // which is its own failure mode. Kept narrow on purpose.
654
+ const PROTECTED_LANE_PHRASES = [
655
+ 'protected lane',
656
+ 'never self-land',
657
+ 'never self land',
658
+ 'do not self-land',
659
+ 'human accepts',
660
+ 'human must accept',
661
+ 'human review required',
662
+ 'orchestrator reviews pre-land',
663
+ 'reviews pre-land',
664
+ ];
665
+
666
+ function declaredProtectedLane(task) {
667
+ const haystack = [
668
+ task.title,
669
+ task.objective,
670
+ task.metadata && task.metadata.protected_lane_note,
671
+ ]
672
+ .map((v) => String(v || ''))
673
+ .join('\n')
674
+ .toLowerCase();
675
+ if (!haystack.trim()) return null;
676
+ const phrase = PROTECTED_LANE_PHRASES.find((p) => haystack.includes(p));
677
+ return phrase ? { phrase } : null;
678
+ }
679
+
680
+ // Only consulted when a task carries no tag at all, so this never overrides an
681
+ // author who did classify their work. Terms are concrete nouns from the denied
682
+ // lanes — things you can only be touching on purpose. Deliberately excludes
683
+ // broad words like "auth", "user", or "api" that appear in ordinary frontend
684
+ // work and would hold the whole queue.
685
+ const PROTECTED_LANE_TERMS = [
686
+ ['billing', ['stripe', 'checkout url', 'checkout_url', 'invoice', 'paywall', 'subscription', 'wallet', 'credit card', 'pricing tier']],
687
+ ['security', ['session minting', 'mints a', 'service token', 'api key', 'credential', 'secret key', 'csrf', 'access token']],
688
+ ['deploy', ['render deploy', 'production deploy', 'deploy to prod', 'migration apply']],
689
+ ['customer', ['send email to', 'customer email', 'outbound email', 'notify users']],
690
+ ];
691
+
692
+ function sniffedProtectedLane(task) {
693
+ const haystack = [task.title, task.objective]
694
+ .map((v) => String(v || ''))
695
+ .join('\n')
696
+ .toLowerCase();
697
+ if (!haystack.trim()) return null;
698
+ for (const [lane, terms] of PROTECTED_LANE_TERMS) {
699
+ const term = terms.find((t) => haystack.includes(t));
700
+ if (term) return { lane, term };
701
+ }
702
+ return null;
703
+ }
704
+
510
705
  function evaluateAutoAccept(task, options = {}) {
511
706
  const {
512
707
  strictVerify = true,
@@ -539,12 +734,47 @@ function evaluateAutoAccept(task, options = {}) {
539
734
  tag === d || tag.split(/[^a-z0-9]+/).some((w) => w === d || w.replace(/s$/, '') === d));
540
735
  if (deniedTag) return { eligible: false, ref, reason: `denied_tag_${deniedTag}` };
541
736
 
737
+ // A protected lane declared in prose is not a protection, it is a note.
738
+ // WEB-410 said "PROTECTED LANE: auth/session minting ... never self-land"
739
+ // in its own title, carried tag 'endgame', and auto-landed anyway. Routing
740
+ // safety through a tag field that authors leave empty fails open, silently.
741
+ // So read the declaration where authors actually write it.
742
+ const declared = declaredProtectedLane(task);
743
+ if (declared) {
744
+ return {
745
+ eligible: false,
746
+ ref,
747
+ reason: 'declared_protected_lane',
748
+ declared_lane: declared.phrase,
749
+ next_action: 'this task declares a protected lane in its own text; a human accepts it with `atris task accept <ref> --as <human>`',
750
+ };
751
+ }
752
+
542
753
  // accept-all: the protected lanes above are the only human gate. No
543
754
  // certification, pass-count, reviewer, or proof-quality bar — but work
544
755
  // is never marked done against evidence it isn't: a proof naming an
545
756
  // unmerged draft PR still blocks, and a recorded check that FAILS still
546
757
  // blocks (absence of a check does not).
547
758
  if (acceptAll) {
759
+ // In this lane the tag IS the safety system — the only human gate. But
760
+ // most rows carry no tag at all (13 of 16 in the 2026-07-25 sweep), so
761
+ // refusing every untagged task would wedge the loop shut, which is the
762
+ // same outage as failing open, just quieter. Instead: when there is no
763
+ // tag to route on, read the task text for the denied lanes themselves.
764
+ // WEB-406 (Stripe checkout + paywall) landed untagged this way.
765
+ if (!tag) {
766
+ const sniffed = sniffedProtectedLane(task);
767
+ if (sniffed) {
768
+ return {
769
+ eligible: false,
770
+ ref,
771
+ reason: 'untagged_protected_lane_text',
772
+ declared_lane: sniffed.lane,
773
+ matched: sniffed.term,
774
+ next_action: `no tag to route on and the text reads as the ${sniffed.lane} lane; tag it or accept explicitly as a human`,
775
+ };
776
+ }
777
+ }
548
778
  const proof = latestProof(task);
549
779
  if (proofHasUnmergedPullRequestBoundary(proof)) {
550
780
  return unmergedPullRequestBoundaryResult(ref, proof);
@@ -570,8 +800,12 @@ function evaluateAutoAccept(task, options = {}) {
570
800
  // into "lands unchecked" the morning after it clears the worktree.
571
801
  // A check that merely isn't in the runnable allowlist counts as no
572
802
  // check at all.
803
+ // verify_unrunnable blocks for the same reason worktree_missing does:
804
+ // it used to arrive here as verify_failed, so leaving it out would turn
805
+ // "the harness could not run the check" into "landed unchecked".
573
806
  if ((tierRequiresStrictVerify && !verifyResult.ok)
574
807
  || verifyResult.reason === 'verify_failed'
808
+ || verifyResult.reason === 'verify_unrunnable'
575
809
  || verifyResult.reason === 'verify_worktree_missing') {
576
810
  return { eligible: false, ref, reason: verifyResult.reason, verify, ...verifyResult };
577
811
  }
@@ -579,6 +813,19 @@ function evaluateAutoAccept(task, options = {}) {
579
813
  if (trustTier === 'probation' && !reviewIntegrity.hasIndependentReview(task)) {
580
814
  return { eligible: false, ref, reason: 'probation_needs_review' };
581
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
+ }
582
829
  return {
583
830
  eligible: true,
584
831
  ref,
@@ -646,9 +893,18 @@ function evaluateAutoAccept(task, options = {}) {
646
893
  if (!verifyResult.ok) {
647
894
  return { eligible: false, ref, reason: verifyResult.reason, verify, ...verifyResult };
648
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 };
649
900
  }
650
901
  }
651
902
 
903
+ if (executeVerify) {
904
+ const hygieneBlock = hygieneBlockResult(task, ref);
905
+ if (hygieneBlock) return hygieneBlock;
906
+ }
907
+
652
908
  return {
653
909
  eligible: true,
654
910
  ref,
@@ -663,12 +919,14 @@ function evaluateAutoAccept(task, options = {}) {
663
919
 
664
920
  module.exports = {
665
921
  AGENT_CERTIFICATION_REVIEW_PASSES,
666
- AUTO_ACCEPT_HIGH_CONFIDENCE_PASSES,
667
922
  DENIED_TAGS,
923
+ declaredProtectedLane,
924
+ sniffedProtectedLane,
668
925
  evaluateAutoAccept,
669
926
  isAutoCertifyVerifyCommandAllowed,
670
927
  isAgentCertified,
671
928
  parseVerifyCommand,
929
+ repoHygieneGate,
672
930
  runVerifyCommand,
673
931
  runVerifyCommandCached,
674
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,22 +714,25 @@ 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,
662
724
  composeLiveUpdate,
663
725
  cronInstalled,
664
- cronMarker,
665
726
  dueForAlarm,
666
727
  digestLine,
667
728
  dejargon,
668
729
  explainResult,
730
+ heartbeatIsLive,
731
+ heartbeatLiveness,
732
+ heartbeatStatusText,
669
733
  installCron,
670
734
  liveAcceptAuthorization,
671
735
  markAlerted,
672
- missionDigestLine,
673
736
  operatorReady,
674
737
  hasAgentJargon,
675
738
  voicePatterns: Object.freeze({
@@ -685,8 +748,8 @@ module.exports = {
685
748
  sendImessage,
686
749
  statePath,
687
750
  uninstallCron,
688
- waitingHours,
689
751
  waitingOnHuman,
752
+ whenAutolandLands,
690
753
  writePolicy,
691
754
  writeState,
692
755
  };