atris 3.12.1 → 3.14.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.
@@ -1,20 +1,24 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
+ const { spawnSync } = require('child_process');
3
4
  const { getLogPath } = require('../lib/journal');
5
+ const { ensureValidCredentials } = require('../utils/auth');
6
+ const { apiRequestJson } = require('../utils/api');
7
+ const { loadConfig } = require('../utils/config');
4
8
 
5
- function visualizeAtris() {
6
- const { logFile, dateFormatted } = getLogPath();
9
+ const DEFAULT_MODEL = 'gpt-image-2';
10
+ const DEFAULT_SIZE = '1536x1024';
11
+ const DEFAULT_QUALITY = 'high';
12
+
13
+ function legacyVisualizeInbox() {
14
+ const { logFile } = getLogPath();
7
15
 
8
- // Check if log exists
9
16
  if (!fs.existsSync(logFile)) {
10
17
  console.log('✗ No journal entry for today. Run "atris log" to create one.');
11
18
  process.exit(1);
12
19
  }
13
20
 
14
- // Read the log file
15
21
  const logContent = fs.readFileSync(logFile, 'utf8');
16
-
17
- // Extract Inbox section
18
22
  const inboxMatch = logContent.match(/## Inbox\n([\s\S]*?)(?=\n##|$)/);
19
23
  if (!inboxMatch || !inboxMatch[1].trim()) {
20
24
  console.log('✗ No items in Inbox. Add ideas to your journal first.');
@@ -35,7 +39,6 @@ function visualizeAtris() {
35
39
  process.exit(1);
36
40
  }
37
41
 
38
- // Display visualization template
39
42
  console.log('');
40
43
  console.log('┌─────────────────────────────────────────────────────────────┐');
41
44
  console.log('│ Atris Visualize — Break Down & Approval Gate │');
@@ -68,7 +71,320 @@ function visualizeAtris() {
68
71
  console.log('');
69
72
  }
70
73
 
74
+ function parseVisualizeArgs(args = []) {
75
+ const options = {
76
+ model: DEFAULT_MODEL,
77
+ size: DEFAULT_SIZE,
78
+ quality: DEFAULT_QUALITY,
79
+ outputFormat: 'png',
80
+ dryRun: false,
81
+ open: false,
82
+ timeoutMs: 180000,
83
+ };
84
+ const promptParts = [];
85
+
86
+ for (let i = 0; i < args.length; i++) {
87
+ const arg = args[i];
88
+ if (arg === '--') {
89
+ promptParts.push(...args.slice(i + 1));
90
+ break;
91
+ }
92
+ if (arg === '--help' || arg === '-h') options.help = true;
93
+ else if (arg === '--dry-run') options.dryRun = true;
94
+ else if (arg === '--open') options.open = true;
95
+ else if (arg === '--no-open') options.open = false;
96
+ else if (arg === '--raw') options.raw = true;
97
+ else if (arg === '--agent' && args[i + 1]) options.agentId = args[++i];
98
+ else if (arg.startsWith('--agent=')) options.agentId = arg.slice('--agent='.length);
99
+ else if (arg === '--model' && args[i + 1]) options.model = args[++i];
100
+ else if (arg.startsWith('--model=')) options.model = arg.slice('--model='.length);
101
+ else if (arg === '--size' && args[i + 1]) options.size = args[++i];
102
+ else if (arg.startsWith('--size=')) options.size = arg.slice('--size='.length);
103
+ else if (arg === '--quality' && args[i + 1]) options.quality = args[++i];
104
+ else if (arg.startsWith('--quality=')) options.quality = arg.slice('--quality='.length);
105
+ else if (arg === '--out' && args[i + 1]) options.out = args[++i];
106
+ else if (arg.startsWith('--out=')) options.out = arg.slice('--out='.length);
107
+ else if (arg === '--timeout' && args[i + 1]) options.timeoutMs = Number(args[++i]) * 1000;
108
+ else if (arg.startsWith('--timeout=')) options.timeoutMs = Number(arg.slice('--timeout='.length)) * 1000;
109
+ else if (arg === '--format' && args[i + 1]) options.outputFormat = args[++i];
110
+ else if (arg.startsWith('--format=')) options.outputFormat = arg.slice('--format='.length);
111
+ else promptParts.push(arg);
112
+ }
113
+
114
+ return { prompt: promptParts.join(' ').trim(), options };
115
+ }
116
+
117
+ function showVisualizeHelp() {
118
+ console.log('');
119
+ console.log('Usage: atris visualize <prompt> [options]');
120
+ console.log('');
121
+ console.log('Generate a Slack/deck-ready business visual from workspace context.');
122
+ console.log('');
123
+ console.log('Options:');
124
+ console.log(' --model <name> Image model (default: gpt-image-2)');
125
+ console.log(' --size <WxH> Output size (default: 1536x1024)');
126
+ console.log(' --quality <level> Quality (default: high)');
127
+ console.log(' --out <path> Save path (default: atris/reports/visuals/<slug>.png)');
128
+ console.log(' --agent <id> Agent id for backend image endpoint');
129
+ console.log(' --dry-run Print generated prompt without calling the backend');
130
+ console.log(' --open Open the saved PNG after generation');
131
+ console.log(' --raw Send your prompt as-is, without workspace prompt shaping');
132
+ console.log('');
133
+ console.log('No prompt keeps the legacy inbox visualization helper.');
134
+ console.log('');
135
+ }
136
+
137
+ function readTextIfExists(filePath, maxChars) {
138
+ try {
139
+ if (!fs.existsSync(filePath)) return '';
140
+ return fs.readFileSync(filePath, 'utf8').slice(0, maxChars);
141
+ } catch {
142
+ return '';
143
+ }
144
+ }
145
+
146
+ function readBusinessMeta(cwd = process.cwd()) {
147
+ const businessPath = path.join(cwd, '.atris', 'business.json');
148
+ try {
149
+ if (!fs.existsSync(businessPath)) return {};
150
+ return JSON.parse(fs.readFileSync(businessPath, 'utf8'));
151
+ } catch {
152
+ return {};
153
+ }
154
+ }
155
+
156
+ function findRelevantContextFiles(cwd, prompt) {
157
+ const roots = [
158
+ path.join(cwd, 'atris', 'context'),
159
+ path.join(cwd, 'atris', 'wiki'),
160
+ ];
161
+ const words = new Set(
162
+ prompt.toLowerCase().split(/[^a-z0-9]+/).filter(w => w.length >= 4)
163
+ );
164
+ const files = [];
165
+
166
+ function walk(dir) {
167
+ if (!fs.existsSync(dir)) return;
168
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
169
+ const full = path.join(dir, entry.name);
170
+ if (entry.isDirectory()) walk(full);
171
+ else if (entry.isFile() && entry.name.endsWith('.md')) files.push(full);
172
+ }
173
+ }
174
+
175
+ roots.forEach(walk);
176
+ return files
177
+ .map(file => {
178
+ const rel = path.relative(cwd, file);
179
+ const haystack = rel.toLowerCase();
180
+ let score = 0;
181
+ for (const word of words) {
182
+ if (haystack.includes(word)) score += 3;
183
+ }
184
+ const body = readTextIfExists(file, 1200).toLowerCase();
185
+ for (const word of words) {
186
+ if (body.includes(word)) score += 1;
187
+ }
188
+ return { file, rel, score };
189
+ })
190
+ .filter(item => item.score > 0)
191
+ .sort((a, b) => b.score - a.score)
192
+ .slice(0, 4);
193
+ }
194
+
195
+ function collectWorkspaceContext(prompt, cwd = process.cwd()) {
196
+ const business = readBusinessMeta(cwd);
197
+ const chunks = [];
198
+ if (business.name || business.slug) {
199
+ chunks.push(`Workspace: ${business.name || business.slug} (${business.slug || 'no-slug'})`);
200
+ }
201
+
202
+ const mapSnippet = readTextIfExists(path.join(cwd, 'atris', 'MAP.md'), 1400);
203
+ if (mapSnippet) chunks.push(`MAP excerpt:\n${mapSnippet}`);
204
+
205
+ const todoSnippet = readTextIfExists(path.join(cwd, 'atris', 'TODO.md'), 1000);
206
+ if (todoSnippet) chunks.push(`TODO excerpt:\n${todoSnippet}`);
207
+
208
+ const relevant = findRelevantContextFiles(cwd, prompt);
209
+ for (const item of relevant) {
210
+ chunks.push(`${item.rel}:\n${readTextIfExists(item.file, 900)}`);
211
+ }
212
+
213
+ return chunks.join('\n\n---\n\n').slice(0, 6000);
214
+ }
215
+
216
+ function classifyArtifact(prompt) {
217
+ const p = prompt.toLowerCase();
218
+ if (/security|compliance|soc2|soc 2|questionnaire|risk|posture/.test(p)) return 'security posture';
219
+ if (/wbr|weekly|metric|metrics|revenue|p&l|pnl|forecast|dashboard/.test(p)) return 'metric story';
220
+ if (/onboard|setup|connect|workflow|process|flow|steps|how to/.test(p)) return 'workflow';
221
+ if (/architecture|system|infra|stack|api|database|service/.test(p)) return 'architecture';
222
+ if (/compare|comparison|versus|\bvs\b|tradeoff/.test(p)) return 'comparison';
223
+ if (/status|update|recap|progress|roadmap/.test(p)) return 'status update';
224
+ return 'business explainer';
225
+ }
226
+
227
+ function slugify(input) {
228
+ return String(input || 'visual')
229
+ .toLowerCase()
230
+ .replace(/[^a-z0-9]+/g, '-')
231
+ .replace(/^-+|-+$/g, '')
232
+ .slice(0, 54) || 'visual';
233
+ }
234
+
235
+ function defaultOutputPath(prompt, cwd = process.cwd()) {
236
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
237
+ const visualsDir = fs.existsSync(path.join(cwd, 'atris'))
238
+ ? path.join(cwd, 'atris', 'reports', 'visuals')
239
+ : path.join(cwd, 'visuals');
240
+ return path.join(visualsDir, `${slugify(prompt)}-${stamp}.png`);
241
+ }
242
+
243
+ function resolveOutputPath(out, prompt, cwd = process.cwd()) {
244
+ if (!out) return defaultOutputPath(prompt, cwd);
245
+ return path.isAbsolute(out) ? out : path.join(cwd, out);
246
+ }
247
+
248
+ function buildImagePrompt(userPrompt, options = {}, cwd = process.cwd()) {
249
+ if (options.raw) return userPrompt;
250
+
251
+ const artifactType = classifyArtifact(userPrompt);
252
+ const context = collectWorkspaceContext(userPrompt, cwd);
253
+ const contextBlock = context ? `\nWorkspace context to respect:\n${context}\n` : '';
254
+
255
+ return `Use case: productivity-visual
256
+ Asset type: Slack-shareable and deck-ready business artifact
257
+ Artifact type: ${artifactType}
258
+ Primary request: ${userPrompt}
259
+ ${contextBlock}
260
+ Design requirements:
261
+ - Create a polished, modern SaaS-style visual on a clean light background.
262
+ - Use business-appropriate typography, generous spacing, and a restrained palette.
263
+ - Make the visual useful at Slack preview size: large labels, short text, no tiny paragraphs.
264
+ - Prefer a clear structure: flow, comparison, architecture diagram, metric story, or status map depending on the request.
265
+ - If rendering text, keep it concise and accurate; do not invent unsupported names, numbers, claims, or logos.
266
+ - Do not use real third-party logos unless the user explicitly asks.
267
+ - Avoid decorative stock-art scenes. The output should feel like a usable work artifact.
268
+ - Include enough visual hierarchy that a busy operator can understand it in 5 seconds.
269
+ `;
270
+ }
271
+
272
+ async function resolveAgentId(token, explicitAgentId) {
273
+ if (explicitAgentId) return { id: explicitAgentId, label: explicitAgentId };
274
+
275
+ const agentsResult = await apiRequestJson('/agent/my-agents', { method: 'GET', token });
276
+ const agents = agentsResult.data?.my_agents || agentsResult.data?.agents || [];
277
+ const activeAgents = agents.filter(agent => agent.status !== 'inactive' && agent.id);
278
+ const agentById = new Map(activeAgents.map(agent => [agent.id, agent]));
279
+ const fromAccessible = (agentId, fallbackLabel) => {
280
+ if (!agentId || !agentById.has(agentId)) return null;
281
+ const agent = agentById.get(agentId);
282
+ return { id: agent.id, label: agent.name || fallbackLabel || agent.id };
283
+ };
284
+
285
+ const config = loadConfig();
286
+ const configAgent = fromAccessible(config.agent_id, config.agent_name);
287
+ if (configAgent) return configAgent;
288
+
289
+ const business = readBusinessMeta();
290
+ const localBusinessAgent = fromAccessible(business.agent_id, business.agent_name);
291
+ if (localBusinessAgent) return localBusinessAgent;
292
+
293
+ if (business.slug) {
294
+ const list = await apiRequestJson('/business/', { method: 'GET', token });
295
+ const businesses = Array.isArray(list.data) ? list.data : [];
296
+ const match = businesses.find(b => b.slug === business.slug || b.name === business.name);
297
+ const agentId = match?.agent_id || match?.default_agent_id || match?.agent?.id;
298
+ const businessAgent = fromAccessible(agentId, match?.agent_name || match?.agent?.name);
299
+ if (businessAgent) return businessAgent;
300
+ }
301
+
302
+ if (activeAgents.length === 1) return { id: activeAgents[0].id, label: activeAgents[0].name || activeAgents[0].id };
303
+ if (activeAgents.length > 1) return { id: activeAgents[0].id, label: activeAgents[0].name || activeAgents[0].id };
304
+
305
+ throw new Error('No agent found. Run "atris agent" or pass --agent <agent_id>.');
306
+ }
307
+
308
+ function writeImageFile(base64Image, outputPath) {
309
+ const clean = String(base64Image || '').replace(/^data:image\/[a-zA-Z0-9.+-]+;base64,/, '');
310
+ if (!clean) throw new Error('Backend returned no image data.');
311
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
312
+ fs.writeFileSync(outputPath, Buffer.from(clean, 'base64'));
313
+ }
314
+
315
+ function maybeOpenImage(outputPath) {
316
+ if (process.platform === 'darwin') spawnSync('open', [outputPath], { stdio: 'ignore' });
317
+ else if (process.platform === 'win32') spawnSync('cmd', ['/c', 'start', '', outputPath], { stdio: 'ignore' });
318
+ else spawnSync('xdg-open', [outputPath], { stdio: 'ignore' });
319
+ }
320
+
321
+ async function generateVisual(prompt, options = {}) {
322
+ const outputPath = resolveOutputPath(options.out, prompt);
323
+ const imagePrompt = buildImagePrompt(prompt, options);
324
+
325
+ if (options.dryRun) {
326
+ console.log('Atris Visualize dry run');
327
+ console.log(`Model: ${options.model}`);
328
+ console.log(`Size: ${options.size}`);
329
+ console.log(`Output: ${outputPath}`);
330
+ console.log('');
331
+ console.log(imagePrompt.trim());
332
+ return { outputPath, imagePrompt, dryRun: true };
333
+ }
334
+
335
+ const ensured = await ensureValidCredentials(apiRequestJson);
336
+ const creds = ensured.error ? null : ensured.credentials;
337
+ if (!creds?.token) {
338
+ const detail = ensured.detail || ensured.error;
339
+ throw new Error(detail ? `Authentication failed: ${detail}. Run "atris login".` : 'Not logged in. Run "atris login".');
340
+ }
341
+
342
+ const agent = await resolveAgentId(creds.token, options.agentId);
343
+ console.log(`Generating visual with ${options.model} via agent ${agent.label}...`);
344
+
345
+ const result = await apiRequestJson(`/agent/${agent.id}/image/generate`, {
346
+ method: 'POST',
347
+ token: creds.token,
348
+ timeoutMs: options.timeoutMs,
349
+ body: {
350
+ prompt: imagePrompt,
351
+ n: 1,
352
+ size: options.size,
353
+ model: options.model,
354
+ quality: options.quality,
355
+ output_format: options.outputFormat,
356
+ },
357
+ });
358
+
359
+ if (!result.ok) {
360
+ throw new Error(`Image generation failed (${result.status}): ${result.error || result.text || 'unknown error'}`);
361
+ }
362
+
363
+ const image = result.data?.images?.[0];
364
+ writeImageFile(image, outputPath);
365
+ console.log(`Saved: ${outputPath}`);
366
+ if (options.open) maybeOpenImage(outputPath);
367
+ return { outputPath, imagePrompt, model: result.data?.model_used || options.model };
368
+ }
369
+
370
+ async function visualizeAtris(args = process.argv.slice(3)) {
371
+ const { prompt, options } = parseVisualizeArgs(args);
372
+ if (options.help) {
373
+ showVisualizeHelp();
374
+ return;
375
+ }
376
+ if (!prompt) {
377
+ legacyVisualizeInbox();
378
+ return;
379
+ }
380
+ await generateVisual(prompt, options);
381
+ }
71
382
 
72
383
  module.exports = {
73
- visualizeAtris
384
+ visualizeAtris,
385
+ parseVisualizeArgs,
386
+ buildImagePrompt,
387
+ classifyArtifact,
388
+ resolveOutputPath,
389
+ generateVisual,
74
390
  };
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
+ };