atris 3.35.0 → 3.37.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 (133) hide show
  1. package/AGENTS.md +37 -0
  2. package/README.md +5 -3
  3. package/atris/GETTING_STARTED.md +1 -1
  4. package/atris/atris.md +3 -0
  5. package/atris/policies/day-loop-voice.md +102 -0
  6. package/atris/policies/outbound-artifact-gate.md +2 -0
  7. package/atris/skills/design/SKILL.md +56 -32
  8. package/atris/skills/endgame/SKILL.md +12 -6
  9. package/atris/skills/engines/SKILL.md +22 -4
  10. package/atris/skills/fable-method/SKILL.md +66 -0
  11. package/atris/skills/improve/SKILL.md +65 -45
  12. package/atris/skills/youtube/SKILL.md +10 -1
  13. package/atris.md +2 -0
  14. package/ax +147 -19
  15. package/bin/atris.js +576 -267
  16. package/commands/activate.js +194 -88
  17. package/commands/agents.js +166 -0
  18. package/commands/autoland.js +459 -107
  19. package/commands/autopilot-front.js +20 -2
  20. package/commands/autopilot.js +118 -2
  21. package/commands/avail.js +407 -0
  22. package/commands/bench.js +188 -0
  23. package/commands/brain.js +3 -0
  24. package/commands/brief.js +651 -0
  25. package/commands/business-sync.js +192 -6
  26. package/commands/clean.js +50 -24
  27. package/commands/close.js +1083 -0
  28. package/commands/cloud.js +245 -0
  29. package/commands/compile.js +292 -1
  30. package/commands/computer.js +150 -3
  31. package/commands/dream.js +365 -0
  32. package/commands/drill.js +371 -0
  33. package/commands/engine.js +993 -32
  34. package/commands/experiments.js +28 -0
  35. package/commands/feedback.js +34 -12
  36. package/commands/fleet-report.js +206 -0
  37. package/commands/gm.js +23 -0
  38. package/commands/goal.js +247 -0
  39. package/commands/improve.js +642 -26
  40. package/commands/init.js +72 -44
  41. package/commands/interview.js +67 -1
  42. package/commands/land.js +152 -52
  43. package/commands/lifecycle.js +39 -3
  44. package/commands/log.js +84 -1
  45. package/commands/loops.js +220 -16
  46. package/commands/meet.js +220 -0
  47. package/commands/member.js +511 -34
  48. package/commands/mission.js +3144 -338
  49. package/commands/next.js +137 -0
  50. package/commands/now.js +220 -25
  51. package/commands/one-lap.js +776 -0
  52. package/commands/orb.js +314 -0
  53. package/commands/pack-craft.js +179 -0
  54. package/commands/pack.js +823 -0
  55. package/commands/play.js +3 -2
  56. package/commands/probe.js +30 -3
  57. package/commands/pulse.js +241 -46
  58. package/commands/push.js +260 -82
  59. package/commands/rainmaker.js +49 -0
  60. package/commands/report.js +415 -0
  61. package/commands/scout.js +147 -0
  62. package/commands/search.js +363 -0
  63. package/commands/skill.js +47 -3
  64. package/commands/slop.js +50 -2
  65. package/commands/soul.js +1 -1
  66. package/commands/stream.js +861 -0
  67. package/commands/study.js +693 -0
  68. package/commands/sync.js +67 -54
  69. package/commands/task.js +1346 -117
  70. package/commands/team.js +73 -0
  71. package/commands/verify.js +96 -0
  72. package/commands/watch.js +303 -0
  73. package/commands/wish.js +500 -0
  74. package/commands/workflow.js +11 -5
  75. package/commands/worktree.js +234 -13
  76. package/commands/xp.js +29 -11
  77. package/lib/auto-accept-certified.js +331 -34
  78. package/lib/autoland.js +319 -54
  79. package/lib/ax-auto-lane.js +96 -0
  80. package/lib/bench/context.js +147 -0
  81. package/lib/bench/engines.js +141 -0
  82. package/lib/bench/report.js +140 -0
  83. package/lib/bench/runner.js +512 -0
  84. package/lib/brief-ledger.js +350 -0
  85. package/lib/cloud-mission.js +259 -0
  86. package/lib/codex-flight.js +154 -0
  87. package/lib/default-runner.js +45 -0
  88. package/lib/default-verifier.js +70 -0
  89. package/lib/engine-registry.js +232 -0
  90. package/lib/experiments/daily.js +640 -0
  91. package/lib/fleet.js +2219 -67
  92. package/lib/improve-vitals-html.js +171 -0
  93. package/lib/known-commands.js +58 -0
  94. package/lib/loop-doctor.js +416 -0
  95. package/lib/member-switches.js +144 -0
  96. package/lib/mission-room.js +1 -0
  97. package/lib/mission-root.js +52 -0
  98. package/lib/next-moves.js +327 -10
  99. package/lib/one-lap-validator.js +60 -0
  100. package/lib/orb-context.js +477 -0
  101. package/lib/orb-scorecard.js +224 -0
  102. package/lib/policy-lessons.js +52 -1
  103. package/lib/pulse.js +277 -3
  104. package/lib/receipt-block.js +168 -0
  105. package/lib/receipt-evidence.js +65 -4
  106. package/lib/router-brain.js +352 -0
  107. package/lib/runner-command.js +10 -0
  108. package/lib/self-drive.js +258 -0
  109. package/lib/short-name.js +103 -0
  110. package/lib/spawn-env.js +18 -0
  111. package/lib/state-detection.js +56 -1
  112. package/lib/sync-status.js +59 -0
  113. package/lib/task-db.js +108 -29
  114. package/lib/task-proof.js +23 -1
  115. package/lib/team-presence.js +260 -0
  116. package/lib/tool-result-encode.js +7 -0
  117. package/lib/trust-tiers.js +90 -0
  118. package/lib/usage.js +107 -0
  119. package/lib/voice-gate.js +163 -0
  120. package/lib/wish-audit.js +1368 -0
  121. package/lib/wish-delegate.js +1840 -0
  122. package/lib/wish-design.js +110 -0
  123. package/lib/wish-stats.js +183 -0
  124. package/lib/wish-store.js +354 -0
  125. package/lib/zip.js +221 -0
  126. package/package.json +3 -1
  127. package/templates/loops/atris/loops/LOOPS.md +55 -0
  128. package/templates/loops/atris/loops/TICK.md +24 -0
  129. package/templates/loops/atris/loops/feedback.md +22 -0
  130. package/templates/loops/atris/loops/quality.md +22 -0
  131. package/templates/loops/atris/wiki/systems/loops.md +41 -0
  132. package/utils/api.js +5 -1
  133. package/utils/auth.js +57 -21
package/commands/land.js CHANGED
@@ -5,11 +5,12 @@ const { spawnSync } = require('child_process');
5
5
  const { listWorktrees, statusCounts } = require('./worktree');
6
6
 
7
7
  const DEFAULT_TTL_DAYS = 7;
8
+ const DEFAULT_STALE_HOURS = 48;
8
9
  const WORKTREE_REAP_GRACE_MS = 60 * 60 * 1000;
9
10
  const PROTECTED_BRANCHES = new Set(['main', 'master']);
10
11
 
11
- function runGit(args, { cwd = process.cwd(), check = true } = {}) {
12
- const result = spawnSync('git', args, { cwd, encoding: 'utf8' });
12
+ function runGit(args, { cwd = process.cwd(), check = true, maxBuffer } = {}) {
13
+ const result = spawnSync('git', args, { cwd, encoding: 'utf8', ...(maxBuffer ? { maxBuffer } : {}) });
13
14
  if (check && result.status !== 0) {
14
15
  throw new Error(`git ${args.join(' ')} failed: ${(result.stderr || result.stdout || '').trim()}`);
15
16
  }
@@ -28,6 +29,8 @@ function refExists(root, ref) {
28
29
 
29
30
  function baseBranch(root, override = '') {
30
31
  if (override && refExists(root, override)) return override;
32
+ if (refExists(root, 'origin/master')) return 'origin/master';
33
+ if (refExists(root, 'origin/main')) return 'origin/main';
31
34
  if (refExists(root, 'master')) return 'master';
32
35
  if (refExists(root, 'main')) return 'main';
33
36
  return '';
@@ -37,6 +40,10 @@ function ageDays(unixSeconds, now = Date.now()) {
37
40
  return Math.floor((now / 1000 - Number(unixSeconds)) / 86400);
38
41
  }
39
42
 
43
+ function ageHours(unixSeconds, now = Date.now()) {
44
+ return Math.floor((now / 1000 - Number(unixSeconds)) / 3600);
45
+ }
46
+
40
47
  function worktreeMtimeMs(worktreePath) {
41
48
  try {
42
49
  return fs.statSync(worktreePath).mtimeMs;
@@ -50,18 +57,29 @@ function worktreeWithinReapGrace(worktreePath, now = Date.now()) {
50
57
  return typeof mtime === 'number' && now - mtime < WORKTREE_REAP_GRACE_MS;
51
58
  }
52
59
 
53
- function listBranches(root) {
54
- const result = runGit(
55
- ['for-each-ref', 'refs/heads', '--format=%(refname:short)%09%(committerdate:unix)'],
56
- { cwd: root, check: false }
57
- );
58
- if (result.status !== 0) return [];
60
+ function listBranches(root, base = '') {
61
+ // With a base, ask git for ahead counts in the same single spawn
62
+ // (%(ahead-behind:) needs git >= 2.41; on failure we retry without it and
63
+ // collectBoard falls back to per-branch aheadCount).
64
+ const format = base
65
+ ? `%(refname:short)%09%(committerdate:unix)%09%(ahead-behind:${base})`
66
+ : '%(refname:short)%09%(committerdate:unix)';
67
+ const result = runGit(['for-each-ref', 'refs/heads', `--format=${format}`], { cwd: root, check: false });
68
+ if (result.status !== 0) {
69
+ if (base) return listBranches(root);
70
+ return [];
71
+ }
59
72
  return result.stdout
60
73
  .split(/\r?\n/)
61
74
  .filter(Boolean)
62
75
  .map((line) => {
63
- const [name, date] = line.split('\t');
64
- return { name, lastCommitUnix: Number(date) };
76
+ const [name, date, aheadBehind] = line.split('\t');
77
+ const entry = { name, lastCommitUnix: Number(date) };
78
+ if (aheadBehind !== undefined) {
79
+ const ahead = Number(aheadBehind.split(' ')[0]);
80
+ if (Number.isFinite(ahead)) entry.ahead = ahead;
81
+ }
82
+ return entry;
65
83
  });
66
84
  }
67
85
 
@@ -71,30 +89,59 @@ function aheadCount(root, base, ref) {
71
89
  return Number(result.stdout.trim()) || 0;
72
90
  }
73
91
 
92
+ function cherryStats(root, base, ref) {
93
+ const result = runGit(['cherry', base, ref], { cwd: root, check: false });
94
+ if (result.status !== 0) return { landedElsewhere: 0, unique: 0 };
95
+ let landedElsewhere = 0;
96
+ let unique = 0;
97
+ for (const line of result.stdout.split(/\r?\n/).filter(Boolean)) {
98
+ if (line.startsWith('- ')) landedElsewhere += 1;
99
+ else if (line.startsWith('+ ')) unique += 1;
100
+ }
101
+ return { landedElsewhere, unique };
102
+ }
103
+
74
104
  // Board: every branch and worktree with unlanded state, classified.
75
105
  // landed — no commits ahead of base; the branch pointer is residue
76
106
  // active — has unlanded commits, younger than TTL
77
107
  // due — has unlanded commits, older than TTL; --reap collects it
78
- function collectBoard(root, { ttlDays = DEFAULT_TTL_DAYS, base: baseOverride = '' } = {}) {
108
+ function collectBoard(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_STALE_HOURS, base: baseOverride = '', now = Date.now(), light = false } = {}) {
79
109
  const base = baseBranch(root, baseOverride);
80
110
  if (!base) throw new Error('no master/main branch found');
81
111
 
82
112
  const branches = [];
83
- for (const branch of listBranches(root)) {
113
+ const lastCommitMsByBranch = new Map();
114
+ for (const branch of listBranches(root, base)) {
84
115
  if (PROTECTED_BRANCHES.has(branch.name)) continue;
85
- const ahead = aheadCount(root, base, branch.name);
86
- const age = ageDays(branch.lastCommitUnix);
116
+ const lastCommitMs = Number(branch.lastCommitUnix) * 1000;
117
+ if (Number.isFinite(lastCommitMs)) lastCommitMsByBranch.set(branch.name, lastCommitMs);
118
+ const ahead = branch.ahead !== undefined ? branch.ahead : aheadCount(root, base, branch.name);
119
+ const age = ageDays(branch.lastCommitUnix, now);
120
+ const hours = ageHours(branch.lastCommitUnix, now);
121
+ const cherry = ahead > 0 ? cherryStats(root, base, branch.name) : { landedElsewhere: 0, unique: 0 };
87
122
  let state = 'active';
88
- if (ahead === 0) state = 'landed';
123
+ if (ahead === 0 || (cherry.landedElsewhere > 0 && cherry.unique === 0)) state = 'landed';
89
124
  else if (age > ttlDays) state = 'due';
90
- branches.push({ name: branch.name, ahead, ageDays: age, state });
125
+ branches.push({
126
+ name: branch.name,
127
+ ahead,
128
+ ageDays: age,
129
+ ageHours: hours,
130
+ activityHours: hours,
131
+ landedElsewhere: cherry.landedElsewhere,
132
+ uniqueChanges: cherry.unique,
133
+ stale: false,
134
+ state,
135
+ });
91
136
  }
92
137
 
93
138
  const worktrees = [];
94
139
  const all = listWorktrees(root);
95
140
  for (const wt of all.slice(1)) {
96
141
  const branch = (wt.branch || '').replace(/^refs\/heads\//, '');
97
- const counts = statusCounts(wt.path) || { staged: 0, unstaged: 0, untracked: 0 };
142
+ // light mode skips the full `git status` per worktree — the banner summary
143
+ // never reads dirty counts, only worktree mtimes for staleness.
144
+ const counts = (light ? null : statusCounts(wt.path)) || { staged: 0, unstaged: 0, untracked: 0 };
98
145
  const dirty = counts.staged + counts.unstaged + counts.untracked;
99
146
  const entry = branches.find((b) => b.name === branch) || null;
100
147
  worktrees.push({
@@ -102,16 +149,35 @@ function collectBoard(root, { ttlDays = DEFAULT_TTL_DAYS, base: baseOverride = '
102
149
  branch,
103
150
  dirty,
104
151
  ageDays: entry ? entry.ageDays : null,
152
+ ageHours: entry ? entry.ageHours : null,
105
153
  state: entry ? entry.state : 'detached',
106
154
  });
107
155
  }
108
156
 
157
+ const activityByBranch = new Map(branches.map((b) => [b.name, lastCommitMsByBranch.get(b.name)]));
158
+ for (const wt of worktrees) {
159
+ if (!wt.branch || !activityByBranch.has(wt.branch)) continue;
160
+ const mtime = worktreeMtimeMs(wt.path);
161
+ if (typeof mtime === 'number' && mtime > activityByBranch.get(wt.branch)) {
162
+ activityByBranch.set(wt.branch, mtime);
163
+ }
164
+ }
165
+ for (const branch of branches) {
166
+ const lastActivity = activityByBranch.get(branch.name);
167
+ if (Number.isFinite(lastActivity)) {
168
+ branch.activityHours = Math.floor((now - lastActivity) / 3600000);
169
+ }
170
+ branch.stale = branch.state === 'active' && branch.activityHours >= staleHours;
171
+ }
172
+
109
173
  const due = branches.filter((b) => b.state === 'due');
110
174
  const landed = branches.filter((b) => b.state === 'landed');
111
175
  const active = branches.filter((b) => b.state === 'active');
176
+ const stale = active.filter((b) => b.stale);
112
177
  return {
113
178
  base,
114
179
  ttlDays,
180
+ staleHours,
115
181
  branches,
116
182
  worktrees,
117
183
  summary: {
@@ -119,23 +185,25 @@ function collectBoard(root, { ttlDays = DEFAULT_TTL_DAYS, base: baseOverride = '
119
185
  active: active.length,
120
186
  due: due.length,
121
187
  landed: landed.length,
188
+ stale: stale.length,
122
189
  worktrees: worktrees.length,
123
190
  },
124
191
  };
125
192
  }
126
193
 
127
- // Fast counts for the boot banner: no per-branch ahead checks.
194
+ // Counts for the boot banner and digest. This intentionally pays the same
195
+ // classification cost as the landing board so "in the air" never includes
196
+ // merged branch residue.
128
197
  function landSummary(cwd = process.cwd(), ttlDays = DEFAULT_TTL_DAYS) {
129
198
  const root = repoRoot(cwd);
130
199
  if (!root) return null;
131
- let branches = 0;
132
- let due = 0;
133
- for (const branch of listBranches(root)) {
134
- if (PROTECTED_BRANCHES.has(branch.name)) continue;
135
- branches += 1;
136
- if (ageDays(branch.lastCommitUnix) > ttlDays) due += 1;
137
- }
138
- return { branches, due, ttlDays };
200
+ const board = collectBoard(root, { ttlDays, light: true });
201
+ return {
202
+ branches: board.summary.active + board.summary.due,
203
+ due: board.summary.due,
204
+ stale: board.summary.stale,
205
+ ttlDays,
206
+ };
139
207
  }
140
208
 
141
209
  function salvageDir(root) {
@@ -165,14 +233,18 @@ function remoteHeads(root) {
165
233
  // piece could not be saved — the caller then keeps the worktree.
166
234
  function salvageWorktree(w, dir, receipt) {
167
235
  try {
168
- const diff = runGit(['diff', 'HEAD'], { cwd: w.path, check: false });
169
- if (diff.status !== 0) return false;
170
- if (diff.stdout.trim()) {
171
- const patchPath = path.join(dir, `${path.basename(w.path)}.dirty.patch`);
172
- fs.writeFileSync(patchPath, diff.stdout);
173
- receipt.patches.push(patchPath);
236
+ // git writes the patch file itself: routing the diff through spawnSync
237
+ // stdout hits Node's 1MB default maxBuffer, so any worktree more than
238
+ // ~1MB dirty read as "salvage failed" and was kept on every reap pass.
239
+ const patchPath = path.join(dir, `${path.basename(w.path)}.dirty.patch`);
240
+ const diff = runGit(['diff', 'HEAD', `--output=${patchPath}`], { cwd: w.path, check: false });
241
+ if (diff.status !== 0) {
242
+ fs.rmSync(patchPath, { force: true });
243
+ return false;
174
244
  }
175
- const untracked = runGit(['ls-files', '--others', '--exclude-standard', '-z'], { cwd: w.path, check: false });
245
+ if (fs.statSync(patchPath).size > 0) receipt.patches.push(patchPath);
246
+ else fs.rmSync(patchPath, { force: true });
247
+ const untracked = runGit(['ls-files', '--others', '--exclude-standard', '-z'], { cwd: w.path, check: false, maxBuffer: 64 * 1024 * 1024 });
176
248
  if (untracked.status !== 0) return false;
177
249
  const files = untracked.stdout.split('\0').filter(Boolean);
178
250
  if (files.length > 0) {
@@ -197,8 +269,8 @@ function salvageWorktree(w, dir, receipt) {
197
269
  // includeDetached: detached-HEAD worktrees have no branch, so the salvage
198
270
  // bundle cannot cover their commits — unattended reaps (autoland) pass false
199
271
  // and leave them for a human reap.
200
- function reap(root, { ttlDays = DEFAULT_TTL_DAYS, base: baseOverride = '', dryRun = false, remote = true, includeDetached = true } = {}) {
201
- const board = collectBoard(root, { ttlDays, base: baseOverride });
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() } = {}) {
273
+ const board = collectBoard(root, { ttlDays, staleHours, base: baseOverride, now });
202
274
  const targets = board.branches.filter((b) => b.state === 'landed' || b.state === 'due');
203
275
  const targetNames = new Set(targets.map((b) => b.name));
204
276
  const candidateWorktrees = board.worktrees.filter(
@@ -206,13 +278,18 @@ function reap(root, { ttlDays = DEFAULT_TTL_DAYS, base: baseOverride = '', dryRu
206
278
  );
207
279
  const protectedWorktrees = [];
208
280
  const worktreeTargets = [];
281
+ const current = path.resolve(root);
209
282
  for (const w of candidateWorktrees) {
210
283
  // Only the fresh-worktree grace protects here. Dirty worktrees are NOT
211
284
  // skipped: reap's salvage (bundle + patches + untracked copies below)
212
285
  // exists precisely so dirty residue can be cleared loss-free. Blanket
213
286
  // dirty protection lives in the janitor's cleanupWorktrees, which has
214
287
  // no salvage machinery.
215
- if (worktreeWithinReapGrace(w.path)) {
288
+ if (protectCurrent && path.resolve(w.path) === current) {
289
+ protectedWorktrees.push({ ...w, reason: 'current_checkout' });
290
+ continue;
291
+ }
292
+ if (worktreeWithinReapGrace(w.path, now)) {
216
293
  protectedWorktrees.push({ ...w, reason: 'fresh_worktree_grace' });
217
294
  continue;
218
295
  }
@@ -276,8 +353,10 @@ function reap(root, { ttlDays = DEFAULT_TTL_DAYS, base: baseOverride = '', dryRu
276
353
  // copies bank everything force-remove would destroy. The fresh-worktree
277
354
  // grace was already applied when candidates were selected.
278
355
  if (w.dirty > 0 && !salvageWorktree(w, dir, receipt)) {
279
- // could not fully back up what force-remove would destroy — keep it
280
- receipt.keptWorktrees.push(w.path);
356
+ // could not fully back up what force-remove would destroy — keep it,
357
+ // and say why: a bare path in the receipt reads as an unexplained
358
+ // no-op (the exact silence BCK-1232 was about).
359
+ receipt.keptWorktrees.push(`${w.path} (salvage incomplete: patch/copy failed, kept to avoid losing work)`);
281
360
  if (w.branch) targetNames.delete(w.branch);
282
361
  continue;
283
362
  }
@@ -304,7 +383,15 @@ function reap(root, { ttlDays = DEFAULT_TTL_DAYS, base: baseOverride = '', dryRu
304
383
  continue;
305
384
  }
306
385
  const deleted = runGit(['branch', '-D', name], { cwd: root, check: false });
307
- if (deleted.status === 0) receipt.deletedBranches.push(name);
386
+ if (deleted.status === 0) {
387
+ receipt.deletedBranches.push(name);
388
+ } else {
389
+ // Same silence class as the worktree-remove path: a branch checked
390
+ // out in a side copy the board never saw makes -D refuse, and an
391
+ // unreported refusal reads as "cleanup done" with nothing cleared.
392
+ const reason = String(deleted.stderr || deleted.stdout || 'branch delete failed').trim().slice(0, 160);
393
+ receipt.keptWorktrees.push(`branch ${name} (${reason})`);
394
+ }
308
395
  }
309
396
 
310
397
  if (remote && receipt.deletedBranches.length > 0) {
@@ -405,7 +492,7 @@ function printStory(story) {
405
492
  console.log(' some other way. nothing here would be lost by clearing it.');
406
493
  } else if (story.landedElsewhere > 0) {
407
494
  console.log(` bottom line: ${story.landedElsewhere} of ${story.changes.length} changes made it into ${story.base} another`);
408
- console.log(` way. ${story.uniqueChanges} exist only here (marked "never made it") — they may have`);
495
+ console.log(` way. ${story.uniqueChanges} exist only here (marked "never made it") - they may have`);
409
496
  console.log(' been redone differently, or they may be real lost work. clearing backs');
410
497
  console.log(' them up first either way.');
411
498
  } else {
@@ -419,19 +506,20 @@ function printStory(story) {
419
506
 
420
507
  function printBoard(board) {
421
508
  console.log('');
422
- console.log('the landing — what is actually done vs still in the air');
509
+ console.log('the landing: what is actually done vs still in the air');
423
510
  console.log('');
424
511
  if (board.branches.length === 0 && board.worktrees.length === 0) {
425
512
  console.log(' everything has landed. nothing in the air, nothing stuck.');
426
513
  console.log('');
427
514
  return;
428
515
  }
429
- const order = { due: 0, detached: 0, landed: 1, active: 2 };
516
+ const order = { due: 0, detached: 0, active: 1, landed: 2 };
430
517
  const rows = [...board.branches].sort((a, b) => (order[a.state] ?? 3) - (order[b.state] ?? 3) || b.ageDays - a.ageDays);
431
518
  const labels = { due: 'overdue', landed: 'landed', active: 'in the air' };
432
519
  for (const b of rows) {
433
520
  const changes = b.ahead === 1 ? '1 change ' : `${b.ahead} changes`;
434
- console.log(` ${(labels[b.state] || b.state).padEnd(11)} ${String(b.ageDays).padStart(3)}d ${changes} ${b.name}`);
521
+ const label = b.stale ? 'stale' : (labels[b.state] || b.state);
522
+ console.log(` ${label.padEnd(11)} ${String(b.ageDays).padStart(3)}d ${changes} ${b.name}`);
435
523
  }
436
524
  for (const w of board.worktrees) {
437
525
  const edits = w.dirty === 1 ? '1 unsaved edit' : `${w.dirty} unsaved edits`;
@@ -439,10 +527,14 @@ function printBoard(board) {
439
527
  }
440
528
  console.log('');
441
529
  const s = board.summary;
442
- console.log(` ${s.unlanded} pieces of work in the air, ${s.due} overdue, ${s.landed} landed and safe to clear.`);
530
+ const stillInAir = s.active + s.due;
531
+ console.log(` ${s.unlanded} ${s.unlanded === 1 ? 'piece' : 'pieces'} tracked: ${stillInAir} still in the air, ${s.stale} stale, ${s.due} overdue, ${s.landed} landed and safe to clear.`);
443
532
  console.log(' work counts as done only when it lands in master.');
444
533
  if (s.due + s.landed > 0) {
445
- console.log(` back up + clear the ${s.due + s.landed} overdue/landed: atris land --reap`);
534
+ const clearable = s.due > 0 && s.landed > 0
535
+ ? `${s.due} overdue and ${s.landed} landed`
536
+ : s.due > 0 ? `${s.due} overdue` : `${s.landed} landed`;
537
+ console.log(` back up + clear ${clearable}: atris land --reap`);
446
538
  }
447
539
  console.log('');
448
540
  }
@@ -450,16 +542,21 @@ function printBoard(board) {
450
542
  function printReceipt(receipt) {
451
543
  console.log('');
452
544
  if (receipt.dryRun) {
453
- console.log(`landing cleanup preview — would clear ${receipt.deletedBranches.length} pieces of work, ${receipt.removedWorktrees.length} side copies:`);
545
+ console.log(`landing cleanup preview: would clear ${receipt.deletedBranches.length} pieces of work, ${receipt.removedWorktrees.length} side copies:`);
454
546
  for (const b of receipt.deletedBranches) console.log(` would clear ${b}`);
455
547
  for (const w of receipt.removedWorktrees) console.log(` would remove ${w}`);
456
548
  } else {
457
- console.log(`landing cleanup done — ${receipt.deletedBranches.length} pieces cleared, ${receipt.removedWorktrees.length} side copies removed`);
549
+ console.log(`landing cleanup done: ${receipt.deletedBranches.length} pieces cleared, ${receipt.removedWorktrees.length} side copies removed`);
458
550
  if (receipt.bundle) console.log(` backed up first, nothing lost: ${receipt.bundle}`);
459
- if (receipt.bundleError) console.log(` backup failed — unlanded work left in place: ${receipt.bundleError}`);
551
+ if (receipt.bundleError) console.log(` backup failed: unlanded work left in place: ${receipt.bundleError}`);
460
552
  for (const p of receipt.patches) console.log(` unsaved edits saved: ${p}`);
461
553
  for (const u of receipt.untracked || []) console.log(` new files saved: ${u}`);
462
- for (const k of receipt.keptWorktrees || []) console.log(` ✋ kept, needs a human: ${k}`);
554
+ // fresh-grace keeps are healthy active work, not a call to action —
555
+ // "needs a human" on those trained readers to ignore the real ones.
556
+ for (const k of receipt.keptWorktrees || []) {
557
+ if (String(k).includes('(fresh_worktree_grace)')) console.log(` in use, left alone: ${k.replace(' (fresh_worktree_grace)', '')}`);
558
+ else console.log(` ✋ kept, needs a human: ${k}`);
559
+ }
463
560
  for (const m of receipt.keptMovedBranches || []) console.log(` moved since scan, left alone: ${m}`);
464
561
  if (receipt.deletedRemote.length > 0) console.log(` also cleared on github: ${receipt.deletedRemote.length}`);
465
562
  }
@@ -475,13 +572,15 @@ function readFlag(args, name, fallback = '') {
475
572
 
476
573
  function showHelp() {
477
574
  console.log('');
478
- console.log('atris land — the landing: what is actually done vs still in the air');
575
+ console.log('atris land: the landing, what is actually done vs still in the air');
479
576
  console.log('');
480
577
  console.log('work counts as done only when it lands in master. everything else is');
481
578
  console.log('in the air, and this shows it so nothing quietly dies.');
482
579
  console.log('');
483
580
  console.log(' atris land show everything still in the air');
581
+ console.log(' atris land status show everything still in the air');
484
582
  console.log(' atris land <name> the story of one piece: what it tried,');
583
+ console.log(' atris land status <name> same story lookup from status mode');
485
584
  console.log(' whether it landed some other way, what');
486
585
  console.log(' would be lost by clearing it');
487
586
  console.log(' atris land --reap back up, then clear anything overdue');
@@ -490,7 +589,7 @@ function showHelp() {
490
589
  console.log(' atris land --ttl <days> change the 7-day overdue line');
491
590
  console.log(' atris land --json machine-readable output');
492
591
  console.log('');
493
- console.log('backups go to .atris/salvage/<date>/ — clearing never loses work.');
592
+ console.log('backups go to .atris/salvage/<date>/; clearing never loses work.');
494
593
  console.log('');
495
594
  }
496
595
 
@@ -517,7 +616,8 @@ function landCommand(args = []) {
517
616
  return 0;
518
617
  }
519
618
 
520
- const name = args.find((a) => !a.startsWith('-') && a !== readFlag(args, '--ttl') && a !== readFlag(args, '--base'));
619
+ const lookupArgs = args[0] === 'status' ? args.slice(1) : args;
620
+ const name = lookupArgs.find((a) => !a.startsWith('-') && a !== readFlag(lookupArgs, '--ttl') && a !== readFlag(lookupArgs, '--base'));
521
621
  if (name) {
522
622
  const story = collectStory(root, name, { base });
523
623
  if (!story) {
@@ -3,6 +3,13 @@ const path = require('path');
3
3
  const { loadCredentials } = require('../utils/auth');
4
4
  const { apiRequestJson } = require('../utils/api');
5
5
  const { loadBusinesses, saveBusinesses, businessMatchesSlug } = require('./business');
6
+ const {
7
+ isLocalMember,
8
+ memberArgFromArgv,
9
+ readLoopFlag,
10
+ setMemberAwake,
11
+ setMemberLoopAwake,
12
+ } = require('../lib/member-switches');
6
13
 
7
14
  /**
8
15
  * Resolve business slug from CLI arg or local .atris/business.json.
@@ -23,6 +30,29 @@ function resolveSlug() {
23
30
  return slug;
24
31
  }
25
32
 
33
+ /**
34
+ * Per-member / per-loop switch path for atris sleep|wake <member> [--loop <id>].
35
+ * Returns true when handled so callers skip business-level lifecycle.
36
+ */
37
+ function handleMemberSwitch(awake) {
38
+ const name = memberArgFromArgv(process.argv);
39
+ if (!name || name === '--help' || name === '-h' || name === 'help') return false;
40
+ if (!isLocalMember(name)) return false;
41
+
42
+ const loopId = readLoopFlag(process.argv);
43
+ if (loopId) {
44
+ setMemberLoopAwake(name, loopId, awake);
45
+ console.log(awake
46
+ ? `${name} loop ${loopId} is awake`
47
+ : `${name} loop ${loopId} is asleep`);
48
+ return true;
49
+ }
50
+
51
+ setMemberAwake(name, awake);
52
+ console.log(awake ? `${name} is awake` : `${name} is asleep`);
53
+ return true;
54
+ }
55
+
26
56
  async function resolveBusiness(token, slug) {
27
57
  const businesses = loadBusinesses();
28
58
 
@@ -91,12 +121,15 @@ async function waitForBusinessComputer(token, business, maxWait = 90000) {
91
121
  * @returns {Promise<void>}
92
122
  */
93
123
  async function sleepAtris() {
124
+ if (handleMemberSwitch(false)) return;
125
+
94
126
  const slug = resolveSlug();
95
127
 
96
128
  if (!slug || slug === '--help' || slug === '-h' || slug === 'help') {
97
- console.log('Usage: atris sleep [business]');
129
+ console.log('Usage: atris sleep [business|member] [--loop <loop-id>]');
98
130
  console.log('');
99
131
  console.log(' Pause a workspace to save compute. Storage only.');
132
+ console.log(' Or flip a local member / member-loop switch asleep.');
100
133
  process.exit(0);
101
134
  }
102
135
 
@@ -131,7 +164,7 @@ async function sleepAtris() {
131
164
  }
132
165
 
133
166
  console.log(`Workspace '${slug}' is now sleeping. Context saved. Wake it with: atris wake ${slug}`);
134
- console.log('Compute paused. Storage only — pennies/day.');
167
+ console.log('Compute paused. Storage only - pennies/day.');
135
168
  }
136
169
 
137
170
  /**
@@ -139,12 +172,15 @@ async function sleepAtris() {
139
172
  * @returns {Promise<void>}
140
173
  */
141
174
  async function wakeAtris() {
175
+ if (handleMemberSwitch(true)) return;
176
+
142
177
  const slug = resolveSlug();
143
178
 
144
179
  if (!slug || slug === '--help' || slug === '-h' || slug === 'help') {
145
- console.log('Usage: atris wake [business]');
180
+ console.log('Usage: atris wake [business|member] [--loop <loop-id>]');
146
181
  console.log('');
147
182
  console.log(' Wake a sleeping workspace. Agents resume automatically.');
183
+ console.log(' Or flip a local member / member-loop switch awake.');
148
184
  process.exit(0);
149
185
  }
150
186
 
package/commands/log.js CHANGED
@@ -52,4 +52,87 @@ function logAtris() {
52
52
  });
53
53
  }
54
54
 
55
- module.exports = { logAtris };
55
+ function localDateString(now = new Date()) {
56
+ const year = String(now.getFullYear());
57
+ const month = String(now.getMonth() + 1).padStart(2, '0');
58
+ const day = String(now.getDate()).padStart(2, '0');
59
+ return `${year}-${month}-${day}`;
60
+ }
61
+
62
+ function digestDate(args) {
63
+ const equalsArg = args.find((arg) => String(arg).startsWith('--date='));
64
+ const dateIndex = args.indexOf('--date');
65
+ const date = equalsArg
66
+ ? equalsArg.slice('--date='.length)
67
+ : dateIndex >= 0
68
+ ? args[dateIndex + 1]
69
+ : localDateString();
70
+
71
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(String(date || ''))) {
72
+ throw new Error('Usage: atris logs [today] [--date YYYY-MM-DD] [--json]');
73
+ }
74
+ return date;
75
+ }
76
+
77
+ function readDigestSection(root, title, relativePath, maxLines = null) {
78
+ const absolutePath = path.join(root, relativePath);
79
+ if (!fs.existsSync(absolutePath) || !fs.statSync(absolutePath).isFile()) return null;
80
+
81
+ const content = fs.readFileSync(absolutePath, 'utf8');
82
+ const lines = content.split(/\r?\n/);
83
+ if (lines[lines.length - 1] === '') lines.pop();
84
+
85
+ if (maxLines !== null && lines.length > maxLines) {
86
+ return {
87
+ title,
88
+ path: relativePath.split(path.sep).join('/'),
89
+ lines: [...lines.slice(0, maxLines), `truncated after ${maxLines} lines`],
90
+ };
91
+ }
92
+
93
+ return {
94
+ title,
95
+ path: relativePath.split(path.sep).join('/'),
96
+ lines,
97
+ };
98
+ }
99
+
100
+ function logsDigest(args = [], options = {}) {
101
+ const root = options.cwd || process.cwd();
102
+ const date = digestDate(args);
103
+ const sections = [];
104
+ const workspacePath = path.join('atris', 'logs', date.slice(0, 4), `${date}.md`);
105
+ const workspaceSection = readDigestSection(root, 'Workspace journal', workspacePath);
106
+ if (workspaceSection) sections.push(workspaceSection);
107
+
108
+ const teamRoot = path.join(root, 'atris', 'team');
109
+ if (fs.existsSync(teamRoot) && fs.statSync(teamRoot).isDirectory()) {
110
+ const members = fs.readdirSync(teamRoot, { withFileTypes: true })
111
+ .filter((entry) => entry.isDirectory())
112
+ .map((entry) => entry.name)
113
+ .sort((left, right) => left.localeCompare(right));
114
+
115
+ for (const member of members) {
116
+ const relativePath = path.join('atris', 'team', member, 'logs', `${date}.md`);
117
+ const section = readDigestSection(root, `Team: ${member}`, relativePath, 80);
118
+ if (section) sections.push(section);
119
+ }
120
+ }
121
+
122
+ const payload = { date, sections };
123
+ if (args.includes('--json')) {
124
+ console.log(JSON.stringify(payload, null, 2));
125
+ } else if (sections.length === 0) {
126
+ console.log(`No logs for ${date} yet.`);
127
+ } else {
128
+ console.log(sections.map((section) => [
129
+ `=== ${section.title} ===`,
130
+ section.path,
131
+ ...section.lines,
132
+ ].join('\n')).join('\n\n'));
133
+ }
134
+
135
+ return payload;
136
+ }
137
+
138
+ module.exports = { logAtris, logsDigest };