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
@@ -320,12 +320,10 @@ function syncLessonsMd(root, mined) {
320
320
  }
321
321
 
322
322
  module.exports = {
323
- AGENT_ACTOR_PATTERN,
324
323
  VERIFY_COMMAND_PATTERN,
325
324
  loadHistory,
326
325
  mineProofPolicy,
327
326
  policyHintsForProof,
328
- policyLessonsPath,
329
327
  readPolicyLessons,
330
328
  writePolicyLessons,
331
329
  syncLessonsMd,
package/lib/pulse.js CHANGED
@@ -288,8 +288,16 @@ function shouldFallbackToAutopilot({ missionReason, autopilotFallback = true, no
288
288
  // actual delta (new commit or newly-dirtied files), never the whole dirty tree —
289
289
  // crediting pre-existing dirt re-rewards the same change every tick (the reward
290
290
  // inflation bug). The caller computes producedWork from a before/after snapshot.
291
- function scoreTick({ verifyPassed, producedWork } = {}) {
291
+ //
292
+ // A crashed engine must also score -1. Before this, a hard `mission error` tick
293
+ // produced no work and ran no verifier, so it scored 0 — indistinguishable from
294
+ // a healthy idle tick. The 2026-07-16 heartbeat death proved the cost: 25
295
+ // consecutive error ticks left reward_sum flat, nothing escalated, and the loop
296
+ // stopped with a ledger that still read green. `actorReason: 'error'` is a
297
+ // failure, not a rest.
298
+ function scoreTick({ verifyPassed, producedWork, actorOk, actorReason } = {}) {
292
299
  if (verifyPassed === false) return -1;
300
+ if (actorOk === false && actorReason === 'error') return -1;
293
301
  if (!producedWork) return 0;
294
302
  return verifyPassed === true ? 1 : 0;
295
303
  }
@@ -575,7 +583,7 @@ function releaseLock(root) {
575
583
  } catch {}
576
584
  }
577
585
 
578
- // --- cron tick script + crontab line (pure string generation) ---
586
+ // --- scheduled tick script + launchd plist (pure string generation) ---
579
587
 
580
588
  // Minimal shell wrapper modeled on the proven commander tick.sh: deadline
581
589
  // self-removal, then hand off to `atris pulse tick` (all real logic lives in JS,
@@ -593,6 +601,8 @@ function buildTickScript(opts = {}) {
593
601
  runnerCommandTemplate = '',
594
602
  verifyCmd = 'npm test',
595
603
  pathDirs = [],
604
+ launchLabel = '',
605
+ launchPlistPath = '',
596
606
  } = opts;
597
607
  if (!root) throw new Error('buildTickScript: root is required');
598
608
  if (!stateHome) throw new Error('buildTickScript: stateHome is required');
@@ -636,16 +646,26 @@ STATE="${stateHome}"
636
646
  LOG_DIR="$STATE/logs"
637
647
  DEADLINE_EPOCH="${deadlineEpoch}"
638
648
  MARKER="${marker}"
649
+ LAUNCH_LABEL="${launchLabel}"
650
+ LAUNCH_PLIST="${launchPlistPath}"
651
+
652
+ # Claude Max subscription auth comes from the macOS keychain. An API key would
653
+ # override it and route autonomous work onto metered API billing.
654
+ unset ANTHROPIC_API_KEY
639
655
 
640
- # Cron has a minimal PATH; restore the dirs the engine's bare-name spawns need.
656
+ # launchd has a minimal PATH; restore the dirs the engine's bare-name spawns need.
641
657
  ${pathExport}
642
658
 
643
659
  mkdir -p "$LOG_DIR"
644
660
 
645
661
  now="$(date +%s)"
646
662
  if [ "$now" -ge "$DEADLINE_EPOCH" ]; then
663
+ if [ -n "$LAUNCH_LABEL" ]; then
664
+ launchctl bootout "gui/$(id -u)/$LAUNCH_LABEL" 2>/dev/null || true
665
+ [ -n "$LAUNCH_PLIST" ] && rm -f "$LAUNCH_PLIST"
666
+ fi
647
667
  crontab -l 2>/dev/null | grep -v "$MARKER" | crontab - 2>/dev/null || true
648
- echo "$(date -Iseconds) pulse expired; removed cron" >> "$LOG_DIR/control.log"
668
+ echo "$(date -Iseconds) pulse expired; removed scheduler" >> "$LOG_DIR/control.log"
649
669
  expired_at="$(date -Iseconds)"
650
670
  journal_year="$(date +%Y)"
651
671
  journal_day="$(date +%F)"
@@ -693,6 +713,48 @@ fi
693
713
  `;
694
714
  }
695
715
 
716
+ function xmlEscape(value) {
717
+ return String(value)
718
+ .replace(/&/g, '&')
719
+ .replace(/</g, '&lt;')
720
+ .replace(/>/g, '&gt;')
721
+ .replace(/"/g, '&quot;')
722
+ .replace(/'/g, '&apos;');
723
+ }
724
+
725
+ function launchIntervalFromCadence({ cadenceMinutes, cron } = {}) {
726
+ const minutes = Number(cadenceMinutes);
727
+ if (Number.isInteger(minutes) && minutes > 0) return { seconds: minutes * 60, fallback: false };
728
+ const normalized = normalizeCronCadence(cron || DEFAULT_CADENCE_CRON);
729
+ const everyMinutes = normalized.match(/^\*\/(\d+) \* \* \* \*$/);
730
+ if (everyMinutes) return { seconds: Number(everyMinutes[1]) * 60, fallback: false };
731
+ if (/^\d+ \* \* \* \*$/.test(normalized)) return { seconds: 3600, fallback: false };
732
+ return { seconds: 3600, fallback: true };
733
+ }
734
+
735
+ function buildLaunchAgentPlist(opts = {}) {
736
+ const { label, scriptPath, stateHome } = opts;
737
+ if (!label) throw new Error('buildLaunchAgentPlist: label is required');
738
+ if (!scriptPath) throw new Error('buildLaunchAgentPlist: scriptPath is required');
739
+ if (!stateHome) throw new Error('buildLaunchAgentPlist: stateHome is required');
740
+ const interval = launchIntervalFromCadence(opts).seconds;
741
+ const logPath = path.join(stateHome, 'logs', 'launchd.log');
742
+ return `<?xml version="1.0" encoding="UTF-8"?>
743
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
744
+ <plist version="1.0">
745
+ <dict>
746
+ <key>Label</key><string>${xmlEscape(label)}</string>
747
+ <key>ProgramArguments</key>
748
+ <array><string>/bin/bash</string><string>${xmlEscape(scriptPath)}</string></array>
749
+ <key>StartInterval</key><integer>${interval}</integer>
750
+ <key>AbandonProcessGroup</key><true/>
751
+ <key>StandardOutPath</key><string>${xmlEscape(logPath)}</string>
752
+ <key>StandardErrorPath</key><string>${xmlEscape(logPath)}</string>
753
+ </dict>
754
+ </plist>
755
+ `;
756
+ }
757
+
696
758
  function buildCrontabLine(opts = {}) {
697
759
  const { cron = DEFAULT_CADENCE_CRON, scriptPath, marker = PULSE_MARKER } = opts;
698
760
  if (!scriptPath) throw new Error('buildCrontabLine: scriptPath is required');
@@ -703,28 +765,17 @@ module.exports = {
703
765
  PULSE_RECEIPT_SCHEMA,
704
766
  SCORECARD_SCHEMA,
705
767
  PULSE_MARKER,
706
- LEGACY_STATE_DIRNAME,
707
768
  DEFAULT_CADENCE_CRON,
708
769
  STALE_TICK_MS,
709
- LIVENESS_STALE_MS,
710
770
  stateDir,
711
- pulseRepoSlug,
712
- pulseStateHome,
713
- pulseMarker,
714
771
  legacyPulseStateHome,
715
- readTickScriptRoot,
716
- legacyPulseStateMatchesRoot,
717
772
  resolvePulseSlot,
718
773
  pulseReceiptsPath,
719
774
  scorecardsPath,
720
- pulseCounterPath,
721
- pulseLockDir,
722
775
  buildPulseReceipt,
723
776
  buildPulseScorecardRow,
724
- buildOrbFailureScorecardRow,
725
777
  buildInterruptedPulseReceipt,
726
778
  ingestOrbScorecard,
727
- orbRunKey,
728
779
  scoreTick,
729
780
  fsSnapshot,
730
781
  diffFsSnapshots,
@@ -744,6 +795,8 @@ module.exports = {
744
795
  acquireLock,
745
796
  releaseLock,
746
797
  buildTickScript,
798
+ buildLaunchAgentPlist,
799
+ launchIntervalFromCadence,
747
800
  buildCrontabLine,
748
801
  normalizeCronCadence,
749
802
  };
@@ -133,9 +133,7 @@ function validateActor(actor, { root } = {}) {
133
133
  }
134
134
 
135
135
  module.exports = {
136
- RESERVED_ACTORS,
137
136
  normalizeActor,
138
- envActor,
139
137
  taskBuilder,
140
138
  reviewEventActors,
141
139
  independentReviewActors,
@@ -6,6 +6,8 @@ const path = require('node:path');
6
6
  const DEFAULT_MIN_RECEIPTS = 3;
7
7
  const DEFAULT_HALF_LIFE_MS = 30 * 24 * 60 * 60 * 1000;
8
8
  const DURATION_SCALE_MS = 60 * 1000;
9
+ const STRETCH_ZONE_MIN = 0.6;
10
+ const STRETCH_ZONE_MAX = 0.85;
9
11
 
10
12
  function objectValue(value) {
11
13
  return value && typeof value === 'object' ? value : null;
@@ -313,15 +315,56 @@ function rankEnginesDetailed(candidates, options = {}) {
313
315
  || fallbackOrder(left.candidate, left.index) - fallbackOrder(right.candidate, right.index)
314
316
  || candidateId(left.candidate).localeCompare(candidateId(right.candidate)))
315
317
  .map((entry) => entry.candidate);
318
+
319
+ const stretchPick = stretchZonePick(ranked.map((candidate) => ({
320
+ candidate,
321
+ predicted: statsByEngine.get(candidateId(candidate))?.verified_pass_rate ?? null,
322
+ })), options);
323
+ const finalRanked = stretchPick
324
+ ? [stretchPick, ...ranked.filter((candidate) => candidate !== stretchPick)]
325
+ : ranked;
316
326
  return {
317
- candidates: ranked,
327
+ candidates: finalRanked,
318
328
  stats,
319
329
  task_type: taskType,
320
330
  used_track_record: true,
321
331
  thin_engines: [],
332
+ stretch_zone_pick: stretchPick ? candidateId(stretchPick) : null,
322
333
  };
323
334
  }
324
335
 
336
+ function engineCost(candidate) {
337
+ const value = Number(candidate && typeof candidate === 'object' ? candidate.cost : NaN);
338
+ return Number.isFinite(value) && value >= 0 ? value : Number.POSITIVE_INFINITY;
339
+ }
340
+
341
+ // stretch zone rule for low-stakes lanes: land the cheapest engine whose
342
+ // predicted success rate sits in the learnable band. entries are
343
+ // { candidate, predicted } rows where predicted is a 0..1 rate or null.
344
+ // returns the winning candidate, or null when nobody is in the band; the
345
+ // caller then keeps the normal score order, whose top is the strongest
346
+ // engine, which is the escalation path when every engine sits below the
347
+ // band floor.
348
+ function stretchZonePick(entries, options = {}) {
349
+ const lowStakes = options.lowStakes === true || normalizedLabel(options.stakes) === 'low';
350
+ if (!lowStakes) return null;
351
+ const rows = (Array.isArray(entries) ? entries : [])
352
+ .map((entry, index) => ({
353
+ candidate: entry && typeof entry === 'object' && 'candidate' in entry ? entry.candidate : entry,
354
+ predicted: entry && typeof entry === 'object' ? finiteNonnegative(entry.predicted) : null,
355
+ index,
356
+ }))
357
+ .filter((row) => row.candidate);
358
+ const inBand = rows
359
+ .filter((row) => row.predicted !== null && row.predicted <= 1
360
+ && row.predicted >= STRETCH_ZONE_MIN && row.predicted <= STRETCH_ZONE_MAX)
361
+ .sort((left, right) => engineCost(left.candidate) - engineCost(right.candidate)
362
+ || right.predicted - left.predicted
363
+ || fallbackOrder(left.candidate, left.index) - fallbackOrder(right.candidate, right.index)
364
+ || candidateId(left.candidate).localeCompare(candidateId(right.candidate)));
365
+ return inBand.length ? inBand[0].candidate : null;
366
+ }
367
+
325
368
  function rankEngines(candidates, options = {}) {
326
369
  return rankEnginesDetailed(candidates, options).candidates;
327
370
  }
@@ -335,6 +378,10 @@ function routerPickExplanation(decision) {
335
378
  return `router picked ${engine} because ${taskType} track records are thin, so fallback order applies.`;
336
379
  }
337
380
  const stats = decision.stats.find((row) => row.engine === engine && row.task_type === taskType);
381
+ if (decision.stretch_zone_pick === engine) {
382
+ const predicted = stats ? stats.verified_pass_rate.toFixed(2) : 'unknown';
383
+ return `router picked ${engine} because the lane is low stakes and its predicted pass rate ${predicted} sits in the ${STRETCH_ZONE_MIN.toFixed(2)} to ${STRETCH_ZONE_MAX.toFixed(2)} stretch zone at the lowest cost.`;
384
+ }
338
385
  if (!stats) return `router picked ${engine} because it has the strongest ${taskType} track record.`;
339
386
  const passRate = (stats.verified_pass_rate * 100).toFixed(1);
340
387
  const duration = stats.median_duration_ms === null ? 'no duration' : `${Math.round(stats.median_duration_ms)} ms median`;
@@ -342,11 +389,12 @@ function routerPickExplanation(decision) {
342
389
  }
343
390
 
344
391
  module.exports = {
345
- DEFAULT_MIN_RECEIPTS,
346
- DEFAULT_HALF_LIFE_MS,
392
+ STRETCH_ZONE_MIN,
393
+ STRETCH_ZONE_MAX,
347
394
  loadRouterHistory,
348
395
  computeEngineTaskStats,
349
396
  rankEngines,
350
397
  rankEnginesDetailed,
351
398
  routerPickExplanation,
399
+ stretchZonePick,
352
400
  };
@@ -67,11 +67,6 @@ const RUNNER_PROFILE_DEFS = Object.freeze({
67
67
  model: 'grok-4.5',
68
68
  commandTemplate: '{bin} --always-approve -p {prompt}',
69
69
  }),
70
- hermes: Object.freeze({
71
- bin: 'hermes',
72
- model: '',
73
- commandTemplate: '{bin} -p -- {prompt}',
74
- }),
75
70
  droid: Object.freeze({
76
71
  bin: 'droid',
77
72
  model: '',
@@ -85,7 +80,6 @@ const RUNNER_PROFILE_DEFS = Object.freeze({
85
80
  const RUNNER_PROFILE_ALIASES = Object.freeze({
86
81
  'atris2-fast': 'atris-fast',
87
82
  'atris-2-fast': 'atris-fast',
88
- 'hermes-agent': 'hermes',
89
83
  });
90
84
 
91
85
  // Back-compat surface: RUNNER_PROFILES still resolves every accepted name
@@ -230,11 +224,7 @@ module.exports = {
230
224
  RUNNER_PROFILE_DEFS,
231
225
  RUNNER_PROFILE_ALIASES,
232
226
  RUNNER_PROFILE_NAMES,
233
- resolveRunnerProfileName,
234
227
  resolveRunnerProfile,
235
- resolveRunnerModel: resolveClaudeRunnerModel,
236
- resolveRunnerBin: resolveClaudeRunnerBin,
237
- resolveRunnerCommandTemplate: resolveClaudeRunnerCommandTemplate,
238
228
  resolveClaudeRunnerModel,
239
229
  resolveClaudeRunnerBin,
240
230
  resolveClaudeRunnerCommandTemplate,
@@ -114,6 +114,5 @@ function buildRemoteAtrisBootstrapCommand(options = {}) {
114
114
 
115
115
  module.exports = {
116
116
  buildRemoteAtrisBootstrapCommand,
117
- getAtrisPackageVersion,
118
117
  writeRuntimeReceipt,
119
118
  };
package/lib/scorecard.js CHANGED
@@ -331,7 +331,6 @@ function readScorecards(atrisDir) {
331
331
  }
332
332
 
333
333
  module.exports = {
334
- PRIVATE_MEMORY_ROOT,
335
334
  getScorecardsPath,
336
335
  buildScorecardData,
337
336
  writeScorecard,
package/lib/self-drive.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
+ const { openHumanAsks } = require('./mission-human-asks');
5
6
 
6
7
  const HUMAN_BLOCKING_REASONS = new Set(['auth-required', 'model-unavailable', 'rate-limit-exceeded-wall']);
7
8
  const OPEN_TASK_STATUSES = new Set(['open', 'claimed', 'review']);
@@ -154,10 +155,10 @@ function projectTasks(taskDb, db, workspaceRoot) {
154
155
  function handleMissionBlocker({ mission, stopReason, workspaceRoot, appendEvent }, injected = {}) {
155
156
  if (!mission || !mission.id) return { taskId: null, dispatched: false, engine: null, reason: 'missing mission' };
156
157
  const blockerClass = reasonClass(stopReason || mission.stop_reason || mission.last_tick_reason);
157
- if (HUMAN_BLOCKING_REASONS.has(blockerClass) || (Array.isArray(mission.human_asks) && mission.human_asks.length)) {
158
+ if (HUMAN_BLOCKING_REASONS.has(blockerClass) || openHumanAsks(mission.human_asks).length) {
158
159
  return { taskId: null, dispatched: false, engine: null, reason: 'human-blocking' };
159
160
  }
160
- if (['ready', 'complete'].includes(String(mission.status || '').toLowerCase())) {
161
+ if (['ready', 'complete', 'stopped'].includes(String(mission.status || '').toLowerCase())) {
161
162
  return { taskId: null, dispatched: false, engine: null, reason: 'stop condition met' };
162
163
  }
163
164
 
@@ -175,23 +176,53 @@ function handleMissionBlocker({ mission, stopReason, workspaceRoot, appendEvent
175
176
  return { taskId: taskRef(task), dispatched: false, engine: null, reason: 'existing blocker task' };
176
177
  }
177
178
 
179
+ const evidence = String(mission.receipt_path || mission.last_tick_reason || stopReason || blockerClass).trim();
180
+ const objective = String(mission.objective || mission.id).trim();
181
+ const fullStopReason = String(stopReason || blockerClass).trim();
182
+ const blockerMetadata = {
183
+ mission_id: mission.id,
184
+ mission_objective: objective,
185
+ mission_blocker_class: blockerClass,
186
+ stop_reason: fullStopReason,
187
+ evidence,
188
+ };
189
+ const latestClosed = matching
190
+ .filter(row => !OPEN_TASK_STATUSES.has(row.status))
191
+ .sort((a, b) => Number(b.updated_at || b.created_at || 0) - Number(a.updated_at || a.created_at || 0))[0];
192
+ if (latestClosed && typeof deps.taskDb.reopenTask === 'function') {
193
+ const reopened = deps.taskDb.reopenTask(db, {
194
+ id: latestClosed.id,
195
+ actor: 'self-drive',
196
+ reason: `mission blocker fired again: ${blockerClass}`,
197
+ metadata: blockerMetadata,
198
+ });
199
+ if (reopened.reopened) {
200
+ const refreshed = deps.taskDb.withTaskDisplayRefs(deps.taskDb.listTasks(db, { workspaceRoot: scopedRoot }));
201
+ task = refreshed.find(row => row.id === latestClosed.id) || reopened.row;
202
+ if (task && typeof deps.taskDb.noteTask === 'function') {
203
+ deps.taskDb.noteTask(db, {
204
+ id: task.id,
205
+ actor: 'self-drive',
206
+ content: `Mission blocker reopened.\nMission objective: ${objective}\nStop reason: ${fullStopReason}\nEvidence: ${evidence}`,
207
+ });
208
+ }
209
+ projectTasks(deps.taskDb, db, scopedRoot);
210
+ appendEvent?.('mission_blocker_task_reopened', {
211
+ task_id: taskRef(task),
212
+ stop_reason: fullStopReason,
213
+ blocker_class: blockerClass,
214
+ });
215
+ }
216
+ }
217
+
178
218
  if (!task) {
179
- const evidence = String(mission.receipt_path || mission.last_tick_reason || stopReason || blockerClass).trim();
180
- const objective = String(mission.objective || mission.id).trim();
181
- const fullStopReason = String(stopReason || blockerClass).trim();
182
219
  const title = missionBlockerTitle(objective, fullStopReason);
183
220
  const created = deps.taskDb.addTask(db, {
184
221
  title,
185
222
  tag: 'mission-blocker',
186
223
  workspaceRoot: scopedRoot,
187
- sourceKey: `mission-blocker:${mission.id}:${blockerClass}:${matching.length}`,
188
- metadata: {
189
- mission_id: mission.id,
190
- mission_objective: objective,
191
- mission_blocker_class: blockerClass,
192
- stop_reason: fullStopReason,
193
- evidence,
194
- },
224
+ sourceKey: `mission-blocker:${mission.id}:${blockerClass}`,
225
+ metadata: blockerMetadata,
195
226
  });
196
227
  const refreshed = deps.taskDb.withTaskDisplayRefs(deps.taskDb.listTasks(db, { workspaceRoot: scopedRoot }));
197
228
  task = refreshed.find((row) => row.id === created.id) || deps.taskDb.getTask(db, created.id);
@@ -255,4 +286,4 @@ function handleMissionBlocker({ mission, stopReason, workspaceRoot, appendEvent
255
286
  }
256
287
  }
257
288
 
258
- module.exports = { handleMissionBlocker };
289
+ module.exports = { handleMissionBlocker, reasonClass, HUMAN_BLOCKING_REASONS };
@@ -406,11 +406,5 @@ function loadContext(workspaceDir) {
406
406
  module.exports = {
407
407
  detectWorkspaceState,
408
408
  loadContext,
409
- getTodayInboxItems,
410
- getTodayLogPath,
411
- getBacklogTasks,
412
- getInProgressTasks,
413
- getCompletedTasks,
414
- getTaskCounts,
415
409
  getTaskGlance
416
410
  };
package/lib/task-db.js CHANGED
@@ -28,6 +28,8 @@ const os = require('os');
28
28
  const crypto = require('crypto');
29
29
  const { DatabaseSync } = require('node:sqlite');
30
30
  const reviewIntegrity = require('./review-integrity');
31
+ const { isDecisionTask } = require('./task-decision');
32
+ const { parseVerifyCommand } = require('./auto-accept-certified');
31
33
 
32
34
  const DEFAULT_DB_PATH = path.join(os.homedir(), '.atris', 'tasks.db');
33
35
  const TASK_EPISODES_FILE = path.join('.atris', 'state', 'task_episodes.jsonl');
@@ -38,6 +40,8 @@ const PROJECTION_EVENT_LIMIT = 8;
38
40
  const PROJECTION_MESSAGE_LIMIT = 6;
39
41
  const PROJECTION_PAYLOAD_TEXT_LIMIT = 1000;
40
42
  const AGENT_CERTIFICATION_REVIEW_PASSES = 2;
43
+ const OPEN_TASK_STATUSES = new Set(['open', 'claimed', 'review']);
44
+ const TERMINAL_MISSION_STATUSES = new Set(['complete', 'stopped']);
41
45
  const TASK_REF_GENERIC_TOKENS = new Set(['app', 'atris', 'atrisos', 'project', 'repo', 'workspace']);
42
46
  const TASK_PLAN_TAGS = new Set([
43
47
  'agent',
@@ -380,6 +384,16 @@ function taskDisplayRefMap(rows) {
380
384
  return map;
381
385
  }
382
386
 
387
+ function taskCreationMetadata(metadata) {
388
+ const next = metadata && typeof metadata === 'object' ? { ...metadata } : {};
389
+ const verify = typeof next.verify === 'string' ? next.verify.trim() : '';
390
+ if (!verify || verify.toLowerCase() === 'git diff --check') {
391
+ next.verification_status = 'degraded';
392
+ next.verification_degraded_reason = verify ? 'diff_only_verify' : 'missing_verify';
393
+ }
394
+ return next;
395
+ }
396
+
383
397
  function addTask(db, { title, tag, workspaceRoot: ws, sourceKey: sk, metadata, status, claimedBy }) {
384
398
  if (!title || !String(title).trim()) throw new Error('title required');
385
399
  const now = Date.now();
@@ -394,6 +408,7 @@ function addTask(db, { title, tag, workspaceRoot: ws, sourceKey: sk, metadata, s
394
408
  ).get(ws, sk);
395
409
  if (existing) return { id: existing.id, inserted: false };
396
410
  }
411
+ const taskMetadata = taskCreationMetadata(metadata);
397
412
  withBusyRetry(() => db.prepare(`
398
413
  INSERT INTO tasks (id, title, status, tag, workspace_root, source_key,
399
414
  claimed_by, claimed_at, created_at, updated_at, metadata)
@@ -409,7 +424,7 @@ function addTask(db, { title, tag, workspaceRoot: ws, sourceKey: sk, metadata, s
409
424
  claimedAt,
410
425
  now,
411
426
  now,
412
- metadata ? JSON.stringify(metadata) : null,
427
+ JSON.stringify(taskMetadata),
413
428
  ));
414
429
  appendTaskEvent(db, {
415
430
  taskId: id,
@@ -421,7 +436,7 @@ function addTask(db, { title, tag, workspaceRoot: ws, sourceKey: sk, metadata, s
421
436
  tag: tag || null,
422
437
  status: taskStatus,
423
438
  source_key: sk || null,
424
- metadata: metadata || null,
439
+ metadata: taskMetadata,
425
440
  },
426
441
  });
427
442
  return { id, inserted: true };
@@ -531,6 +546,72 @@ function releaseTask(db, { id, actor }) {
531
546
  return { released: true, row: updated, event };
532
547
  }
533
548
 
549
+ function reopenTask(db, { id, actor, reason, metadata: metadataPatch } = {}) {
550
+ if (!id) throw new Error('id required');
551
+ const row = getTask(db, id);
552
+ if (!row) return { reopened: false, reason: 'not_found' };
553
+ if (OPEN_TASK_STATUSES.has(row.status)) return { reopened: false, reason: `already_${row.status}`, row };
554
+ if (!['done', 'failed', 'archived'].includes(row.status)) {
555
+ return { reopened: false, reason: `not_reopenable_${row.status}` };
556
+ }
557
+
558
+ const now = Math.max(Date.now(), Number(row.updated_at || 0) + 1);
559
+ const actorText = actor || process.env.ATRIS_AGENT_ID || process.env.USER || null;
560
+ const reasonText = String(reason || 'reopened').trim();
561
+ const metadata = {
562
+ ...(row.metadata && typeof row.metadata === 'object' ? row.metadata : {}),
563
+ ...(metadataPatch && typeof metadataPatch === 'object' ? metadataPatch : {}),
564
+ reopened_at: new Date(now).toISOString(),
565
+ reopened_by: actorText,
566
+ reopened_from: row.status,
567
+ reopen_reason: reasonText,
568
+ };
569
+ for (const key of [
570
+ 'accepted_at',
571
+ 'accepted_by',
572
+ 'agent_certification_policy',
573
+ 'agent_certified',
574
+ 'agent_certified_at',
575
+ 'agent_certified_by',
576
+ 'agent_review_actors',
577
+ 'agent_review_pass_count',
578
+ 'agent_reviewed_at',
579
+ 'agent_reviewed_by',
580
+ 'approval_status',
581
+ 'archived_at',
582
+ 'archived_by',
583
+ 'archived_from',
584
+ 'archived_reason',
585
+ 'independent_review_by',
586
+ 'latest_agent_lesson',
587
+ 'latest_agent_next_task',
588
+ 'latest_agent_proof',
589
+ ]) delete metadata[key];
590
+
591
+ const result = withBusyRetry(() => db.prepare(`
592
+ UPDATE tasks
593
+ SET status = 'open',
594
+ claimed_by = NULL,
595
+ claimed_at = NULL,
596
+ done_at = NULL,
597
+ updated_at = ?,
598
+ metadata = ?
599
+ WHERE id = ?
600
+ AND status = ?
601
+ AND updated_at = ?
602
+ `).run(now, JSON.stringify(metadata), id, row.status, row.updated_at));
603
+ if (result.changes !== 1) return { reopened: false, reason: 'stale_task_state' };
604
+ const updated = getTask(db, id);
605
+ const event = appendTaskEvent(db, {
606
+ taskId: id,
607
+ workspaceRoot: updated.workspace_root,
608
+ actor: actorText,
609
+ eventType: 'reopened',
610
+ payload: { previous_status: row.status, reason: reasonText },
611
+ });
612
+ return { reopened: true, event, row: updated };
613
+ }
614
+
534
615
  function doneTask(db, { id, status, actor, allowReview = false, action, proof, autoAccepted = false } = {}) {
535
616
  if (!id) throw new Error('id required');
536
617
  const final = status || 'done';
@@ -570,6 +651,39 @@ function doneTask(db, { id, status, actor, allowReview = false, action, proof, a
570
651
  return { updated: false };
571
652
  }
572
653
 
654
+ function reapMissionBlockerTasks(db, { workspaceRoot: ws, missions = [], actor } = {}) {
655
+ const missionById = new Map((Array.isArray(missions) ? missions : [])
656
+ .filter(mission => mission && mission.id)
657
+ .map(mission => [String(mission.id), mission]));
658
+ const candidates = listTasks(db, { workspaceRoot: ws || null, limit: null })
659
+ .filter(row => OPEN_TASK_STATUSES.has(row.status))
660
+ .filter(row => row.tag === 'mission-blocker')
661
+ .filter(row => row.metadata && row.metadata.mission_id && row.metadata.mission_blocker_class)
662
+ .map(row => ({ row, mission: missionById.get(String(row.metadata.mission_id)) }))
663
+ .filter(({ mission }) => mission && TERMINAL_MISSION_STATUSES.has(String(mission.status || '').toLowerCase()));
664
+ const closed = [];
665
+ for (const { row, mission } of candidates) {
666
+ const missionStatus = String(mission.status).toLowerCase();
667
+ const reason = `mission ${mission.id} is ${missionStatus}`;
668
+ const result = archiveTask(db, {
669
+ id: row.id,
670
+ actor,
671
+ reason,
672
+ });
673
+ if (!result.archived) continue;
674
+ closed.push({
675
+ task_id: row.id,
676
+ title: row.title,
677
+ previous_status: row.status,
678
+ mission_id: mission.id,
679
+ mission_status: missionStatus,
680
+ blocker_class: row.metadata.mission_blocker_class,
681
+ reason,
682
+ });
683
+ }
684
+ return { closed, count: closed.length };
685
+ }
686
+
573
687
  // Distinct terminal status for housekeeping sweeps (duplicate loop-ticks,
574
688
  // off-roadmap backlog resets, synthetic-test cleanup). Never conflate this
575
689
  // with `failed` — 'failed' means the work itself did not succeed, 'archived'
@@ -1556,6 +1670,21 @@ function reviewTask(db, { id, actor, reward, lesson, nextTask, proof, verify, ca
1556
1670
  metadata.agent_reviewed_by = reviewer;
1557
1671
  if (proofText) metadata.latest_agent_proof = proofText;
1558
1672
  if (verifyText) {
1673
+ // Gate at the write path, not just cmdReview: a verify the strict parser
1674
+ // rejects can never be run by the hourly autoland recheck, so storing it
1675
+ // silently parks the task on a human forever (observed 2026-07-31: five
1676
+ // certified tasks stalled for days on unrunnable verifies stored by
1677
+ // fleet lanes that bypass the command-layer gate).
1678
+ const parsedVerify = parseVerifyCommand(verifyText);
1679
+ if (!parsedVerify.ok) {
1680
+ const error = new Error(
1681
+ `verify command is not runnable by the hourly recheck (${parsedVerify.reason || 'verify_command_not_allowed'}); `
1682
+ + 'use an allowlisted shape: cd backend && ../venv/bin/python -m pytest <file> -q, '
1683
+ + 'test -s <artifact>, npm test, node --test <file>, tsc, or git diff --check',
1684
+ );
1685
+ error.reason = parsedVerify.reason || 'verify_command_not_allowed';
1686
+ throw error;
1687
+ }
1559
1688
  metadata.verify = verifyText;
1560
1689
  metadata.latest_agent_verify = verifyText;
1561
1690
  }
@@ -1997,8 +2126,11 @@ function appendSection(lines, name, rows) {
1997
2126
  const tag = [...new Set(tags.filter(Boolean).map(value => String(value).trim()).filter(Boolean))]
1998
2127
  .map(value => ` [${value}]`)
1999
2128
  .join('');
2129
+ // Decision holds live in metadata.tags (or the primary tag). Surface them
2130
+ // so a human judgment row never blends into ordinary work on the board.
2131
+ const decision = isDecisionTask(row) ? ' [decision]' : '';
2000
2132
  const displayRef = meta.todo_id || row.display_id || row.id;
2001
- lines.push(`- **[${displayRef}]** ${row.title}${tag}`);
2133
+ lines.push(`- **[${displayRef}]** ${row.title}${tag}${decision}`);
2002
2134
  if (row.claimed_by && row.status === 'claimed') lines.push(` **Claimed by:** ${row.claimed_by}`);
2003
2135
  if (meta.verify) lines.push(` **Verify:** ${meta.verify}`);
2004
2136
  }
@@ -2046,12 +2178,13 @@ module.exports = {
2046
2178
  listTasks,
2047
2179
  claimTask,
2048
2180
  releaseTask,
2181
+ reopenTask,
2049
2182
  backlogTask,
2050
2183
  clearPlanTasks,
2051
2184
  doneTask,
2185
+ reapMissionBlockerTasks,
2052
2186
  archiveTask,
2053
2187
  relabelArchivedTasks,
2054
- isJune10BacklogResetMarker,
2055
2188
  readyTask,
2056
2189
  setTaskResult,
2057
2190
  reviseTask,
@@ -2065,10 +2198,7 @@ module.exports = {
2065
2198
  taskProjection,
2066
2199
  renderTodoMarkdown,
2067
2200
  normalizeTaskRef,
2068
- shortestUniqueTaskRef,
2069
2201
  taskDisplayRefMap,
2070
2202
  withTaskDisplayRefs,
2071
2203
  newId,
2072
- // Test surface
2073
- _SCHEMA: SCHEMA,
2074
2204
  };