atris 3.38.0 → 3.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/AGENTS.md +25 -6
  2. package/atris/PERSONA.md +8 -4
  3. package/atris.md +7 -0
  4. package/ax +2 -1
  5. package/bin/atris.js +31 -6
  6. package/commands/agent-spawn.js +13 -11
  7. package/commands/autoland.js +28 -77
  8. package/commands/bench.js +10 -12
  9. package/commands/business.js +345 -0
  10. package/commands/chat-scan.js +5 -7
  11. package/commands/codex-goal.js +8 -10
  12. package/commands/computer.js +20 -0
  13. package/commands/console.js +19 -3
  14. package/commands/decide.js +166 -0
  15. package/commands/deck.js +1 -4
  16. package/commands/drill.js +14 -24
  17. package/commands/engine.js +196 -8
  18. package/commands/gm.js +8 -6
  19. package/commands/harvest.js +1 -4
  20. package/commands/init.js +23 -3
  21. package/commands/land.js +8 -14
  22. package/commands/launchpad.js +1 -14
  23. package/commands/lifecycle.js +5 -5
  24. package/commands/log.js +55 -5
  25. package/commands/member.js +558 -574
  26. package/commands/mission.js +456 -237
  27. package/commands/pack.js +2746 -164
  28. package/commands/play.js +6 -4
  29. package/commands/probe.js +2 -2
  30. package/commands/pulse.js +15 -16
  31. package/commands/release.js +10 -9
  32. package/commands/router.js +5 -4
  33. package/commands/site-deploy.js +885 -0
  34. package/commands/site.js +11 -2
  35. package/commands/slop.js +14 -2
  36. package/commands/stream.js +4 -18
  37. package/commands/task.js +880 -558
  38. package/commands/taste.js +101 -0
  39. package/commands/team.js +176 -3
  40. package/commands/vercel.js +4 -2
  41. package/commands/voice.js +195 -0
  42. package/commands/watch.js +1 -22
  43. package/commands/wiki.js +1 -4
  44. package/commands/workflow.js +2 -2
  45. package/commands/worktree.js +1 -14
  46. package/commands/xp.js +27 -24
  47. package/lib/accept-verify-gate.js +5 -1
  48. package/lib/arg-parser.js +41 -0
  49. package/lib/auto-accept-certified.js +116 -1
  50. package/lib/autoland.js +66 -0
  51. package/lib/bench/runner.js +19 -1
  52. package/lib/context-gatherer.js +7 -1
  53. package/lib/engine-registry.js +141 -20
  54. package/lib/falsifier-probe.js +84 -0
  55. package/lib/fleet.js +65 -15
  56. package/lib/git-spawn.js +15 -0
  57. package/lib/json-file.js +37 -0
  58. package/lib/known-commands.js +2 -2
  59. package/lib/lesson-preflight.js +146 -0
  60. package/lib/loop-doctor.js +0 -2
  61. package/lib/mission-human-asks.js +28 -0
  62. package/lib/mission-protected-lane.js +4 -1
  63. package/lib/official-cli-integration.js +47 -2
  64. package/lib/orb-context.js +8 -1
  65. package/lib/pack-capabilities.js +685 -0
  66. package/lib/router-brain.js +51 -1
  67. package/lib/runner-command.js +0 -6
  68. package/lib/self-drive.js +44 -13
  69. package/lib/task-db.js +137 -3
  70. package/lib/task-decision.js +50 -0
  71. package/lib/taste-lessons.js +153 -0
  72. package/lib/tool-result-encode.js +17 -1
  73. package/lib/voice-gate.js +66 -0
  74. package/lib/wish-audit.js +1 -1
  75. package/lib/wish-delegate.js +1 -1
  76. package/lib/zip.js +95 -7
  77. package/package.json +2 -1
  78. package/templates/business-starter/persona.md +9 -0
@@ -7,6 +7,8 @@ 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');
10
12
 
11
13
  function findWorkspaceBusinessId(startDir = process.cwd()) {
12
14
  let dir = path.resolve(startDir);
@@ -93,27 +95,6 @@ function parseConfirmFlag(flags) {
93
95
  return joined.match(/--confirm[=\s]+["']?([^"']+)["']?/)?.[1] || '';
94
96
  }
95
97
 
96
- function hasFlag(args, name) {
97
- return args.includes(name);
98
- }
99
-
100
- function readFlag(args, name, fallback = '') {
101
- const prefix = `${name}=`;
102
- for (let i = 0; i < args.length; i += 1) {
103
- const arg = args[i];
104
- if (arg === name && args[i + 1] && !String(args[i + 1]).startsWith('--')) return args[i + 1];
105
- if (String(arg).startsWith(prefix)) return String(arg).slice(prefix.length).replace(/^["']|["']$/g, '');
106
- }
107
- return fallback;
108
- }
109
-
110
- function readNumberFlag(args, name, fallback = null) {
111
- const raw = readFlag(args, name, '');
112
- if (!raw) return fallback;
113
- const value = Number(raw);
114
- return Number.isFinite(value) ? value : null;
115
- }
116
-
117
98
  function readRepeatedFlag(args, name) {
118
99
  const values = [];
119
100
  const prefix = `${name}=`;
@@ -365,17 +346,12 @@ function emptyMemberGoals(name) {
365
346
  }
366
347
 
367
348
  function loadMemberGoals(name, paths = memberPaths(name)) {
368
- if (!fs.existsSync(paths.goalsJson)) return emptyMemberGoals(name);
369
- try {
370
- const parsed = JSON.parse(fs.readFileSync(paths.goalsJson, 'utf8'));
371
- return {
372
- ...emptyMemberGoals(name),
373
- ...parsed,
374
- goals: Array.isArray(parsed.goals) ? parsed.goals : [],
375
- };
376
- } catch {
377
- return emptyMemberGoals(name);
378
- }
349
+ const parsed = readJson(paths.goalsJson, {}) || {};
350
+ return {
351
+ ...emptyMemberGoals(name),
352
+ ...parsed,
353
+ goals: Array.isArray(parsed.goals) ? parsed.goals : [],
354
+ };
379
355
  }
380
356
 
381
357
  function activeGoal(goalsState, goalId = '') {
@@ -1411,7 +1387,7 @@ function taskIsClosed(task) {
1411
1387
 
1412
1388
  function taskProjectionRows() {
1413
1389
  const projectionPath = path.join(process.cwd(), '.atris', 'state', 'tasks.projection.json');
1414
- const projection = readJsonIfExists(projectionPath);
1390
+ const projection = readJson(projectionPath);
1415
1391
  return Array.isArray(projection?.tasks) ? projection.tasks : [];
1416
1392
  }
1417
1393
 
@@ -1511,7 +1487,7 @@ function sortEvidenceCandidates(candidates) {
1511
1487
 
1512
1488
  function readTaskProjectionEvidence(name) {
1513
1489
  const projectionPath = path.join(process.cwd(), '.atris', 'state', 'tasks.projection.json');
1514
- const projection = readJsonIfExists(projectionPath);
1490
+ const projection = readJson(projectionPath);
1515
1491
  const tasks = taskProjectionRows();
1516
1492
  const candidates = sortEvidenceCandidates(
1517
1493
  tasks
@@ -1663,7 +1639,7 @@ function latestActionableUserLine(thread) {
1663
1639
  function readMemberRoomEvidence(name) {
1664
1640
  const roots = [path.join(process.cwd(), '.obelisk', 'threads')];
1665
1641
  const projectsPath = path.join(os.homedir(), '.obelisk', 'projects.json');
1666
- const projects = readJsonIfExists(projectsPath);
1642
+ const projects = readJson(projectsPath);
1667
1643
  if (Array.isArray(projects)) {
1668
1644
  const cwd = path.resolve(process.cwd());
1669
1645
  const project = projects.find((item) => item?.path && path.resolve(item.path) === cwd && item.id);
@@ -1676,7 +1652,7 @@ function readMemberRoomEvidence(name) {
1676
1652
  for (const item of listThreadJsonFiles(root)) {
1677
1653
  if (seen.has(item.path)) continue;
1678
1654
  seen.add(item.path);
1679
- const thread = readJsonIfExists(item.path);
1655
+ const thread = readJson(item.path);
1680
1656
  files_checked += 1;
1681
1657
  const context = thread?.atrisContext || {};
1682
1658
  const linkedTasks = Array.isArray(context.linkedTasks) ? context.linkedTasks : [];
@@ -1717,11 +1693,11 @@ function readMemberRoomEvidence(name) {
1717
1693
  }
1718
1694
 
1719
1695
  function readRecentWakeReceiptEvidence(name) {
1720
- const latestLoop = readJsonIfExists(memberLoopPaths(name).latestPath);
1696
+ const latestLoop = readJson(memberLoopPaths(name).latestPath);
1721
1697
  const receiptPath = Array.isArray(latestLoop?.tick_receipts)
1722
1698
  ? latestLoop.tick_receipts.slice().reverse().find(Boolean)
1723
1699
  : null;
1724
- const latestWake = receiptPath ? readJsonIfExists(receiptPath) : null;
1700
+ const latestWake = receiptPath ? readJson(receiptPath) : null;
1725
1701
  return {
1726
1702
  latest_loop_path: memberLoopPaths(name).latestPath,
1727
1703
  latest_loop_status: latestLoop?.status || null,
@@ -1863,7 +1839,7 @@ function problemSignalSources(root = process.cwd()) {
1863
1839
 
1864
1840
  function rowsFromProblemSignalSource(source) {
1865
1841
  if (source.kind === 'jsonl') return readJsonlRowsIfExists(source.path, 80);
1866
- const row = readJsonIfExists(source.path);
1842
+ const row = readJson(source.path);
1867
1843
  return row ? [row] : [];
1868
1844
  }
1869
1845
 
@@ -1984,7 +1960,7 @@ function readMemberSignalSourceConfig(root = process.cwd()) {
1984
1960
  path.join(root, '.atris', 'member-signal-sources.json'),
1985
1961
  ];
1986
1962
  for (const configPath of candidates) {
1987
- const config = readJsonIfExists(configPath);
1963
+ const config = readJson(configPath);
1988
1964
  if (config && typeof config === 'object') return { config, configPath };
1989
1965
  }
1990
1966
  return { config: null, configPath: null };
@@ -2839,7 +2815,7 @@ function installMemberAliveCron(name, args = []) {
2839
2815
  stop_command: `atris member alive ${name} --uninstall`,
2840
2816
  error,
2841
2817
  };
2842
- writeJsonFile(paths.latestPath, payload);
2818
+ writeJson(paths.latestPath, payload);
2843
2819
  printJsonOrText(payload, [
2844
2820
  `${dryRun ? 'Would install' : installed ? 'Installed' : 'Install failed'} hourly alive loop: ${name}`,
2845
2821
  `Cron: ${crontabLine}`,
@@ -2907,14 +2883,6 @@ function writeMemberLoopReceipt(name, payload) {
2907
2883
  return receiptPath;
2908
2884
  }
2909
2885
 
2910
- function readJsonIfExists(filePath) {
2911
- try {
2912
- return JSON.parse(fs.readFileSync(filePath, 'utf8'));
2913
- } catch {
2914
- return null;
2915
- }
2916
- }
2917
-
2918
2886
  function isPidAlive(pid) {
2919
2887
  const numeric = Number(pid);
2920
2888
  if (!Number.isFinite(numeric) || numeric <= 0) return false;
@@ -2926,11 +2894,6 @@ function isPidAlive(pid) {
2926
2894
  }
2927
2895
  }
2928
2896
 
2929
- function writeJsonFile(filePath, payload) {
2930
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
2931
- fs.writeFileSync(filePath, JSON.stringify(payload, null, 2) + '\n', 'utf8');
2932
- }
2933
-
2934
2897
  function safeReadText(filePath, maxBytes = 250000) {
2935
2898
  try {
2936
2899
  const stat = fs.statSync(filePath);
@@ -2941,14 +2904,6 @@ function safeReadText(filePath, maxBytes = 250000) {
2941
2904
  }
2942
2905
  }
2943
2906
 
2944
- function safeReadJson(filePath) {
2945
- try {
2946
- return JSON.parse(fs.readFileSync(filePath, 'utf8'));
2947
- } catch {
2948
- return null;
2949
- }
2950
- }
2951
-
2952
2907
  function repoRelative(root, filePath) {
2953
2908
  return path.relative(root, filePath).replace(/\\/g, '/');
2954
2909
  }
@@ -3162,7 +3117,7 @@ function autoImproverTaskWaitingForHuman(task, status) {
3162
3117
 
3163
3118
  function collectAutoImproverTaskSignals(root) {
3164
3119
  const projectionPath = path.join(root, '.atris', 'state', 'tasks.projection.json');
3165
- const projection = safeReadJson(projectionPath);
3120
+ const projection = readJson(projectionPath);
3166
3121
  const tasks = Array.isArray(projection?.tasks) ? projection.tasks : [];
3167
3122
  const openStatuses = new Set(['open', 'todo', 'claimed', 'doing', 'in_progress', 'review', 'blocked']);
3168
3123
  const staleTasks = [];
@@ -3476,7 +3431,7 @@ function autoImproverTaskIsActionable(task) {
3476
3431
  }
3477
3432
 
3478
3433
  function findExistingAutoImproverTask(title) {
3479
- 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'));
3480
3435
  const tasks = Array.isArray(projection?.tasks) ? projection.tasks : [];
3481
3436
  const key = lowerCompact(stripAutoImproverTitleNoise(title));
3482
3437
  if (!key) return null;
@@ -3554,9 +3509,9 @@ function createAutoImproverTask(candidate, receiptPath) {
3554
3509
 
3555
3510
  function writeAutoImproverReceipt(name, payload, receiptPath = null) {
3556
3511
  const finalReceiptPath = receiptPath || path.join(process.cwd(), 'atris', 'runs', `auto-improver-dogfood-${fileSafeStamp()}.json`);
3557
- writeJsonFile(finalReceiptPath, payload);
3512
+ writeJson(finalReceiptPath, payload);
3558
3513
  const latestPath = path.join(process.cwd(), '.atris', 'state', 'auto-improver-dogfood-latest.json');
3559
- writeJsonFile(latestPath, { ...payload, receipt_path: finalReceiptPath });
3514
+ writeJson(latestPath, { ...payload, receipt_path: finalReceiptPath });
3560
3515
  return { receiptPath: finalReceiptPath, latestPath };
3561
3516
  }
3562
3517
 
@@ -3646,7 +3601,7 @@ async function runAutoImproverWake(name, paths, { execute = false, confirmed = f
3646
3601
  : 'No bounded prevented-fire task was created yet.',
3647
3602
  },
3648
3603
  };
3649
- 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'));
3650
3605
  const finalWrite = writeAutoImproverReceipt(name, payload, plannedReceiptPath);
3651
3606
  // A no-op scan identical to the previous tick earns a receipt but not a journal entry —
3652
3607
  // the cadence loop was appending the same "found: N, prevented: 0" row every ~4 minutes
@@ -3731,7 +3686,7 @@ function acquireMemberLoopLease(name, { runId, ttlMs }) {
3731
3686
  return writeLease();
3732
3687
  } catch (error) {
3733
3688
  if (error.code !== 'EEXIST') throw error;
3734
- const active = readJsonIfExists(paths.lockPath);
3689
+ const active = readJson(paths.lockPath);
3735
3690
  const expired = active && Number(active.expires_at_ms || 0) <= nowMs;
3736
3691
  const deadPid = active && !isPidAlive(active.pid);
3737
3692
  if (active && (expired || deadPid)) {
@@ -3749,7 +3704,7 @@ function acquireMemberLoopLease(name, { runId, ttlMs }) {
3749
3704
  }
3750
3705
 
3751
3706
  function refreshMemberLoopLease(paths, lease, ttlMs) {
3752
- writeJsonFile(paths.lockPath, {
3707
+ writeJson(paths.lockPath, {
3753
3708
  ...lease,
3754
3709
  heartbeat_at: stampIso(),
3755
3710
  expires_at_ms: Date.now() + ttlMs,
@@ -3757,7 +3712,7 @@ function refreshMemberLoopLease(paths, lease, ttlMs) {
3757
3712
  }
3758
3713
 
3759
3714
  function releaseMemberLoopLease(paths, lease) {
3760
- const active = readJsonIfExists(paths.lockPath);
3715
+ const active = readJson(paths.lockPath);
3761
3716
  if (!active || active.run_id !== lease.run_id || active.pid !== lease.pid) return;
3762
3717
  fs.rmSync(paths.lockPath, { force: true });
3763
3718
  }
@@ -3772,11 +3727,11 @@ function activeMemberLoopLease(lease) {
3772
3727
  function archiveMemberLoopState(name) {
3773
3728
  const paths = memberLoopPaths(name);
3774
3729
  const archivedAt = stampIso();
3775
- const previousLease = readJsonIfExists(paths.lockPath);
3730
+ const previousLease = readJson(paths.lockPath);
3776
3731
  const activeLease = activeMemberLoopLease(previousLease);
3777
3732
  fs.mkdirSync(paths.stateDir, { recursive: true });
3778
3733
  if (activeLease) {
3779
- writeJsonFile(paths.stopPath, {
3734
+ writeJson(paths.stopPath, {
3780
3735
  schema: 'atris.member_loop_stop.v1',
3781
3736
  member: name,
3782
3737
  requested_at: archivedAt,
@@ -3801,7 +3756,7 @@ function archiveMemberLoopState(name) {
3801
3756
  stop_path: paths.stopPath,
3802
3757
  latest_path: paths.latestPath,
3803
3758
  };
3804
- writeJsonFile(paths.latestPath, payload);
3759
+ writeJson(paths.latestPath, payload);
3805
3760
  return payload;
3806
3761
  }
3807
3762
 
@@ -4323,6 +4278,16 @@ function memberUpgrade(name) {
4323
4278
 
4324
4279
  // --- PUSH subcommand ---
4325
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
+
4326
4291
  async function memberPush(name) {
4327
4292
  if (!name) {
4328
4293
  console.error('Usage: atris member push <name>');
@@ -4346,6 +4311,9 @@ async function memberPush(name) {
4346
4311
  const content = fs.readFileSync(memberFile, 'utf8');
4347
4312
  const fm = parseFrontmatter(content);
4348
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();
4349
4317
 
4350
4318
  if (existingAgentId) {
4351
4319
  console.log(`Pushing member "${name}" to cloud (updating agent ${existingAgentId})...`);
@@ -4353,7 +4321,9 @@ async function memberPush(name) {
4353
4321
  console.log(`Pushing member "${name}" to cloud (creating new agent)...`);
4354
4322
  }
4355
4323
 
4356
- const businessId = findWorkspaceBusinessId();
4324
+ if (!businessId) {
4325
+ console.log('no business binding found; run `atris business init "<name>" --here` to bind this workspace.');
4326
+ }
4357
4327
  const endpoint = businessId
4358
4328
  ? `/agent/import-member?business_id=${encodeURIComponent(businessId)}`
4359
4329
  : '/agent/import-member';
@@ -7295,52 +7265,218 @@ function workspaceSnapshot(name = null, paths = null) {
7295
7265
  }
7296
7266
  }
7297
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
+
7298
7472
  function wakeDecision(name, paths, { force = false, runtimeKind = memberRuntimeKind(name) } = {}) {
7299
7473
  const purpose = missionPurpose(paths);
7300
7474
  const steering = readSteeringMemory(paths, name);
7301
7475
  const state = loadMemberGoals(name, paths);
7302
7476
  const goal = activeGoal(state);
7303
7477
  const current = memberOpenExperiment(state);
7304
- const isGeneralist = lowerCompact(name) === 'generalist';
7305
- if (isGeneralist && purpose.meaningful) {
7306
- const domainFile = findUnprocessedGeneralistDomainFile(process.cwd());
7307
- if (domainFile) {
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: {
7328
- north_star: purpose.northStar || null,
7329
- runtime_id: purpose.runtimeMission.id || null,
7330
- runtime_status: purpose.runtimeMission.status || null,
7331
- runtime_next: purpose.runtimeMission.next || null,
7332
- },
7333
- steering,
7334
- evidence: {
7335
- generalist_domain_scan: domainFile,
7336
- task_projection: null,
7337
- nearest_open_loop: null,
7338
- },
7339
- workspace: null,
7340
- domain_file: domainFile,
7341
- };
7342
- }
7343
- }
7478
+ const generalistDecision = wakeRuleGeneralistDomainFile(name, { purpose, steering, state, goal, current });
7479
+ if (generalistDecision) return generalistDecision;
7344
7480
  const rawDirective = steeringWakeDirective(steering, name, goal);
7345
7481
  const directiveClosure = rawDirective ? steeringDirectiveClosure(rawDirective) : null;
7346
7482
  const directive = directiveClosure?.all_closed ? null : rawDirective;
@@ -7383,219 +7519,12 @@ function wakeDecision(name, paths, { force = false, runtimeKind = memberRuntimeK
7383
7519
  next_command: wakeScores.selected.next_command,
7384
7520
  } : null;
7385
7521
 
7386
- if (!purpose.meaningful) {
7387
- const ask = `Define atris/team/${name}/MISSION.md with a concrete North Star before this member wakes itself.`;
7388
- return {
7389
- decision: 'ask',
7390
- reason: 'mission_missing_or_placeholder',
7391
- needs_user: true,
7392
- ask,
7393
- next_command: `edit atris/team/${name}/MISSION.md`,
7394
- state,
7395
- goal: goal || null,
7396
- current_experiment: current || null,
7397
- checks,
7398
- mission: {
7399
- north_star: purpose.northStar || null,
7400
- runtime_id: purpose.runtimeMission.id || null,
7401
- runtime_status: purpose.runtimeMission.status || null,
7402
- runtime_next: purpose.runtimeMission.next || null,
7403
- },
7404
- steering,
7405
- evidence,
7406
- workspace,
7407
- };
7408
- }
7409
-
7410
- const discoveredTask = evidence.problem_discovery?.selected || null;
7411
- if (discoveredTask?.source === 'log_error_scan' && discoveredTask.autonomous_action === 'create_task') {
7412
- return {
7413
- decision: 'create_task',
7414
- reason: 'autonomous_error_discovery:log_error_scan',
7415
- needs_user: false,
7416
- ask: null,
7417
- next_command: discoveredTask.next_command,
7418
- state,
7419
- goal: goal || null,
7420
- current_experiment: current || null,
7421
- autonomous_problem: discoveredTask,
7422
- checks,
7423
- mission: {
7424
- north_star: purpose.northStar,
7425
- runtime_id: purpose.runtimeMission.id || null,
7426
- runtime_status: purpose.runtimeMission.status || null,
7427
- runtime_next: purpose.runtimeMission.next || null,
7428
- },
7429
- steering,
7430
- evidence,
7431
- workspace,
7432
- };
7433
- }
7434
-
7435
- if (!goal) {
7436
- const discoveredProblem = evidence.problem_discovery?.selected || null;
7437
- if (discoveredProblem) {
7438
- return {
7439
- decision: 'set_objective',
7440
- reason: `autonomous_problem_discovery:${discoveredProblem.source}`,
7441
- needs_user: false,
7442
- ask: null,
7443
- next_command: discoveredProblem.next_command || `atris member wake ${name} --execute --confirm-autonomy-policy`,
7444
- state,
7445
- goal: null,
7446
- current_experiment: null,
7447
- autonomous_problem: discoveredProblem,
7448
- checks,
7449
- mission: {
7450
- north_star: purpose.northStar,
7451
- runtime_id: purpose.runtimeMission.id || null,
7452
- runtime_status: purpose.runtimeMission.status || null,
7453
- runtime_next: purpose.runtimeMission.next || null,
7454
- },
7455
- steering,
7456
- evidence,
7457
- workspace,
7458
- };
7459
- }
7460
- return {
7461
- decision: 'stop',
7462
- reason: 'no_active_goal',
7463
- needs_user: false,
7464
- ask: null,
7465
- next_command: `atris member goal-from-mission ${name}`,
7466
- state,
7467
- goal: null,
7468
- current_experiment: null,
7469
- autonomous_problem: null,
7470
- checks,
7471
- mission: {
7472
- north_star: purpose.northStar,
7473
- runtime_id: purpose.runtimeMission.id || null,
7474
- runtime_status: purpose.runtimeMission.status || null,
7475
- runtime_next: purpose.runtimeMission.next || null,
7476
- },
7477
- steering,
7478
- evidence,
7479
- workspace,
7480
- };
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);
7481
7526
  }
7482
-
7483
- if (blocked) {
7484
- return {
7485
- decision: 'ask',
7486
- reason: 'blocked_experiment',
7487
- needs_user: true,
7488
- ask: blocked.block?.ask || 'Needs operator input before another wake.',
7489
- next_command: `atris member review ${name} ${blocked.id} --discard --proof "..."`,
7490
- state,
7491
- goal,
7492
- current_experiment: blocked,
7493
- checks,
7494
- mission: {
7495
- north_star: purpose.northStar,
7496
- runtime_id: purpose.runtimeMission.id || null,
7497
- runtime_status: purpose.runtimeMission.status || null,
7498
- runtime_next: purpose.runtimeMission.next || null,
7499
- },
7500
- steering,
7501
- evidence,
7502
- workspace,
7503
- };
7504
- }
7505
-
7506
- // Skip the wait when the only thing "open" is an unreviewable fallback proposal;
7507
- // otherwise the member is parked forever on a gate no reviewer can clear.
7508
- if (current && !experimentIsUnreviewable(current)) {
7509
- return {
7510
- decision: 'wait',
7511
- reason: `open_experiment_${current.status}`,
7512
- needs_user: false,
7513
- ask: null,
7514
- next_command: `atris member review ${name} ${current.id} --accept --proof "..." --value 4`,
7515
- state,
7516
- goal,
7517
- current_experiment: current,
7518
- checks,
7519
- mission: {
7520
- north_star: purpose.northStar,
7521
- runtime_id: purpose.runtimeMission.id || null,
7522
- runtime_status: purpose.runtimeMission.status || null,
7523
- runtime_next: purpose.runtimeMission.next || null,
7524
- },
7525
- steering,
7526
- evidence,
7527
- workspace,
7528
- };
7529
- }
7530
-
7531
- if (wakeScores.selected && wakeScores.selected.decision !== 'tick') {
7532
- const selected = wakeScores.selected;
7533
- const needsUser = selected.decision === 'ask';
7534
- return {
7535
- decision: selected.decision,
7536
- reason: selected.reason,
7537
- needs_user: needsUser,
7538
- ask: needsUser ? (selected.ask || `Need operator input for ${selected.title}.`) : null,
7539
- next_command: selected.next_command,
7540
- state,
7541
- goal,
7542
- current_experiment: null,
7543
- checks,
7544
- mission: {
7545
- north_star: purpose.northStar,
7546
- runtime_id: purpose.runtimeMission.id || null,
7547
- runtime_status: purpose.runtimeMission.status || null,
7548
- runtime_next: purpose.runtimeMission.next || null,
7549
- },
7550
- steering,
7551
- evidence,
7552
- workspace,
7553
- };
7554
- }
7555
-
7556
- if (!workspace.clean_for_member && !force) {
7557
- return {
7558
- decision: 'wait',
7559
- reason: 'workspace_dirty_in_member_scope',
7560
- needs_user: false,
7561
- ask: null,
7562
- next_command: `commit/stash files in atris/team/${name}/ (or member scope) — or rerun: atris member wake ${name} --force`,
7563
- state,
7564
- goal,
7565
- current_experiment: null,
7566
- checks,
7567
- mission: {
7568
- north_star: purpose.northStar,
7569
- runtime_id: purpose.runtimeMission.id || null,
7570
- runtime_status: purpose.runtimeMission.status || null,
7571
- runtime_next: purpose.runtimeMission.next || null,
7572
- },
7573
- steering,
7574
- evidence,
7575
- workspace,
7576
- };
7577
- }
7578
-
7579
- return {
7580
- decision: 'tick',
7581
- reason: 'safe_next_bounded_step',
7582
- needs_user: false,
7583
- ask: null,
7584
- next_command: `atris member tick ${name} --goal ${goal.id}`,
7585
- state,
7586
- goal,
7587
- current_experiment: null,
7588
- checks,
7589
- mission: {
7590
- north_star: purpose.northStar,
7591
- runtime_id: purpose.runtimeMission.id || null,
7592
- runtime_status: purpose.runtimeMission.status || null,
7593
- runtime_next: purpose.runtimeMission.next || null,
7594
- },
7595
- steering,
7596
- evidence,
7597
- workspace,
7598
- };
7527
+ return null;
7599
7528
  }
7600
7529
 
7601
7530
  async function runMemberWake(name, { execute = false, confirmed = false, force = false, domainInput = {} } = {}) {
@@ -8029,84 +7958,60 @@ function memberAliveAll(...args) {
8029
7958
  return memberLoopAll('alive', ...args);
8030
7959
  }
8031
7960
 
8032
- async function memberLoop(name, ...args) {
8033
- if (name === '--all' || name === 'all') return memberLoopAll('loop', ...args);
8034
- requireMemberDir(name);
8035
- const asJson = hasFlag(args, '--json');
8036
- const aliveMode = hasFlag(args, '--alive');
8037
- const execute = hasFlag(args, '--execute');
8038
- const confirmed = hasFlag(args, '--confirm-autonomy-policy');
8039
- const force = hasFlag(args, '--force');
8040
- const stop = hasFlag(args, '--stop');
8041
- const status = hasFlag(args, '--status');
8042
- const agentFlag = String(readFlag(args, '--agent', '') || '').trim().toLowerCase();
8043
- const agent = agentFlag === 'claude' ? 'claude' : agentFlag === 'codex' ? 'codex' : undefined;
8044
- const model = String(readFlag(args, '--model', '') || '').trim() || undefined;
8045
- const operateMaxWall = Math.max(60, Math.min(1800, Number(readNumberFlag(args, '--operate-max-wall', readNumberFlag(args, '--max-wall', 900)))));
8046
- const autoAcceptLimit = Math.max(1, Math.floor(Number(readNumberFlag(args, '--auto-accept-limit', 8))));
8047
- const paths = memberLoopPaths(name);
8048
- fs.mkdirSync(paths.stateDir, { recursive: true });
8049
-
8050
- if (hasFlag(args, '--install')) {
8051
- return installMemberAliveCron(name, args);
8052
- }
8053
-
8054
- if (hasFlag(args, '--uninstall')) {
8055
- return uninstallMemberAliveCron(name, args);
8056
- }
8057
-
8058
- if (status) {
8059
- const active = readJsonIfExists(paths.lockPath);
8060
- const latest = readJsonIfExists(paths.latestPath);
8061
- const payload = {
8062
- ok: true,
8063
- action: 'loop_status',
8064
- member: name,
8065
- active: Boolean(active && Number(active.expires_at_ms || 0) > Date.now() && isPidAlive(active.pid)),
8066
- lease: active || null,
8067
- latest: latest || null,
8068
- hourly_alive: memberAliveCronStatus(name, paths),
8069
- lock_path: paths.lockPath,
8070
- latest_path: paths.latestPath,
8071
- };
8072
- printJsonOrText(payload, [
8073
- `Loop status: ${name}`,
8074
- `Active: ${payload.active ? 'yes' : 'no'}`,
8075
- `Hourly: ${payload.hourly_alive.installed ? 'installed' : payload.hourly_alive.script_exists ? 'script ready' : 'not installed'}`,
8076
- `Latest: ${latest?.receipt_path ? path.relative(process.cwd(), latest.receipt_path) : 'none'}`,
8077
- ], asJson);
8078
- return;
8079
- }
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
+ }
8080
7983
 
8081
- if (stop) {
8082
- const requestedAt = stampIso();
8083
- const stopPayload = {
8084
- schema: 'atris.member_loop_stop.v1',
8085
- member: name,
8086
- requested_at: requestedAt,
8087
- pid: process.pid,
8088
- };
8089
- writeJsonFile(paths.stopPath, stopPayload);
8090
- const receiptPath = writeMemberLoopReceipt(name, {
8091
- ok: true,
8092
- action: 'loop_stop',
8093
- member: name,
8094
- requested_at: requestedAt,
8095
- stop_path: paths.stopPath,
8096
- });
8097
- printJsonOrText({
8098
- ok: true,
8099
- action: 'loop_stop',
8100
- member: name,
8101
- stop_path: paths.stopPath,
8102
- receipt_path: receiptPath,
8103
- }, [
8104
- `Stop requested for ${name}.`,
8105
- `Receipt: ${path.relative(process.cwd(), receiptPath)}`,
8106
- ], asJson);
8107
- return;
8108
- }
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
+ }
8109
8012
 
8013
+ // Loop phase: derive the timing plan (tick count, interval, duration, lease ttl) from flags.
8014
+ function memberLoopTimingPlan(name, args, { execute, confirmed }) {
8110
8015
  const ticksFlag = readNumberFlag(args, '--ticks', null);
8111
8016
  const minutes = readNumberFlag(args, '--minutes', null);
8112
8017
  const durationSeconds = readNumberFlag(args, '--duration-seconds', readNumberFlag(args, '--seconds', null));
@@ -8137,183 +8042,190 @@ async function memberLoop(name, ...args) {
8137
8042
  forever ? intervalMs + (2 * 60 * 60 * 1000) : durationMs + 60000,
8138
8043
  intervalMs + 60000,
8139
8044
  );
8045
+ return { ticksFlag, hourly, forever, intervalMs, durationMs, ticks, runId, startedAt, ttlMs };
8046
+ }
8140
8047
 
8141
- if (execute && !confirmed) {
8142
- const receiptPath = writeMemberLoopReceipt(name, {
8143
- ok: false,
8144
- action: 'loop',
8145
- member: name,
8146
- status: 'blocked',
8147
- reason: 'execute_requires_confirm_autonomy_policy',
8148
- mode: 'execute',
8149
- started_at: startedAt,
8150
- finished_at: stampIso(),
8151
- });
8152
- const payload = {
8153
- ok: false,
8154
- action: 'loop',
8155
- member: name,
8156
- status: 'blocked',
8157
- reason: 'execute_requires_confirm_autonomy_policy',
8158
- receipt_path: receiptPath,
8159
- };
8160
- writeJsonFile(paths.latestPath, payload);
8161
- printJsonOrText(payload, [
8162
- `Loop blocked for ${name}: execute requires --confirm-autonomy-policy.`,
8163
- `Receipt: ${path.relative(process.cwd(), receiptPath)}`,
8164
- ], asJson);
8165
- process.exitCode = 1;
8166
- return;
8167
- }
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
+ }
8168
8075
 
8169
- const lease = acquireMemberLoopLease(name, { runId, ttlMs });
8170
- if (!lease.acquired) {
8171
- const receiptPath = writeMemberLoopReceipt(name, {
8172
- ok: true,
8173
- action: 'loop',
8174
- member: name,
8175
- status: 'skipped',
8176
- reason: 'loop_already_active',
8177
- mode: execute ? 'execute' : 'dry_run',
8178
- active_lease: lease.lease || null,
8179
- started_at: startedAt,
8180
- finished_at: stampIso(),
8181
- });
8182
- const payload = {
8183
- ok: true,
8184
- action: 'loop',
8185
- member: name,
8186
- status: 'skipped',
8187
- reason: 'loop_already_active',
8188
- ticks: 0,
8189
- receipt_path: receiptPath,
8190
- active_lease: lease.lease || null,
8191
- };
8192
- writeJsonFile(paths.latestPath, payload);
8193
- printJsonOrText(payload, [
8194
- `Loop skipped for ${name}: another loop is active.`,
8195
- `Receipt: ${path.relative(process.cwd(), receiptPath)}`,
8196
- ], asJson);
8197
- return;
8198
- }
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');
8199
8174
 
8200
- fs.rmSync(paths.stopPath, { force: true });
8201
- const tickLogPath = path.join(process.cwd(), 'atris', 'runs', `${runId}.jsonl`);
8202
- fs.mkdirSync(path.dirname(tickLogPath), { recursive: true });
8203
- const tickResults = [];
8204
- const decisions = {};
8205
- let stopped = false;
8206
- let failed = false;
8207
- // Stop spinning: if the member can do no real work for this many ticks in a row (waiting on a
8208
- // human review, blocked, or no goal), break early and surface a clear handoff instead of
8209
- // burning every remaining tick on the same no-op decision.
8210
- let earlyExit = null;
8211
- let consecutiveIdle = 0;
8212
- const idleBreakThreshold = 2;
8213
8175
  // Repeated identical `ask:*` wake decisions (e.g. ask:mission_missing_or_placeholder) mean the
8214
8176
  // member is stuck asking the same question every tick regardless of --execute; the idle
8215
- // 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
8216
8178
  // remaining tick re-asking. Reuse the same blocked_on_human handoff once we see the ask repeat.
8217
- let lastAskKey = null;
8218
- let consecutiveIdenticalAsk = 0;
8219
- 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
+ }
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;
8220
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;
8221
8213
  try {
8222
- for (let index = 0; index < ticks; index += 1) {
8214
+ for (let index = 0; index < plan.ticks; index += 1) {
8223
8215
  if (fs.existsSync(paths.stopPath)) {
8224
- stopped = true;
8216
+ state.stopped = true;
8225
8217
  break;
8226
8218
  }
8227
- refreshMemberLoopLease(paths, lease.lease, ttlMs);
8219
+ refreshMemberLoopLease(paths, lease.lease, plan.ttlMs);
8228
8220
  const tickStartedAt = stampIso();
8229
8221
  try {
8230
- if (aliveMode) {
8231
- const alive = runAliveTick(name, {
8232
- execute,
8233
- confirmed,
8234
- force,
8235
- agent,
8236
- model,
8237
- maxWallSeconds: operateMaxWall,
8238
- autoAcceptLimit,
8239
- noPrime: index > 0,
8240
- });
8241
- const key = `${alive.status || 'alive'}:${alive.reason || 'tick'}`;
8242
- decisions[key] = (decisions[key] || 0) + 1;
8243
- const tick = {
8244
- tick: index + 1,
8245
- started_at: tickStartedAt,
8246
- finished_at: alive.finished_at || stampIso(),
8247
- ok: alive.ok !== false,
8248
- decision: alive.status || 'alive_tick',
8249
- reason: alive.reason || null,
8250
- executed: execute,
8251
- needs_user: alive.needs_user === true,
8252
- next_command: alive.next_command || null,
8253
- has_mission: alive.has_mission === true,
8254
- has_goal: alive.has_goal === true,
8255
- has_steering: false,
8256
- productive: alive.status === 'completed',
8257
- blocked_on_human: alive.blocked_on_human === true,
8258
- operate_ok: alive.operate?.ok,
8259
- auto_accept_accepted: alive.auto_accept?.json?.summary?.accepted ?? alive.auto_accept?.json?.summary?.would_accept,
8260
- receipt_path: alive.receipt_path || alive.operate?.receipt_path || null,
8261
- alive,
8262
- };
8263
- tickResults.push(tick);
8264
- fs.appendFileSync(tickLogPath, JSON.stringify(tick) + '\n', 'utf8');
8222
+ if (options.aliveMode) {
8223
+ memberLoopAliveTick(name, run, index, tickStartedAt);
8265
8224
  } else {
8266
- const wake = await runMemberWake(name, { execute, confirmed, force });
8267
- const key = `${wake.decision}:${wake.reason}`;
8268
- decisions[key] = (decisions[key] || 0) + 1;
8269
- const tick = {
8270
- tick: index + 1,
8271
- started_at: tickStartedAt,
8272
- finished_at: stampIso(),
8273
- ok: true,
8274
- decision: wake.decision,
8275
- reason: wake.reason,
8276
- executed: wake.executed,
8277
- needs_user: wake.needs_user,
8278
- next_command: wake.next_command,
8279
- has_mission: wake.checks?.has_mission === true,
8280
- has_goal: wake.checks?.has_goal === true,
8281
- has_steering: wake.checks?.has_steering === true,
8282
- productive: wake.executed === true
8283
- || /executed/.test(wake.reason || '')
8284
- || ['tick', 'close_loop', 'report_proof', 'create_missing_task', 'create_task', 'set_objective'].includes(wake.decision),
8285
- blocked_on_human: wake.needs_user === true || /^open_experiment_/.test(wake.reason || ''),
8286
- current_experiment: wake.current_experiment?.id || null,
8287
- receipt_path: wake.receipt_path,
8288
- };
8289
- tickResults.push(tick);
8290
- fs.appendFileSync(tickLogPath, JSON.stringify(tick) + '\n', 'utf8');
8291
-
8292
- if (wake.decision === 'ask') {
8293
- if (key === lastAskKey) {
8294
- consecutiveIdenticalAsk += 1;
8295
- } else {
8296
- lastAskKey = key;
8297
- consecutiveIdenticalAsk = 1;
8298
- }
8299
- if (consecutiveIdenticalAsk >= identicalAskBreakThreshold) {
8300
- earlyExit = {
8301
- after_tick: tick.tick,
8302
- decision: tick.decision || null,
8303
- reason: tick.reason || 'idle',
8304
- needs_user: tick.needs_user === true,
8305
- blocked_on_human: true,
8306
- next_command: tick.next_command || null,
8307
- stop: 'repeated_identical_ask',
8308
- };
8309
- }
8310
- } else {
8311
- lastAskKey = null;
8312
- consecutiveIdenticalAsk = 0;
8313
- }
8225
+ await memberLoopWakeTick(name, run, index, tickStartedAt);
8314
8226
  }
8315
8227
  } catch (error) {
8316
- failed = true;
8228
+ state.failed = true;
8317
8229
  const tick = {
8318
8230
  tick: index + 1,
8319
8231
  started_at: tickStartedAt,
@@ -8321,19 +8233,19 @@ async function memberLoop(name, ...args) {
8321
8233
  ok: false,
8322
8234
  error: error instanceof Error ? error.message : String(error),
8323
8235
  };
8324
- tickResults.push(tick);
8325
- fs.appendFileSync(tickLogPath, JSON.stringify(tick) + '\n', 'utf8');
8236
+ state.tickResults.push(tick);
8237
+ fs.appendFileSync(state.tickLogPath, JSON.stringify(tick) + '\n', 'utf8');
8326
8238
  break;
8327
8239
  }
8328
- if (earlyExit) break;
8329
- if (execute) {
8330
- const last = tickResults[tickResults.length - 1];
8240
+ if (state.earlyExit) break;
8241
+ if (options.execute) {
8242
+ const last = state.tickResults[state.tickResults.length - 1];
8331
8243
  if (last && last.productive) {
8332
- consecutiveIdle = 0;
8244
+ state.consecutiveIdle = 0;
8333
8245
  } else if (last) {
8334
- consecutiveIdle += 1;
8335
- if (consecutiveIdle >= idleBreakThreshold) {
8336
- earlyExit = {
8246
+ state.consecutiveIdle += 1;
8247
+ if (state.consecutiveIdle >= MEMBER_LOOP_IDLE_BREAK_THRESHOLD) {
8248
+ state.earlyExit = {
8337
8249
  after_tick: last.tick,
8338
8250
  decision: last.decision || null,
8339
8251
  reason: last.reason || 'idle',
@@ -8345,12 +8257,19 @@ async function memberLoop(name, ...args) {
8345
8257
  }
8346
8258
  }
8347
8259
  }
8348
- if (index < ticks - 1 && intervalMs > 0) sleepSync(intervalMs);
8260
+ if (index < plan.ticks - 1 && plan.intervalMs > 0) sleepSync(plan.intervalMs);
8349
8261
  }
8350
8262
  } finally {
8351
8263
  releaseMemberLoopLease(paths, lease.lease);
8352
8264
  }
8265
+ }
8353
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;
8354
8273
  const finishedAt = stampIso();
8355
8274
  const summary = {
8356
8275
  ok: !failed,
@@ -8392,7 +8311,7 @@ async function memberLoop(name, ...args) {
8392
8311
  };
8393
8312
  const receiptPath = writeMemberLoopReceipt(name, summary);
8394
8313
  const payload = { ...summary, receipt_path: receiptPath };
8395
- writeJsonFile(paths.latestPath, payload);
8314
+ writeJson(paths.latestPath, payload);
8396
8315
  printJsonOrText(payload, [
8397
8316
  `${aliveMode ? 'Alive' : 'Loop'}: ${name}`,
8398
8317
  `Status: ${payload.status}`,
@@ -8404,6 +8323,71 @@ async function memberLoop(name, ...args) {
8404
8323
  ], asJson);
8405
8324
  }
8406
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
+
8407
8391
  async function memberTick(name, ...args) {
8408
8392
  const paths = requireMemberDir(name);
8409
8393
  const asJson = hasFlag(args, '--json');