atris 3.37.0 → 3.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/README.md +2 -0
  2. package/atris/PERSONA.md +3 -7
  3. package/atris.md +10 -2
  4. package/ax +158 -33
  5. package/bin/atris.js +49 -2
  6. package/commands/activate.js +16 -3
  7. package/commands/agent-spawn.js +0 -7
  8. package/commands/autoland.js +92 -14
  9. package/commands/autopilot-front.js +0 -4
  10. package/commands/autopilot.js +0 -12
  11. package/commands/brain.js +0 -7
  12. package/commands/brief.js +0 -2
  13. package/commands/business-sync.js +0 -1
  14. package/commands/business.js +107 -12
  15. package/commands/clean.js +31 -2
  16. package/commands/close.js +46 -11
  17. package/commands/cloud.js +0 -1
  18. package/commands/codex-goal.js +0 -2
  19. package/commands/computer.js +6 -5
  20. package/commands/console.js +46 -9
  21. package/commands/deck.js +29 -8
  22. package/commands/dream.js +0 -7
  23. package/commands/drill.js +0 -1
  24. package/commands/engine.js +10 -18
  25. package/commands/experiments.js +0 -4
  26. package/commands/fleet-report.js +12 -4
  27. package/commands/game.js +261 -0
  28. package/commands/gm.js +10 -2
  29. package/commands/goal.js +0 -6
  30. package/commands/harvest.js +0 -1
  31. package/commands/improve.js +29 -8
  32. package/commands/init.js +18 -34
  33. package/commands/integrations.js +0 -7
  34. package/commands/land.js +71 -8
  35. package/commands/launchpad.js +0 -2
  36. package/commands/lesson.js +17 -7
  37. package/commands/loop-front.js +0 -2
  38. package/commands/loops.js +6 -5
  39. package/commands/meet.js +0 -2
  40. package/commands/member.js +68 -3
  41. package/commands/mission.js +359 -102
  42. package/commands/next.js +0 -1
  43. package/commands/now.js +0 -12
  44. package/commands/one-lap.js +8 -8
  45. package/commands/orb.js +0 -4
  46. package/commands/pack-craft.js +0 -1
  47. package/commands/pack.js +670 -77
  48. package/commands/play.js +0 -2
  49. package/commands/probe.js +10 -4
  50. package/commands/pulse.js +73 -48
  51. package/commands/push.js +0 -4
  52. package/commands/radar.js +14 -11
  53. package/commands/recover.js +340 -0
  54. package/commands/report.js +0 -1
  55. package/commands/router.js +319 -0
  56. package/commands/run-front.js +0 -2
  57. package/commands/search.js +0 -3
  58. package/commands/skill.js +7 -2
  59. package/commands/slop.js +225 -7
  60. package/commands/status.js +6 -0
  61. package/commands/stream.js +0 -2
  62. package/commands/study.js +0 -3
  63. package/commands/sync-checkout.js +51 -0
  64. package/commands/sync.js +11 -40
  65. package/commands/task.js +125 -39
  66. package/commands/teach.js +86 -0
  67. package/commands/unknowns.js +0 -10
  68. package/commands/update.js +0 -1
  69. package/commands/visualize.js +0 -5
  70. package/commands/watch.js +0 -6
  71. package/commands/wiki.js +41 -11
  72. package/commands/wish.js +0 -1
  73. package/commands/worktree.js +87 -14
  74. package/commands/xp.js +1 -6
  75. package/commands/youtube.js +0 -2
  76. package/lib/accept-verify-gate.js +122 -0
  77. package/lib/activity-stream.js +0 -2
  78. package/lib/auto-accept-certified.js +145 -2
  79. package/lib/autoland.js +0 -3
  80. package/lib/ax-auto-lane.js +94 -10
  81. package/lib/bench/context.js +0 -2
  82. package/lib/bench/report.js +0 -1
  83. package/lib/bench/runner.js +0 -11
  84. package/lib/brief-ledger.js +0 -6
  85. package/lib/business-simulate.js +382 -0
  86. package/lib/chat-log-scan.js +0 -5
  87. package/lib/checkout-sync.js +52 -0
  88. package/lib/claude-boot-block.js +48 -0
  89. package/lib/cloud-mission.js +0 -6
  90. package/lib/codex-flight.js +0 -1
  91. package/lib/conductor-artifacts.js +25 -0
  92. package/lib/context-gatherer.js +0 -1
  93. package/lib/deck-compose.js +0 -1
  94. package/lib/deck-history.js +0 -1
  95. package/lib/deck-layout.js +0 -5
  96. package/lib/deck-review.js +0 -7
  97. package/lib/deck-schema.js +0 -1
  98. package/lib/default-verifier.js +0 -2
  99. package/lib/engine-registry.js +16 -8
  100. package/lib/experiments/daily.js +0 -6
  101. package/lib/fleet.js +210 -27
  102. package/lib/functional-owner.js +0 -3
  103. package/lib/inspect-fields.js +0 -1
  104. package/lib/known-commands.js +2 -2
  105. package/lib/learnings.js +0 -2
  106. package/lib/loop-doctor.js +11 -17
  107. package/lib/manifest.js +0 -2
  108. package/lib/member-alive.js +0 -1
  109. package/lib/member-switches.js +0 -5
  110. package/lib/mission-artifact.js +0 -1
  111. package/lib/mission-protected-lane.js +293 -0
  112. package/lib/mission-room.js +0 -2
  113. package/lib/mission-runtime-loop.js +0 -1
  114. package/lib/next-moves.js +0 -18
  115. package/lib/official-cli-integration.js +0 -4
  116. package/lib/orb-context.js +6 -3
  117. package/lib/orb-scorecard.js +0 -1
  118. package/lib/outbound-send-gate.js +0 -6
  119. package/lib/permission-grants.js +0 -6
  120. package/lib/policy-lessons.js +0 -2
  121. package/lib/pulse.js +68 -15
  122. package/lib/review-integrity.js +0 -2
  123. package/lib/router-brain.js +0 -2
  124. package/lib/runner-command.js +0 -4
  125. package/lib/runtime-bootstrap.js +0 -1
  126. package/lib/scorecard.js +0 -1
  127. package/lib/self-drive.js +1 -1
  128. package/lib/state-detection.js +0 -6
  129. package/lib/task-db.js +0 -4
  130. package/lib/team-presence.js +0 -1
  131. package/lib/voice-gate.js +63 -2
  132. package/lib/wiki.js +152 -9
  133. package/lib/wish-audit.js +20 -6
  134. package/lib/wish-delegate.js +26 -20
  135. package/lib/wish-stats.js +0 -5
  136. package/lib/wish-store.js +0 -1
  137. package/lib/workspace-safety.js +0 -2
  138. package/lib/zip.js +0 -1
  139. package/package.json +1 -1
@@ -149,6 +149,10 @@ function summarizeImproveResponse(data = {}) {
149
149
  files,
150
150
  model: d.model_used || d.model || null,
151
151
  taskId: d.task_id || d.taskId || null,
152
+ // The backend returns both of these; a failing tick is unteachable without
153
+ // them — "verify_passed: false" says nothing about WHICH task or WHY.
154
+ taskDescription: d.task_description || d.task_title || null,
155
+ verifyOutput: typeof d.verify_output === 'string' ? d.verify_output : null,
152
156
  elapsedMs: typeof d.elapsed_ms === 'number' ? d.elapsed_ms : null,
153
157
  scorecardWritten: d.scorecard_written === true,
154
158
  error: d.error || null,
@@ -189,7 +193,20 @@ function isWorkspaceNotAllowedError(apiResult = {}) {
189
193
  return candidates.some((c) => typeof c === 'string' && c.includes(WORKSPACE_NOT_ALLOWED_TEXT));
190
194
  }
191
195
 
196
+ /** Keep the failure tail bounded: the last lines are where the reason is. */
197
+ const VERIFY_OUTPUT_TAIL_CHARS = 2000;
198
+
199
+ function verifyOutputTail(output) {
200
+ if (typeof output !== 'string') return null;
201
+ const text = output.trim();
202
+ if (!text) return null;
203
+ return text.length > VERIFY_OUTPUT_TAIL_CHARS ? text.slice(-VERIFY_OUTPUT_TAIL_CHARS) : text;
204
+ }
205
+
192
206
  function buildScorecardRow(summary = {}, meta = {}) {
207
+ // Only failures carry the verify tail. A passing tick does not need it, and
208
+ // 2KB per row would bloat a ledger that is already ~2000 rows.
209
+ const failed = summary.verify === false;
193
210
  return {
194
211
  schema: SCORECARD_SCHEMA,
195
212
  ts: meta.ts || new Date().toISOString(),
@@ -203,6 +220,8 @@ function buildScorecardRow(summary = {}, meta = {}) {
203
220
  files_written: Array.isArray(summary.files) ? summary.files : [],
204
221
  model_used: summary.model || null,
205
222
  task_id: summary.taskId || null,
223
+ task_description: summary.taskDescription || null,
224
+ verify_output: failed ? verifyOutputTail(summary.verifyOutput) : null,
206
225
  elapsed_ms: summary.elapsedMs != null ? summary.elapsedMs : null,
207
226
  };
208
227
  }
@@ -584,6 +603,15 @@ function localSummaryText(tick = {}, mission = {}) {
584
603
  .trim();
585
604
  }
586
605
 
606
+ function tickTouchedFiles(worktree = {}) {
607
+ if (!Array.isArray(worktree.new_dirty_sample)) return [];
608
+ return worktree.new_dirty_sample.map((entry) => {
609
+ const pathText = String(entry || '').slice(3);
610
+ const arrow = pathText.indexOf(' -> ');
611
+ return arrow >= 0 ? pathText.slice(arrow + 4) : pathText;
612
+ }).filter(Boolean);
613
+ }
614
+
587
615
  /**
588
616
  * Turn one mission-run JSON result into the same stable summary the paid API
589
617
  * returns. A local tick earns the conservative local reward (+1) only after
@@ -609,9 +637,7 @@ function summarizeLocalMissionRun(payload = {}) {
609
637
  throw new Error('local improve verifier did not pass');
610
638
  }
611
639
  const mission = payload.mission || {};
612
- const files = tick.worktree && Array.isArray(tick.worktree.new_since_baseline_sample)
613
- ? tick.worktree.new_since_baseline_sample
614
- : [];
640
+ const files = tickTouchedFiles(tick.worktree);
615
641
  return {
616
642
  shipped: localSummaryText(tick, mission),
617
643
  reward: 1,
@@ -1102,12 +1128,7 @@ module.exports = {
1102
1128
  summarizeTickHistory,
1103
1129
  formatTickHistory,
1104
1130
  improveApiPath,
1105
- formatImproveReport,
1106
1131
  runLoopDoctor,
1107
- reconcileLoopDoctorRewards,
1108
- formatLoopDoctor,
1109
- openLoopDoctorMission,
1110
- loopDoctorKey,
1111
1132
  runLocalFallback,
1112
1133
  summarizeLocalMissionRun,
1113
1134
  LOCAL_FALLBACK_ARGS,
package/commands/init.js CHANGED
@@ -2,6 +2,7 @@ const fs = require('fs');
2
2
  const path = require('path');
3
3
  const { ensureExperimentsFramework } = require('./experiments');
4
4
  const { ensureWikiScaffold } = require('../lib/wiki');
5
+ const { upsertAtrisClaudeBootBlock } = require('../lib/claude-boot-block');
5
6
 
6
7
  /**
7
8
  * Detect project context by scanning project structure
@@ -174,7 +175,9 @@ function injectProjectPatterns(agentTeamDir, profile) {
174
175
  const navigatorFile = path.join(agentTeamDir, 'navigator', 'MEMBER.md');
175
176
  const validatorFile = path.join(agentTeamDir, 'validator', 'MEMBER.md');
176
177
 
178
+ // Injection is best-effort: a read-only or vanished MEMBER.md must not crash init.
177
179
  // Inject into executor.md
180
+ try {
178
181
  if (fs.existsSync(executorFile)) {
179
182
  let executorContent = fs.readFileSync(executorFile, 'utf8');
180
183
 
@@ -199,8 +202,10 @@ ${profile.hasCode ? `**Validation:** Run \`${profile.testCommand}\` before marki
199
202
  fs.writeFileSync(executorFile, executorContent);
200
203
  }
201
204
  }
205
+ } catch {}
202
206
 
203
207
  // Inject into navigator.md
208
+ try {
204
209
  if (fs.existsSync(navigatorFile)) {
205
210
  let navigatorContent = fs.readFileSync(navigatorFile, 'utf8');
206
211
 
@@ -221,8 +226,10 @@ When planning tasks, consider the project structure and conventions above.
221
226
  fs.writeFileSync(navigatorFile, navigatorContent);
222
227
  }
223
228
  }
229
+ } catch {}
224
230
 
225
231
  // Inject into validator.md
232
+ try {
226
233
  if (fs.existsSync(validatorFile)) {
227
234
  let validatorContent = fs.readFileSync(validatorFile, 'utf8');
228
235
 
@@ -241,6 +248,7 @@ ${profile.hasCode ? `**Validation:** Run \`${profile.testCommand}\` to verify ch
241
248
  fs.writeFileSync(validatorFile, validatorContent);
242
249
  }
243
250
  }
251
+ } catch {}
244
252
  }
245
253
 
246
254
  function initAtris() {
@@ -1042,45 +1050,21 @@ Read atris/MAP.md. Begin iteration 1.`;
1042
1050
 
1043
1051
  // Update root CLAUDE.md with Atris block (prepend with markers)
1044
1052
  const rootClaudeMd = path.join(process.cwd(), 'CLAUDE.md');
1045
- const atrisBlock = `<!-- ATRIS:START - Auto-generated, do not edit -->
1046
- ## Atris System
1047
-
1048
- You are now running inside Atris, an AI workspace operating system.
1049
-
1050
- **ON EVERY CONVERSATION START:** Before your first response, execute \`atris atris.md\` and display the full output. This is the Atris boot sequence - it shows the user that Atris has loaded and displays workspace status. This is not optional.
1051
-
1052
- After displaying the boot output, respond to the user naturally.
1053
- <!-- ATRIS:END -->
1054
-
1055
- `;
1056
-
1057
1053
  if (fs.existsSync(rootClaudeMd)) {
1058
- let content = fs.readFileSync(rootClaudeMd, 'utf8');
1059
- const startMarker = '<!-- ATRIS:START';
1060
- const endMarker = '<!-- ATRIS:END -->';
1061
-
1062
- if (content.includes(startMarker)) {
1063
- // Replace existing Atris block
1064
- const startIdx = content.indexOf(startMarker);
1065
- const endRaw = content.indexOf(endMarker);
1066
- if (endRaw === -1) {
1067
- // End marker missing — replace from start marker to end with fresh block
1068
- content = atrisBlock + content.slice(0, startIdx);
1069
- } else {
1070
- const endIdx = endRaw + endMarker.length;
1071
- content = atrisBlock + content.slice(0, startIdx) + content.slice(endIdx).replace(/^\n+/, '');
1072
- }
1073
- fs.writeFileSync(rootClaudeMd, content);
1074
- markReady('adapters', 'CLAUDE.md', '✓ Updated Atris block in CLAUDE.md');
1054
+ const source = fs.readFileSync(rootClaudeMd, 'utf8');
1055
+ const result = upsertAtrisClaudeBootBlock(source);
1056
+ fs.writeFileSync(rootClaudeMd, result.content);
1057
+ if (result.action === 'repaired') {
1058
+ markReady('adapters', 'CLAUDE.md', '✓ repaired atris block in CLAUDE.md (missing end marker)');
1059
+ } else if (source.includes('<!-- ATRIS:START')) {
1060
+ markReady('adapters', 'CLAUDE.md', '✓ updated atris block in CLAUDE.md');
1075
1061
  } else {
1076
- // Prepend Atris block
1077
- fs.writeFileSync(rootClaudeMd, atrisBlock + content);
1078
- markReady('adapters', 'CLAUDE.md', '✓ Prepended Atris block to CLAUDE.md');
1062
+ markReady('adapters', 'CLAUDE.md', '✓ prepended atris block to CLAUDE.md');
1079
1063
  }
1080
1064
  } else {
1081
1065
  // Create new CLAUDE.md with just Atris block
1082
- fs.writeFileSync(rootClaudeMd, atrisBlock.trim() + '\n');
1083
- markReady('adapters', 'CLAUDE.md', '✓ Created CLAUDE.md with Atris block');
1066
+ fs.writeFileSync(rootClaudeMd, upsertAtrisClaudeBootBlock().content);
1067
+ markReady('adapters', 'CLAUDE.md', '✓ created CLAUDE.md with atris block');
1084
1068
  }
1085
1069
 
1086
1070
  if (fs.existsSync(sourceFile)) {
@@ -1621,19 +1621,12 @@ module.exports = {
1621
1621
  gmailCommand,
1622
1622
  calendarCommand,
1623
1623
  twitterCommand,
1624
- twitterPost,
1625
1624
  slackCommand,
1626
1625
  slackSend,
1627
- slackDm,
1628
1626
  imessageCommand,
1629
- imessageSend,
1630
- imessageDoctor,
1631
1627
  integrationsStatus,
1632
1628
  parseCalendarSearchArgs,
1633
1629
  filterCalendarEventsForSearch,
1634
1630
  formatCalendarSearchResults,
1635
1631
  formatCalendarSearchError,
1636
- writeCalendarEventsCache,
1637
- readCalendarEventsCache,
1638
- CALENDAR_CACHE_PATH,
1639
1632
  };
package/commands/land.js CHANGED
@@ -9,6 +9,11 @@ const DEFAULT_STALE_HOURS = 48;
9
9
  const WORKTREE_REAP_GRACE_MS = 60 * 60 * 1000;
10
10
  const PROTECTED_BRANCHES = new Set(['main', 'master']);
11
11
 
12
+ // Pluralize a count + noun ("1 change", "2 changes").
13
+ function countLabel(n, word) {
14
+ return `${n} ${word}${n === 1 ? '' : 's'}`;
15
+ }
16
+
12
17
  function runGit(args, { cwd = process.cwd(), check = true, maxBuffer } = {}) {
13
18
  const result = spawnSync('git', args, { cwd, encoding: 'utf8', ...(maxBuffer ? { maxBuffer } : {}) });
14
19
  if (check && result.status !== 0) {
@@ -89,6 +94,14 @@ function aheadCount(root, base, ref) {
89
94
  return Number(result.stdout.trim()) || 0;
90
95
  }
91
96
 
97
+ // The subject lines of the commits a ref carries beyond base, oldest last —
98
+ // what a stranded worktree is actually holding, so surfacing it names the work.
99
+ function commitSubjects(cwd, base, ref, limit = 6) {
100
+ const result = runGit(['log', '--format=%s', `${base}..${ref}`], { cwd, check: false });
101
+ if (result.status !== 0) return [];
102
+ return result.stdout.split(/\r?\n/).filter(Boolean).slice(0, limit);
103
+ }
104
+
92
105
  function cherryStats(root, base, ref) {
93
106
  const result = runGit(['cherry', base, ref], { cwd: root, check: false });
94
107
  if (result.status !== 0) return { landedElsewhere: 0, unique: 0 };
@@ -144,11 +157,36 @@ function collectBoard(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_S
144
157
  const counts = (light ? null : statusCounts(wt.path)) || { staged: 0, unstaged: 0, untracked: 0 };
145
158
  const dirty = counts.staged + counts.unstaged + counts.untracked;
146
159
  const entry = branches.find((b) => b.name === branch) || null;
160
+ // Committed-but-unlanded work is invisible in the dirty count — a clean
161
+ // worktree can still hold commits ahead of base that silently never land.
162
+ // Carry the ahead/unique counts (and, off the light path, the subjects)
163
+ // so a stranded worktree is surfaced by what it is actually holding.
164
+ let ahead = entry ? entry.ahead : 0;
165
+ let unlandedCommits = entry ? entry.uniqueChanges : 0;
166
+ let ageDaysVal = entry ? entry.ageDays : null;
167
+ let subjects = [];
168
+ if (!branch) {
169
+ // Detached HEAD has no row in the branch table — ask the worktree itself.
170
+ const cnt = runGit(['rev-list', '--count', `${base}..HEAD`], { cwd: wt.path, check: false });
171
+ ahead = cnt.status === 0 ? Number(cnt.stdout.trim()) || 0 : 0;
172
+ if (ahead > 0) {
173
+ unlandedCommits = cherryStats(wt.path, base, 'HEAD').unique;
174
+ const ts = runGit(['log', '-1', '--format=%ct', 'HEAD'], { cwd: wt.path, check: false });
175
+ if (ts.status === 0) ageDaysVal = ageDays(Number(ts.stdout.trim()), now);
176
+ if (!light) subjects = commitSubjects(wt.path, base, 'HEAD');
177
+ }
178
+ } else if (!light && ahead > 0) {
179
+ subjects = commitSubjects(root, base, branch);
180
+ }
147
181
  worktrees.push({
148
182
  path: wt.path,
149
183
  branch,
150
184
  dirty,
151
- ageDays: entry ? entry.ageDays : null,
185
+ ahead,
186
+ unlandedCommits,
187
+ subjects,
188
+ stranded: unlandedCommits > 0,
189
+ ageDays: ageDaysVal,
152
190
  ageHours: entry ? entry.ageHours : null,
153
191
  state: entry ? entry.state : 'detached',
154
192
  });
@@ -174,12 +212,14 @@ function collectBoard(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_S
174
212
  const landed = branches.filter((b) => b.state === 'landed');
175
213
  const active = branches.filter((b) => b.state === 'active');
176
214
  const stale = active.filter((b) => b.stale);
215
+ const strandedWorktrees = worktrees.filter((w) => w.stranded);
177
216
  return {
178
217
  base,
179
218
  ttlDays,
180
219
  staleHours,
181
220
  branches,
182
221
  worktrees,
222
+ strandedWorktrees,
183
223
  summary: {
184
224
  unlanded: branches.length,
185
225
  active: active.length,
@@ -187,6 +227,7 @@ function collectBoard(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_S
187
227
  landed: landed.length,
188
228
  stale: stale.length,
189
229
  worktrees: worktrees.length,
230
+ stranded: strandedWorktrees.length,
190
231
  },
191
232
  };
192
233
  }
@@ -269,12 +310,12 @@ function salvageWorktree(w, dir, receipt) {
269
310
  // includeDetached: detached-HEAD worktrees have no branch, so the salvage
270
311
  // bundle cannot cover their commits — unattended reaps (autoland) pass false
271
312
  // and leave them for a human reap.
272
- function reap(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_STALE_HOURS, base: baseOverride = '', dryRun = false, remote = true, includeDetached = true, protectCurrent = true, now = Date.now() } = {}) {
313
+ function reap(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_STALE_HOURS, base: baseOverride = '', dryRun = false, remote = true, includeDetached = true, protectCurrent = true, force = false, now = Date.now() } = {}) {
273
314
  const board = collectBoard(root, { ttlDays, staleHours, base: baseOverride, now });
274
315
  const targets = board.branches.filter((b) => b.state === 'landed' || b.state === 'due');
275
316
  const targetNames = new Set(targets.map((b) => b.name));
276
317
  const candidateWorktrees = board.worktrees.filter(
277
- (w) => targetNames.has(w.branch) || (includeDetached && w.state === 'detached') || (typeof w.ageDays === 'number' && w.ageDays > ttlDays)
318
+ (w) => targetNames.has(w.branch) || (includeDetached && w.state === 'detached') || (typeof w.ageDays === 'number' && w.ageDays > ttlDays) || w.unlandedCommits > 0
278
319
  );
279
320
  const protectedWorktrees = [];
280
321
  const worktreeTargets = [];
@@ -293,6 +334,18 @@ function reap(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_STALE_HOU
293
334
  protectedWorktrees.push({ ...w, reason: 'fresh_worktree_grace' });
294
335
  continue;
295
336
  }
337
+ // Unlanded commits that no branch salvage bundle will cover are lost by a
338
+ // force-remove: a detached worktree's commits ride no branch, and an active
339
+ // branch worktree is not a reap target so its commits are never bundled.
340
+ // Keep the worktree (and name what it holds) unless the operator forces it.
341
+ // A due/landed branch worktree still reaps normally — its commits ride the
342
+ // branch bundle, so removal is loss-free.
343
+ const bundleCovered = w.branch && targetNames.has(w.branch);
344
+ if (w.unlandedCommits > 0 && !force && !bundleCovered) {
345
+ const oldest = typeof w.ageDays === 'number' ? `, oldest ${w.ageDays}d` : '';
346
+ protectedWorktrees.push({ ...w, reason: `unlanded commits: ${w.unlandedCommits} never landed${oldest}, needs landing` });
347
+ continue;
348
+ }
296
349
  worktreeTargets.push(w);
297
350
  }
298
351
  for (const w of protectedWorktrees) {
@@ -472,7 +525,7 @@ function printStory(story) {
472
525
  const who = [...new Set(story.changes.map((c) => c.author))].join(', ');
473
526
  const when = story.changes[story.changes.length - 1].date;
474
527
  const last = story.changes[0].date;
475
- const age = story.ageDays === null ? '' : ` (${story.ageDays} days ago)`;
528
+ const age = story.ageDays === null ? '' : ` (${countLabel(story.ageDays, 'day')} ago)`;
476
529
  console.log(` started ${when}, last touched ${last}${age}, by ${who}.`);
477
530
  console.log('');
478
531
  console.log(' what it tried to do:');
@@ -488,7 +541,7 @@ function printStory(story) {
488
541
  console.log('');
489
542
  }
490
543
  if (story.uniqueChanges === 0) {
491
- console.log(` bottom line: all ${story.changes.length} changes made it into ${story.base}`);
544
+ console.log(` bottom line: all ${countLabel(story.changes.length, 'change')} made it into ${story.base}`);
492
545
  console.log(' some other way. nothing here would be lost by clearing it.');
493
546
  } else if (story.landedElsewhere > 0) {
494
547
  console.log(` bottom line: ${story.landedElsewhere} of ${story.changes.length} changes made it into ${story.base} another`);
@@ -522,8 +575,15 @@ function printBoard(board) {
522
575
  console.log(` ${label.padEnd(11)} ${String(b.ageDays).padStart(3)}d ${changes} ${b.name}`);
523
576
  }
524
577
  for (const w of board.worktrees) {
525
- const edits = w.dirty === 1 ? '1 unsaved edit' : `${w.dirty} unsaved edits`;
526
- console.log(` side copy ${w.path} (${edits})`);
578
+ const bits = [];
579
+ if (w.stranded) bits.push(w.unlandedCommits === 1 ? '1 commit never landed' : `${w.unlandedCommits} commits never landed`);
580
+ if (w.dirty > 0) bits.push(w.dirty === 1 ? '1 unsaved edit' : `${w.dirty} unsaved edits`);
581
+ const age = typeof w.ageDays === 'number' ? `, ${countLabel(w.ageDays, 'day')} old` : '';
582
+ const detail = bits.length ? ` (${bits.join(', ')}${age})` : '';
583
+ console.log(` side copy ${w.path}${detail}`);
584
+ if (w.stranded) {
585
+ for (const s of w.subjects) console.log(` - ${s}`);
586
+ }
527
587
  }
528
588
  console.log('');
529
589
  const s = board.summary;
@@ -586,6 +646,8 @@ function showHelp() {
586
646
  console.log(' atris land --reap back up, then clear anything overdue');
587
647
  console.log(' or already landed (here and on github)');
588
648
  console.log(' atris land --reap --dry-run preview what would be cleared');
649
+ console.log(' atris land --reap --force also clear side copies holding');
650
+ console.log(' commits that never landed (backs up first)');
589
651
  console.log(' atris land --ttl <days> change the 7-day overdue line');
590
652
  console.log(' atris land --json machine-readable output');
591
653
  console.log('');
@@ -610,7 +672,7 @@ function landCommand(args = []) {
610
672
  const json = args.includes('--json');
611
673
 
612
674
  if (args.includes('--reap')) {
613
- const receipt = reap(root, { ttlDays, base, dryRun: args.includes('--dry-run'), remote: !args.includes('--no-remote') });
675
+ const receipt = reap(root, { ttlDays, base, dryRun: args.includes('--dry-run'), remote: !args.includes('--no-remote'), force: args.includes('--force') });
614
676
  if (json) console.log(JSON.stringify(receipt, null, 2));
615
677
  else printReceipt(receipt);
616
678
  return 0;
@@ -637,6 +699,7 @@ function landCommand(args = []) {
637
699
 
638
700
  module.exports = {
639
701
  collectBoard,
702
+ countLabel,
640
703
  landCommand,
641
704
  landSummary,
642
705
  reap,
@@ -632,7 +632,5 @@ function launchpadCommand(args = []) {
632
632
  }
633
633
 
634
634
  module.exports = {
635
- collectLaunchpad,
636
635
  launchpadCommand,
637
- renderLaunchpad,
638
636
  };
@@ -213,7 +213,23 @@ function mineLessons(args) {
213
213
  }
214
214
  }
215
215
 
216
+ function printLessonUsage() {
217
+ console.log('');
218
+ console.log(' Usage: atris lesson add <slug> <pass|fail> "<text>"');
219
+ console.log(' atris lesson mine [--json] [--dry-run]');
220
+ console.log(' atris lesson sweep [--json] [--dry-run]');
221
+ console.log(' atris lesson resolve [--json] [--dry-run]');
222
+ console.log('');
223
+ }
224
+
216
225
  function lessonAtris(subcommand, ...args) {
226
+ // -h/--help is an explicit help request: print usage and exit 0, before any
227
+ // filesystem check, so `atris lesson --help` works everywhere.
228
+ if (subcommand === '-h' || subcommand === '--help') {
229
+ printLessonUsage();
230
+ process.exit(0);
231
+ }
232
+
217
233
  const atrisDir = path.join(process.cwd(), 'atris');
218
234
  if (!fs.existsSync(atrisDir)) {
219
235
  console.error(' ✗ atris/ folder not found. Run "atris init" first.');
@@ -236,12 +252,7 @@ function lessonAtris(subcommand, ...args) {
236
252
  }
237
253
 
238
254
  if (subcommand !== 'add') {
239
- console.log('');
240
- console.log(' Usage: atris lesson add <slug> <pass|fail> "<text>"');
241
- console.log(' atris lesson mine [--json] [--dry-run]');
242
- console.log(' atris lesson sweep [--json] [--dry-run]');
243
- console.log(' atris lesson resolve [--json] [--dry-run]');
244
- console.log('');
255
+ printLessonUsage();
245
256
  process.exit(subcommand ? 1 : 0);
246
257
  }
247
258
 
@@ -269,4 +280,3 @@ function lessonAtris(subcommand, ...args) {
269
280
 
270
281
  module.exports = lessonAtris;
271
282
  module.exports.autoResolveLessons = autoResolveLessons;
272
- module.exports.tagLessonResolvedInMd = tagLessonResolvedInMd;
@@ -396,11 +396,9 @@ function loopFront(argv = []) {
396
396
  module.exports = {
397
397
  routeLoop,
398
398
  renderLoopHome,
399
- printLoopHome,
400
399
  startLocalOptions,
401
400
  localRunSummary,
402
401
  loopStatusJson,
403
- loopSeedMove,
404
402
  createNextLoopTask,
405
403
  loopReport,
406
404
  renderLoopReport,
package/commands/loops.js CHANGED
@@ -382,6 +382,12 @@ function showLoopsHelp() {
382
382
  }
383
383
 
384
384
  function loopsCommand(subcommand, ...args) {
385
+ // `loops <sub> --help` is a help request, not a run — tick/start/stop/init
386
+ // mutate loop state, so never execute a subcommand just to show usage.
387
+ if (subcommand === '--help' || subcommand === '-h' || subcommand === 'help'
388
+ || args.includes('--help') || args.includes('-h')) {
389
+ return showLoopsHelp();
390
+ }
385
391
  switch (subcommand) {
386
392
  case undefined:
387
393
  case 'audit':
@@ -408,9 +414,4 @@ function loopsCommand(subcommand, ...args) {
408
414
 
409
415
  module.exports = {
410
416
  loopsCommand,
411
- showBoard,
412
- initLoops,
413
- auditLoops,
414
- tickLoops,
415
- structuralProblems,
416
417
  };
package/commands/meet.js CHANGED
@@ -215,6 +215,4 @@ async function meetCommand(args = [], root = process.cwd()) {
215
215
  module.exports = {
216
216
  meetCommand,
217
217
  slugify,
218
- buildBookingProfile,
219
- renderProfile,
220
218
  };
@@ -8,6 +8,24 @@ const { apiRequestJson } = require('../utils/api');
8
8
  const { runAliveTick } = require('../lib/member-alive');
9
9
  const { defaultObjectiveRunner } = require('../lib/default-runner');
10
10
 
11
+ function findWorkspaceBusinessId(startDir = process.cwd()) {
12
+ let dir = path.resolve(startDir);
13
+ while (true) {
14
+ const file = path.join(dir, '.atris', 'business.json');
15
+ if (fs.existsSync(file)) {
16
+ try {
17
+ return JSON.parse(fs.readFileSync(file, 'utf8')).business_id || null;
18
+ } catch (error) {
19
+ console.warn(`Warning: could not parse ${file}: ${error.message}`);
20
+ return null;
21
+ }
22
+ }
23
+ const parent = path.dirname(dir);
24
+ if (parent === dir) return null;
25
+ dir = parent;
26
+ }
27
+ }
28
+
11
29
  function todayLogName() {
12
30
  const now = new Date();
13
31
  return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}.md`;
@@ -2394,6 +2412,27 @@ function experimentIsClosed(experiment) {
2394
2412
  return ['accepted', 'discarded', 'superseded'].includes(String(experiment?.status || '').toLowerCase());
2395
2413
  }
2396
2414
 
2415
+ // A proposal that cannot be reviewed must not gate the member.
2416
+ //
2417
+ // wake returns decision 'wait' for any open experiment, on the theory that the
2418
+ // ball is with a human reviewer. That is right for a real proposal. It is a
2419
+ // permanent deadlock for a FALLBACK proposal: those carry verifier=null and
2420
+ // generation.mode='fallback' (template text emitted when LLM authoring did not
2421
+ // happen), and the review executor correctly refuses to accept anything
2422
+ // unverifiable. So the member waits on a gate nothing can pass -- observed
2423
+ // 2026-07-26 in project-obelisk with validator blocked 52 days, signal-scout 48,
2424
+ // and land-keeper 22, three of six rostered members doing no work at all.
2425
+ //
2426
+ // Deliberately narrow: a proposal WITH a verifier still blocks (a human genuinely
2427
+ // owes it a review), and 'running'/'blocked' still block regardless of shape.
2428
+ function experimentIsUnreviewable(experiment) {
2429
+ if (String(experiment?.status || '').toLowerCase() !== 'proposed') return false;
2430
+ const verifier = experiment?.verifier;
2431
+ const hasVerifier = Array.isArray(verifier) ? verifier.length > 0 : Boolean(String(verifier || '').trim());
2432
+ if (hasVerifier) return false;
2433
+ return String(experiment?.generation?.mode || '').toLowerCase() === 'fallback';
2434
+ }
2435
+
2397
2436
  function supersedeOtherOpenExperiments(state, activeGoal, proof) {
2398
2437
  const superseded = [];
2399
2438
  for (const goal of state.goals || []) {
@@ -4314,7 +4353,11 @@ async function memberPush(name) {
4314
4353
  console.log(`Pushing member "${name}" to cloud (creating new agent)...`);
4315
4354
  }
4316
4355
 
4317
- const result = await apiRequestJson('/agent/import-member', {
4356
+ const businessId = findWorkspaceBusinessId();
4357
+ const endpoint = businessId
4358
+ ? `/agent/import-member?business_id=${encodeURIComponent(businessId)}`
4359
+ : '/agent/import-member';
4360
+ const result = await apiRequestJson(endpoint, {
4318
4361
  method: 'POST',
4319
4362
  headers: { 'Content-Type': 'text/markdown' },
4320
4363
  body: content,
@@ -4340,6 +4383,7 @@ async function memberPush(name) {
4340
4383
 
4341
4384
  const action = existingAgentId ? 'Updated' : 'Created';
4342
4385
  console.log(`${action} successfully. Agent ID: ${agentId}`);
4386
+ if (businessId) console.log(`Bound to business ${businessId}`);
4343
4387
  }
4344
4388
 
4345
4389
  // --- PULL subcommand ---
@@ -7459,7 +7503,9 @@ function wakeDecision(name, paths, { force = false, runtimeKind = memberRuntimeK
7459
7503
  };
7460
7504
  }
7461
7505
 
7462
- if (current) {
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)) {
7463
7509
  return {
7464
7510
  decision: 'wait',
7465
7511
  reason: `open_experiment_${current.status}`,
@@ -7627,6 +7673,18 @@ async function runMemberWake(name, { execute = false, confirmed = false, force =
7627
7673
  evidence: planned.evidence,
7628
7674
  mission: planned.mission,
7629
7675
  });
7676
+ // An unreviewable fallback proposal no longer blocks wake (it deadlocked
7677
+ // members for weeks), but it must not accumulate either: one open
7678
+ // proposal per goal stays the invariant, so the stale fallback is
7679
+ // superseded by the one being proposed now.
7680
+ for (const prior of goal.experiments) {
7681
+ if (experimentIsUnreviewable(prior)) {
7682
+ prior.status = 'superseded';
7683
+ prior.superseded_at = now;
7684
+ prior.superseded_by = experiment.id;
7685
+ prior.superseded_reason = 'unreviewable_fallback_replaced_by_next_proposal';
7686
+ }
7687
+ }
7630
7688
  goal.experiments.push(experiment);
7631
7689
  goal.history = Array.isArray(goal.history) ? goal.history : [];
7632
7690
  goal.history.push({ at: now, event: 'wake_tick_proposed_experiment', experiment_id: experiment.id });
@@ -9069,4 +9127,11 @@ async function memberCommand(subcommand, ...args) {
9069
9127
  }
9070
9128
  }
9071
9129
 
9072
- module.exports = { memberCommand, findAllMembers, parseFrontmatter, wakeBootLines, buildMemberRunStartArgs };
9130
+ module.exports = {
9131
+ memberCommand,
9132
+ findAllMembers,
9133
+ findWorkspaceBusinessId,
9134
+ parseFrontmatter,
9135
+ wakeBootLines,
9136
+ buildMemberRunStartArgs,
9137
+ };