atris 3.34.0 → 3.35.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 (53) hide show
  1. package/AGENTS.md +0 -2
  2. package/FOR_AGENTS.md +5 -3
  3. package/atris/skills/engines/SKILL.md +16 -7
  4. package/atris/skills/render-cli/SKILL.md +88 -0
  5. package/atris.md +4 -2
  6. package/ax +475 -17
  7. package/bin/atris.js +197 -44
  8. package/commands/aeo.js +52 -0
  9. package/commands/autoland.js +313 -19
  10. package/commands/business.js +91 -8
  11. package/commands/codex-goal.js +26 -2
  12. package/commands/drive.js +187 -0
  13. package/commands/engine.js +73 -2
  14. package/commands/feed.js +202 -0
  15. package/commands/github.js +38 -0
  16. package/commands/gm.js +262 -3
  17. package/commands/init.js +13 -1
  18. package/commands/integrations.js +39 -11
  19. package/commands/interview.js +143 -0
  20. package/commands/land.js +114 -13
  21. package/commands/lesson.js +112 -1
  22. package/commands/linear.js +38 -0
  23. package/commands/member.js +398 -42
  24. package/commands/mission.js +554 -64
  25. package/commands/now.js +25 -1
  26. package/commands/radar.js +259 -14
  27. package/commands/serve.js +54 -0
  28. package/commands/status.js +50 -5
  29. package/commands/stripe.js +38 -0
  30. package/commands/supabase.js +39 -0
  31. package/commands/task.js +935 -71
  32. package/commands/truth.js +29 -3
  33. package/commands/unknowns.js +627 -0
  34. package/commands/update.js +44 -0
  35. package/commands/vercel.js +38 -0
  36. package/commands/worktree.js +68 -10
  37. package/commands/write.js +399 -0
  38. package/lib/auto-accept-certified.js +70 -19
  39. package/lib/autoland.js +39 -3
  40. package/lib/fleet.js +250 -9
  41. package/lib/memory-view.js +14 -5
  42. package/lib/mission-runtime-loop.js +7 -0
  43. package/lib/official-cli-integration.js +174 -0
  44. package/lib/outbound-send-gate.js +165 -0
  45. package/lib/permission-grants.js +293 -0
  46. package/lib/review-integrity.js +147 -0
  47. package/lib/runner-command.js +23 -0
  48. package/lib/task-db.js +220 -7
  49. package/lib/task-proof.js +20 -0
  50. package/lib/task-receipt.js +93 -0
  51. package/package.json +1 -1
  52. package/utils/update-check.js +27 -6
  53. package/atris/learnings.jsonl +0 -1
@@ -7,6 +7,9 @@ const { spawnSync } = require('child_process');
7
7
 
8
8
  const autoland = require('../lib/autoland');
9
9
  const { operatorReady, hasAgentJargon } = autoland;
10
+ const MISSION_AUTO_VERIFY_STATUSES = new Set(['planning', 'paused', 'ready']);
11
+ const CLOSED_TASK_STATUSES = new Set(['done', 'archived']);
12
+ const MAX_MISSION_AUTO_VERIFY_PER_TICK = 3;
10
13
 
11
14
  function repoRoot(cwd = process.cwd()) {
12
15
  const result = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' });
@@ -62,6 +65,130 @@ function readProjection(root) {
62
65
  }
63
66
  }
64
67
 
68
+ function compactId(value) {
69
+ return String(value || '').trim();
70
+ }
71
+
72
+ function taskIdentityRefs(task) {
73
+ return [
74
+ task?.id,
75
+ task?.display_id,
76
+ task?.legacy_ref,
77
+ task?.ref,
78
+ task?.task_id,
79
+ task?.metadata?.task_id,
80
+ task?.metadata?.display_id,
81
+ task?.metadata?.ref,
82
+ ].map(compactId).filter(Boolean);
83
+ }
84
+
85
+ function taskGoalRefs(task) {
86
+ return [
87
+ task?.goal_id,
88
+ task?.goalId,
89
+ task?.mission_id,
90
+ task?.missionId,
91
+ task?.metadata?.goal_id,
92
+ task?.metadata?.goalId,
93
+ task?.metadata?.mission_id,
94
+ task?.metadata?.missionId,
95
+ task?.metadata?.goal?.id,
96
+ ].map(compactId).filter(Boolean);
97
+ }
98
+
99
+ function missionTaskRefs(mission) {
100
+ return [
101
+ ...(Array.isArray(mission?.task_ids) ? mission.task_ids : []),
102
+ mission?.task_id,
103
+ mission?.current_task_id,
104
+ mission?.task_ref,
105
+ mission?.xp_task?.task_id,
106
+ mission?.xp_task?.ref,
107
+ ].map(compactId).filter(Boolean);
108
+ }
109
+
110
+ function attachedTasksForMission(mission, tasks) {
111
+ const missionId = compactId(mission?.id);
112
+ const taskRefs = new Set(missionTaskRefs(mission));
113
+ return (tasks || []).filter((task) => {
114
+ if (missionId && taskGoalRefs(task).includes(missionId)) return true;
115
+ if (taskRefs.size === 0) return false;
116
+ return taskIdentityRefs(task).some((ref) => taskRefs.has(ref));
117
+ });
118
+ }
119
+
120
+ function taskIsClosed(task) {
121
+ return CLOSED_TASK_STATUSES.has(String(task?.status || '').trim().toLowerCase());
122
+ }
123
+
124
+ function missionHasVerifier(mission) {
125
+ return Boolean(String(mission?.verifier || mission?.effective_verifier || '').trim());
126
+ }
127
+
128
+ function missionReadyForClosedTaskVerify(mission, tasks) {
129
+ const status = String(mission?.status || '').trim().toLowerCase();
130
+ if (!MISSION_AUTO_VERIFY_STATUSES.has(status)) return false;
131
+ if (!missionHasVerifier(mission)) return false;
132
+ if (mission?.verifier_result?.passed === true) return false;
133
+ const attached = attachedTasksForMission(mission, tasks);
134
+ return attached.length > 0 && attached.every(taskIsClosed);
135
+ }
136
+
137
+ function parseMissionVerifyResult(id, result) {
138
+ let payload = null;
139
+ try {
140
+ payload = JSON.parse(result.stdout || '{}');
141
+ } catch {}
142
+ const verifier = payload?.verifier_result || null;
143
+ const passed = verifier ? verifier.passed === true : null;
144
+ return {
145
+ id,
146
+ result: passed === true ? 'passed' : passed === false ? 'failed' : (payload?.action || (result.status === 0 ? 'tick_recorded' : 'tick_failed')),
147
+ status: payload?.mission?.status || null,
148
+ receipt_path: payload?.receipt_path || null,
149
+ exit_status: result.status,
150
+ };
151
+ }
152
+
153
+ function verifyClosedTaskMissions(root, tasks, { limit = MAX_MISSION_AUTO_VERIFY_PER_TICK } = {}) {
154
+ const verified = [];
155
+ const errors = [];
156
+ let candidates = [];
157
+ try {
158
+ const { listMissions } = require('./mission');
159
+ candidates = listMissions(root).filter((mission) => missionReadyForClosedTaskVerify(mission, tasks)).slice(0, limit);
160
+ } catch (err) {
161
+ return {
162
+ verified,
163
+ errors: [{ error: String((err && err.message) || err).slice(0, 200) }],
164
+ };
165
+ }
166
+
167
+ for (const mission of candidates) {
168
+ const result = runOwnCli(root, [
169
+ 'mission',
170
+ 'tick',
171
+ mission.id,
172
+ '--verify',
173
+ '--summary',
174
+ 'All linked repair tasks are closed; autoland re-ran the mission verifier.',
175
+ '--json',
176
+ ]);
177
+ const outcome = parseMissionVerifyResult(mission.id, result);
178
+ verified.push(outcome);
179
+ if (result.status !== 0 || outcome.result === 'failed' || outcome.result === 'tick_failed') {
180
+ errors.push({
181
+ id: mission.id,
182
+ result: outcome.result,
183
+ status: result.status,
184
+ stderr: String(result.stderr || '').slice(0, 200),
185
+ stdout: String(result.stdout || '').slice(-500),
186
+ });
187
+ }
188
+ }
189
+ return { verified, errors };
190
+ }
191
+
65
192
  function landSummarySafe(root) {
66
193
  try {
67
194
  return require('./land').landSummary(root);
@@ -70,9 +197,10 @@ function landSummarySafe(root) {
70
197
  }
71
198
  }
72
199
 
73
- function evaluateQueue(root, { strictVerify }) {
200
+ function evaluateQueue(root, { strictVerify, acceptAll }) {
74
201
  const cliArgs = ['task', 'auto-accept-certified', '--dry-run', '--json', '--limit', '50'];
75
- if (strictVerify) cliArgs.push('--strict-verify');
202
+ if (acceptAll) cliArgs.push('--all');
203
+ else if (strictVerify === false) cliArgs.push('--no-strict-verify');
76
204
  const result = runOwnCli(root, cliArgs);
77
205
  try {
78
206
  const parsed = JSON.parse(result.stdout);
@@ -92,6 +220,9 @@ function plainReason(reason) {
92
220
  denied_tag_feedback: 'customer feedback — yours to approve',
93
221
  denied_tag_voice: 'voice/comms — yours to approve',
94
222
  needs_second_reviewer_or_third_pass: 'needs one more independent check first',
223
+ needs_independent_reviewer: 'built and judged by the same actor, needs an independent check',
224
+ verifier_is_builder: 'the re-check actor built this row, another actor must re-check',
225
+ judge_equals_worker: 'built and judged by the same actor, hand the review to someone else',
95
226
  not_agent_certified: 'not certified yet',
96
227
  insufficient_review_passes: 'not enough review passes yet',
97
228
  strict_verify_missing: 'no recorded check command to re-run',
@@ -106,7 +237,8 @@ function showStatus(root, args) {
106
237
  const policy = autoland.readPolicy(root);
107
238
  const enabled = Boolean(policy && policy.enabled);
108
239
  const strictVerify = policy ? policy.strict_verify !== false : true;
109
- const results = evaluateQueue(root, { strictVerify })
240
+ const acceptAll = Boolean(policy && policy.accept_all);
241
+ const results = evaluateQueue(root, { strictVerify, acceptAll })
110
242
  .filter((r) => r.reason !== 'not_in_review');
111
243
  const wouldLand = results.filter((r) => r.action === 'would_accept');
112
244
  const blocked = results.filter((r) => r.action !== 'would_accept');
@@ -128,9 +260,18 @@ function showStatus(root, args) {
128
260
  console.log('');
129
261
  console.log(`autoland — certified work lands itself; you keep the irreversible calls`);
130
262
  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'}`);
263
+ const policyOwner = String(policy?.enabled_by || 'unknown').trim() || 'unknown';
264
+ const policyText = enabled
265
+ ? (policy.default
266
+ ? `on by default for this workspace (no policy set; would accept as ${policyOwner}). set it: atris autoland on`
267
+ : `on (flipped by ${policyOwner}${policy.enabled_at ? ` on ${String(policy.enabled_at).slice(0, 10)}` : ''})`)
268
+ : 'off - everything waits for you';
269
+ console.log(` policy: ${policyText}`);
270
+ if (enabled && acceptAll) console.log(' bar: everything lands except the protected lanes (money, deploys, security, customer, outward)');
132
271
  console.log(` heartbeat: ${autoland.cronInstalled(root) ? 'running hourly' : 'not installed'}`);
133
272
  if (policy && policy.imessage_to) console.log(` daily message: ${policy.imessage_to} at ${policy.digest_hour ?? autoland.DEFAULT_DIGEST_HOUR}:00`);
273
+ 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`);
134
275
  console.log('');
135
276
  if (wouldLand.length > 0) {
136
277
  console.log(` ready to land on their own: ${wouldLand.length}`);
@@ -163,6 +304,7 @@ function turnOn(root, args) {
163
304
  const to = flag(args, '--to', '');
164
305
  const digestHour = Number(flag(args, '--digest-hour', autoland.DEFAULT_DIGEST_HOUR));
165
306
  const alarmHours = Number(flag(args, '--alarm-hours', autoland.DEFAULT_ALARM_HOURS));
307
+ const acceptAll = args.includes('--all');
166
308
  const policy = autoland.writePolicy(root, {
167
309
  enabled: true,
168
310
  enabled_by: owner,
@@ -171,11 +313,13 @@ function turnOn(root, args) {
171
313
  digest_hour: Number.isFinite(digestHour) ? digestHour : autoland.DEFAULT_DIGEST_HOUR,
172
314
  alarm_hours: Number.isFinite(alarmHours) && alarmHours > 0 ? alarmHours : autoland.DEFAULT_ALARM_HOURS,
173
315
  strict_verify: !args.includes('--no-strict-verify'),
316
+ accept_all: acceptAll,
174
317
  });
175
318
  const cronOk = autoland.installCron(root);
176
319
  console.log('');
177
320
  console.log('autoland is on.');
178
- console.log(` certified, verified, reversible work now lands itself, accepted as ${owner}.`);
321
+ if (acceptAll) console.log(` everything in review now lands itself, accepted as ${owner} — only the protected lanes wait.`);
322
+ else console.log(` certified, verified, reversible work now lands itself, accepted as ${owner}.`);
179
323
  console.log(' protected lanes (money, deploys, security, customer, outward) still wait for you.');
180
324
  console.log(` heartbeat: ${cronOk ? 'installed, runs hourly' : 'could not install cron — run atris autoland tick yourself'}`);
181
325
  if (policy.imessage_to) {
@@ -209,6 +353,8 @@ function runDigest(root, args, { forceSend = false } = {}) {
209
353
  landed: landSummarySafe(root),
210
354
  project: projectName(root),
211
355
  nextMoves: digestNextMoves(root),
356
+ acceptAll: Boolean(policy.accept_all),
357
+ reapError: autoland.readState(root).last_reap_error?.error || null,
212
358
  });
213
359
  console.log(text);
214
360
  // the full story: what each piece actually was, in its own words
@@ -239,6 +385,15 @@ function runDigest(root, args, { forceSend = false } = {}) {
239
385
  return 0;
240
386
  }
241
387
 
388
+ function pidAlive(pid) {
389
+ try {
390
+ process.kill(Number(pid), 0);
391
+ return true;
392
+ } catch {
393
+ return false;
394
+ }
395
+ }
396
+
242
397
  function runTick(root, args) {
243
398
  const json = args.includes('--json');
244
399
  const policy = autoland.readPolicy(root);
@@ -249,28 +404,70 @@ function runTick(root, args) {
249
404
  return 0;
250
405
  }
251
406
 
407
+ // One live tick at a time: verify re-runs can take minutes each, and a
408
+ // second hourly cron firing into the same snapshot double-accepts,
409
+ // double-alarms, and double-digests. A stale lock (dead pid or >55min)
410
+ // never wedges the loop.
411
+ const lockPath = path.join(root, '.atris', 'state', 'autoland.tick.lock');
412
+ try {
413
+ const prev = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
414
+ const fresh = Date.now() - Number(prev.at || 0) < 55 * 60 * 1000;
415
+ if (fresh && pidAlive(prev.pid)) {
416
+ receipt.skipped_reason = 'tick_already_running';
417
+ if (json) console.log(JSON.stringify(receipt));
418
+ else console.log('autoland tick: another tick is still running; skipped.');
419
+ return 0;
420
+ }
421
+ } catch {}
422
+ try {
423
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
424
+ fs.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, at: Date.now() }));
425
+ } catch {}
426
+ try {
427
+ return runTickBody(root, { json, policy, receipt });
428
+ } finally {
429
+ try { fs.unlinkSync(lockPath); } catch {}
430
+ }
431
+ }
432
+
433
+ function runTickBody(root, { json, policy, receipt }) {
434
+
252
435
  // 1. certify what has executable proof — re-run the runnable check named in
253
436
  // each Review proof as a second actor. Without this the tick only lands rows
254
437
  // some always-on mission happened to certify, and everything else waits on a
255
438
  // 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';
264
- }
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';
265
446
  }
266
447
 
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');
448
+ // 2. land what is eligible — the policy is the standing authorization.
449
+ // No hardcoded --limit here: a fixed low cap (this used to be 12) silently
450
+ // undercounts a real backlog every single tick — 12/hour forever even with
451
+ // 78 certified rows waiting. Let `atris task auto-accept-certified` apply
452
+ // its own default (12 without --all, a high safety cap under --all) so a
453
+ // policy with accept_all:true actually drains the full certified set.
454
+ const cliArgs = ['task', 'auto-accept-certified', '--json'];
455
+ if (policy.accept_all) cliArgs.push('--all');
456
+ else if (policy.strict_verify === false) cliArgs.push('--no-strict-verify');
270
457
  const accept = runOwnCli(root, cliArgs);
271
458
  try {
272
459
  const parsed = JSON.parse(accept.stdout);
460
+ // A refused sweep (ok:false — a guard tripped, policy race) carries no
461
+ // summary fields. Name the reason instead of leaving nulls that read as
462
+ // "no work": a blind heartbeat must say WHY it is blind.
463
+ if (parsed.ok === false) {
464
+ receipt.accept_error = String(parsed.reason || 'auto-accept refused');
465
+ }
273
466
  receipt.landed = (parsed.results || []).filter((r) => r.action === 'accepted').map((r) => r.ref);
467
+ receipt.certified = parsed.certified ?? null;
468
+ receipt.scanned = parsed.scanned ?? null;
469
+ receipt.skipped = parsed.skipped ?? null;
470
+ receipt.undercounted = Boolean(parsed.undercounted);
274
471
  } catch (err) {
275
472
  receipt.accept_error = accept.stderr.slice(0, 200) || 'auto-accept output unreadable';
276
473
  }
@@ -306,6 +503,42 @@ function runTick(root, args) {
306
503
  }
307
504
  }
308
505
 
506
+ // 3b. janitor, every tick: a mission left paused past 48h and a worktree
507
+ // already merged into base are the same rot pattern as an unlanded branch —
508
+ // left alone they accumulate until a human runs `mission stop` and
509
+ // `worktree cleanup --apply` by hand, which is exactly the chore autoland
510
+ // exists to remove. Off switch: policy.janitor === false (default on, the
511
+ // same absent-means-on convention as live_updates/strict_verify). Counts
512
+ // accumulate in state so the daily digest reports the day's total, not
513
+ // just the last tick's.
514
+ receipt.missions_stopped = 0;
515
+ receipt.worktrees_reaped = 0;
516
+ if (policy.janitor !== false) {
517
+ try {
518
+ 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);
522
+ } catch (err) {
523
+ receipt.janitor_mission_error = String((err && err.message) || err).slice(0, 200);
524
+ }
525
+ try {
526
+ const { cleanupWorktrees } = require('./worktree');
527
+ const cleaned = cleanupWorktrees({ root, apply: true });
528
+ receipt.worktrees_reaped = cleaned.removed.length;
529
+ if (cleaned.removed.length > 0) receipt.worktrees_reaped_paths = cleaned.removed.map((w) => w.path);
530
+ } catch (err) {
531
+ receipt.janitor_worktree_error = String((err && err.message) || err).slice(0, 200);
532
+ }
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
+ };
539
+ }
540
+ }
541
+
309
542
  // 4. daily digest at the configured hour
310
543
  const today = new Date().toISOString().slice(0, 10);
311
544
  const digestHour = Number(policy.digest_hour ?? autoland.DEFAULT_DIGEST_HOUR);
@@ -316,6 +549,9 @@ function runTick(root, args) {
316
549
  landed: landSummarySafe(root),
317
550
  project: projectName(root),
318
551
  nextMoves: digestNextMoves(root),
552
+ acceptAll: Boolean(policy.accept_all),
553
+ reapError: state.last_reap_error?.error || null,
554
+ janitor: state.janitor || null,
319
555
  });
320
556
  if (policy.imessage_to) {
321
557
  const sent = autoland.sendImessage(root, policy.imessage_to, text);
@@ -323,6 +559,7 @@ function runTick(root, args) {
323
559
  }
324
560
  receipt.digest_text = text;
325
561
  state.last_digest_date = today;
562
+ delete state.janitor;
326
563
 
327
564
  // 5. once a day, keep the receipt shelf lean: compress old run receipts
328
565
  // into the manifest and drop unreferenced clutter, newest 200 kept.
@@ -334,11 +571,58 @@ function runTick(root, args) {
334
571
  receipt.receipts_pruned = null;
335
572
  }
336
573
  }
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) {
582
+ 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);
594
+ }
595
+ // Missions rot the same way branches do: paused/planning/ready and
596
+ // untouched for a week means abandoned. Age them out under the same
597
+ // daily gate so `mission list` shows work, not archaeology.
598
+ try {
599
+ const { expireStaleMissions } = require('./mission');
600
+ const expiredMissions = expireStaleMissions(root);
601
+ if (expiredMissions.length > 0) receipt.expired_missions = expiredMissions.length;
602
+ } catch (err) {
603
+ receipt.mission_expiry_error = String((err && err.message) || err).slice(0, 200);
604
+ }
605
+ try {
606
+ const missionVerify = verifyClosedTaskMissions(root, readProjection(root));
607
+ if (missionVerify.verified.length > 0) receipt.verified_missions = missionVerify.verified;
608
+ if (missionVerify.errors.length > 0) receipt.mission_verify_errors = missionVerify.errors;
609
+ } catch (err) {
610
+ receipt.mission_verify_errors = [{ error: String((err && err.message) || err).slice(0, 200) }];
611
+ }
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;
618
+ }
337
619
  autoland.writeState(root, state);
338
620
 
339
621
  if (json) console.log(JSON.stringify(receipt));
340
622
  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'}`);
623
+ const reapNote = receipt.reaped ? `, reaped ${receipt.reaped.branches} landed/overdue branches` : '';
624
+ 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}`);
342
626
  }
343
627
  return 0;
344
628
  }
@@ -355,6 +639,9 @@ function showHelp() {
355
639
  console.log(' atris autoland on [--to <phone>] flip it on: hourly heartbeat, daily');
356
640
  console.log(' message, ping when something waits');
357
641
  console.log(' on you past 24h');
642
+ console.log(' atris autoland on --all lower the bar: everything lands except');
643
+ console.log(' the protected lanes; a failing recorded');
644
+ console.log(' check still blocks');
358
645
  console.log(' atris autoland off back to approving every item');
359
646
  console.log(' atris autoland digest [--send] the daily message, now');
360
647
  console.log(' atris autoland tick [--json] one heartbeat (what the hourly cron runs)');
@@ -382,4 +669,11 @@ function autolandCommand(args = []) {
382
669
  return 1;
383
670
  }
384
671
 
385
- module.exports = { autolandCommand, operatorReady, hasAgentJargon };
672
+ module.exports = {
673
+ autolandCommand,
674
+ attachedTasksForMission,
675
+ missionReadyForClosedTaskVerify,
676
+ operatorReady,
677
+ hasAgentJargon,
678
+ verifyClosedTaskMissions,
679
+ };
@@ -28,6 +28,42 @@ function isHelpToken(arg) {
28
28
  return arg === '--help' || arg === '-h' || arg === 'help' || arg === '-?';
29
29
  }
30
30
 
31
+ const BUSINESS_CREATE_USAGE = 'Usage: atris business create <name> [--description "..."] [--workspace] [--here|--root <dir>]';
32
+
33
+ function isAccidentalHelpBusiness(value) {
34
+ const raw = String(value || '').trim();
35
+ if (!raw) return false;
36
+ if (isHelpToken(raw)) return true;
37
+ const normalized = slugify(raw);
38
+ if (normalized === 'help') return true;
39
+ if (/^help-\d+$/.test(normalized)) return true;
40
+ return false;
41
+ }
42
+
43
+ function validateBusinessCreateName(name) {
44
+ if (!name || !String(name).trim()) {
45
+ return { ok: false, usage: BUSINESS_CREATE_USAGE };
46
+ }
47
+ if (isHelpToken(name)) {
48
+ return { ok: false, usage: BUSINESS_CREATE_USAGE };
49
+ }
50
+ if (String(name).startsWith('-')) {
51
+ return {
52
+ ok: false,
53
+ usage: BUSINESS_CREATE_USAGE,
54
+ detail: `Refusing to create a business named "${name}" — looks like a flag, not a name.`,
55
+ };
56
+ }
57
+ if (isAccidentalHelpBusiness(name)) {
58
+ return {
59
+ ok: false,
60
+ usage: BUSINESS_CREATE_USAGE,
61
+ detail: `Refusing to create a business named "${name}" — looks like accidental help output, not a real business.`,
62
+ };
63
+ }
64
+ return { ok: true };
65
+ }
66
+
31
67
  function loadBusinesses() {
32
68
  const p = getBusinessConfigPath();
33
69
  if (!fs.existsSync(p)) return {};
@@ -308,14 +344,46 @@ function analyzeBusinessDoctor({ cache = {}, cloudBusinesses = [], folderBinding
308
344
  }
309
345
  }
310
346
 
347
+ const cacheRemovals = [];
348
+ for (const business of cloudBusinesses || []) {
349
+ if (!isAccidentalHelpBusiness(business?.name) && !isAccidentalHelpBusiness(business?.slug)) continue;
350
+ const label = business.slug || business.name || business.id;
351
+ issues.push({
352
+ level: 'warn',
353
+ code: 'accidental-help-business',
354
+ subject: label,
355
+ message: `${label} looks like an accidental help business (April 2026 ghost rows)`,
356
+ fixable: true,
357
+ });
358
+ }
359
+
360
+ for (const [key, entry] of Object.entries(cache || {})) {
361
+ const active = findActiveMatch(key, entry);
362
+ const accidental = isAccidentalHelpBusiness(key)
363
+ || isAccidentalHelpBusiness(entry?.name)
364
+ || isAccidentalHelpBusiness(entry?.slug)
365
+ || (active && (isAccidentalHelpBusiness(active.name) || isAccidentalHelpBusiness(active.slug)));
366
+ if (!accidental) continue;
367
+ issues.push({
368
+ level: 'warn',
369
+ code: 'accidental-help-cache',
370
+ subject: key,
371
+ message: `${key} is cached for an accidental help business`,
372
+ fixable: true,
373
+ });
374
+ if (!cacheRemovals.includes(key)) cacheRemovals.push(key);
375
+ }
376
+
311
377
  return {
312
378
  issues,
313
379
  cacheUpdates,
380
+ cacheRemovals,
314
381
  stats: {
315
382
  cache_entries: Object.keys(cache || {}).length,
316
383
  cloud_active: cloudBusinesses.length,
317
384
  folders: folderBindings.length,
318
385
  fixable_cache_entries: Object.keys(cacheUpdates).length,
386
+ accidental_help_cache_removals: cacheRemovals.length,
319
387
  },
320
388
  };
321
389
  }
@@ -2191,11 +2259,20 @@ async function businessDoctor(...args) {
2191
2259
  });
2192
2260
 
2193
2261
  const cacheUpdateKeys = Object.keys(analysis.cacheUpdates);
2262
+ const cacheRemovalKeys = analysis.cacheRemovals || [];
2194
2263
  let fixed = [];
2195
- if (options.fix && cacheUpdateKeys.length > 0) {
2196
- cache = { ...cache, ...analysis.cacheUpdates };
2264
+ if (options.fix && (cacheUpdateKeys.length > 0 || cacheRemovalKeys.length > 0)) {
2265
+ if (cacheUpdateKeys.length > 0) {
2266
+ cache = { ...cache, ...analysis.cacheUpdates };
2267
+ }
2268
+ for (const key of cacheRemovalKeys) {
2269
+ delete cache[key];
2270
+ fixed.push(`removed:${key}`);
2271
+ }
2272
+ if (cacheUpdateKeys.length > 0) {
2273
+ fixed.push(...cacheUpdateKeys);
2274
+ }
2197
2275
  saveBusinesses(cache);
2198
- fixed = cacheUpdateKeys;
2199
2276
  analysis = analyzeBusinessDoctor({
2200
2277
  cache,
2201
2278
  cloudBusinesses: listResult.data,
@@ -2237,11 +2314,10 @@ async function businessDoctor(...args) {
2237
2314
  }
2238
2315
 
2239
2316
  async function createBusinessInternal(name, flags = [], mode = 'auto') {
2240
- if (!name || isHelpToken(name) || String(name).startsWith('-')) {
2241
- console.error('Usage: atris business create <name> [--description "..."] [--workspace] [--here|--root <dir>]');
2242
- if (name && String(name).startsWith('-') && !isHelpToken(name)) {
2243
- console.error(`\n Refusing to create a business named "${name}" — looks like a flag, not a name.`);
2244
- }
2317
+ const validation = validateBusinessCreateName(name);
2318
+ if (!validation.ok) {
2319
+ console.error(validation.usage);
2320
+ if (validation.detail) console.error(`\n ${validation.detail}`);
2245
2321
  process.exit(1);
2246
2322
  }
2247
2323
 
@@ -2897,6 +2973,11 @@ async function businessCommand(subcommand, ...args) {
2897
2973
  printBusinessHelp();
2898
2974
  return;
2899
2975
  }
2976
+ const nameTakingSubcommands = new Set(['create', 'new', 'init', 'workspace']);
2977
+ if (nameTakingSubcommands.has(subcommand) && args.some(isHelpToken)) {
2978
+ printBusinessHelp();
2979
+ return;
2980
+ }
2900
2981
  if (args.length > 0 && isHelpToken(args[0]) && subcommand !== 'doctor') {
2901
2982
  printBusinessHelp();
2902
2983
  return;
@@ -2999,6 +3080,8 @@ module.exports = {
2999
3080
  saveBusinesses,
3000
3081
  getBusinessConfigPath,
3001
3082
  businessMatchesSlug,
3083
+ isAccidentalHelpBusiness,
3084
+ validateBusinessCreateName,
3002
3085
  analyzeBusinessDoctor,
3003
3086
  readBusinessFolderBindings,
3004
3087
  createCanonicalBusinessWorkspace,
@@ -99,11 +99,35 @@ function chmodPrivate(filePath) {
99
99
  }
100
100
  }
101
101
 
102
- function runSqliteJson(dbPath, sql, { readonly = true } = {}) {
102
+ function runSqliteOnce(dbPath, sql, readonly) {
103
103
  const sqliteArgs = [];
104
104
  if (readonly) sqliteArgs.push('-readonly');
105
105
  sqliteArgs.push('-json', dbPath, sql);
106
- const result = spawnSync('sqlite3', sqliteArgs, { encoding: 'utf8' });
106
+ return spawnSync('sqlite3', sqliteArgs, { encoding: 'utf8' });
107
+ }
108
+
109
+ function runSqliteJson(dbPath, sql, { readonly = true } = {}) {
110
+ let result = runSqliteOnce(dbPath, sql, readonly);
111
+ // Codex DBs are WAL-mode. `sqlite3 -readonly` cannot open a WAL db unless a
112
+ // writer already holds the -shm/-wal sidecars open (error 14 with no Codex
113
+ // running). Fall back to reading a private snapshot copy of db+wal.
114
+ const walLocked =
115
+ readonly &&
116
+ result.status !== 0 &&
117
+ /unable to open database file/i.test(String(result.stderr || result.stdout || ''));
118
+ if (walLocked) {
119
+ const snapDir = fs.mkdtempSync(path.join(os.tmpdir(), 'atris-codex-goal-'));
120
+ const snapDb = path.join(snapDir, path.basename(dbPath));
121
+ try {
122
+ fs.copyFileSync(dbPath, snapDb);
123
+ for (const ext of ['-wal', '-shm']) {
124
+ if (fs.existsSync(dbPath + ext)) fs.copyFileSync(dbPath + ext, snapDb + ext);
125
+ }
126
+ result = runSqliteOnce(snapDb, sql, false);
127
+ } finally {
128
+ fs.rmSync(snapDir, { recursive: true, force: true });
129
+ }
130
+ }
107
131
  if (result.error) throw new Error(`sqlite3 failed: ${result.error.message}`);
108
132
  if (result.status !== 0) {
109
133
  const detail = (result.stderr || result.stdout || '').trim();