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
@@ -7,6 +7,26 @@ const { loadCredentials } = require('../utils/auth');
7
7
  const { apiRequestJson } = require('../utils/api');
8
8
  const { runAliveTick } = require('../lib/member-alive');
9
9
  const { defaultObjectiveRunner } = require('../lib/default-runner');
10
+ const { readJson, writeJson } = require('../lib/json-file');
11
+ const { hasFlag, readFlag, readNumberFlag } = require('../lib/arg-parser');
12
+
13
+ function findWorkspaceBusinessId(startDir = process.cwd()) {
14
+ let dir = path.resolve(startDir);
15
+ while (true) {
16
+ const file = path.join(dir, '.atris', 'business.json');
17
+ if (fs.existsSync(file)) {
18
+ try {
19
+ return JSON.parse(fs.readFileSync(file, 'utf8')).business_id || null;
20
+ } catch (error) {
21
+ console.warn(`Warning: could not parse ${file}: ${error.message}`);
22
+ return null;
23
+ }
24
+ }
25
+ const parent = path.dirname(dir);
26
+ if (parent === dir) return null;
27
+ dir = parent;
28
+ }
29
+ }
10
30
 
11
31
  function todayLogName() {
12
32
  const now = new Date();
@@ -75,27 +95,6 @@ function parseConfirmFlag(flags) {
75
95
  return joined.match(/--confirm[=\s]+["']?([^"']+)["']?/)?.[1] || '';
76
96
  }
77
97
 
78
- function hasFlag(args, name) {
79
- return args.includes(name);
80
- }
81
-
82
- function readFlag(args, name, fallback = '') {
83
- const prefix = `${name}=`;
84
- for (let i = 0; i < args.length; i += 1) {
85
- const arg = args[i];
86
- if (arg === name && args[i + 1] && !String(args[i + 1]).startsWith('--')) return args[i + 1];
87
- if (String(arg).startsWith(prefix)) return String(arg).slice(prefix.length).replace(/^["']|["']$/g, '');
88
- }
89
- return fallback;
90
- }
91
-
92
- function readNumberFlag(args, name, fallback = null) {
93
- const raw = readFlag(args, name, '');
94
- if (!raw) return fallback;
95
- const value = Number(raw);
96
- return Number.isFinite(value) ? value : null;
97
- }
98
-
99
98
  function readRepeatedFlag(args, name) {
100
99
  const values = [];
101
100
  const prefix = `${name}=`;
@@ -347,17 +346,12 @@ function emptyMemberGoals(name) {
347
346
  }
348
347
 
349
348
  function loadMemberGoals(name, paths = memberPaths(name)) {
350
- if (!fs.existsSync(paths.goalsJson)) return emptyMemberGoals(name);
351
- try {
352
- const parsed = JSON.parse(fs.readFileSync(paths.goalsJson, 'utf8'));
353
- return {
354
- ...emptyMemberGoals(name),
355
- ...parsed,
356
- goals: Array.isArray(parsed.goals) ? parsed.goals : [],
357
- };
358
- } catch {
359
- return emptyMemberGoals(name);
360
- }
349
+ const parsed = readJson(paths.goalsJson, {}) || {};
350
+ return {
351
+ ...emptyMemberGoals(name),
352
+ ...parsed,
353
+ goals: Array.isArray(parsed.goals) ? parsed.goals : [],
354
+ };
361
355
  }
362
356
 
363
357
  function activeGoal(goalsState, goalId = '') {
@@ -1393,7 +1387,7 @@ function taskIsClosed(task) {
1393
1387
 
1394
1388
  function taskProjectionRows() {
1395
1389
  const projectionPath = path.join(process.cwd(), '.atris', 'state', 'tasks.projection.json');
1396
- const projection = readJsonIfExists(projectionPath);
1390
+ const projection = readJson(projectionPath);
1397
1391
  return Array.isArray(projection?.tasks) ? projection.tasks : [];
1398
1392
  }
1399
1393
 
@@ -1493,7 +1487,7 @@ function sortEvidenceCandidates(candidates) {
1493
1487
 
1494
1488
  function readTaskProjectionEvidence(name) {
1495
1489
  const projectionPath = path.join(process.cwd(), '.atris', 'state', 'tasks.projection.json');
1496
- const projection = readJsonIfExists(projectionPath);
1490
+ const projection = readJson(projectionPath);
1497
1491
  const tasks = taskProjectionRows();
1498
1492
  const candidates = sortEvidenceCandidates(
1499
1493
  tasks
@@ -1645,7 +1639,7 @@ function latestActionableUserLine(thread) {
1645
1639
  function readMemberRoomEvidence(name) {
1646
1640
  const roots = [path.join(process.cwd(), '.obelisk', 'threads')];
1647
1641
  const projectsPath = path.join(os.homedir(), '.obelisk', 'projects.json');
1648
- const projects = readJsonIfExists(projectsPath);
1642
+ const projects = readJson(projectsPath);
1649
1643
  if (Array.isArray(projects)) {
1650
1644
  const cwd = path.resolve(process.cwd());
1651
1645
  const project = projects.find((item) => item?.path && path.resolve(item.path) === cwd && item.id);
@@ -1658,7 +1652,7 @@ function readMemberRoomEvidence(name) {
1658
1652
  for (const item of listThreadJsonFiles(root)) {
1659
1653
  if (seen.has(item.path)) continue;
1660
1654
  seen.add(item.path);
1661
- const thread = readJsonIfExists(item.path);
1655
+ const thread = readJson(item.path);
1662
1656
  files_checked += 1;
1663
1657
  const context = thread?.atrisContext || {};
1664
1658
  const linkedTasks = Array.isArray(context.linkedTasks) ? context.linkedTasks : [];
@@ -1699,11 +1693,11 @@ function readMemberRoomEvidence(name) {
1699
1693
  }
1700
1694
 
1701
1695
  function readRecentWakeReceiptEvidence(name) {
1702
- const latestLoop = readJsonIfExists(memberLoopPaths(name).latestPath);
1696
+ const latestLoop = readJson(memberLoopPaths(name).latestPath);
1703
1697
  const receiptPath = Array.isArray(latestLoop?.tick_receipts)
1704
1698
  ? latestLoop.tick_receipts.slice().reverse().find(Boolean)
1705
1699
  : null;
1706
- const latestWake = receiptPath ? readJsonIfExists(receiptPath) : null;
1700
+ const latestWake = receiptPath ? readJson(receiptPath) : null;
1707
1701
  return {
1708
1702
  latest_loop_path: memberLoopPaths(name).latestPath,
1709
1703
  latest_loop_status: latestLoop?.status || null,
@@ -1845,7 +1839,7 @@ function problemSignalSources(root = process.cwd()) {
1845
1839
 
1846
1840
  function rowsFromProblemSignalSource(source) {
1847
1841
  if (source.kind === 'jsonl') return readJsonlRowsIfExists(source.path, 80);
1848
- const row = readJsonIfExists(source.path);
1842
+ const row = readJson(source.path);
1849
1843
  return row ? [row] : [];
1850
1844
  }
1851
1845
 
@@ -1966,7 +1960,7 @@ function readMemberSignalSourceConfig(root = process.cwd()) {
1966
1960
  path.join(root, '.atris', 'member-signal-sources.json'),
1967
1961
  ];
1968
1962
  for (const configPath of candidates) {
1969
- const config = readJsonIfExists(configPath);
1963
+ const config = readJson(configPath);
1970
1964
  if (config && typeof config === 'object') return { config, configPath };
1971
1965
  }
1972
1966
  return { config: null, configPath: null };
@@ -2394,6 +2388,27 @@ function experimentIsClosed(experiment) {
2394
2388
  return ['accepted', 'discarded', 'superseded'].includes(String(experiment?.status || '').toLowerCase());
2395
2389
  }
2396
2390
 
2391
+ // A proposal that cannot be reviewed must not gate the member.
2392
+ //
2393
+ // wake returns decision 'wait' for any open experiment, on the theory that the
2394
+ // ball is with a human reviewer. That is right for a real proposal. It is a
2395
+ // permanent deadlock for a FALLBACK proposal: those carry verifier=null and
2396
+ // generation.mode='fallback' (template text emitted when LLM authoring did not
2397
+ // happen), and the review executor correctly refuses to accept anything
2398
+ // unverifiable. So the member waits on a gate nothing can pass -- observed
2399
+ // 2026-07-26 in project-obelisk with validator blocked 52 days, signal-scout 48,
2400
+ // and land-keeper 22, three of six rostered members doing no work at all.
2401
+ //
2402
+ // Deliberately narrow: a proposal WITH a verifier still blocks (a human genuinely
2403
+ // owes it a review), and 'running'/'blocked' still block regardless of shape.
2404
+ function experimentIsUnreviewable(experiment) {
2405
+ if (String(experiment?.status || '').toLowerCase() !== 'proposed') return false;
2406
+ const verifier = experiment?.verifier;
2407
+ const hasVerifier = Array.isArray(verifier) ? verifier.length > 0 : Boolean(String(verifier || '').trim());
2408
+ if (hasVerifier) return false;
2409
+ return String(experiment?.generation?.mode || '').toLowerCase() === 'fallback';
2410
+ }
2411
+
2397
2412
  function supersedeOtherOpenExperiments(state, activeGoal, proof) {
2398
2413
  const superseded = [];
2399
2414
  for (const goal of state.goals || []) {
@@ -2800,7 +2815,7 @@ function installMemberAliveCron(name, args = []) {
2800
2815
  stop_command: `atris member alive ${name} --uninstall`,
2801
2816
  error,
2802
2817
  };
2803
- writeJsonFile(paths.latestPath, payload);
2818
+ writeJson(paths.latestPath, payload);
2804
2819
  printJsonOrText(payload, [
2805
2820
  `${dryRun ? 'Would install' : installed ? 'Installed' : 'Install failed'} hourly alive loop: ${name}`,
2806
2821
  `Cron: ${crontabLine}`,
@@ -2868,14 +2883,6 @@ function writeMemberLoopReceipt(name, payload) {
2868
2883
  return receiptPath;
2869
2884
  }
2870
2885
 
2871
- function readJsonIfExists(filePath) {
2872
- try {
2873
- return JSON.parse(fs.readFileSync(filePath, 'utf8'));
2874
- } catch {
2875
- return null;
2876
- }
2877
- }
2878
-
2879
2886
  function isPidAlive(pid) {
2880
2887
  const numeric = Number(pid);
2881
2888
  if (!Number.isFinite(numeric) || numeric <= 0) return false;
@@ -2887,11 +2894,6 @@ function isPidAlive(pid) {
2887
2894
  }
2888
2895
  }
2889
2896
 
2890
- function writeJsonFile(filePath, payload) {
2891
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
2892
- fs.writeFileSync(filePath, JSON.stringify(payload, null, 2) + '\n', 'utf8');
2893
- }
2894
-
2895
2897
  function safeReadText(filePath, maxBytes = 250000) {
2896
2898
  try {
2897
2899
  const stat = fs.statSync(filePath);
@@ -2902,14 +2904,6 @@ function safeReadText(filePath, maxBytes = 250000) {
2902
2904
  }
2903
2905
  }
2904
2906
 
2905
- function safeReadJson(filePath) {
2906
- try {
2907
- return JSON.parse(fs.readFileSync(filePath, 'utf8'));
2908
- } catch {
2909
- return null;
2910
- }
2911
- }
2912
-
2913
2907
  function repoRelative(root, filePath) {
2914
2908
  return path.relative(root, filePath).replace(/\\/g, '/');
2915
2909
  }
@@ -3123,7 +3117,7 @@ function autoImproverTaskWaitingForHuman(task, status) {
3123
3117
 
3124
3118
  function collectAutoImproverTaskSignals(root) {
3125
3119
  const projectionPath = path.join(root, '.atris', 'state', 'tasks.projection.json');
3126
- const projection = safeReadJson(projectionPath);
3120
+ const projection = readJson(projectionPath);
3127
3121
  const tasks = Array.isArray(projection?.tasks) ? projection.tasks : [];
3128
3122
  const openStatuses = new Set(['open', 'todo', 'claimed', 'doing', 'in_progress', 'review', 'blocked']);
3129
3123
  const staleTasks = [];
@@ -3437,7 +3431,7 @@ function autoImproverTaskIsActionable(task) {
3437
3431
  }
3438
3432
 
3439
3433
  function findExistingAutoImproverTask(title) {
3440
- const projection = safeReadJson(path.join(process.cwd(), '.atris', 'state', 'tasks.projection.json'));
3434
+ const projection = readJson(path.join(process.cwd(), '.atris', 'state', 'tasks.projection.json'));
3441
3435
  const tasks = Array.isArray(projection?.tasks) ? projection.tasks : [];
3442
3436
  const key = lowerCompact(stripAutoImproverTitleNoise(title));
3443
3437
  if (!key) return null;
@@ -3515,9 +3509,9 @@ function createAutoImproverTask(candidate, receiptPath) {
3515
3509
 
3516
3510
  function writeAutoImproverReceipt(name, payload, receiptPath = null) {
3517
3511
  const finalReceiptPath = receiptPath || path.join(process.cwd(), 'atris', 'runs', `auto-improver-dogfood-${fileSafeStamp()}.json`);
3518
- writeJsonFile(finalReceiptPath, payload);
3512
+ writeJson(finalReceiptPath, payload);
3519
3513
  const latestPath = path.join(process.cwd(), '.atris', 'state', 'auto-improver-dogfood-latest.json');
3520
- writeJsonFile(latestPath, { ...payload, receipt_path: finalReceiptPath });
3514
+ writeJson(latestPath, { ...payload, receipt_path: finalReceiptPath });
3521
3515
  return { receiptPath: finalReceiptPath, latestPath };
3522
3516
  }
3523
3517
 
@@ -3607,7 +3601,7 @@ async function runAutoImproverWake(name, paths, { execute = false, confirmed = f
3607
3601
  : 'No bounded prevented-fire task was created yet.',
3608
3602
  },
3609
3603
  };
3610
- const previousLatest = safeReadJson(path.join(process.cwd(), '.atris', 'state', 'auto-improver-dogfood-latest.json'));
3604
+ const previousLatest = readJson(path.join(process.cwd(), '.atris', 'state', 'auto-improver-dogfood-latest.json'));
3611
3605
  const finalWrite = writeAutoImproverReceipt(name, payload, plannedReceiptPath);
3612
3606
  // A no-op scan identical to the previous tick earns a receipt but not a journal entry —
3613
3607
  // the cadence loop was appending the same "found: N, prevented: 0" row every ~4 minutes
@@ -3692,7 +3686,7 @@ function acquireMemberLoopLease(name, { runId, ttlMs }) {
3692
3686
  return writeLease();
3693
3687
  } catch (error) {
3694
3688
  if (error.code !== 'EEXIST') throw error;
3695
- const active = readJsonIfExists(paths.lockPath);
3689
+ const active = readJson(paths.lockPath);
3696
3690
  const expired = active && Number(active.expires_at_ms || 0) <= nowMs;
3697
3691
  const deadPid = active && !isPidAlive(active.pid);
3698
3692
  if (active && (expired || deadPid)) {
@@ -3710,7 +3704,7 @@ function acquireMemberLoopLease(name, { runId, ttlMs }) {
3710
3704
  }
3711
3705
 
3712
3706
  function refreshMemberLoopLease(paths, lease, ttlMs) {
3713
- writeJsonFile(paths.lockPath, {
3707
+ writeJson(paths.lockPath, {
3714
3708
  ...lease,
3715
3709
  heartbeat_at: stampIso(),
3716
3710
  expires_at_ms: Date.now() + ttlMs,
@@ -3718,7 +3712,7 @@ function refreshMemberLoopLease(paths, lease, ttlMs) {
3718
3712
  }
3719
3713
 
3720
3714
  function releaseMemberLoopLease(paths, lease) {
3721
- const active = readJsonIfExists(paths.lockPath);
3715
+ const active = readJson(paths.lockPath);
3722
3716
  if (!active || active.run_id !== lease.run_id || active.pid !== lease.pid) return;
3723
3717
  fs.rmSync(paths.lockPath, { force: true });
3724
3718
  }
@@ -3733,11 +3727,11 @@ function activeMemberLoopLease(lease) {
3733
3727
  function archiveMemberLoopState(name) {
3734
3728
  const paths = memberLoopPaths(name);
3735
3729
  const archivedAt = stampIso();
3736
- const previousLease = readJsonIfExists(paths.lockPath);
3730
+ const previousLease = readJson(paths.lockPath);
3737
3731
  const activeLease = activeMemberLoopLease(previousLease);
3738
3732
  fs.mkdirSync(paths.stateDir, { recursive: true });
3739
3733
  if (activeLease) {
3740
- writeJsonFile(paths.stopPath, {
3734
+ writeJson(paths.stopPath, {
3741
3735
  schema: 'atris.member_loop_stop.v1',
3742
3736
  member: name,
3743
3737
  requested_at: archivedAt,
@@ -3762,7 +3756,7 @@ function archiveMemberLoopState(name) {
3762
3756
  stop_path: paths.stopPath,
3763
3757
  latest_path: paths.latestPath,
3764
3758
  };
3765
- writeJsonFile(paths.latestPath, payload);
3759
+ writeJson(paths.latestPath, payload);
3766
3760
  return payload;
3767
3761
  }
3768
3762
 
@@ -4284,6 +4278,16 @@ function memberUpgrade(name) {
4284
4278
 
4285
4279
  // --- PUSH subcommand ---
4286
4280
 
4281
+ function readMemberPushBusinessBinding(root = process.cwd()) {
4282
+ const file = path.join(root, '.atris', 'business.json');
4283
+ if (!fs.existsSync(file)) return null;
4284
+ try {
4285
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
4286
+ } catch (e) {
4287
+ throw new Error(`Failed to read .atris/business.json: ${e.message || e}`);
4288
+ }
4289
+ }
4290
+
4287
4291
  async function memberPush(name) {
4288
4292
  if (!name) {
4289
4293
  console.error('Usage: atris member push <name>');
@@ -4307,6 +4311,9 @@ async function memberPush(name) {
4307
4311
  const content = fs.readFileSync(memberFile, 'utf8');
4308
4312
  const fm = parseFrontmatter(content);
4309
4313
  const existingAgentId = fm && fm['agent-id'];
4314
+ const taskDb = require('../lib/task-db');
4315
+ const binding = readMemberPushBusinessBinding(taskDb.workspaceRoot());
4316
+ const businessId = String(binding && (binding.business_id || binding.id) || '').trim();
4310
4317
 
4311
4318
  if (existingAgentId) {
4312
4319
  console.log(`Pushing member "${name}" to cloud (updating agent ${existingAgentId})...`);
@@ -4314,7 +4321,13 @@ async function memberPush(name) {
4314
4321
  console.log(`Pushing member "${name}" to cloud (creating new agent)...`);
4315
4322
  }
4316
4323
 
4317
- const result = await apiRequestJson('/agent/import-member', {
4324
+ if (!businessId) {
4325
+ console.log('no business binding found; run `atris business init "<name>" --here` to bind this workspace.');
4326
+ }
4327
+ const endpoint = businessId
4328
+ ? `/agent/import-member?business_id=${encodeURIComponent(businessId)}`
4329
+ : '/agent/import-member';
4330
+ const result = await apiRequestJson(endpoint, {
4318
4331
  method: 'POST',
4319
4332
  headers: { 'Content-Type': 'text/markdown' },
4320
4333
  body: content,
@@ -4340,6 +4353,7 @@ async function memberPush(name) {
4340
4353
 
4341
4354
  const action = existingAgentId ? 'Updated' : 'Created';
4342
4355
  console.log(`${action} successfully. Agent ID: ${agentId}`);
4356
+ if (businessId) console.log(`Bound to business ${businessId}`);
4343
4357
  }
4344
4358
 
4345
4359
  // --- PULL subcommand ---
@@ -7251,52 +7265,218 @@ function workspaceSnapshot(name = null, paths = null) {
7251
7265
  }
7252
7266
  }
7253
7267
 
7268
+ // Shared mission summary every wake decision carries. The mission-gate and generalist
7269
+ // rules coerce a falsy North Star to null (pre-gate we cannot trust the mission file yet).
7270
+ function wakeMissionSummary(purpose, { coerceNorthStarNull = false } = {}) {
7271
+ return {
7272
+ north_star: coerceNorthStarNull ? (purpose.northStar || null) : purpose.northStar,
7273
+ runtime_id: purpose.runtimeMission.id || null,
7274
+ runtime_status: purpose.runtimeMission.status || null,
7275
+ runtime_next: purpose.runtimeMission.next || null,
7276
+ };
7277
+ }
7278
+
7279
+ // Merge a rule's verdict (decision/reason/ask/next_command + who owns it) with the
7280
+ // context every decision reports. Key order matters: callers serialize this to JSON.
7281
+ function composeWakeDecision(ctx, verdict) {
7282
+ const result = {
7283
+ decision: verdict.decision,
7284
+ reason: verdict.reason,
7285
+ needs_user: Boolean(verdict.needs_user),
7286
+ ask: verdict.ask || null,
7287
+ next_command: verdict.next_command,
7288
+ state: ctx.state,
7289
+ goal: verdict.goal,
7290
+ current_experiment: verdict.current_experiment,
7291
+ };
7292
+ if ('autonomous_problem' in verdict) result.autonomous_problem = verdict.autonomous_problem;
7293
+ result.checks = ctx.checks;
7294
+ result.mission = verdict.mission || wakeMissionSummary(ctx.purpose);
7295
+ result.steering = ctx.steering;
7296
+ result.evidence = ctx.evidence;
7297
+ result.workspace = ctx.workspace;
7298
+ return result;
7299
+ }
7300
+
7301
+ // Rule: a generalist with a meaningful mission processes dropped domain files before
7302
+ // anything else. Fires before evidence collection, so it returns a complete decision
7303
+ // with its own checks and no workspace snapshot.
7304
+ function wakeRuleGeneralistDomainFile(name, { purpose, steering, state, goal, current }) {
7305
+ if (lowerCompact(name) !== 'generalist' || !purpose.meaningful) return null;
7306
+ const domainFile = findUnprocessedGeneralistDomainFile(process.cwd());
7307
+ if (!domainFile) return null;
7308
+ return {
7309
+ decision: 'process_domain_file',
7310
+ reason: `generalist_domain_scan:${domainFile.status}`,
7311
+ needs_user: false,
7312
+ ask: null,
7313
+ next_command: `atris member wake ${name} --execute --domain-file ${domainFile.path} --json`,
7314
+ state,
7315
+ goal: goal || null,
7316
+ current_experiment: current || null,
7317
+ checks: {
7318
+ has_member: true,
7319
+ has_mission: true,
7320
+ mission_meaningful: true,
7321
+ has_goal: Boolean(goal),
7322
+ has_open_experiment: Boolean(current),
7323
+ has_unprocessed_domain_file: true,
7324
+ domain_scan_window_minutes: 60,
7325
+ checked_existing_tasks: false,
7326
+ },
7327
+ mission: wakeMissionSummary(purpose, { coerceNorthStarNull: true }),
7328
+ steering,
7329
+ evidence: {
7330
+ generalist_domain_scan: domainFile,
7331
+ task_projection: null,
7332
+ nearest_open_loop: null,
7333
+ },
7334
+ workspace: null,
7335
+ domain_file: domainFile,
7336
+ };
7337
+ }
7338
+
7339
+ // Rule: a member without a meaningful mission asks the operator before waking itself.
7340
+ function wakeRuleMissionGate(ctx) {
7341
+ if (ctx.purpose.meaningful) return null;
7342
+ return {
7343
+ decision: 'ask',
7344
+ reason: 'mission_missing_or_placeholder',
7345
+ needs_user: true,
7346
+ ask: `Define atris/team/${ctx.name}/MISSION.md with a concrete North Star before this member wakes itself.`,
7347
+ next_command: `edit atris/team/${ctx.name}/MISSION.md`,
7348
+ goal: ctx.goal || null,
7349
+ current_experiment: ctx.current || null,
7350
+ mission: wakeMissionSummary(ctx.purpose, { coerceNorthStarNull: true }),
7351
+ };
7352
+ }
7353
+
7354
+ // Rule: log-error discovery that asks for a task gets one opened autonomously.
7355
+ function wakeRuleAutonomousErrorTask(ctx) {
7356
+ const discoveredTask = ctx.evidence.problem_discovery?.selected || null;
7357
+ if (!discoveredTask) return null;
7358
+ if (discoveredTask.source !== 'log_error_scan' || discoveredTask.autonomous_action !== 'create_task') return null;
7359
+ return {
7360
+ decision: 'create_task',
7361
+ reason: 'autonomous_error_discovery:log_error_scan',
7362
+ next_command: discoveredTask.next_command,
7363
+ goal: ctx.goal || null,
7364
+ current_experiment: ctx.current || null,
7365
+ autonomous_problem: discoveredTask,
7366
+ };
7367
+ }
7368
+
7369
+ // Rule: no active goal — adopt a discovered problem as the objective, else stop.
7370
+ function wakeRuleNeedsGoal(ctx) {
7371
+ if (ctx.goal) return null;
7372
+ const discoveredProblem = ctx.evidence.problem_discovery?.selected || null;
7373
+ if (discoveredProblem) {
7374
+ return {
7375
+ decision: 'set_objective',
7376
+ reason: `autonomous_problem_discovery:${discoveredProblem.source}`,
7377
+ next_command: discoveredProblem.next_command || `atris member wake ${ctx.name} --execute --confirm-autonomy-policy`,
7378
+ goal: null,
7379
+ current_experiment: null,
7380
+ autonomous_problem: discoveredProblem,
7381
+ };
7382
+ }
7383
+ return {
7384
+ decision: 'stop',
7385
+ reason: 'no_active_goal',
7386
+ next_command: `atris member goal-from-mission ${ctx.name}`,
7387
+ goal: null,
7388
+ current_experiment: null,
7389
+ autonomous_problem: null,
7390
+ };
7391
+ }
7392
+
7393
+ // Rule: a blocked experiment needs operator input before another wake.
7394
+ function wakeRuleBlockedExperiment(ctx) {
7395
+ if (!ctx.blocked) return null;
7396
+ return {
7397
+ decision: 'ask',
7398
+ reason: 'blocked_experiment',
7399
+ needs_user: true,
7400
+ ask: ctx.blocked.block?.ask || 'Needs operator input before another wake.',
7401
+ next_command: `atris member review ${ctx.name} ${ctx.blocked.id} --discard --proof "..."`,
7402
+ goal: ctx.goal,
7403
+ current_experiment: ctx.blocked,
7404
+ };
7405
+ }
7406
+
7407
+ // Rule: an open reviewable proposal parks the member until review. Skip the wait when
7408
+ // the only thing "open" is an unreviewable fallback proposal; otherwise the member is
7409
+ // parked forever on a gate no reviewer can clear.
7410
+ function wakeRuleOpenReviewableProposal(ctx) {
7411
+ if (!ctx.current || experimentIsUnreviewable(ctx.current)) return null;
7412
+ return {
7413
+ decision: 'wait',
7414
+ reason: `open_experiment_${ctx.current.status}`,
7415
+ next_command: `atris member review ${ctx.name} ${ctx.current.id} --accept --proof "..." --value 4`,
7416
+ goal: ctx.goal,
7417
+ current_experiment: ctx.current,
7418
+ };
7419
+ }
7420
+
7421
+ // Rule: the scored candidate list can steer the wake somewhere other than a tick.
7422
+ function wakeRuleScoredNonTickCandidate(ctx) {
7423
+ const selected = ctx.wakeScores.selected;
7424
+ if (!selected || selected.decision === 'tick') return null;
7425
+ const needsUser = selected.decision === 'ask';
7426
+ return {
7427
+ decision: selected.decision,
7428
+ reason: selected.reason,
7429
+ needs_user: needsUser,
7430
+ ask: needsUser ? (selected.ask || `Need operator input for ${selected.title}.`) : null,
7431
+ next_command: selected.next_command,
7432
+ goal: ctx.goal,
7433
+ current_experiment: null,
7434
+ };
7435
+ }
7436
+
7437
+ // Rule: dirty files inside the member's own scope gate the wake unless forced.
7438
+ function wakeRuleDirtyMemberScope(ctx) {
7439
+ if (ctx.workspace.clean_for_member || ctx.force) return null;
7440
+ return {
7441
+ decision: 'wait',
7442
+ reason: 'workspace_dirty_in_member_scope',
7443
+ next_command: `commit/stash files in atris/team/${ctx.name}/ (or member scope) — or rerun: atris member wake ${ctx.name} --force`,
7444
+ goal: ctx.goal,
7445
+ current_experiment: null,
7446
+ };
7447
+ }
7448
+
7449
+ // Rule: nothing blocks the member, so take the safe next bounded step.
7450
+ function wakeRuleSafeBoundedTick(ctx) {
7451
+ return {
7452
+ decision: 'tick',
7453
+ reason: 'safe_next_bounded_step',
7454
+ next_command: `atris member tick ${ctx.name} --goal ${ctx.goal.id}`,
7455
+ goal: ctx.goal,
7456
+ current_experiment: null,
7457
+ };
7458
+ }
7459
+
7460
+ // Ordered decision list: the first rule that speaks wins. Order is the contract.
7461
+ const WAKE_DECISION_RULES = [
7462
+ wakeRuleMissionGate,
7463
+ wakeRuleAutonomousErrorTask,
7464
+ wakeRuleNeedsGoal,
7465
+ wakeRuleBlockedExperiment,
7466
+ wakeRuleOpenReviewableProposal,
7467
+ wakeRuleScoredNonTickCandidate,
7468
+ wakeRuleDirtyMemberScope,
7469
+ wakeRuleSafeBoundedTick,
7470
+ ];
7471
+
7254
7472
  function wakeDecision(name, paths, { force = false, runtimeKind = memberRuntimeKind(name) } = {}) {
7255
7473
  const purpose = missionPurpose(paths);
7256
7474
  const steering = readSteeringMemory(paths, name);
7257
7475
  const state = loadMemberGoals(name, paths);
7258
7476
  const goal = activeGoal(state);
7259
7477
  const current = memberOpenExperiment(state);
7260
- const isGeneralist = lowerCompact(name) === 'generalist';
7261
- if (isGeneralist && purpose.meaningful) {
7262
- const domainFile = findUnprocessedGeneralistDomainFile(process.cwd());
7263
- if (domainFile) {
7264
- return {
7265
- decision: 'process_domain_file',
7266
- reason: `generalist_domain_scan:${domainFile.status}`,
7267
- needs_user: false,
7268
- ask: null,
7269
- next_command: `atris member wake ${name} --execute --domain-file ${domainFile.path} --json`,
7270
- state,
7271
- goal: goal || null,
7272
- current_experiment: current || null,
7273
- checks: {
7274
- has_member: true,
7275
- has_mission: true,
7276
- mission_meaningful: true,
7277
- has_goal: Boolean(goal),
7278
- has_open_experiment: Boolean(current),
7279
- has_unprocessed_domain_file: true,
7280
- domain_scan_window_minutes: 60,
7281
- checked_existing_tasks: false,
7282
- },
7283
- mission: {
7284
- north_star: purpose.northStar || null,
7285
- runtime_id: purpose.runtimeMission.id || null,
7286
- runtime_status: purpose.runtimeMission.status || null,
7287
- runtime_next: purpose.runtimeMission.next || null,
7288
- },
7289
- steering,
7290
- evidence: {
7291
- generalist_domain_scan: domainFile,
7292
- task_projection: null,
7293
- nearest_open_loop: null,
7294
- },
7295
- workspace: null,
7296
- domain_file: domainFile,
7297
- };
7298
- }
7299
- }
7478
+ const generalistDecision = wakeRuleGeneralistDomainFile(name, { purpose, steering, state, goal, current });
7479
+ if (generalistDecision) return generalistDecision;
7300
7480
  const rawDirective = steeringWakeDirective(steering, name, goal);
7301
7481
  const directiveClosure = rawDirective ? steeringDirectiveClosure(rawDirective) : null;
7302
7482
  const directive = directiveClosure?.all_closed ? null : rawDirective;
@@ -7339,217 +7519,12 @@ function wakeDecision(name, paths, { force = false, runtimeKind = memberRuntimeK
7339
7519
  next_command: wakeScores.selected.next_command,
7340
7520
  } : null;
7341
7521
 
7342
- if (!purpose.meaningful) {
7343
- const ask = `Define atris/team/${name}/MISSION.md with a concrete North Star before this member wakes itself.`;
7344
- return {
7345
- decision: 'ask',
7346
- reason: 'mission_missing_or_placeholder',
7347
- needs_user: true,
7348
- ask,
7349
- next_command: `edit atris/team/${name}/MISSION.md`,
7350
- state,
7351
- goal: goal || null,
7352
- current_experiment: current || null,
7353
- checks,
7354
- mission: {
7355
- north_star: purpose.northStar || null,
7356
- runtime_id: purpose.runtimeMission.id || null,
7357
- runtime_status: purpose.runtimeMission.status || null,
7358
- runtime_next: purpose.runtimeMission.next || null,
7359
- },
7360
- steering,
7361
- evidence,
7362
- workspace,
7363
- };
7364
- }
7365
-
7366
- const discoveredTask = evidence.problem_discovery?.selected || null;
7367
- if (discoveredTask?.source === 'log_error_scan' && discoveredTask.autonomous_action === 'create_task') {
7368
- return {
7369
- decision: 'create_task',
7370
- reason: 'autonomous_error_discovery:log_error_scan',
7371
- needs_user: false,
7372
- ask: null,
7373
- next_command: discoveredTask.next_command,
7374
- state,
7375
- goal: goal || null,
7376
- current_experiment: current || null,
7377
- autonomous_problem: discoveredTask,
7378
- checks,
7379
- mission: {
7380
- north_star: purpose.northStar,
7381
- runtime_id: purpose.runtimeMission.id || null,
7382
- runtime_status: purpose.runtimeMission.status || null,
7383
- runtime_next: purpose.runtimeMission.next || null,
7384
- },
7385
- steering,
7386
- evidence,
7387
- workspace,
7388
- };
7522
+ const ctx = { name, force, purpose, steering, state, goal, current, blocked, evidence, workspace, checks, wakeScores };
7523
+ for (const rule of WAKE_DECISION_RULES) {
7524
+ const verdict = rule(ctx);
7525
+ if (verdict) return composeWakeDecision(ctx, verdict);
7389
7526
  }
7390
-
7391
- if (!goal) {
7392
- const discoveredProblem = evidence.problem_discovery?.selected || null;
7393
- if (discoveredProblem) {
7394
- return {
7395
- decision: 'set_objective',
7396
- reason: `autonomous_problem_discovery:${discoveredProblem.source}`,
7397
- needs_user: false,
7398
- ask: null,
7399
- next_command: discoveredProblem.next_command || `atris member wake ${name} --execute --confirm-autonomy-policy`,
7400
- state,
7401
- goal: null,
7402
- current_experiment: null,
7403
- autonomous_problem: discoveredProblem,
7404
- checks,
7405
- mission: {
7406
- north_star: purpose.northStar,
7407
- runtime_id: purpose.runtimeMission.id || null,
7408
- runtime_status: purpose.runtimeMission.status || null,
7409
- runtime_next: purpose.runtimeMission.next || null,
7410
- },
7411
- steering,
7412
- evidence,
7413
- workspace,
7414
- };
7415
- }
7416
- return {
7417
- decision: 'stop',
7418
- reason: 'no_active_goal',
7419
- needs_user: false,
7420
- ask: null,
7421
- next_command: `atris member goal-from-mission ${name}`,
7422
- state,
7423
- goal: null,
7424
- current_experiment: null,
7425
- autonomous_problem: null,
7426
- checks,
7427
- mission: {
7428
- north_star: purpose.northStar,
7429
- runtime_id: purpose.runtimeMission.id || null,
7430
- runtime_status: purpose.runtimeMission.status || null,
7431
- runtime_next: purpose.runtimeMission.next || null,
7432
- },
7433
- steering,
7434
- evidence,
7435
- workspace,
7436
- };
7437
- }
7438
-
7439
- if (blocked) {
7440
- return {
7441
- decision: 'ask',
7442
- reason: 'blocked_experiment',
7443
- needs_user: true,
7444
- ask: blocked.block?.ask || 'Needs operator input before another wake.',
7445
- next_command: `atris member review ${name} ${blocked.id} --discard --proof "..."`,
7446
- state,
7447
- goal,
7448
- current_experiment: blocked,
7449
- checks,
7450
- mission: {
7451
- north_star: purpose.northStar,
7452
- runtime_id: purpose.runtimeMission.id || null,
7453
- runtime_status: purpose.runtimeMission.status || null,
7454
- runtime_next: purpose.runtimeMission.next || null,
7455
- },
7456
- steering,
7457
- evidence,
7458
- workspace,
7459
- };
7460
- }
7461
-
7462
- if (current) {
7463
- return {
7464
- decision: 'wait',
7465
- reason: `open_experiment_${current.status}`,
7466
- needs_user: false,
7467
- ask: null,
7468
- next_command: `atris member review ${name} ${current.id} --accept --proof "..." --value 4`,
7469
- state,
7470
- goal,
7471
- current_experiment: current,
7472
- checks,
7473
- mission: {
7474
- north_star: purpose.northStar,
7475
- runtime_id: purpose.runtimeMission.id || null,
7476
- runtime_status: purpose.runtimeMission.status || null,
7477
- runtime_next: purpose.runtimeMission.next || null,
7478
- },
7479
- steering,
7480
- evidence,
7481
- workspace,
7482
- };
7483
- }
7484
-
7485
- if (wakeScores.selected && wakeScores.selected.decision !== 'tick') {
7486
- const selected = wakeScores.selected;
7487
- const needsUser = selected.decision === 'ask';
7488
- return {
7489
- decision: selected.decision,
7490
- reason: selected.reason,
7491
- needs_user: needsUser,
7492
- ask: needsUser ? (selected.ask || `Need operator input for ${selected.title}.`) : null,
7493
- next_command: selected.next_command,
7494
- state,
7495
- goal,
7496
- current_experiment: null,
7497
- checks,
7498
- mission: {
7499
- north_star: purpose.northStar,
7500
- runtime_id: purpose.runtimeMission.id || null,
7501
- runtime_status: purpose.runtimeMission.status || null,
7502
- runtime_next: purpose.runtimeMission.next || null,
7503
- },
7504
- steering,
7505
- evidence,
7506
- workspace,
7507
- };
7508
- }
7509
-
7510
- if (!workspace.clean_for_member && !force) {
7511
- return {
7512
- decision: 'wait',
7513
- reason: 'workspace_dirty_in_member_scope',
7514
- needs_user: false,
7515
- ask: null,
7516
- next_command: `commit/stash files in atris/team/${name}/ (or member scope) — or rerun: atris member wake ${name} --force`,
7517
- state,
7518
- goal,
7519
- current_experiment: null,
7520
- checks,
7521
- mission: {
7522
- north_star: purpose.northStar,
7523
- runtime_id: purpose.runtimeMission.id || null,
7524
- runtime_status: purpose.runtimeMission.status || null,
7525
- runtime_next: purpose.runtimeMission.next || null,
7526
- },
7527
- steering,
7528
- evidence,
7529
- workspace,
7530
- };
7531
- }
7532
-
7533
- return {
7534
- decision: 'tick',
7535
- reason: 'safe_next_bounded_step',
7536
- needs_user: false,
7537
- ask: null,
7538
- next_command: `atris member tick ${name} --goal ${goal.id}`,
7539
- state,
7540
- goal,
7541
- current_experiment: null,
7542
- checks,
7543
- mission: {
7544
- north_star: purpose.northStar,
7545
- runtime_id: purpose.runtimeMission.id || null,
7546
- runtime_status: purpose.runtimeMission.status || null,
7547
- runtime_next: purpose.runtimeMission.next || null,
7548
- },
7549
- steering,
7550
- evidence,
7551
- workspace,
7552
- };
7527
+ return null;
7553
7528
  }
7554
7529
 
7555
7530
  async function runMemberWake(name, { execute = false, confirmed = false, force = false, domainInput = {} } = {}) {
@@ -7627,6 +7602,18 @@ async function runMemberWake(name, { execute = false, confirmed = false, force =
7627
7602
  evidence: planned.evidence,
7628
7603
  mission: planned.mission,
7629
7604
  });
7605
+ // An unreviewable fallback proposal no longer blocks wake (it deadlocked
7606
+ // members for weeks), but it must not accumulate either: one open
7607
+ // proposal per goal stays the invariant, so the stale fallback is
7608
+ // superseded by the one being proposed now.
7609
+ for (const prior of goal.experiments) {
7610
+ if (experimentIsUnreviewable(prior)) {
7611
+ prior.status = 'superseded';
7612
+ prior.superseded_at = now;
7613
+ prior.superseded_by = experiment.id;
7614
+ prior.superseded_reason = 'unreviewable_fallback_replaced_by_next_proposal';
7615
+ }
7616
+ }
7630
7617
  goal.experiments.push(experiment);
7631
7618
  goal.history = Array.isArray(goal.history) ? goal.history : [];
7632
7619
  goal.history.push({ at: now, event: 'wake_tick_proposed_experiment', experiment_id: experiment.id });
@@ -7971,84 +7958,60 @@ function memberAliveAll(...args) {
7971
7958
  return memberLoopAll('alive', ...args);
7972
7959
  }
7973
7960
 
7974
- async function memberLoop(name, ...args) {
7975
- if (name === '--all' || name === 'all') return memberLoopAll('loop', ...args);
7976
- requireMemberDir(name);
7977
- const asJson = hasFlag(args, '--json');
7978
- const aliveMode = hasFlag(args, '--alive');
7979
- const execute = hasFlag(args, '--execute');
7980
- const confirmed = hasFlag(args, '--confirm-autonomy-policy');
7981
- const force = hasFlag(args, '--force');
7982
- const stop = hasFlag(args, '--stop');
7983
- const status = hasFlag(args, '--status');
7984
- const agentFlag = String(readFlag(args, '--agent', '') || '').trim().toLowerCase();
7985
- const agent = agentFlag === 'claude' ? 'claude' : agentFlag === 'codex' ? 'codex' : undefined;
7986
- const model = String(readFlag(args, '--model', '') || '').trim() || undefined;
7987
- const operateMaxWall = Math.max(60, Math.min(1800, Number(readNumberFlag(args, '--operate-max-wall', readNumberFlag(args, '--max-wall', 900)))));
7988
- const autoAcceptLimit = Math.max(1, Math.floor(Number(readNumberFlag(args, '--auto-accept-limit', 8))));
7989
- const paths = memberLoopPaths(name);
7990
- fs.mkdirSync(paths.stateDir, { recursive: true });
7991
-
7992
- if (hasFlag(args, '--install')) {
7993
- return installMemberAliveCron(name, args);
7994
- }
7995
-
7996
- if (hasFlag(args, '--uninstall')) {
7997
- return uninstallMemberAliveCron(name, args);
7998
- }
7999
-
8000
- if (status) {
8001
- const active = readJsonIfExists(paths.lockPath);
8002
- const latest = readJsonIfExists(paths.latestPath);
8003
- const payload = {
8004
- ok: true,
8005
- action: 'loop_status',
8006
- member: name,
8007
- active: Boolean(active && Number(active.expires_at_ms || 0) > Date.now() && isPidAlive(active.pid)),
8008
- lease: active || null,
8009
- latest: latest || null,
8010
- hourly_alive: memberAliveCronStatus(name, paths),
8011
- lock_path: paths.lockPath,
8012
- latest_path: paths.latestPath,
8013
- };
8014
- printJsonOrText(payload, [
8015
- `Loop status: ${name}`,
8016
- `Active: ${payload.active ? 'yes' : 'no'}`,
8017
- `Hourly: ${payload.hourly_alive.installed ? 'installed' : payload.hourly_alive.script_exists ? 'script ready' : 'not installed'}`,
8018
- `Latest: ${latest?.receipt_path ? path.relative(process.cwd(), latest.receipt_path) : 'none'}`,
8019
- ], asJson);
8020
- return;
8021
- }
7961
+ // Loop phase: report lease + latest-receipt state without touching anything.
7962
+ function memberLoopStatusReport(name, paths, asJson) {
7963
+ const active = readJson(paths.lockPath);
7964
+ const latest = readJson(paths.latestPath);
7965
+ const payload = {
7966
+ ok: true,
7967
+ action: 'loop_status',
7968
+ member: name,
7969
+ active: Boolean(active && Number(active.expires_at_ms || 0) > Date.now() && isPidAlive(active.pid)),
7970
+ lease: active || null,
7971
+ latest: latest || null,
7972
+ hourly_alive: memberAliveCronStatus(name, paths),
7973
+ lock_path: paths.lockPath,
7974
+ latest_path: paths.latestPath,
7975
+ };
7976
+ printJsonOrText(payload, [
7977
+ `Loop status: ${name}`,
7978
+ `Active: ${payload.active ? 'yes' : 'no'}`,
7979
+ `Hourly: ${payload.hourly_alive.installed ? 'installed' : payload.hourly_alive.script_exists ? 'script ready' : 'not installed'}`,
7980
+ `Latest: ${latest?.receipt_path ? path.relative(process.cwd(), latest.receipt_path) : 'none'}`,
7981
+ ], asJson);
7982
+ }
8022
7983
 
8023
- if (stop) {
8024
- const requestedAt = stampIso();
8025
- const stopPayload = {
8026
- schema: 'atris.member_loop_stop.v1',
8027
- member: name,
8028
- requested_at: requestedAt,
8029
- pid: process.pid,
8030
- };
8031
- writeJsonFile(paths.stopPath, stopPayload);
8032
- const receiptPath = writeMemberLoopReceipt(name, {
8033
- ok: true,
8034
- action: 'loop_stop',
8035
- member: name,
8036
- requested_at: requestedAt,
8037
- stop_path: paths.stopPath,
8038
- });
8039
- printJsonOrText({
8040
- ok: true,
8041
- action: 'loop_stop',
8042
- member: name,
8043
- stop_path: paths.stopPath,
8044
- receipt_path: receiptPath,
8045
- }, [
8046
- `Stop requested for ${name}.`,
8047
- `Receipt: ${path.relative(process.cwd(), receiptPath)}`,
8048
- ], asJson);
8049
- return;
8050
- }
7984
+ // Loop phase: drop a stop file so the running loop breaks at its next tick boundary.
7985
+ function memberLoopRequestStop(name, paths, asJson) {
7986
+ const requestedAt = stampIso();
7987
+ const stopPayload = {
7988
+ schema: 'atris.member_loop_stop.v1',
7989
+ member: name,
7990
+ requested_at: requestedAt,
7991
+ pid: process.pid,
7992
+ };
7993
+ writeJson(paths.stopPath, stopPayload);
7994
+ const receiptPath = writeMemberLoopReceipt(name, {
7995
+ ok: true,
7996
+ action: 'loop_stop',
7997
+ member: name,
7998
+ requested_at: requestedAt,
7999
+ stop_path: paths.stopPath,
8000
+ });
8001
+ printJsonOrText({
8002
+ ok: true,
8003
+ action: 'loop_stop',
8004
+ member: name,
8005
+ stop_path: paths.stopPath,
8006
+ receipt_path: receiptPath,
8007
+ }, [
8008
+ `Stop requested for ${name}.`,
8009
+ `Receipt: ${path.relative(process.cwd(), receiptPath)}`,
8010
+ ], asJson);
8011
+ }
8051
8012
 
8013
+ // Loop phase: derive the timing plan (tick count, interval, duration, lease ttl) from flags.
8014
+ function memberLoopTimingPlan(name, args, { execute, confirmed }) {
8052
8015
  const ticksFlag = readNumberFlag(args, '--ticks', null);
8053
8016
  const minutes = readNumberFlag(args, '--minutes', null);
8054
8017
  const durationSeconds = readNumberFlag(args, '--duration-seconds', readNumberFlag(args, '--seconds', null));
@@ -8079,183 +8042,190 @@ async function memberLoop(name, ...args) {
8079
8042
  forever ? intervalMs + (2 * 60 * 60 * 1000) : durationMs + 60000,
8080
8043
  intervalMs + 60000,
8081
8044
  );
8045
+ return { ticksFlag, hourly, forever, intervalMs, durationMs, ticks, runId, startedAt, ttlMs };
8046
+ }
8082
8047
 
8083
- if (execute && !confirmed) {
8084
- const receiptPath = writeMemberLoopReceipt(name, {
8085
- ok: false,
8086
- action: 'loop',
8087
- member: name,
8088
- status: 'blocked',
8089
- reason: 'execute_requires_confirm_autonomy_policy',
8090
- mode: 'execute',
8091
- started_at: startedAt,
8092
- finished_at: stampIso(),
8093
- });
8094
- const payload = {
8095
- ok: false,
8096
- action: 'loop',
8097
- member: name,
8098
- status: 'blocked',
8099
- reason: 'execute_requires_confirm_autonomy_policy',
8100
- receipt_path: receiptPath,
8101
- };
8102
- writeJsonFile(paths.latestPath, payload);
8103
- printJsonOrText(payload, [
8104
- `Loop blocked for ${name}: execute requires --confirm-autonomy-policy.`,
8105
- `Receipt: ${path.relative(process.cwd(), receiptPath)}`,
8106
- ], asJson);
8107
- process.exitCode = 1;
8108
- return;
8109
- }
8048
+ // Loop phase: refuse to execute without the autonomy confirmation, with a receipt saying why.
8049
+ function memberLoopRefuseUnconfirmed(name, paths, { asJson, startedAt }) {
8050
+ const receiptPath = writeMemberLoopReceipt(name, {
8051
+ ok: false,
8052
+ action: 'loop',
8053
+ member: name,
8054
+ status: 'blocked',
8055
+ reason: 'execute_requires_confirm_autonomy_policy',
8056
+ mode: 'execute',
8057
+ started_at: startedAt,
8058
+ finished_at: stampIso(),
8059
+ });
8060
+ const payload = {
8061
+ ok: false,
8062
+ action: 'loop',
8063
+ member: name,
8064
+ status: 'blocked',
8065
+ reason: 'execute_requires_confirm_autonomy_policy',
8066
+ receipt_path: receiptPath,
8067
+ };
8068
+ writeJson(paths.latestPath, payload);
8069
+ printJsonOrText(payload, [
8070
+ `Loop blocked for ${name}: execute requires --confirm-autonomy-policy.`,
8071
+ `Receipt: ${path.relative(process.cwd(), receiptPath)}`,
8072
+ ], asJson);
8073
+ process.exitCode = 1;
8074
+ }
8110
8075
 
8111
- const lease = acquireMemberLoopLease(name, { runId, ttlMs });
8112
- if (!lease.acquired) {
8113
- const receiptPath = writeMemberLoopReceipt(name, {
8114
- ok: true,
8115
- action: 'loop',
8116
- member: name,
8117
- status: 'skipped',
8118
- reason: 'loop_already_active',
8119
- mode: execute ? 'execute' : 'dry_run',
8120
- active_lease: lease.lease || null,
8121
- started_at: startedAt,
8122
- finished_at: stampIso(),
8123
- });
8124
- const payload = {
8125
- ok: true,
8126
- action: 'loop',
8127
- member: name,
8128
- status: 'skipped',
8129
- reason: 'loop_already_active',
8130
- ticks: 0,
8131
- receipt_path: receiptPath,
8132
- active_lease: lease.lease || null,
8133
- };
8134
- writeJsonFile(paths.latestPath, payload);
8135
- printJsonOrText(payload, [
8136
- `Loop skipped for ${name}: another loop is active.`,
8137
- `Receipt: ${path.relative(process.cwd(), receiptPath)}`,
8138
- ], asJson);
8139
- return;
8140
- }
8076
+ // Loop phase: another loop holds the lease, so skip this run and leave a receipt behind.
8077
+ function memberLoopSkipAlreadyActive(name, paths, lease, { asJson, execute, startedAt }) {
8078
+ const receiptPath = writeMemberLoopReceipt(name, {
8079
+ ok: true,
8080
+ action: 'loop',
8081
+ member: name,
8082
+ status: 'skipped',
8083
+ reason: 'loop_already_active',
8084
+ mode: execute ? 'execute' : 'dry_run',
8085
+ active_lease: lease.lease || null,
8086
+ started_at: startedAt,
8087
+ finished_at: stampIso(),
8088
+ });
8089
+ const payload = {
8090
+ ok: true,
8091
+ action: 'loop',
8092
+ member: name,
8093
+ status: 'skipped',
8094
+ reason: 'loop_already_active',
8095
+ ticks: 0,
8096
+ receipt_path: receiptPath,
8097
+ active_lease: lease.lease || null,
8098
+ };
8099
+ writeJson(paths.latestPath, payload);
8100
+ printJsonOrText(payload, [
8101
+ `Loop skipped for ${name}: another loop is active.`,
8102
+ `Receipt: ${path.relative(process.cwd(), receiptPath)}`,
8103
+ ], asJson);
8104
+ }
8105
+
8106
+ // Tick phase (alive mode): run the full operate pipeline once and record what it did.
8107
+ function memberLoopAliveTick(name, run, index, tickStartedAt) {
8108
+ const { options, state } = run;
8109
+ const alive = runAliveTick(name, {
8110
+ execute: options.execute,
8111
+ confirmed: options.confirmed,
8112
+ force: options.force,
8113
+ agent: options.agent,
8114
+ model: options.model,
8115
+ maxWallSeconds: options.operateMaxWall,
8116
+ autoAcceptLimit: options.autoAcceptLimit,
8117
+ noPrime: index > 0,
8118
+ });
8119
+ const key = `${alive.status || 'alive'}:${alive.reason || 'tick'}`;
8120
+ state.decisions[key] = (state.decisions[key] || 0) + 1;
8121
+ const tick = {
8122
+ tick: index + 1,
8123
+ started_at: tickStartedAt,
8124
+ finished_at: alive.finished_at || stampIso(),
8125
+ ok: alive.ok !== false,
8126
+ decision: alive.status || 'alive_tick',
8127
+ reason: alive.reason || null,
8128
+ executed: options.execute,
8129
+ needs_user: alive.needs_user === true,
8130
+ next_command: alive.next_command || null,
8131
+ has_mission: alive.has_mission === true,
8132
+ has_goal: alive.has_goal === true,
8133
+ has_steering: false,
8134
+ productive: alive.status === 'completed',
8135
+ blocked_on_human: alive.blocked_on_human === true,
8136
+ operate_ok: alive.operate?.ok,
8137
+ auto_accept_accepted: alive.auto_accept?.json?.summary?.accepted ?? alive.auto_accept?.json?.summary?.would_accept,
8138
+ receipt_path: alive.receipt_path || alive.operate?.receipt_path || null,
8139
+ alive,
8140
+ };
8141
+ state.tickResults.push(tick);
8142
+ fs.appendFileSync(state.tickLogPath, JSON.stringify(tick) + '\n', 'utf8');
8143
+ }
8144
+
8145
+ // Tick phase (wake mode): take one wake decision, record it, and notice a member stuck
8146
+ // re-asking the same question so the loop can hand off instead of burning remaining ticks.
8147
+ async function memberLoopWakeTick(name, run, index, tickStartedAt) {
8148
+ const { options, state } = run;
8149
+ const wake = await runMemberWake(name, { execute: options.execute, confirmed: options.confirmed, force: options.force });
8150
+ const key = `${wake.decision}:${wake.reason}`;
8151
+ state.decisions[key] = (state.decisions[key] || 0) + 1;
8152
+ const tick = {
8153
+ tick: index + 1,
8154
+ started_at: tickStartedAt,
8155
+ finished_at: stampIso(),
8156
+ ok: true,
8157
+ decision: wake.decision,
8158
+ reason: wake.reason,
8159
+ executed: wake.executed,
8160
+ needs_user: wake.needs_user,
8161
+ next_command: wake.next_command,
8162
+ has_mission: wake.checks?.has_mission === true,
8163
+ has_goal: wake.checks?.has_goal === true,
8164
+ has_steering: wake.checks?.has_steering === true,
8165
+ productive: wake.executed === true
8166
+ || /executed/.test(wake.reason || '')
8167
+ || ['tick', 'close_loop', 'report_proof', 'create_missing_task', 'create_task', 'set_objective'].includes(wake.decision),
8168
+ blocked_on_human: wake.needs_user === true || /^open_experiment_/.test(wake.reason || ''),
8169
+ current_experiment: wake.current_experiment?.id || null,
8170
+ receipt_path: wake.receipt_path,
8171
+ };
8172
+ state.tickResults.push(tick);
8173
+ fs.appendFileSync(state.tickLogPath, JSON.stringify(tick) + '\n', 'utf8');
8141
8174
 
8142
- fs.rmSync(paths.stopPath, { force: true });
8143
- const tickLogPath = path.join(process.cwd(), 'atris', 'runs', `${runId}.jsonl`);
8144
- fs.mkdirSync(path.dirname(tickLogPath), { recursive: true });
8145
- const tickResults = [];
8146
- const decisions = {};
8147
- let stopped = false;
8148
- let failed = false;
8149
- // Stop spinning: if the member can do no real work for this many ticks in a row (waiting on a
8150
- // human review, blocked, or no goal), break early and surface a clear handoff instead of
8151
- // burning every remaining tick on the same no-op decision.
8152
- let earlyExit = null;
8153
- let consecutiveIdle = 0;
8154
- const idleBreakThreshold = 2;
8155
8175
  // Repeated identical `ask:*` wake decisions (e.g. ask:mission_missing_or_placeholder) mean the
8156
8176
  // member is stuck asking the same question every tick regardless of --execute; the idle
8157
- // early-exit above only fires in execute mode, so without this a dry-run loop burns every
8177
+ // early-exit only fires in execute mode, so without this a dry-run loop burns every
8158
8178
  // remaining tick re-asking. Reuse the same blocked_on_human handoff once we see the ask repeat.
8159
- let lastAskKey = null;
8160
- let consecutiveIdenticalAsk = 0;
8161
- const identicalAskBreakThreshold = 2;
8179
+ if (wake.decision === 'ask') {
8180
+ if (key === state.lastAskKey) {
8181
+ state.consecutiveIdenticalAsk += 1;
8182
+ } else {
8183
+ state.lastAskKey = key;
8184
+ state.consecutiveIdenticalAsk = 1;
8185
+ }
8186
+ if (state.consecutiveIdenticalAsk >= MEMBER_LOOP_IDENTICAL_ASK_BREAK_THRESHOLD) {
8187
+ state.earlyExit = {
8188
+ after_tick: tick.tick,
8189
+ decision: tick.decision || null,
8190
+ reason: tick.reason || 'idle',
8191
+ needs_user: tick.needs_user === true,
8192
+ blocked_on_human: true,
8193
+ next_command: tick.next_command || null,
8194
+ stop: 'repeated_identical_ask',
8195
+ };
8196
+ }
8197
+ } else {
8198
+ state.lastAskKey = null;
8199
+ state.consecutiveIdenticalAsk = 0;
8200
+ }
8201
+ }
8162
8202
 
8203
+ // Stop spinning: if the member can do no real work for this many ticks in a row (waiting on a
8204
+ // human review, blocked, or no goal), break early and surface a clear handoff instead of
8205
+ // burning every remaining tick on the same no-op decision.
8206
+ const MEMBER_LOOP_IDLE_BREAK_THRESHOLD = 2;
8207
+ const MEMBER_LOOP_IDENTICAL_ASK_BREAK_THRESHOLD = 2;
8208
+
8209
+ // Loop phase: control. Hold the lease, honor the stop file, pace ticks by interval, and
8210
+ // break early when the member is idling instead of faking activity.
8211
+ async function memberLoopRunTicks(name, paths, lease, run) {
8212
+ const { options, plan, state } = run;
8163
8213
  try {
8164
- for (let index = 0; index < ticks; index += 1) {
8214
+ for (let index = 0; index < plan.ticks; index += 1) {
8165
8215
  if (fs.existsSync(paths.stopPath)) {
8166
- stopped = true;
8216
+ state.stopped = true;
8167
8217
  break;
8168
8218
  }
8169
- refreshMemberLoopLease(paths, lease.lease, ttlMs);
8219
+ refreshMemberLoopLease(paths, lease.lease, plan.ttlMs);
8170
8220
  const tickStartedAt = stampIso();
8171
8221
  try {
8172
- if (aliveMode) {
8173
- const alive = runAliveTick(name, {
8174
- execute,
8175
- confirmed,
8176
- force,
8177
- agent,
8178
- model,
8179
- maxWallSeconds: operateMaxWall,
8180
- autoAcceptLimit,
8181
- noPrime: index > 0,
8182
- });
8183
- const key = `${alive.status || 'alive'}:${alive.reason || 'tick'}`;
8184
- decisions[key] = (decisions[key] || 0) + 1;
8185
- const tick = {
8186
- tick: index + 1,
8187
- started_at: tickStartedAt,
8188
- finished_at: alive.finished_at || stampIso(),
8189
- ok: alive.ok !== false,
8190
- decision: alive.status || 'alive_tick',
8191
- reason: alive.reason || null,
8192
- executed: execute,
8193
- needs_user: alive.needs_user === true,
8194
- next_command: alive.next_command || null,
8195
- has_mission: alive.has_mission === true,
8196
- has_goal: alive.has_goal === true,
8197
- has_steering: false,
8198
- productive: alive.status === 'completed',
8199
- blocked_on_human: alive.blocked_on_human === true,
8200
- operate_ok: alive.operate?.ok,
8201
- auto_accept_accepted: alive.auto_accept?.json?.summary?.accepted ?? alive.auto_accept?.json?.summary?.would_accept,
8202
- receipt_path: alive.receipt_path || alive.operate?.receipt_path || null,
8203
- alive,
8204
- };
8205
- tickResults.push(tick);
8206
- fs.appendFileSync(tickLogPath, JSON.stringify(tick) + '\n', 'utf8');
8222
+ if (options.aliveMode) {
8223
+ memberLoopAliveTick(name, run, index, tickStartedAt);
8207
8224
  } else {
8208
- const wake = await runMemberWake(name, { execute, confirmed, force });
8209
- const key = `${wake.decision}:${wake.reason}`;
8210
- decisions[key] = (decisions[key] || 0) + 1;
8211
- const tick = {
8212
- tick: index + 1,
8213
- started_at: tickStartedAt,
8214
- finished_at: stampIso(),
8215
- ok: true,
8216
- decision: wake.decision,
8217
- reason: wake.reason,
8218
- executed: wake.executed,
8219
- needs_user: wake.needs_user,
8220
- next_command: wake.next_command,
8221
- has_mission: wake.checks?.has_mission === true,
8222
- has_goal: wake.checks?.has_goal === true,
8223
- has_steering: wake.checks?.has_steering === true,
8224
- productive: wake.executed === true
8225
- || /executed/.test(wake.reason || '')
8226
- || ['tick', 'close_loop', 'report_proof', 'create_missing_task', 'create_task', 'set_objective'].includes(wake.decision),
8227
- blocked_on_human: wake.needs_user === true || /^open_experiment_/.test(wake.reason || ''),
8228
- current_experiment: wake.current_experiment?.id || null,
8229
- receipt_path: wake.receipt_path,
8230
- };
8231
- tickResults.push(tick);
8232
- fs.appendFileSync(tickLogPath, JSON.stringify(tick) + '\n', 'utf8');
8233
-
8234
- if (wake.decision === 'ask') {
8235
- if (key === lastAskKey) {
8236
- consecutiveIdenticalAsk += 1;
8237
- } else {
8238
- lastAskKey = key;
8239
- consecutiveIdenticalAsk = 1;
8240
- }
8241
- if (consecutiveIdenticalAsk >= identicalAskBreakThreshold) {
8242
- earlyExit = {
8243
- after_tick: tick.tick,
8244
- decision: tick.decision || null,
8245
- reason: tick.reason || 'idle',
8246
- needs_user: tick.needs_user === true,
8247
- blocked_on_human: true,
8248
- next_command: tick.next_command || null,
8249
- stop: 'repeated_identical_ask',
8250
- };
8251
- }
8252
- } else {
8253
- lastAskKey = null;
8254
- consecutiveIdenticalAsk = 0;
8255
- }
8225
+ await memberLoopWakeTick(name, run, index, tickStartedAt);
8256
8226
  }
8257
8227
  } catch (error) {
8258
- failed = true;
8228
+ state.failed = true;
8259
8229
  const tick = {
8260
8230
  tick: index + 1,
8261
8231
  started_at: tickStartedAt,
@@ -8263,19 +8233,19 @@ async function memberLoop(name, ...args) {
8263
8233
  ok: false,
8264
8234
  error: error instanceof Error ? error.message : String(error),
8265
8235
  };
8266
- tickResults.push(tick);
8267
- fs.appendFileSync(tickLogPath, JSON.stringify(tick) + '\n', 'utf8');
8236
+ state.tickResults.push(tick);
8237
+ fs.appendFileSync(state.tickLogPath, JSON.stringify(tick) + '\n', 'utf8');
8268
8238
  break;
8269
8239
  }
8270
- if (earlyExit) break;
8271
- if (execute) {
8272
- const last = tickResults[tickResults.length - 1];
8240
+ if (state.earlyExit) break;
8241
+ if (options.execute) {
8242
+ const last = state.tickResults[state.tickResults.length - 1];
8273
8243
  if (last && last.productive) {
8274
- consecutiveIdle = 0;
8244
+ state.consecutiveIdle = 0;
8275
8245
  } else if (last) {
8276
- consecutiveIdle += 1;
8277
- if (consecutiveIdle >= idleBreakThreshold) {
8278
- earlyExit = {
8246
+ state.consecutiveIdle += 1;
8247
+ if (state.consecutiveIdle >= MEMBER_LOOP_IDLE_BREAK_THRESHOLD) {
8248
+ state.earlyExit = {
8279
8249
  after_tick: last.tick,
8280
8250
  decision: last.decision || null,
8281
8251
  reason: last.reason || 'idle',
@@ -8287,12 +8257,19 @@ async function memberLoop(name, ...args) {
8287
8257
  }
8288
8258
  }
8289
8259
  }
8290
- if (index < ticks - 1 && intervalMs > 0) sleepSync(intervalMs);
8260
+ if (index < plan.ticks - 1 && plan.intervalMs > 0) sleepSync(plan.intervalMs);
8291
8261
  }
8292
8262
  } finally {
8293
8263
  releaseMemberLoopLease(paths, lease.lease);
8294
8264
  }
8265
+ }
8295
8266
 
8267
+ // Loop phase: result recording. One summary object, one receipt, one latest pointer, one report.
8268
+ function memberLoopRecordResult(name, paths, lease, run) {
8269
+ const { options, plan, state } = run;
8270
+ const { execute, aliveMode, asJson } = options;
8271
+ const { hourly, forever, runId, ticks, intervalMs, durationMs, startedAt } = plan;
8272
+ const { tickResults, decisions, stopped, failed, earlyExit, tickLogPath } = state;
8296
8273
  const finishedAt = stampIso();
8297
8274
  const summary = {
8298
8275
  ok: !failed,
@@ -8334,7 +8311,7 @@ async function memberLoop(name, ...args) {
8334
8311
  };
8335
8312
  const receiptPath = writeMemberLoopReceipt(name, summary);
8336
8313
  const payload = { ...summary, receipt_path: receiptPath };
8337
- writeJsonFile(paths.latestPath, payload);
8314
+ writeJson(paths.latestPath, payload);
8338
8315
  printJsonOrText(payload, [
8339
8316
  `${aliveMode ? 'Alive' : 'Loop'}: ${name}`,
8340
8317
  `Status: ${payload.status}`,
@@ -8346,6 +8323,71 @@ async function memberLoop(name, ...args) {
8346
8323
  ], asJson);
8347
8324
  }
8348
8325
 
8326
+ // Driver: the phases above do the work; this reads as setup -> gate -> run -> record.
8327
+ async function memberLoop(name, ...args) {
8328
+ if (name === '--all' || name === 'all') return memberLoopAll('loop', ...args);
8329
+ requireMemberDir(name);
8330
+ const asJson = hasFlag(args, '--json');
8331
+ const aliveMode = hasFlag(args, '--alive');
8332
+ const execute = hasFlag(args, '--execute');
8333
+ const confirmed = hasFlag(args, '--confirm-autonomy-policy');
8334
+ const force = hasFlag(args, '--force');
8335
+ const agentFlag = String(readFlag(args, '--agent', '') || '').trim().toLowerCase();
8336
+ const agent = agentFlag === 'claude' ? 'claude' : agentFlag === 'codex' ? 'codex' : undefined;
8337
+ const model = String(readFlag(args, '--model', '') || '').trim() || undefined;
8338
+ const operateMaxWall = Math.max(60, Math.min(1800, Number(readNumberFlag(args, '--operate-max-wall', readNumberFlag(args, '--max-wall', 900)))));
8339
+ const autoAcceptLimit = Math.max(1, Math.floor(Number(readNumberFlag(args, '--auto-accept-limit', 8))));
8340
+ const options = { asJson, aliveMode, execute, confirmed, force, agent, model, operateMaxWall, autoAcceptLimit };
8341
+ const paths = memberLoopPaths(name);
8342
+ fs.mkdirSync(paths.stateDir, { recursive: true });
8343
+
8344
+ if (hasFlag(args, '--install')) {
8345
+ return installMemberAliveCron(name, args);
8346
+ }
8347
+
8348
+ if (hasFlag(args, '--uninstall')) {
8349
+ return uninstallMemberAliveCron(name, args);
8350
+ }
8351
+
8352
+ if (hasFlag(args, '--status')) {
8353
+ return memberLoopStatusReport(name, paths, asJson);
8354
+ }
8355
+
8356
+ if (hasFlag(args, '--stop')) {
8357
+ return memberLoopRequestStop(name, paths, asJson);
8358
+ }
8359
+
8360
+ const plan = memberLoopTimingPlan(name, args, { execute, confirmed });
8361
+
8362
+ if (execute && !confirmed) {
8363
+ return memberLoopRefuseUnconfirmed(name, paths, { asJson, startedAt: plan.startedAt });
8364
+ }
8365
+
8366
+ const lease = acquireMemberLoopLease(name, { runId: plan.runId, ttlMs: plan.ttlMs });
8367
+ if (!lease.acquired) {
8368
+ return memberLoopSkipAlreadyActive(name, paths, lease, { asJson, execute, startedAt: plan.startedAt });
8369
+ }
8370
+
8371
+ fs.rmSync(paths.stopPath, { force: true });
8372
+ const tickLogPath = path.join(process.cwd(), 'atris', 'runs', `${plan.runId}.jsonl`);
8373
+ fs.mkdirSync(path.dirname(tickLogPath), { recursive: true });
8374
+ const state = {
8375
+ tickResults: [],
8376
+ decisions: {},
8377
+ stopped: false,
8378
+ failed: false,
8379
+ earlyExit: null,
8380
+ consecutiveIdle: 0,
8381
+ lastAskKey: null,
8382
+ consecutiveIdenticalAsk: 0,
8383
+ tickLogPath,
8384
+ };
8385
+
8386
+ const run = { options, plan, state };
8387
+ await memberLoopRunTicks(name, paths, lease, run);
8388
+ memberLoopRecordResult(name, paths, lease, run);
8389
+ }
8390
+
8349
8391
  async function memberTick(name, ...args) {
8350
8392
  const paths = requireMemberDir(name);
8351
8393
  const asJson = hasFlag(args, '--json');
@@ -9069,4 +9111,11 @@ async function memberCommand(subcommand, ...args) {
9069
9111
  }
9070
9112
  }
9071
9113
 
9072
- module.exports = { memberCommand, findAllMembers, parseFrontmatter, wakeBootLines, buildMemberRunStartArgs };
9114
+ module.exports = {
9115
+ memberCommand,
9116
+ findAllMembers,
9117
+ findWorkspaceBusinessId,
9118
+ parseFrontmatter,
9119
+ wakeBootLines,
9120
+ buildMemberRunStartArgs,
9121
+ };