atris 3.37.0 → 3.38.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 (139) hide show
  1. package/README.md +2 -0
  2. package/atris/PERSONA.md +3 -7
  3. package/atris.md +10 -2
  4. package/ax +158 -33
  5. package/bin/atris.js +49 -2
  6. package/commands/activate.js +16 -3
  7. package/commands/agent-spawn.js +0 -7
  8. package/commands/autoland.js +92 -14
  9. package/commands/autopilot-front.js +0 -4
  10. package/commands/autopilot.js +0 -12
  11. package/commands/brain.js +0 -7
  12. package/commands/brief.js +0 -2
  13. package/commands/business-sync.js +0 -1
  14. package/commands/business.js +107 -12
  15. package/commands/clean.js +31 -2
  16. package/commands/close.js +46 -11
  17. package/commands/cloud.js +0 -1
  18. package/commands/codex-goal.js +0 -2
  19. package/commands/computer.js +6 -5
  20. package/commands/console.js +46 -9
  21. package/commands/deck.js +29 -8
  22. package/commands/dream.js +0 -7
  23. package/commands/drill.js +0 -1
  24. package/commands/engine.js +10 -18
  25. package/commands/experiments.js +0 -4
  26. package/commands/fleet-report.js +12 -4
  27. package/commands/game.js +261 -0
  28. package/commands/gm.js +10 -2
  29. package/commands/goal.js +0 -6
  30. package/commands/harvest.js +0 -1
  31. package/commands/improve.js +29 -8
  32. package/commands/init.js +18 -34
  33. package/commands/integrations.js +0 -7
  34. package/commands/land.js +71 -8
  35. package/commands/launchpad.js +0 -2
  36. package/commands/lesson.js +17 -7
  37. package/commands/loop-front.js +0 -2
  38. package/commands/loops.js +6 -5
  39. package/commands/meet.js +0 -2
  40. package/commands/member.js +68 -3
  41. package/commands/mission.js +359 -102
  42. package/commands/next.js +0 -1
  43. package/commands/now.js +0 -12
  44. package/commands/one-lap.js +8 -8
  45. package/commands/orb.js +0 -4
  46. package/commands/pack-craft.js +0 -1
  47. package/commands/pack.js +670 -77
  48. package/commands/play.js +0 -2
  49. package/commands/probe.js +10 -4
  50. package/commands/pulse.js +73 -48
  51. package/commands/push.js +0 -4
  52. package/commands/radar.js +14 -11
  53. package/commands/recover.js +340 -0
  54. package/commands/report.js +0 -1
  55. package/commands/router.js +319 -0
  56. package/commands/run-front.js +0 -2
  57. package/commands/search.js +0 -3
  58. package/commands/skill.js +7 -2
  59. package/commands/slop.js +225 -7
  60. package/commands/status.js +6 -0
  61. package/commands/stream.js +0 -2
  62. package/commands/study.js +0 -3
  63. package/commands/sync-checkout.js +51 -0
  64. package/commands/sync.js +11 -40
  65. package/commands/task.js +125 -39
  66. package/commands/teach.js +86 -0
  67. package/commands/unknowns.js +0 -10
  68. package/commands/update.js +0 -1
  69. package/commands/visualize.js +0 -5
  70. package/commands/watch.js +0 -6
  71. package/commands/wiki.js +41 -11
  72. package/commands/wish.js +0 -1
  73. package/commands/worktree.js +87 -14
  74. package/commands/xp.js +1 -6
  75. package/commands/youtube.js +0 -2
  76. package/lib/accept-verify-gate.js +122 -0
  77. package/lib/activity-stream.js +0 -2
  78. package/lib/auto-accept-certified.js +145 -2
  79. package/lib/autoland.js +0 -3
  80. package/lib/ax-auto-lane.js +94 -10
  81. package/lib/bench/context.js +0 -2
  82. package/lib/bench/report.js +0 -1
  83. package/lib/bench/runner.js +0 -11
  84. package/lib/brief-ledger.js +0 -6
  85. package/lib/business-simulate.js +382 -0
  86. package/lib/chat-log-scan.js +0 -5
  87. package/lib/checkout-sync.js +52 -0
  88. package/lib/claude-boot-block.js +48 -0
  89. package/lib/cloud-mission.js +0 -6
  90. package/lib/codex-flight.js +0 -1
  91. package/lib/conductor-artifacts.js +25 -0
  92. package/lib/context-gatherer.js +0 -1
  93. package/lib/deck-compose.js +0 -1
  94. package/lib/deck-history.js +0 -1
  95. package/lib/deck-layout.js +0 -5
  96. package/lib/deck-review.js +0 -7
  97. package/lib/deck-schema.js +0 -1
  98. package/lib/default-verifier.js +0 -2
  99. package/lib/engine-registry.js +16 -8
  100. package/lib/experiments/daily.js +0 -6
  101. package/lib/fleet.js +210 -27
  102. package/lib/functional-owner.js +0 -3
  103. package/lib/inspect-fields.js +0 -1
  104. package/lib/known-commands.js +2 -2
  105. package/lib/learnings.js +0 -2
  106. package/lib/loop-doctor.js +11 -17
  107. package/lib/manifest.js +0 -2
  108. package/lib/member-alive.js +0 -1
  109. package/lib/member-switches.js +0 -5
  110. package/lib/mission-artifact.js +0 -1
  111. package/lib/mission-protected-lane.js +293 -0
  112. package/lib/mission-room.js +0 -2
  113. package/lib/mission-runtime-loop.js +0 -1
  114. package/lib/next-moves.js +0 -18
  115. package/lib/official-cli-integration.js +0 -4
  116. package/lib/orb-context.js +6 -3
  117. package/lib/orb-scorecard.js +0 -1
  118. package/lib/outbound-send-gate.js +0 -6
  119. package/lib/permission-grants.js +0 -6
  120. package/lib/policy-lessons.js +0 -2
  121. package/lib/pulse.js +68 -15
  122. package/lib/review-integrity.js +0 -2
  123. package/lib/router-brain.js +0 -2
  124. package/lib/runner-command.js +0 -4
  125. package/lib/runtime-bootstrap.js +0 -1
  126. package/lib/scorecard.js +0 -1
  127. package/lib/self-drive.js +1 -1
  128. package/lib/state-detection.js +0 -6
  129. package/lib/task-db.js +0 -4
  130. package/lib/team-presence.js +0 -1
  131. package/lib/voice-gate.js +63 -2
  132. package/lib/wiki.js +152 -9
  133. package/lib/wish-audit.js +20 -6
  134. package/lib/wish-delegate.js +26 -20
  135. package/lib/wish-stats.js +0 -5
  136. package/lib/wish-store.js +0 -1
  137. package/lib/workspace-safety.js +0 -2
  138. package/lib/zip.js +0 -1
  139. package/package.json +1 -1
@@ -4,6 +4,7 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { spawnSync } = require('child_process');
6
6
  const { stampLatestOpenBriefForWorktree } = require('../lib/brief-ledger');
7
+ const { isConductorArtifact } = require('../lib/conductor-artifacts');
7
8
 
8
9
  const REGEN_ADAPTER_FILES = ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md'];
9
10
  const COMMAND_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
@@ -261,11 +262,16 @@ function baseBranchName(ref) {
261
262
  return String(ref || '').replace(/^refs\/heads\//, '').replace(/^origin\//, '');
262
263
  }
263
264
 
264
- function statusCounts(root, { ignoredUnstagedFiles = new Set(), ignoredUntrackedFiles = new Set() } = {}) {
265
+ function statusCounts(root, {
266
+ ignoredUnstagedFiles = new Set(),
267
+ ignoredUntrackedFiles = new Set(),
268
+ ignoreUntracked = null,
269
+ } = {}) {
265
270
  if (!fs.existsSync(root)) return null;
266
271
  // -uall expands untracked directories to individual files so ignoredUntrackedFiles
267
- // can match exact paths (plain porcelain collapses them to "?? dir/").
268
- const statusArgs = ignoredUntrackedFiles.size ? ['status', '--porcelain', '-uall'] : ['status', '--porcelain'];
272
+ // and ignoreUntracked can match exact paths (plain porcelain collapses them to "?? dir/").
273
+ const expandUntracked = ignoredUntrackedFiles.size || typeof ignoreUntracked === 'function';
274
+ const statusArgs = expandUntracked ? ['status', '--porcelain', '-uall'] : ['status', '--porcelain'];
269
275
  const result = runGit(statusArgs, { cwd: root, check: false });
270
276
  if (result.status !== 0) return null;
271
277
  let staged = 0;
@@ -276,6 +282,7 @@ function statusCounts(root, { ignoredUnstagedFiles = new Set(), ignoredUntracked
276
282
  if (ignoredUnstagedFiles.has(file) && line[0] === ' ' && line[1] !== ' ') continue;
277
283
  if (line.startsWith('??')) {
278
284
  if (ignoredUntrackedFiles.has(file)) continue;
285
+ if (typeof ignoreUntracked === 'function' && ignoreUntracked(file)) continue;
279
286
  untracked += 1;
280
287
  continue;
281
288
  }
@@ -285,6 +292,16 @@ function statusCounts(root, { ignoredUnstagedFiles = new Set(), ignoredUntracked
285
292
  return { staged, unstaged, untracked };
286
293
  }
287
294
 
295
+ // Remove conductor plumbing from the index after a broad `git add -A`, so the commit
296
+ // records engine output only. Paths that were never staged are simply not listed.
297
+ function unstageConductorArtifacts(root) {
298
+ const staged = runGit(['diff', '--cached', '--name-only'], { cwd: root, check: false });
299
+ if (staged.status !== 0) return [];
300
+ const drop = String(staged.stdout || '').split(/\r?\n/).filter(Boolean).filter(isConductorArtifact);
301
+ if (drop.length) runGit(['reset', '-q', 'HEAD', '--', ...drop], { cwd: root, check: false });
302
+ return drop;
303
+ }
304
+
288
305
  function changedFiles(root, args) {
289
306
  const result = runGit([...args, '--', ...REGEN_ADAPTER_FILES], { cwd: root, check: false });
290
307
  if (result.status !== 0) return new Set();
@@ -472,6 +489,14 @@ function createOrFindPr(root, branch, targetRef, title, dryRun) {
472
489
  `Target: ${targetBranch}`,
473
490
  ].join('\n');
474
491
  if (dryRun) return `dry-run: gh pr create --base ${targetBranch} --head ${branch}`;
492
+ // An engine that produced nothing leaves a branch level with its base. Asking
493
+ // GitHub for a PR there fails with "No commits between ...", and that throw used
494
+ // to escape the flight and abort the whole run. Say it plainly instead.
495
+ const ahead = runGit(['rev-list', '--count', `${targetRef}..HEAD`], { cwd: root, check: false });
496
+ if (ahead.status === 0 && String(ahead.stdout || '').trim() === '0') {
497
+ console.log('pr: skipped (no commits ahead of base)');
498
+ return '';
499
+ }
475
500
  const created = spawnSync(
476
501
  'gh',
477
502
  ['pr', 'create', '--base', targetBranch, '--head', branch, '--title', title, '--body', body],
@@ -498,6 +523,36 @@ function shipHelp() {
498
523
  console.log(' recommended flight verify: npm run test:fast && node --test <focused files>');
499
524
  }
500
525
 
526
+ function deniedLaneForShip(root, branch, message) {
527
+ try {
528
+ const { DENIED_TAGS } = require('../lib/fleet');
529
+ const refs = new Set();
530
+ for (const source of [String(branch || ''), String(message || '')]) {
531
+ for (const m of source.toUpperCase().matchAll(/([A-Z]{2,6}-\d+)/g)) refs.add(m[1]);
532
+ for (const m of source.toLowerCase().matchAll(/([a-z]{2,6})-(\d+)/g)) refs.add(`${m[1].toUpperCase()}-${m[2]}`);
533
+ }
534
+ if (!refs.size) return null;
535
+ const projectionPath = path.join(findPrimaryRoot(root), '.atris', 'state', 'tasks.projection.json');
536
+ if (!fs.existsSync(projectionPath)) return null;
537
+ const projection = JSON.parse(fs.readFileSync(projectionPath, 'utf8'));
538
+ for (const task of projection.tasks || []) {
539
+ const ids = [task.display_id, task.legacy_ref].filter(Boolean).map(v => String(v).toUpperCase());
540
+ if (!ids.some(id => refs.has(id))) continue;
541
+ const tags = [task.tag, ...(task.metadata?.todo_tags || [])].filter(Boolean).map(t => String(t).toLowerCase());
542
+ const hit = tags.find(t => DENIED_TAGS.includes(t));
543
+ if (hit) return { taskId: ids[0], tag: hit };
544
+ }
545
+ return null;
546
+ } catch {
547
+ return null;
548
+ }
549
+ }
550
+
551
+ function findPrimaryRoot(root) {
552
+ const worktrees = listWorktrees(root);
553
+ return worktrees[0]?.path || root;
554
+ }
555
+
501
556
  function shipWorktree(args) {
502
557
  if (hasFlag(args, '--help') || hasFlag(args, '-h') || args[0] === 'help') {
503
558
  shipHelp();
@@ -531,6 +586,17 @@ function shipWorktree(args) {
531
586
  console.error('blocked: ship from an isolated worktree, not the primary checkout');
532
587
  return 2;
533
588
  }
589
+ // Denied lanes are checked at dispatch, but a flight that was staffed
590
+ // before a task entered a denied lane (or killed mid-flight) could still
591
+ // ship: a payments command self-landed twice this way on 2026-07-26. The
592
+ // ship gate is the last door, so it re-checks.
593
+ if (!hasFlag(args, '--allow-denied-lane')) {
594
+ const denied = deniedLaneForShip(root, branch, message);
595
+ if (denied) {
596
+ console.error(`blocked: this branch carries ${denied.taskId} which sits in the protected ${denied.tag} lane; a human lands it (or pass --allow-denied-lane on written order)`);
597
+ return 2;
598
+ }
599
+ }
534
600
 
535
601
  let oneLapProof = null;
536
602
  try {
@@ -552,7 +618,10 @@ function shipWorktree(args) {
552
618
  refreshRemoteRef(root, targetRef);
553
619
  const skippedAdapters = restoreRegeneratedAdapterChurn(root, message, { dryRun });
554
620
  const ignoredUnstagedFiles = dryRun ? new Set(skippedAdapters) : new Set();
555
- const counts = statusCounts(root, { ignoredUnstagedFiles }) || { staged: 0, unstaged: 0, untracked: 0 };
621
+ // Conductor plumbing must not make the checkout look dirty, or a flight where the
622
+ // engine wrote nothing commits the fleet's own prompt file and calls it work.
623
+ const counts = statusCounts(root, { ignoredUnstagedFiles, ignoreUntracked: isConductorArtifact })
624
+ || { staged: 0, unstaged: 0, untracked: 0 };
556
625
  const dirty = counts.staged || counts.unstaged || counts.untracked;
557
626
  if (dirty && !message) {
558
627
  console.error('blocked: --message is required when there are local changes to commit');
@@ -563,6 +632,9 @@ function shipWorktree(args) {
563
632
  console.log(`commit: ${message}`);
564
633
  if (!dryRun) {
565
634
  runGit(['add', '-A'], { cwd: root });
635
+ // `add -A` sweeps in the fleet's own prompt file and friends. Drop them from the
636
+ // index so a work commit carries work, not harness plumbing. Safe when absent.
637
+ unstageConductorArtifacts(root);
566
638
  if (!proofTreeMatches('index')) return 3;
567
639
  runGit(['commit', '-m', message], { cwd: root });
568
640
  if (!proofTreeMatches('head')) return 3;
@@ -578,7 +650,12 @@ function shipWorktree(args) {
578
650
  if (!dryRun) runCommand(verify, { cwd: root });
579
651
  const afterVerifyRows = dryRun ? [] : statusSnapshot(root);
580
652
  const verifyDelta = newStatusRows(beforeVerify, afterVerifyRows);
581
- const afterVerify = dryRun ? { staged: 0, unstaged: 0, untracked: 0 } : statusCounts(root) || { staged: 0, unstaged: 0, untracked: 0 };
653
+ // This check exists to catch a verifier that writes files. Conductor plumbing was
654
+ // already present before the verifier ran, so counting it here blocks a clean ship.
655
+ const afterVerify = dryRun
656
+ ? { staged: 0, unstaged: 0, untracked: 0 }
657
+ : statusCounts(root, { ignoreUntracked: isConductorArtifact })
658
+ || { staged: 0, unstaged: 0, untracked: 0 };
582
659
  if (!dryRun && (afterVerify.staged || afterVerify.unstaged || afterVerify.untracked)) {
583
660
  console.error(
584
661
  `blocked: verifier left checkout dirty staged=${afterVerify.staged} ` +
@@ -687,8 +764,10 @@ function guard(args) {
687
764
  console.error('run: atris worktree start --member <member>|--agent <name> --task "<short task>" --claim');
688
765
  return 2;
689
766
  }
690
- // The CLI's own worktree metadata (written by `worktree start`) must not count as dirt.
691
- const counts = statusCounts(root, { ignoredUntrackedFiles: new Set(['.atris/agent-worktree.json']) });
767
+ // Conductor plumbing (worktree metadata, the fleet prompt written for this very
768
+ // engine, runtime scratch, the brief ledger) must not count as dirt — otherwise the
769
+ // harness blocks the engine with the harness's own files.
770
+ const counts = statusCounts(root, { ignoreUntracked: isConductorArtifact });
692
771
  if (counts && (counts.staged || counts.unstaged || counts.untracked) && !hasFlag(args, '--allow-dirty')) {
693
772
  console.error(`blocked: checkout is dirty staged=${counts.staged} unstaged=${counts.unstaged} untracked=${counts.untracked}`);
694
773
  return 3;
@@ -880,20 +959,14 @@ function worktreeCommand(args = []) {
880
959
 
881
960
  module.exports = {
882
961
  branchName,
883
- statusSnapshot,
884
- newStatusRows,
885
962
  createAgentWorktree,
963
+ deniedLaneForShip,
886
964
  createOrFindPr,
887
965
  cleanupWorktrees,
888
- defaultShipTarget,
889
966
  defaultStartBase,
890
- defaultWorktreePath,
891
967
  listWorktrees,
892
968
  parseWorktrees,
893
- REGEN_ADAPTER_FILES,
894
969
  normalizeTargetRef,
895
- restoreRegeneratedAdapterChurn,
896
- prMergeRef,
897
970
  slugify,
898
971
  statusCounts,
899
972
  swarloClaim,
package/commands/xp.js CHANGED
@@ -2353,6 +2353,7 @@ async function xpCommand(...args) {
2353
2353
  });
2354
2354
  if (!result.ok) {
2355
2355
  console.error(`Failed to load XP graph: ${result.error || result.status}`);
2356
+ console.error('Run: atris xp --local to render your AgentXP from proof receipts in this workspace.');
2356
2357
  process.exit(1);
2357
2358
  }
2358
2359
 
@@ -2366,14 +2367,8 @@ async function xpCommand(...args) {
2366
2367
 
2367
2368
  module.exports = {
2368
2369
  xpCommand,
2369
- buildContributionRows,
2370
2370
  buildCareerXpProjection,
2371
- buildAllCareerXpProjection,
2372
- buildCareerXpSessionCapsule,
2373
- collectAllLocalXpProjection,
2374
2371
  collectLocalXpProjection,
2375
- buildAgentXpSyncPacket,
2376
2372
  syncAgentXp,
2377
- receiptFromTaskEpisode,
2378
2373
  render,
2379
2374
  };
@@ -452,13 +452,11 @@ async function youtubeCommand(argv = process.argv.slice(3), deps = {}) {
452
452
  module.exports = {
453
453
  DEFAULT_QUERY,
454
454
  DEFAULT_TIMEOUT_MS,
455
- showYoutubeHelp,
456
455
  parseYoutubeArgs,
457
456
  buildYoutubePayload,
458
457
  extractLocalTranscript,
459
458
  processYoutube,
460
459
  shouldRetryWithLocalTranscript,
461
460
  formatYoutubeResult,
462
- formatTimestampFromMs,
463
461
  youtubeCommand,
464
462
  };
@@ -0,0 +1,122 @@
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
+ return {
105
+ ok: false,
106
+ reason: result.reason || 'verify_failed',
107
+ detail: result.reason === 'verify_workdir_missing' || result.reason === 'verify_worktree_missing'
108
+ ? 'the stored verify command points at a directory that no longer exists'
109
+ : `the stored verify command did not pass (${result.reason || 'nonzero exit'})`,
110
+ command,
111
+ ran: true,
112
+ exit_code: typeof result.status === 'number' ? result.status : null,
113
+ };
114
+ }
115
+
116
+ return { ok: true, reason: 'verify_passed', detail: '', command, ran: true, exit_code: 0 };
117
+ }
118
+
119
+ module.exports = {
120
+ storedVerifyCommand,
121
+ evaluateAcceptVerify,
122
+ };
@@ -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
  };
@@ -458,11 +458,29 @@ function runVerifyCommand(verify, workspaceRoot) {
458
458
  if (!pythonPathCheck.ok) return pythonPathCheck;
459
459
  const result = spawnSync(parsed.argv[0], parsed.argv.slice(1), {
460
460
  cwd: cwdCheck.cwd,
461
- env: parsed.env ? { ...process.env, ...parsed.env } : process.env,
461
+ env: verifyCommandEnv(parsed.env),
462
462
  shell: false,
463
463
  encoding: 'utf8',
464
464
  timeout: 120000,
465
465
  });
466
+ // "I could not run the check" is not "the check failed". Collapsing the two
467
+ // let a broken harness read as broken code: under cron's PATH every
468
+ // `npm run ...` verifier returned ENOENT, mapped to verify_failed, and 16
469
+ // landable tasks were skipped silently every hour for 20 days. A verifier
470
+ // the harness cannot execute must be loud and must never look like a
471
+ // verdict on the work.
472
+ const unrunnable = unrunnableVerifyReason(result);
473
+ if (unrunnable) {
474
+ return {
475
+ ok: false,
476
+ reason: 'verify_unrunnable',
477
+ unrunnable_cause: unrunnable,
478
+ alarm: true,
479
+ command: parsed.argv.join(' '),
480
+ status: result.status,
481
+ stderr: String(result.stderr || '').slice(0, 400),
482
+ };
483
+ }
466
484
  return {
467
485
  ok: result.status === 0,
468
486
  reason: result.status === 0 ? 'verify_passed' : 'verify_failed',
@@ -471,6 +489,36 @@ function runVerifyCommand(verify, workspaceRoot) {
471
489
  };
472
490
  }
473
491
 
492
+ // Verifiers run with shell:false, so the child gets no PATH resolution beyond
493
+ // whatever the parent inherited. Cron has no Homebrew PATH — that is why every
494
+ // cron entry hardcodes an absolute node path. `npm`, `npx` and friends live in
495
+ // the same bin directory as the node binary currently executing, so seeding
496
+ // PATH with it makes recorded checks runnable from cron with zero config: no
497
+ // crontab PATH line to keep in sync, no new env var.
498
+ function verifyCommandEnv(extraEnv) {
499
+ const base = extraEnv ? { ...process.env, ...extraEnv } : { ...process.env };
500
+ const binDir = path.dirname(process.execPath);
501
+ const current = String(base.PATH || '');
502
+ const alreadyPresent = current.split(path.delimiter).includes(binDir);
503
+ if (binDir && !alreadyPresent) {
504
+ base.PATH = current ? `${binDir}${path.delimiter}${current}` : binDir;
505
+ }
506
+ return base;
507
+ }
508
+
509
+ // ENOENT: the binary is not on PATH at all. ETIMEDOUT: the 120s ceiling cut it
510
+ // off before it could reach a verdict. Neither is evidence about the diff.
511
+ function unrunnableVerifyReason(result) {
512
+ const code = result && result.error ? result.error.code : null;
513
+ if (code === 'ENOENT') return 'command_not_found';
514
+ if (code === 'ETIMEDOUT' || result.signal === 'SIGTERM') return 'verify_timed_out';
515
+ if (code) return String(code).toLowerCase();
516
+ // spawnSync reports a null status with no error object when the child was
517
+ // killed outright; treat that as unrunnable rather than a silent failure.
518
+ if (result && result.status === null) return 'no_exit_status';
519
+ return null;
520
+ }
521
+
474
522
  function runVerifyCommandCached(verify, workspaceRoot, cache = null) {
475
523
  if (!(cache instanceof Map)) return runVerifyCommand(verify, workspaceRoot);
476
524
  const key = `${path.resolve(workspaceRoot || process.cwd())}\u0000${String(verify || '').trim()}`;
@@ -507,6 +555,61 @@ function receiptEvidenceBlock(task, ref) {
507
555
  return null;
508
556
  }
509
557
 
558
+ // Authors declare protected lanes in the task text, not the tag field. Match
559
+ // only explicit, deliberate declarations — an unanchored "security" would
560
+ // snag every task that merely mentions the word and wedge the loop shut,
561
+ // which is its own failure mode. Kept narrow on purpose.
562
+ const PROTECTED_LANE_PHRASES = [
563
+ 'protected lane',
564
+ 'never self-land',
565
+ 'never self land',
566
+ 'do not self-land',
567
+ 'human accepts',
568
+ 'human must accept',
569
+ 'human review required',
570
+ 'orchestrator reviews pre-land',
571
+ 'reviews pre-land',
572
+ ];
573
+
574
+ function declaredProtectedLane(task) {
575
+ const haystack = [
576
+ task.title,
577
+ task.objective,
578
+ task.metadata && task.metadata.protected_lane_note,
579
+ ]
580
+ .map((v) => String(v || ''))
581
+ .join('\n')
582
+ .toLowerCase();
583
+ if (!haystack.trim()) return null;
584
+ const phrase = PROTECTED_LANE_PHRASES.find((p) => haystack.includes(p));
585
+ return phrase ? { phrase } : null;
586
+ }
587
+
588
+ // Only consulted when a task carries no tag at all, so this never overrides an
589
+ // author who did classify their work. Terms are concrete nouns from the denied
590
+ // lanes — things you can only be touching on purpose. Deliberately excludes
591
+ // broad words like "auth", "user", or "api" that appear in ordinary frontend
592
+ // work and would hold the whole queue.
593
+ const PROTECTED_LANE_TERMS = [
594
+ ['billing', ['stripe', 'checkout url', 'checkout_url', 'invoice', 'paywall', 'subscription', 'wallet', 'credit card', 'pricing tier']],
595
+ ['security', ['session minting', 'mints a', 'service token', 'api key', 'credential', 'secret key', 'csrf', 'access token']],
596
+ ['deploy', ['render deploy', 'production deploy', 'deploy to prod', 'migration apply']],
597
+ ['customer', ['send email to', 'customer email', 'outbound email', 'notify users']],
598
+ ];
599
+
600
+ function sniffedProtectedLane(task) {
601
+ const haystack = [task.title, task.objective]
602
+ .map((v) => String(v || ''))
603
+ .join('\n')
604
+ .toLowerCase();
605
+ if (!haystack.trim()) return null;
606
+ for (const [lane, terms] of PROTECTED_LANE_TERMS) {
607
+ const term = terms.find((t) => haystack.includes(t));
608
+ if (term) return { lane, term };
609
+ }
610
+ return null;
611
+ }
612
+
510
613
  function evaluateAutoAccept(task, options = {}) {
511
614
  const {
512
615
  strictVerify = true,
@@ -539,12 +642,47 @@ function evaluateAutoAccept(task, options = {}) {
539
642
  tag === d || tag.split(/[^a-z0-9]+/).some((w) => w === d || w.replace(/s$/, '') === d));
540
643
  if (deniedTag) return { eligible: false, ref, reason: `denied_tag_${deniedTag}` };
541
644
 
645
+ // A protected lane declared in prose is not a protection, it is a note.
646
+ // WEB-410 said "PROTECTED LANE: auth/session minting ... never self-land"
647
+ // in its own title, carried tag 'endgame', and auto-landed anyway. Routing
648
+ // safety through a tag field that authors leave empty fails open, silently.
649
+ // So read the declaration where authors actually write it.
650
+ const declared = declaredProtectedLane(task);
651
+ if (declared) {
652
+ return {
653
+ eligible: false,
654
+ ref,
655
+ reason: 'declared_protected_lane',
656
+ declared_lane: declared.phrase,
657
+ next_action: 'this task declares a protected lane in its own text; a human accepts it with `atris task accept <ref> --as <human>`',
658
+ };
659
+ }
660
+
542
661
  // accept-all: the protected lanes above are the only human gate. No
543
662
  // certification, pass-count, reviewer, or proof-quality bar — but work
544
663
  // is never marked done against evidence it isn't: a proof naming an
545
664
  // unmerged draft PR still blocks, and a recorded check that FAILS still
546
665
  // blocks (absence of a check does not).
547
666
  if (acceptAll) {
667
+ // In this lane the tag IS the safety system — the only human gate. But
668
+ // most rows carry no tag at all (13 of 16 in the 2026-07-25 sweep), so
669
+ // refusing every untagged task would wedge the loop shut, which is the
670
+ // same outage as failing open, just quieter. Instead: when there is no
671
+ // tag to route on, read the task text for the denied lanes themselves.
672
+ // WEB-406 (Stripe checkout + paywall) landed untagged this way.
673
+ if (!tag) {
674
+ const sniffed = sniffedProtectedLane(task);
675
+ if (sniffed) {
676
+ return {
677
+ eligible: false,
678
+ ref,
679
+ reason: 'untagged_protected_lane_text',
680
+ declared_lane: sniffed.lane,
681
+ matched: sniffed.term,
682
+ next_action: `no tag to route on and the text reads as the ${sniffed.lane} lane; tag it or accept explicitly as a human`,
683
+ };
684
+ }
685
+ }
548
686
  const proof = latestProof(task);
549
687
  if (proofHasUnmergedPullRequestBoundary(proof)) {
550
688
  return unmergedPullRequestBoundaryResult(ref, proof);
@@ -570,8 +708,12 @@ function evaluateAutoAccept(task, options = {}) {
570
708
  // into "lands unchecked" the morning after it clears the worktree.
571
709
  // A check that merely isn't in the runnable allowlist counts as no
572
710
  // check at all.
711
+ // verify_unrunnable blocks for the same reason worktree_missing does:
712
+ // it used to arrive here as verify_failed, so leaving it out would turn
713
+ // "the harness could not run the check" into "landed unchecked".
573
714
  if ((tierRequiresStrictVerify && !verifyResult.ok)
574
715
  || verifyResult.reason === 'verify_failed'
716
+ || verifyResult.reason === 'verify_unrunnable'
575
717
  || verifyResult.reason === 'verify_worktree_missing') {
576
718
  return { eligible: false, ref, reason: verifyResult.reason, verify, ...verifyResult };
577
719
  }
@@ -663,8 +805,9 @@ function evaluateAutoAccept(task, options = {}) {
663
805
 
664
806
  module.exports = {
665
807
  AGENT_CERTIFICATION_REVIEW_PASSES,
666
- AUTO_ACCEPT_HIGH_CONFIDENCE_PASSES,
667
808
  DENIED_TAGS,
809
+ declaredProtectedLane,
810
+ sniffedProtectedLane,
668
811
  evaluateAutoAccept,
669
812
  isAutoCertifyVerifyCommandAllowed,
670
813
  isAgentCertified,
package/lib/autoland.js CHANGED
@@ -661,7 +661,6 @@ module.exports = {
661
661
  composeDigest,
662
662
  composeLiveUpdate,
663
663
  cronInstalled,
664
- cronMarker,
665
664
  dueForAlarm,
666
665
  digestLine,
667
666
  dejargon,
@@ -669,7 +668,6 @@ module.exports = {
669
668
  installCron,
670
669
  liveAcceptAuthorization,
671
670
  markAlerted,
672
- missionDigestLine,
673
671
  operatorReady,
674
672
  hasAgentJargon,
675
673
  voicePatterns: Object.freeze({
@@ -685,7 +683,6 @@ module.exports = {
685
683
  sendImessage,
686
684
  statePath,
687
685
  uninstallCron,
688
- waitingHours,
689
686
  waitingOnHuman,
690
687
  writePolicy,
691
688
  writeState,