atris 3.58.6 → 3.58.8

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 (90) hide show
  1. package/GETTING_STARTED.md +2 -2
  2. package/PERSONA.md +11 -11
  3. package/README.md +9 -1
  4. package/atris/GEMINI.md +7 -6
  5. package/atris/PERSONA.md +4 -4
  6. package/atris/features/README.md +77 -6
  7. package/atris/policies/engineering-principles.md +129 -0
  8. package/atris/policies/genesis.md +112 -0
  9. package/atris/policies/product-design-principles.md +100 -0
  10. package/atris/skills/README.md +44 -15
  11. package/atris/skills/aeo/SKILL.md +10 -10
  12. package/atris/skills/atris/SKILL.md +1 -1
  13. package/atris/skills/design/SKILL.md +3 -1
  14. package/atris/skills/engines/SKILL.md +42 -50
  15. package/atris/skills/meta/SKILL.md +4 -4
  16. package/atris/skills/youtube/SKILL.md +11 -11
  17. package/bin/atris.js +60 -8
  18. package/commands/aeo.js +9 -12
  19. package/commands/analytics.js +8 -11
  20. package/commands/auth.js +8 -2
  21. package/commands/autopilot.js +4 -3
  22. package/commands/avail.js +1 -1
  23. package/commands/brain.js +4 -1
  24. package/commands/business.js +13 -8
  25. package/commands/close.js +2 -1
  26. package/commands/computer.js +15 -10
  27. package/commands/context-sync.js +3 -3
  28. package/commands/design.js +362 -0
  29. package/commands/doc-health.js +377 -0
  30. package/commands/drive.js +32 -0
  31. package/commands/experiments.js +13 -4
  32. package/commands/improve.js +68 -2
  33. package/commands/integrations.js +2 -5
  34. package/commands/land.js +144 -4
  35. package/commands/learn.js +7 -2
  36. package/commands/member.js +239 -58
  37. package/commands/mission.js +94 -18
  38. package/commands/pack.js +2 -2
  39. package/commands/probe.js +11 -5
  40. package/commands/pull.js +1 -1
  41. package/commands/pulse.js +38 -0
  42. package/commands/push.js +1 -1
  43. package/commands/rainmaker.js +3 -16
  44. package/commands/rsi.js +156 -0
  45. package/commands/spaceship.js +1 -1
  46. package/commands/stream.js +27 -2
  47. package/commands/study.js +15 -9
  48. package/commands/sync.js +1 -1
  49. package/commands/task.js +54 -3
  50. package/commands/workflow.js +17 -7
  51. package/commands/youtube.js +76 -8
  52. package/lib/apply-gate.js +22 -4
  53. package/lib/codex-flight.js +28 -5
  54. package/lib/daily-log.js +88 -0
  55. package/lib/design-api.js +130 -0
  56. package/lib/engine-ask.js +1 -1
  57. package/lib/first-minute.js +1 -6
  58. package/lib/fleet.js +4 -1
  59. package/lib/improve-vitals-html.js +3 -2
  60. package/lib/known-commands.js +3 -3
  61. package/lib/lesson-contradiction.js +2 -2
  62. package/lib/map-refs.js +183 -0
  63. package/lib/member-context.js +42 -0
  64. package/lib/mission-room.js +4 -4
  65. package/lib/noninteractive.js +15 -4
  66. package/lib/policy-lessons.js +1 -1
  67. package/lib/receipt-block.js +137 -28
  68. package/lib/rsi-record.js +335 -0
  69. package/lib/runner-command.js +1 -1
  70. package/lib/scorecard.js +4 -4
  71. package/lib/sleep-sync.js +11 -0
  72. package/lib/state-detection.js +36 -17
  73. package/lib/task-db.js +74 -53
  74. package/lib/task-list-keeper.js +192 -0
  75. package/lib/todo-fallback.js +10 -4
  76. package/lib/todo.js +22 -10
  77. package/lib/wish-delegate.js +19 -5
  78. package/mcp/atris-mcp/index.mjs +227 -0
  79. package/package.json +4 -2
  80. package/scripts/det/det.js +2 -2
  81. package/scripts/det/pr-description.js +2 -2
  82. package/scripts/det/test.js +2 -2
  83. package/scripts/det/ytnotes +43 -8
  84. package/utils/api.js +1 -1
  85. package/utils/auth.js +79 -14
  86. package/utils/backend-root.js +31 -0
  87. package/utils/config.js +5 -4
  88. package/utils/owner-identity.js +61 -0
  89. package/atris/policies/LESSONS.md +0 -13
  90. package/atris/skills/autopilot/atris-autopilot.md +0 -71
@@ -121,11 +121,7 @@ function getTasksFromTodoSection(content, sectionName) {
121
121
 
122
122
  if (tasks.length > 0) return tasks;
123
123
 
124
- return body
125
- .split('\n')
126
- .map((line) => line.trim())
127
- .filter((line) => /^-\s+/.test(line) && !/\(empty/i.test(line))
128
- .map((line) => line.replace(/^-+\s*/, '').trim());
124
+ return require('./todo-fallback').parseSection(content, sectionName).map(task => task.title);
129
125
  }
130
126
 
131
127
  function getTasksFromDbBucket(atrisDir, bucketName) {
@@ -149,7 +145,7 @@ function getTasksFromDbBucket(atrisDir, bucketName) {
149
145
 
150
146
  // Single-read task lane counts for boot/status surfaces.
151
147
  // Prefers the task DB (source of truth); falls back to TODO.md parsing.
152
- // Review counts only exist on the DB path, markdown views don't carry the lane.
148
+ // Generated markdown carries the Review lane, but cannot certify its proof.
153
149
  function getTaskCounts(atrisDir) {
154
150
  try {
155
151
  const taskDb = require('./task-db');
@@ -179,7 +175,7 @@ function getTaskCounts(atrisDir) {
179
175
  return {
180
176
  backlog: getBacklogTasks(atrisDir).length,
181
177
  active: getInProgressTasks(atrisDir).length,
182
- review: 0,
178
+ review: getTasksFromTodoSection(readTodoText(atrisDir), 'Review').length,
183
179
  reviewCertified: 0,
184
180
  source: 'todo'
185
181
  };
@@ -230,7 +226,7 @@ function getTaskGlance(atrisDir, sampleSize = 3) {
230
226
  return {
231
227
  backlog: backlogTitles.length,
232
228
  active: activeTitles.length,
233
- review: 0,
229
+ review: getTasksFromTodoSection(readTodoText(atrisDir), 'Review').length,
234
230
  reviewCertified: 0,
235
231
  activeTitles: activeTitles.slice(0, sampleSize),
236
232
  backlogTitles: backlogTitles.slice(0, sampleSize),
@@ -239,6 +235,10 @@ function getTaskGlance(atrisDir, sampleSize = 3) {
239
235
  };
240
236
  }
241
237
 
238
+ function readTodoText(atrisDir) {
239
+ try { return fs.readFileSync(path.join(atrisDir, 'TODO.md'), 'utf8'); } catch { return ''; }
240
+ }
241
+
242
242
  function getTodayInboxItems(workspaceDir) {
243
243
  const atrisDir = path.join(workspaceDir, 'atris');
244
244
  const logsDir = path.join(atrisDir, 'logs');
@@ -271,6 +271,32 @@ function getTodayInboxItems(workspaceDir) {
271
271
  /**
272
272
  * Detect workspace state: fresh install, has inbox items, in-progress work, or blocked
273
273
  */
274
+ // Blocked means a task whose status is failed: the DB has no separate
275
+ // blocked status, doneTask logs a failed task as a "blocked" event, and the
276
+ // board renders failed tasks under "## Blocked". Once this workspace has rows
277
+ // in the task DB, the DB is the only source. The DB file is shared by every
278
+ // workspace on the machine, so a workspace with no rows there falls back to
279
+ // the Blocked section of its own TODO.md, never a word elsewhere in a title.
280
+ function hasBlockedTasks(atrisDir, todoFile, legacyTaskContextFile) {
281
+ try {
282
+ const taskDb = require('./task-db');
283
+ if (fs.existsSync(taskDb.getDbPath())) {
284
+ const db = taskDb.open();
285
+ const workspaceRoot = taskDb.workspaceRoot(path.dirname(path.resolve(atrisDir)));
286
+ if (taskDb.listTasks(db, { workspaceRoot, limit: 1 }).length) {
287
+ return taskDb.listTasks(db, { workspaceRoot, status: 'failed', limit: 1 }).length > 0;
288
+ }
289
+ }
290
+ } catch (e) {
291
+ if (process.env.ATRIS_DEBUG) console.error('[state-detection] db blocked read failed:', e.message);
292
+ }
293
+ const taskFilePath = fs.existsSync(todoFile)
294
+ ? todoFile
295
+ : (fs.existsSync(legacyTaskContextFile) ? legacyTaskContextFile : null);
296
+ if (!taskFilePath) return false;
297
+ return getTasksFromTodoSection(fs.readFileSync(taskFilePath, 'utf8'), 'Blocked').length > 0;
298
+ }
299
+
274
300
  function detectWorkspaceState(workspaceDir) {
275
301
  const atrisDir = path.join(workspaceDir, 'atris');
276
302
  const featuresDir = path.join(atrisDir, 'features');
@@ -317,15 +343,8 @@ function detectWorkspaceState(workspaceDir) {
317
343
  }
318
344
  }
319
345
 
320
- // Check for blocked tasks (prefer TODO.md, fallback to legacy TASK_CONTEXTS.md)
321
- const taskFilePath = fs.existsSync(todoFile)
322
- ? todoFile
323
- : (fs.existsSync(legacyTaskContextFile) ? legacyTaskContextFile : null);
324
- if (taskFilePath) {
325
- const content = fs.readFileSync(taskFilePath, 'utf8');
326
- if (content.includes('Risk Level: High') || /\bblocked\b/i.test(content)) {
327
- return { state: 'blocked', reason: 'Tasks marked as blocked or high risk' };
328
- }
346
+ if (hasBlockedTasks(atrisDir, todoFile, legacyTaskContextFile)) {
347
+ return { state: 'blocked', reason: 'Tasks marked as blocked' };
329
348
  }
330
349
 
331
350
  // Default: setup complete but no active work
package/lib/task-db.js CHANGED
@@ -28,11 +28,13 @@ const os = require('os');
28
28
  const crypto = require('crypto');
29
29
  const { DatabaseSync } = require('node:sqlite');
30
30
  const reviewIntegrity = require('./review-integrity');
31
+ const dailyLog = require('./daily-log');
31
32
  const { isGenericScratchRoot } = require('./scratch-root');
32
33
  const { isDecisionTask } = require('./task-decision');
33
34
  const { parseVerifyCommand } = require('./auto-accept-certified');
34
35
  const { taskExplanation } = require('./task-explanation');
35
36
  const { treeHashFor } = require('./tree-hash');
37
+ const { sleepSync } = require('./sleep-sync');
36
38
 
37
39
  const DEFAULT_DB_PATH = path.join(os.homedir(), '.atris', 'tasks.db');
38
40
  const TASK_EPISODES_FILE = path.join('.atris', 'state', 'task_episodes.jsonl');
@@ -67,23 +69,6 @@ const TASK_PLAN_TAGS = new Set([
67
69
  'ux',
68
70
  ]);
69
71
 
70
- function todayLogName() {
71
- const now = new Date();
72
- return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}.md`;
73
- }
74
-
75
- function compactLogText(value, max = 240) {
76
- const text = String(value || '').replace(/\s+/g, ' ').trim();
77
- if (!text) return '';
78
- return text.length > max ? `${text.slice(0, Math.max(0, max - 3)).trim()}...` : text;
79
- }
80
-
81
- function logFieldRows(fields) {
82
- return Object.entries(fields)
83
- .filter(([, value]) => value !== undefined && value !== null && value !== '')
84
- .map(([key, value]) => `- ${key}: ${compactLogText(value, 500)}`);
85
- }
86
-
87
72
  function taskMemberCandidates(row, actor) {
88
73
  const metadata = row && row.metadata && typeof row.metadata === 'object' ? row.metadata : {};
89
74
  return [
@@ -100,21 +85,22 @@ function taskMemberCandidates(row, actor) {
100
85
 
101
86
  function existingMemberSlug(workspaceRoot, row, actor) {
102
87
  for (const candidate of taskMemberCandidates(row, actor)) {
103
- if (!/^[a-zA-Z0-9._-]+$/.test(candidate)) continue;
104
- const memberFile = path.join(workspaceRoot, 'atris', 'team', candidate, 'MEMBER.md');
105
- if (fs.existsSync(memberFile)) return candidate;
88
+ const slug = dailyLog.memberSlug(workspaceRoot, candidate);
89
+ if (slug) return slug;
106
90
  }
107
91
  return null;
108
92
  }
109
93
 
94
+ function taskLogRef(db, row) {
95
+ const allRows = listTasks(db, { workspaceRoot: row.workspace_root });
96
+ return taskDisplayRefMap(allRows).get(row.id) || shortestUniqueTaskRef(row.id, allRows.map(task => task.id), 8) || row.id;
97
+ }
98
+
110
99
  function appendTaskCompletionLogs(db, row, { status, actor, action, proof } = {}) {
111
100
  if (!row || !row.workspace_root || !fs.existsSync(path.join(row.workspace_root, 'atris'))) return {};
112
- const logName = todayLogName();
113
- const stamp = new Date().toTimeString().slice(0, 5);
114
- const allRows = listTasks(db, { workspaceRoot: row.workspace_root });
115
- const ref = taskDisplayRefMap(allRows).get(row.id) || shortestUniqueTaskRef(row.id, allRows.map(task => task.id), 8) || row.id;
101
+ const ref = taskLogRef(db, row);
116
102
  const metadata = row.metadata && typeof row.metadata === 'object' ? row.metadata : {};
117
- const proofText = compactLogText(proof || metadata.latest_agent_proof || metadata.verify || '', 500);
103
+ const proofText = dailyLog.compactLogText(proof || metadata.latest_agent_proof || metadata.verify || '', 500);
118
104
  const member = existingMemberSlug(row.workspace_root, row, actor);
119
105
  const title = status === 'archived' ? 'Task archived'
120
106
  : status === 'failed' ? 'Task failed'
@@ -131,26 +117,10 @@ function appendTaskCompletionLogs(db, row, { status, actor, action, proof } = {}
131
117
  proof: proofText,
132
118
  };
133
119
 
134
- const projectDir = path.join(row.workspace_root, 'atris', 'logs', logName.slice(0, 4));
135
- fs.mkdirSync(projectDir, { recursive: true });
136
- const projectLogPath = path.join(projectDir, logName);
137
- fs.appendFileSync(projectLogPath, [
138
- `## ${stamp} · ${title}`,
139
- ...logFieldRows(fields),
140
- '',
141
- ].join('\n'), 'utf8');
142
-
143
- let memberLogPath = null;
144
- if (member) {
145
- const memberLogsDir = path.join(row.workspace_root, 'atris', 'team', member, 'logs');
146
- fs.mkdirSync(memberLogsDir, { recursive: true });
147
- memberLogPath = path.join(memberLogsDir, logName);
148
- fs.appendFileSync(memberLogPath, [
149
- `## ${stamp} · ${title}`,
150
- ...logFieldRows({ ...fields, member }),
151
- '',
152
- ].join('\n'), 'utf8');
153
- }
120
+ const projectLogPath = dailyLog.appendMasterDailyEntry(row.workspace_root, title, fields);
121
+ const memberLogPath = member
122
+ ? dailyLog.appendMemberDailyEntry(row.workspace_root, member, title, fields)
123
+ : null;
154
124
 
155
125
  return {
156
126
  project_log_path: projectLogPath,
@@ -159,6 +129,25 @@ function appendTaskCompletionLogs(db, row, { status, actor, action, proof } = {}
159
129
  };
160
130
  }
161
131
 
132
+ function appendTaskWorkLog(db, row, { actor, title, note } = {}) {
133
+ if (!row || !row.workspace_root || !fs.existsSync(path.join(row.workspace_root, 'atris'))) return {};
134
+ const member = existingMemberSlug(row.workspace_root, row, actor);
135
+ const fields = {
136
+ task: taskLogRef(db, row),
137
+ title: row.title,
138
+ tag: row.tag,
139
+ actor,
140
+ note: dailyLog.compactLogText(note, 500),
141
+ };
142
+ const memberLogPath = member
143
+ ? dailyLog.appendMemberDailyEntry(row.workspace_root, member, title, fields)
144
+ : null;
145
+ const projectLogPath = dailyLog.MASTER_NOTE.test(String(note || ''))
146
+ ? dailyLog.appendMasterDailyEntry(row.workspace_root, dailyLog.noteTitle(note), { ...fields, member })
147
+ : null;
148
+ return { project_log_path: projectLogPath, member_log_path: memberLogPath, member };
149
+ }
150
+
162
151
  const SCHEMA = `
163
152
  CREATE TABLE IF NOT EXISTS tasks (
164
153
  id TEXT PRIMARY KEY,
@@ -186,6 +175,9 @@ CREATE TABLE IF NOT EXISTS task_events (
186
175
  );
187
176
  CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
188
177
  CREATE INDEX IF NOT EXISTS idx_tasks_workspace ON tasks(workspace_root);
178
+ CREATE INDEX IF NOT EXISTS idx_tasks_workspace_status_updated ON tasks(workspace_root, status, updated_at);
179
+ CREATE INDEX IF NOT EXISTS idx_tasks_workspace_blocker ON tasks(workspace_root, tag)
180
+ WHERE tag = 'mission-blocker';
189
181
  CREATE INDEX IF NOT EXISTS idx_tasks_claimed_by ON tasks(claimed_by);
190
182
  CREATE INDEX IF NOT EXISTS idx_task_events_task ON task_events(task_id, version);
191
183
  CREATE INDEX IF NOT EXISTS idx_task_events_ws ON task_events(workspace_root, created_at);
@@ -515,7 +507,9 @@ function claimTask(db, { id, claimedBy }) {
515
507
  eventType: 'claimed',
516
508
  payload: { claimed_by: claimedBy },
517
509
  });
518
- return { claimed: true, row: { ...row, metadata: row.metadata ? safeJSON(row.metadata) : null } };
510
+ const enriched = { ...row, metadata: row.metadata ? safeJSON(row.metadata) : null };
511
+ appendTaskWorkLog(db, enriched, { actor: claimedBy, title: 'Task claimed' });
512
+ return { claimed: true, row: enriched };
519
513
  }
520
514
  // Either id doesn't exist or status != 'open'. Tell the caller which.
521
515
  const row = db.prepare('SELECT id, status, claimed_by FROM tasks WHERE id = ?').get(id);
@@ -665,11 +659,12 @@ function doneTask(db, { id, status, actor, allowReview = false, action, proof, a
665
659
  return { updated: false };
666
660
  }
667
661
 
668
- function reapMissionBlockerTasks(db, { workspaceRoot: ws, missions = [], actor } = {}) {
662
+ function reapMissionBlockerTasks(db, { workspaceRoot: ws, missions = [], actor, rows = null, skipLogs = false } = {}) {
669
663
  const missionById = new Map((Array.isArray(missions) ? missions : [])
670
664
  .filter(mission => mission && mission.id)
671
665
  .map(mission => [String(mission.id), mission]));
672
- const candidates = listTasks(db, { workspaceRoot: ws || null, limit: null })
666
+ const source = Array.isArray(rows) ? rows : listTasks(db, { workspaceRoot: ws || null, limit: null });
667
+ const candidates = source
673
668
  .filter(row => OPEN_TASK_STATUSES.has(row.status))
674
669
  .filter(row => row.tag === 'mission-blocker')
675
670
  .filter(row => row.metadata && row.metadata.mission_id && row.metadata.mission_blocker_class)
@@ -683,6 +678,7 @@ function reapMissionBlockerTasks(db, { workspaceRoot: ws, missions = [], actor }
683
678
  id: row.id,
684
679
  actor,
685
680
  reason,
681
+ skipLogs,
686
682
  });
687
683
  if (!result.archived) continue;
688
684
  closed.push({
@@ -710,7 +706,7 @@ function reapMissionBlockerTasks(db, { workspaceRoot: ws, missions = [], actor }
710
706
  // metadata.archived_from. Individual archive commands never archive 'done'
711
707
  // rows. The clear-done sweep opts in through fromDone after selecting only the
712
708
  // same 'done' rows counted by `atris status`.
713
- function archiveTask(db, { id, actor, reason, fromFailed = false, fromDone = false } = {}) {
709
+ function archiveTask(db, { id, actor, reason, fromFailed = false, fromDone = false, skipLogs = false } = {}) {
714
710
  if (!id) throw new Error('id required');
715
711
  const reasonText = String(reason || '').trim();
716
712
  if (!reasonText) throw new Error('reason required');
@@ -752,7 +748,7 @@ function archiveTask(db, { id, actor, reason, fromFailed = false, fromDone = fal
752
748
  ...(['failed', 'done'].includes(row.status) ? { previous_status: row.status } : {}),
753
749
  },
754
750
  });
755
- const logs = appendTaskCompletionLogs(db, updated, {
751
+ const logs = skipLogs ? null : appendTaskCompletionLogs(db, updated, {
756
752
  status: 'archived',
757
753
  actor: metadata.archived_by,
758
754
  action: 'archived',
@@ -1519,6 +1515,11 @@ function noteTask(db, { id, actor, content }) {
1519
1515
  eventType: 'message',
1520
1516
  payload: { content: text },
1521
1517
  });
1518
+ // Structured trace notes already land on the member log through
1519
+ // task result/ready; logging them here would double the receipt.
1520
+ if (!/^TASK_[A-Z_]+_TRACE\s/.test(text)) {
1521
+ appendTaskWorkLog(db, row, { actor, title: 'Task note', note: text });
1522
+ }
1522
1523
  return { noted: true, event };
1523
1524
  }
1524
1525
 
@@ -2126,7 +2127,8 @@ function renderTodoMarkdown(rows, { title = 'TODO.md', doneLimit = TODO_RENDER_D
2126
2127
  failed: displayRows.filter(r => r.status === 'failed'),
2127
2128
  done: displayRows.filter(r => r.status === 'done'),
2128
2129
  };
2129
- const lines = [`# ${title}`, '', '> Regenerated from durable Atris task state. Do not treat this file as truth.', ''];
2130
+ const lines = [`# ${title}`, '', 'Details for any task: atris task show <ID>', '', '> Regenerated from durable Atris task state. Do not treat this file as truth.', ''];
2131
+ if (process.env.ATRIS_TODO_RENDER !== 'full') lines.push('<!-- ATRIS_TODO_COMPACT:2 -->', '');
2130
2132
  for (const section of preservedSections) {
2131
2133
  const text = String(section || '').trim();
2132
2134
  if (!text) continue;
@@ -2170,6 +2172,25 @@ function appendSection(lines, name, rows) {
2170
2172
  const decision = isDecisionTask(row) ? ' [decision]' : '';
2171
2173
  const displayRef = meta.todo_id || row.display_id || row.id;
2172
2174
  const explanation = taskExplanation(row);
2175
+ if (process.env.ATRIS_TODO_RENDER !== 'full') {
2176
+ const singleLine = value => String(value || '').replace(/\s+/g, ' ').trim();
2177
+ const clip = (value, limit) => {
2178
+ const text = singleLine(value);
2179
+ return text.length > limit ? text.slice(0, limit - 1) + '…' : text;
2180
+ };
2181
+ const owner = singleLine(row.claimed_by || meta.assigned_to || meta.owner) || 'unassigned';
2182
+ const title = clip(row.title, name === 'Completed' ? 100 : 140);
2183
+ const labels = name === 'Backlog' || name === 'Blocked' ? tag + decision : '';
2184
+ const assignment = name === 'Backlog' || name === 'Completed' ? '' : ` · ${owner}`;
2185
+ const previewLimit = name === 'Blocked' ? 60 : 40;
2186
+ const verify = (name === 'In Progress' || name === 'Blocked') && meta.verify
2187
+ ? ` · verify: ${singleLine(meta.verify).slice(0, previewLimit)}` : '';
2188
+ lines.push(`- **[${displayRef}]** ${title}${labels}${assignment}${verify}`);
2189
+ if (name === 'Review') {
2190
+ lines.push(` **Done looks like:** ${clip(explanation.done_looks_like, 160)}`);
2191
+ }
2192
+ continue;
2193
+ }
2173
2194
  // The plain face leads. The exact original title remains immediately below
2174
2195
  // it so old TODO-only projects and deep inspection keep full fidelity.
2175
2196
  lines.push(`- **[${displayRef}]** ${explanation.what_changes}${tag}${decision}`);
@@ -2204,8 +2225,7 @@ function withBusyRetry(fn, attempts = 8) {
2204
2225
  const busy = /SQLITE_BUSY|database is locked/i.test(msg) || code === 'SQLITE_BUSY' || code === 5;
2205
2226
  if (!busy) throw e;
2206
2227
  // Sleep synchronously, node:sqlite is sync; matches the rest of the API
2207
- const end = Date.now() + delay + Math.floor(Math.random() * delay);
2208
- while (Date.now() < end) {} // tight loop is fine, delay is small
2228
+ sleepSync(delay + Math.floor(Math.random() * delay));
2209
2229
  delay = Math.min(delay * 2, 500);
2210
2230
  }
2211
2231
  }
@@ -2214,6 +2234,7 @@ function withBusyRetry(fn, attempts = 8) {
2214
2234
 
2215
2235
  module.exports = {
2216
2236
  open,
2237
+ withBusyRetry,
2217
2238
  close,
2218
2239
  getDbPath,
2219
2240
  workspaceRoot,
@@ -0,0 +1,192 @@
1
+ 'use strict';
2
+
3
+ // Puts finished and untouched tasks off the visible list. History stays
4
+ // archived. A current list costs one indexed lookup and no writes.
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const taskDb = require('./task-db');
9
+
10
+ const DAY_MS = 24 * 60 * 60 * 1000;
11
+ const OPEN_STATUSES = new Set(['open', 'claimed', 'review']);
12
+
13
+ // Failed rows already leave the today screen after 7 days. They stay
14
+ // findable until 30 days, then they leave the list too.
15
+ const IDLE_LIMITS_MS = {
16
+ claimed: 3 * DAY_MS,
17
+ open: 14 * DAY_MS,
18
+ review: 14 * DAY_MS,
19
+ failed: 30 * DAY_MS,
20
+ };
21
+
22
+ const REASONS = {
23
+ done: 'finished work left the list',
24
+ claimed: 'claimed work sat untouched for 3 days',
25
+ open: 'waiting work sat untouched for 14 days',
26
+ review: 'review sat untouched for 14 days',
27
+ failed: 'failed work sat untouched for 30 days',
28
+ };
29
+
30
+ const CANDIDATE_SQL = `
31
+ SELECT id, title, status, tag, updated_at, created_at, done_at, metadata
32
+ FROM tasks
33
+ WHERE workspace_root = ? AND status = 'done'
34
+ UNION ALL
35
+ SELECT id, title, status, tag, updated_at, created_at, done_at, metadata
36
+ FROM tasks
37
+ WHERE workspace_root = ? AND status = 'claimed' AND updated_at < ?
38
+ UNION ALL
39
+ SELECT id, title, status, tag, updated_at, created_at, done_at, metadata
40
+ FROM tasks
41
+ WHERE workspace_root = ? AND status = 'open' AND updated_at < ?
42
+ UNION ALL
43
+ SELECT id, title, status, tag, updated_at, created_at, done_at, metadata
44
+ FROM tasks
45
+ WHERE workspace_root = ? AND status = 'review' AND updated_at < ?
46
+ UNION ALL
47
+ SELECT id, title, status, tag, updated_at, created_at, done_at, metadata
48
+ FROM tasks
49
+ WHERE workspace_root = ? AND status = 'failed' AND updated_at < ?
50
+ UNION ALL
51
+ SELECT id, title, status, tag, updated_at, created_at, done_at, metadata
52
+ FROM tasks
53
+ WHERE workspace_root = ? AND tag = 'mission-blocker' AND status IN ('open', 'claimed', 'review')
54
+ `;
55
+
56
+ function actorName(actor) {
57
+ return actor || process.env.ATRIS_AGENT_ID || process.env.USER || 'task-list';
58
+ }
59
+
60
+ function decisionFor(row, now) {
61
+ if (!row || row.status === 'archived') return null;
62
+ if (row.status === 'done') {
63
+ return { reason: REASONS.done, fromDone: true, fromFailed: false };
64
+ }
65
+ if (!Object.prototype.hasOwnProperty.call(IDLE_LIMITS_MS, row.status)) return null;
66
+ const at = Number(row.updated_at || row.created_at || 0);
67
+ if (!((now - at) > IDLE_LIMITS_MS[row.status])) return null;
68
+ return { reason: REASONS[row.status], fromDone: false, fromFailed: row.status === 'failed' };
69
+ }
70
+
71
+ function selectCandidates(db, workspaceRoot, now) {
72
+ return db.prepare(CANDIDATE_SQL).all(
73
+ workspaceRoot,
74
+ workspaceRoot, now - IDLE_LIMITS_MS.claimed,
75
+ workspaceRoot, now - IDLE_LIMITS_MS.open,
76
+ workspaceRoot, now - IDLE_LIMITS_MS.review,
77
+ workspaceRoot, now - IDLE_LIMITS_MS.failed,
78
+ workspaceRoot,
79
+ );
80
+ }
81
+
82
+ function blockerFrom(row) {
83
+ if (!row || row.tag !== 'mission-blocker' || !OPEN_STATUSES.has(row.status)) return null;
84
+ let metadata = row.metadata;
85
+ if (typeof metadata === 'string') {
86
+ try { metadata = JSON.parse(metadata); } catch { metadata = null; }
87
+ }
88
+ if (!metadata || !metadata.mission_id || !metadata.mission_blocker_class) return null;
89
+ return { ...row, metadata };
90
+ }
91
+
92
+ function loadMissions(workspaceRoot) {
93
+ try {
94
+ return require('../commands/mission').listMissions(workspaceRoot) || [];
95
+ } catch {
96
+ return [];
97
+ }
98
+ }
99
+
100
+ function noteKeep(workspaceRoot, actor, count) {
101
+ if (!count || !workspaceRoot || !fs.existsSync(path.join(workspaceRoot, 'atris'))) return;
102
+ try {
103
+ const now = new Date();
104
+ const year = String(now.getFullYear());
105
+ const dir = path.join(workspaceRoot, 'atris', 'logs', year);
106
+ fs.mkdirSync(dir, { recursive: true });
107
+ const name = `${year}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}.md`;
108
+ const stamp = now.toTimeString().slice(0, 5);
109
+ fs.appendFileSync(path.join(dir, name), `## ${stamp} · Task list kept\n- count: ${count}\n- actor: ${actor}\n\n`);
110
+ } catch {
111
+ // The rows are already off the list. A missed note should not put them back.
112
+ }
113
+ }
114
+
115
+ function keepTaskList(db, { workspaceRoot, actor, now = Date.now(), missions } = {}) {
116
+ const who = actorName(actor);
117
+ const seen = new Set();
118
+ const toArchive = [];
119
+ const blockers = [];
120
+ for (const row of selectCandidates(db, workspaceRoot, now)) {
121
+ if (!row || seen.has(row.id)) continue;
122
+ seen.add(row.id);
123
+ const decision = decisionFor(row, now);
124
+ if (decision) {
125
+ toArchive.push({ id: row.id, previous_status: row.status, ...decision });
126
+ continue;
127
+ }
128
+ const blocker = blockerFrom(row);
129
+ if (blocker) blockers.push(blocker);
130
+ }
131
+
132
+ const missionList = Array.isArray(missions)
133
+ ? missions
134
+ : (blockers.length ? loadMissions(workspaceRoot) : []);
135
+ if (!toArchive.length && !(blockers.length && missionList.length)) {
136
+ return { put_away: [], reaped: [] };
137
+ }
138
+
139
+ const putAway = [];
140
+ let reaped = [];
141
+ db.exec('BEGIN IMMEDIATE');
142
+ try {
143
+ for (const item of toArchive) {
144
+ const result = taskDb.archiveTask(db, {
145
+ id: item.id,
146
+ actor: who,
147
+ reason: item.reason,
148
+ fromDone: item.fromDone,
149
+ fromFailed: item.fromFailed,
150
+ skipLogs: true,
151
+ });
152
+ if (result.archived) {
153
+ putAway.push({ id: item.id, previous_status: item.previous_status, reason: item.reason });
154
+ }
155
+ }
156
+ if (blockers.length && missionList.length) {
157
+ reaped = taskDb.reapMissionBlockerTasks(db, {
158
+ workspaceRoot,
159
+ missions: missionList,
160
+ actor: who,
161
+ rows: blockers,
162
+ skipLogs: true,
163
+ }).closed || [];
164
+ }
165
+ db.exec('COMMIT');
166
+ } catch (error) {
167
+ try { db.exec('ROLLBACK'); } catch { /* the original error is the one to surface */ }
168
+ throw error;
169
+ }
170
+ noteKeep(workspaceRoot, who, putAway.length + reaped.length);
171
+ return { put_away: putAway, reaped };
172
+ }
173
+
174
+ function keepWorkspaceTaskList(cwd = process.cwd(), { actor } = {}) {
175
+ const dbPath = taskDb.getDbPath();
176
+ if (!dbPath || !fs.existsSync(dbPath)) {
177
+ return { put_away: [], reaped: [], skipped: 'no_db' };
178
+ }
179
+ const db = taskDb.open();
180
+ return keepTaskList(db, {
181
+ workspaceRoot: taskDb.workspaceRoot(cwd),
182
+ actor,
183
+ now: Date.now(),
184
+ });
185
+ }
186
+
187
+ module.exports = {
188
+ DAY_MS,
189
+ IDLE_LIMITS_MS,
190
+ keepTaskList,
191
+ keepWorkspaceTaskList,
192
+ };
@@ -34,7 +34,7 @@ function cleanTaskTitle(text) {
34
34
  const withoutTags = raw.replace(/\s*\[[\w-]+\]/g, '').trim();
35
35
  const bold = withoutTags.match(/^\*\*(.+?)\*\*\s*(?:[\u2014-]\s*)?(.*)$/);
36
36
  if (!bold) return withoutTags;
37
- return [bold[1], bold[2]].filter(Boolean).join(' \u2014 ').trim();
37
+ return [bold[1], bold[2]].filter(Boolean).join(' - ').replace(/\s*\u2014\s*/g, ' - ').trim();
38
38
  }
39
39
 
40
40
  function parseSection(content, sectionName) {
@@ -80,15 +80,21 @@ function parseSection(content, sectionName) {
80
80
  const bracketTaskMatch = line.match(/^- \*\*\[([^\]\n]+)\]\*\*\s+(.+)$/);
81
81
  if (bracketTaskMatch) {
82
82
  if (current) tasks.push(current);
83
- const { allTags, tag } = tagsFromText(bracketTaskMatch[2]);
83
+ // Compact rows carry display-only verification previews. Never execute
84
+ // a clipped command; the full command lives in task show or the DB.
85
+ const titleOnly = content.includes('<!-- ATRIS_TODO_COMPACT:2 -->') && /^(Backlog|Completed)$/i.test(sectionName);
86
+ const compact = !titleOnly && bracketTaskMatch[2].match(/^(.*?) · ([^·]+?)(?: · verify: (.*))?$/);
87
+ const title = compact ? compact[1] : bracketTaskMatch[2];
88
+ const { allTags, tag } = tagsFromText(title);
84
89
  current = {
85
90
  id: bracketTaskMatch[1],
86
- title: cleanTaskTitle(bracketTaskMatch[2]),
91
+ title: cleanTaskTitle(title),
87
92
  tag,
88
93
  tags: allTags,
89
- claimed: null,
94
+ claimed: compact && compact[2] !== 'unassigned' ? compact[2].trim() : null,
90
95
  stage: null,
91
96
  verify: null,
97
+ ...(compact || titleOnly ? { verify_preview: compact ? compact[3] || null : null } : {}),
92
98
  };
93
99
  continue;
94
100
  }
package/lib/todo.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // SHIM. Same export surface as the legacy markdown parser, but reads from the
2
2
  // SQLite task store first when ATRIS_TASK_DB=1 is set, falling back to the
3
- // pure markdown parser at lib/todo-fallback.js.
3
+ // pure markdown parser at lib/todo-fallback.js. Compact generated boards also
4
+ // consult an existing store so verification previews never replace commands.
4
5
  //
5
6
  // All 3 callers (commands/autopilot.js, commands/status.js, commands/run.js)
6
7
  // inherit the strangler without changing their import path.
@@ -22,12 +23,14 @@ function dbToShimRow(row) {
22
23
  ? metadata.verify.trim()
23
24
  : null;
24
25
  const claimed = row.claimed_by || metadata.claimed || null;
26
+ const tags = [...new Set([row.tag, ...(Array.isArray(metadata.todo_tags) ? metadata.todo_tags : []),
27
+ ...(Array.isArray(metadata.tags) ? metadata.tags : [])].filter(Boolean))];
25
28
  // Map DB row → the shape the existing consumers expect from parseTodo().
26
29
  return {
27
30
  id: row.id,
28
31
  title: row.title,
29
- tag: row.tag || null,
30
- tags: row.tag ? [row.tag] : [],
32
+ tag: tags.includes('endgame') ? 'endgame' : (row.tag || tags[0] || null),
33
+ tags,
31
34
  claimed,
32
35
  stage: row.status === 'claimed' ? 'in_progress' : (metadata.stage || null),
33
36
  verify,
@@ -38,25 +41,29 @@ function dbToShimRow(row) {
38
41
  function dbBuckets(workspaceRoot) {
39
42
  const taskDb = require('./task-db');
40
43
  const db = taskDb.open();
41
- const rows = taskDb.listTasks(db, { workspaceRoot, limit: 500 });
44
+ const rows = taskDb.withTaskDisplayRefs(taskDb.listTasks(db, { workspaceRoot, limit: 500 }));
42
45
  // Need raw source_key for merge dedup, plus the shim shape for callers.
43
46
  const backlog = [];
44
47
  const inProgress = [];
45
48
  const review = [];
46
49
  const completed = [];
47
50
  const sourceKeys = new Set();
51
+ const sourceRefs = new Set();
48
52
  // Query directly so the shim can dedup against markdown by the strong key
49
53
  // even if future list filters hide rows.
50
54
  const stmt = db.prepare('SELECT id, source_key FROM tasks WHERE workspace_root = ? AND source_key IS NOT NULL');
51
55
  for (const r of stmt.all(workspaceRoot)) sourceKeys.add(r.source_key);
52
56
  for (const r of rows) {
57
+ for (const ref of [r.id, r.display_id, r.legacy_ref, r.metadata?.todo_id]) {
58
+ if (ref) sourceRefs.add(String(ref).toLowerCase());
59
+ }
53
60
  const shaped = dbToShimRow(r);
54
61
  if (r.status === 'open') backlog.push(shaped);
55
62
  else if (r.status === 'claimed') inProgress.push(shaped);
56
63
  else if (r.status === 'review') review.push(shaped);
57
64
  else if (r.status === 'done' || r.status === 'failed') completed.push(shaped);
58
65
  }
59
- return { backlog, inProgress, review, completed, sourceKeys };
66
+ return { backlog, inProgress, review, completed, sourceKeys, sourceRefs };
60
67
  }
61
68
 
62
69
  function mergeBuckets(dbBuck, mdBuck, todoPath) {
@@ -71,6 +78,7 @@ function mergeBuckets(dbBuck, mdBuck, todoPath) {
71
78
  for (const k of ['backlog', 'inProgress', 'review', 'completed']) {
72
79
  for (const r of dbBuck[k]) { out[k].push(r); seenTitles.add(norm(r.title)); }
73
80
  for (const r of mdBuck[k]) {
81
+ if (r.id && dbBuck.sourceRefs?.has(String(r.id).toLowerCase())) continue;
74
82
  const sk = todoPath ? taskDb.sourceKey(todoPath, r.title) : null;
75
83
  if (sk && dbBuck.sourceKeys && dbBuck.sourceKeys.has(sk)) continue;
76
84
  if (seenTitles.has(norm(r.title))) continue;
@@ -81,14 +89,19 @@ function mergeBuckets(dbBuck, mdBuck, todoPath) {
81
89
  }
82
90
 
83
91
  function parseTodo(todoPath) {
84
- // Legacy pure-markdown path.
85
- if (!TASK_DB_ENABLED) return fallback.parseTodoFile(todoPath);
92
+ const mdBuck = fallback.parseTodoFile(todoPath);
93
+ const compact = Object.values(mdBuck).some(rows => rows.some(row => Object.hasOwn(row, 'verify_preview')));
94
+ // Compact generated boards omit full commands. Resolve them from existing
95
+ // durable state even without the legacy opt-in, without minting a database.
96
+ if (!TASK_DB_ENABLED && !compact) return mdBuck;
86
97
 
87
98
  // DB-first merged view. Workspace scope = directory containing todoPath
88
99
  // (or its parent), so the DB stays per-repo.
89
100
  const path = require('path');
90
101
  const fs = require('fs');
91
- const taskDb = require('./task-db');
102
+ let taskDb;
103
+ try { taskDb = require('./task-db'); } catch { return mdBuck; }
104
+ if (!TASK_DB_ENABLED && !fs.existsSync(taskDb.getDbPath())) return mdBuck;
92
105
  let workspaceRoot;
93
106
  try {
94
107
  const todoAbs = path.resolve(todoPath);
@@ -106,9 +119,8 @@ function parseTodo(todoPath) {
106
119
  } catch (e) {
107
120
  // If sqlite blew up (missing in node, perms), don't break the legacy path.
108
121
  if (process.env.ATRIS_DEBUG) console.error('[todo shim] db read failed:', e.message);
109
- return fallback.parseTodoFile(todoPath);
122
+ return mdBuck;
110
123
  }
111
- const mdBuck = fallback.parseTodoFile(todoPath);
112
124
  return mergeBuckets(dbBuck, mdBuck, todoPath);
113
125
  }
114
126