atris 3.13.0 → 3.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -3
- package/bin/atris.js +34 -8
- package/commands/aeo.js +197 -0
- package/commands/business.js +393 -6
- package/commands/computer.js +181 -3
- package/commands/live.js +289 -0
- package/commands/pull.js +8 -4
- package/commands/push.js +2 -2
- package/commands/task.js +217 -0
- package/lib/task-db.js +288 -0
- package/lib/todo-fallback.js +142 -0
- package/lib/todo.js +99 -184
- package/package.json +2 -2
- package/cli/__pycache__/atris_code.cpython-314.pyc +0 -0
- package/cli/__pycache__/runtime_guard.cpython-312.pyc +0 -0
- package/cli/__pycache__/runtime_guard.cpython-314.pyc +0 -0
package/commands/task.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// `atris task` — SQLite-backed task plane. TODO.md stays the human-readable
|
|
2
|
+
// board; this gives agents atomic claims and a compact sync row.
|
|
3
|
+
|
|
4
|
+
'use strict';
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
|
|
10
|
+
const DEFAULT_OWNER = process.env.ATRIS_AGENT_ID
|
|
11
|
+
|| process.env.USER
|
|
12
|
+
|| os.userInfo().username
|
|
13
|
+
|| 'unknown';
|
|
14
|
+
|
|
15
|
+
let taskDbModule = null;
|
|
16
|
+
|
|
17
|
+
function getTaskDb() {
|
|
18
|
+
if (taskDbModule) return taskDbModule;
|
|
19
|
+
try {
|
|
20
|
+
taskDbModule = require('../lib/task-db');
|
|
21
|
+
return taskDbModule;
|
|
22
|
+
} catch (e) {
|
|
23
|
+
const message = String(e && (e.message || e));
|
|
24
|
+
const missingSqlite = e && (
|
|
25
|
+
e.code === 'ERR_UNKNOWN_BUILTIN_MODULE'
|
|
26
|
+
|| /node:sqlite|No such built-in module/i.test(message)
|
|
27
|
+
);
|
|
28
|
+
if (missingSqlite) {
|
|
29
|
+
console.error('atris task requires Node.js 22+ because it uses built-in node:sqlite.');
|
|
30
|
+
console.error('Use the markdown TODO.md flow on older Node versions.');
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
throw e;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function help() {
|
|
38
|
+
console.log(`
|
|
39
|
+
atris task — local agent task plane (SQLite, gitignored)
|
|
40
|
+
|
|
41
|
+
atris task add "<title>" [--tag <tag>] Create a task
|
|
42
|
+
atris task list [--all] [--status <s>] List tasks (default: this workspace)
|
|
43
|
+
atris task claim <id> [--as <owner>] Atomic claim
|
|
44
|
+
atris task done <id> [--failed] Mark complete (or failed)
|
|
45
|
+
atris task import <file> One-shot import from TODO.md
|
|
46
|
+
atris task where Print db path + workspace scope
|
|
47
|
+
atris task help This help
|
|
48
|
+
|
|
49
|
+
Env:
|
|
50
|
+
ATRIS_TASKS_DB Override db path (default ~/.atris/tasks.db)
|
|
51
|
+
ATRIS_AGENT_ID Owner id for claim/done (default: $USER)
|
|
52
|
+
`.trim());
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function flag(args, name) {
|
|
56
|
+
const i = args.indexOf(name);
|
|
57
|
+
if (i === -1) return null;
|
|
58
|
+
return args[i + 1] || true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function hasFlag(args, name) {
|
|
62
|
+
return args.indexOf(name) !== -1;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function positional(args) {
|
|
66
|
+
return args.filter((a, i) => {
|
|
67
|
+
if (a.startsWith('--')) return false;
|
|
68
|
+
if (i > 0 && args[i - 1].startsWith('--')) return false;
|
|
69
|
+
return true;
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function cmdAdd(args) {
|
|
74
|
+
const pos = positional(args);
|
|
75
|
+
const title = pos.join(' ').trim();
|
|
76
|
+
if (!title) {
|
|
77
|
+
console.error('atris task add: title required');
|
|
78
|
+
process.exit(2);
|
|
79
|
+
}
|
|
80
|
+
const tag = flag(args, '--tag');
|
|
81
|
+
const taskDb = getTaskDb();
|
|
82
|
+
const db = taskDb.open();
|
|
83
|
+
const ws = taskDb.workspaceRoot();
|
|
84
|
+
const result = taskDb.addTask(db, {
|
|
85
|
+
title,
|
|
86
|
+
tag: typeof tag === 'string' ? tag : null,
|
|
87
|
+
workspaceRoot: ws,
|
|
88
|
+
});
|
|
89
|
+
console.log(`${result.id}\t${title}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function cmdList(args) {
|
|
93
|
+
const all = hasFlag(args, '--all');
|
|
94
|
+
const status = flag(args, '--status');
|
|
95
|
+
const taskDb = getTaskDb();
|
|
96
|
+
const db = taskDb.open();
|
|
97
|
+
const rows = taskDb.listTasks(db, {
|
|
98
|
+
workspaceRoot: all ? null : taskDb.workspaceRoot(),
|
|
99
|
+
status: typeof status === 'string' ? status : null,
|
|
100
|
+
limit: 200,
|
|
101
|
+
});
|
|
102
|
+
if (rows.length === 0) {
|
|
103
|
+
console.log('(no tasks)');
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
for (const r of rows) {
|
|
107
|
+
const claim = r.claimed_by ? ` [${r.claimed_by}]` : '';
|
|
108
|
+
const tag = r.tag ? ` #${r.tag}` : '';
|
|
109
|
+
console.log(`${r.status.padEnd(8)} ${r.id}${claim}${tag}\t${r.title}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function cmdClaim(args) {
|
|
114
|
+
const pos = positional(args);
|
|
115
|
+
const id = pos[0];
|
|
116
|
+
if (!id) {
|
|
117
|
+
console.error('atris task claim: id required');
|
|
118
|
+
process.exit(2);
|
|
119
|
+
}
|
|
120
|
+
const owner = flag(args, '--as') || DEFAULT_OWNER;
|
|
121
|
+
const taskDb = getTaskDb();
|
|
122
|
+
const db = taskDb.open();
|
|
123
|
+
const result = taskDb.claimTask(db, { id, claimedBy: String(owner) });
|
|
124
|
+
if (result.claimed) {
|
|
125
|
+
console.log(`claimed ${id} as ${owner}`);
|
|
126
|
+
} else {
|
|
127
|
+
console.error(`claim failed: ${result.reason}${result.claimed_by ? ` (held by ${result.claimed_by})` : ''}`);
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function cmdDone(args) {
|
|
133
|
+
const pos = positional(args);
|
|
134
|
+
const id = pos[0];
|
|
135
|
+
if (!id) {
|
|
136
|
+
console.error('atris task done: id required');
|
|
137
|
+
process.exit(2);
|
|
138
|
+
}
|
|
139
|
+
const failed = hasFlag(args, '--failed');
|
|
140
|
+
const taskDb = getTaskDb();
|
|
141
|
+
const db = taskDb.open();
|
|
142
|
+
const result = taskDb.doneTask(db, { id, status: failed ? 'failed' : 'done' });
|
|
143
|
+
if (result.updated) {
|
|
144
|
+
console.log(`${failed ? 'failed' : 'done'} ${id}`);
|
|
145
|
+
} else {
|
|
146
|
+
console.error(`done failed: ${id} not in open|claimed`);
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function cmdImport(args) {
|
|
152
|
+
const pos = positional(args);
|
|
153
|
+
const target = pos[0] || 'atris/TODO.md';
|
|
154
|
+
const filePath = path.resolve(target);
|
|
155
|
+
if (!fs.existsSync(filePath)) {
|
|
156
|
+
console.error(`atris task import: file not found: ${filePath}`);
|
|
157
|
+
process.exit(2);
|
|
158
|
+
}
|
|
159
|
+
const { parseTodoFile } = require('../lib/todo-fallback');
|
|
160
|
+
const parsed = parseTodoFile(filePath);
|
|
161
|
+
const taskDb = getTaskDb();
|
|
162
|
+
const db = taskDb.open();
|
|
163
|
+
const ws = taskDb.workspaceRoot();
|
|
164
|
+
const all = [
|
|
165
|
+
...parsed.backlog.map(t => ({ ...t, importStatus: 'open' })),
|
|
166
|
+
...parsed.inProgress.map(t => ({ ...t, importStatus: 'claimed' })),
|
|
167
|
+
];
|
|
168
|
+
let inserted = 0;
|
|
169
|
+
let skipped = 0;
|
|
170
|
+
for (const t of all) {
|
|
171
|
+
if (!t.title) continue;
|
|
172
|
+
const sk = taskDb.sourceKey(filePath, t.title);
|
|
173
|
+
const result = taskDb.addTask(db, {
|
|
174
|
+
title: t.title,
|
|
175
|
+
tag: t.tag || null,
|
|
176
|
+
workspaceRoot: ws,
|
|
177
|
+
sourceKey: sk,
|
|
178
|
+
status: t.importStatus,
|
|
179
|
+
claimedBy: t.claimed || null,
|
|
180
|
+
metadata: { todo_id: t.id, claimed: t.claimed, stage: t.stage, verify: t.verify },
|
|
181
|
+
});
|
|
182
|
+
if (result.inserted) inserted++; else skipped++;
|
|
183
|
+
}
|
|
184
|
+
console.log(`imported ${inserted} new, skipped ${skipped} (already imported), source=${filePath}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function cmdWhere() {
|
|
188
|
+
const taskDb = getTaskDb();
|
|
189
|
+
console.log(`db: ${taskDb.getDbPath()}`);
|
|
190
|
+
console.log(`workspace: ${taskDb.workspaceRoot()}`);
|
|
191
|
+
console.log(`owner: ${DEFAULT_OWNER}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function run(args) {
|
|
195
|
+
const sub = (args && args[0]) || 'help';
|
|
196
|
+
const rest = (args || []).slice(1);
|
|
197
|
+
switch (sub) {
|
|
198
|
+
case 'add': return cmdAdd(rest);
|
|
199
|
+
case 'list': return cmdList(rest);
|
|
200
|
+
case 'ls': return cmdList(rest);
|
|
201
|
+
case 'claim': return cmdClaim(rest);
|
|
202
|
+
case 'done': return cmdDone(rest);
|
|
203
|
+
case 'fail': return cmdDone([...rest, '--failed']);
|
|
204
|
+
case 'import': return cmdImport(rest);
|
|
205
|
+
case 'where': return cmdWhere();
|
|
206
|
+
case 'help':
|
|
207
|
+
case '--help':
|
|
208
|
+
case '-h':
|
|
209
|
+
return help();
|
|
210
|
+
default:
|
|
211
|
+
console.error(`atris task: unknown subcommand "${sub}"`);
|
|
212
|
+
help();
|
|
213
|
+
process.exit(2);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
module.exports = { run };
|
package/lib/task-db.js
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// SQLite-backed task store. node:sqlite (built-in, v22+).
|
|
2
|
+
// Local state layer for `atris task`. TODO.md stays the human-readable
|
|
3
|
+
// project board; this store gives agents atomic claims and a compact sync row.
|
|
4
|
+
//
|
|
5
|
+
// Path: ~/.atris/tasks.db (gitignored, never blobbed). Per-workspace scope via
|
|
6
|
+
// workspace_root column. Rows survive across machines only when explicitly
|
|
7
|
+
// synced (out of scope for tick 1).
|
|
8
|
+
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
// node:sqlite emits an ExperimentalWarning. Suppress only that exact class by
|
|
12
|
+
// monkey-patching process.emit at this narrow filter — other warnings (and
|
|
13
|
+
// any pre-existing listeners installed by host code) are untouched.
|
|
14
|
+
{
|
|
15
|
+
const originalEmit = process.emit;
|
|
16
|
+
process.emit = function patchedEmit(name, data, ...args) {
|
|
17
|
+
if (name === 'warning' && data && data.name === 'ExperimentalWarning'
|
|
18
|
+
&& /SQLite/i.test(data.message || '')) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
return originalEmit.apply(process, [name, data, ...args]);
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
const os = require('os');
|
|
28
|
+
const crypto = require('crypto');
|
|
29
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
30
|
+
|
|
31
|
+
const DEFAULT_DB_PATH = path.join(os.homedir(), '.atris', 'tasks.db');
|
|
32
|
+
|
|
33
|
+
const SCHEMA = `
|
|
34
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
35
|
+
id TEXT PRIMARY KEY,
|
|
36
|
+
title TEXT NOT NULL,
|
|
37
|
+
status TEXT NOT NULL DEFAULT 'open',
|
|
38
|
+
tag TEXT,
|
|
39
|
+
workspace_root TEXT NOT NULL,
|
|
40
|
+
source_key TEXT,
|
|
41
|
+
claimed_by TEXT,
|
|
42
|
+
claimed_at INTEGER,
|
|
43
|
+
created_at INTEGER NOT NULL,
|
|
44
|
+
updated_at INTEGER NOT NULL,
|
|
45
|
+
done_at INTEGER,
|
|
46
|
+
metadata TEXT
|
|
47
|
+
);
|
|
48
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
|
|
49
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_workspace ON tasks(workspace_root);
|
|
50
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_claimed_by ON tasks(claimed_by);
|
|
51
|
+
CREATE UNIQUE INDEX IF NOT EXISTS uq_tasks_source ON tasks(workspace_root, source_key)
|
|
52
|
+
WHERE source_key IS NOT NULL;
|
|
53
|
+
`;
|
|
54
|
+
|
|
55
|
+
let _cachedDb = null;
|
|
56
|
+
let _cachedPath = null;
|
|
57
|
+
|
|
58
|
+
function getDbPath() {
|
|
59
|
+
return process.env.ATRIS_TASKS_DB || DEFAULT_DB_PATH;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function open(dbPath) {
|
|
63
|
+
const target = dbPath || getDbPath();
|
|
64
|
+
if (_cachedDb && _cachedPath === target) return _cachedDb;
|
|
65
|
+
const dir = path.dirname(target);
|
|
66
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
67
|
+
const db = new DatabaseSync(target);
|
|
68
|
+
// Concurrency: WAL gives readers + one writer concurrency; busy_timeout
|
|
69
|
+
// makes contended writers wait instead of returning SQLITE_BUSY at the
|
|
70
|
+
// C library level. We additionally wrap the setup PRAGMAs + DDL in our
|
|
71
|
+
// own retry — under heavy spawn-storm contention, node:sqlite leaks
|
|
72
|
+
// SQLITE_BUSY past the busy_timeout for `db.exec()` calls.
|
|
73
|
+
withBusyRetry(() => db.exec('PRAGMA journal_mode = WAL'));
|
|
74
|
+
withBusyRetry(() => db.exec('PRAGMA busy_timeout = 30000'));
|
|
75
|
+
withBusyRetry(() => db.exec('PRAGMA foreign_keys = ON'));
|
|
76
|
+
withBusyRetry(() => db.exec(SCHEMA));
|
|
77
|
+
// Schema version. Bump at every additive migration; tick 1 ships at v1.
|
|
78
|
+
// Future migrations read this and apply diffs idempotently.
|
|
79
|
+
withBusyRetry(() => db.exec('PRAGMA user_version = 1'));
|
|
80
|
+
_cachedDb = db;
|
|
81
|
+
_cachedPath = target;
|
|
82
|
+
return db;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function close() {
|
|
86
|
+
if (_cachedDb) {
|
|
87
|
+
try { _cachedDb.close(); } catch (_) {}
|
|
88
|
+
_cachedDb = null;
|
|
89
|
+
_cachedPath = null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 26-char ULID-ish (sortable by time prefix). Crockford-safe alphabet.
|
|
94
|
+
const ULID_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
95
|
+
function newId() {
|
|
96
|
+
const ts = Date.now();
|
|
97
|
+
let head = '';
|
|
98
|
+
let n = ts;
|
|
99
|
+
for (let i = 0; i < 10; i++) {
|
|
100
|
+
head = ULID_ALPHABET[n % 32] + head;
|
|
101
|
+
n = Math.floor(n / 32);
|
|
102
|
+
}
|
|
103
|
+
let tail = '';
|
|
104
|
+
const rand = crypto.randomBytes(10);
|
|
105
|
+
for (let i = 0; i < 16; i++) tail += ULID_ALPHABET[rand[i % rand.length] % 32];
|
|
106
|
+
return head + tail;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Walk up from `start` to find the canonical workspace root. We check for
|
|
110
|
+
// .git, then atris/, then .atris/. If none found, fall back to `start`. This
|
|
111
|
+
// makes `atris task add` from a subdirectory write the same workspace_root
|
|
112
|
+
// as parseTodo() reads from the project's atris/TODO.md.
|
|
113
|
+
function findWorkspaceRoot(start) {
|
|
114
|
+
let cur = path.resolve(start || process.cwd());
|
|
115
|
+
// Cap the walk at 32 levels to avoid pathological symlink loops.
|
|
116
|
+
for (let i = 0; i < 32; i++) {
|
|
117
|
+
if (fs.existsSync(path.join(cur, '.git'))) return cur;
|
|
118
|
+
if (fs.existsSync(path.join(cur, 'atris'))) return cur;
|
|
119
|
+
if (fs.existsSync(path.join(cur, '.atris'))) return cur;
|
|
120
|
+
const parent = path.dirname(cur);
|
|
121
|
+
if (parent === cur) break;
|
|
122
|
+
cur = parent;
|
|
123
|
+
}
|
|
124
|
+
return path.resolve(start || process.cwd());
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function workspaceRoot(cwd) {
|
|
128
|
+
// Normalize symlinks (notably macOS /tmp → /private/tmp), then walk up to
|
|
129
|
+
// the project root so subdirs and the repo root agree on the same key.
|
|
130
|
+
let target = cwd || process.cwd();
|
|
131
|
+
try { target = fs.realpathSync(target); } catch {}
|
|
132
|
+
return findWorkspaceRoot(target);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function normalizeTitle(t) {
|
|
136
|
+
return String(t || '').toLowerCase().trim().replace(/\s+/g, ' ').replace(/[^a-z0-9 ]/g, '');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function sourceKey(sourceFile, title) {
|
|
140
|
+
if (!sourceFile) return null;
|
|
141
|
+
// Realpath the source file so symlinked / relative imports collapse to the
|
|
142
|
+
// same key. Falls back to input string when the path doesn't resolve.
|
|
143
|
+
let canonical = sourceFile;
|
|
144
|
+
try { canonical = fs.realpathSync(sourceFile); } catch {}
|
|
145
|
+
const h = crypto.createHash('sha1');
|
|
146
|
+
h.update(`${canonical}${normalizeTitle(title)}`);
|
|
147
|
+
return h.digest('hex');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function addTask(db, { title, tag, workspaceRoot: ws, sourceKey: sk, metadata, status, claimedBy }) {
|
|
151
|
+
if (!title || !String(title).trim()) throw new Error('title required');
|
|
152
|
+
const now = Date.now();
|
|
153
|
+
const id = newId();
|
|
154
|
+
const taskStatus = ['open', 'claimed', 'done', 'failed'].includes(status) ? status : 'open';
|
|
155
|
+
const claimedAt = taskStatus === 'claimed' ? now : null;
|
|
156
|
+
// Idempotent on (workspace_root, source_key) when source_key supplied.
|
|
157
|
+
if (sk) {
|
|
158
|
+
const existing = db.prepare(
|
|
159
|
+
'SELECT id FROM tasks WHERE workspace_root = ? AND source_key = ?'
|
|
160
|
+
).get(ws, sk);
|
|
161
|
+
if (existing) return { id: existing.id, inserted: false };
|
|
162
|
+
}
|
|
163
|
+
withBusyRetry(() => db.prepare(`
|
|
164
|
+
INSERT INTO tasks (id, title, status, tag, workspace_root, source_key,
|
|
165
|
+
claimed_by, claimed_at, created_at, updated_at, metadata)
|
|
166
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
167
|
+
`).run(
|
|
168
|
+
id,
|
|
169
|
+
String(title).trim(),
|
|
170
|
+
taskStatus,
|
|
171
|
+
tag || null,
|
|
172
|
+
ws,
|
|
173
|
+
sk || null,
|
|
174
|
+
taskStatus === 'claimed' ? (claimedBy || null) : null,
|
|
175
|
+
claimedAt,
|
|
176
|
+
now,
|
|
177
|
+
now,
|
|
178
|
+
metadata ? JSON.stringify(metadata) : null,
|
|
179
|
+
));
|
|
180
|
+
return { id, inserted: true };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function listTasks(db, { workspaceRoot: ws, status, claimedBy, limit }) {
|
|
184
|
+
const where = [];
|
|
185
|
+
const args = [];
|
|
186
|
+
if (ws) { where.push('workspace_root = ?'); args.push(ws); }
|
|
187
|
+
if (status) { where.push('status = ?'); args.push(status); }
|
|
188
|
+
if (claimedBy) { where.push('claimed_by = ?'); args.push(claimedBy); }
|
|
189
|
+
const sql = `
|
|
190
|
+
SELECT id, title, status, tag, workspace_root, source_key, claimed_by, claimed_at, created_at, updated_at, done_at, metadata
|
|
191
|
+
FROM tasks
|
|
192
|
+
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
|
|
193
|
+
ORDER BY
|
|
194
|
+
CASE status WHEN 'open' THEN 0 WHEN 'claimed' THEN 1 WHEN 'failed' THEN 2 WHEN 'done' THEN 3 ELSE 4 END,
|
|
195
|
+
created_at DESC
|
|
196
|
+
${limit ? 'LIMIT ' + Number(limit) : ''}
|
|
197
|
+
`;
|
|
198
|
+
return db.prepare(sql).all(...args).map(r => ({
|
|
199
|
+
...r,
|
|
200
|
+
metadata: r.metadata ? safeJSON(r.metadata) : null,
|
|
201
|
+
}));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Atomic claim. Returns { claimed: true, row } only if THIS call won the row.
|
|
205
|
+
// Race-safe via single UPDATE with WHERE status='open' guard. SQLite serializes
|
|
206
|
+
// writes; busy_timeout absorbs contention. Caller must check `.claimed`.
|
|
207
|
+
function claimTask(db, { id, claimedBy }) {
|
|
208
|
+
if (!id) throw new Error('id required');
|
|
209
|
+
if (!claimedBy) throw new Error('claimedBy required');
|
|
210
|
+
const now = Date.now();
|
|
211
|
+
const stmt = db.prepare(`
|
|
212
|
+
UPDATE tasks
|
|
213
|
+
SET status = 'claimed',
|
|
214
|
+
claimed_by = ?,
|
|
215
|
+
claimed_at = ?,
|
|
216
|
+
updated_at = ?
|
|
217
|
+
WHERE id = ?
|
|
218
|
+
AND status = 'open'
|
|
219
|
+
`);
|
|
220
|
+
const result = withBusyRetry(() => stmt.run(claimedBy, now, now, id));
|
|
221
|
+
if (result.changes === 1) {
|
|
222
|
+
const row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
|
|
223
|
+
return { claimed: true, row: { ...row, metadata: row.metadata ? safeJSON(row.metadata) : null } };
|
|
224
|
+
}
|
|
225
|
+
// Either id doesn't exist or status != 'open'. Tell the caller which.
|
|
226
|
+
const row = db.prepare('SELECT id, status, claimed_by FROM tasks WHERE id = ?').get(id);
|
|
227
|
+
if (!row) return { claimed: false, reason: 'not_found' };
|
|
228
|
+
return { claimed: false, reason: 'already_' + row.status, claimed_by: row.claimed_by };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function doneTask(db, { id, status }) {
|
|
232
|
+
if (!id) throw new Error('id required');
|
|
233
|
+
const final = status || 'done';
|
|
234
|
+
if (!['done', 'failed'].includes(final)) throw new Error('status must be done|failed');
|
|
235
|
+
const now = Date.now();
|
|
236
|
+
const result = withBusyRetry(() => db.prepare(`
|
|
237
|
+
UPDATE tasks
|
|
238
|
+
SET status = ?, done_at = ?, updated_at = ?
|
|
239
|
+
WHERE id = ?
|
|
240
|
+
AND status IN ('open', 'claimed')
|
|
241
|
+
`).run(final, now, now, id));
|
|
242
|
+
return { updated: result.changes === 1 };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function safeJSON(s) {
|
|
246
|
+
try { return JSON.parse(s); } catch { return null; }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Wrap a write op so SQLITE_BUSY (concurrent writers from other processes)
|
|
250
|
+
// retries with exponential backoff. busy_timeout pragma alone leaks busy
|
|
251
|
+
// errors under spawn-storm contention with node:sqlite (~3% raw lock rate
|
|
252
|
+
// observed at 1000 attempts). Total wait ≤ ~6s; well above realistic
|
|
253
|
+
// contention windows for our agent fleet.
|
|
254
|
+
function withBusyRetry(fn, attempts = 8) {
|
|
255
|
+
let delay = 5;
|
|
256
|
+
let lastErr;
|
|
257
|
+
for (let i = 0; i < attempts; i++) {
|
|
258
|
+
try { return fn(); }
|
|
259
|
+
catch (e) {
|
|
260
|
+
lastErr = e;
|
|
261
|
+
const msg = String(e && e.message || '');
|
|
262
|
+
const code = e && (e.code || e.errcode);
|
|
263
|
+
const busy = /SQLITE_BUSY|database is locked/i.test(msg) || code === 'SQLITE_BUSY' || code === 5;
|
|
264
|
+
if (!busy) throw e;
|
|
265
|
+
// Sleep synchronously — node:sqlite is sync; matches the rest of the API
|
|
266
|
+
const end = Date.now() + delay + Math.floor(Math.random() * delay);
|
|
267
|
+
while (Date.now() < end) {} // tight loop is fine, delay is small
|
|
268
|
+
delay = Math.min(delay * 2, 500);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
throw lastErr;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
module.exports = {
|
|
275
|
+
open,
|
|
276
|
+
close,
|
|
277
|
+
getDbPath,
|
|
278
|
+
workspaceRoot,
|
|
279
|
+
sourceKey,
|
|
280
|
+
normalizeTitle,
|
|
281
|
+
addTask,
|
|
282
|
+
listTasks,
|
|
283
|
+
claimTask,
|
|
284
|
+
doneTask,
|
|
285
|
+
newId,
|
|
286
|
+
// Test surface
|
|
287
|
+
_SCHEMA: SCHEMA,
|
|
288
|
+
};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Pure TODO.md markdown parser. The original lib/todo.js logic, extracted so
|
|
2
|
+
// the shim in lib/todo.js can fall back to it when the SQLite store is empty
|
|
3
|
+
// (or when ATRIS_TASK_DB is not enabled).
|
|
4
|
+
//
|
|
5
|
+
// Do not add new callers. Use lib/todo.js (the shim) — it merges DB + markdown.
|
|
6
|
+
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
|
|
12
|
+
function parseTodoFile(todoPath) {
|
|
13
|
+
if (!fs.existsSync(todoPath)) return { backlog: [], inProgress: [], completed: [] };
|
|
14
|
+
const content = fs.readFileSync(todoPath, 'utf8');
|
|
15
|
+
return {
|
|
16
|
+
backlog: parseSection(content, 'Backlog'),
|
|
17
|
+
inProgress: parseSection(content, 'In Progress'),
|
|
18
|
+
completed: parseSection(content, 'Completed'),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseSection(content, sectionName) {
|
|
23
|
+
const escaped = sectionName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
24
|
+
const match = content.match(new RegExp(`##\\s+${escaped}\\n([\\s\\S]*?)(?=\\n##|$)`, 'i'));
|
|
25
|
+
if (!match) return [];
|
|
26
|
+
|
|
27
|
+
const body = (match[1] || '').trim();
|
|
28
|
+
if (!body || /^\(clean\)/i.test(body) || /^\(empty/i.test(body) || /^\(see /i.test(body)) return [];
|
|
29
|
+
|
|
30
|
+
const tasks = [];
|
|
31
|
+
const lines = body.split('\n');
|
|
32
|
+
let current = null;
|
|
33
|
+
|
|
34
|
+
for (const rawLine of lines) {
|
|
35
|
+
const line = rawLine.trimEnd();
|
|
36
|
+
|
|
37
|
+
const taskMatch = line.match(/^- \*\*([A-Za-z][A-Za-z0-9#]*\d[a-z]?):\*\*\s*(.+)$/);
|
|
38
|
+
if (taskMatch) {
|
|
39
|
+
if (current) tasks.push(current);
|
|
40
|
+
const allTags = [...taskMatch[2].matchAll(/\[(\w+)\]/g)].map(m => m[1]);
|
|
41
|
+
const tag = allTags.includes('endgame') ? 'endgame' : (allTags[0] || null);
|
|
42
|
+
current = {
|
|
43
|
+
id: taskMatch[1],
|
|
44
|
+
title: taskMatch[2].replace(/\s*\[\w+\]/g, '').trim(),
|
|
45
|
+
tag,
|
|
46
|
+
tags: allTags,
|
|
47
|
+
claimed: null,
|
|
48
|
+
stage: null,
|
|
49
|
+
verify: null,
|
|
50
|
+
};
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const checkMatch = line.match(/^- \[[ x]\]\s+(.+)$/);
|
|
55
|
+
if (checkMatch && !current) {
|
|
56
|
+
tasks.push({ id: null, title: checkMatch[1].trim(), tag: null, claimed: null, stage: null, verify: null });
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const plainMatch = line.match(/^- (.+)$/);
|
|
61
|
+
if (plainMatch && !current && !plainMatch[1].startsWith('**')) {
|
|
62
|
+
tasks.push({ id: null, title: plainMatch[1].trim(), tag: null, claimed: null, stage: null, verify: null });
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!current) continue;
|
|
67
|
+
|
|
68
|
+
const claimMatch = line.match(/\*\*Claimed by:\*\*\s*(.+)$/) || line.match(/Claimed by:\s*(.+)$/);
|
|
69
|
+
if (claimMatch) { current.claimed = claimMatch[1].trim(); continue; }
|
|
70
|
+
|
|
71
|
+
const stageMatch = line.match(/\*\*Stage:\*\*\s*(.+)$/) || line.match(/Stage:\s*(.+)$/);
|
|
72
|
+
if (stageMatch) { current.stage = stageMatch[1].trim(); continue; }
|
|
73
|
+
|
|
74
|
+
const verifyMatch = line.match(/\*\*Verify:\*\*\s*(.+)$/) || line.match(/Verify:\s*(.+)$/);
|
|
75
|
+
if (verifyMatch) { current.verify = verifyMatch[1].trim(); continue; }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (current) tasks.push(current);
|
|
79
|
+
return tasks;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function getTeamMemberJournal(atrisDir, memberName) {
|
|
83
|
+
const journalDir = path.join(atrisDir, 'team', memberName, 'journal');
|
|
84
|
+
if (!fs.existsSync(journalDir)) return null;
|
|
85
|
+
|
|
86
|
+
let files;
|
|
87
|
+
try {
|
|
88
|
+
files = fs.readdirSync(journalDir).filter(f => f.endsWith('.md')).sort().reverse();
|
|
89
|
+
} catch { return null; }
|
|
90
|
+
|
|
91
|
+
if (files.length === 0) return null;
|
|
92
|
+
|
|
93
|
+
const latestFile = files[0];
|
|
94
|
+
let content;
|
|
95
|
+
try { content = fs.readFileSync(path.join(journalDir, latestFile), 'utf8'); }
|
|
96
|
+
catch { return null; }
|
|
97
|
+
|
|
98
|
+
const date = latestFile.replace('.md', '');
|
|
99
|
+
const taskMatch = content.match(/\*\*Task:\*\*\s*(.+)/);
|
|
100
|
+
const deliveredMatch = content.match(/\*\*Delivered:\*\*\s*(.+)/);
|
|
101
|
+
const patternMatch = content.match(/\*\*Pattern:\*\*\s*(.+)/);
|
|
102
|
+
const learnedMatch = content.match(/\*\*Learned:\*\*\s*(.+)/);
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
date,
|
|
106
|
+
file: path.join(journalDir, latestFile),
|
|
107
|
+
task: taskMatch ? taskMatch[1].trim() : null,
|
|
108
|
+
delivered: deliveredMatch ? deliveredMatch[1].trim() : null,
|
|
109
|
+
pattern: patternMatch ? patternMatch[1].trim() : null,
|
|
110
|
+
learned: learnedMatch ? learnedMatch[1].trim() : null,
|
|
111
|
+
raw: content,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function listTeamMembers(atrisDir) {
|
|
116
|
+
const teamDir = path.join(atrisDir, 'team');
|
|
117
|
+
if (!fs.existsSync(teamDir)) return [];
|
|
118
|
+
return fs.readdirSync(teamDir).filter(name => {
|
|
119
|
+
if (name.startsWith('_') || name.startsWith('.')) return false;
|
|
120
|
+
const full = path.join(teamDir, name);
|
|
121
|
+
try { return fs.statSync(full).isDirectory(); } catch { return false; }
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function getTeamActivity(atrisDir) {
|
|
126
|
+
const members = listTeamMembers(atrisDir);
|
|
127
|
+
const activity = [];
|
|
128
|
+
for (const member of members) {
|
|
129
|
+
const journal = getTeamMemberJournal(atrisDir, member);
|
|
130
|
+
if (journal) activity.push({ member, ...journal });
|
|
131
|
+
}
|
|
132
|
+
activity.sort((a, b) => b.date.localeCompare(a.date));
|
|
133
|
+
return activity;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = {
|
|
137
|
+
parseTodoFile,
|
|
138
|
+
parseSection,
|
|
139
|
+
getTeamMemberJournal,
|
|
140
|
+
listTeamMembers,
|
|
141
|
+
getTeamActivity,
|
|
142
|
+
};
|