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
@@ -6,7 +6,12 @@ const os = require('os');
6
6
  const { spawnSync } = require('child_process');
7
7
 
8
8
  const autoland = require('../lib/autoland');
9
- const { operatorReady, hasAgentJargon } = autoland;
9
+ const { gateForHuman } = require('../lib/voice-gate');
10
+ const { evaluateAutoAccept } = require('../lib/auto-accept-certified');
11
+ const { operatorReady, hasAgentJargon, explainResult } = autoland;
12
+ const MISSION_AUTO_VERIFY_STATUSES = new Set(['planning', 'paused', 'ready']);
13
+ const CLOSED_TASK_STATUSES = new Set(['done', 'archived']);
14
+ const MAX_MISSION_AUTO_VERIFY_PER_TICK = 3;
10
15
 
11
16
  function repoRoot(cwd = process.cwd()) {
12
17
  const result = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' });
@@ -18,20 +23,100 @@ function projectName(root) {
18
23
  return path.basename(root);
19
24
  }
20
25
 
26
+ function missionReasonBreakdown(rows, fallback = 'stale') {
27
+ const tally = {};
28
+ for (const row of rows || []) {
29
+ const reason = String(row?.reason || row?.pause_reason || fallback).trim() || fallback;
30
+ tally[reason] = (tally[reason] || 0) + 1;
31
+ }
32
+ return tally;
33
+ }
34
+
35
+ function mergeReasonBreakdown(left = {}, right = {}) {
36
+ const merged = { ...(left && typeof left === 'object' ? left : {}) };
37
+ for (const [reason, count] of Object.entries(right || {})) {
38
+ merged[reason] = (Number(merged[reason]) || 0) + (Number(count) || 0);
39
+ }
40
+ return merged;
41
+ }
42
+
43
+ function mergeHeldMissions(left = {}, held = []) {
44
+ const merged = { ...(left && typeof left === 'object' ? left : {}) };
45
+ for (const row of held || []) {
46
+ if (!row || !row.id) continue;
47
+ merged[row.id] = {
48
+ id: row.id,
49
+ owner: row.owner || null,
50
+ objective: row.objective || '',
51
+ reason: row.reason || 'operator-required',
52
+ resume_command: row.resume_command || `atris mission run ${row.id}`,
53
+ };
54
+ }
55
+ return merged;
56
+ }
57
+
58
+ function recordJanitorState(state, { stopped = [], held = [], worktrees = 0 } = {}) {
59
+ const stoppedCount = Array.isArray(stopped) ? stopped.length : Number(stopped) || 0;
60
+ const heldRows = Array.isArray(held) ? held : [];
61
+ const worktreeCount = Number(worktrees) || 0;
62
+ if (!stoppedCount && !heldRows.length && !worktreeCount) return;
63
+ const prior = state.janitor && typeof state.janitor === 'object' ? state.janitor : {};
64
+ const missionHolds = mergeHeldMissions(prior.mission_holds, heldRows);
65
+ const missionStopReasons = mergeReasonBreakdown(prior.mission_stop_reasons, missionReasonBreakdown(stopped, 'stale'));
66
+ state.janitor = {
67
+ ...prior,
68
+ missions_stopped: (Number(prior.missions_stopped) || 0) + stoppedCount,
69
+ worktrees_reaped: (Number(prior.worktrees_reaped) || 0) + worktreeCount,
70
+ ...(Object.keys(missionStopReasons).length ? { mission_stop_reasons: missionStopReasons } : {}),
71
+ ...(Object.keys(missionHolds).length ? { mission_holds: missionHolds, missions_held: Object.keys(missionHolds).length } : {}),
72
+ };
73
+ }
74
+
21
75
  // The digest's "next, if you agree" section: top candidate moves from Atris
22
76
  // state, each with the member best suited to own it. Moves that can't explain
23
77
  // themselves are counted, not shown. Never blocks the digest.
78
+ function missionHasBudgetRemaining(mission, nowMs = Date.now()) {
79
+ if (mission?.budget_contract?.policy !== 'spend_full_budget') return false;
80
+ const seconds = Number(mission?.budget_contract?.requested_seconds || mission?.max_wall_seconds || 0);
81
+ const startedMs = Date.parse(mission?.started_at || mission?.created_at || mission?.updated_at || '');
82
+ return Number.isFinite(seconds) && seconds > 0
83
+ && Number.isFinite(startedMs) && startedMs + seconds * 1000 > nowMs;
84
+ }
85
+
86
+ function normalizedMissionObjective(mission) {
87
+ return String(mission?.objective || '').trim().toLowerCase().replace(/\s+/g, ' ');
88
+ }
89
+
24
90
  function digestNextMoves(root) {
25
91
  try {
26
92
  const { nextMoves } = require('../lib/next-moves');
27
93
  const { resolveFunctionalOwner } = require('../lib/functional-owner');
28
- const all = (nextMoves(root, 5) || []).filter((move) => move && move.title);
29
- const ready = all.filter((move) => operatorReady(move.title)).slice(0, 3).map((move) => {
94
+ const { listMissions } = require('./mission');
95
+ const missionRows = listMissions(root) || [];
96
+ const missions = new Map(missionRows.map((mission) => [mission.id, mission]));
97
+ const activeBudgetObjectives = new Set(missionRows
98
+ .filter((mission) => missionHasBudgetRemaining(mission))
99
+ .map(normalizedMissionObjective)
100
+ .filter(Boolean));
101
+ const all = (nextMoves(root, 5) || [])
102
+ .filter((move) => move && move.title)
103
+ .filter((move) => {
104
+ if (move.kind !== 'mission_ready') return true;
105
+ const mission = missions.get(move.ref);
106
+ return !missionHasBudgetRemaining(mission)
107
+ && !activeBudgetObjectives.has(normalizedMissionObjective(mission));
108
+ });
109
+ const explainable = all.filter((move) => move.kind === 'mission_ready' || operatorReady(move.title));
110
+ const ready = explainable.slice(0, 3).map((move) => {
30
111
  let owner = null;
31
- try { owner = resolveFunctionalOwner({ title: move.title, root })?.owner || null; } catch {}
32
- return { title: move.title, owner };
112
+ const mission = move.kind === 'mission_ready' ? missions.get(move.ref) : null;
113
+ if (mission?.owner) owner = mission.owner;
114
+ else {
115
+ try { owner = resolveFunctionalOwner({ title: move.title, root })?.owner || null; } catch {}
116
+ }
117
+ return { title: move.title, owner, kind: move.kind || null, label: move.label || null };
33
118
  });
34
- return { moves: ready, unexplained: all.length - ready.length };
119
+ return { moves: ready, unexplained: all.length - explainable.length };
35
120
  } catch {
36
121
  return { moves: [], unexplained: 0 };
37
122
  }
@@ -62,6 +147,158 @@ function readProjection(root) {
62
147
  }
63
148
  }
64
149
 
150
+ function readAcceptedTaskHistory(root, fallbackTasks = [], dbPath = undefined) {
151
+ try {
152
+ const taskDb = require('../lib/task-db');
153
+ const db = taskDb.open(dbPath);
154
+ const rows = taskDb.listTasks(db, { workspaceRoot: root });
155
+ return taskDb.withTaskDisplayRefs(rows);
156
+ } catch {
157
+ return fallbackTasks;
158
+ }
159
+ }
160
+
161
+ function digestStoryRows(accepted, acceptedTasks) {
162
+ const byRef = new Map((acceptedTasks || []).map((task) => [task.display_id || task.legacy_ref || task.id, task]));
163
+ return (accepted?.auto || []).map((item) => {
164
+ const summary = autoland.digestLine(item);
165
+ if (!summary || !operatorReady(summary)) return null;
166
+ const task = byRef.get(item.ref);
167
+ const candidates = [
168
+ task?.review?.landing?.happened || task?.metadata?.landing_happened || '',
169
+ item.result,
170
+ item.happened,
171
+ autoland.clarify(item.title, 160),
172
+ summary,
173
+ ].map((candidate) => autoland.historicalLandingText(candidate, 160));
174
+ return { item, story: candidates.find((candidate) => operatorReady(candidate)) || summary };
175
+ }).filter(Boolean);
176
+ }
177
+
178
+ function compactId(value) {
179
+ return String(value || '').trim();
180
+ }
181
+
182
+ function taskIdentityRefs(task) {
183
+ return [
184
+ task?.id,
185
+ task?.display_id,
186
+ task?.legacy_ref,
187
+ task?.ref,
188
+ task?.task_id,
189
+ task?.metadata?.task_id,
190
+ task?.metadata?.display_id,
191
+ task?.metadata?.ref,
192
+ ].map(compactId).filter(Boolean);
193
+ }
194
+
195
+ function taskGoalRefs(task) {
196
+ return [
197
+ task?.goal_id,
198
+ task?.goalId,
199
+ task?.mission_id,
200
+ task?.missionId,
201
+ task?.metadata?.goal_id,
202
+ task?.metadata?.goalId,
203
+ task?.metadata?.mission_id,
204
+ task?.metadata?.missionId,
205
+ task?.metadata?.goal?.id,
206
+ ].map(compactId).filter(Boolean);
207
+ }
208
+
209
+ function missionTaskRefs(mission) {
210
+ return [
211
+ ...(Array.isArray(mission?.task_ids) ? mission.task_ids : []),
212
+ mission?.task_id,
213
+ mission?.current_task_id,
214
+ mission?.task_ref,
215
+ mission?.xp_task?.task_id,
216
+ mission?.xp_task?.ref,
217
+ ].map(compactId).filter(Boolean);
218
+ }
219
+
220
+ function attachedTasksForMission(mission, tasks) {
221
+ const missionId = compactId(mission?.id);
222
+ const taskRefs = new Set(missionTaskRefs(mission));
223
+ return (tasks || []).filter((task) => {
224
+ if (missionId && taskGoalRefs(task).includes(missionId)) return true;
225
+ if (taskRefs.size === 0) return false;
226
+ return taskIdentityRefs(task).some((ref) => taskRefs.has(ref));
227
+ });
228
+ }
229
+
230
+ function taskIsClosed(task) {
231
+ return CLOSED_TASK_STATUSES.has(String(task?.status || '').trim().toLowerCase());
232
+ }
233
+
234
+ function missionHasVerifier(mission) {
235
+ return Boolean(String(mission?.verifier || mission?.effective_verifier || '').trim());
236
+ }
237
+
238
+ function missionReadyForClosedTaskVerify(mission, tasks) {
239
+ const status = String(mission?.status || '').trim().toLowerCase();
240
+ if (!MISSION_AUTO_VERIFY_STATUSES.has(status)) return false;
241
+ if (!missionHasVerifier(mission)) return false;
242
+ if (mission?.verifier_result?.passed === true) return false;
243
+ const attached = attachedTasksForMission(mission, tasks);
244
+ return attached.length > 0 && attached.every(taskIsClosed);
245
+ }
246
+
247
+ function parseMissionVerifyResult(id, result) {
248
+ let payload = null;
249
+ try {
250
+ payload = JSON.parse(result.stdout || '{}');
251
+ } catch {}
252
+ const verifier = payload?.verifier_result || null;
253
+ const passed = verifier ? verifier.passed === true : null;
254
+ return {
255
+ id,
256
+ result: passed === true ? 'passed' : passed === false ? 'failed' : (payload?.action || (result.status === 0 ? 'tick_recorded' : 'tick_failed')),
257
+ status: payload?.mission?.status || null,
258
+ receipt_path: payload?.receipt_path || null,
259
+ exit_status: result.status,
260
+ };
261
+ }
262
+
263
+ function verifyClosedTaskMissions(root, tasks, { limit = MAX_MISSION_AUTO_VERIFY_PER_TICK } = {}) {
264
+ const verified = [];
265
+ const errors = [];
266
+ let candidates = [];
267
+ try {
268
+ const { listMissions } = require('./mission');
269
+ candidates = listMissions(root).filter((mission) => missionReadyForClosedTaskVerify(mission, tasks)).slice(0, limit);
270
+ } catch (err) {
271
+ return {
272
+ verified,
273
+ errors: [{ error: String((err && err.message) || err).slice(0, 200) }],
274
+ };
275
+ }
276
+
277
+ for (const mission of candidates) {
278
+ const result = runOwnCli(root, [
279
+ 'mission',
280
+ 'tick',
281
+ mission.id,
282
+ '--verify',
283
+ '--summary',
284
+ 'All linked repair tasks are closed; autoland re-ran the mission verifier.',
285
+ '--json',
286
+ ]);
287
+ const outcome = parseMissionVerifyResult(mission.id, result);
288
+ verified.push(outcome);
289
+ if (result.status !== 0 || outcome.result === 'failed' || outcome.result === 'tick_failed') {
290
+ errors.push({
291
+ id: mission.id,
292
+ result: outcome.result,
293
+ status: result.status,
294
+ stderr: String(result.stderr || '').slice(0, 200),
295
+ stdout: String(result.stdout || '').slice(-500),
296
+ });
297
+ }
298
+ }
299
+ return { verified, errors };
300
+ }
301
+
65
302
  function landSummarySafe(root) {
66
303
  try {
67
304
  return require('./land').landSummary(root);
@@ -70,28 +307,147 @@ function landSummarySafe(root) {
70
307
  }
71
308
  }
72
309
 
73
- function evaluateQueue(root, { strictVerify }) {
74
- const cliArgs = ['task', 'auto-accept-certified', '--dry-run', '--json', '--limit', '50'];
75
- if (strictVerify) cliArgs.push('--strict-verify');
76
- const result = runOwnCli(root, cliArgs);
77
- try {
78
- const parsed = JSON.parse(result.stdout);
79
- return Array.isArray(parsed.results) ? parsed.results : [];
80
- } catch (err) {
81
- return [];
310
+ function refreshLandingRefs(root) {
311
+ const remotes = spawnSync('git', ['remote'], { cwd: root, encoding: 'utf8', timeout: 10000 });
312
+ if (remotes.status !== 0 || !remotes.stdout.split(/\r?\n/).includes('origin')) return;
313
+ // One round-trip for both; a missing branch doesn't fail the other with a
314
+ // multi-ref fetch on any git that supports it, and fetch errors are ignored
315
+ // here anyway.
316
+ const both = spawnSync('git', ['fetch', 'origin', 'master', 'main'], { cwd: root, encoding: 'utf8', timeout: 30000 });
317
+ if (both.status === 0) return;
318
+ for (const branch of ['master', 'main']) {
319
+ spawnSync('git', ['fetch', 'origin', branch], { cwd: root, encoding: 'utf8', timeout: 30000 });
320
+ }
321
+ }
322
+
323
+ function sweepLanding(root, { ttlDays, staleHours, now = Date.now() } = {}) {
324
+ refreshLandingRefs(root);
325
+ const { collectBoard, reap } = require('./land');
326
+ // light: this board only feeds .branches staleness + .summary; reap builds
327
+ // its own full board for dirty-count salvage decisions.
328
+ const beforeBoard = collectBoard(root, { ttlDays, staleHours, now, light: true });
329
+ const stale = beforeBoard.branches
330
+ .filter((b) => b.state === 'active' && b.stale)
331
+ .sort((a, b) => b.activityHours - a.activityHours)
332
+ .map((b) => ({
333
+ name: b.name,
334
+ ahead: b.ahead,
335
+ ageHours: b.ageHours,
336
+ activityHours: b.activityHours,
337
+ }));
338
+ const reaped = reap(root, {
339
+ ttlDays,
340
+ staleHours,
341
+ remote: false,
342
+ includeDetached: false,
343
+ now,
344
+ });
345
+ const afterBoard = collectBoard(root, { ttlDays, staleHours, now, light: true });
346
+ const human = [];
347
+ for (const kept of reaped.keptWorktrees || []) {
348
+ if (String(kept).includes('(fresh_worktree_grace)')) continue;
349
+ if (String(kept).includes('(current_checkout)')) continue;
350
+ human.push(String(kept));
351
+ }
352
+ for (const moved of reaped.keptMovedBranches || []) human.push(`${moved} moved during cleanup`);
353
+ if (reaped.bundleError) human.push(`backup failed: ${reaped.bundleError}`);
354
+ return {
355
+ at: new Date(now).toISOString(),
356
+ before: beforeBoard.summary,
357
+ after: afterBoard.summary,
358
+ stale,
359
+ human,
360
+ reaped: {
361
+ branches: reaped.deletedBranches.length,
362
+ worktrees: reaped.removedWorktrees.length,
363
+ bundle: reaped.bundle,
364
+ patches: reaped.patches.length,
365
+ untracked: (reaped.untracked || []).length,
366
+ bundleError: reaped.bundleError || null,
367
+ keptWorktrees: reaped.keptWorktrees || [],
368
+ keptMovedBranches: reaped.keptMovedBranches || [],
369
+ },
370
+ };
371
+ }
372
+
373
+ function recordLandingSweepState(state, sweep, { error = null } = {}) {
374
+ const at = sweep?.at || new Date().toISOString();
375
+ const today = at.slice(0, 10);
376
+ state.last_reap_date = today;
377
+ state.last_reap_at = at;
378
+ if (error) {
379
+ state.last_reap_error = { date: today, error };
380
+ delete state.landing_sweep;
381
+ return;
82
382
  }
383
+ delete state.last_reap_error;
384
+ state.landing_sweep = {
385
+ at,
386
+ before: sweep.before,
387
+ after: sweep.after,
388
+ reaped: sweep.reaped,
389
+ stale_count: sweep.stale.length,
390
+ stale: sweep.stale.slice(0, 10),
391
+ human_count: sweep.human.length,
392
+ human: sweep.human.slice(0, 10),
393
+ };
394
+ }
395
+
396
+ function wishSweepSummaryLine(summary, error = null) {
397
+ if (error) return `wishes: sweep failed (${error})`;
398
+ const dispatched = Number(summary?.dispatched) || 0;
399
+ const fulfilled = Number(summary?.fulfilled) || 0;
400
+ const waiting = Number(summary?.waiting_on_operator) || 0;
401
+ const noExecutor = Number(summary?.skipped_no_executor) || 0;
402
+ const capped = Number(summary?.capped) || 0;
403
+ let line = `wishes: ${dispatched} dispatched, ${waiting} waiting on operator, ${fulfilled} fulfilled`;
404
+ const notes = [];
405
+ if (noExecutor > 0) notes.push(`${noExecutor} need a working builder`);
406
+ if (capped > 0) notes.push(`${capped} held for the next tick`);
407
+ if (notes.length) line += ` (${notes.join(', ')})`;
408
+ return line;
409
+ }
410
+
411
+ function evaluateQueue(root, { strictVerify, acceptAll }) {
412
+ return readProjection(root)
413
+ .filter((task) => task && task.status === 'review')
414
+ .filter((task) => String(task.review?.approval_status || task.metadata?.approval_status || 'pending') === 'pending')
415
+ .sort((a, b) => Number(b.updated_at || 0) - Number(a.updated_at || 0))
416
+ .slice(0, 50)
417
+ .map((task) => {
418
+ const evaluation = evaluateAutoAccept(
419
+ { ...task, workspace_root: root },
420
+ { strictVerify, acceptAll, executeVerify: false },
421
+ );
422
+ return {
423
+ ...evaluation,
424
+ action: evaluation.eligible ? 'would_accept' : 'skipped',
425
+ };
426
+ });
427
+ }
428
+
429
+ function protectedReviewWaiting(root, policy = {}, waiting = []) {
430
+ const alreadyWaiting = new Set((waiting || []).map((row) => row.ref));
431
+ return evaluateQueue(root, {
432
+ strictVerify: policy.strict_verify !== false,
433
+ acceptAll: Boolean(policy.accept_all),
434
+ })
435
+ .filter((row) => /^denied_tag_/.test(String(row.reason || '')))
436
+ .filter((row) => !alreadyWaiting.has(row.ref));
83
437
  }
84
438
 
85
439
  function plainReason(reason) {
86
440
  const map = {
87
- denied_tag_billing: 'money — yours to approve',
88
- denied_tag_deploy: 'a deploy — yours to approve',
89
- denied_tag_security: 'security — yours to approve',
90
- denied_tag_customer: 'customer-facing — yours to approve',
91
- denied_tag_external: 'outward-facing — yours to approve',
92
- denied_tag_feedback: 'customer feedback — yours to approve',
93
- denied_tag_voice: 'voice/comms — yours to approve',
441
+ denied_tag_billing: 'money: human decision required',
442
+ denied_tag_deploy: 'a deploy: human decision required',
443
+ denied_tag_security: 'security: human decision required',
444
+ denied_tag_customer: 'customer-facing: human decision required',
445
+ denied_tag_external: 'outward-facing: human decision required',
446
+ denied_tag_feedback: 'customer feedback: human decision required',
94
447
  needs_second_reviewer_or_third_pass: 'needs one more independent check first',
448
+ needs_independent_reviewer: 'built and judged by the same actor, needs an independent check',
449
+ verifier_is_builder: 'the re-check actor built this row, another actor must re-check',
450
+ judge_equals_worker: 'built and judged by the same actor, hand the review to someone else',
95
451
  not_agent_certified: 'not certified yet',
96
452
  insufficient_review_passes: 'not enough review passes yet',
97
453
  strict_verify_missing: 'no recorded check command to re-run',
@@ -106,19 +462,25 @@ function showStatus(root, args) {
106
462
  const policy = autoland.readPolicy(root);
107
463
  const enabled = Boolean(policy && policy.enabled);
108
464
  const strictVerify = policy ? policy.strict_verify !== false : true;
109
- const results = evaluateQueue(root, { strictVerify })
465
+ const acceptAll = Boolean(policy && policy.accept_all);
466
+ const results = evaluateQueue(root, { strictVerify, acceptAll })
110
467
  .filter((r) => r.reason !== 'not_in_review');
111
- const wouldLand = results.filter((r) => r.action === 'would_accept');
468
+ const readyForRecheck = results.filter((r) => r.action === 'would_accept');
469
+ const wouldLand = readyForRecheck.filter((r) => r.verification_pending !== true);
112
470
  const blocked = results.filter((r) => r.action !== 'would_accept');
113
471
  const tasks = readProjection(root);
114
472
  const waiting = autoland.waitingOnHuman(tasks);
473
+ const heartbeatInstalled = typeof policy?.heartbeat_installed === 'boolean'
474
+ ? policy.heartbeat_installed
475
+ : null;
115
476
 
116
477
  if (json) {
117
478
  console.log(JSON.stringify({
118
479
  enabled,
119
480
  policy,
120
- heartbeat_installed: autoland.cronInstalled(root),
481
+ heartbeat_installed: heartbeatInstalled,
121
482
  would_land: wouldLand,
483
+ ready_for_recheck: readyForRecheck,
122
484
  blocked,
123
485
  waiting_on_human: waiting,
124
486
  }, null, 2));
@@ -126,27 +488,41 @@ function showStatus(root, args) {
126
488
  }
127
489
 
128
490
  console.log('');
129
- console.log(`autoland — certified work lands itself; you keep the irreversible calls`);
491
+ console.log('autoland: certified work lands itself; you keep the irreversible calls');
130
492
  console.log('');
131
- console.log(` policy: ${enabled ? `on (flipped by ${policy.enabled_by}${policy.enabled_at ? ` on ${String(policy.enabled_at).slice(0, 10)}` : ''})` : 'off — everything waits for you'}`);
132
- console.log(` heartbeat: ${autoland.cronInstalled(root) ? 'running hourly' : 'not installed'}`);
493
+ const policyOwner = String(policy?.enabled_by || 'unknown').trim() || 'unknown';
494
+ const policyText = enabled
495
+ ? (policy.default
496
+ ? `on by default for this workspace (no policy set; would accept as ${policyOwner}). set it: atris autoland on`
497
+ : `on (flipped by ${policyOwner}${policy.enabled_at ? ` on ${String(policy.enabled_at).slice(0, 10)}` : ''})`)
498
+ : 'off - everything waits for you';
499
+ console.log(` policy: ${policyText}`);
500
+ if (enabled && acceptAll) console.log(' bar: everything lands except the protected lanes (money, deploys, security, customer, outward)');
501
+ const heartbeatText = heartbeatInstalled === true
502
+ ? 'running hourly'
503
+ : heartbeatInstalled === false
504
+ ? 'not installed'
505
+ : 'unknown - run atris autoland on to check and repair';
506
+ console.log(` heartbeat: ${heartbeatText}`);
133
507
  if (policy && policy.imessage_to) console.log(` daily message: ${policy.imessage_to} at ${policy.digest_hour ?? autoland.DEFAULT_DIGEST_HOUR}:00`);
508
+ const reapTrouble = autoland.readState(root).last_reap_error;
509
+ if (reapTrouble) console.log(` cleanup trouble: landing sweep failed on ${reapTrouble.date} (${reapTrouble.error}) - run: atris land --reap`);
134
510
  console.log('');
135
- if (wouldLand.length > 0) {
136
- console.log(` ready to land on their own: ${wouldLand.length}`);
137
- for (const r of wouldLand.slice(0, 10)) console.log(` lands itself ${r.ref}`);
511
+ if (readyForRecheck.length > 0) {
512
+ console.log(` ready for heartbeat recheck: ${readyForRecheck.length}`);
513
+ for (const r of readyForRecheck.slice(0, 10)) console.log(` rechecks then lands ${r.ref}`);
138
514
  } else {
139
515
  console.log(' nothing is ready to land on its own right now.');
140
516
  }
141
517
  const humanOnly = blocked.filter((r) => String(r.reason || '').startsWith('denied_tag_'));
142
518
  const needsWork = blocked.filter((r) => !String(r.reason || '').startsWith('denied_tag_'));
143
519
  if (humanOnly.length > 0) {
144
- console.log(` yours to approve (protected lanes): ${humanOnly.length}`);
145
- for (const r of humanOnly.slice(0, 10)) console.log(` waits for you ${r.ref} — ${plainReason(r.reason)}`);
520
+ console.log(` protected reviews waiting on you: ${humanOnly.length}`);
521
+ for (const r of humanOnly.slice(0, 10)) console.log(` waits for you ${r.ref} - ${plainReason(r.reason)}`);
146
522
  }
147
523
  if (needsWork.length > 0) {
148
524
  console.log(` not ready yet: ${needsWork.length}`);
149
- for (const r of needsWork.slice(0, 10)) console.log(` held back ${r.ref} — ${plainReason(String(r.reason || ''))}`);
525
+ for (const r of needsWork.slice(0, 10)) console.log(` held back ${r.ref} - ${plainReason(String(r.reason || ''))}`);
150
526
  }
151
527
  if (waiting.length > 0) {
152
528
  console.log('');
@@ -163,6 +539,8 @@ function turnOn(root, args) {
163
539
  const to = flag(args, '--to', '');
164
540
  const digestHour = Number(flag(args, '--digest-hour', autoland.DEFAULT_DIGEST_HOUR));
165
541
  const alarmHours = Number(flag(args, '--alarm-hours', autoland.DEFAULT_ALARM_HOURS));
542
+ const acceptAll = args.includes('--all');
543
+ const previous = autoland.readPolicy(root);
166
544
  const policy = autoland.writePolicy(root, {
167
545
  enabled: true,
168
546
  enabled_by: owner,
@@ -171,17 +549,25 @@ function turnOn(root, args) {
171
549
  digest_hour: Number.isFinite(digestHour) ? digestHour : autoland.DEFAULT_DIGEST_HOUR,
172
550
  alarm_hours: Number.isFinite(alarmHours) && alarmHours > 0 ? alarmHours : autoland.DEFAULT_ALARM_HOURS,
173
551
  strict_verify: !args.includes('--no-strict-verify'),
552
+ accept_all: acceptAll,
553
+ daily_experiment: previous.daily_experiment !== false,
174
554
  });
175
555
  const cronOk = autoland.installCron(root);
556
+ autoland.writePolicy(root, {
557
+ ...policy,
558
+ heartbeat_installed: cronOk,
559
+ heartbeat_checked_at: new Date().toISOString(),
560
+ });
176
561
  console.log('');
177
562
  console.log('autoland is on.');
178
- console.log(` certified, verified, reversible work now lands itself, accepted as ${owner}.`);
563
+ if (acceptAll) console.log(` everything in review now lands itself, accepted as ${owner}; only the protected lanes wait.`);
564
+ else console.log(` certified, verified, reversible work now lands itself, accepted as ${owner}.`);
179
565
  console.log(' protected lanes (money, deploys, security, customer, outward) still wait for you.');
180
- console.log(` heartbeat: ${cronOk ? 'installed, runs hourly' : 'could not install cron — run atris autoland tick yourself'}`);
566
+ console.log(` heartbeat: ${cronOk ? 'installed, runs hourly' : 'could not install cron; run atris autoland tick yourself'}`);
181
567
  if (policy.imessage_to) {
182
568
  console.log(` daily message to ${policy.imessage_to} at ${policy.digest_hour}:00; anything waiting on you past ${policy.alarm_hours}h pings you.`);
183
569
  } else {
184
- console.log(' no phone number set — digest goes to the log only. add one: atris autoland on --to <your number>');
570
+ console.log(' no phone number set; digest goes to the log only. add one: atris autoland on --to <your number>');
185
571
  }
186
572
  console.log(' turn it off any time: atris autoland off');
187
573
  console.log('');
@@ -190,11 +576,20 @@ function turnOn(root, args) {
190
576
 
191
577
  function turnOff(root) {
192
578
  const policy = autoland.readPolicy(root) || {};
193
- autoland.writePolicy(root, { ...policy, enabled: false, disabled_at: new Date().toISOString() });
579
+ const disabledPolicy = autoland.writePolicy(root, {
580
+ ...policy,
581
+ enabled: false,
582
+ disabled_at: new Date().toISOString(),
583
+ });
194
584
  const cronOk = autoland.uninstallCron(root);
585
+ autoland.writePolicy(root, {
586
+ ...disabledPolicy,
587
+ heartbeat_installed: cronOk ? false : null,
588
+ heartbeat_checked_at: new Date().toISOString(),
589
+ });
195
590
  console.log('');
196
591
  console.log('autoland is off. everything waits for your accept again.');
197
- console.log(` heartbeat ${cronOk ? 'removed' : 'removal failed — check crontab -l'}.`);
592
+ console.log(` heartbeat ${cronOk ? 'removed' : 'removal failed; check crontab -l'}.`);
198
593
  console.log('');
199
594
  return 0;
200
595
  }
@@ -202,32 +597,39 @@ function turnOff(root) {
202
597
  function runDigest(root, args, { forceSend = false } = {}) {
203
598
  const policy = autoland.readPolicy(root) || {};
204
599
  const tasks = readProjection(root);
205
- const accepted = autoland.acceptedInLastDay(tasks);
600
+ const acceptedTasks = readAcceptedTaskHistory(root, tasks);
601
+ const accepted = autoland.acceptedInLastDay(acceptedTasks);
602
+ const state = autoland.readState(root);
603
+ let waitingWishes = [];
604
+ try {
605
+ waitingWishes = require('./wish').waitingOperatorWishes(root);
606
+ } catch {}
607
+ const waiting = autoland.waitingOnHuman(tasks);
206
608
  const text = autoland.composeDigest({
207
609
  accepted,
208
- waiting: autoland.waitingOnHuman(tasks),
610
+ waiting,
611
+ protectedWaiting: protectedReviewWaiting(root, policy, waiting),
612
+ waitingWishes,
209
613
  landed: landSummarySafe(root),
210
614
  project: projectName(root),
211
615
  nextMoves: digestNextMoves(root),
616
+ acceptAll: Boolean(policy.accept_all),
617
+ reapError: state.last_reap_error?.error || null,
618
+ landingSweep: state.landing_sweep || null,
619
+ fullStory: true,
620
+ root,
212
621
  });
213
622
  console.log(text);
214
623
  // the full story: what each piece actually was, in its own words
215
- const byRef = new Map(tasks.map((t) => [t.display_id || t.legacy_ref || t.id, t]));
216
- const storied = accepted.auto.filter((item) => {
217
- const t = byRef.get(item.ref);
218
- return t && (t.review?.landing?.happened || t.metadata?.landing_happened);
219
- });
624
+ const storied = digestStoryRows(accepted, acceptedTasks).slice(3);
220
625
  if (storied.length > 0) {
221
626
  let printedStoryHeader = false;
222
- for (const item of storied) {
223
- const t = byRef.get(item.ref);
224
- const happened = String(t.review?.landing?.happened || t.metadata?.landing_happened || '').replace(/\s+/g, ' ').slice(0, 160);
225
- if (!operatorReady(happened)) continue;
627
+ for (const { item, story } of storied) {
226
628
  if (!printedStoryHeader) {
227
629
  console.log('');
228
630
  printedStoryHeader = true;
229
631
  }
230
- console.log(` ${item.ref} ${happened}`);
632
+ console.log(` - ${story}`);
231
633
  }
232
634
  }
233
635
  const shouldSend = (forceSend || args.includes('--send')) && policy.imessage_to;
@@ -239,38 +641,104 @@ function runDigest(root, args, { forceSend = false } = {}) {
239
641
  return 0;
240
642
  }
241
643
 
644
+ function pidAlive(pid) {
645
+ try {
646
+ process.kill(Number(pid), 0);
647
+ return true;
648
+ } catch {
649
+ return false;
650
+ }
651
+ }
652
+
242
653
  function runTick(root, args) {
243
654
  const json = args.includes('--json');
244
655
  const policy = autoland.readPolicy(root);
245
- const receipt = { at: new Date().toISOString(), landed: [], alarms: 0, digest_sent: false, enabled: Boolean(policy && policy.enabled) };
656
+ const receipt = { at: new Date().toISOString(), landed: [], alarms: 0, digest_due: false, digest_sent: false, enabled: Boolean(policy && policy.enabled) };
246
657
  if (!policy || policy.enabled !== true) {
247
658
  if (json) console.log(JSON.stringify(receipt));
248
659
  else console.log('autoland is off; tick did nothing.');
249
660
  return 0;
250
661
  }
251
662
 
252
- // 1. certify what has executable proof — re-run the runnable check named in
253
- // each Review proof as a second actor. Without this the tick only lands rows
254
- // some always-on mission happened to certify, and everything else waits on a
255
- // human who never needed to look. Denied lanes and check-less proofs still wait.
256
- if (policy.drain_reviews !== false) {
257
- const certify = runOwnCli(root, ['task', 'certify-verified', '--json']);
258
- try {
259
- const parsed = JSON.parse(certify.stdout);
260
- receipt.reviews_certified = parsed.certified ?? 0;
261
- if (parsed.ok !== true) receipt.certify_error = 'certify-verified failed';
262
- } catch {
263
- receipt.certify_error = certify.stderr.slice(0, 200) || 'certify-verified output unreadable';
663
+ // One live tick at a time: verify re-runs can take minutes each, and a
664
+ // second hourly cron firing into the same snapshot double-accepts,
665
+ // double-alarms, and double-digests. A stale lock (dead pid or >55min)
666
+ // never wedges the loop.
667
+ const lockPath = path.join(root, '.atris', 'state', 'autoland.tick.lock');
668
+ try {
669
+ const prev = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
670
+ const fresh = Date.now() - Number(prev.at || 0) < 55 * 60 * 1000;
671
+ if (fresh && pidAlive(prev.pid)) {
672
+ receipt.skipped_reason = 'tick_already_running';
673
+ if (json) console.log(JSON.stringify(receipt));
674
+ else console.log('autoland tick: another tick is still running; skipped.');
675
+ return 0;
264
676
  }
677
+ } catch {}
678
+ try {
679
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
680
+ fs.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, at: Date.now() }));
681
+ } catch {}
682
+ try {
683
+ return runTickBody(root, { json, policy, receipt });
684
+ } finally {
685
+ try { fs.unlinkSync(lockPath); } catch {}
265
686
  }
687
+ }
688
+
689
+ function persistTickReceipt(root, receipt) {
690
+ const runsDir = path.join(root, 'atris', 'runs');
691
+ fs.mkdirSync(runsDir, { recursive: true });
692
+ const safeTime = String(receipt.at || new Date().toISOString()).replace(/[:.]/g, '-');
693
+ const receiptPath = path.join(runsDir, `autoland-tick-${safeTime}.json`);
694
+ receipt.receipt_path = path.relative(root, receiptPath);
695
+ fs.writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`, 'utf8');
696
+ return receipt.receipt_path;
697
+ }
266
698
 
267
- // 2. land what is eligible — the policy is the standing authorization
268
- const cliArgs = ['task', 'auto-accept-certified', '--json', '--limit', '12'];
269
- if (policy.strict_verify !== false) cliArgs.push('--strict-verify');
699
+ function digestTickStatus(receipt) {
700
+ if (receipt.digest_sent) return 'sent';
701
+ if (receipt.digest_due) return 'saved';
702
+ return 'not due';
703
+ }
704
+
705
+ function runTickBody(root, { json, policy, receipt }) {
706
+
707
+ // 1. certify and land in one task process. Keeping both phases together lets
708
+ // the landing gate reuse the live certification verifier result without
709
+ // persisting trust across heartbeats. Denied lanes and check-less proofs wait.
710
+ // No hardcoded --limit here: a fixed low cap (this used to be 12) silently
711
+ // undercounts a real backlog every single tick — 12/hour forever even with
712
+ // 78 certified rows waiting. Let `atris task auto-accept-certified` apply
713
+ // its own default (12 without --all, a high safety cap under --all) so a
714
+ // policy with accept_all:true actually drains the full certified set.
715
+ const cliArgs = ['task', 'auto-accept-certified', '--json', '--certify-first'];
716
+ if (policy.accept_all) cliArgs.push('--all');
717
+ else if (policy.strict_verify === false) cliArgs.push('--no-strict-verify');
270
718
  const accept = runOwnCli(root, cliArgs);
271
719
  try {
272
720
  const parsed = JSON.parse(accept.stdout);
273
- receipt.landed = (parsed.results || []).filter((r) => r.action === 'accepted').map((r) => r.ref);
721
+ const results = Array.isArray(parsed.results) ? parsed.results : [];
722
+ // A refused sweep (ok:false — a guard tripped, policy race) carries no
723
+ // summary fields. Name the reason instead of leaving nulls that read as
724
+ // "no work": a blind heartbeat must say WHY it is blind.
725
+ if (parsed.ok === false) {
726
+ receipt.accept_error = String(parsed.reason || 'auto-accept refused');
727
+ }
728
+ receipt.reviews_certified = parsed.certification?.certified ?? 0;
729
+ if (parsed.certification && parsed.certification.ok !== true) {
730
+ receipt.certify_error = 'certify-verified failed';
731
+ }
732
+ receipt.landed = results.filter((r) => r.action === 'accepted').map((r) => r.ref);
733
+ const citationBlocks = results
734
+ .filter((r) => r.action === 'skipped' && r.proof_state === 'suite_green_citation_required')
735
+ .map((r) => ({ ref: r.ref, reason: r.reason }));
736
+ if (citationBlocks.length) receipt.citation_blocks = citationBlocks;
737
+ receipt.certified = parsed.certified ?? null;
738
+ receipt.scanned = parsed.scanned ?? null;
739
+ receipt.revised = parsed.revised ?? null;
740
+ receipt.skipped = parsed.skipped ?? null;
741
+ receipt.undercounted = Boolean(parsed.undercounted);
274
742
  } catch (err) {
275
743
  receipt.accept_error = accept.stderr.slice(0, 200) || 'auto-accept output unreadable';
276
744
  }
@@ -284,8 +752,12 @@ function runTick(root, args) {
284
752
  landedRefs: receipt.landed,
285
753
  tasks: tasksForLive,
286
754
  project: projectName(root),
755
+ root,
287
756
  });
288
757
  if (text) {
758
+ // Keep the exact operator surface beside the delivery result. The next
759
+ // linguist pass must audit what was sent, not reconstruct today's code.
760
+ receipt.live_update_text = text;
289
761
  const sent = autoland.sendImessage(root, policy.imessage_to, text);
290
762
  receipt.live_update_sent = sent.ok;
291
763
  }
@@ -295,6 +767,7 @@ function runTick(root, args) {
295
767
  const state = autoland.readState(root);
296
768
  const tasks = readProjection(root);
297
769
  const waiting = autoland.waitingOnHuman(tasks);
770
+ const protectedWaiting = protectedReviewWaiting(root, policy, waiting);
298
771
  const alarmHours = Number(policy.alarm_hours) || autoland.DEFAULT_ALARM_HOURS;
299
772
  const due = autoland.dueForAlarm(waiting, state, { alarmHours });
300
773
  if (due.length > 0 && policy.imessage_to) {
@@ -306,16 +779,98 @@ function runTick(root, args) {
306
779
  }
307
780
  }
308
781
 
782
+ // 3b. janitor, every tick: a mission left paused past 48h and a worktree
783
+ // already merged into base are the same rot pattern as an unlanded branch —
784
+ // left alone they accumulate until a human runs `mission stop` and
785
+ // `worktree cleanup --apply` by hand, which is exactly the chore autoland
786
+ // exists to remove. Off switch: policy.janitor === false (default on, the
787
+ // same absent-means-on convention as live_updates/strict_verify). Counts
788
+ // accumulate in state so the daily digest reports the day's total, not
789
+ // just the last tick's.
790
+ receipt.missions_stopped = 0;
791
+ receipt.missions_held = 0;
792
+ receipt.worktrees_reaped = 0;
793
+ if (policy.janitor !== false) {
794
+ let stoppedMissions = [];
795
+ let heldMissions = [];
796
+ try {
797
+ const { reapPausedMissions } = require('./mission');
798
+ stoppedMissions = reapPausedMissions(root);
799
+ heldMissions = Array.isArray(stoppedMissions.held) ? stoppedMissions.held : [];
800
+ receipt.missions_stopped = stoppedMissions.length;
801
+ if (stoppedMissions.length > 0) receipt.missions_stopped_refs = stoppedMissions.map((m) => m.id);
802
+ if (stoppedMissions.length > 0) receipt.missions_stopped_reasons = missionReasonBreakdown(stoppedMissions, 'stale');
803
+ receipt.missions_held = heldMissions.length;
804
+ if (heldMissions.length > 0) {
805
+ receipt.missions_held_refs = heldMissions.map((m) => m.id);
806
+ receipt.missions_held_reasons = missionReasonBreakdown(heldMissions, 'operator-required');
807
+ }
808
+ } catch (err) {
809
+ receipt.janitor_mission_error = String((err && err.message) || err).slice(0, 200);
810
+ }
811
+ try {
812
+ const { cleanupWorktrees } = require('./worktree');
813
+ const cleaned = cleanupWorktrees({ root, apply: true });
814
+ receipt.worktrees_reaped = cleaned.removed.length;
815
+ if (cleaned.removed.length > 0) receipt.worktrees_reaped_paths = cleaned.removed.map((w) => w.path);
816
+ } catch (err) {
817
+ receipt.janitor_worktree_error = String((err && err.message) || err).slice(0, 200);
818
+ }
819
+ recordJanitorState(state, { stopped: stoppedMissions, held: heldMissions, worktrees: receipt.worktrees_reaped });
820
+ }
821
+
822
+ // 3c. landing sweep, every tick: merged branch residue is cleared,
823
+ // TTL-expired work is salvaged before deletion, and 48h-stale active work
824
+ // is carried into the digest instead of silently aging forever.
825
+ try {
826
+ const landingSweep = sweepLanding(root);
827
+ receipt.reaped = landingSweep.reaped;
828
+ receipt.landing_sweep = {
829
+ before: landingSweep.before,
830
+ after: landingSweep.after,
831
+ stale: landingSweep.stale.length,
832
+ human: landingSweep.human.length,
833
+ };
834
+ if (landingSweep.reaped.bundleError) {
835
+ receipt.reap_error = `backup failed, unlanded work kept in place: ${landingSweep.reaped.bundleError}`;
836
+ }
837
+ recordLandingSweepState(state, landingSweep);
838
+ } catch (err) {
839
+ receipt.reap_error = String((err && err.message) || err).slice(0, 200);
840
+ recordLandingSweepState(state, null, { error: receipt.reap_error });
841
+ }
842
+
843
+ // 3d. wish sweep: queued wishes are allowed to wake a mission without a
844
+ // foreground `atris wish` session, but they must never endanger landing.
845
+ try {
846
+ const wishDispatch = require('./wish').sweepWishes(root);
847
+ receipt.wish_dispatch = wishDispatch;
848
+ } catch (err) {
849
+ receipt.wish_dispatch_error = String((err && err.message) || err).slice(0, 200);
850
+ }
851
+
309
852
  // 4. daily digest at the configured hour
310
853
  const today = new Date().toISOString().slice(0, 10);
311
854
  const digestHour = Number(policy.digest_hour ?? autoland.DEFAULT_DIGEST_HOUR);
312
855
  if (new Date().getHours() === digestHour && state.last_digest_date !== today) {
856
+ receipt.digest_due = true;
857
+ let waitingWishes = [];
858
+ try {
859
+ waitingWishes = require('./wish').waitingOperatorWishes(root);
860
+ } catch {}
313
861
  const text = autoland.composeDigest({
314
- accepted: autoland.acceptedInLastDay(tasks),
862
+ accepted: autoland.acceptedInLastDay(readAcceptedTaskHistory(root, tasks)),
315
863
  waiting,
864
+ protectedWaiting,
865
+ waitingWishes,
316
866
  landed: landSummarySafe(root),
317
867
  project: projectName(root),
318
868
  nextMoves: digestNextMoves(root),
869
+ acceptAll: Boolean(policy.accept_all),
870
+ reapError: state.last_reap_error?.error || null,
871
+ janitor: state.janitor || null,
872
+ landingSweep: state.landing_sweep || null,
873
+ root,
319
874
  });
320
875
  if (policy.imessage_to) {
321
876
  const sent = autoland.sendImessage(root, policy.imessage_to, text);
@@ -323,6 +878,7 @@ function runTick(root, args) {
323
878
  }
324
879
  receipt.digest_text = text;
325
880
  state.last_digest_date = today;
881
+ delete state.janitor;
326
882
 
327
883
  // 5. once a day, keep the receipt shelf lean: compress old run receipts
328
884
  // into the manifest and drop unreferenced clutter, newest 200 kept.
@@ -334,27 +890,101 @@ function runTick(root, args) {
334
890
  receipt.receipts_pruned = null;
335
891
  }
336
892
  }
893
+
894
+ // 5b. daily keep/revert experiment — self-gated inside experiments daily;
895
+ // hourly ticks are harmless no-ops after the first run each day.
896
+ if (policy.daily_experiment !== false) {
897
+ const daily = runOwnCli(root, ['experiments', 'daily', '--json']);
898
+ receipt.daily_experiment = {
899
+ status: daily.status,
900
+ stdout: daily.stdout.trim(),
901
+ stderr: daily.stderr.slice(0, 200) || null,
902
+ };
903
+ try {
904
+ receipt.daily_experiment.result = JSON.parse(daily.stdout.trim());
905
+ } catch {
906
+ receipt.daily_experiment.result = null;
907
+ }
908
+ } else {
909
+ receipt.daily_experiment_skipped = true;
910
+ }
911
+
912
+ // 6. once a day, do slower mission cleanup. Landing cleanup runs hourly
913
+ // above, outside this date gate, so limbo cannot silently accumulate.
914
+ if (state.last_mission_cleanup_date !== today) {
915
+ // Missions rot the same way branches do: paused/planning/ready and
916
+ // untouched for a week means abandoned. Age them out under the same
917
+ // daily gate so `mission list` shows work, not archaeology.
918
+ try {
919
+ const { expireStaleMissions } = require('./mission');
920
+ const expiredMissions = expireStaleMissions(root);
921
+ const heldMissions = Array.isArray(expiredMissions.held) ? expiredMissions.held : [];
922
+ if (expiredMissions.length > 0) receipt.expired_missions = expiredMissions.length;
923
+ if (expiredMissions.length > 0) receipt.expired_mission_reasons = missionReasonBreakdown(expiredMissions, 'stale');
924
+ if (expiredMissions.length > 0) recordJanitorState(state, { stopped: expiredMissions });
925
+ if (heldMissions.length > 0) {
926
+ receipt.expired_missions_held = heldMissions.length;
927
+ receipt.expired_missions_held_refs = heldMissions.map((m) => m.id);
928
+ receipt.expired_missions_held_reasons = missionReasonBreakdown(heldMissions, 'operator-required');
929
+ recordJanitorState(state, { held: heldMissions });
930
+ }
931
+ } catch (err) {
932
+ receipt.mission_expiry_error = String((err && err.message) || err).slice(0, 200);
933
+ }
934
+ try {
935
+ const missionVerify = verifyClosedTaskMissions(root, readProjection(root));
936
+ if (missionVerify.verified.length > 0) receipt.verified_missions = missionVerify.verified;
937
+ if (missionVerify.errors.length > 0) receipt.mission_verify_errors = missionVerify.errors;
938
+ } catch (err) {
939
+ receipt.mission_verify_errors = [{ error: String((err && err.message) || err).slice(0, 200) }];
940
+ }
941
+ state.last_mission_cleanup_date = today;
942
+ }
337
943
  autoland.writeState(root, state);
338
944
 
945
+ try {
946
+ persistTickReceipt(root, receipt);
947
+ } catch (err) {
948
+ receipt.receipt_error = String((err && err.message) || err).slice(0, 200);
949
+ }
950
+
339
951
  if (json) console.log(JSON.stringify(receipt));
340
952
  else {
341
- console.log(`autoland tick: ${receipt.reviews_certified ?? 0} reviews certified, ${receipt.landed.length} landed${receipt.landed.length ? ` (${receipt.landed.join(', ')})` : ''}, ${receipt.alarms} alarms, digest ${receipt.digest_sent ? 'sent' : 'not due'}`);
953
+ const reapedTotal = Number(receipt.reaped?.branches || 0) + Number(receipt.reaped?.worktrees || 0);
954
+ const reapNote = reapedTotal > 0 ? `, reaped ${receipt.reaped.branches} landed/overdue branches` : '';
955
+ const janitorNote = `, janitor stopped ${receipt.missions_stopped} mission${receipt.missions_stopped === 1 ? '' : 's'} + reaped ${receipt.worktrees_reaped} worktree${receipt.worktrees_reaped === 1 ? '' : 's'}`;
956
+ const heldNote = receipt.missions_held ? `, held ${receipt.missions_held} human-blocked mission${receipt.missions_held === 1 ? '' : 's'}` : '';
957
+ const wishNote = `, ${wishSweepSummaryLine(receipt.wish_dispatch, receipt.wish_dispatch_error)}`;
958
+ const receiptNote = receipt.receipt_path ? `, receipt ${receipt.receipt_path}` : '';
959
+ const summary = `autoland tick: ${receipt.reviews_certified ?? 0} reviews certified, ${receipt.landed.length} landed${receipt.landed.length ? ` (${receipt.landed.join(', ')})` : ''}, ${receipt.alarms} alarms, digest ${digestTickStatus(receipt)}${reapNote}${janitorNote}${heldNote}${wishNote}${receiptNote}`;
960
+ console.log(gateForHuman(summary).text);
961
+ for (const fulfilled of receipt.wish_dispatch?.fulfilled_results || []) {
962
+ if (fulfilled.review_ask) console.log(fulfilled.review_ask);
963
+ }
342
964
  }
343
965
  return 0;
344
966
  }
345
967
 
346
968
  function showHelp() {
347
969
  console.log('');
348
- console.log('atris autoland — you approve the policy once; certified work lands itself');
970
+ console.log('atris autoland: you approve the policy once; certified work lands itself');
349
971
  console.log('');
350
972
  console.log('finished work that passed its checks and two independent reviews lands');
351
973
  console.log('automatically with a receipt. money, deploys, security, customer, and');
352
974
  console.log('outward-facing work always waits for you.');
353
975
  console.log('');
976
+ console.log('recommended agent flight checks:');
977
+ console.log(' fast suite: npm run test:fast');
978
+ console.log(' focused: node --test <focused files>');
979
+ console.log('record one runnable verifier; autoland reruns the recorded check.');
980
+ console.log('');
354
981
  console.log(' atris autoland what would land, what waits for you');
355
982
  console.log(' atris autoland on [--to <phone>] flip it on: hourly heartbeat, daily');
356
983
  console.log(' message, ping when something waits');
357
984
  console.log(' on you past 24h');
985
+ console.log(' atris autoland on --all lower the bar: everything lands except');
986
+ console.log(' the protected lanes; a failing recorded');
987
+ console.log(' check still blocks');
358
988
  console.log(' atris autoland off back to approving every item');
359
989
  console.log(' atris autoland digest [--send] the daily message, now');
360
990
  console.log(' atris autoland tick [--json] one heartbeat (what the hourly cron runs)');
@@ -382,4 +1012,20 @@ function autolandCommand(args = []) {
382
1012
  return 1;
383
1013
  }
384
1014
 
385
- module.exports = { autolandCommand, operatorReady, hasAgentJargon };
1015
+ module.exports = {
1016
+ autolandCommand,
1017
+ attachedTasksForMission,
1018
+ digestNextMoves,
1019
+ digestStoryRows,
1020
+ digestTickStatus,
1021
+ explainResult,
1022
+ missionReadyForClosedTaskVerify,
1023
+ operatorReady,
1024
+ readAcceptedTaskHistory,
1025
+ hasAgentJargon,
1026
+ runTickBody,
1027
+ persistTickReceipt,
1028
+ sweepLanding,
1029
+ verifyClosedTaskMissions,
1030
+ wishSweepSummaryLine,
1031
+ };