atris 3.35.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 (133) hide show
  1. package/AGENTS.md +37 -0
  2. package/README.md +5 -3
  3. package/atris/GETTING_STARTED.md +1 -1
  4. package/atris/atris.md +3 -0
  5. package/atris/policies/day-loop-voice.md +102 -0
  6. package/atris/policies/outbound-artifact-gate.md +2 -0
  7. package/atris/skills/design/SKILL.md +56 -32
  8. package/atris/skills/endgame/SKILL.md +12 -6
  9. package/atris/skills/engines/SKILL.md +22 -4
  10. package/atris/skills/fable-method/SKILL.md +66 -0
  11. package/atris/skills/improve/SKILL.md +65 -45
  12. package/atris/skills/youtube/SKILL.md +10 -1
  13. package/atris.md +2 -0
  14. package/ax +147 -19
  15. package/bin/atris.js +565 -265
  16. package/commands/activate.js +194 -88
  17. package/commands/agents.js +166 -0
  18. package/commands/autoland.js +459 -107
  19. package/commands/autopilot-front.js +20 -2
  20. package/commands/autopilot.js +118 -2
  21. package/commands/avail.js +407 -0
  22. package/commands/bench.js +188 -0
  23. package/commands/brain.js +3 -0
  24. package/commands/brief.js +651 -0
  25. package/commands/business-sync.js +192 -6
  26. package/commands/clean.js +50 -24
  27. package/commands/close.js +1083 -0
  28. package/commands/cloud.js +245 -0
  29. package/commands/compile.js +292 -1
  30. package/commands/computer.js +150 -3
  31. package/commands/dream.js +365 -0
  32. package/commands/drill.js +371 -0
  33. package/commands/engine.js +993 -32
  34. package/commands/experiments.js +28 -0
  35. package/commands/feedback.js +34 -12
  36. package/commands/fleet-report.js +206 -0
  37. package/commands/gm.js +23 -0
  38. package/commands/goal.js +247 -0
  39. package/commands/improve.js +642 -26
  40. package/commands/init.js +72 -44
  41. package/commands/interview.js +67 -1
  42. package/commands/land.js +152 -52
  43. package/commands/lifecycle.js +39 -3
  44. package/commands/log.js +84 -1
  45. package/commands/loops.js +220 -16
  46. package/commands/meet.js +220 -0
  47. package/commands/member.js +511 -34
  48. package/commands/mission.js +3029 -339
  49. package/commands/next.js +137 -0
  50. package/commands/now.js +220 -25
  51. package/commands/one-lap.js +776 -0
  52. package/commands/orb.js +314 -0
  53. package/commands/pack-craft.js +179 -0
  54. package/commands/pack.js +823 -0
  55. package/commands/play.js +3 -2
  56. package/commands/probe.js +30 -3
  57. package/commands/pulse.js +241 -46
  58. package/commands/push.js +260 -82
  59. package/commands/rainmaker.js +49 -0
  60. package/commands/report.js +415 -0
  61. package/commands/scout.js +147 -0
  62. package/commands/search.js +363 -0
  63. package/commands/skill.js +47 -3
  64. package/commands/slop.js +50 -2
  65. package/commands/soul.js +1 -1
  66. package/commands/stream.js +861 -0
  67. package/commands/study.js +693 -0
  68. package/commands/sync.js +67 -54
  69. package/commands/task.js +1346 -117
  70. package/commands/team.js +73 -0
  71. package/commands/verify.js +96 -0
  72. package/commands/watch.js +303 -0
  73. package/commands/wish.js +500 -0
  74. package/commands/workflow.js +11 -5
  75. package/commands/worktree.js +234 -13
  76. package/commands/xp.js +29 -11
  77. package/lib/auto-accept-certified.js +331 -34
  78. package/lib/autoland.js +319 -54
  79. package/lib/ax-auto-lane.js +79 -0
  80. package/lib/bench/context.js +147 -0
  81. package/lib/bench/engines.js +141 -0
  82. package/lib/bench/report.js +140 -0
  83. package/lib/bench/runner.js +512 -0
  84. package/lib/brief-ledger.js +350 -0
  85. package/lib/cloud-mission.js +259 -0
  86. package/lib/codex-flight.js +154 -0
  87. package/lib/default-runner.js +45 -0
  88. package/lib/default-verifier.js +70 -0
  89. package/lib/engine-registry.js +232 -0
  90. package/lib/experiments/daily.js +640 -0
  91. package/lib/fleet.js +2219 -67
  92. package/lib/improve-vitals-html.js +171 -0
  93. package/lib/known-commands.js +58 -0
  94. package/lib/loop-doctor.js +416 -0
  95. package/lib/member-switches.js +144 -0
  96. package/lib/mission-room.js +1 -0
  97. package/lib/mission-root.js +52 -0
  98. package/lib/next-moves.js +327 -10
  99. package/lib/one-lap-validator.js +60 -0
  100. package/lib/orb-context.js +477 -0
  101. package/lib/orb-scorecard.js +224 -0
  102. package/lib/policy-lessons.js +52 -1
  103. package/lib/pulse.js +277 -3
  104. package/lib/receipt-block.js +168 -0
  105. package/lib/receipt-evidence.js +65 -4
  106. package/lib/router-brain.js +352 -0
  107. package/lib/runner-command.js +10 -0
  108. package/lib/self-drive.js +258 -0
  109. package/lib/short-name.js +103 -0
  110. package/lib/spawn-env.js +18 -0
  111. package/lib/state-detection.js +56 -1
  112. package/lib/sync-status.js +59 -0
  113. package/lib/task-db.js +108 -29
  114. package/lib/task-proof.js +23 -1
  115. package/lib/team-presence.js +260 -0
  116. package/lib/tool-result-encode.js +7 -0
  117. package/lib/trust-tiers.js +90 -0
  118. package/lib/usage.js +107 -0
  119. package/lib/voice-gate.js +163 -0
  120. package/lib/wish-audit.js +1368 -0
  121. package/lib/wish-delegate.js +1840 -0
  122. package/lib/wish-design.js +110 -0
  123. package/lib/wish-stats.js +183 -0
  124. package/lib/wish-store.js +354 -0
  125. package/lib/zip.js +221 -0
  126. package/package.json +3 -1
  127. package/templates/loops/atris/loops/LOOPS.md +55 -0
  128. package/templates/loops/atris/loops/TICK.md +24 -0
  129. package/templates/loops/atris/loops/feedback.md +22 -0
  130. package/templates/loops/atris/loops/quality.md +22 -0
  131. package/templates/loops/atris/wiki/systems/loops.md +41 -0
  132. package/utils/api.js +5 -1
  133. package/utils/auth.js +57 -21
@@ -6,7 +6,9 @@ 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;
10
12
  const MISSION_AUTO_VERIFY_STATUSES = new Set(['planning', 'paused', 'ready']);
11
13
  const CLOSED_TASK_STATUSES = new Set(['done', 'archived']);
12
14
  const MAX_MISSION_AUTO_VERIFY_PER_TICK = 3;
@@ -21,20 +23,100 @@ function projectName(root) {
21
23
  return path.basename(root);
22
24
  }
23
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
+
24
75
  // The digest's "next, if you agree" section: top candidate moves from Atris
25
76
  // state, each with the member best suited to own it. Moves that can't explain
26
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
+
27
90
  function digestNextMoves(root) {
28
91
  try {
29
92
  const { nextMoves } = require('../lib/next-moves');
30
93
  const { resolveFunctionalOwner } = require('../lib/functional-owner');
31
- const all = (nextMoves(root, 5) || []).filter((move) => move && move.title);
32
- 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) => {
33
111
  let owner = null;
34
- try { owner = resolveFunctionalOwner({ title: move.title, root })?.owner || null; } catch {}
35
- 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 };
36
118
  });
37
- return { moves: ready, unexplained: all.length - ready.length };
119
+ return { moves: ready, unexplained: all.length - explainable.length };
38
120
  } catch {
39
121
  return { moves: [], unexplained: 0 };
40
122
  }
@@ -65,6 +147,34 @@ function readProjection(root) {
65
147
  }
66
148
  }
67
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
+
68
178
  function compactId(value) {
69
179
  return String(value || '').trim();
70
180
  }
@@ -197,28 +307,143 @@ function landSummarySafe(root) {
197
307
  }
198
308
  }
199
309
 
200
- function evaluateQueue(root, { strictVerify, acceptAll }) {
201
- const cliArgs = ['task', 'auto-accept-certified', '--dry-run', '--json', '--limit', '50'];
202
- if (acceptAll) cliArgs.push('--all');
203
- else if (strictVerify === false) cliArgs.push('--no-strict-verify');
204
- const result = runOwnCli(root, cliArgs);
205
- try {
206
- const parsed = JSON.parse(result.stdout);
207
- return Array.isArray(parsed.results) ? parsed.results : [];
208
- } catch (err) {
209
- 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;
210
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));
211
437
  }
212
438
 
213
439
  function plainReason(reason) {
214
440
  const map = {
215
- denied_tag_billing: 'money — yours to approve',
216
- denied_tag_deploy: 'a deploy — yours to approve',
217
- denied_tag_security: 'security — yours to approve',
218
- denied_tag_customer: 'customer-facing — yours to approve',
219
- denied_tag_external: 'outward-facing — yours to approve',
220
- denied_tag_feedback: 'customer feedback — yours to approve',
221
- 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',
222
447
  needs_second_reviewer_or_third_pass: 'needs one more independent check first',
223
448
  needs_independent_reviewer: 'built and judged by the same actor, needs an independent check',
224
449
  verifier_is_builder: 'the re-check actor built this row, another actor must re-check',
@@ -240,17 +465,22 @@ function showStatus(root, args) {
240
465
  const acceptAll = Boolean(policy && policy.accept_all);
241
466
  const results = evaluateQueue(root, { strictVerify, acceptAll })
242
467
  .filter((r) => r.reason !== 'not_in_review');
243
- 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);
244
470
  const blocked = results.filter((r) => r.action !== 'would_accept');
245
471
  const tasks = readProjection(root);
246
472
  const waiting = autoland.waitingOnHuman(tasks);
473
+ const heartbeatInstalled = typeof policy?.heartbeat_installed === 'boolean'
474
+ ? policy.heartbeat_installed
475
+ : null;
247
476
 
248
477
  if (json) {
249
478
  console.log(JSON.stringify({
250
479
  enabled,
251
480
  policy,
252
- heartbeat_installed: autoland.cronInstalled(root),
481
+ heartbeat_installed: heartbeatInstalled,
253
482
  would_land: wouldLand,
483
+ ready_for_recheck: readyForRecheck,
254
484
  blocked,
255
485
  waiting_on_human: waiting,
256
486
  }, null, 2));
@@ -258,7 +488,7 @@ function showStatus(root, args) {
258
488
  }
259
489
 
260
490
  console.log('');
261
- 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');
262
492
  console.log('');
263
493
  const policyOwner = String(policy?.enabled_by || 'unknown').trim() || 'unknown';
264
494
  const policyText = enabled
@@ -268,26 +498,31 @@ function showStatus(root, args) {
268
498
  : 'off - everything waits for you';
269
499
  console.log(` policy: ${policyText}`);
270
500
  if (enabled && acceptAll) console.log(' bar: everything lands except the protected lanes (money, deploys, security, customer, outward)');
271
- console.log(` heartbeat: ${autoland.cronInstalled(root) ? 'running hourly' : 'not installed'}`);
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}`);
272
507
  if (policy && policy.imessage_to) console.log(` daily message: ${policy.imessage_to} at ${policy.digest_hour ?? autoland.DEFAULT_DIGEST_HOUR}:00`);
273
508
  const reapTrouble = autoland.readState(root).last_reap_error;
274
- if (reapTrouble) console.log(` cleanup trouble: daily sweep failed on ${reapTrouble.date} (${reapTrouble.error}) — run: atris land --reap`);
509
+ if (reapTrouble) console.log(` cleanup trouble: landing sweep failed on ${reapTrouble.date} (${reapTrouble.error}) - run: atris land --reap`);
275
510
  console.log('');
276
- if (wouldLand.length > 0) {
277
- console.log(` ready to land on their own: ${wouldLand.length}`);
278
- 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}`);
279
514
  } else {
280
515
  console.log(' nothing is ready to land on its own right now.');
281
516
  }
282
517
  const humanOnly = blocked.filter((r) => String(r.reason || '').startsWith('denied_tag_'));
283
518
  const needsWork = blocked.filter((r) => !String(r.reason || '').startsWith('denied_tag_'));
284
519
  if (humanOnly.length > 0) {
285
- console.log(` yours to approve (protected lanes): ${humanOnly.length}`);
286
- 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)}`);
287
522
  }
288
523
  if (needsWork.length > 0) {
289
524
  console.log(` not ready yet: ${needsWork.length}`);
290
- 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 || ''))}`);
291
526
  }
292
527
  if (waiting.length > 0) {
293
528
  console.log('');
@@ -305,6 +540,7 @@ function turnOn(root, args) {
305
540
  const digestHour = Number(flag(args, '--digest-hour', autoland.DEFAULT_DIGEST_HOUR));
306
541
  const alarmHours = Number(flag(args, '--alarm-hours', autoland.DEFAULT_ALARM_HOURS));
307
542
  const acceptAll = args.includes('--all');
543
+ const previous = autoland.readPolicy(root);
308
544
  const policy = autoland.writePolicy(root, {
309
545
  enabled: true,
310
546
  enabled_by: owner,
@@ -314,18 +550,24 @@ function turnOn(root, args) {
314
550
  alarm_hours: Number.isFinite(alarmHours) && alarmHours > 0 ? alarmHours : autoland.DEFAULT_ALARM_HOURS,
315
551
  strict_verify: !args.includes('--no-strict-verify'),
316
552
  accept_all: acceptAll,
553
+ daily_experiment: previous.daily_experiment !== false,
317
554
  });
318
555
  const cronOk = autoland.installCron(root);
556
+ autoland.writePolicy(root, {
557
+ ...policy,
558
+ heartbeat_installed: cronOk,
559
+ heartbeat_checked_at: new Date().toISOString(),
560
+ });
319
561
  console.log('');
320
562
  console.log('autoland is on.');
321
- if (acceptAll) console.log(` everything in review now lands itself, accepted as ${owner} — only the protected lanes wait.`);
563
+ if (acceptAll) console.log(` everything in review now lands itself, accepted as ${owner}; only the protected lanes wait.`);
322
564
  else console.log(` certified, verified, reversible work now lands itself, accepted as ${owner}.`);
323
565
  console.log(' protected lanes (money, deploys, security, customer, outward) still wait for you.');
324
- 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'}`);
325
567
  if (policy.imessage_to) {
326
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.`);
327
569
  } else {
328
- 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>');
329
571
  }
330
572
  console.log(' turn it off any time: atris autoland off');
331
573
  console.log('');
@@ -334,11 +576,20 @@ function turnOn(root, args) {
334
576
 
335
577
  function turnOff(root) {
336
578
  const policy = autoland.readPolicy(root) || {};
337
- 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
+ });
338
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
+ });
339
590
  console.log('');
340
591
  console.log('autoland is off. everything waits for your accept again.');
341
- console.log(` heartbeat ${cronOk ? 'removed' : 'removal failed — check crontab -l'}.`);
592
+ console.log(` heartbeat ${cronOk ? 'removed' : 'removal failed; check crontab -l'}.`);
342
593
  console.log('');
343
594
  return 0;
344
595
  }
@@ -346,34 +597,39 @@ function turnOff(root) {
346
597
  function runDigest(root, args, { forceSend = false } = {}) {
347
598
  const policy = autoland.readPolicy(root) || {};
348
599
  const tasks = readProjection(root);
349
- 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);
350
608
  const text = autoland.composeDigest({
351
609
  accepted,
352
- waiting: autoland.waitingOnHuman(tasks),
610
+ waiting,
611
+ protectedWaiting: protectedReviewWaiting(root, policy, waiting),
612
+ waitingWishes,
353
613
  landed: landSummarySafe(root),
354
614
  project: projectName(root),
355
615
  nextMoves: digestNextMoves(root),
356
616
  acceptAll: Boolean(policy.accept_all),
357
- reapError: autoland.readState(root).last_reap_error?.error || null,
617
+ reapError: state.last_reap_error?.error || null,
618
+ landingSweep: state.landing_sweep || null,
619
+ fullStory: true,
620
+ root,
358
621
  });
359
622
  console.log(text);
360
623
  // the full story: what each piece actually was, in its own words
361
- const byRef = new Map(tasks.map((t) => [t.display_id || t.legacy_ref || t.id, t]));
362
- const storied = accepted.auto.filter((item) => {
363
- const t = byRef.get(item.ref);
364
- return t && (t.review?.landing?.happened || t.metadata?.landing_happened);
365
- });
624
+ const storied = digestStoryRows(accepted, acceptedTasks).slice(3);
366
625
  if (storied.length > 0) {
367
626
  let printedStoryHeader = false;
368
- for (const item of storied) {
369
- const t = byRef.get(item.ref);
370
- const happened = String(t.review?.landing?.happened || t.metadata?.landing_happened || '').replace(/\s+/g, ' ').slice(0, 160);
371
- if (!operatorReady(happened)) continue;
627
+ for (const { item, story } of storied) {
372
628
  if (!printedStoryHeader) {
373
629
  console.log('');
374
630
  printedStoryHeader = true;
375
631
  }
376
- console.log(` ${item.ref} ${happened}`);
632
+ console.log(` - ${story}`);
377
633
  }
378
634
  }
379
635
  const shouldSend = (forceSend || args.includes('--send')) && policy.imessage_to;
@@ -397,7 +653,7 @@ function pidAlive(pid) {
397
653
  function runTick(root, args) {
398
654
  const json = args.includes('--json');
399
655
  const policy = autoland.readPolicy(root);
400
- 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) };
401
657
  if (!policy || policy.enabled !== true) {
402
658
  if (json) console.log(JSON.stringify(receipt));
403
659
  else console.log('autoland is off; tick did nothing.');
@@ -430,42 +686,57 @@ function runTick(root, args) {
430
686
  }
431
687
  }
432
688
 
433
- function runTickBody(root, { json, policy, receipt }) {
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
+ }
434
698
 
435
- // 1. certify what has executable proof — re-run the runnable check named in
436
- // each Review proof as a second actor. Without this the tick only lands rows
437
- // some always-on mission happened to certify, and everything else waits on a
438
- // human who never needed to look. Denied lanes and check-less proofs still wait.
439
- const certify = runOwnCli(root, ['task', 'certify-verified', '--json']);
440
- try {
441
- const parsed = JSON.parse(certify.stdout);
442
- receipt.reviews_certified = parsed.certified ?? 0;
443
- if (parsed.ok !== true) receipt.certify_error = 'certify-verified failed';
444
- } catch {
445
- receipt.certify_error = certify.stderr.slice(0, 200) || 'certify-verified output unreadable';
446
- }
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 }) {
447
706
 
448
- // 2. land what is eligible — the policy is the standing authorization.
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.
449
710
  // No hardcoded --limit here: a fixed low cap (this used to be 12) silently
450
711
  // undercounts a real backlog every single tick — 12/hour forever even with
451
712
  // 78 certified rows waiting. Let `atris task auto-accept-certified` apply
452
713
  // its own default (12 without --all, a high safety cap under --all) so a
453
714
  // policy with accept_all:true actually drains the full certified set.
454
- const cliArgs = ['task', 'auto-accept-certified', '--json'];
715
+ const cliArgs = ['task', 'auto-accept-certified', '--json', '--certify-first'];
455
716
  if (policy.accept_all) cliArgs.push('--all');
456
717
  else if (policy.strict_verify === false) cliArgs.push('--no-strict-verify');
457
718
  const accept = runOwnCli(root, cliArgs);
458
719
  try {
459
720
  const parsed = JSON.parse(accept.stdout);
721
+ const results = Array.isArray(parsed.results) ? parsed.results : [];
460
722
  // A refused sweep (ok:false — a guard tripped, policy race) carries no
461
723
  // summary fields. Name the reason instead of leaving nulls that read as
462
724
  // "no work": a blind heartbeat must say WHY it is blind.
463
725
  if (parsed.ok === false) {
464
726
  receipt.accept_error = String(parsed.reason || 'auto-accept refused');
465
727
  }
466
- receipt.landed = (parsed.results || []).filter((r) => r.action === 'accepted').map((r) => r.ref);
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;
467
737
  receipt.certified = parsed.certified ?? null;
468
738
  receipt.scanned = parsed.scanned ?? null;
739
+ receipt.revised = parsed.revised ?? null;
469
740
  receipt.skipped = parsed.skipped ?? null;
470
741
  receipt.undercounted = Boolean(parsed.undercounted);
471
742
  } catch (err) {
@@ -481,8 +752,12 @@ function runTickBody(root, { json, policy, receipt }) {
481
752
  landedRefs: receipt.landed,
482
753
  tasks: tasksForLive,
483
754
  project: projectName(root),
755
+ root,
484
756
  });
485
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;
486
761
  const sent = autoland.sendImessage(root, policy.imessage_to, text);
487
762
  receipt.live_update_sent = sent.ok;
488
763
  }
@@ -492,6 +767,7 @@ function runTickBody(root, { json, policy, receipt }) {
492
767
  const state = autoland.readState(root);
493
768
  const tasks = readProjection(root);
494
769
  const waiting = autoland.waitingOnHuman(tasks);
770
+ const protectedWaiting = protectedReviewWaiting(root, policy, waiting);
495
771
  const alarmHours = Number(policy.alarm_hours) || autoland.DEFAULT_ALARM_HOURS;
496
772
  const due = autoland.dueForAlarm(waiting, state, { alarmHours });
497
773
  if (due.length > 0 && policy.imessage_to) {
@@ -512,13 +788,23 @@ function runTickBody(root, { json, policy, receipt }) {
512
788
  // accumulate in state so the daily digest reports the day's total, not
513
789
  // just the last tick's.
514
790
  receipt.missions_stopped = 0;
791
+ receipt.missions_held = 0;
515
792
  receipt.worktrees_reaped = 0;
516
793
  if (policy.janitor !== false) {
794
+ let stoppedMissions = [];
795
+ let heldMissions = [];
517
796
  try {
518
797
  const { reapPausedMissions } = require('./mission');
519
- const stopped = reapPausedMissions(root);
520
- receipt.missions_stopped = stopped.length;
521
- if (stopped.length > 0) receipt.missions_stopped_refs = stopped.map((m) => m.id);
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
+ }
522
808
  } catch (err) {
523
809
  receipt.janitor_mission_error = String((err && err.message) || err).slice(0, 200);
524
810
  }
@@ -530,28 +816,61 @@ function runTickBody(root, { json, policy, receipt }) {
530
816
  } catch (err) {
531
817
  receipt.janitor_worktree_error = String((err && err.message) || err).slice(0, 200);
532
818
  }
533
- if (receipt.missions_stopped || receipt.worktrees_reaped) {
534
- const tally = state.janitor && typeof state.janitor === 'object' ? state.janitor : {};
535
- state.janitor = {
536
- missions_stopped: (Number(tally.missions_stopped) || 0) + receipt.missions_stopped,
537
- worktrees_reaped: (Number(tally.worktrees_reaped) || 0) + receipt.worktrees_reaped,
538
- };
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}`;
539
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);
540
850
  }
541
851
 
542
852
  // 4. daily digest at the configured hour
543
853
  const today = new Date().toISOString().slice(0, 10);
544
854
  const digestHour = Number(policy.digest_hour ?? autoland.DEFAULT_DIGEST_HOUR);
545
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 {}
546
861
  const text = autoland.composeDigest({
547
- accepted: autoland.acceptedInLastDay(tasks),
862
+ accepted: autoland.acceptedInLastDay(readAcceptedTaskHistory(root, tasks)),
548
863
  waiting,
864
+ protectedWaiting,
865
+ waitingWishes,
549
866
  landed: landSummarySafe(root),
550
867
  project: projectName(root),
551
868
  nextMoves: digestNextMoves(root),
552
869
  acceptAll: Boolean(policy.accept_all),
553
870
  reapError: state.last_reap_error?.error || null,
554
871
  janitor: state.janitor || null,
872
+ landingSweep: state.landing_sweep || null,
873
+ root,
555
874
  });
556
875
  if (policy.imessage_to) {
557
876
  const sent = autoland.sendImessage(root, policy.imessage_to, text);
@@ -571,34 +890,44 @@ function runTickBody(root, { json, policy, receipt }) {
571
890
  receipt.receipts_pruned = null;
572
891
  }
573
892
  }
574
- // 6. once a day, drain the landing itself: back up (bundle + patches into
575
- // .atris/salvage/) then clear branches already landed or past TTL, and
576
- // their worktrees. Local-only — remote branches may back open PRs, and
577
- // closing those is a human call. Its own date gate, not the digest hour,
578
- // so a machine asleep at digest time still drains on its next tick.
579
- // Without this the board grows until a human runs `atris land --reap`,
580
- // which is exactly the chore autoland exists to remove.
581
- if (state.last_reap_date !== today) {
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
+ };
582
903
  try {
583
- const { reap } = require('./land');
584
- const reaped = reap(root, { remote: false, includeDetached: false });
585
- receipt.reaped = {
586
- branches: reaped.deletedBranches.length,
587
- worktrees: reaped.removedWorktrees.length,
588
- bundle: reaped.bundle,
589
- patches: reaped.patches.length,
590
- };
591
- if (reaped.bundleError) receipt.reap_error = `backup failed, unlanded work kept in place: ${reaped.bundleError}`;
592
- } catch (err) {
593
- receipt.reap_error = String((err && err.message) || err).slice(0, 200);
904
+ receipt.daily_experiment.result = JSON.parse(daily.stdout.trim());
905
+ } catch {
906
+ receipt.daily_experiment.result = null;
594
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) {
595
915
  // Missions rot the same way branches do: paused/planning/ready and
596
916
  // untouched for a week means abandoned. Age them out under the same
597
917
  // daily gate so `mission list` shows work, not archaeology.
598
918
  try {
599
919
  const { expireStaleMissions } = require('./mission');
600
920
  const expiredMissions = expireStaleMissions(root);
921
+ const heldMissions = Array.isArray(expiredMissions.held) ? expiredMissions.held : [];
601
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
+ }
602
931
  } catch (err) {
603
932
  receipt.mission_expiry_error = String((err && err.message) || err).slice(0, 200);
604
933
  }
@@ -609,32 +938,46 @@ function runTickBody(root, { json, policy, receipt }) {
609
938
  } catch (err) {
610
939
  receipt.mission_verify_errors = [{ error: String((err && err.message) || err).slice(0, 200) }];
611
940
  }
612
- state.last_reap_date = today;
613
- // a failed sweep must not be a secret: status and the next digest carry
614
- // it until a sweep succeeds. The date gate above still holds so a broken
615
- // repo errors once a day, not hourly.
616
- if (receipt.reap_error) state.last_reap_error = { date: today, error: receipt.reap_error };
617
- else delete state.last_reap_error;
941
+ state.last_mission_cleanup_date = today;
618
942
  }
619
943
  autoland.writeState(root, state);
620
944
 
945
+ try {
946
+ persistTickReceipt(root, receipt);
947
+ } catch (err) {
948
+ receipt.receipt_error = String((err && err.message) || err).slice(0, 200);
949
+ }
950
+
621
951
  if (json) console.log(JSON.stringify(receipt));
622
952
  else {
623
- const reapNote = receipt.reaped ? `, reaped ${receipt.reaped.branches} landed/overdue branches` : '';
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` : '';
624
955
  const janitorNote = `, janitor stopped ${receipt.missions_stopped} mission${receipt.missions_stopped === 1 ? '' : 's'} + reaped ${receipt.worktrees_reaped} worktree${receipt.worktrees_reaped === 1 ? '' : 's'}`;
625
- 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'}${reapNote}${janitorNote}`);
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
+ }
626
964
  }
627
965
  return 0;
628
966
  }
629
967
 
630
968
  function showHelp() {
631
969
  console.log('');
632
- 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');
633
971
  console.log('');
634
972
  console.log('finished work that passed its checks and two independent reviews lands');
635
973
  console.log('automatically with a receipt. money, deploys, security, customer, and');
636
974
  console.log('outward-facing work always waits for you.');
637
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('');
638
981
  console.log(' atris autoland what would land, what waits for you');
639
982
  console.log(' atris autoland on [--to <phone>] flip it on: hourly heartbeat, daily');
640
983
  console.log(' message, ping when something waits');
@@ -672,8 +1015,17 @@ function autolandCommand(args = []) {
672
1015
  module.exports = {
673
1016
  autolandCommand,
674
1017
  attachedTasksForMission,
1018
+ digestNextMoves,
1019
+ digestStoryRows,
1020
+ digestTickStatus,
1021
+ explainResult,
675
1022
  missionReadyForClosedTaskVerify,
676
1023
  operatorReady,
1024
+ readAcceptedTaskHistory,
677
1025
  hasAgentJargon,
1026
+ runTickBody,
1027
+ persistTickReceipt,
1028
+ sweepLanding,
678
1029
  verifyClosedTaskMissions,
1030
+ wishSweepSummaryLine,
679
1031
  };