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.
- package/README.md +8 -0
- package/atris/policies/engineering-principles.md +129 -0
- package/atris/policies/genesis.md +112 -0
- package/atris/policies/product-design-principles.md +100 -0
- package/atris/skills/design/SKILL.md +3 -1
- package/atris/skills/engines/SKILL.md +3 -3
- package/atris/skills/x-search/SKILL.md +2 -2
- package/atris/skills/youtube/SKILL.md +44 -28
- package/bin/atris.js +56 -3
- package/commands/auth.js +58 -24
- package/commands/brain.js +1 -0
- package/commands/design.js +362 -0
- package/commands/doc-health.js +329 -0
- package/commands/drive.js +32 -0
- package/commands/improve.js +67 -1
- package/commands/land.js +144 -4
- package/commands/learn.js +211 -40
- package/commands/member.js +65 -11
- package/commands/mission.js +37 -7
- package/commands/pulse.js +38 -0
- package/commands/rsi.js +156 -0
- package/commands/task.js +41 -1
- package/commands/workflow.js +15 -14
- package/commands/x-search.js +9 -10
- package/commands/youtube.js +518 -107
- package/lib/apply-gate.js +22 -4
- package/lib/daily-log.js +88 -0
- package/lib/design-api.js +130 -0
- package/lib/engine-ask.js +1 -1
- package/lib/first-minute.js +1 -6
- package/lib/known-commands.js +3 -3
- package/lib/member-context.js +42 -0
- package/lib/rsi-record.js +335 -0
- package/lib/state-detection.js +8 -8
- package/lib/task-db.js +71 -51
- package/lib/task-list-keeper.js +192 -0
- package/lib/todo-fallback.js +9 -3
- package/lib/todo.js +22 -10
- package/mcp/atris-mcp/index.mjs +174 -0
- package/package.json +8 -3
- package/scripts/det/ytnotes +122 -10
- 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
|
+
};
|
package/lib/state-detection.js
CHANGED
|
@@ -121,11 +121,7 @@ function getTasksFromTodoSection(content, sectionName) {
|
|
|
121
121
|
|
|
122
122
|
if (tasks.length > 0) return tasks;
|
|
123
123
|
|
|
124
|
-
return
|
|
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
|
-
//
|
|
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:
|
|
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:
|
|
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
|
-
|
|
104
|
-
|
|
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
|
|
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
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
|
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}`);
|