atris 3.15.13 → 3.15.22

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 (93) hide show
  1. package/AGENTS.md +84 -8
  2. package/README.md +5 -1
  3. package/atris/AGENTS.md +46 -1
  4. package/atris/CLAUDE.md +36 -1
  5. package/atris/GEMINI.md +14 -1
  6. package/atris/atris.md +12 -1
  7. package/atris/atrisDev.md +3 -2
  8. package/atris/context/README.md +11 -0
  9. package/atris/features/company-brain-sync/validate.md +5 -5
  10. package/atris/learnings.jsonl +1 -0
  11. package/atris/policies/atris-design.md +2 -0
  12. package/atris/skills/aeo/SKILL.md +2 -2
  13. package/atris/skills/atris/SKILL.md +15 -62
  14. package/atris/skills/design/SKILL.md +2 -0
  15. package/atris/skills/imessage/SKILL.md +19 -2
  16. package/atris/skills/loop/SKILL.md +6 -5
  17. package/atris/skills/magic-inbox/SKILL.md +1 -1
  18. package/atris/team/_template/MEMBER.md +23 -1
  19. package/atris/team/brainstormer/START_HERE.md +6 -0
  20. package/atris/team/executor/MEMBER.md +13 -0
  21. package/atris/team/executor/START_HERE.md +6 -0
  22. package/atris/team/launcher/START_HERE.md +6 -0
  23. package/atris/team/mission-lead/MEMBER.md +39 -0
  24. package/atris/team/mission-lead/MISSION.md +33 -0
  25. package/atris/team/mission-lead/START_HERE.md +6 -0
  26. package/atris/team/navigator/MEMBER.md +11 -0
  27. package/atris/team/navigator/START_HERE.md +6 -0
  28. package/atris/team/opus-overnight/MEMBER.md +39 -0
  29. package/atris/team/opus-overnight/MISSION.md +61 -0
  30. package/atris/team/opus-overnight/START_HERE.md +6 -0
  31. package/atris/team/opus-overnight/STEERING.md +35 -0
  32. package/atris/team/researcher/START_HERE.md +6 -0
  33. package/atris/team/validator/MEMBER.md +26 -6
  34. package/atris/team/validator/START_HERE.md +6 -0
  35. package/atris/wiki/concepts/agent-activation-contract.md +79 -0
  36. package/atris/wiki/concepts/workspace-initialization-contract.md +73 -0
  37. package/atris/wiki/index.md +27 -0
  38. package/atris/wiki/sources/atris-labs-2026-05-10.txt +17 -0
  39. package/atris/wiki/sources/atris-labs-goals-2026-05-10.txt +15 -0
  40. package/atris/wiki/sources/atrisos-generative-ui-product-surface-2026-05-10.txt +10 -0
  41. package/atris/wiki/sources/jack-dorsey-2026-05-10.txt +12 -0
  42. package/atris.md +49 -13
  43. package/bin/atris.js +660 -22
  44. package/commands/activate.js +12 -3
  45. package/commands/aeo.js +1 -1
  46. package/commands/align.js +10 -10
  47. package/commands/analytics.js +9 -4
  48. package/commands/app.js +2 -0
  49. package/commands/apps.js +276 -0
  50. package/commands/auth.js +1 -1
  51. package/commands/autopilot.js +74 -5
  52. package/commands/brain.js +536 -61
  53. package/commands/brainstorm.js +12 -12
  54. package/commands/business-sync.js +142 -24
  55. package/commands/clean.js +9 -6
  56. package/commands/codex-goal.js +311 -0
  57. package/commands/errors.js +11 -1
  58. package/commands/feedback.js +55 -17
  59. package/commands/fork.js +2 -2
  60. package/commands/gm.js +376 -0
  61. package/commands/init.js +80 -3
  62. package/commands/integrations.js +524 -0
  63. package/commands/learn.js +25 -16
  64. package/commands/lesson.js +41 -0
  65. package/commands/lifecycle.js +2 -2
  66. package/commands/member.js +2416 -9
  67. package/commands/mission.js +1776 -0
  68. package/commands/now.js +48 -7
  69. package/commands/play.js +425 -0
  70. package/commands/publish.js +2 -1
  71. package/commands/pull.js +72 -29
  72. package/commands/push.js +199 -17
  73. package/commands/review.js +51 -13
  74. package/commands/skill.js +2 -2
  75. package/commands/soul.js +19 -13
  76. package/commands/status.js +6 -1
  77. package/commands/sync.js +5 -4
  78. package/commands/task.js +1041 -147
  79. package/commands/terminal.js +5 -5
  80. package/commands/verify.js +7 -5
  81. package/commands/visualize.js +7 -0
  82. package/commands/wiki.js +53 -16
  83. package/commands/workflow.js +298 -54
  84. package/commands/workspace-clean.js +1 -1
  85. package/commands/worktree.js +468 -0
  86. package/commands/xp.js +1608 -0
  87. package/lib/manifest.js +34 -4
  88. package/lib/scorecard.js +3 -2
  89. package/lib/task-db.js +408 -27
  90. package/lib/todo-fallback.js +28 -2
  91. package/lib/todo.js +5 -3
  92. package/package.json +23 -2
  93. package/utils/update-check.js +51 -1
@@ -55,16 +55,25 @@ function activateAtris() {
55
55
  // Sort descending (most recent first)
56
56
  allLogs.sort().reverse();
57
57
 
58
- // Extract C# items from logs until we have 3
58
+ // Extract C# items from logs until we have 3. Dedupe by ID across files
59
+ // since per-day numbering reuses C1, C2, etc. — the same ID appearing
60
+ // in two day-files would otherwise render as duplicate rows.
61
+ const seenIds = new Set();
59
62
  for (const logPath of allLogs) {
60
63
  if (recentCompletions.length >= 3) break;
61
64
  const content = fs.readFileSync(logPath, 'utf8');
62
65
  const completedSection = content.match(/## Completed ✅\n([\s\S]*?)(?=\n## |$)/);
63
66
  if (completedSection) {
64
- const matches = completedSection[1].matchAll(/- \*\*C(\d+):\*?\*?\s*(.+?)(?:\s*\[.*\])?$/gm);
67
+ // Match `- **C#: Title**` (title between the bold markers). If extra
68
+ // prose follows after `**`, ignore it — the activation panel shows
69
+ // titles only, truncated to 59 chars.
70
+ const matches = completedSection[1].matchAll(/- \*\*C(\d+):\s*([^*\n]+?)\*\*/gm);
65
71
  for (const match of matches) {
66
72
  if (recentCompletions.length >= 3) break;
67
- recentCompletions.push({ id: `C${match[1]}`, desc: match[2], file: path.basename(logPath) });
73
+ const id = `C${match[1]}`;
74
+ if (seenIds.has(id)) continue;
75
+ seenIds.add(id);
76
+ recentCompletions.push({ id, desc: match[2].trim(), file: path.basename(logPath) });
68
77
  }
69
78
  }
70
79
  }
package/commands/aeo.js CHANGED
@@ -88,7 +88,7 @@ function printHelp() {
88
88
  console.log('');
89
89
  console.log('Examples:');
90
90
  console.log(' atris aeo init');
91
- console.log(' atris aeo draft "what is pallet" --queries "what is pallet,best freight platform"');
91
+ console.log(' atris aeo draft "what is example-co" --queries "what is example-co,best freight platform"');
92
92
  console.log(' atris aeo draft "how does atris work" --workspace doordash --slug atris-overview');
93
93
  }
94
94
 
package/commands/align.js CHANGED
@@ -12,10 +12,10 @@
12
12
  *
13
13
  * USAGE:
14
14
  * atris align # auto-detect business from .atris/business.json
15
- * atris align pallet # explicit business slug
16
- * atris align pallet --dry-run # show diff, do nothing
17
- * atris align pallet --fix # local is canonical: delete EC2 extras, push local-only
18
- * atris align pallet --fix --from cloud # cloud is canonical: pull EC2-only, delete local extras
15
+ * atris align example-co # explicit business slug
16
+ * atris align example-co --dry-run # show diff, do nothing
17
+ * atris align example-co --fix # local is canonical: delete EC2 extras, push local-only
18
+ * atris align example-co --fix --from cloud # cloud is canonical: pull EC2-only, delete local extras
19
19
  */
20
20
 
21
21
  const fs = require('fs');
@@ -489,15 +489,15 @@ async function alignAtris() {
489
489
  if (!slug || slug.startsWith('-')) slug = null;
490
490
  }
491
491
 
492
- if (!slug || slug === '--help' || slug === '-h') {
492
+ if (!slug || slug === '--help' || slug === '-h' || slug === 'help') {
493
493
  console.log('Usage: atris align [business] [--fix] [--hard] [--from cloud|local] [--dry-run]');
494
494
  console.log('');
495
495
  console.log(' atris align Diff current workspace against cloud (auto-detect)');
496
- console.log(' atris align pallet Diff pallet workspace');
497
- console.log(' atris align pallet --fix Fix drift (local is canonical by default)');
498
- console.log(' atris align pallet --fix --hard Force-push: nuke cloud cruft, upload local. Skips diff. Fast.');
499
- console.log(' atris align pallet --fix --from cloud Cloud is canonical: pull EC2-only, delete local extras');
500
- console.log(' atris align pallet --dry-run Show what would change, do nothing');
496
+ console.log(' atris align example-co Diff example-co workspace');
497
+ console.log(' atris align example-co --fix Fix drift (local is canonical by default)');
498
+ console.log(' atris align example-co --fix --hard Force-push: nuke cloud cruft, upload local. Skips diff. Fast.');
499
+ console.log(' atris align example-co --fix --from cloud Cloud is canonical: pull EC2-only, delete local extras');
500
+ console.log(' atris align example-co --dry-run Show what would change, do nothing');
501
501
  process.exit(0);
502
502
  }
503
503
 
@@ -67,12 +67,17 @@ function analyticsAtris() {
67
67
  }
68
68
  }
69
69
 
70
- // Parse timestamps for productivity hours
71
- const timestampMatches = content.match(/\*\*(\d{2}):(\d{2}):(\d{2})\*\*/g);
70
+ // Parse timestamps for productivity hours. Match the journal heading
71
+ // format `### Title — HH:MM` and the legacy `**HH:MM:SS**` form.
72
+ const timestampMatches = content.match(/(?:—|--)\s*(\d{2}):\d{2}(?::\d{2})?\b|\*\*(\d{2}):\d{2}(?::\d{2})?\*\*/g);
72
73
  if (timestampMatches) {
73
74
  timestampMatches.forEach(ts => {
74
- const hour = parseInt(ts.match(/\d{2}/)[0]);
75
- hourCounts[hour] = (hourCounts[hour] || 0) + 1;
75
+ const m = ts.match(/(\d{2}):/);
76
+ if (!m) return;
77
+ const hour = parseInt(m[1], 10);
78
+ if (Number.isFinite(hour) && hour >= 0 && hour < 24) {
79
+ hourCounts[hour] = (hourCounts[hour] || 0) + 1;
80
+ }
76
81
  });
77
82
  }
78
83
  });
package/commands/app.js CHANGED
@@ -299,6 +299,8 @@ async function appCommand(subcommand, ...args) {
299
299
  await show(args[0]);
300
300
  break;
301
301
  case 'help':
302
+ case '-h':
303
+ case '--help':
302
304
  case undefined:
303
305
  await help();
304
306
  break;
@@ -0,0 +1,276 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { spawnSync } = require('child_process');
4
+
5
+ function findAppsPackRoot(startDir = process.cwd()) {
6
+ const explicit = process.env.ATRIS_APPS_PACK;
7
+ if (explicit) {
8
+ const resolved = path.resolve(explicit);
9
+ if (fs.existsSync(path.join(resolved, 'scripts', 'app_use.py'))) return resolved;
10
+ }
11
+ let current = path.resolve(startDir);
12
+ while (true) {
13
+ const candidate = path.join(current, 'atris', 'apps-pack');
14
+ if (fs.existsSync(path.join(candidate, 'scripts', 'app_use.py'))) return candidate;
15
+ const parent = path.dirname(current);
16
+ if (parent === current) break;
17
+ current = parent;
18
+ }
19
+ return null;
20
+ }
21
+
22
+ function appsUsageLines() {
23
+ return [
24
+ 'Usage: atris apps <command>',
25
+ '',
26
+ 'Commands:',
27
+ ' list [--json] List available local apps',
28
+ ' run <slug> [--lines N] Run an app and print data/latest.md or --json status',
29
+ ' owner <slug> [--json] Show owner view: launch, usage, learning, next actions',
30
+ ' status [--json] Show local app health',
31
+ ' queue Show app improvement queue',
32
+ ' rate <slug> <up|down> [note] Record output feedback',
33
+ ' smoke Run fresh-checkout smoke test',
34
+ ' doctor [--strict] Audit pack health, smoke, and source cleanliness',
35
+ ' handoff [--json] Print app operator checklist and receipt paths',
36
+ ' overnight Run the bounded overnight app loop',
37
+ ' overnight-install [--start] Install bounded macOS LaunchAgent',
38
+ ' overnight-agent [status|stop] Inspect or stop macOS LaunchAgent',
39
+ ];
40
+ }
41
+
42
+ function printAppsHelp() {
43
+ console.log('');
44
+ for (const line of appsUsageLines()) console.log(line);
45
+ console.log('');
46
+ }
47
+
48
+ function wantsJson(subcommand, rawArgs) {
49
+ return subcommand === '--json' || rawArgs.includes('--json');
50
+ }
51
+
52
+ function normalizeInvocation(subcommand, rawArgs) {
53
+ if (subcommand === '--json') {
54
+ return { subcommand: 'list', rawArgs: ['--json', ...rawArgs] };
55
+ }
56
+ return { subcommand, rawArgs };
57
+ }
58
+
59
+ function exitAppsError(message, json, options = {}) {
60
+ const payload = {
61
+ ok: false,
62
+ error: message,
63
+ ...(options.extra || {}),
64
+ };
65
+ if (options.usage) payload.usage = appsUsageLines();
66
+ if (json) {
67
+ console.log(JSON.stringify(payload, null, 2));
68
+ } else {
69
+ console.error(message);
70
+ if (options.usage) printAppsHelp();
71
+ }
72
+ process.exit(options.code || 1);
73
+ }
74
+
75
+ function parseScalar(value) {
76
+ const trimmed = value.trim();
77
+ if (trimmed === '[]') return [];
78
+ if (trimmed === 'true') return true;
79
+ if (trimmed === 'false') return false;
80
+ if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed);
81
+ const quoted = trimmed.match(/^"(.*)"$/) || trimmed.match(/^'(.*)'$/);
82
+ return quoted ? quoted[1] : trimmed;
83
+ }
84
+
85
+ function readAppManifest(appDir) {
86
+ const text = fs.readFileSync(path.join(appDir, 'APP.md'), 'utf8');
87
+ const match = text.match(/^---\n([\s\S]*?)\n---/);
88
+ const manifest = {};
89
+ if (!match) return manifest;
90
+ let currentList = null;
91
+ for (const line of match[1].split('\n')) {
92
+ const listItem = line.match(/^\s+-\s+(.*)$/);
93
+ if (listItem && currentList) {
94
+ manifest[currentList].push(parseScalar(listItem[1]));
95
+ continue;
96
+ }
97
+ if (/^\s+/.test(line)) {
98
+ if (currentList && manifest[currentList].length === 0) delete manifest[currentList];
99
+ currentList = null;
100
+ continue;
101
+ }
102
+ const field = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/);
103
+ if (!field) continue;
104
+ const [, key, rawValue] = field;
105
+ if (rawValue === '') {
106
+ manifest[key] = [];
107
+ currentList = key;
108
+ } else {
109
+ manifest[key] = parseScalar(rawValue);
110
+ currentList = null;
111
+ }
112
+ }
113
+ return manifest;
114
+ }
115
+
116
+ function listAppManifests(packRoot) {
117
+ const appsDir = path.join(packRoot, 'apps');
118
+ return fs.readdirSync(appsDir)
119
+ .filter((name) => fs.existsSync(path.join(appsDir, name, 'APP.md')))
120
+ .sort()
121
+ .map((slug) => ({
122
+ slug,
123
+ path: path.join(appsDir, slug, 'APP.md'),
124
+ latest_output: path.join(appsDir, slug, 'data', 'latest.md'),
125
+ ...readAppManifest(path.join(appsDir, slug)),
126
+ }));
127
+ }
128
+
129
+ function popOption(args, name, fallback = null) {
130
+ const eqPrefix = `${name}=`;
131
+ const eqIndex = args.findIndex((arg) => arg.startsWith(eqPrefix));
132
+ if (eqIndex >= 0) {
133
+ const value = args[eqIndex].slice(eqPrefix.length);
134
+ args.splice(eqIndex, 1);
135
+ return value || fallback;
136
+ }
137
+ const index = args.indexOf(name);
138
+ if (index >= 0) {
139
+ const value = args[index + 1];
140
+ args.splice(index, value === undefined ? 1 : 2);
141
+ return value || fallback;
142
+ }
143
+ return fallback;
144
+ }
145
+
146
+ function runPackScript(packRoot, script, args) {
147
+ const result = spawnSync('python3', [script, ...args], {
148
+ cwd: packRoot,
149
+ stdio: 'inherit',
150
+ env: process.env,
151
+ });
152
+ if (result.error) {
153
+ console.error(`✗ Failed to run python3 ${script}: ${result.error.message}`);
154
+ process.exit(1);
155
+ }
156
+ process.exit(result.status ?? 0);
157
+ }
158
+
159
+ function appsCommand(subcommand, ...rawArgs) {
160
+ const jsonRequested = wantsJson(subcommand, rawArgs);
161
+ const normalized = normalizeInvocation(subcommand, rawArgs);
162
+ subcommand = normalized.subcommand;
163
+ rawArgs = normalized.rawArgs;
164
+
165
+ if (!subcommand || subcommand === 'help' || subcommand === '--help' || subcommand === '-h') {
166
+ printAppsHelp();
167
+ return;
168
+ }
169
+ const packRoot = findAppsPackRoot();
170
+ if (!packRoot) {
171
+ if (jsonRequested) {
172
+ exitAppsError('No Atris app pack found.', true, {
173
+ extra: { expected: 'atris/apps-pack/ in this workspace, or ATRIS_APPS_PACK' },
174
+ });
175
+ }
176
+ console.error('✗ No Atris app pack found.');
177
+ console.error(' Expected atris/apps-pack/ in this workspace, or set ATRIS_APPS_PACK.');
178
+ process.exit(1);
179
+ }
180
+
181
+ const args = [...rawArgs];
182
+ const workspace = path.resolve(popOption(args, '--workspace', process.cwd()));
183
+ const lines = popOption(args, '--lines', null);
184
+ const note = popOption(args, '--note', null);
185
+ const hours = popOption(args, '--hours', null);
186
+ const intervalMinutes = popOption(args, '--interval-minutes', null);
187
+ const maxCycles = popOption(args, '--max-cycles', null);
188
+ const label = popOption(args, '--label', null);
189
+ const python = popOption(args, '--python', null);
190
+ const json = args.includes('--json');
191
+ if (json) args.splice(args.indexOf('--json'), 1);
192
+ const noRun = args.includes('--no-run');
193
+ if (noRun) args.splice(args.indexOf('--no-run'), 1);
194
+
195
+ if (subcommand === 'list') {
196
+ if (json) {
197
+ console.log(JSON.stringify({ pack: packRoot, apps: listAppManifests(packRoot) }, null, 2));
198
+ process.exit(0);
199
+ }
200
+ runPackScript(packRoot, 'scripts/app_use.py', ['--list']);
201
+ }
202
+ if (subcommand === 'status') runPackScript(packRoot, 'scripts/app_status.py', json ? ['--json'] : []);
203
+ if (subcommand === 'queue') runPackScript(packRoot, 'scripts/app_improvement_queue.py', []);
204
+ if (subcommand === 'smoke') runPackScript(packRoot, 'scripts/install_smoke.py', []);
205
+ if (subcommand === 'doctor') runPackScript(packRoot, 'scripts/install_smoke.py', []);
206
+ if (subcommand === 'handoff') {
207
+ const command = ['--workspace', workspace];
208
+ if (json) command.push('--json');
209
+ runPackScript(packRoot, 'scripts/app_operator_checklist.py', command);
210
+ }
211
+ if (subcommand === 'overnight') {
212
+ const command = ['--workspace', workspace];
213
+ if (hours) command.push('--hours', hours);
214
+ if (intervalMinutes) command.push('--interval-minutes', intervalMinutes);
215
+ if (maxCycles) command.push('--max-cycles', maxCycles);
216
+ runPackScript(packRoot, 'scripts/app_overnight.py', command);
217
+ }
218
+ if (subcommand === 'overnight-install') {
219
+ const command = ['--workspace', workspace];
220
+ if (hours) command.push('--hours', hours);
221
+ if (intervalMinutes) command.push('--interval-minutes', intervalMinutes);
222
+ if (maxCycles) command.push('--max-cycles', maxCycles);
223
+ if (label) command.push('--label', label);
224
+ if (python) command.push('--python', python);
225
+ if (args.includes('--start')) command.push('--start');
226
+ if (args.includes('--dry-run')) command.push('--dry-run');
227
+ runPackScript(packRoot, 'scripts/install_overnight_launch_agent.py', command);
228
+ }
229
+ if (subcommand === 'overnight-agent') {
230
+ const command = [args.shift() || 'status'];
231
+ if (label) command.push('--label', label);
232
+ if (args.includes('--dry-run')) command.push('--dry-run');
233
+ if (json) command.push('--json');
234
+ runPackScript(packRoot, 'scripts/control_overnight_launch_agent.py', command);
235
+ }
236
+ if (subcommand === 'run') {
237
+ const slug = args.shift();
238
+ if (!slug) {
239
+ exitAppsError('Usage: atris apps run <slug> [--workspace <path>] [--lines N]', json);
240
+ }
241
+ const command = [slug, '--workspace', workspace];
242
+ if (lines) command.push('--lines', lines);
243
+ if (json) command.push('--json');
244
+ if (noRun) command.push('--no-run');
245
+ runPackScript(packRoot, 'scripts/app_use.py', command);
246
+ }
247
+ if (subcommand === 'owner') {
248
+ const slug = args.shift();
249
+ if (!slug) {
250
+ exitAppsError('Usage: atris apps owner <slug> [--workspace <path>] [--lines N]', json);
251
+ }
252
+ const command = [slug, '--workspace', workspace];
253
+ if (lines) command.push('--lines', lines);
254
+ if (json) command.push('--json');
255
+ if (noRun) command.push('--no-run');
256
+ runPackScript(packRoot, 'scripts/app_owner.py', command);
257
+ }
258
+ if (subcommand === 'rate') {
259
+ const slug = args.shift();
260
+ const rating = args.shift();
261
+ const ratingNote = note || args.join(' ');
262
+ if (!slug || !['up', 'down'].includes(rating)) {
263
+ console.error('Usage: atris apps rate <slug> <up|down> [note]');
264
+ process.exit(1);
265
+ }
266
+ runPackScript(packRoot, 'scripts/app_rate.py', [slug, rating, ratingNote || '']);
267
+ }
268
+
269
+ exitAppsError(`Unknown apps command: ${subcommand}`, json, { usage: true });
270
+ }
271
+
272
+ module.exports = {
273
+ appsCommand,
274
+ findAppsPackRoot,
275
+ listAppManifests,
276
+ };
package/commands/auth.js CHANGED
@@ -341,7 +341,7 @@ async function accountsCmd() {
341
341
  }
342
342
  profiles.forEach(p => deleteProfile(p));
343
343
  deleteCredentials();
344
- console.log(`✓ Removed ${profiles.length} account(s).`);
344
+ console.log(`✓ Removed ${profiles.length} ${profiles.length === 1 ? 'account' : 'accounts'}.`);
345
345
  process.exit(0);
346
346
  }
347
347
  if (!target) {
@@ -39,6 +39,7 @@ async function suggestNextTask(cwd, skipped = new Set(), { auto = false } = {})
39
39
 
40
40
  for (const t of todo.backlog) {
41
41
  if (t.tags && t.tags.includes('unverified')) continue;
42
+ if (shouldSkipEndgameAtPicker(cwd, t)) continue;
42
43
  if (t.tag === 'endgame' && !skipped.has(t.title)) {
43
44
  suggestions.push({
44
45
  task: t.title,
@@ -125,6 +126,7 @@ async function suggestNextTask(cwd, skipped = new Set(), { auto = false } = {})
125
126
  // --- Backlog tasks ---
126
127
  for (const t of todo.backlog) {
127
128
  if (t.tags && t.tags.includes('unverified')) continue;
129
+ if (shouldSkipEndgameAtPicker(cwd, t)) continue;
128
130
  if (skipped.has(t.title)) continue;
129
131
  const remaining = todo.backlog.filter(b => !(b.tags && b.tags.includes('unverified'))).length;
130
132
  suggestions.push({
@@ -697,6 +699,12 @@ function writeLesson(cwd, slug, status, explanation) {
697
699
  }
698
700
 
699
701
  let content = fs.readFileSync(lessonsPath, 'utf8');
702
+ // Same-day dedup: if an identical line already exists, skip the write. A
703
+ // cron firing every 13min produced 5 identical no-verify-field lessons in
704
+ // one day (2026-05-08) before the picker-side fix landed — pure noise. The
705
+ // append-only contract still holds across days because today's date is in
706
+ // the line.
707
+ if (content.includes(lessonLine)) return;
700
708
  // Append after the --- separator
701
709
  if (content.includes('---\n')) {
702
710
  content = content.replace(/---\n/, `---\n\n${lessonLine}\n`);
@@ -784,7 +792,7 @@ function getVerifyCommand(cwd, taskTitle) {
784
792
  const todoPath = path.join(cwd, 'atris', 'TODO.md');
785
793
  if (fs.existsSync(todoPath)) {
786
794
  const todo = parseTodo(todoPath);
787
- const task = [...todo.inProgress, ...todo.backlog, ...todo.completed]
795
+ const task = [...todo.inProgress, ...(todo.review || []), ...todo.backlog, ...todo.completed]
788
796
  .find(t => t.title === taskTitle);
789
797
  if (task && task.verify) return { cmd: task.verify, explicit: true };
790
798
  }
@@ -813,6 +821,14 @@ function detectDefaultVerify(cwd) {
813
821
  if (fs.existsSync(path.join(cwd, 'pytest.ini')) ||
814
822
  fs.existsSync(path.join(cwd, 'pyproject.toml')) ||
815
823
  fs.existsSync(path.join(cwd, 'setup.py'))) {
824
+ // Prefer a repo-curated fast lane over bare `pytest`. Large repos (e.g.
825
+ // atrisos-backend) ship a critical-path runner because the full suite is
826
+ // unsafe to run unsupervised (CLAUDE.md: "NEVER run pytest tests/ ... eats
827
+ // 10GB+ RAM"). lessons.md 2026-05-10 verify-failed: bare `pytest` failed
828
+ // a reactive-signal verify and halted the loop.
829
+ for (const fast of ['backend/scripts/test_fast.sh', 'scripts/test_fast.sh', 'test_fast.sh']) {
830
+ if (fs.existsSync(path.join(cwd, fast))) return `bash ${fast}`;
831
+ }
816
832
  return 'pytest';
817
833
  }
818
834
  if (fs.existsSync(path.join(cwd, 'Cargo.toml'))) {
@@ -1177,10 +1193,16 @@ function runTaskOnce(context, options = {}) {
1177
1193
  // task is already done — either way, halt. This is the keystone that makes
1178
1194
  // Verify load-bearing. The cmd is captured here and reused post-execute so
1179
1195
  // an agent cannot swap the rubric mid-tick.
1196
+ //
1197
+ // Timeout: 300s. Many endgame Verify clauses chain a fast-suite run
1198
+ // (test_fast.sh ~60s) plus extra assertions. At 60s the gate timed out
1199
+ // before the chain could finish, the catch branch labeled it "falsifiable",
1200
+ // and the loop executed already-done work. 300s lets the standard
1201
+ // pytest+fast-suite shape complete cleanly.
1180
1202
  const skipFalsifiability = options.skipFalsifiability === true;
1181
1203
  if (!skipFalsifiability && verifyResult.explicit && context.kind === 'endgame' && verifyCmd) {
1182
1204
  try {
1183
- execSync(verifyCmd, { cwd, stdio: 'pipe', timeout: 60000 });
1205
+ execSync(verifyCmd, { cwd, stdio: 'pipe', timeout: 300000 });
1184
1206
  writeLesson(cwd, 'verify-not-falsifiable', 'fail',
1185
1207
  `Verify \`${verifyCmd}\` passed before work started on "${context.task}". Either the rubric is trivial or the task is already done. Tick halted.`);
1186
1208
  return {
@@ -1466,7 +1488,8 @@ function readEndgameState(cwd) {
1466
1488
  pickedAt: pickedMatch ? pickedMatch[1].trim() : null,
1467
1489
  horizon: horizonMatch ? horizonMatch[1].trim() : '',
1468
1490
  remaining: todo.backlog.filter(t => t.tag === 'endgame').length
1469
- + todo.inProgress.filter(t => t.tag === 'endgame').length,
1491
+ + todo.inProgress.filter(t => t.tag === 'endgame').length
1492
+ + (todo.review || []).filter(t => t.tag === 'endgame').length,
1470
1493
  completed: todo.completed.filter(t => t.tag === 'endgame').length,
1471
1494
  };
1472
1495
  } catch {
@@ -2207,6 +2230,42 @@ function isLessonResolvedLegacy(lessonLine, cwd) {
2207
2230
  * system already wrote down about itself. A `fail` lesson with `isLessonResolved
2208
2231
  * === false` means grep confirms the bug pattern is still present — actionable.
2209
2232
  */
2233
+ /**
2234
+ * Returns true if a recent (within `windowDays`) `verify-not-falsifiable`
2235
+ * lesson references this exact task title. The falsifiability gate halts the
2236
+ * tick when the Verify clause already passes before work starts (task is
2237
+ * already shipped or rubric is trivial), but nothing in TODO.md changes —
2238
+ * so the next tick re-picks the same task and burns another 90s+ halting in
2239
+ * the same place. Reading the lesson log breaks the loop without requiring
2240
+ * a TODO.md hand-edit (which is the structurally-broken file we route around
2241
+ * per feedback_todo_md_is_the_problem).
2242
+ */
2243
+ function hasRecentVerifyPrePass(cwd, taskTitle, windowDays = 7) {
2244
+ if (!taskTitle) return false;
2245
+ const lessons = parseLessons(cwd);
2246
+ if (lessons.length === 0) return false;
2247
+ const cutoff = new Date();
2248
+ cutoff.setDate(cutoff.getDate() - windowDays);
2249
+ const cutoffDate = cutoff.toISOString().split('T')[0];
2250
+ const needle = `"${taskTitle}"`;
2251
+ for (const l of lessons) {
2252
+ if (l.id !== 'verify-not-falsifiable') continue;
2253
+ if (l.date < cutoffDate) continue;
2254
+ if (l.body.includes(needle)) return true;
2255
+ }
2256
+ return false;
2257
+ }
2258
+
2259
+ function shouldSkipEndgameAtPicker(cwd, task) {
2260
+ if (!task || task.tag !== 'endgame') return false;
2261
+ // Endgame tasks must declare an explicit **Verify:** field. runTaskOnce would
2262
+ // halt them, so the picker must not downgrade them into generic backlog work.
2263
+ if (!task.verify) return true;
2264
+ // If Verify already passed before work started recently, the task is already
2265
+ // shipped or the rubric is trivial. Keep it out of all picker paths.
2266
+ return hasRecentVerifyPrePass(cwd, task.title);
2267
+ }
2268
+
2210
2269
  function pickUnresolvedFailLesson(cwd) {
2211
2270
  const lessons = parseLessons(cwd);
2212
2271
  if (lessons.length === 0) return null;
@@ -2245,6 +2304,11 @@ function pickUnresolvedFailLesson(cwd) {
2245
2304
  return candidates[0];
2246
2305
  }
2247
2306
 
2307
+ function getLessonVerdict(lessonLine) {
2308
+ const match = lessonLine.match(/\*\*\[\d{4}-\d{2}-\d{2}\]\s+[\w-]+\*\*\s*[—-]\s*(pass|fail)\b/i);
2309
+ return match ? match[1].toLowerCase() : null;
2310
+ }
2311
+
2248
2312
  /**
2249
2313
  * Propose 3 candidate next horizons for the autopilot loop. Combines
2250
2314
  * `getIdleTickCount` + `getRecentSignals` into a prompt asking the LLM
@@ -2286,6 +2350,7 @@ Based on these signals, propose exactly 3 candidate next horizons for the loop t
2286
2350
  - A real, concrete horizon tied to what the signals actually reveal (no placeholders, no "candidate 1", no TODO/FIXME stubs).
2287
2351
  - Something the loop can actually work on in this repo right now.
2288
2352
  - Distinct from the other two candidates.
2353
+ - Not a restatement of a \`pass\` lesson; pass lessons are shipped constraints, not open work.
2289
2354
 
2290
2355
  Output STRICT JSON ONLY — no prose, no markdown code fences, no commentary. The output must be a single JSON array with exactly 3 objects, each shaped:
2291
2356
 
@@ -2353,7 +2418,7 @@ Reply with the JSON array and nothing else.`;
2353
2418
  throw new Error(`proposeCandidateHorizons: expected at least 1 valid candidate, got ${candidates.length}`);
2354
2419
  }
2355
2420
 
2356
- // Filter out candidates derived from resolved lessons
2421
+ // Filter out candidates derived from shipped/resolved lessons.
2357
2422
  const lessonsPath = path.join(cwd, 'atris', 'lessons.md');
2358
2423
  const filtered = [];
2359
2424
  for (const c of candidates) {
@@ -2362,12 +2427,16 @@ Reply with the JSON array and nothing else.`;
2362
2427
  for (const lessonLine of signals.recentLessons) {
2363
2428
  const slugMatch = lessonLine.match(/\*\*\[\d{4}-\d{2}-\d{2}\]\s+([\w-]+)\*\*/);
2364
2429
  if (!slugMatch) continue;
2365
- if (lessonLine.includes('[resolved]')) continue;
2430
+ const alreadyResolved = lessonLine.includes('[resolved]');
2366
2431
  const slug = slugMatch[1];
2367
2432
  // Fuzzy match: check if slug keywords appear in the candidate text
2368
2433
  const slugWords = slug.split('-').filter(w => w.length > 2);
2369
2434
  const matchCount = slugWords.filter(w => combinedText.includes(w)).length;
2370
2435
  if (matchCount < Math.ceil(slugWords.length * 0.5)) continue;
2436
+ if (alreadyResolved || getLessonVerdict(lessonLine) === 'pass') {
2437
+ droppedByLesson = true;
2438
+ break;
2439
+ }
2371
2440
  // Candidate matches this lesson — check if the lesson is resolved
2372
2441
  if (isLessonResolved(lessonLine, cwd)) {
2373
2442
  // Tag lesson [resolved] in lessons.md