atris 3.58.6 → 3.58.7

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.
package/commands/drive.js CHANGED
@@ -9,6 +9,7 @@
9
9
  const fs = require('fs');
10
10
  const path = require('path');
11
11
  const { spawnSync } = require('child_process');
12
+ const rsi = require('../lib/rsi-record');
12
13
 
13
14
  const BIN = path.join(__dirname, '..', 'bin', 'atris.js');
14
15
 
@@ -73,9 +74,31 @@ async function driveCommand(argv) {
73
74
  return 0;
74
75
  }
75
76
 
77
+ // Dream-RSI: one drive run is one bounded improvement attempt. Recorded
78
+ // only when the workspace has the recorder; a recording failure never
79
+ // changes the run or its exit code.
80
+ const rsiLog = (m) => process.stderr.write(`rsi: ${m}\n`);
81
+ const rsiAttempt = dryRun ? null : rsi.beginAttempt(cwd, { lane: rsi.IMPROVE_LANE, engine: 'claude', log: rsiLog });
82
+ const rsiBefore = rsiAttempt ? rsi.gitSnapshot(cwd) : null;
83
+ const rsiT0 = Date.now();
84
+ const rsiFinish = (outcome) => {
85
+ if (!rsiAttempt) return;
86
+ const delta = rsi.gitDelta(cwd, rsiBefore);
87
+ rsi.finishAttempt(cwd, rsiAttempt, {
88
+ commits: delta.commits,
89
+ files: delta.files,
90
+ elapsed_s: Math.round((Date.now() - rsiT0) / 100) / 10,
91
+ engine_calls: 1,
92
+ verify: 'skipped',
93
+ ...outcome,
94
+ }, { log: rsiLog });
95
+ };
96
+
97
+ try {
76
98
  const doctor = runAtris(['mission', 'doctor', '--json'], cwd);
77
99
  const report = parseJsonLoose(doctor.stdout);
78
100
  if (!report || !Array.isArray(report.findings)) {
101
+ rsiFinish({ status: 'failed', reason: 'mission doctor returned no parseable findings' });
79
102
  console.error('drive: mission doctor returned no parseable findings.');
80
103
  if (doctor.stderr) console.error(doctor.stderr.slice(0, 500));
81
104
  return 1;
@@ -167,6 +190,11 @@ async function driveCommand(argv) {
167
190
  };
168
191
  if (!dryRun) appendState(cwd, record);
169
192
 
193
+ rsiFinish({
194
+ status: fixed.length ? 'shipped' : 'nothing',
195
+ reason: `${fixed.length} auto-fixed, ${disengagements.length} still need a human`.slice(0, 200),
196
+ });
197
+
170
198
  if (json) { console.log(JSON.stringify({ ok: true, ...record }, null, 2)); return 0; }
171
199
 
172
200
  console.log(`drive tick: ${report.checked_count} missions checked, ${report.findings.length} findings`);
@@ -182,6 +210,10 @@ async function driveCommand(argv) {
182
210
  }
183
211
  console.log(` next: atris drive status · fix the ✋ list · re-run atris drive`);
184
212
  return disengagements.length > 0 ? 0 : 0;
213
+ } catch (err) {
214
+ rsiFinish({ status: 'failed', reason: String(err && err.message ? err.message : err).slice(0, 200) });
215
+ throw err;
216
+ }
185
217
  }
186
218
 
187
219
  module.exports = { driveCommand };
@@ -30,6 +30,7 @@ const close = require('./close');
30
30
  const { readUsage } = require('../lib/usage');
31
31
  const { knownCommands } = require('../lib/known-commands');
32
32
  const { treeHashFor } = require('../lib/tree-hash');
33
+ const rsi = require('../lib/rsi-record');
33
34
 
34
35
  /**
35
36
  * Expand a leading `~` to the real home directory for LOCAL filesystem
@@ -735,6 +736,68 @@ function runLocalFallback(opts = {}) {
735
736
  };
736
737
  }
737
738
 
739
+ // --- Dream-RSI attempt recording -----------------------------------------
740
+ // A shipping tick (mode full, not dry-run) is one bounded attempt at
741
+ // improving the workspace. When the workspace has the recorder
742
+ // (backend/scripts/rsi/record.py), wrap the tick in open -> finish so the
743
+ // attempt lands in .atris/state/rsi/attempts.jsonl. Recording never changes
744
+ // the tick's result or exit code.
745
+
746
+ function improveAttemptOutcome(result, { workspace, before, elapsedMs }) {
747
+ const s = (result && result.summary) || {};
748
+ const delta = rsi.gitDelta(workspace, before);
749
+ const files = [...new Set([...(Array.isArray(s.files) ? s.files : []), ...delta.files])].slice(0, 200);
750
+ const landed = files.length > 0 || delta.commits > 0 || Boolean(s.shipped);
751
+ const verify = s.verify === true ? 'pass' : s.verify === false ? 'fail' : 'skipped';
752
+ let status;
753
+ if (!result || !result.ok || s.error || verify === 'fail') status = 'failed';
754
+ else if (landed) status = 'shipped';
755
+ else status = 'nothing';
756
+ const reason = status === 'failed'
757
+ ? String(result.error || s.error || 'tick failed').slice(-200)
758
+ : String(s.shipped || result.reason || 'nothing to do').slice(0, 200);
759
+ return {
760
+ status,
761
+ verify,
762
+ commits: delta.commits,
763
+ files,
764
+ elapsed_s: Math.round(elapsedMs / 100) / 10,
765
+ engine_calls: 1,
766
+ reason,
767
+ };
768
+ }
769
+
770
+ async function runImprove(opts = {}, deps = {}) {
771
+ const workspace = opts.workspace || process.cwd();
772
+ const log = deps.log || (() => {});
773
+ // Local receipt writes expand a leading ~; the recorder check must too, or
774
+ // a `~/...` workspace would never see its own backend/scripts/rsi/record.py.
775
+ const rsiRoot = expandHome(workspace);
776
+ const shippingTick = (opts.mode || 'full') === 'full' && !opts.dryRun;
777
+ const attempt = shippingTick
778
+ ? rsi.beginAttempt(rsiRoot, { lane: rsi.IMPROVE_LANE, engine: rsi.engineFromModel(opts.model) || 'claude', log })
779
+ : null;
780
+ if (!attempt) return runImproveCore(opts, deps);
781
+ const startedMs = Date.now();
782
+ const before = rsi.gitSnapshot(rsiRoot);
783
+ try {
784
+ const result = await runImproveCore(opts, deps);
785
+ rsi.finishAttempt(rsiRoot, attempt, improveAttemptOutcome(result, { workspace: rsiRoot, before, elapsedMs: Date.now() - startedMs }), { log });
786
+ return result;
787
+ } catch (err) {
788
+ rsi.finishAttempt(rsiRoot, attempt, {
789
+ status: 'failed',
790
+ verify: 'skipped',
791
+ commits: 0,
792
+ files: [],
793
+ elapsed_s: Math.round((Date.now() - startedMs) / 100) / 10,
794
+ engine_calls: 1,
795
+ reason: String(err && err.message || err).slice(-200),
796
+ }, { log });
797
+ throw err;
798
+ }
799
+ }
800
+
738
801
  /**
739
802
  * Run one improvement tick. Dependency-injected so tests can fake the
740
803
  * network (apiRequestJson), auth (loadCredentials), the local fallback,
@@ -743,7 +806,7 @@ function runLocalFallback(opts = {}) {
743
806
  * Returns a structured result:
744
807
  * { ok, source: 'api'|'local'|'none', reason, summary?, scorecardPath?, local?, apiResult?, error? }
745
808
  */
746
- async function runImprove(opts = {}, deps = {}) {
809
+ async function runImproveCore(opts = {}, deps = {}) {
747
810
  const apiFn = deps.apiRequestJson || apiRequestJson;
748
811
  const loadCreds = deps.loadCredentials || loadCredentials;
749
812
  const localFn = deps.runLocalFallback || runLocalFallback;
@@ -989,6 +1052,8 @@ function isRevisionSignalFile(file) {
989
1052
  const REVISION_WINDOW_MS = REVISION_WINDOW_HOURS * 60 * 60 * 1000;
990
1053
  const AGENT_TRAILER_MARKERS = [
991
1054
  'atris-builder[bot]',
1055
+ 'night@atris.ai',
1056
+ 'devin',
992
1057
  'claude',
993
1058
  'cursor',
994
1059
  'codex',
@@ -1406,6 +1471,7 @@ async function run(argv = [], deps = {}) {
1406
1471
  module.exports = {
1407
1472
  run,
1408
1473
  runImprove,
1474
+ runImproveCore,
1409
1475
  parseImproveArgs,
1410
1476
  buildImprovePayload,
1411
1477
  summarizeImproveResponse,
package/commands/land.js CHANGED
@@ -58,6 +58,102 @@ function worktreeWithinReapGrace(worktreePath, now = Date.now()) {
58
58
  return typeof mtime === 'number' && now - mtime < WORKTREE_REAP_GRACE_MS;
59
59
  }
60
60
 
61
+ function canonicalPath(p) {
62
+ try {
63
+ return fs.realpathSync(p);
64
+ } catch {
65
+ return path.resolve(p);
66
+ }
67
+ }
68
+
69
+ function gitCommonDir(root) {
70
+ for (const args of [['rev-parse', '--path-format=absolute', '--git-common-dir'], ['rev-parse', '--git-common-dir']]) {
71
+ const res = runGit(args, { cwd: root, check: false });
72
+ if (res.status === 0 && res.stdout.trim()) return path.resolve(root, res.stdout.trim());
73
+ }
74
+ return '';
75
+ }
76
+
77
+ function headRefName(headFile) {
78
+ try {
79
+ const m = /^ref: refs\/heads\/(.+)$/.exec(fs.readFileSync(headFile, 'utf8').trim());
80
+ return m ? m[1] : '';
81
+ } catch {
82
+ return '';
83
+ }
84
+ }
85
+
86
+ // Branch checkouts straight from .git/worktrees/<id>/HEAD plus the main
87
+ // checkout's HEAD — the live truth, not the board snapshot. A `worktree add`
88
+ // still in flight (or a half-registered entry) can leave a branch checked out
89
+ // in a directory the snapshot never saw, and git's own `branch -D` refusal
90
+ // only consults registrations whose gitdir link is already written.
91
+ // pendingFresh marks a fresh registration whose HEAD file does not exist yet:
92
+ // an add mid-flight that has not named its branch.
93
+ function liveCheckouts(root, now = Date.now()) {
94
+ const bound = new Map();
95
+ let pendingFresh = false;
96
+ const common = gitCommonDir(root);
97
+ if (!common) return { bound, pendingFresh };
98
+ if (path.basename(common) === '.git') {
99
+ const name = headRefName(path.join(common, 'HEAD'));
100
+ const mainPath = path.dirname(common);
101
+ if (name && fs.existsSync(mainPath)) bound.set(name, { path: mainPath, adminDir: common });
102
+ }
103
+ let ids = [];
104
+ try {
105
+ ids = fs.readdirSync(path.join(common, 'worktrees'));
106
+ } catch {
107
+ return { bound, pendingFresh };
108
+ }
109
+ for (const id of ids) {
110
+ const adminDir = path.join(common, 'worktrees', id);
111
+ let stat;
112
+ try {
113
+ stat = fs.statSync(adminDir);
114
+ } catch {
115
+ continue;
116
+ }
117
+ if (!stat.isDirectory()) continue;
118
+ const headFile = path.join(adminDir, 'HEAD');
119
+ const name = headRefName(headFile);
120
+ if (!name) {
121
+ if (!fs.existsSync(headFile) && now - stat.mtimeMs < WORKTREE_REAP_GRACE_MS) pendingFresh = true;
122
+ continue;
123
+ }
124
+ let wtPath = null;
125
+ try {
126
+ wtPath = path.dirname(fs.readFileSync(path.join(adminDir, 'gitdir'), 'utf8').trim());
127
+ } catch {
128
+ // gitdir unwritten or lost: cannot prove which directory is the
129
+ // checkout, so the branch stays claimed — fail safe, prune's job.
130
+ }
131
+ if (wtPath && !fs.existsSync(wtPath)) continue;
132
+ bound.set(name, { path: wtPath, adminDir });
133
+ }
134
+ return { bound, pendingFresh };
135
+ }
136
+
137
+ // The branch's own reflog records when `worktree add -b` (or `git branch`)
138
+ // created it. While an add is in flight a just-created target branch is very
139
+ // likely the checkout being wired up right now — keep it for this pass.
140
+ function branchCreatedWithinGrace(root, name, now = Date.now()) {
141
+ const res = runGit(['reflog', 'show', '--date=unix', name], { cwd: root, check: false });
142
+ if (res.status !== 0) return false;
143
+ const lines = res.stdout.split(/\r?\n/).filter(Boolean);
144
+ const m = /@\{(\d+)\}/.exec(lines[lines.length - 1] || '');
145
+ return Boolean(m) && now - Number(m[1]) * 1000 < WORKTREE_REAP_GRACE_MS;
146
+ }
147
+
148
+ // One receipt line for a branch kept because a live checkout claims it:
149
+ // fresh checkouts name the grace, older ones name where the branch is in use.
150
+ function checkoutKeepLine(name, bound, now) {
151
+ if (worktreeWithinReapGrace(bound.path || bound.adminDir, now)) {
152
+ return bound.path ? `${bound.path} (fresh_worktree_grace)` : `branch ${name} (fresh_worktree_grace)`;
153
+ }
154
+ return `branch ${name} (checked out in ${bound.path || 'a worktree'})`;
155
+ }
156
+
61
157
  function listBranches(root, base = '') {
62
158
  // With a base, ask git for ahead counts in the same single spawn
63
159
  // (%(ahead-behind:) needs git >= 2.41; on failure we retry without it and
@@ -151,7 +247,12 @@ function collectBoard(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_S
151
247
  const worktrees = [];
152
248
  const all = listWorktrees(root);
153
249
  for (const wt of all.slice(1)) {
154
- const branch = (wt.branch || '').replace(/^refs\/heads\//, '');
250
+ const rawBranch = (wt.branch || '').replace(/^refs\/heads\//, '');
251
+ // 'detached' is a marker, not a name: left as-is it reads as a real branch
252
+ // name (hiding the worktree's own commits and, in reap, pushing the word
253
+ // "detached" into the delete list). Treat it as no branch so the worktree
254
+ // is asked directly what it holds.
255
+ const branch = rawBranch === 'detached' ? '' : rawBranch;
155
256
  // light mode skips the full `git status` per worktree, the banner summary
156
257
  // never reads dirty counts, only worktree mtimes for staleness.
157
258
  const counts = (light ? null : statusCounts(wt.path)) || { staged: 0, unstaged: 0, untracked: 0 };
@@ -376,8 +477,25 @@ function reap(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_STALE_HOU
376
477
  };
377
478
  if (targetNames.size === 0 && worktreeTargets.length === 0) return receipt;
378
479
  if (dryRun) {
480
+ // The preview must match the real pass: a branch still checked out in a
481
+ // worktree the board missed is kept, not listed as deleted. A checkout
482
+ // that is itself a removal target would be gone first, so it still reads
483
+ // as deletable here.
484
+ const live = liveCheckouts(root, now);
485
+ const targetPaths = new Set(worktreeTargets.map((w) => canonicalPath(w.path)));
379
486
  receipt.removedWorktrees = worktreeTargets.map((w) => w.path);
380
- receipt.deletedBranches = [...targetNames];
487
+ receipt.deletedBranches = [...targetNames].filter((name) => {
488
+ const bound = live.bound.get(name);
489
+ if (bound && !(bound.path && targetPaths.has(canonicalPath(bound.path)))) {
490
+ receipt.keptWorktrees.push(checkoutKeepLine(name, bound, now));
491
+ return false;
492
+ }
493
+ if (!bound && live.pendingFresh && branchCreatedWithinGrace(root, name, now)) {
494
+ receipt.keptWorktrees.push(`branch ${name} (fresh_worktree_grace)`);
495
+ return false;
496
+ }
497
+ return true;
498
+ });
381
499
  return receipt;
382
500
  }
383
501
 
@@ -406,9 +524,17 @@ function reap(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_STALE_HOU
406
524
  (w) => targetNames.has(w.branch) || (includeDetached && w.state === 'detached')
407
525
  );
408
526
  for (const w of survivingWorktreeTargets) {
527
+ // The grace check ran at scan time, and a bundle build can sit between it
528
+ // and removal — an engine that booted mid-sweep refreshes the directory
529
+ // mtime in that gap. Stat once more at removal time, the cheapest place
530
+ // to never be wrong about freshness.
531
+ if (worktreeWithinReapGrace(w.path)) {
532
+ receipt.keptWorktrees.push(`${w.path} (fresh_worktree_grace)`);
533
+ if (w.branch) targetNames.delete(w.branch);
534
+ continue;
535
+ }
409
536
  // Salvage-then-remove, never keep-because-dirty: patches + untracked
410
- // copies bank everything force-remove would destroy. The fresh-worktree
411
- // grace was already applied when candidates were selected.
537
+ // copies bank everything force-remove would destroy.
412
538
  if (w.dirty > 0 && !salvageWorktree(w, dir, receipt)) {
413
539
  // could not fully back up what force-remove would destroy, keep it,
414
540
  // and say why: a bare path in the receipt reads as an unexplained
@@ -430,7 +556,21 @@ function reap(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_STALE_HOU
430
556
  }
431
557
  }
432
558
 
559
+ // Re-scan after removals: worktrees just removed freed their branches, and
560
+ // anything still bound is checked out in a directory that exists — the
561
+ // board snapshot may have missed it entirely (a `worktree add` mid-flight),
562
+ // and git's own -D refusal only reads fully wired registrations.
563
+ const live = liveCheckouts(root, now);
433
564
  for (const name of targetNames) {
565
+ const bound = live.bound.get(name);
566
+ if (bound) {
567
+ receipt.keptWorktrees.push(checkoutKeepLine(name, bound, now));
568
+ continue;
569
+ }
570
+ if (live.pendingFresh && branchCreatedWithinGrace(root, name, now)) {
571
+ receipt.keptWorktrees.push(`branch ${name} (fresh_worktree_grace)`);
572
+ continue;
573
+ }
434
574
  const entry = board.branches.find((b) => b.name === name);
435
575
  // the board is a snapshot; an agent may have committed since it was
436
576
  // taken. A branch that moved is left alone, the next reap sees the
package/commands/learn.js CHANGED
@@ -420,6 +420,10 @@ function logDirect(jsonStr, deps = {}) {
420
420
  }
421
421
  }
422
422
 
423
+ function leftoverClaimableInsight(insight) {
424
+ return /^\[claimable\]/i.test(String(insight || '').trim());
425
+ }
426
+
423
427
  /**
424
428
  * Harvest learnings from journal Notes sections.
425
429
  * Scans recent journals for lines that look like insights.
@@ -450,13 +454,14 @@ function harvestFromJournals(deps = {}) {
450
454
  // Scan last 7 journals for Notes section entries
451
455
  const candidates = [];
452
456
  for (const logPath of allLogs.slice(0, 7)) {
453
- const content = fs.readFileSync(logPath, 'utf8');
457
+ const content = fs.readFileSync(logPath, 'utf8').replace(/\r\n/g, '\n');
454
458
  const notesMatch = content.match(/## Notes\n([\s\S]*?)(?=\n## |$)/);
455
459
  if (notesMatch && notesMatch[1].trim()) {
456
460
  const lines = notesMatch[1].trim().split('\n').filter(l => l.startsWith('- '));
457
461
  for (const line of lines) {
458
462
  // Strip bullet and optional timestamp prefix
459
463
  const insight = line.replace(/^- (\d{2}:\d{2} \u2014 )?/, '').trim();
464
+ if (leftoverClaimableInsight(insight)) continue;
460
465
  if (insight.length > 10) {
461
466
  candidates.push({ insight, source: path.basename(logPath) });
462
467
  }
@@ -10,6 +10,7 @@ const { defaultObjectiveRunner } = require('../lib/default-runner');
10
10
  const { readJson, writeJson } = require('../lib/json-file');
11
11
  const { hasFlag, readFlag, readNumberFlag } = require('../lib/arg-parser');
12
12
  const { ensureMemberBundle, memberBundlePresent } = require('../lib/member-scaffold');
13
+ const { memberProcessPrompt, MEMBER_PROCESS_PATH } = require('../lib/member-context');
13
14
 
14
15
  function findWorkspaceBusinessId(startDir = process.cwd()) {
15
16
  let dir = path.resolve(startDir);
@@ -462,6 +463,7 @@ function missionPurpose(paths) {
462
463
  }
463
464
 
464
465
  const MEMBER_RUN_RUNNABLE_STATUSES = new Set(['planning', 'running', 'ready']);
466
+ const MISSION_TERMINAL_STATUSES = new Set(['complete', 'stopped', 'failed']);
465
467
 
466
468
  function memberRunMissionMap() {
467
469
  try {
@@ -928,12 +930,11 @@ function memberPing(name, ...args) {
928
930
  process.exit(2);
929
931
  }
930
932
  const missionMod = require('./mission');
931
- const terminal = new Set(['complete', 'stopped', 'failed']);
932
933
  const candidates = [
933
934
  ...missionMod.listMissions(process.cwd()),
934
935
  ...missionMod.listWorktreeRollupMissions(process.cwd()),
935
936
  ]
936
- .filter((m) => m && m.owner === name && !terminal.has(m.status))
937
+ .filter((m) => m && m.owner === name && !MISSION_TERMINAL_STATUSES.has(m.status))
937
938
  .sort((a, b) => String(b.updated_at || b.created_at || '').localeCompare(String(a.updated_at || a.created_at || '')));
938
939
 
939
940
  const taskNote = pingClaimedTaskDialogue(name, text, from);
@@ -4216,6 +4217,7 @@ function memberActivate(name) {
4216
4217
 
4217
4218
  const content = fs.readFileSync(activePath, 'utf8');
4218
4219
  const fm = parseFrontmatter(content) || {};
4220
+ const sharedProcess = memberProcessPrompt(process.cwd());
4219
4221
 
4220
4222
  console.log('');
4221
4223
  console.log(`Activating: ${fm.name || name} (${fm.role || 'no role'})`);
@@ -4298,7 +4300,8 @@ function memberActivate(name) {
4298
4300
 
4299
4301
  console.log('');
4300
4302
  console.log(`Member "${fm.name || name}" activated.`);
4301
- console.log(`Tell your agent: "You are the ${fm.role || name}. Read team/${name}/MEMBER.md."`);
4303
+ const identityPath = path.relative(process.cwd(), activePath);
4304
+ console.log(`Tell your agent: "You are the ${fm.role || name}. Read ${sharedProcess ? `${MEMBER_PROCESS_PATH}, then ` : ''}${identityPath}. Stay inside this member's permissions."`);
4302
4305
  }
4303
4306
 
4304
4307
  // --- UPGRADE subcommand ---
@@ -4707,6 +4710,49 @@ function memberGoalFromMission(name, ...args) {
4707
4710
  );
4708
4711
  return;
4709
4712
  }
4713
+ if (MISSION_TERMINAL_STATUSES.has(lowerCompact(runtime.status || ''))) {
4714
+ // A finished mission must not mint or re-point an active goal; that recreated the
4715
+ // stale-goal defect class (OBL-1961, OBL-2212, OBL-2232) after every retire. --force
4716
+ // cannot bypass this, it only widens the existing-goal match on a living mission.
4717
+ const staleGoals = state.goals.filter((goal) => (
4718
+ goal.source === 'mission' && goal.status === 'active'
4719
+ && runtime.id && goal.mission_id === runtime.id
4720
+ ));
4721
+ const staleList = staleGoals.map((goal) => goal.title || goal.id).join('; ');
4722
+ const ask = staleGoals.length
4723
+ ? `Mission ${runtime.id || compactSentence(runtimeFocus, 88)} is ${runtime.status}, so its active goal is stale. Retire ${compactSentence(staleList, 140)} by setting its status in goals.json, or start a living mission and run: atris member goal-from-mission ${name} --force`
4724
+ : `Mission ${runtime.id || compactSentence(runtimeFocus, 88)} is ${runtime.status}. Start a living mission, then run: atris member goal-from-mission ${name} --force`;
4725
+ const logPath = appendMemberGoalLog(paths.memberDir, name, 'Member goal-from-mission blocked', {
4726
+ ask,
4727
+ mission_id: runtime.id || '',
4728
+ mission_status: runtime.status || '',
4729
+ stale_goals: staleGoals.map((goal) => goal.id),
4730
+ });
4731
+ printJsonOrText(
4732
+ {
4733
+ ok: true,
4734
+ action: 'needs_user',
4735
+ member: name,
4736
+ needs_user: true,
4737
+ ask,
4738
+ stale_goals: staleGoals,
4739
+ mission: {
4740
+ north_star: purpose.northStar,
4741
+ runtime_id: runtime.id || null,
4742
+ runtime_status: runtime.status || null,
4743
+ runtime_next: runtime.next || null,
4744
+ },
4745
+ mission_file: paths.missionFile,
4746
+ log_path: logPath,
4747
+ },
4748
+ [
4749
+ `Blocked for ${name}: mission ${runtime.id || 'in now.md'} is ${runtime.status}, a finished mission cannot drive an active goal.`,
4750
+ `Ask: ${ask}`,
4751
+ ],
4752
+ asJson,
4753
+ );
4754
+ return;
4755
+ }
4710
4756
  // The title IS the mission focus, no boilerplate prefix; the acceptance list already
4711
4757
  // says "one bounded step" and the why carries the full sentence.
4712
4758
  const title = compactSentence(runtimeFocus, 96);
@@ -4977,7 +5023,8 @@ function fallbackProposalForGoal(goal, context = {}) {
4977
5023
  };
4978
5024
  }
4979
5025
 
4980
- function proposalPromptForGoal(goal, context = {}) {
5026
+ function proposalPromptForGoal(goal, context = {}, cwd = process.cwd()) {
5027
+ const sharedProcess = memberProcessPrompt(cwd);
4981
5028
  const files = (context?.evidence?.goal_files?.files || [])
4982
5029
  .filter((file) => file.exists && file.excerpt)
4983
5030
  .slice(0, 4)
@@ -5005,6 +5052,7 @@ function proposalPromptForGoal(goal, context = {}) {
5005
5052
  },
5006
5053
  };
5007
5054
  return [
5055
+ ...(sharedProcess ? [sharedProcess, ''] : []),
5008
5056
  'You generate the next bounded Atris member experiment.',
5009
5057
  'Read the JSON context and return only JSON with keys: title, proof_target, next_step, verifier, stop_rule.',
5010
5058
  'The next_step must be adaptive to the goal/evidence, concrete, receipt-backed, and safe for one bounded tick.',
@@ -5058,10 +5106,11 @@ async function callAtris2ProposalLlm(goal, context = {}) {
5058
5106
  const injected = injectedLlmProposal();
5059
5107
  if (injected) return injected;
5060
5108
  if (process.env.ATRIS_MEMBER_PROPOSAL_LLM !== '1') return null;
5109
+ const prompt = proposalPromptForGoal(goal, context, process.cwd());
5061
5110
  try {
5062
5111
  const { postTurn } = require('../ax');
5063
5112
  const output = { isTTY: false, write() { return true; } };
5064
- const result = await postTurn(proposalPromptForGoal(goal, context), {
5113
+ const result = await postTurn(prompt, {
5065
5114
  mode: process.env.ATRIS_MEMBER_PROPOSAL_LLM_MODE || 'fast',
5066
5115
  route: 'local',
5067
5116
  cwd: process.cwd(),
@@ -5761,6 +5810,7 @@ function emptyObjectiveGeneratorProposal(extra = {}) {
5761
5810
  updated_at: stampIso(),
5762
5811
  status: extra.status || 'empty',
5763
5812
  advisory_only: true,
5813
+ auto_task_eligible: extra.auto_task_eligible !== false,
5764
5814
  world_model_used: false,
5765
5815
  llm_source: extra.llm_source || null,
5766
5816
  llm_error: extra.llm_error || null,
@@ -5893,8 +5943,8 @@ function fallbackObjectiveGeneratorProposal(graph, recommendations, transferPatt
5893
5943
  ),
5894
5944
  suggested_member: member,
5895
5945
  suggested_patterns: objectivePatternMatches(transferPatterns, proposedObjective),
5896
- }, { status: 'ok', llm_error: 'llm_not_configured', world_model_used: true });
5897
- return proposal || emptyObjectiveGeneratorProposal({ status: 'llm_not_configured', llm_error: 'llm_not_configured' });
5946
+ }, { status: 'ok', llm_error: 'llm_not_configured', world_model_used: true, auto_task_eligible: false });
5947
+ return proposal || emptyObjectiveGeneratorProposal({ status: 'llm_not_configured', llm_error: 'llm_not_configured', auto_task_eligible: false });
5898
5948
  }
5899
5949
 
5900
5950
  function objectiveGeneratorPrompt(graph, recommendations, transferPatterns = []) {
@@ -6056,7 +6106,7 @@ async function runObjectiveGeneratorWake(name, paths, { execute = false } = {})
6056
6106
  proposal = emptyObjectiveGeneratorProposal({ status: llm.error === 'invalid_json' ? 'parse_error' : 'llm_error', llm_source: llm.source, llm_error: llm.error });
6057
6107
  proposal.world_model_used = true;
6058
6108
  } else {
6059
- reason = 'heuristic_objective_proposal_written';
6109
+ reason = execute ? 'heuristic_objective_proposal_written' : 'heuristic_objective_proposal_dry_run';
6060
6110
  proposal = fallbackObjectiveGeneratorProposal(graph, recommendations, transferPatterns);
6061
6111
  }
6062
6112
  }
@@ -6065,7 +6115,7 @@ async function runObjectiveGeneratorWake(name, paths, { execute = false } = {})
6065
6115
  proposal.suggested_patterns = objectivePatternMatches(transferPatterns, proposal.proposed_objective);
6066
6116
  }
6067
6117
 
6068
- if (execute && proposal?.status === 'ok' && Number(proposal.overall_score) > 7) {
6118
+ if (execute && proposal?.status === 'ok' && proposal.auto_task_eligible !== false && Number(proposal.overall_score) > 7) {
6069
6119
  createdTask = createAutoObjectiveTask(proposal);
6070
6120
  proposal.created_task = createdTask.ok ? {
6071
6121
  id: createdTask.task_id || null,
@@ -6081,7 +6131,8 @@ async function runObjectiveGeneratorWake(name, paths, { execute = false } = {})
6081
6131
  }
6082
6132
 
6083
6133
  const proposalsPath = objectiveGeneratorProposalsPath(root);
6084
- if (execute) {
6134
+ const proposalsWritten = Boolean(execute);
6135
+ if (proposalsWritten) {
6085
6136
  fs.mkdirSync(path.dirname(proposalsPath), { recursive: true });
6086
6137
  fs.writeFileSync(proposalsPath, JSON.stringify(proposal, null, 2) + '\n', 'utf8');
6087
6138
  }
@@ -6111,6 +6162,7 @@ async function runObjectiveGeneratorWake(name, paths, { execute = false } = {})
6111
6162
  llm_successful: Boolean(llm?.source && llm?.proposal && proposal.status === 'ok'),
6112
6163
  llm_error: llm?.error || proposal.llm_error || null,
6113
6164
  proposals_path: path.relative(root, proposalsPath),
6165
+ proposals_written: proposalsWritten,
6114
6166
  task_creation_threshold: 7,
6115
6167
  task_created: Boolean(createdTask?.ok),
6116
6168
  created_task: proposal.created_task,
@@ -6128,7 +6180,7 @@ async function runObjectiveGeneratorWake(name, paths, { execute = false } = {})
6128
6180
  score: proposal.overall_score || '',
6129
6181
  task: proposal.created_task?.ref || '',
6130
6182
  receipt: path.relative(root, receiptPath),
6131
- output: path.relative(root, proposalsPath),
6183
+ output: proposalsWritten ? path.relative(root, proposalsPath) : '',
6132
6184
  });
6133
6185
 
6134
6186
  return {
@@ -7817,6 +7869,7 @@ const WAKE_REASON_TEXT = {
7817
7869
  auto_improver_task_create_failed: 'it found an improvement but could not put the task on the board',
7818
7870
  heuristic_cross_domain_proof_written: 'it wrote a cross-domain proof using its built-in heuristics',
7819
7871
  heuristic_objective_proposal_written: 'it drafted an objective proposal using its built-in heuristics',
7872
+ heuristic_objective_proposal_dry_run: 'it previewed an objective proposal using its built-in heuristics, without writing it',
7820
7873
  install_requires_clean_git: 'installing needs a clean git tree first',
7821
7874
  insufficient_world_model_data: 'its world model is too thin to act on yet',
7822
7875
  llm_json_parse_failed: 'the model reply did not parse, so it stopped rather than act on garbage',
@@ -9232,6 +9285,7 @@ async function memberCommand(subcommand, ...args) {
9232
9285
  }
9233
9286
 
9234
9287
  module.exports = {
9288
+ proposalPromptForGoal,
9235
9289
  memberCommand,
9236
9290
  findAllMembers,
9237
9291
  findWorkspaceBusinessId,