atris 3.34.0 → 3.36.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 (163) hide show
  1. package/AGENTS.md +35 -0
  2. package/FOR_AGENTS.md +5 -3
  3. package/README.md +5 -3
  4. package/atris/GETTING_STARTED.md +1 -1
  5. package/atris/atris.md +3 -0
  6. package/atris/policies/day-loop-voice.md +102 -0
  7. package/atris/policies/outbound-artifact-gate.md +2 -0
  8. package/atris/skills/design/SKILL.md +56 -32
  9. package/atris/skills/endgame/SKILL.md +12 -6
  10. package/atris/skills/engines/SKILL.md +35 -8
  11. package/atris/skills/fable-method/SKILL.md +66 -0
  12. package/atris/skills/improve/SKILL.md +65 -45
  13. package/atris/skills/render-cli/SKILL.md +88 -0
  14. package/atris/skills/youtube/SKILL.md +10 -1
  15. package/atris.md +6 -2
  16. package/ax +617 -31
  17. package/bin/atris.js +719 -266
  18. package/commands/activate.js +194 -88
  19. package/commands/aeo.js +52 -0
  20. package/commands/agents.js +166 -0
  21. package/commands/autoland.js +718 -72
  22. package/commands/autopilot-front.js +20 -2
  23. package/commands/autopilot.js +118 -2
  24. package/commands/avail.js +407 -0
  25. package/commands/bench.js +188 -0
  26. package/commands/brain.js +3 -0
  27. package/commands/brief.js +651 -0
  28. package/commands/business-sync.js +192 -6
  29. package/commands/business.js +91 -8
  30. package/commands/clean.js +50 -24
  31. package/commands/close.js +1083 -0
  32. package/commands/cloud.js +245 -0
  33. package/commands/codex-goal.js +26 -2
  34. package/commands/compile.js +292 -1
  35. package/commands/computer.js +150 -3
  36. package/commands/dream.js +365 -0
  37. package/commands/drill.js +371 -0
  38. package/commands/drive.js +187 -0
  39. package/commands/engine.js +1061 -29
  40. package/commands/experiments.js +28 -0
  41. package/commands/feed.js +202 -0
  42. package/commands/feedback.js +34 -12
  43. package/commands/fleet-report.js +206 -0
  44. package/commands/github.js +38 -0
  45. package/commands/gm.js +285 -3
  46. package/commands/goal.js +247 -0
  47. package/commands/improve.js +642 -26
  48. package/commands/init.js +83 -43
  49. package/commands/integrations.js +39 -11
  50. package/commands/interview.js +209 -0
  51. package/commands/land.js +253 -52
  52. package/commands/lesson.js +112 -1
  53. package/commands/lifecycle.js +39 -3
  54. package/commands/linear.js +38 -0
  55. package/commands/log.js +84 -1
  56. package/commands/loops.js +220 -16
  57. package/commands/meet.js +220 -0
  58. package/commands/member.js +899 -66
  59. package/commands/mission.js +3512 -332
  60. package/commands/next.js +137 -0
  61. package/commands/now.js +240 -21
  62. package/commands/one-lap.js +776 -0
  63. package/commands/orb.js +314 -0
  64. package/commands/pack-craft.js +179 -0
  65. package/commands/pack.js +823 -0
  66. package/commands/play.js +3 -2
  67. package/commands/probe.js +30 -3
  68. package/commands/pulse.js +241 -46
  69. package/commands/push.js +260 -82
  70. package/commands/radar.js +259 -14
  71. package/commands/rainmaker.js +49 -0
  72. package/commands/report.js +415 -0
  73. package/commands/scout.js +147 -0
  74. package/commands/search.js +363 -0
  75. package/commands/serve.js +54 -0
  76. package/commands/skill.js +47 -3
  77. package/commands/slop.js +50 -2
  78. package/commands/soul.js +1 -1
  79. package/commands/status.js +50 -5
  80. package/commands/stream.js +861 -0
  81. package/commands/stripe.js +38 -0
  82. package/commands/study.js +693 -0
  83. package/commands/supabase.js +39 -0
  84. package/commands/sync.js +67 -54
  85. package/commands/task.js +2275 -182
  86. package/commands/team.js +73 -0
  87. package/commands/truth.js +29 -3
  88. package/commands/unknowns.js +627 -0
  89. package/commands/update.js +44 -0
  90. package/commands/vercel.js +38 -0
  91. package/commands/verify.js +96 -0
  92. package/commands/watch.js +303 -0
  93. package/commands/wish.js +500 -0
  94. package/commands/workflow.js +11 -5
  95. package/commands/worktree.js +299 -20
  96. package/commands/write.js +399 -0
  97. package/commands/xp.js +29 -11
  98. package/lib/auto-accept-certified.js +391 -43
  99. package/lib/autoland.js +353 -52
  100. package/lib/ax-auto-lane.js +79 -0
  101. package/lib/bench/context.js +147 -0
  102. package/lib/bench/engines.js +141 -0
  103. package/lib/bench/report.js +140 -0
  104. package/lib/bench/runner.js +512 -0
  105. package/lib/brief-ledger.js +350 -0
  106. package/lib/cloud-mission.js +259 -0
  107. package/lib/codex-flight.js +154 -0
  108. package/lib/default-runner.js +45 -0
  109. package/lib/default-verifier.js +70 -0
  110. package/lib/engine-registry.js +232 -0
  111. package/lib/experiments/daily.js +640 -0
  112. package/lib/fleet.js +2431 -38
  113. package/lib/improve-vitals-html.js +171 -0
  114. package/lib/known-commands.js +58 -0
  115. package/lib/loop-doctor.js +416 -0
  116. package/lib/member-switches.js +144 -0
  117. package/lib/memory-view.js +14 -5
  118. package/lib/mission-room.js +1 -0
  119. package/lib/mission-root.js +52 -0
  120. package/lib/mission-runtime-loop.js +7 -0
  121. package/lib/next-moves.js +327 -10
  122. package/lib/official-cli-integration.js +174 -0
  123. package/lib/one-lap-validator.js +60 -0
  124. package/lib/orb-context.js +477 -0
  125. package/lib/orb-scorecard.js +224 -0
  126. package/lib/outbound-send-gate.js +165 -0
  127. package/lib/permission-grants.js +293 -0
  128. package/lib/policy-lessons.js +52 -1
  129. package/lib/pulse.js +277 -3
  130. package/lib/receipt-block.js +168 -0
  131. package/lib/receipt-evidence.js +65 -4
  132. package/lib/review-integrity.js +147 -0
  133. package/lib/router-brain.js +352 -0
  134. package/lib/runner-command.js +33 -0
  135. package/lib/self-drive.js +258 -0
  136. package/lib/short-name.js +103 -0
  137. package/lib/spawn-env.js +18 -0
  138. package/lib/state-detection.js +56 -1
  139. package/lib/sync-status.js +59 -0
  140. package/lib/task-db.js +319 -27
  141. package/lib/task-proof.js +43 -1
  142. package/lib/task-receipt.js +93 -0
  143. package/lib/team-presence.js +260 -0
  144. package/lib/tool-result-encode.js +7 -0
  145. package/lib/trust-tiers.js +90 -0
  146. package/lib/usage.js +107 -0
  147. package/lib/voice-gate.js +163 -0
  148. package/lib/wish-audit.js +1368 -0
  149. package/lib/wish-delegate.js +1840 -0
  150. package/lib/wish-design.js +110 -0
  151. package/lib/wish-stats.js +183 -0
  152. package/lib/wish-store.js +354 -0
  153. package/lib/zip.js +221 -0
  154. package/package.json +3 -1
  155. package/templates/loops/atris/loops/LOOPS.md +55 -0
  156. package/templates/loops/atris/loops/TICK.md +24 -0
  157. package/templates/loops/atris/loops/feedback.md +22 -0
  158. package/templates/loops/atris/loops/quality.md +22 -0
  159. package/templates/loops/atris/wiki/systems/loops.md +41 -0
  160. package/utils/api.js +5 -1
  161. package/utils/auth.js +57 -21
  162. package/utils/update-check.js +27 -6
  163. package/atris/learnings.jsonl +0 -1
package/commands/land.js CHANGED
@@ -5,10 +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;
9
+ const WORKTREE_REAP_GRACE_MS = 60 * 60 * 1000;
8
10
  const PROTECTED_BRANCHES = new Set(['main', 'master']);
9
11
 
10
- function runGit(args, { cwd = process.cwd(), check = true } = {}) {
11
- 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 } : {}) });
12
14
  if (check && result.status !== 0) {
13
15
  throw new Error(`git ${args.join(' ')} failed: ${(result.stderr || result.stdout || '').trim()}`);
14
16
  }
@@ -27,6 +29,8 @@ function refExists(root, ref) {
27
29
 
28
30
  function baseBranch(root, override = '') {
29
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';
30
34
  if (refExists(root, 'master')) return 'master';
31
35
  if (refExists(root, 'main')) return 'main';
32
36
  return '';
@@ -36,18 +40,46 @@ function ageDays(unixSeconds, now = Date.now()) {
36
40
  return Math.floor((now / 1000 - Number(unixSeconds)) / 86400);
37
41
  }
38
42
 
39
- function listBranches(root) {
40
- const result = runGit(
41
- ['for-each-ref', 'refs/heads', '--format=%(refname:short)%09%(committerdate:unix)'],
42
- { cwd: root, check: false }
43
- );
44
- if (result.status !== 0) return [];
43
+ function ageHours(unixSeconds, now = Date.now()) {
44
+ return Math.floor((now / 1000 - Number(unixSeconds)) / 3600);
45
+ }
46
+
47
+ function worktreeMtimeMs(worktreePath) {
48
+ try {
49
+ return fs.statSync(worktreePath).mtimeMs;
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ function worktreeWithinReapGrace(worktreePath, now = Date.now()) {
56
+ const mtime = worktreeMtimeMs(worktreePath);
57
+ return typeof mtime === 'number' && now - mtime < WORKTREE_REAP_GRACE_MS;
58
+ }
59
+
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
+ }
45
72
  return result.stdout
46
73
  .split(/\r?\n/)
47
74
  .filter(Boolean)
48
75
  .map((line) => {
49
- const [name, date] = line.split('\t');
50
- 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;
51
83
  });
52
84
  }
53
85
 
@@ -57,30 +89,59 @@ function aheadCount(root, base, ref) {
57
89
  return Number(result.stdout.trim()) || 0;
58
90
  }
59
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
+
60
104
  // Board: every branch and worktree with unlanded state, classified.
61
105
  // landed — no commits ahead of base; the branch pointer is residue
62
106
  // active — has unlanded commits, younger than TTL
63
107
  // due — has unlanded commits, older than TTL; --reap collects it
64
- 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 } = {}) {
65
109
  const base = baseBranch(root, baseOverride);
66
110
  if (!base) throw new Error('no master/main branch found');
67
111
 
68
112
  const branches = [];
69
- for (const branch of listBranches(root)) {
113
+ const lastCommitMsByBranch = new Map();
114
+ for (const branch of listBranches(root, base)) {
70
115
  if (PROTECTED_BRANCHES.has(branch.name)) continue;
71
- const ahead = aheadCount(root, base, branch.name);
72
- 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 };
73
122
  let state = 'active';
74
- if (ahead === 0) state = 'landed';
123
+ if (ahead === 0 || (cherry.landedElsewhere > 0 && cherry.unique === 0)) state = 'landed';
75
124
  else if (age > ttlDays) state = 'due';
76
- 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
+ });
77
136
  }
78
137
 
79
138
  const worktrees = [];
80
139
  const all = listWorktrees(root);
81
140
  for (const wt of all.slice(1)) {
82
141
  const branch = (wt.branch || '').replace(/^refs\/heads\//, '');
83
- 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 };
84
145
  const dirty = counts.staged + counts.unstaged + counts.untracked;
85
146
  const entry = branches.find((b) => b.name === branch) || null;
86
147
  worktrees.push({
@@ -88,16 +149,35 @@ function collectBoard(root, { ttlDays = DEFAULT_TTL_DAYS, base: baseOverride = '
88
149
  branch,
89
150
  dirty,
90
151
  ageDays: entry ? entry.ageDays : null,
152
+ ageHours: entry ? entry.ageHours : null,
91
153
  state: entry ? entry.state : 'detached',
92
154
  });
93
155
  }
94
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
+
95
173
  const due = branches.filter((b) => b.state === 'due');
96
174
  const landed = branches.filter((b) => b.state === 'landed');
97
175
  const active = branches.filter((b) => b.state === 'active');
176
+ const stale = active.filter((b) => b.stale);
98
177
  return {
99
178
  base,
100
179
  ttlDays,
180
+ staleHours,
101
181
  branches,
102
182
  worktrees,
103
183
  summary: {
@@ -105,23 +185,25 @@ function collectBoard(root, { ttlDays = DEFAULT_TTL_DAYS, base: baseOverride = '
105
185
  active: active.length,
106
186
  due: due.length,
107
187
  landed: landed.length,
188
+ stale: stale.length,
108
189
  worktrees: worktrees.length,
109
190
  },
110
191
  };
111
192
  }
112
193
 
113
- // 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.
114
197
  function landSummary(cwd = process.cwd(), ttlDays = DEFAULT_TTL_DAYS) {
115
198
  const root = repoRoot(cwd);
116
199
  if (!root) return null;
117
- let branches = 0;
118
- let due = 0;
119
- for (const branch of listBranches(root)) {
120
- if (PROTECTED_BRANCHES.has(branch.name)) continue;
121
- branches += 1;
122
- if (ageDays(branch.lastCommitUnix) > ttlDays) due += 1;
123
- }
124
- 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
+ };
125
207
  }
126
208
 
127
209
  function salvageDir(root) {
@@ -146,16 +228,76 @@ function remoteHeads(root) {
146
228
  return heads;
147
229
  }
148
230
 
231
+ // Everything a force-remove would destroy: staged + unstaged tracked changes
232
+ // (git diff HEAD) and untracked files (copied verbatim). Returns false if any
233
+ // piece could not be saved — the caller then keeps the worktree.
234
+ function salvageWorktree(w, dir, receipt) {
235
+ try {
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;
244
+ }
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 });
248
+ if (untracked.status !== 0) return false;
249
+ const files = untracked.stdout.split('\0').filter(Boolean);
250
+ if (files.length > 0) {
251
+ const destRoot = path.join(dir, `${path.basename(w.path)}.untracked`);
252
+ for (const rel of files) {
253
+ const dest = path.join(destRoot, rel);
254
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
255
+ fs.copyFileSync(path.join(w.path, rel), dest);
256
+ }
257
+ receipt.untracked.push(destRoot);
258
+ }
259
+ return true;
260
+ } catch (err) {
261
+ return false;
262
+ }
263
+ }
264
+
149
265
  // Reap: salvage then delete everything landed (residue) or past TTL.
150
266
  // Salvage first, always: unlanded commits go into a git bundle, dirty
151
- // worktrees into patch files, so a reap is never a loss of work.
152
- function reap(root, { ttlDays = DEFAULT_TTL_DAYS, base: baseOverride = '', dryRun = false, remote = true } = {}) {
153
- const board = collectBoard(root, { ttlDays, base: baseOverride });
267
+ // worktrees into patch files + untracked-file copies, so a reap is never
268
+ // a loss of work — and when a backup cannot be written, the work stays.
269
+ // includeDetached: detached-HEAD worktrees have no branch, so the salvage
270
+ // bundle cannot cover their commits — unattended reaps (autoland) pass false
271
+ // 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() } = {}) {
273
+ const board = collectBoard(root, { ttlDays, staleHours, base: baseOverride, now });
154
274
  const targets = board.branches.filter((b) => b.state === 'landed' || b.state === 'due');
155
275
  const targetNames = new Set(targets.map((b) => b.name));
156
- const worktreeTargets = board.worktrees.filter(
157
- (w) => targetNames.has(w.branch) || w.state === 'detached' || (typeof w.ageDays === 'number' && w.ageDays > ttlDays)
276
+ const candidateWorktrees = board.worktrees.filter(
277
+ (w) => targetNames.has(w.branch) || (includeDetached && w.state === 'detached') || (typeof w.ageDays === 'number' && w.ageDays > ttlDays)
158
278
  );
279
+ const protectedWorktrees = [];
280
+ const worktreeTargets = [];
281
+ const current = path.resolve(root);
282
+ for (const w of candidateWorktrees) {
283
+ // Only the fresh-worktree grace protects here. Dirty worktrees are NOT
284
+ // skipped: reap's salvage (bundle + patches + untracked copies below)
285
+ // exists precisely so dirty residue can be cleared loss-free. Blanket
286
+ // dirty protection lives in the janitor's cleanupWorktrees, which has
287
+ // no salvage machinery.
288
+ if (protectCurrent && path.resolve(w.path) === current) {
289
+ protectedWorktrees.push({ ...w, reason: 'current_checkout' });
290
+ continue;
291
+ }
292
+ if (worktreeWithinReapGrace(w.path, now)) {
293
+ protectedWorktrees.push({ ...w, reason: 'fresh_worktree_grace' });
294
+ continue;
295
+ }
296
+ worktreeTargets.push(w);
297
+ }
298
+ for (const w of protectedWorktrees) {
299
+ if (w.branch) targetNames.delete(w.branch);
300
+ }
159
301
  for (const w of worktreeTargets) {
160
302
  if (w.branch && !targetNames.has(w.branch)) targetNames.add(w.branch);
161
303
  }
@@ -165,7 +307,11 @@ function reap(root, { ttlDays = DEFAULT_TTL_DAYS, base: baseOverride = '', dryRu
165
307
  ttlDays,
166
308
  dryRun,
167
309
  bundle: null,
310
+ bundleError: null,
168
311
  patches: [],
312
+ untracked: [],
313
+ keptWorktrees: protectedWorktrees.map((w) => `${w.path} (${w.reason})`),
314
+ keptMovedBranches: [],
169
315
  removedWorktrees: [],
170
316
  deletedBranches: [],
171
317
  deletedRemote: [],
@@ -190,24 +336,62 @@ function reap(root, { ttlDays = DEFAULT_TTL_DAYS, base: baseOverride = '', dryRu
190
336
  check: false,
191
337
  });
192
338
  if (result.status === 0) receipt.bundle = bundlePath;
339
+ else {
340
+ // Salvage-first is a hard promise: no bundle, no deletion of unlanded
341
+ // work. These branches (and their worktrees) survive until a reap can
342
+ // actually back them up.
343
+ receipt.bundleError = String(result.stderr || result.stdout || 'bundle failed').trim().slice(0, 200);
344
+ for (const name of withCommits) targetNames.delete(name);
345
+ }
193
346
  }
194
347
 
195
- for (const w of worktreeTargets) {
196
- if (w.dirty > 0) {
197
- const diff = runGit(['diff'], { cwd: w.path, check: false });
198
- if (diff.status === 0 && diff.stdout.trim()) {
199
- const patchPath = path.join(dir, `${path.basename(w.path)}.dirty.patch`);
200
- fs.writeFileSync(patchPath, diff.stdout);
201
- receipt.patches.push(patchPath);
202
- }
348
+ const survivingWorktreeTargets = worktreeTargets.filter(
349
+ (w) => targetNames.has(w.branch) || (includeDetached && w.state === 'detached')
350
+ );
351
+ for (const w of survivingWorktreeTargets) {
352
+ // Salvage-then-remove, never keep-because-dirty: patches + untracked
353
+ // copies bank everything force-remove would destroy. The fresh-worktree
354
+ // grace was already applied when candidates were selected.
355
+ if (w.dirty > 0 && !salvageWorktree(w, dir, receipt)) {
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)`);
360
+ if (w.branch) targetNames.delete(w.branch);
361
+ continue;
203
362
  }
204
363
  const removed = runGit(['worktree', 'remove', '--force', w.path], { cwd: root, check: false });
205
- if (removed.status === 0) receipt.removedWorktrees.push(w.path);
364
+ if (removed.status === 0) {
365
+ receipt.removedWorktrees.push(w.path);
366
+ } else {
367
+ // A locked worktree fails here and, unreported, reap re-salvages the
368
+ // same patches every pass forever. Say what blocked it so a human can
369
+ // unlock (or a dead lock can be challenged) instead of looping.
370
+ const reason = String(removed.stderr || removed.stdout || 'remove failed').trim().slice(0, 160);
371
+ receipt.keptWorktrees.push(`${w.path} (${reason})`);
372
+ if (w.branch) targetNames.delete(w.branch);
373
+ }
206
374
  }
207
375
 
208
376
  for (const name of targetNames) {
377
+ const entry = board.branches.find((b) => b.name === name);
378
+ // the board is a snapshot; an agent may have committed since it was
379
+ // taken. A branch that moved is left alone — the next reap sees the
380
+ // new truth and bundles it before touching it.
381
+ if (!entry || aheadCount(root, board.base, name) !== entry.ahead) {
382
+ receipt.keptMovedBranches.push(name);
383
+ continue;
384
+ }
209
385
  const deleted = runGit(['branch', '-D', name], { cwd: root, check: false });
210
- 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
+ }
211
395
  }
212
396
 
213
397
  if (remote && receipt.deletedBranches.length > 0) {
@@ -308,7 +492,7 @@ function printStory(story) {
308
492
  console.log(' some other way. nothing here would be lost by clearing it.');
309
493
  } else if (story.landedElsewhere > 0) {
310
494
  console.log(` bottom line: ${story.landedElsewhere} of ${story.changes.length} changes made it into ${story.base} another`);
311
- 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`);
312
496
  console.log(' been redone differently, or they may be real lost work. clearing backs');
313
497
  console.log(' them up first either way.');
314
498
  } else {
@@ -322,19 +506,20 @@ function printStory(story) {
322
506
 
323
507
  function printBoard(board) {
324
508
  console.log('');
325
- 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');
326
510
  console.log('');
327
511
  if (board.branches.length === 0 && board.worktrees.length === 0) {
328
512
  console.log(' everything has landed. nothing in the air, nothing stuck.');
329
513
  console.log('');
330
514
  return;
331
515
  }
332
- const order = { due: 0, detached: 0, landed: 1, active: 2 };
516
+ const order = { due: 0, detached: 0, active: 1, landed: 2 };
333
517
  const rows = [...board.branches].sort((a, b) => (order[a.state] ?? 3) - (order[b.state] ?? 3) || b.ageDays - a.ageDays);
334
518
  const labels = { due: 'overdue', landed: 'landed', active: 'in the air' };
335
519
  for (const b of rows) {
336
520
  const changes = b.ahead === 1 ? '1 change ' : `${b.ahead} changes`;
337
- 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}`);
338
523
  }
339
524
  for (const w of board.worktrees) {
340
525
  const edits = w.dirty === 1 ? '1 unsaved edit' : `${w.dirty} unsaved edits`;
@@ -342,10 +527,14 @@ function printBoard(board) {
342
527
  }
343
528
  console.log('');
344
529
  const s = board.summary;
345
- 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.`);
346
532
  console.log(' work counts as done only when it lands in master.');
347
533
  if (s.due + s.landed > 0) {
348
- 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`);
349
538
  }
350
539
  console.log('');
351
540
  }
@@ -353,13 +542,22 @@ function printBoard(board) {
353
542
  function printReceipt(receipt) {
354
543
  console.log('');
355
544
  if (receipt.dryRun) {
356
- 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:`);
357
546
  for (const b of receipt.deletedBranches) console.log(` would clear ${b}`);
358
547
  for (const w of receipt.removedWorktrees) console.log(` would remove ${w}`);
359
548
  } else {
360
- 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`);
361
550
  if (receipt.bundle) console.log(` backed up first, nothing lost: ${receipt.bundle}`);
551
+ if (receipt.bundleError) console.log(` backup failed: unlanded work left in place: ${receipt.bundleError}`);
362
552
  for (const p of receipt.patches) console.log(` unsaved edits saved: ${p}`);
553
+ for (const u of receipt.untracked || []) console.log(` new files saved: ${u}`);
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
+ }
560
+ for (const m of receipt.keptMovedBranches || []) console.log(` moved since scan, left alone: ${m}`);
363
561
  if (receipt.deletedRemote.length > 0) console.log(` also cleared on github: ${receipt.deletedRemote.length}`);
364
562
  }
365
563
  console.log(` still flying (recent work, left alone): ${receipt.kept.length}`);
@@ -374,13 +572,15 @@ function readFlag(args, name, fallback = '') {
374
572
 
375
573
  function showHelp() {
376
574
  console.log('');
377
- 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');
378
576
  console.log('');
379
577
  console.log('work counts as done only when it lands in master. everything else is');
380
578
  console.log('in the air, and this shows it so nothing quietly dies.');
381
579
  console.log('');
382
580
  console.log(' atris land show everything still in the air');
581
+ console.log(' atris land status show everything still in the air');
383
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');
384
584
  console.log(' whether it landed some other way, what');
385
585
  console.log(' would be lost by clearing it');
386
586
  console.log(' atris land --reap back up, then clear anything overdue');
@@ -389,7 +589,7 @@ function showHelp() {
389
589
  console.log(' atris land --ttl <days> change the 7-day overdue line');
390
590
  console.log(' atris land --json machine-readable output');
391
591
  console.log('');
392
- 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.');
393
593
  console.log('');
394
594
  }
395
595
 
@@ -416,7 +616,8 @@ function landCommand(args = []) {
416
616
  return 0;
417
617
  }
418
618
 
419
- 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'));
420
621
  if (name) {
421
622
  const story = collectStory(root, name, { base });
422
623
  if (!story) {
@@ -1,9 +1,112 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
- const { writeLesson } = require('./autopilot');
3
+ const {
4
+ writeLesson,
5
+ parseLessons,
6
+ loadLessonMetadata,
7
+ runLessonDetector,
8
+ } = require('./autopilot');
4
9
  const { detectLessonContradictions } = require('../lib/lesson-contradiction');
5
10
  const taskDb = require('../lib/task-db');
6
11
 
12
+ /**
13
+ * Tag a lesson's line in atris/lessons.md with `[resolved]` (idempotent).
14
+ * Inserts the marker right after the verdict separator so both the autopilot
15
+ * parser (`resolvedTag`) and the memory view treat the lesson as retired.
16
+ * @returns {boolean} true if the file was changed.
17
+ */
18
+ function tagLessonResolvedInMd(cwd, slug) {
19
+ const lessonsPath = path.join(cwd, 'atris', 'lessons.md');
20
+ if (!fs.existsSync(lessonsPath)) return false;
21
+ const lines = fs.readFileSync(lessonsPath, 'utf8').split('\n');
22
+ let changed = false;
23
+ for (let i = 0; i < lines.length; i++) {
24
+ const m = lines[i].match(/\*\*\[\d{4}-\d{2}-\d{2}\]\s+([\w-]+)\*\*/);
25
+ if (!m || m[1] !== slug) continue;
26
+ if (/\[resolved\]/i.test(lines[i])) continue; // already tagged
27
+ lines[i] = lines[i].replace(
28
+ /(\*\*\[\d{4}-\d{2}-\d{2}\]\s+[\w-]+\*\*\s*[—-]\s*(?:pass|fail)?\s*[—-]?\s*)/,
29
+ '$1[resolved] '
30
+ );
31
+ changed = true;
32
+ }
33
+ if (changed) fs.writeFileSync(lessonsPath, lines.join('\n'));
34
+ return changed;
35
+ }
36
+
37
+ /**
38
+ * Auto-resolve detector-backed fail lessons whose detector now passes.
39
+ *
40
+ * A `fail` lesson records a bug; its detector exits 0 once the bug is gone.
41
+ * When that happens we retire the lesson: stamp `status: resolved` +
42
+ * `resolved_at` in the atris/lessons.json sidecar and tag `[resolved]` in
43
+ * atris/lessons.md. Retired lessons stop being re-picked by the self-heal
44
+ * loop and drop out of the active memory view, keeping the lesson file small
45
+ * and trustworthy.
46
+ *
47
+ * Only detector-backed `fail` lessons self-retire. Prose-only lessons (no
48
+ * detector) and `observed` process rules are never auto-resolved — they have
49
+ * no falsifiable pass state.
50
+ *
51
+ * @param {string} cwd
52
+ * @param {object} [options] - { dryRun, detectorTimeout }
53
+ * @returns {{ checked: string[], resolved: string[], dryRun: boolean }}
54
+ */
55
+ function autoResolveLessons(cwd, options = {}) {
56
+ const dryRun = !!options.dryRun;
57
+ const lessons = parseLessons(cwd);
58
+ const metadata = loadLessonMetadata(cwd);
59
+ const today = new Date().toISOString().slice(0, 10);
60
+ const checked = [];
61
+ const resolved = [];
62
+
63
+ for (const lesson of lessons) {
64
+ const meta = lesson.meta;
65
+ if (!meta || !meta.detector) continue; // only detector-backed lessons self-retire
66
+ if (lesson.verdict !== 'fail') continue; // only bug lessons carry a resolve semantic
67
+ if (meta.status === 'resolved') continue; // already retired
68
+ if (meta.status === 'observed') continue; // process rule, never auto-resolve
69
+ checked.push(lesson.id);
70
+ if (!runLessonDetector(meta.detector, cwd, options.detectorTimeout)) continue;
71
+ resolved.push(lesson.id);
72
+ if (!dryRun) {
73
+ metadata[lesson.id] = { ...meta, status: 'resolved', resolved_at: today };
74
+ tagLessonResolvedInMd(cwd, lesson.id);
75
+ }
76
+ }
77
+
78
+ if (!dryRun && resolved.length) {
79
+ const metaPath = path.join(cwd, 'atris', 'lessons.json');
80
+ fs.writeFileSync(metaPath, JSON.stringify(metadata, null, 2) + '\n');
81
+ }
82
+
83
+ return { checked, resolved, dryRun };
84
+ }
85
+
86
+ function resolveLessons(args) {
87
+ const cwd = process.cwd();
88
+ const json = args.includes('--json');
89
+ const dryRun = args.includes('--dry-run');
90
+ const { checked, resolved } = autoResolveLessons(cwd, { dryRun });
91
+
92
+ if (json) {
93
+ console.log(JSON.stringify({
94
+ ok: true,
95
+ action: 'lesson_resolve',
96
+ dry_run: dryRun,
97
+ detectors_checked: checked.length,
98
+ resolved_count: resolved.length,
99
+ resolved,
100
+ }, null, 2));
101
+ return;
102
+ }
103
+
104
+ const verb = dryRun ? 'would auto-resolve' : 'auto-resolved';
105
+ const tail = resolved.length ? `: ${resolved.join(', ')}` : '';
106
+ console.log(`ran ${checked.length} detector(s); ${verb} ${resolved.length} lesson(s)${tail}`);
107
+ if (dryRun) console.log('(dry-run: nothing written)');
108
+ }
109
+
7
110
  function sweepLessons(args) {
8
111
  const cwd = process.cwd();
9
112
  const json = args.includes('--json');
@@ -127,11 +230,17 @@ function lessonAtris(subcommand, ...args) {
127
230
  return;
128
231
  }
129
232
 
233
+ if (subcommand === 'resolve') {
234
+ resolveLessons(args);
235
+ return;
236
+ }
237
+
130
238
  if (subcommand !== 'add') {
131
239
  console.log('');
132
240
  console.log(' Usage: atris lesson add <slug> <pass|fail> "<text>"');
133
241
  console.log(' atris lesson mine [--json] [--dry-run]');
134
242
  console.log(' atris lesson sweep [--json] [--dry-run]');
243
+ console.log(' atris lesson resolve [--json] [--dry-run]');
135
244
  console.log('');
136
245
  process.exit(subcommand ? 1 : 0);
137
246
  }
@@ -159,3 +268,5 @@ function lessonAtris(subcommand, ...args) {
159
268
  }
160
269
 
161
270
  module.exports = lessonAtris;
271
+ module.exports.autoResolveLessons = autoResolveLessons;
272
+ module.exports.tagLessonResolvedInMd = tagLessonResolvedInMd;