atris 3.58.5 → 3.58.7

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 (42) hide show
  1. package/README.md +8 -0
  2. package/atris/policies/engineering-principles.md +129 -0
  3. package/atris/policies/genesis.md +112 -0
  4. package/atris/policies/product-design-principles.md +100 -0
  5. package/atris/skills/design/SKILL.md +3 -1
  6. package/atris/skills/engines/SKILL.md +3 -3
  7. package/atris/skills/x-search/SKILL.md +2 -2
  8. package/atris/skills/youtube/SKILL.md +44 -28
  9. package/bin/atris.js +56 -3
  10. package/commands/auth.js +58 -24
  11. package/commands/brain.js +1 -0
  12. package/commands/design.js +362 -0
  13. package/commands/doc-health.js +329 -0
  14. package/commands/drive.js +32 -0
  15. package/commands/improve.js +67 -1
  16. package/commands/land.js +144 -4
  17. package/commands/learn.js +211 -40
  18. package/commands/member.js +65 -11
  19. package/commands/mission.js +37 -7
  20. package/commands/pulse.js +38 -0
  21. package/commands/rsi.js +156 -0
  22. package/commands/task.js +41 -1
  23. package/commands/workflow.js +15 -14
  24. package/commands/x-search.js +9 -10
  25. package/commands/youtube.js +518 -107
  26. package/lib/apply-gate.js +22 -4
  27. package/lib/daily-log.js +88 -0
  28. package/lib/design-api.js +130 -0
  29. package/lib/engine-ask.js +1 -1
  30. package/lib/first-minute.js +1 -6
  31. package/lib/known-commands.js +3 -3
  32. package/lib/member-context.js +42 -0
  33. package/lib/rsi-record.js +335 -0
  34. package/lib/state-detection.js +8 -8
  35. package/lib/task-db.js +71 -51
  36. package/lib/task-list-keeper.js +192 -0
  37. package/lib/todo-fallback.js +9 -3
  38. package/lib/todo.js +22 -10
  39. package/mcp/atris-mcp/index.mjs +174 -0
  40. package/package.json +8 -3
  41. package/scripts/det/ytnotes +122 -10
  42. package/utils/auth.js +109 -13
@@ -0,0 +1,335 @@
1
+ 'use strict';
2
+
3
+ // Record one bounded improvement attempt into the Dream-RSI ledger.
4
+ //
5
+ // When the workspace root contains backend/scripts/rsi/record.py (the
6
+ // backend checkout with the recorder), a command that runs one bounded
7
+ // attempt at improving the workspace (improve tick, pulse tick, drive)
8
+ // wraps the work in start-tree -> choose -> open -> finish. Every step is
9
+ // best-effort: a missing recorder, a failed choose, or a dead python logs
10
+ // one line and the command's behavior and exit code stay untouched.
11
+ //
12
+ // State lands in $ATRIS_RSI_STATE (default <root>/.atris/state/rsi), the
13
+ // same paths backend/scripts/rsi/schema.py uses.
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const { spawnSync } = require('child_process');
18
+
19
+ // Lane name for bounded self-improvement attempts recorded from this CLI.
20
+ const IMPROVE_LANE = 'improve_tick';
21
+ // While an attempt node is open, nested atris subprocesses (local mission
22
+ // fallback, autopilot, mission ticks spawned by drive) must not open their
23
+ // own nodes: one command run is one attempt.
24
+ const GUARD_ENV = 'ATRIS_RSI_ATTEMPT_NODE';
25
+ const SPAWN_TIMEOUT_MS = 20000;
26
+ const NODE_SCHEMA = 'atris.rsi.node.v1';
27
+
28
+ function scriptsDir(root) {
29
+ return path.join(root, 'backend', 'scripts', 'rsi');
30
+ }
31
+
32
+ function recorderPath(root) {
33
+ const p = path.join(scriptsDir(root), 'record.py');
34
+ return fs.existsSync(p) ? p : null;
35
+ }
36
+
37
+ // The repo's own venv first (same as scripts/rsi_record_wrap.sh), else
38
+ // whatever python3 is on PATH.
39
+ function pythonBin(root) {
40
+ const venv = path.join(root, 'venv', 'bin', 'python');
41
+ try {
42
+ fs.accessSync(venv, fs.constants.X_OK);
43
+ return venv;
44
+ } catch {
45
+ return 'python3';
46
+ }
47
+ }
48
+
49
+ function stateDir(root) {
50
+ return process.env.ATRIS_RSI_STATE || path.join(root, '.atris', 'state', 'rsi');
51
+ }
52
+
53
+ function attemptsPath(root) {
54
+ return path.join(stateDir(root), 'attempts.jsonl');
55
+ }
56
+
57
+ function dreamsPath(root) {
58
+ return path.join(stateDir(root), 'dreams.jsonl');
59
+ }
60
+
61
+ function currentPolicyId(root, fallback = 'p-0001') {
62
+ try {
63
+ const raw = fs.readFileSync(path.join(root, 'atris', 'rsi', 'policy.current'), 'utf8').trim();
64
+ return raw || fallback;
65
+ } catch {
66
+ return fallback;
67
+ }
68
+ }
69
+
70
+ function localDate(d = new Date()) {
71
+ const y = d.getFullYear();
72
+ const m = String(d.getMonth() + 1).padStart(2, '0');
73
+ const day = String(d.getDate()).padStart(2, '0');
74
+ return `${y}-${m}-${day}`;
75
+ }
76
+
77
+ // "<prefix>-YYYY-MM-DD[-N]" -> "YYYY-MM-DD" (record.py start-tree shape).
78
+ function treeDate(treeId) {
79
+ const parts = String(treeId || '').split('-');
80
+ if (parts.length < 4) return null;
81
+ const date = parts.slice(1, 4).join('-');
82
+ return /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null;
83
+ }
84
+
85
+ // Read attempts.jsonl tolerantly: blank lines, non-JSON, and foreign rows
86
+ // are skipped. Latest row per node id wins (finish appends a new row).
87
+ function readAttemptRows(root) {
88
+ let lines;
89
+ try {
90
+ lines = fs.readFileSync(attemptsPath(root), 'utf8').split(/\r?\n/);
91
+ } catch {
92
+ return [];
93
+ }
94
+ const rows = [];
95
+ for (const line of lines) {
96
+ const trimmed = line.trim();
97
+ if (!trimmed) continue;
98
+ try {
99
+ const row = JSON.parse(trimmed);
100
+ if (row && row.schema === NODE_SCHEMA) rows.push(row);
101
+ } catch {
102
+ // skip non-JSON rows
103
+ }
104
+ }
105
+ return rows;
106
+ }
107
+
108
+ function latestNodes(root) {
109
+ const latest = new Map();
110
+ for (const row of readAttemptRows(root)) {
111
+ if (row && row.id) latest.set(String(row.id), row);
112
+ }
113
+ return [...latest.values()];
114
+ }
115
+
116
+ // Reuse today's tree for the lane when one exists (the newest one wins when
117
+ // a day has several), so every tick on a day shares one tree.
118
+ function todayTreeId(root, lane, today = localDate()) {
119
+ const matches = new Set();
120
+ for (const row of readAttemptRows(root)) {
121
+ if (row && row.lane === lane && treeDate(row.tree_id) === today) matches.add(String(row.tree_id));
122
+ }
123
+ const sorted = [...matches].sort();
124
+ return sorted.length ? sorted[sorted.length - 1] : null;
125
+ }
126
+
127
+ function runPy(root, script, args, timeoutMs = SPAWN_TIMEOUT_MS) {
128
+ const scriptPath = path.join(scriptsDir(root), script);
129
+ const r = spawnSync(pythonBin(root), [scriptPath, ...args], {
130
+ cwd: root,
131
+ encoding: 'utf8',
132
+ timeout: timeoutMs,
133
+ env: process.env,
134
+ });
135
+ return {
136
+ ok: r.status === 0,
137
+ stdout: String(r.stdout || '').trim(),
138
+ stderr: String(r.stderr || '').trim(),
139
+ error: r.error || null,
140
+ };
141
+ }
142
+
143
+ function startTree(root, lane) {
144
+ const today = localDate();
145
+ const existing = todayTreeId(root, lane, today);
146
+ if (existing) return existing;
147
+ const r = runPy(root, 'record.py', ['start-tree', '--lane', lane, '--date', today]);
148
+ const id = r.ok ? (r.stdout.split('\n').pop() || '').trim() : '';
149
+ return id || null;
150
+ }
151
+
152
+ // Ask the live policy for one action (--w 1). Returns the first action or
153
+ // null; an empty list means the policy chose to stop, a failure returns null.
154
+ function chooseAction(root, treeId, { queueItems = 0, w = 1 } = {}) {
155
+ const choose = path.join(scriptsDir(root), 'choose.py');
156
+ if (!fs.existsSync(choose)) return null;
157
+ const r = runPy(root, 'choose.py', [
158
+ '--tree', treeId,
159
+ '--date', localDate(),
160
+ '--queue-items', String(queueItems),
161
+ '--w', String(w),
162
+ ]);
163
+ if (!r.ok) return null;
164
+ try {
165
+ const actions = JSON.parse(r.stdout);
166
+ return Array.isArray(actions) && actions.length ? actions[0] : null;
167
+ } catch {
168
+ return null;
169
+ }
170
+ }
171
+
172
+ function gitHead(root, short = false) {
173
+ try {
174
+ const args = short ? ['rev-parse', '--short', 'HEAD'] : ['rev-parse', 'HEAD'];
175
+ const r = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8', timeout: 15000 });
176
+ return r.status === 0 ? String(r.stdout || '').trim() : null;
177
+ } catch {
178
+ return null;
179
+ }
180
+ }
181
+
182
+ function gitDirtyFiles(root) {
183
+ try {
184
+ const r = spawnSync('git', ['-C', root, 'status', '--porcelain'], { encoding: 'utf8', timeout: 15000 });
185
+ if (r.status !== 0) return [];
186
+ return String(r.stdout || '')
187
+ .split('\n')
188
+ .map((l) => l.slice(3).trim())
189
+ .filter(Boolean)
190
+ .map((f) => {
191
+ const arrow = f.indexOf(' -> ');
192
+ return arrow >= 0 ? f.slice(arrow + 4) : f;
193
+ });
194
+ } catch {
195
+ return [];
196
+ }
197
+ }
198
+
199
+ // A cheap working-tree snapshot: HEAD + the set of dirty paths. Comparing
200
+ // two snapshots gives a tick's ACTUAL contribution; pre-existing dirt is
201
+ // excluded so a tick is not credited for mess it did not make.
202
+ function gitSnapshot(root) {
203
+ return { head: gitHead(root), dirty: new Set(gitDirtyFiles(root)) };
204
+ }
205
+
206
+ // Commits and files between a captured snapshot (or bare HEAD string) and
207
+ // now: the committed diff plus paths the tick newly dirtied.
208
+ function gitDelta(root, before) {
209
+ const out = { commits: 0, files: [] };
210
+ if (!root) return out;
211
+ const beforeHead = typeof before === 'string' ? before : (before && before.head);
212
+ const beforeDirty = before && before.dirty instanceof Set ? before.dirty : new Set();
213
+ const files = new Set();
214
+ try {
215
+ const after = gitHead(root);
216
+ if (beforeHead && after && beforeHead !== after) {
217
+ const count = spawnSync('git', ['-C', root, 'rev-list', '--count', `${beforeHead}..${after}`], { encoding: 'utf8', timeout: 15000 });
218
+ if (count.status === 0) out.commits = Number(String(count.stdout).trim()) || 0;
219
+ const diff = spawnSync('git', ['-C', root, 'diff', '--name-only', beforeHead, after], { encoding: 'utf8', timeout: 15000 });
220
+ if (diff.status === 0) String(diff.stdout).split('\n').map((f) => f.trim()).filter(Boolean).forEach((f) => files.add(f));
221
+ }
222
+ for (const f of gitDirtyFiles(root)) {
223
+ if (!beforeDirty.has(f)) files.add(f);
224
+ }
225
+ } catch {
226
+ // git missing or not a repo: commits 0, no files
227
+ }
228
+ out.files = [...files].slice(0, 200);
229
+ return out;
230
+ }
231
+
232
+ // Map a model name to a recorded engine. The schema's ENGINES are
233
+ // codex/devin/cursor/agy/claude; unknown models return null so callers fall
234
+ // back to 'claude' (the CLI's own runner default).
235
+ function engineFromModel(model) {
236
+ const m = String(model || '').toLowerCase();
237
+ if (!m) return null;
238
+ if (m.includes('codex')) return 'codex';
239
+ if (m.includes('devin')) return 'devin';
240
+ if (m.includes('cursor') || m.includes('composer')) return 'cursor';
241
+ if (m.includes('agy') || m.includes('antigravity')) return 'agy';
242
+ if (m.includes('claude') || m.includes('sonnet') || m.includes('opus') || m.includes('haiku')) return 'claude';
243
+ return null;
244
+ }
245
+
246
+ /**
247
+ * Open one recorded attempt. Returns null when the workspace has no
248
+ * recorder, when a parent attempt is already open (nested call), or when
249
+ * any recorder step fails. Never throws.
250
+ *
251
+ * opts.engine: the engine the tick will actually use (default 'claude').
252
+ * opts.ground: the ground the tick targets; '' is allowed when the tick has
253
+ * no notion of target.
254
+ * The returned attempt carries .hint = the policy-chosen action (or null),
255
+ * so a tick with a notion of target can steer by hint.ground.
256
+ */
257
+ function beginAttempt(root, { lane = IMPROVE_LANE, engine = 'claude', ground = '', queueItems = 0, log = () => {} } = {}) {
258
+ try {
259
+ if (!root || process.env[GUARD_ENV]) return null;
260
+ if (!recorderPath(root)) return null;
261
+ const tree = startTree(root, lane);
262
+ if (!tree) {
263
+ log('rsi: recorder present but start-tree failed; attempt not recorded');
264
+ return null;
265
+ }
266
+ const chosen = chooseAction(root, tree, { queueItems, w: 1 });
267
+ const action = {
268
+ policy_id: (chosen && chosen.policy_id) || currentPolicyId(root),
269
+ kind: 'open',
270
+ // The ground the tick actually targeted. Callers without a notion of
271
+ // target pass '' (allowed); the policy's pick is exposed as .hint
272
+ // instead of being claimed as the ground the tick took.
273
+ ground: String(ground || ''),
274
+ engine: engine || 'claude',
275
+ model: chosen && chosen.model != null ? chosen.model : null,
276
+ cap_s: Number(chosen && chosen.cap_s) || 1800,
277
+ prompt_variant: (chosen && chosen.prompt_variant) || 'v1',
278
+ };
279
+ const context = {
280
+ base_commit: gitHead(root, true) || '',
281
+ ground: action.ground,
282
+ queue_items: queueItems,
283
+ };
284
+ const r = runPy(root, 'record.py', [
285
+ 'open', '--tree', tree, '--lane', lane,
286
+ '--action-json', JSON.stringify(action),
287
+ '--context-json', JSON.stringify(context),
288
+ ]);
289
+ const node = r.ok ? (r.stdout.split('\n').pop() || '').trim() : '';
290
+ if (!node) {
291
+ log('rsi: node open failed; attempt not recorded');
292
+ return null;
293
+ }
294
+ process.env[GUARD_ENV] = node;
295
+ return { tree, node, lane, action, hint: chosen || null };
296
+ } catch (e) {
297
+ log(`rsi: attempt record skipped (${e && e.message ? e.message : e})`);
298
+ return null;
299
+ }
300
+ }
301
+
302
+ // Close an attempt opened by beginAttempt. Never throws; returns true when
303
+ // the finish row was written.
304
+ function finishAttempt(root, attempt, outcome = {}, { log = () => {} } = {}) {
305
+ if (!attempt || !attempt.node) return false;
306
+ if (process.env[GUARD_ENV] === attempt.node) delete process.env[GUARD_ENV];
307
+ try {
308
+ const r = runPy(root, 'record.py', [
309
+ 'finish', '--node', attempt.node,
310
+ '--outcome-json', JSON.stringify(outcome),
311
+ ]);
312
+ if (!r.ok) {
313
+ log(`rsi: node finish failed${r.stderr ? ` (${r.stderr.split('\n').pop()})` : ''}`);
314
+ return false;
315
+ }
316
+ return true;
317
+ } catch (e) {
318
+ log(`rsi: node finish skipped (${e && e.message ? e.message : e})`);
319
+ return false;
320
+ }
321
+ }
322
+
323
+ module.exports = {
324
+ IMPROVE_LANE,
325
+ GUARD_ENV,
326
+ attemptsPath,
327
+ dreamsPath,
328
+ currentPolicyId,
329
+ latestNodes,
330
+ gitSnapshot,
331
+ gitDelta,
332
+ engineFromModel,
333
+ beginAttempt,
334
+ finishAttempt,
335
+ };
@@ -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');
package/lib/task-db.js CHANGED
@@ -28,6 +28,7 @@ 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');
@@ -67,23 +68,6 @@ const TASK_PLAN_TAGS = new Set([
67
68
  'ux',
68
69
  ]);
69
70
 
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
71
  function taskMemberCandidates(row, actor) {
88
72
  const metadata = row && row.metadata && typeof row.metadata === 'object' ? row.metadata : {};
89
73
  return [
@@ -100,21 +84,22 @@ function taskMemberCandidates(row, actor) {
100
84
 
101
85
  function existingMemberSlug(workspaceRoot, row, actor) {
102
86
  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;
87
+ const slug = dailyLog.memberSlug(workspaceRoot, candidate);
88
+ if (slug) return slug;
106
89
  }
107
90
  return null;
108
91
  }
109
92
 
93
+ function taskLogRef(db, row) {
94
+ const allRows = listTasks(db, { workspaceRoot: row.workspace_root });
95
+ return taskDisplayRefMap(allRows).get(row.id) || shortestUniqueTaskRef(row.id, allRows.map(task => task.id), 8) || row.id;
96
+ }
97
+
110
98
  function appendTaskCompletionLogs(db, row, { status, actor, action, proof } = {}) {
111
99
  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;
100
+ const ref = taskLogRef(db, row);
116
101
  const metadata = row.metadata && typeof row.metadata === 'object' ? row.metadata : {};
117
- const proofText = compactLogText(proof || metadata.latest_agent_proof || metadata.verify || '', 500);
102
+ const proofText = dailyLog.compactLogText(proof || metadata.latest_agent_proof || metadata.verify || '', 500);
118
103
  const member = existingMemberSlug(row.workspace_root, row, actor);
119
104
  const title = status === 'archived' ? 'Task archived'
120
105
  : status === 'failed' ? 'Task failed'
@@ -131,26 +116,10 @@ function appendTaskCompletionLogs(db, row, { status, actor, action, proof } = {}
131
116
  proof: proofText,
132
117
  };
133
118
 
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
- }
119
+ const projectLogPath = dailyLog.appendMasterDailyEntry(row.workspace_root, title, fields);
120
+ const memberLogPath = member
121
+ ? dailyLog.appendMemberDailyEntry(row.workspace_root, member, title, fields)
122
+ : null;
154
123
 
155
124
  return {
156
125
  project_log_path: projectLogPath,
@@ -159,6 +128,25 @@ function appendTaskCompletionLogs(db, row, { status, actor, action, proof } = {}
159
128
  };
160
129
  }
161
130
 
131
+ function appendTaskWorkLog(db, row, { actor, title, note } = {}) {
132
+ if (!row || !row.workspace_root || !fs.existsSync(path.join(row.workspace_root, 'atris'))) return {};
133
+ const member = existingMemberSlug(row.workspace_root, row, actor);
134
+ const fields = {
135
+ task: taskLogRef(db, row),
136
+ title: row.title,
137
+ tag: row.tag,
138
+ actor,
139
+ note: dailyLog.compactLogText(note, 500),
140
+ };
141
+ const memberLogPath = member
142
+ ? dailyLog.appendMemberDailyEntry(row.workspace_root, member, title, fields)
143
+ : null;
144
+ const projectLogPath = dailyLog.MASTER_NOTE.test(String(note || ''))
145
+ ? dailyLog.appendMasterDailyEntry(row.workspace_root, dailyLog.noteTitle(note), { ...fields, member })
146
+ : null;
147
+ return { project_log_path: projectLogPath, member_log_path: memberLogPath, member };
148
+ }
149
+
162
150
  const SCHEMA = `
163
151
  CREATE TABLE IF NOT EXISTS tasks (
164
152
  id TEXT PRIMARY KEY,
@@ -186,6 +174,9 @@ CREATE TABLE IF NOT EXISTS task_events (
186
174
  );
187
175
  CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
188
176
  CREATE INDEX IF NOT EXISTS idx_tasks_workspace ON tasks(workspace_root);
177
+ CREATE INDEX IF NOT EXISTS idx_tasks_workspace_status_updated ON tasks(workspace_root, status, updated_at);
178
+ CREATE INDEX IF NOT EXISTS idx_tasks_workspace_blocker ON tasks(workspace_root, tag)
179
+ WHERE tag = 'mission-blocker';
189
180
  CREATE INDEX IF NOT EXISTS idx_tasks_claimed_by ON tasks(claimed_by);
190
181
  CREATE INDEX IF NOT EXISTS idx_task_events_task ON task_events(task_id, version);
191
182
  CREATE INDEX IF NOT EXISTS idx_task_events_ws ON task_events(workspace_root, created_at);
@@ -515,7 +506,9 @@ function claimTask(db, { id, claimedBy }) {
515
506
  eventType: 'claimed',
516
507
  payload: { claimed_by: claimedBy },
517
508
  });
518
- return { claimed: true, row: { ...row, metadata: row.metadata ? safeJSON(row.metadata) : null } };
509
+ const enriched = { ...row, metadata: row.metadata ? safeJSON(row.metadata) : null };
510
+ appendTaskWorkLog(db, enriched, { actor: claimedBy, title: 'Task claimed' });
511
+ return { claimed: true, row: enriched };
519
512
  }
520
513
  // Either id doesn't exist or status != 'open'. Tell the caller which.
521
514
  const row = db.prepare('SELECT id, status, claimed_by FROM tasks WHERE id = ?').get(id);
@@ -665,11 +658,12 @@ function doneTask(db, { id, status, actor, allowReview = false, action, proof, a
665
658
  return { updated: false };
666
659
  }
667
660
 
668
- function reapMissionBlockerTasks(db, { workspaceRoot: ws, missions = [], actor } = {}) {
661
+ function reapMissionBlockerTasks(db, { workspaceRoot: ws, missions = [], actor, rows = null, skipLogs = false } = {}) {
669
662
  const missionById = new Map((Array.isArray(missions) ? missions : [])
670
663
  .filter(mission => mission && mission.id)
671
664
  .map(mission => [String(mission.id), mission]));
672
- const candidates = listTasks(db, { workspaceRoot: ws || null, limit: null })
665
+ const source = Array.isArray(rows) ? rows : listTasks(db, { workspaceRoot: ws || null, limit: null });
666
+ const candidates = source
673
667
  .filter(row => OPEN_TASK_STATUSES.has(row.status))
674
668
  .filter(row => row.tag === 'mission-blocker')
675
669
  .filter(row => row.metadata && row.metadata.mission_id && row.metadata.mission_blocker_class)
@@ -683,6 +677,7 @@ function reapMissionBlockerTasks(db, { workspaceRoot: ws, missions = [], actor }
683
677
  id: row.id,
684
678
  actor,
685
679
  reason,
680
+ skipLogs,
686
681
  });
687
682
  if (!result.archived) continue;
688
683
  closed.push({
@@ -710,7 +705,7 @@ function reapMissionBlockerTasks(db, { workspaceRoot: ws, missions = [], actor }
710
705
  // metadata.archived_from. Individual archive commands never archive 'done'
711
706
  // rows. The clear-done sweep opts in through fromDone after selecting only the
712
707
  // same 'done' rows counted by `atris status`.
713
- function archiveTask(db, { id, actor, reason, fromFailed = false, fromDone = false } = {}) {
708
+ function archiveTask(db, { id, actor, reason, fromFailed = false, fromDone = false, skipLogs = false } = {}) {
714
709
  if (!id) throw new Error('id required');
715
710
  const reasonText = String(reason || '').trim();
716
711
  if (!reasonText) throw new Error('reason required');
@@ -752,7 +747,7 @@ function archiveTask(db, { id, actor, reason, fromFailed = false, fromDone = fal
752
747
  ...(['failed', 'done'].includes(row.status) ? { previous_status: row.status } : {}),
753
748
  },
754
749
  });
755
- const logs = appendTaskCompletionLogs(db, updated, {
750
+ const logs = skipLogs ? null : appendTaskCompletionLogs(db, updated, {
756
751
  status: 'archived',
757
752
  actor: metadata.archived_by,
758
753
  action: 'archived',
@@ -1519,6 +1514,11 @@ function noteTask(db, { id, actor, content }) {
1519
1514
  eventType: 'message',
1520
1515
  payload: { content: text },
1521
1516
  });
1517
+ // Structured trace notes already land on the member log through
1518
+ // task result/ready; logging them here would double the receipt.
1519
+ if (!/^TASK_[A-Z_]+_TRACE\s/.test(text)) {
1520
+ appendTaskWorkLog(db, row, { actor, title: 'Task note', note: text });
1521
+ }
1522
1522
  return { noted: true, event };
1523
1523
  }
1524
1524
 
@@ -2126,7 +2126,8 @@ function renderTodoMarkdown(rows, { title = 'TODO.md', doneLimit = TODO_RENDER_D
2126
2126
  failed: displayRows.filter(r => r.status === 'failed'),
2127
2127
  done: displayRows.filter(r => r.status === 'done'),
2128
2128
  };
2129
- const lines = [`# ${title}`, '', '> Regenerated from durable Atris task state. Do not treat this file as truth.', ''];
2129
+ const lines = [`# ${title}`, '', 'Details for any task: atris task show <ID>', '', '> Regenerated from durable Atris task state. Do not treat this file as truth.', ''];
2130
+ if (process.env.ATRIS_TODO_RENDER !== 'full') lines.push('<!-- ATRIS_TODO_COMPACT:2 -->', '');
2130
2131
  for (const section of preservedSections) {
2131
2132
  const text = String(section || '').trim();
2132
2133
  if (!text) continue;
@@ -2170,6 +2171,25 @@ function appendSection(lines, name, rows) {
2170
2171
  const decision = isDecisionTask(row) ? ' [decision]' : '';
2171
2172
  const displayRef = meta.todo_id || row.display_id || row.id;
2172
2173
  const explanation = taskExplanation(row);
2174
+ if (process.env.ATRIS_TODO_RENDER !== 'full') {
2175
+ const singleLine = value => String(value || '').replace(/\s+/g, ' ').trim();
2176
+ const clip = (value, limit) => {
2177
+ const text = singleLine(value);
2178
+ return text.length > limit ? text.slice(0, limit - 1) + '…' : text;
2179
+ };
2180
+ const owner = singleLine(row.claimed_by || meta.assigned_to || meta.owner) || 'unassigned';
2181
+ const title = clip(row.title, name === 'Completed' ? 100 : 140);
2182
+ const labels = name === 'Backlog' || name === 'Blocked' ? tag + decision : '';
2183
+ const assignment = name === 'Backlog' || name === 'Completed' ? '' : ` · ${owner}`;
2184
+ const previewLimit = name === 'Blocked' ? 60 : 40;
2185
+ const verify = (name === 'In Progress' || name === 'Blocked') && meta.verify
2186
+ ? ` · verify: ${singleLine(meta.verify).slice(0, previewLimit)}` : '';
2187
+ lines.push(`- **[${displayRef}]** ${title}${labels}${assignment}${verify}`);
2188
+ if (name === 'Review') {
2189
+ lines.push(` **Done looks like:** ${clip(explanation.done_looks_like, 160)}`);
2190
+ }
2191
+ continue;
2192
+ }
2173
2193
  // The plain face leads. The exact original title remains immediately below
2174
2194
  // it so old TODO-only projects and deep inspection keep full fidelity.
2175
2195
  lines.push(`- **[${displayRef}]** ${explanation.what_changes}${tag}${decision}`);