atris 3.14.0 → 3.15.11
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/AGENTS.md +24 -4
- package/README.md +4 -3
- package/atris/atris.md +38 -13
- package/atris/features/company-brain-sync/build.md +140 -0
- package/atris/features/company-brain-sync/idea.md +52 -0
- package/atris/features/company-brain-sync/validate.md +229 -0
- package/atris/skills/imessage/SKILL.md +44 -0
- package/bin/atris.js +56 -6
- package/commands/aeo.js +197 -0
- package/commands/align.js +1 -1
- package/commands/brain.js +840 -0
- package/commands/business-sync.js +716 -0
- package/commands/init.js +15 -3
- package/commands/integrations.js +128 -0
- package/commands/live.js +311 -0
- package/commands/now.js +263 -0
- package/commands/pull.js +121 -6
- package/commands/push.js +146 -48
- package/commands/task.js +1658 -18
- package/lib/company-brain-sync.js +178 -0
- package/lib/manifest.js +2 -1
- package/lib/task-db.js +271 -4
- package/package.json +12 -2
package/commands/task.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
// `atris task`
|
|
2
|
-
//
|
|
1
|
+
// `atris task` - SQLite-backed task state. TODO.md is a regenerated view;
|
|
2
|
+
// events are the durable trail that web/desktop/cloud projections can read.
|
|
3
3
|
|
|
4
4
|
'use strict';
|
|
5
5
|
|
|
6
6
|
const fs = require('fs');
|
|
7
|
+
const http = require('http');
|
|
7
8
|
const path = require('path');
|
|
8
9
|
const os = require('os');
|
|
9
10
|
|
|
@@ -36,19 +37,40 @@ function getTaskDb() {
|
|
|
36
37
|
|
|
37
38
|
function help() {
|
|
38
39
|
console.log(`
|
|
39
|
-
atris task
|
|
40
|
+
atris task - durable local task state (SQLite, gitignored)
|
|
41
|
+
|
|
42
|
+
atris task Show the task desk
|
|
43
|
+
atris task new "<title>" Create a task
|
|
44
|
+
atris task next Claim/show the next open task
|
|
45
|
+
atris task say <id> "<message>" Add context to a task
|
|
46
|
+
atris task finish <id> [--proof "..."] Complete, optionally review
|
|
40
47
|
|
|
41
48
|
atris task add "<title>" [--tag <tag>] Create a task
|
|
49
|
+
atris task delegate "<title>" --to <id> Create an assigned task
|
|
50
|
+
atris task day [--json] Show today's owner-grouped task list
|
|
42
51
|
atris task list [--all] [--status <s>] List tasks (default: this workspace)
|
|
43
52
|
atris task claim <id> [--as <owner>] Atomic claim
|
|
53
|
+
atris task note <id> "<message>" Append dialogue/context to a task
|
|
54
|
+
atris task show <id> [--json] Show a task card + dialogue
|
|
44
55
|
atris task done <id> [--failed] Mark complete (or failed)
|
|
56
|
+
atris task review <id> --reward <n> Write review event + RSI episode
|
|
57
|
+
atris task status [--json] Compact live status for web/Swarlo
|
|
58
|
+
atris task setup [--import-todo] Create/refresh task projection
|
|
59
|
+
atris task serve [--port <n>] Open local task factory board
|
|
60
|
+
atris task sync --dry-run Plan cloud/Swarlo task sync writes
|
|
45
61
|
atris task import <file> One-shot import from TODO.md
|
|
62
|
+
atris task events [id] Print append-only task events
|
|
63
|
+
atris task export [--out <file>] Write web/desktop JSON projection
|
|
64
|
+
atris task render [--out <file>] Regenerate TODO.md view from state
|
|
46
65
|
atris task where Print db path + workspace scope
|
|
47
66
|
atris task help This help
|
|
48
67
|
|
|
49
68
|
Env:
|
|
50
69
|
ATRIS_TASKS_DB Override db path (default ~/.atris/tasks.db)
|
|
51
70
|
ATRIS_AGENT_ID Owner id for claim/done (default: $USER)
|
|
71
|
+
|
|
72
|
+
Headless:
|
|
73
|
+
Add --json to task commands for machine-readable output and stable automation.
|
|
52
74
|
`.trim());
|
|
53
75
|
}
|
|
54
76
|
|
|
@@ -62,6 +84,32 @@ function hasFlag(args, name) {
|
|
|
62
84
|
return args.indexOf(name) !== -1;
|
|
63
85
|
}
|
|
64
86
|
|
|
87
|
+
function wantsJson(args) {
|
|
88
|
+
return hasFlag(args, '--json');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function printJson(value) {
|
|
92
|
+
console.log(JSON.stringify(value, null, 2));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function jsonModeActive() {
|
|
96
|
+
return process.argv.includes('--json');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function failTask(label, reason, detail, exitCode = 2) {
|
|
100
|
+
if (jsonModeActive()) {
|
|
101
|
+
console.error(JSON.stringify({
|
|
102
|
+
ok: false,
|
|
103
|
+
command: label,
|
|
104
|
+
reason,
|
|
105
|
+
detail: detail || null,
|
|
106
|
+
}));
|
|
107
|
+
} else {
|
|
108
|
+
console.error(detail || `${label}: ${reason}`);
|
|
109
|
+
}
|
|
110
|
+
process.exit(exitCode);
|
|
111
|
+
}
|
|
112
|
+
|
|
65
113
|
function positional(args) {
|
|
66
114
|
return args.filter((a, i) => {
|
|
67
115
|
if (a.startsWith('--')) return false;
|
|
@@ -70,6 +118,486 @@ function positional(args) {
|
|
|
70
118
|
});
|
|
71
119
|
}
|
|
72
120
|
|
|
121
|
+
function writeDefaultProjection(taskDb, db, { all = false } = {}) {
|
|
122
|
+
const projection = enrichTaskProjection(taskDb.taskProjection(db, {
|
|
123
|
+
workspaceRoot: all ? null : taskDb.workspaceRoot(),
|
|
124
|
+
limit: 500,
|
|
125
|
+
}));
|
|
126
|
+
const outPath = path.resolve(path.join('.atris', 'state', 'tasks.projection.json'));
|
|
127
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
128
|
+
fs.writeFileSync(outPath, JSON.stringify(projection, null, 2) + '\n', 'utf8');
|
|
129
|
+
return { projection, outPath };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function taskFromProjection(projection, id) {
|
|
133
|
+
return projection.tasks.find(t => t.id === id) || null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function createNextTaskIfRequested(taskDb, db, args, currentTask, title) {
|
|
137
|
+
const nextTitle = String(title || '').trim();
|
|
138
|
+
if (!hasFlag(args, '--create-next') || !nextTitle) return null;
|
|
139
|
+
const result = taskDb.addTask(db, {
|
|
140
|
+
title: nextTitle,
|
|
141
|
+
tag: currentTask && currentTask.tag || null,
|
|
142
|
+
workspaceRoot: taskDb.workspaceRoot(),
|
|
143
|
+
metadata: {
|
|
144
|
+
parent_task_id: currentTask && currentTask.id || null,
|
|
145
|
+
source: 'task_review_next',
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
return result;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function readLocalBusinessBinding(root = process.cwd()) {
|
|
152
|
+
const file = path.join(root, '.atris', 'business.json');
|
|
153
|
+
if (!fs.existsSync(file)) return null;
|
|
154
|
+
try {
|
|
155
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
156
|
+
} catch (e) {
|
|
157
|
+
throw new Error(`Failed to read .atris/business.json: ${e.message || e}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function extractGoalLines(text) {
|
|
162
|
+
const goals = [];
|
|
163
|
+
let inFrontmatter = false;
|
|
164
|
+
let seenContent = false;
|
|
165
|
+
for (const raw of String(text || '').split(/\r?\n/)) {
|
|
166
|
+
const line = raw.trim();
|
|
167
|
+
if (line === '---' && !seenContent) {
|
|
168
|
+
inFrontmatter = !inFrontmatter;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (inFrontmatter) continue;
|
|
172
|
+
if (!line) continue;
|
|
173
|
+
seenContent = true;
|
|
174
|
+
if (line.startsWith('#') || line.startsWith('---') || /^\|[-\s|]+\|$/.test(line)) continue;
|
|
175
|
+
if (/^\|/.test(line)) {
|
|
176
|
+
const cells = line.split('|').map(c => c.trim()).filter(Boolean);
|
|
177
|
+
if (cells[0] && !/^goal$/i.test(cells[0])) goals.push(cells.slice(0, 3).join(' / '));
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
goals.push(line.replace(/^[-*]\s+/, '').replace(/^\d+\.\s+/, ''));
|
|
181
|
+
}
|
|
182
|
+
return goals.filter(Boolean).slice(0, 8);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function readGoalSources(root = process.cwd()) {
|
|
186
|
+
const candidates = [
|
|
187
|
+
path.join(root, 'atris', 'goals.md'),
|
|
188
|
+
path.join(root, 'goals.md'),
|
|
189
|
+
path.join(root, 'atris', 'wiki', 'concepts', 'atris-labs-goals.md'),
|
|
190
|
+
];
|
|
191
|
+
for (const file of candidates) {
|
|
192
|
+
if (!fs.existsSync(file)) continue;
|
|
193
|
+
const goals = extractGoalLines(fs.readFileSync(file, 'utf8'));
|
|
194
|
+
if (goals.length) return { path: file, goals };
|
|
195
|
+
}
|
|
196
|
+
return { path: null, goals: [] };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function taskReviewSummary(task) {
|
|
200
|
+
const reviewed = (task.events || []).slice().reverse().find(e => e.event_type === 'reviewed');
|
|
201
|
+
const payload = reviewed && reviewed.payload || {};
|
|
202
|
+
return {
|
|
203
|
+
reward: payload.reward === undefined ? null : payload.reward,
|
|
204
|
+
proof: payload.proof || null,
|
|
205
|
+
lesson: payload.lesson || null,
|
|
206
|
+
next_task: payload.next_task || null,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function taskAssignee(task) {
|
|
211
|
+
const metadata = task && task.metadata || {};
|
|
212
|
+
return metadata.assigned_to || task.claimed_by || null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function scoreGoalMatch(task, goal) {
|
|
216
|
+
const haystack = `${task.title} ${task.tag || ''}`.toLowerCase();
|
|
217
|
+
const words = String(goal || '').toLowerCase().match(/[a-z0-9]{4,}/g) || [];
|
|
218
|
+
return words.reduce((score, word) => score + (haystack.includes(word) ? 1 : 0), 0);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function pickTaskGoal(task, goals) {
|
|
222
|
+
if (!goals.length) return null;
|
|
223
|
+
let best = goals[0];
|
|
224
|
+
let bestScore = -1;
|
|
225
|
+
for (const goal of goals) {
|
|
226
|
+
const score = scoreGoalMatch(task, goal);
|
|
227
|
+
if (score > bestScore) {
|
|
228
|
+
best = goal;
|
|
229
|
+
bestScore = score;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return best;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function buildTaskStreams(tasks, goals) {
|
|
236
|
+
const buckets = new Map();
|
|
237
|
+
for (const task of tasks) {
|
|
238
|
+
const objective = task.objective || 'Unmapped work';
|
|
239
|
+
if (!buckets.has(objective)) {
|
|
240
|
+
buckets.set(objective, {
|
|
241
|
+
objective,
|
|
242
|
+
active_count: 0,
|
|
243
|
+
done_count: 0,
|
|
244
|
+
open_count: 0,
|
|
245
|
+
doing_count: 0,
|
|
246
|
+
blocked_count: 0,
|
|
247
|
+
review_count: 0,
|
|
248
|
+
tasks: [],
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
const stream = buckets.get(objective);
|
|
252
|
+
const column = taskColumn(task);
|
|
253
|
+
if (task.status === 'done') stream.done_count += 1; else stream.active_count += 1;
|
|
254
|
+
if (column === 'open') stream.open_count += 1;
|
|
255
|
+
if (column === 'doing') stream.doing_count += 1;
|
|
256
|
+
if (column === 'blocked') stream.blocked_count += 1;
|
|
257
|
+
if (column === 'review') stream.review_count += 1;
|
|
258
|
+
stream.tasks.push({
|
|
259
|
+
id: task.id,
|
|
260
|
+
title: task.title,
|
|
261
|
+
status: task.status,
|
|
262
|
+
tag: task.tag,
|
|
263
|
+
claimed_by: task.claimed_by,
|
|
264
|
+
assigned_to: taskAssignee(task),
|
|
265
|
+
parent_task_id: task.lineage && task.lineage.parent_task_id || null,
|
|
266
|
+
child_task_ids: task.lineage && task.lineage.child_task_ids || [],
|
|
267
|
+
proof: task.review && task.review.proof || null,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
for (const goal of goals) {
|
|
271
|
+
if (!buckets.has(goal)) {
|
|
272
|
+
buckets.set(goal, {
|
|
273
|
+
objective: goal,
|
|
274
|
+
active_count: 0,
|
|
275
|
+
done_count: 0,
|
|
276
|
+
open_count: 0,
|
|
277
|
+
doing_count: 0,
|
|
278
|
+
blocked_count: 0,
|
|
279
|
+
review_count: 0,
|
|
280
|
+
tasks: [],
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return Array.from(buckets.values())
|
|
285
|
+
.sort((a, b) => (b.active_count - a.active_count) || (b.done_count - a.done_count) || a.objective.localeCompare(b.objective));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function enrichTaskProjection(projection) {
|
|
289
|
+
const root = projection.workspace_root || process.cwd();
|
|
290
|
+
const goalSource = readGoalSources(root);
|
|
291
|
+
const byId = new Map((projection.tasks || []).map(task => [task.id, task]));
|
|
292
|
+
const children = new Map();
|
|
293
|
+
for (const task of projection.tasks || []) {
|
|
294
|
+
const parentId = task.metadata && task.metadata.parent_task_id;
|
|
295
|
+
if (!parentId) continue;
|
|
296
|
+
if (!children.has(parentId)) children.set(parentId, []);
|
|
297
|
+
children.get(parentId).push(task);
|
|
298
|
+
}
|
|
299
|
+
const enrichedTasks = (projection.tasks || []).map(task => {
|
|
300
|
+
const parentId = task.metadata && task.metadata.parent_task_id || null;
|
|
301
|
+
const parent = parentId ? byId.get(parentId) : null;
|
|
302
|
+
const childTasks = children.get(task.id) || [];
|
|
303
|
+
const review = taskReviewSummary(task);
|
|
304
|
+
return {
|
|
305
|
+
...task,
|
|
306
|
+
objective: pickTaskGoal(task, goalSource.goals),
|
|
307
|
+
review,
|
|
308
|
+
lineage: {
|
|
309
|
+
parent_task_id: parentId,
|
|
310
|
+
parent_title: parent ? parent.title : null,
|
|
311
|
+
child_task_ids: childTasks.map(child => child.id),
|
|
312
|
+
child_titles: childTasks.map(child => child.title),
|
|
313
|
+
next_task_suggestion: review.next_task,
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
});
|
|
317
|
+
return {
|
|
318
|
+
...projection,
|
|
319
|
+
goals: {
|
|
320
|
+
source_path: goalSource.path,
|
|
321
|
+
items: goalSource.goals,
|
|
322
|
+
},
|
|
323
|
+
streams: buildTaskStreams(enrichedTasks, goalSource.goals),
|
|
324
|
+
tasks: enrichedTasks,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function taskTypeForCloud(task) {
|
|
329
|
+
const tag = String(task.tag || '').toLowerCase();
|
|
330
|
+
if (['inbound', 'outbound', 'creative', 'improvement'].includes(tag)) return tag;
|
|
331
|
+
if (['design', 'writing', 'image', 'video', 'launch'].includes(tag)) return 'creative';
|
|
332
|
+
if (['sales', 'gtm', 'customer', 'email'].includes(tag)) return 'outbound';
|
|
333
|
+
return 'improvement';
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function taskStateForCloud(task) {
|
|
337
|
+
if (task.status === 'claimed') return 'doing';
|
|
338
|
+
if (task.status === 'failed') return 'blocked';
|
|
339
|
+
if (task.status === 'done') return 'done';
|
|
340
|
+
return 'open';
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function ownerMemberIdForCloud(task) {
|
|
344
|
+
const ownerValue = task.claimed_by || taskAssignee(task);
|
|
345
|
+
if (!ownerValue) return null;
|
|
346
|
+
const owner = String(ownerValue).trim();
|
|
347
|
+
if (!owner) return null;
|
|
348
|
+
if (owner.includes(':')) return owner;
|
|
349
|
+
return `agent:${owner}`;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function taskDescriptionForCloud(task) {
|
|
353
|
+
const lines = [
|
|
354
|
+
`Local task: ${task.id}`,
|
|
355
|
+
`Status: ${task.status}`,
|
|
356
|
+
`Latest event: ${task.latest_event_type || 'none'}`,
|
|
357
|
+
];
|
|
358
|
+
if (task.messages && task.messages.length) {
|
|
359
|
+
lines.push('', 'Thread:');
|
|
360
|
+
for (const message of task.messages.slice(-5)) {
|
|
361
|
+
lines.push(`- ${message.actor || 'unknown'}: ${message.content}`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
const reviewed = (task.events || []).slice().reverse().find(e => e.event_type === 'reviewed');
|
|
365
|
+
if (reviewed && reviewed.payload) {
|
|
366
|
+
if (reviewed.payload.proof) lines.push('', `Proof: ${reviewed.payload.proof}`);
|
|
367
|
+
if (reviewed.payload.lesson) lines.push(`Lesson: ${reviewed.payload.lesson}`);
|
|
368
|
+
if (reviewed.payload.next_task) lines.push(`Next: ${reviewed.payload.next_task}`);
|
|
369
|
+
}
|
|
370
|
+
return lines.join('\n').slice(0, 5000);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function cloudPayloadForTask(task, businessId) {
|
|
374
|
+
const metadata = task.metadata || {};
|
|
375
|
+
const claimedAtEvent = (task.events || []).find(e => e.event_type === 'claimed');
|
|
376
|
+
return {
|
|
377
|
+
type: taskTypeForCloud(task),
|
|
378
|
+
title: String(task.title || '').slice(0, 200),
|
|
379
|
+
description: taskDescriptionForCloud(task),
|
|
380
|
+
owner_member_id: ownerMemberIdForCloud(task),
|
|
381
|
+
needs_approval: false,
|
|
382
|
+
metadata: {
|
|
383
|
+
...metadata,
|
|
384
|
+
source: 'atris_cli_task',
|
|
385
|
+
business_id: businessId,
|
|
386
|
+
local_task_id: task.id,
|
|
387
|
+
local_status: task.status,
|
|
388
|
+
local_tag: task.tag || null,
|
|
389
|
+
current_version: task.current_version,
|
|
390
|
+
latest_event_type: task.latest_event_type,
|
|
391
|
+
workspace_root: task.workspace_root,
|
|
392
|
+
swarlo: {
|
|
393
|
+
lease_owner: task.claimed_by || null,
|
|
394
|
+
lease_state: task.status === 'claimed' ? 'held' : 'none',
|
|
395
|
+
lease_started_at: claimedAtEvent ? new Date(claimedAtEvent.created_at).toISOString() : null,
|
|
396
|
+
},
|
|
397
|
+
},
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function syncPlanForProjection(projection, businessId) {
|
|
402
|
+
const endpoint = `/business/${businessId}/work/tasks`;
|
|
403
|
+
const plan = [];
|
|
404
|
+
for (const task of projection.tasks) {
|
|
405
|
+
const payload = cloudPayloadForTask(task, businessId);
|
|
406
|
+
const cloudTaskId = task.metadata && (task.metadata.cloud_task_id || task.metadata.supabase_task_id);
|
|
407
|
+
if (cloudTaskId) {
|
|
408
|
+
plan.push({
|
|
409
|
+
action: 'patch',
|
|
410
|
+
method: 'PATCH',
|
|
411
|
+
endpoint: `${endpoint}/${cloudTaskId}`,
|
|
412
|
+
local_task_id: task.id,
|
|
413
|
+
cloud_task_id: cloudTaskId,
|
|
414
|
+
body: {
|
|
415
|
+
...payload,
|
|
416
|
+
state: taskStateForCloud(task),
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
} else {
|
|
420
|
+
plan.push({
|
|
421
|
+
action: 'post',
|
|
422
|
+
method: 'POST',
|
|
423
|
+
endpoint,
|
|
424
|
+
local_task_id: task.id,
|
|
425
|
+
body: payload,
|
|
426
|
+
after_create: taskStateForCloud(task) === 'open' ? [] : [{
|
|
427
|
+
method: 'PATCH',
|
|
428
|
+
endpoint: `${endpoint}/{created_task_id}`,
|
|
429
|
+
body: { state: taskStateForCloud(task) },
|
|
430
|
+
}],
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return plan;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function latestTaskEvent(task) {
|
|
438
|
+
const events = task.events || [];
|
|
439
|
+
return events.length ? events[events.length - 1] : null;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function taskStatusSummary(projection) {
|
|
443
|
+
const tasks = projection.tasks || [];
|
|
444
|
+
const columns = {
|
|
445
|
+
plan: tasks.filter(task => taskColumn(task) === 'open'),
|
|
446
|
+
do: tasks.filter(task => taskColumn(task) === 'doing'),
|
|
447
|
+
review: tasks.filter(task => taskColumn(task) === 'review' || taskColumn(task) === 'blocked'),
|
|
448
|
+
done: tasks.filter(task => taskColumn(task) === 'done'),
|
|
449
|
+
};
|
|
450
|
+
const active = [...columns.do, ...columns.review, ...columns.plan];
|
|
451
|
+
const lastUpdated = tasks.reduce((max, task) => Math.max(max, Number(task.updated_at || 0)), 0);
|
|
452
|
+
const swarloFeed = tasks
|
|
453
|
+
.flatMap(task => (task.events || []).map(event => ({
|
|
454
|
+
task_id: task.id,
|
|
455
|
+
task_title: task.title,
|
|
456
|
+
actor: event.actor || task.claimed_by || null,
|
|
457
|
+
kind: event.event_type === 'claimed'
|
|
458
|
+
? 'claim'
|
|
459
|
+
: event.event_type === 'completed' || event.event_type === 'reviewed'
|
|
460
|
+
? 'result'
|
|
461
|
+
: 'note',
|
|
462
|
+
channel: task.tag || 'tasks',
|
|
463
|
+
content: event.payload && (event.payload.content || event.payload.proof || event.payload.lesson)
|
|
464
|
+
|| humanEventType(event.event_type),
|
|
465
|
+
created_at: event.created_at,
|
|
466
|
+
metadata: {
|
|
467
|
+
swarlo: {
|
|
468
|
+
task_key: task.id,
|
|
469
|
+
kind: event.event_type === 'claimed' ? 'claim' : event.event_type === 'completed' || event.event_type === 'reviewed' ? 'result' : 'note',
|
|
470
|
+
status: taskStateForCloud(task),
|
|
471
|
+
},
|
|
472
|
+
},
|
|
473
|
+
})))
|
|
474
|
+
.sort((a, b) => b.created_at - a.created_at)
|
|
475
|
+
.slice(0, 12);
|
|
476
|
+
return {
|
|
477
|
+
schema: 'atris.task_status.v1',
|
|
478
|
+
generated_at: projection.generated_at,
|
|
479
|
+
workspace_root: projection.workspace_root,
|
|
480
|
+
goals: projection.goals || { source_path: null, items: [] },
|
|
481
|
+
counts: {
|
|
482
|
+
total: tasks.length,
|
|
483
|
+
active: tasks.filter(task => task.status !== 'done').length,
|
|
484
|
+
plan: columns.plan.length,
|
|
485
|
+
do: columns.do.length,
|
|
486
|
+
review: columns.review.length,
|
|
487
|
+
done: columns.done.length,
|
|
488
|
+
},
|
|
489
|
+
current: columns.do[0] || columns.review[0] || null,
|
|
490
|
+
next: columns.plan[0] || null,
|
|
491
|
+
needs_review: columns.review.slice(0, 5),
|
|
492
|
+
streams: (projection.streams || []).slice(0, 8).map(stream => ({
|
|
493
|
+
objective: stream.objective,
|
|
494
|
+
active_count: stream.active_count,
|
|
495
|
+
done_count: stream.done_count,
|
|
496
|
+
open_count: stream.open_count,
|
|
497
|
+
doing_count: stream.doing_count,
|
|
498
|
+
review_count: stream.review_count,
|
|
499
|
+
blocked_count: stream.blocked_count,
|
|
500
|
+
})),
|
|
501
|
+
last_event: active.map(task => ({ task, event: latestTaskEvent(task) })).filter(row => row.event)
|
|
502
|
+
.sort((a, b) => b.event.created_at - a.event.created_at)[0] || null,
|
|
503
|
+
last_updated_at: lastUpdated ? new Date(lastUpdated).toISOString() : null,
|
|
504
|
+
swarlo: {
|
|
505
|
+
feed: swarloFeed,
|
|
506
|
+
realtime_contract: {
|
|
507
|
+
claim: 'Swarlo claim -> canonical task state=doing + lease metadata',
|
|
508
|
+
report_done: 'Swarlo report(done) -> canonical task state=done + proof metadata',
|
|
509
|
+
web: 'atrisos-web reads canonical tasks through /api/agent/:id/tasks or /api/business/* and live activity through public business/Swarlo posts',
|
|
510
|
+
},
|
|
511
|
+
},
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function humanEventType(type) {
|
|
516
|
+
return String(type || 'event').replace(/_/g, ' ');
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function formatTaskLine(task) {
|
|
520
|
+
if (!task) return 'none';
|
|
521
|
+
const owner = task.claimed_by ? ` @${task.claimed_by}` : '';
|
|
522
|
+
const assigned = !task.claimed_by && taskAssignee(task) ? ` -> ${taskAssignee(task)}` : '';
|
|
523
|
+
const tag = task.tag ? ` #${task.tag}` : '';
|
|
524
|
+
return `${task.id.slice(0, 8)}${owner}${assigned}${tag} ${task.title}`;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function cmdStatus(args) {
|
|
528
|
+
const all = hasFlag(args, '--all');
|
|
529
|
+
const taskDb = getTaskDb();
|
|
530
|
+
const db = taskDb.open();
|
|
531
|
+
const { projection, outPath } = writeDefaultProjection(taskDb, db, { all });
|
|
532
|
+
const status = taskStatusSummary(projection);
|
|
533
|
+
if (wantsJson(args)) {
|
|
534
|
+
printJson({
|
|
535
|
+
ok: true,
|
|
536
|
+
action: 'status',
|
|
537
|
+
projection_path: outPath,
|
|
538
|
+
status,
|
|
539
|
+
});
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
console.log('TASK STATUS');
|
|
543
|
+
console.log(`workspace ${status.workspace_root || '(all)'}`);
|
|
544
|
+
console.log(`plan ${status.counts.plan} / do ${status.counts.do} / review ${status.counts.review} / done ${status.counts.done}`);
|
|
545
|
+
console.log(`current ${formatTaskLine(status.current)}`);
|
|
546
|
+
console.log(`next ${formatTaskLine(status.next)}`);
|
|
547
|
+
if (status.needs_review.length) {
|
|
548
|
+
console.log('review');
|
|
549
|
+
for (const task of status.needs_review.slice(0, 3)) console.log(` ${formatTaskLine(task)}`);
|
|
550
|
+
}
|
|
551
|
+
console.log(`swarlo feed ${status.swarlo.feed.length} event${status.swarlo.feed.length === 1 ? '' : 's'}`);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function resolveTaskRef(taskDb, db, ref) {
|
|
555
|
+
const token = String(ref || '').trim();
|
|
556
|
+
if (!token) return { ok: false, reason: 'missing' };
|
|
557
|
+
const exact = taskDb.getTask(db, token);
|
|
558
|
+
if (exact) return { ok: true, id: exact.id, row: exact };
|
|
559
|
+
const rows = taskDb.listTasks(db, { workspaceRoot: taskDb.workspaceRoot(), limit: 500 });
|
|
560
|
+
const matches = rows.filter(r => r.id.startsWith(token));
|
|
561
|
+
if (matches.length === 1) return { ok: true, id: matches[0].id, row: matches[0] };
|
|
562
|
+
if (matches.length > 1) return { ok: false, reason: 'ambiguous', matches };
|
|
563
|
+
return { ok: false, reason: 'not_found' };
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function requireTaskId(taskDb, db, ref, label) {
|
|
567
|
+
const resolved = resolveTaskRef(taskDb, db, ref);
|
|
568
|
+
if (resolved.ok) return resolved.id;
|
|
569
|
+
if (resolved.reason === 'ambiguous') {
|
|
570
|
+
failTask(label, 'ambiguous', `ambiguous task id prefix "${ref}"`);
|
|
571
|
+
} else if (resolved.reason === 'missing') {
|
|
572
|
+
failTask(label, 'missing_id', 'task id required');
|
|
573
|
+
} else {
|
|
574
|
+
failTask(label, 'not_found', `task not found: ${ref}`);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function renderTaskDesk(rows) {
|
|
579
|
+
const active = rows.filter(r => r.status !== 'done');
|
|
580
|
+
const done = rows.filter(r => r.status === 'done');
|
|
581
|
+
if (rows.length === 0) {
|
|
582
|
+
console.log('No tasks yet.');
|
|
583
|
+
console.log('Start with: atris task new "Ship the smallest useful thing"');
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
console.log('TASK DESK');
|
|
587
|
+
console.log('');
|
|
588
|
+
for (const r of active.slice(0, 12)) {
|
|
589
|
+
const owner = r.claimed_by ? ` @${r.claimed_by}` : '';
|
|
590
|
+
const assigned = !r.claimed_by && taskAssignee(r) ? ` -> ${taskAssignee(r)}` : '';
|
|
591
|
+
const tag = r.tag ? ` #${r.tag}` : '';
|
|
592
|
+
console.log(`${r.status.padEnd(7)} ${r.id.slice(0, 8)}${owner}${assigned}${tag}`);
|
|
593
|
+
console.log(` ${r.title}`);
|
|
594
|
+
}
|
|
595
|
+
if (active.length === 0) console.log('clear no active tasks');
|
|
596
|
+
console.log('');
|
|
597
|
+
console.log(`active ${active.length} / done ${done.length}`);
|
|
598
|
+
console.log('next: atris task next');
|
|
599
|
+
}
|
|
600
|
+
|
|
73
601
|
function cmdAdd(args) {
|
|
74
602
|
const pos = positional(args);
|
|
75
603
|
const title = pos.join(' ').trim();
|
|
@@ -86,9 +614,186 @@ function cmdAdd(args) {
|
|
|
86
614
|
tag: typeof tag === 'string' ? tag : null,
|
|
87
615
|
workspaceRoot: ws,
|
|
88
616
|
});
|
|
617
|
+
const { projection, outPath } = writeDefaultProjection(taskDb, db);
|
|
618
|
+
if (wantsJson(args)) {
|
|
619
|
+
printJson({
|
|
620
|
+
ok: true,
|
|
621
|
+
action: 'created',
|
|
622
|
+
task_id: result.id,
|
|
623
|
+
inserted: result.inserted !== false,
|
|
624
|
+
projection_path: outPath,
|
|
625
|
+
task: taskFromProjection(projection, result.id),
|
|
626
|
+
});
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
89
629
|
console.log(`${result.id}\t${title}`);
|
|
90
630
|
}
|
|
91
631
|
|
|
632
|
+
function delegateHandoff(taskId, owner, via, tag) {
|
|
633
|
+
const shortId = taskId.slice(0, 8);
|
|
634
|
+
const handoff = {
|
|
635
|
+
command: `atris task claim ${shortId} --as ${owner}`,
|
|
636
|
+
};
|
|
637
|
+
if (via === 'swarlo') {
|
|
638
|
+
handoff.swarlo = {
|
|
639
|
+
task_key: taskId,
|
|
640
|
+
action: 'claim',
|
|
641
|
+
channel: tag || 'tasks',
|
|
642
|
+
assignee: owner,
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
return handoff;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function cmdDelegate(args) {
|
|
649
|
+
const pos = positional(args);
|
|
650
|
+
const title = pos.join(' ').trim();
|
|
651
|
+
const owner = flag(args, '--to') || flag(args, '--as');
|
|
652
|
+
if (!title) {
|
|
653
|
+
console.error('atris task delegate: title required');
|
|
654
|
+
process.exit(2);
|
|
655
|
+
}
|
|
656
|
+
if (!owner || owner === true) {
|
|
657
|
+
console.error('atris task delegate: --to <owner> required');
|
|
658
|
+
process.exit(2);
|
|
659
|
+
}
|
|
660
|
+
const viaFlag = flag(args, '--via');
|
|
661
|
+
const via = viaFlag === 'swarlo' ? 'swarlo' : 'local';
|
|
662
|
+
const tag = flag(args, '--tag');
|
|
663
|
+
const note = flag(args, '--note');
|
|
664
|
+
const claimNow = hasFlag(args, '--claim');
|
|
665
|
+
const taskDb = getTaskDb();
|
|
666
|
+
const db = taskDb.open();
|
|
667
|
+
const ws = taskDb.workspaceRoot();
|
|
668
|
+
const metadata = {
|
|
669
|
+
assigned_to: String(owner),
|
|
670
|
+
delegate_via: via,
|
|
671
|
+
swarlo_channel: via === 'swarlo' ? String(tag || 'tasks') : null,
|
|
672
|
+
created_for_day: new Date().toISOString().slice(0, 10),
|
|
673
|
+
};
|
|
674
|
+
const result = taskDb.addTask(db, {
|
|
675
|
+
title,
|
|
676
|
+
tag: typeof tag === 'string' ? tag : null,
|
|
677
|
+
workspaceRoot: ws,
|
|
678
|
+
status: claimNow ? 'claimed' : 'open',
|
|
679
|
+
claimedBy: claimNow ? String(owner) : null,
|
|
680
|
+
metadata,
|
|
681
|
+
});
|
|
682
|
+
if (typeof note === 'string' && note.trim()) {
|
|
683
|
+
taskDb.noteTask(db, { id: result.id, actor: DEFAULT_OWNER, content: note });
|
|
684
|
+
}
|
|
685
|
+
const { projection, outPath } = writeDefaultProjection(taskDb, db);
|
|
686
|
+
const task = taskFromProjection(projection, result.id);
|
|
687
|
+
const handoff = delegateHandoff(result.id, String(owner), via, typeof tag === 'string' ? tag : null);
|
|
688
|
+
if (wantsJson(args)) {
|
|
689
|
+
printJson({
|
|
690
|
+
ok: true,
|
|
691
|
+
action: 'delegated',
|
|
692
|
+
task_id: result.id,
|
|
693
|
+
inserted: result.inserted !== false,
|
|
694
|
+
owner: String(owner),
|
|
695
|
+
via,
|
|
696
|
+
handoff,
|
|
697
|
+
projection_path: outPath,
|
|
698
|
+
task,
|
|
699
|
+
});
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
const tagText = tag && tag !== true ? ` #${tag}` : '';
|
|
703
|
+
console.log(`delegated ${result.id.slice(0, 8)} -> ${owner}${tagText} via=${via}`);
|
|
704
|
+
console.log(`claim: ${handoff.command}`);
|
|
705
|
+
if (handoff.swarlo) console.log(`swarlo: ${handoff.swarlo.channel}/${handoff.swarlo.action}`);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function taskDayGroups(tasks) {
|
|
709
|
+
const active = tasks.filter(task => task.status !== 'done');
|
|
710
|
+
const groups = new Map();
|
|
711
|
+
for (const task of active) {
|
|
712
|
+
const owner = taskAssignee(task) || 'unassigned';
|
|
713
|
+
if (!groups.has(owner)) groups.set(owner, []);
|
|
714
|
+
groups.get(owner).push(task);
|
|
715
|
+
}
|
|
716
|
+
return Array.from(groups.entries())
|
|
717
|
+
.sort((a, b) => {
|
|
718
|
+
if (a[0] === 'unassigned') return 1;
|
|
719
|
+
if (b[0] === 'unassigned') return -1;
|
|
720
|
+
return a[0].localeCompare(b[0]);
|
|
721
|
+
})
|
|
722
|
+
.map(([owner, ownerTasks]) => ({
|
|
723
|
+
owner,
|
|
724
|
+
tasks: ownerTasks.sort((a, b) => {
|
|
725
|
+
const statusOrder = { claimed: 0, open: 1, failed: 2, done: 3 };
|
|
726
|
+
return (statusOrder[a.status] - statusOrder[b.status]) || (b.updated_at - a.updated_at);
|
|
727
|
+
}),
|
|
728
|
+
}));
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function cmdDay(args) {
|
|
732
|
+
const all = hasFlag(args, '--all');
|
|
733
|
+
const taskDb = getTaskDb();
|
|
734
|
+
const db = taskDb.open();
|
|
735
|
+
const { projection, outPath } = writeDefaultProjection(taskDb, db, { all });
|
|
736
|
+
const groups = taskDayGroups(projection.tasks || []);
|
|
737
|
+
const counts = {
|
|
738
|
+
active: groups.reduce((sum, group) => sum + group.tasks.length, 0),
|
|
739
|
+
owners: groups.length,
|
|
740
|
+
open: (projection.tasks || []).filter(task => task.status === 'open').length,
|
|
741
|
+
claimed: (projection.tasks || []).filter(task => task.status === 'claimed').length,
|
|
742
|
+
review: (projection.tasks || []).filter(task => task.status === 'failed' || (task.status === 'done' && task.review && task.review.reward === null)).length,
|
|
743
|
+
};
|
|
744
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
745
|
+
if (wantsJson(args)) {
|
|
746
|
+
printJson({
|
|
747
|
+
ok: true,
|
|
748
|
+
action: 'day',
|
|
749
|
+
date,
|
|
750
|
+
projection_path: outPath,
|
|
751
|
+
counts,
|
|
752
|
+
groups,
|
|
753
|
+
});
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
console.log('TASK DAY');
|
|
757
|
+
console.log(`${date} active ${counts.active} / owners ${counts.owners} / review ${counts.review}`);
|
|
758
|
+
console.log('');
|
|
759
|
+
if (!groups.length) {
|
|
760
|
+
console.log('clear no active tasks');
|
|
761
|
+
}
|
|
762
|
+
for (const group of groups) {
|
|
763
|
+
console.log(`${group.owner}`);
|
|
764
|
+
for (const task of group.tasks.slice(0, 8)) {
|
|
765
|
+
const tag = task.tag ? ` #${task.tag}` : '';
|
|
766
|
+
const claim = task.claimed_by ? ` @${task.claimed_by}` : '';
|
|
767
|
+
console.log(` ${task.status.padEnd(7)} ${task.id.slice(0, 8)}${claim}${tag} ${task.title}`);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
console.log('');
|
|
771
|
+
console.log('add: atris task delegate "..." --to codex --tag tasks');
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function cmdHome(args) {
|
|
775
|
+
const all = hasFlag(args, '--all');
|
|
776
|
+
const taskDb = getTaskDb();
|
|
777
|
+
const db = taskDb.open();
|
|
778
|
+
const rows = taskDb.listTasks(db, {
|
|
779
|
+
workspaceRoot: all ? null : taskDb.workspaceRoot(),
|
|
780
|
+
limit: 200,
|
|
781
|
+
});
|
|
782
|
+
const { projection, outPath } = writeDefaultProjection(taskDb, db, { all });
|
|
783
|
+
if (wantsJson(args)) {
|
|
784
|
+
printJson({
|
|
785
|
+
ok: true,
|
|
786
|
+
action: 'desk',
|
|
787
|
+
projection_path: outPath,
|
|
788
|
+
active_count: projection.tasks.filter(t => t.status !== 'done').length,
|
|
789
|
+
done_count: projection.tasks.filter(t => t.status === 'done').length,
|
|
790
|
+
projection,
|
|
791
|
+
});
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
renderTaskDesk(rows);
|
|
795
|
+
}
|
|
796
|
+
|
|
92
797
|
function cmdList(args) {
|
|
93
798
|
const all = hasFlag(args, '--all');
|
|
94
799
|
const status = flag(args, '--status');
|
|
@@ -99,6 +804,10 @@ function cmdList(args) {
|
|
|
99
804
|
status: typeof status === 'string' ? status : null,
|
|
100
805
|
limit: 200,
|
|
101
806
|
});
|
|
807
|
+
if (wantsJson(args)) {
|
|
808
|
+
printJson({ ok: true, action: 'list', tasks: rows });
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
102
811
|
if (rows.length === 0) {
|
|
103
812
|
console.log('(no tasks)');
|
|
104
813
|
return;
|
|
@@ -120,15 +829,163 @@ function cmdClaim(args) {
|
|
|
120
829
|
const owner = flag(args, '--as') || DEFAULT_OWNER;
|
|
121
830
|
const taskDb = getTaskDb();
|
|
122
831
|
const db = taskDb.open();
|
|
123
|
-
const
|
|
832
|
+
const taskId = requireTaskId(taskDb, db, id, 'atris task claim');
|
|
833
|
+
const result = taskDb.claimTask(db, { id: taskId, claimedBy: String(owner) });
|
|
124
834
|
if (result.claimed) {
|
|
125
|
-
|
|
835
|
+
const { projection, outPath } = writeDefaultProjection(taskDb, db);
|
|
836
|
+
if (wantsJson(args)) {
|
|
837
|
+
printJson({
|
|
838
|
+
ok: true,
|
|
839
|
+
action: 'claimed',
|
|
840
|
+
task_id: taskId,
|
|
841
|
+
owner: String(owner),
|
|
842
|
+
projection_path: outPath,
|
|
843
|
+
task: taskFromProjection(projection, taskId),
|
|
844
|
+
});
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
console.log(`claimed ${taskId} as ${owner}`);
|
|
126
848
|
} else {
|
|
127
849
|
console.error(`claim failed: ${result.reason}${result.claimed_by ? ` (held by ${result.claimed_by})` : ''}`);
|
|
128
850
|
process.exit(1);
|
|
129
851
|
}
|
|
130
852
|
}
|
|
131
853
|
|
|
854
|
+
function cmdNext(args) {
|
|
855
|
+
const owner = flag(args, '--as') || DEFAULT_OWNER;
|
|
856
|
+
const taskDb = getTaskDb();
|
|
857
|
+
const db = taskDb.open();
|
|
858
|
+
const claimed = taskDb.listTasks(db, {
|
|
859
|
+
workspaceRoot: taskDb.workspaceRoot(),
|
|
860
|
+
status: 'claimed',
|
|
861
|
+
claimedBy: String(owner),
|
|
862
|
+
limit: 1,
|
|
863
|
+
});
|
|
864
|
+
if (claimed.length) {
|
|
865
|
+
const { projection, outPath } = writeDefaultProjection(taskDb, db);
|
|
866
|
+
if (wantsJson(args)) {
|
|
867
|
+
printJson({
|
|
868
|
+
ok: true,
|
|
869
|
+
action: 'current',
|
|
870
|
+
task_id: claimed[0].id,
|
|
871
|
+
owner: String(owner),
|
|
872
|
+
projection_path: outPath,
|
|
873
|
+
task: taskFromProjection(projection, claimed[0].id),
|
|
874
|
+
});
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
console.log(`current ${claimed[0].id.slice(0, 8)} @${owner}`);
|
|
878
|
+
console.log(claimed[0].title);
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
const open = taskDb.listTasks(db, {
|
|
882
|
+
workspaceRoot: taskDb.workspaceRoot(),
|
|
883
|
+
status: 'open',
|
|
884
|
+
limit: 1,
|
|
885
|
+
});
|
|
886
|
+
if (!open.length) {
|
|
887
|
+
const { outPath } = writeDefaultProjection(taskDb, db);
|
|
888
|
+
if (wantsJson(args)) {
|
|
889
|
+
printJson({
|
|
890
|
+
ok: true,
|
|
891
|
+
action: 'none',
|
|
892
|
+
task_id: null,
|
|
893
|
+
owner: String(owner),
|
|
894
|
+
projection_path: outPath,
|
|
895
|
+
});
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
console.log('No open tasks.');
|
|
899
|
+
console.log('Start with: atris task new "..."');
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
const result = taskDb.claimTask(db, { id: open[0].id, claimedBy: String(owner) });
|
|
903
|
+
if (!result.claimed) {
|
|
904
|
+
console.error(`next failed: ${result.reason}`);
|
|
905
|
+
process.exit(1);
|
|
906
|
+
}
|
|
907
|
+
const { projection, outPath } = writeDefaultProjection(taskDb, db);
|
|
908
|
+
if (wantsJson(args)) {
|
|
909
|
+
printJson({
|
|
910
|
+
ok: true,
|
|
911
|
+
action: 'next',
|
|
912
|
+
task_id: open[0].id,
|
|
913
|
+
owner: String(owner),
|
|
914
|
+
projection_path: outPath,
|
|
915
|
+
task: taskFromProjection(projection, open[0].id),
|
|
916
|
+
});
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
console.log(`next ${open[0].id.slice(0, 8)} @${owner}`);
|
|
920
|
+
console.log(open[0].title);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function cmdNote(args) {
|
|
924
|
+
const pos = positional(args);
|
|
925
|
+
const id = pos[0];
|
|
926
|
+
const content = pos.slice(1).join(' ').trim();
|
|
927
|
+
if (!id || !content) {
|
|
928
|
+
console.error('atris task note: id and message required');
|
|
929
|
+
process.exit(2);
|
|
930
|
+
}
|
|
931
|
+
const actor = flag(args, '--as') || DEFAULT_OWNER;
|
|
932
|
+
const taskDb = getTaskDb();
|
|
933
|
+
const db = taskDb.open();
|
|
934
|
+
const taskId = requireTaskId(taskDb, db, id, 'atris task note');
|
|
935
|
+
const result = taskDb.noteTask(db, { id: taskId, actor: String(actor), content });
|
|
936
|
+
if (!result.noted) {
|
|
937
|
+
console.error(`note failed: ${result.reason}`);
|
|
938
|
+
process.exit(1);
|
|
939
|
+
}
|
|
940
|
+
const { projection, outPath } = writeDefaultProjection(taskDb, db);
|
|
941
|
+
if (wantsJson(args)) {
|
|
942
|
+
printJson({
|
|
943
|
+
ok: true,
|
|
944
|
+
action: 'noted',
|
|
945
|
+
task_id: taskId,
|
|
946
|
+
version: result.event.version,
|
|
947
|
+
projection_path: outPath,
|
|
948
|
+
task: taskFromProjection(projection, taskId),
|
|
949
|
+
});
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
console.log(`noted ${taskId} v${result.event.version}`);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
function cmdShow(args) {
|
|
956
|
+
const pos = positional(args);
|
|
957
|
+
const id = pos[0];
|
|
958
|
+
if (!id) {
|
|
959
|
+
console.error('atris task show: id required');
|
|
960
|
+
process.exit(2);
|
|
961
|
+
}
|
|
962
|
+
const taskDb = getTaskDb();
|
|
963
|
+
const db = taskDb.open();
|
|
964
|
+
const taskId = requireTaskId(taskDb, db, id, 'atris task show');
|
|
965
|
+
const projection = taskDb.taskProjection(db, { taskId });
|
|
966
|
+
const task = projection.tasks[0];
|
|
967
|
+
if (!task) {
|
|
968
|
+
console.error(`task not found: ${id}`);
|
|
969
|
+
process.exit(1);
|
|
970
|
+
}
|
|
971
|
+
if (hasFlag(args, '--json')) {
|
|
972
|
+
console.log(JSON.stringify(task, null, 2));
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
const owner = task.claimed_by ? ` / ${task.claimed_by}` : '';
|
|
976
|
+
const tag = task.tag ? ` #${task.tag}` : '';
|
|
977
|
+
console.log(`${task.status.toUpperCase()} ${task.id} v${task.current_version}${owner}${tag}`);
|
|
978
|
+
console.log(task.title);
|
|
979
|
+
if (task.messages.length) {
|
|
980
|
+
console.log('');
|
|
981
|
+
console.log('Dialogue:');
|
|
982
|
+
for (const m of task.messages) {
|
|
983
|
+
const who = m.actor || 'unknown';
|
|
984
|
+
console.log(`- v${m.version} ${who}: ${m.content}`);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
132
989
|
function cmdDone(args) {
|
|
133
990
|
const pos = positional(args);
|
|
134
991
|
const id = pos[0];
|
|
@@ -139,27 +996,147 @@ function cmdDone(args) {
|
|
|
139
996
|
const failed = hasFlag(args, '--failed');
|
|
140
997
|
const taskDb = getTaskDb();
|
|
141
998
|
const db = taskDb.open();
|
|
142
|
-
const
|
|
999
|
+
const taskId = requireTaskId(taskDb, db, id, 'atris task done');
|
|
1000
|
+
const result = taskDb.doneTask(db, { id: taskId, status: failed ? 'failed' : 'done' });
|
|
143
1001
|
if (result.updated) {
|
|
144
|
-
|
|
1002
|
+
const { projection, outPath } = writeDefaultProjection(taskDb, db);
|
|
1003
|
+
if (wantsJson(args)) {
|
|
1004
|
+
printJson({
|
|
1005
|
+
ok: true,
|
|
1006
|
+
action: failed ? 'failed' : 'done',
|
|
1007
|
+
task_id: taskId,
|
|
1008
|
+
projection_path: outPath,
|
|
1009
|
+
task: taskFromProjection(projection, taskId),
|
|
1010
|
+
});
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
console.log(`${failed ? 'failed' : 'done'} ${taskId}`);
|
|
145
1014
|
} else {
|
|
146
|
-
console.error(`done failed: ${
|
|
1015
|
+
console.error(`done failed: ${taskId} not in open|claimed`);
|
|
147
1016
|
process.exit(1);
|
|
148
1017
|
}
|
|
149
1018
|
}
|
|
150
1019
|
|
|
151
|
-
function
|
|
1020
|
+
function cmdFinish(args) {
|
|
152
1021
|
const pos = positional(args);
|
|
153
|
-
const
|
|
1022
|
+
const id = pos[0];
|
|
1023
|
+
if (!id) {
|
|
1024
|
+
console.error('atris task finish: id required');
|
|
1025
|
+
process.exit(2);
|
|
1026
|
+
}
|
|
1027
|
+
const taskDb = getTaskDb();
|
|
1028
|
+
const db = taskDb.open();
|
|
1029
|
+
const taskId = requireTaskId(taskDb, db, id, 'atris task finish');
|
|
1030
|
+
const currentTask = taskDb.getTask(db, taskId);
|
|
1031
|
+
const done = taskDb.doneTask(db, { id: taskId, status: hasFlag(args, '--failed') ? 'failed' : 'done' });
|
|
1032
|
+
if (!done.updated) {
|
|
1033
|
+
console.error(`finish failed: ${taskId} not in open|claimed`);
|
|
1034
|
+
process.exit(1);
|
|
1035
|
+
}
|
|
1036
|
+
const hasReview = hasFlag(args, '--review') || flag(args, '--lesson') || flag(args, '--next') || flag(args, '--proof') || flag(args, '--reward');
|
|
1037
|
+
if (hasReview) {
|
|
1038
|
+
const result = taskDb.reviewTask(db, {
|
|
1039
|
+
id: taskId,
|
|
1040
|
+
actor: String(flag(args, '--as') || DEFAULT_OWNER),
|
|
1041
|
+
reward: flag(args, '--reward') || 1,
|
|
1042
|
+
lesson: typeof flag(args, '--lesson') === 'string' ? flag(args, '--lesson') : '',
|
|
1043
|
+
nextTask: typeof flag(args, '--next') === 'string' ? flag(args, '--next') : '',
|
|
1044
|
+
proof: typeof flag(args, '--proof') === 'string' ? flag(args, '--proof') : '',
|
|
1045
|
+
});
|
|
1046
|
+
const nextCreated = createNextTaskIfRequested(taskDb, db, args, currentTask, result.episode.next_task_suggestion);
|
|
1047
|
+
const { projection, outPath } = writeDefaultProjection(taskDb, db);
|
|
1048
|
+
if (wantsJson(args)) {
|
|
1049
|
+
printJson({
|
|
1050
|
+
ok: true,
|
|
1051
|
+
action: 'finished',
|
|
1052
|
+
task_id: taskId,
|
|
1053
|
+
reviewed: true,
|
|
1054
|
+
reward: result.episode.reward.value,
|
|
1055
|
+
episode: result.episode,
|
|
1056
|
+
next_task_id: nextCreated ? nextCreated.id : null,
|
|
1057
|
+
projection_path: outPath,
|
|
1058
|
+
projection,
|
|
1059
|
+
task: taskFromProjection(projection, taskId),
|
|
1060
|
+
});
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
console.log(`finished ${taskId} reward=${result.episode.reward.value}`);
|
|
1064
|
+
if (result.episode.next_task_suggestion) console.log(`next: ${result.episode.next_task_suggestion}`);
|
|
1065
|
+
if (nextCreated) console.log(`created next ${nextCreated.id}`);
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
const { projection, outPath } = writeDefaultProjection(taskDb, db);
|
|
1069
|
+
if (wantsJson(args)) {
|
|
1070
|
+
printJson({
|
|
1071
|
+
ok: true,
|
|
1072
|
+
action: 'finished',
|
|
1073
|
+
task_id: taskId,
|
|
1074
|
+
reviewed: false,
|
|
1075
|
+
projection_path: outPath,
|
|
1076
|
+
task: taskFromProjection(projection, taskId),
|
|
1077
|
+
});
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
console.log(`finished ${taskId}`);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
function cmdReview(args) {
|
|
1084
|
+
const pos = positional(args);
|
|
1085
|
+
const id = pos[0];
|
|
1086
|
+
if (!id) {
|
|
1087
|
+
console.error('atris task review: id required');
|
|
1088
|
+
process.exit(2);
|
|
1089
|
+
}
|
|
1090
|
+
const reward = flag(args, '--reward');
|
|
1091
|
+
const lesson = flag(args, '--lesson') || '';
|
|
1092
|
+
const nextTask = flag(args, '--next') || '';
|
|
1093
|
+
const proof = flag(args, '--proof') || '';
|
|
1094
|
+
const actor = flag(args, '--as') || DEFAULT_OWNER;
|
|
1095
|
+
const taskDb = getTaskDb();
|
|
1096
|
+
const db = taskDb.open();
|
|
1097
|
+
const taskId = requireTaskId(taskDb, db, id, 'atris task review');
|
|
1098
|
+
const currentTask = taskDb.getTask(db, taskId);
|
|
1099
|
+
const result = taskDb.reviewTask(db, {
|
|
1100
|
+
id: taskId,
|
|
1101
|
+
actor: String(actor),
|
|
1102
|
+
reward: reward === true || reward === null ? 0 : reward,
|
|
1103
|
+
lesson: typeof lesson === 'string' ? lesson : '',
|
|
1104
|
+
nextTask: typeof nextTask === 'string' ? nextTask : '',
|
|
1105
|
+
proof: typeof proof === 'string' ? proof : '',
|
|
1106
|
+
});
|
|
1107
|
+
if (!result.reviewed) {
|
|
1108
|
+
console.error(`review failed: ${result.reason}`);
|
|
1109
|
+
process.exit(1);
|
|
1110
|
+
}
|
|
1111
|
+
const nextCreated = createNextTaskIfRequested(taskDb, db, args, currentTask, result.episode.next_task_suggestion);
|
|
1112
|
+
const { projection, outPath } = writeDefaultProjection(taskDb, db);
|
|
1113
|
+
if (wantsJson(args)) {
|
|
1114
|
+
printJson({
|
|
1115
|
+
ok: true,
|
|
1116
|
+
action: 'reviewed',
|
|
1117
|
+
task_id: taskId,
|
|
1118
|
+
version: result.event.version,
|
|
1119
|
+
reward: result.episode.reward.value,
|
|
1120
|
+
episode: result.episode,
|
|
1121
|
+
next_task_id: nextCreated ? nextCreated.id : null,
|
|
1122
|
+
projection_path: outPath,
|
|
1123
|
+
projection,
|
|
1124
|
+
task: taskFromProjection(projection, taskId),
|
|
1125
|
+
});
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
console.log(`reviewed ${taskId} v${result.event.version} reward=${result.episode.reward.value}`);
|
|
1129
|
+
if (result.episode.next_task_suggestion) console.log(`next: ${result.episode.next_task_suggestion}`);
|
|
1130
|
+
if (nextCreated) console.log(`created next ${nextCreated.id}`);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function importTodoFile(taskDb, db, target) {
|
|
154
1134
|
const filePath = path.resolve(target);
|
|
155
1135
|
if (!fs.existsSync(filePath)) {
|
|
156
|
-
|
|
157
|
-
process.exit(2);
|
|
1136
|
+
return { ok: false, reason: 'not_found', filePath };
|
|
158
1137
|
}
|
|
159
1138
|
const { parseTodoFile } = require('../lib/todo-fallback');
|
|
160
1139
|
const parsed = parseTodoFile(filePath);
|
|
161
|
-
const taskDb = getTaskDb();
|
|
162
|
-
const db = taskDb.open();
|
|
163
1140
|
const ws = taskDb.workspaceRoot();
|
|
164
1141
|
const all = [
|
|
165
1142
|
...parsed.backlog.map(t => ({ ...t, importStatus: 'open' })),
|
|
@@ -181,28 +1158,691 @@ function cmdImport(args) {
|
|
|
181
1158
|
});
|
|
182
1159
|
if (result.inserted) inserted++; else skipped++;
|
|
183
1160
|
}
|
|
1161
|
+
return { ok: true, inserted, skipped, filePath };
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
function cmdImport(args) {
|
|
1165
|
+
const pos = positional(args);
|
|
1166
|
+
const target = pos[0] || 'atris/TODO.md';
|
|
1167
|
+
const taskDb = getTaskDb();
|
|
1168
|
+
const db = taskDb.open();
|
|
1169
|
+
const result = importTodoFile(taskDb, db, target);
|
|
1170
|
+
if (!result.ok) {
|
|
1171
|
+
console.error(`atris task import: file not found: ${result.filePath}`);
|
|
1172
|
+
process.exit(2);
|
|
1173
|
+
}
|
|
1174
|
+
const { inserted, skipped, filePath } = result;
|
|
1175
|
+
const { outPath } = writeDefaultProjection(taskDb, db);
|
|
1176
|
+
if (wantsJson(args)) {
|
|
1177
|
+
printJson({
|
|
1178
|
+
ok: true,
|
|
1179
|
+
action: 'imported',
|
|
1180
|
+
inserted,
|
|
1181
|
+
skipped,
|
|
1182
|
+
source: filePath,
|
|
1183
|
+
projection_path: outPath,
|
|
1184
|
+
});
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
184
1187
|
console.log(`imported ${inserted} new, skipped ${skipped} (already imported), source=${filePath}`);
|
|
185
1188
|
}
|
|
186
1189
|
|
|
187
|
-
function cmdWhere() {
|
|
1190
|
+
function cmdWhere(args) {
|
|
188
1191
|
const taskDb = getTaskDb();
|
|
1192
|
+
if (wantsJson(args)) {
|
|
1193
|
+
printJson({
|
|
1194
|
+
ok: true,
|
|
1195
|
+
db: taskDb.getDbPath(),
|
|
1196
|
+
workspace: taskDb.workspaceRoot(),
|
|
1197
|
+
owner: DEFAULT_OWNER,
|
|
1198
|
+
});
|
|
1199
|
+
return;
|
|
1200
|
+
}
|
|
189
1201
|
console.log(`db: ${taskDb.getDbPath()}`);
|
|
190
1202
|
console.log(`workspace: ${taskDb.workspaceRoot()}`);
|
|
191
1203
|
console.log(`owner: ${DEFAULT_OWNER}`);
|
|
192
1204
|
}
|
|
193
1205
|
|
|
1206
|
+
function cmdEvents(args) {
|
|
1207
|
+
const pos = positional(args);
|
|
1208
|
+
let taskId = pos[0] || null;
|
|
1209
|
+
const all = hasFlag(args, '--all');
|
|
1210
|
+
const taskDb = getTaskDb();
|
|
1211
|
+
const db = taskDb.open();
|
|
1212
|
+
if (taskId) taskId = requireTaskId(taskDb, db, taskId, 'atris task events');
|
|
1213
|
+
const events = taskDb.listTaskEvents(db, {
|
|
1214
|
+
taskId,
|
|
1215
|
+
workspaceRoot: all || taskId ? null : taskDb.workspaceRoot(),
|
|
1216
|
+
limit: 500,
|
|
1217
|
+
});
|
|
1218
|
+
if (wantsJson(args)) {
|
|
1219
|
+
printJson({ ok: true, action: 'events', events });
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
if (events.length === 0) {
|
|
1223
|
+
console.log('(no task events)');
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
for (const e of events) {
|
|
1227
|
+
const actor = e.actor ? ` actor=${e.actor}` : '';
|
|
1228
|
+
console.log(`${e.version}\t${e.event_type}\t${e.task_id}${actor}\t${JSON.stringify(e.payload || {})}`);
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
function cmdExport(args) {
|
|
1233
|
+
const out = flag(args, '--out') || path.join('.atris', 'state', 'tasks.projection.json');
|
|
1234
|
+
const all = hasFlag(args, '--all');
|
|
1235
|
+
const taskDb = getTaskDb();
|
|
1236
|
+
const db = taskDb.open();
|
|
1237
|
+
const outPath = path.resolve(String(out));
|
|
1238
|
+
const projection = enrichTaskProjection(taskDb.taskProjection(db, {
|
|
1239
|
+
workspaceRoot: all ? null : taskDb.workspaceRoot(),
|
|
1240
|
+
limit: 500,
|
|
1241
|
+
}));
|
|
1242
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
1243
|
+
fs.writeFileSync(outPath, JSON.stringify(projection, null, 2) + '\n', 'utf8');
|
|
1244
|
+
if (wantsJson(args)) {
|
|
1245
|
+
printJson({
|
|
1246
|
+
ok: true,
|
|
1247
|
+
action: 'exported',
|
|
1248
|
+
count: projection.tasks.length,
|
|
1249
|
+
projection_path: outPath,
|
|
1250
|
+
projection,
|
|
1251
|
+
});
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
console.log(`exported ${projection.tasks.length} task${projection.tasks.length === 1 ? '' : 's'} -> ${outPath}`);
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
function cmdSetup(args) {
|
|
1258
|
+
const taskDb = getTaskDb();
|
|
1259
|
+
const db = taskDb.open();
|
|
1260
|
+
const ws = taskDb.workspaceRoot();
|
|
1261
|
+
let importResult = null;
|
|
1262
|
+
if (hasFlag(args, '--import-todo')) {
|
|
1263
|
+
importResult = importTodoFile(taskDb, db, flag(args, '--todo') || 'atris/TODO.md');
|
|
1264
|
+
if (!importResult.ok && flag(args, '--todo')) {
|
|
1265
|
+
console.error(`atris task setup: TODO file not found: ${importResult.filePath}`);
|
|
1266
|
+
process.exit(2);
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
const { projection, outPath } = writeDefaultProjection(taskDb, db);
|
|
1270
|
+
if (wantsJson(args)) {
|
|
1271
|
+
printJson({
|
|
1272
|
+
ok: true,
|
|
1273
|
+
action: 'setup',
|
|
1274
|
+
count: projection.tasks.length,
|
|
1275
|
+
projection_path: outPath,
|
|
1276
|
+
import: importResult && importResult.ok ? {
|
|
1277
|
+
inserted: importResult.inserted,
|
|
1278
|
+
skipped: importResult.skipped,
|
|
1279
|
+
source: importResult.filePath,
|
|
1280
|
+
} : null,
|
|
1281
|
+
projection,
|
|
1282
|
+
});
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
console.log(`tasks ready: ${projection.tasks.length} task${projection.tasks.length === 1 ? '' : 's'}`);
|
|
1286
|
+
console.log(`projection: ${outPath}`);
|
|
1287
|
+
if (importResult && importResult.ok) {
|
|
1288
|
+
console.log(`imported ${importResult.inserted} new, skipped ${importResult.skipped}`);
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
function cmdRender(args) {
|
|
1293
|
+
const out = flag(args, '--out') || path.join('atris', 'TODO.md');
|
|
1294
|
+
const all = hasFlag(args, '--all');
|
|
1295
|
+
const taskDb = getTaskDb();
|
|
1296
|
+
const db = taskDb.open();
|
|
1297
|
+
const rows = taskDb.listTasks(db, {
|
|
1298
|
+
workspaceRoot: all ? null : taskDb.workspaceRoot(),
|
|
1299
|
+
limit: 500,
|
|
1300
|
+
});
|
|
1301
|
+
const markdown = taskDb.renderTodoMarkdown(rows);
|
|
1302
|
+
const outPath = path.resolve(String(out));
|
|
1303
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
1304
|
+
fs.writeFileSync(outPath, markdown, 'utf8');
|
|
1305
|
+
if (wantsJson(args)) {
|
|
1306
|
+
printJson({
|
|
1307
|
+
ok: true,
|
|
1308
|
+
action: 'rendered',
|
|
1309
|
+
count: rows.length,
|
|
1310
|
+
path: outPath,
|
|
1311
|
+
});
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
console.log(`rendered ${rows.length} task${rows.length === 1 ? '' : 's'} -> ${outPath}`);
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
function cmdSync(args) {
|
|
1318
|
+
const dryRun = hasFlag(args, '--dry-run');
|
|
1319
|
+
const businessIdFlag = flag(args, '--business-id');
|
|
1320
|
+
if (!dryRun) {
|
|
1321
|
+
console.error('atris task sync: only --dry-run is supported right now');
|
|
1322
|
+
process.exit(2);
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
const taskDb = getTaskDb();
|
|
1326
|
+
const db = taskDb.open();
|
|
1327
|
+
const binding = readLocalBusinessBinding(taskDb.workspaceRoot());
|
|
1328
|
+
const businessId = String(
|
|
1329
|
+
businessIdFlag && businessIdFlag !== true
|
|
1330
|
+
? businessIdFlag
|
|
1331
|
+
: binding && (binding.business_id || binding.id) || ''
|
|
1332
|
+
).trim();
|
|
1333
|
+
if (!businessId) {
|
|
1334
|
+
const detail = 'business id required: run inside a business workspace or pass --business-id <id>';
|
|
1335
|
+
if (wantsJson(args)) {
|
|
1336
|
+
printJson({ ok: false, action: 'sync_plan', reason: 'missing_business_id', detail });
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
1339
|
+
console.error(`atris task sync: ${detail}`);
|
|
1340
|
+
process.exit(2);
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
const { projection, outPath } = writeDefaultProjection(taskDb, db);
|
|
1344
|
+
const plan = syncPlanForProjection(projection, businessId);
|
|
1345
|
+
if (wantsJson(args)) {
|
|
1346
|
+
printJson({
|
|
1347
|
+
ok: true,
|
|
1348
|
+
action: 'sync_plan',
|
|
1349
|
+
dry_run: true,
|
|
1350
|
+
business_id: businessId,
|
|
1351
|
+
workspace_root: projection.workspace_root,
|
|
1352
|
+
projection_path: outPath,
|
|
1353
|
+
planned_writes: plan.length,
|
|
1354
|
+
plan,
|
|
1355
|
+
});
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
console.log(`task sync dry-run: ${plan.length} planned write${plan.length === 1 ? '' : 's'}`);
|
|
1360
|
+
console.log(`business: ${businessId}`);
|
|
1361
|
+
for (const item of plan) {
|
|
1362
|
+
console.log(`${item.method.padEnd(5)} ${item.endpoint} <= ${item.local_task_id.slice(0, 8)} ${item.body.title}`);
|
|
1363
|
+
for (const followup of item.after_create || []) {
|
|
1364
|
+
console.log(` then ${followup.method} ${followup.endpoint} state=${followup.body.state}`);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
function taskColumn(task) {
|
|
1370
|
+
if (task.status === 'open') return 'open';
|
|
1371
|
+
if (task.status === 'claimed') return 'doing';
|
|
1372
|
+
if (task.status === 'failed') return 'blocked';
|
|
1373
|
+
if (task.status === 'done' && task.latest_event_type !== 'reviewed') return 'review';
|
|
1374
|
+
return 'done';
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
function taskBoardHtml() {
|
|
1378
|
+
return `<!doctype html>
|
|
1379
|
+
<html lang="en">
|
|
1380
|
+
<head>
|
|
1381
|
+
<meta charset="utf-8">
|
|
1382
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1383
|
+
<title>Atris Task Factory</title>
|
|
1384
|
+
<style>
|
|
1385
|
+
:root { color-scheme: dark; --bg:#101113; --panel:#17191d; --line:#292d34; --text:#f0f2f5; --muted:#9299a6; --accent:#68d391; --warn:#f6c177; --bad:#f38ba8; }
|
|
1386
|
+
* { box-sizing: border-box; }
|
|
1387
|
+
body { margin:0; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background:var(--bg); color:var(--text); }
|
|
1388
|
+
header { height:56px; display:flex; align-items:center; justify-content:space-between; padding:0 18px; border-bottom:1px solid var(--line); background:#121418; }
|
|
1389
|
+
h1 { font-size:15px; margin:0; font-weight:650; letter-spacing:0; }
|
|
1390
|
+
.sub { color:var(--muted); font-size:12px; }
|
|
1391
|
+
main { display:grid; grid-template-columns: 320px 1fr; height:calc(100vh - 56px); }
|
|
1392
|
+
aside { border-right:1px solid var(--line); padding:14px; overflow:auto; background:#121418; }
|
|
1393
|
+
section { min-width:0; overflow:auto; padding:14px; }
|
|
1394
|
+
label { display:block; color:var(--muted); font-size:11px; margin:10px 0 5px; }
|
|
1395
|
+
input, textarea, select { width:100%; border:1px solid var(--line); background:#0d0f12; color:var(--text); border-radius:7px; padding:9px 10px; font:inherit; font-size:13px; }
|
|
1396
|
+
textarea { min-height:82px; resize:vertical; }
|
|
1397
|
+
button { border:1px solid var(--line); background:#20242a; color:var(--text); border-radius:7px; padding:8px 10px; font:inherit; font-size:12px; cursor:pointer; }
|
|
1398
|
+
button:hover { border-color:#3b414b; background:#252a32; }
|
|
1399
|
+
.primary { background:#214b35; border-color:#2f684a; }
|
|
1400
|
+
.grid { display:grid; grid-template-columns: repeat(5, minmax(180px, 1fr)); gap:12px; align-items:start; }
|
|
1401
|
+
.overview { display:grid; grid-template-columns: minmax(260px, 1.4fr) minmax(260px, 1fr); gap:12px; margin-bottom:12px; }
|
|
1402
|
+
.goalbox, .chainbox { background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:11px; min-height:88px; }
|
|
1403
|
+
.goalbox h2, .chainbox h2 { margin:0 0 8px; color:var(--muted); font-size:12px; font-weight:650; }
|
|
1404
|
+
.goalitem { font-size:13px; line-height:1.3; margin:5px 0; }
|
|
1405
|
+
.chainitem { display:grid; grid-template-columns:72px 1fr; gap:8px; font-size:12px; line-height:1.3; margin:5px 0; color:var(--muted); }
|
|
1406
|
+
.chainitem strong { color:var(--text); font-weight:600; }
|
|
1407
|
+
.streams { display:grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap:12px; margin-bottom:12px; }
|
|
1408
|
+
.stream { background:#12161b; border:1px solid var(--line); border-radius:8px; padding:10px; min-height:126px; }
|
|
1409
|
+
.stream h2 { margin:0 0 8px; font-size:12px; color:var(--text); line-height:1.25; }
|
|
1410
|
+
.streambar { display:flex; height:7px; overflow:hidden; border-radius:999px; background:#0d0f12; border:1px solid var(--line); margin:8px 0; }
|
|
1411
|
+
.streambar span { display:block; min-width:2px; }
|
|
1412
|
+
.seg-open { background:#667085; }
|
|
1413
|
+
.seg-doing { background:#68d391; }
|
|
1414
|
+
.seg-review { background:#f6c177; }
|
|
1415
|
+
.seg-blocked { background:#f38ba8; }
|
|
1416
|
+
.streamtask { display:grid; grid-template-columns:64px 1fr; gap:8px; color:var(--muted); font-size:11px; line-height:1.25; margin-top:6px; }
|
|
1417
|
+
.streamtask strong { color:var(--text); font-weight:550; }
|
|
1418
|
+
.col { background:var(--panel); border:1px solid var(--line); border-radius:8px; min-height:160px; overflow:hidden; }
|
|
1419
|
+
.col h2 { margin:0; padding:10px 11px; font-size:12px; color:var(--muted); border-bottom:1px solid var(--line); display:flex; justify-content:space-between; }
|
|
1420
|
+
.cards { padding:8px; display:flex; flex-direction:column; gap:8px; }
|
|
1421
|
+
.card { text-align:left; width:100%; background:#111419; border:1px solid #252a31; border-radius:8px; padding:9px; }
|
|
1422
|
+
.card.active { border-color:#4c7a61; box-shadow:0 0 0 1px rgba(104,211,145,.2); }
|
|
1423
|
+
.title { font-size:13px; line-height:1.25; }
|
|
1424
|
+
.meta { margin-top:6px; color:var(--muted); font-size:11px; display:flex; gap:6px; flex-wrap:wrap; }
|
|
1425
|
+
.pill { border:1px solid var(--line); border-radius:999px; padding:1px 6px; }
|
|
1426
|
+
.why { margin-top:7px; color:var(--muted); font-size:11px; line-height:1.25; }
|
|
1427
|
+
.fact { margin:10px 0; background:#0d0f12; border:1px solid var(--line); border-radius:7px; padding:8px; font-size:12px; line-height:1.35; }
|
|
1428
|
+
.fact b { color:var(--muted); font-size:11px; display:block; margin-bottom:3px; }
|
|
1429
|
+
.room { margin-top:14px; border-top:1px solid var(--line); padding-top:12px; }
|
|
1430
|
+
.room h3 { margin:0 0 4px; font-size:14px; }
|
|
1431
|
+
.thread { margin:10px 0; display:flex; flex-direction:column; gap:7px; }
|
|
1432
|
+
.msg { background:#0d0f12; border:1px solid var(--line); border-radius:7px; padding:8px; font-size:12px; }
|
|
1433
|
+
.msg .who { color:var(--muted); font-size:11px; margin-bottom:3px; }
|
|
1434
|
+
.actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:10px; }
|
|
1435
|
+
.full { grid-column:1 / -1; }
|
|
1436
|
+
.empty { color:var(--muted); font-size:12px; padding:10px; }
|
|
1437
|
+
@media (max-width: 980px) { main { grid-template-columns:1fr; height:auto; } aside { border-right:0; border-bottom:1px solid var(--line); } .grid, .overview { grid-template-columns:1fr; } }
|
|
1438
|
+
</style>
|
|
1439
|
+
</head>
|
|
1440
|
+
<body>
|
|
1441
|
+
<header>
|
|
1442
|
+
<div>
|
|
1443
|
+
<h1>Atris Task Factory</h1>
|
|
1444
|
+
<div class="sub">local durable tasks / Swarlo-ready event stream</div>
|
|
1445
|
+
</div>
|
|
1446
|
+
<button id="refresh">Refresh</button>
|
|
1447
|
+
</header>
|
|
1448
|
+
<main>
|
|
1449
|
+
<aside>
|
|
1450
|
+
<form id="create">
|
|
1451
|
+
<label>New task</label>
|
|
1452
|
+
<textarea id="title" placeholder="Need something done..."></textarea>
|
|
1453
|
+
<label>Lane</label>
|
|
1454
|
+
<input id="tag" value="tasks">
|
|
1455
|
+
<button class="primary full" type="submit" style="margin-top:10px;width:100%">Create task</button>
|
|
1456
|
+
</form>
|
|
1457
|
+
<div class="room" id="room">
|
|
1458
|
+
<div class="empty">Select a task to open its room.</div>
|
|
1459
|
+
</div>
|
|
1460
|
+
</aside>
|
|
1461
|
+
<section>
|
|
1462
|
+
<div class="overview" id="overview"></div>
|
|
1463
|
+
<div class="streams" id="streams"></div>
|
|
1464
|
+
<div class="grid" id="board"></div>
|
|
1465
|
+
</section>
|
|
1466
|
+
</main>
|
|
1467
|
+
<script>
|
|
1468
|
+
const columns = [
|
|
1469
|
+
['open', 'Open'],
|
|
1470
|
+
['doing', 'Doing'],
|
|
1471
|
+
['review', 'Review'],
|
|
1472
|
+
['blocked', 'Blocked'],
|
|
1473
|
+
['done', 'Done']
|
|
1474
|
+
];
|
|
1475
|
+
let state = { tasks: [] };
|
|
1476
|
+
let selected = null;
|
|
1477
|
+
const $ = (id) => document.getElementById(id);
|
|
1478
|
+
|
|
1479
|
+
async function api(path, options = {}) {
|
|
1480
|
+
const res = await fetch(path, {
|
|
1481
|
+
...options,
|
|
1482
|
+
headers: { 'content-type': 'application/json', ...(options.headers || {}) }
|
|
1483
|
+
});
|
|
1484
|
+
const data = await res.json();
|
|
1485
|
+
if (!res.ok || data.ok === false) throw new Error(data.detail || data.reason || 'request failed');
|
|
1486
|
+
return data;
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
function taskColumn(task) {
|
|
1490
|
+
if (task.status === 'open') return 'open';
|
|
1491
|
+
if (task.status === 'claimed') return 'doing';
|
|
1492
|
+
if (task.status === 'failed') return 'blocked';
|
|
1493
|
+
if (task.status === 'done' && task.latest_event_type !== 'reviewed') return 'review';
|
|
1494
|
+
return 'done';
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
async function load() {
|
|
1498
|
+
const data = await api('/api/tasks');
|
|
1499
|
+
state = data.projection;
|
|
1500
|
+
render();
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
function render() {
|
|
1504
|
+
renderOverview();
|
|
1505
|
+
renderStreams();
|
|
1506
|
+
const board = $('board');
|
|
1507
|
+
board.innerHTML = '';
|
|
1508
|
+
for (const [key, label] of columns) {
|
|
1509
|
+
const tasks = state.tasks.filter((task) => taskColumn(task) === key);
|
|
1510
|
+
const col = document.createElement('div');
|
|
1511
|
+
col.className = 'col';
|
|
1512
|
+
col.innerHTML = '<h2><span>' + label + '</span><span>' + tasks.length + '</span></h2><div class="cards"></div>';
|
|
1513
|
+
const cards = col.querySelector('.cards');
|
|
1514
|
+
if (!tasks.length) cards.innerHTML = '<div class="empty">No tasks</div>';
|
|
1515
|
+
for (const task of tasks) cards.appendChild(card(task));
|
|
1516
|
+
board.appendChild(col);
|
|
1517
|
+
}
|
|
1518
|
+
renderRoom();
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
function taskById(id) {
|
|
1522
|
+
return state.tasks.find((task) => task.id === id) || null;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
function renderOverview() {
|
|
1526
|
+
const active = state.tasks.filter((task) => task.status !== 'done');
|
|
1527
|
+
const reviewed = state.tasks.filter((task) => task.latest_event_type === 'reviewed');
|
|
1528
|
+
const goals = state.goals && state.goals.items || [];
|
|
1529
|
+
const goalHtml = goals.length
|
|
1530
|
+
? goals.slice(0, 4).map((goal) => '<div class="goalitem"></div>').join('')
|
|
1531
|
+
: '<div class="empty">No atris/goals.md found. Add goals to give tasks a north star.</div>';
|
|
1532
|
+
const latest = reviewed.slice(0, 3);
|
|
1533
|
+
const chainHtml = latest.length
|
|
1534
|
+
? latest.map((task) => '<div class="chainitem"><span>' + task.id.slice(0, 8) + '</span><strong></strong></div>').join('')
|
|
1535
|
+
: '<div class="empty">Complete a task with proof to start the chain.</div>';
|
|
1536
|
+
$('overview').innerHTML = [
|
|
1537
|
+
'<div class="goalbox"><h2>Goals</h2>' + goalHtml + '</div>',
|
|
1538
|
+
'<div class="chainbox"><h2>Compounding Chain</h2><div class="chainitem"><span>active</span><strong>' + active.length + ' open loops</strong></div>' + chainHtml + '</div>'
|
|
1539
|
+
].join('');
|
|
1540
|
+
$('overview').querySelectorAll('.goalitem').forEach((el, i) => { el.textContent = goals[i]; });
|
|
1541
|
+
$('overview').querySelectorAll('.chainbox .chainitem strong').forEach((el, i) => {
|
|
1542
|
+
if (i === 0) return;
|
|
1543
|
+
const task = latest[i - 1];
|
|
1544
|
+
el.textContent = (task.review && task.review.next_task) ? task.title + ' -> ' + task.review.next_task : task.title;
|
|
1545
|
+
});
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
function renderStreams() {
|
|
1549
|
+
const streams = (state.streams || []).filter((stream) => stream.active_count || stream.done_count).slice(0, 6);
|
|
1550
|
+
const root = $('streams');
|
|
1551
|
+
if (!streams.length) {
|
|
1552
|
+
root.innerHTML = '';
|
|
1553
|
+
return;
|
|
1554
|
+
}
|
|
1555
|
+
root.innerHTML = streams.map((stream) => {
|
|
1556
|
+
const total = Math.max(1, stream.open_count + stream.doing_count + stream.review_count + stream.blocked_count);
|
|
1557
|
+
const widths = {
|
|
1558
|
+
open: Math.max(0, Math.round(stream.open_count / total * 100)),
|
|
1559
|
+
doing: Math.max(0, Math.round(stream.doing_count / total * 100)),
|
|
1560
|
+
review: Math.max(0, Math.round(stream.review_count / total * 100)),
|
|
1561
|
+
blocked: Math.max(0, Math.round(stream.blocked_count / total * 100))
|
|
1562
|
+
};
|
|
1563
|
+
const tasks = stream.tasks.filter((task) => task.status !== 'done').slice(0, 3);
|
|
1564
|
+
const taskHtml = tasks.length
|
|
1565
|
+
? tasks.map((task) => '<div class="streamtask"><span>' + task.id.slice(0, 8) + '</span><strong></strong></div>').join('')
|
|
1566
|
+
: '<div class="empty">No active tasks in this stream.</div>';
|
|
1567
|
+
return [
|
|
1568
|
+
'<div class="stream">',
|
|
1569
|
+
'<h2></h2>',
|
|
1570
|
+
'<div class="meta"><span class="pill">' + stream.active_count + ' active</span><span class="pill">' + stream.done_count + ' done</span></div>',
|
|
1571
|
+
'<div class="streambar"><span class="seg-open" style="width:' + widths.open + '%"></span><span class="seg-doing" style="width:' + widths.doing + '%"></span><span class="seg-review" style="width:' + widths.review + '%"></span><span class="seg-blocked" style="width:' + widths.blocked + '%"></span></div>',
|
|
1572
|
+
taskHtml,
|
|
1573
|
+
'</div>'
|
|
1574
|
+
].join('');
|
|
1575
|
+
}).join('');
|
|
1576
|
+
root.querySelectorAll('.stream h2').forEach((el, i) => { el.textContent = streams[i].objective; });
|
|
1577
|
+
root.querySelectorAll('.stream').forEach((streamEl, i) => {
|
|
1578
|
+
const tasks = streams[i].tasks.filter((task) => task.status !== 'done').slice(0, 3);
|
|
1579
|
+
streamEl.querySelectorAll('.streamtask strong').forEach((el, idx) => { el.textContent = tasks[idx].title; });
|
|
1580
|
+
});
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
function card(task) {
|
|
1584
|
+
const btn = document.createElement('button');
|
|
1585
|
+
btn.className = 'card' + (selected === task.id ? ' active' : '');
|
|
1586
|
+
btn.onclick = () => { selected = task.id; render(); };
|
|
1587
|
+
const owner = task.claimed_by ? '@' + task.claimed_by : 'unowned';
|
|
1588
|
+
btn.innerHTML = '<div class="title"></div><div class="meta"><span class="pill"></span><span class="pill"></span><span class="pill"></span></div><div class="why"></div>';
|
|
1589
|
+
btn.querySelector('.title').textContent = task.title;
|
|
1590
|
+
const pills = btn.querySelectorAll('.pill');
|
|
1591
|
+
pills[0].textContent = task.id.slice(0, 8);
|
|
1592
|
+
pills[1].textContent = owner;
|
|
1593
|
+
pills[2].textContent = 'v' + task.current_version;
|
|
1594
|
+
const why = task.objective || (task.lineage && task.lineage.parent_title) || (task.review && task.review.proof) || '';
|
|
1595
|
+
btn.querySelector('.why').textContent = why;
|
|
1596
|
+
return btn;
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
function renderRoom() {
|
|
1600
|
+
const task = state.tasks.find((t) => t.id === selected);
|
|
1601
|
+
const room = $('room');
|
|
1602
|
+
if (!task) {
|
|
1603
|
+
room.innerHTML = '<div class="empty">Select a task to open its room.</div>';
|
|
1604
|
+
return;
|
|
1605
|
+
}
|
|
1606
|
+
const messages = task.messages.map((m) => '<div class="msg"><div class="who">' + (m.actor || 'unknown') + ' / v' + m.version + '</div><div></div></div>').join('');
|
|
1607
|
+
const parent = task.lineage && task.lineage.parent_title ? task.lineage.parent_title : 'none';
|
|
1608
|
+
const children = task.lineage && task.lineage.child_titles && task.lineage.child_titles.length ? task.lineage.child_titles.join(' | ') : (task.review && task.review.next_task || 'none yet');
|
|
1609
|
+
room.innerHTML = [
|
|
1610
|
+
'<h3></h3>',
|
|
1611
|
+
'<div class="meta"><span class="pill">' + task.status + '</span><span class="pill">' + (task.claimed_by || 'unowned') + '</span><span class="pill">v' + task.current_version + '</span></div>',
|
|
1612
|
+
'<div class="fact"><b>Goal</b><div id="taskGoal"></div></div>',
|
|
1613
|
+
'<div class="fact"><b>Lineage</b><div id="taskLineage"></div></div>',
|
|
1614
|
+
'<div class="fact"><b>Proof / lesson</b><div id="taskProof"></div></div>',
|
|
1615
|
+
'<div class="thread">' + (messages || '<div class="empty">No thread yet.</div>') + '</div>',
|
|
1616
|
+
'<label>Add context</label><textarea id="note" placeholder="Decision, blocker, context, update..."></textarea>',
|
|
1617
|
+
'<label>Proof</label><input id="proof" placeholder="npm test, PR link, screenshot, blocked reason...">',
|
|
1618
|
+
'<label>Lesson</label><textarea id="lesson" placeholder="What did this task teach us?"></textarea>',
|
|
1619
|
+
'<label>Next task</label><input id="nextTask" placeholder="Optional next sharper task">',
|
|
1620
|
+
'<div class="actions"><button id="claim">Claim</button><button id="saveNote">Say</button><button id="finish" class="primary full">Finish + review</button></div>'
|
|
1621
|
+
].join('');
|
|
1622
|
+
room.querySelector('h3').textContent = task.title;
|
|
1623
|
+
$('taskGoal').textContent = task.objective || 'No matching goal yet.';
|
|
1624
|
+
$('taskLineage').textContent = 'parent: ' + parent + ' / next: ' + children;
|
|
1625
|
+
$('taskProof').textContent = task.review && (task.review.proof || task.review.lesson)
|
|
1626
|
+
? ((task.review.proof || 'no proof') + ' / ' + (task.review.lesson || 'no lesson'))
|
|
1627
|
+
: 'No proof yet.';
|
|
1628
|
+
room.querySelectorAll('.msg div:last-child').forEach((el, i) => { el.textContent = task.messages[i].content; });
|
|
1629
|
+
$('claim').onclick = () => mutate('/api/tasks/' + task.id + '/claim', { owner: 'operator' });
|
|
1630
|
+
$('saveNote').onclick = () => mutate('/api/tasks/' + task.id + '/message', { actor: 'operator', content: $('note').value });
|
|
1631
|
+
$('finish').onclick = () => mutate('/api/tasks/' + task.id + '/finish', {
|
|
1632
|
+
actor: 'operator',
|
|
1633
|
+
proof: $('proof').value,
|
|
1634
|
+
lesson: $('lesson').value,
|
|
1635
|
+
next: $('nextTask').value,
|
|
1636
|
+
createNext: Boolean($('nextTask').value.trim())
|
|
1637
|
+
});
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
async function mutate(path, body) {
|
|
1641
|
+
await api(path, { method: 'POST', body: JSON.stringify(body) });
|
|
1642
|
+
await load();
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
$('create').onsubmit = async (e) => {
|
|
1646
|
+
e.preventDefault();
|
|
1647
|
+
const title = $('title').value.trim();
|
|
1648
|
+
if (!title) return;
|
|
1649
|
+
const data = await api('/api/tasks', { method: 'POST', body: JSON.stringify({ title, tag: $('tag').value || 'tasks' }) });
|
|
1650
|
+
selected = data.task_id;
|
|
1651
|
+
$('title').value = '';
|
|
1652
|
+
await load();
|
|
1653
|
+
};
|
|
1654
|
+
$('refresh').onclick = load;
|
|
1655
|
+
load();
|
|
1656
|
+
setInterval(load, 2500);
|
|
1657
|
+
</script>
|
|
1658
|
+
</body>
|
|
1659
|
+
</html>`;
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
function readJsonBody(req) {
|
|
1663
|
+
return new Promise((resolve, reject) => {
|
|
1664
|
+
let body = '';
|
|
1665
|
+
req.on('data', chunk => {
|
|
1666
|
+
body += chunk;
|
|
1667
|
+
if (body.length > 1_000_000) {
|
|
1668
|
+
reject(new Error('body too large'));
|
|
1669
|
+
req.destroy();
|
|
1670
|
+
}
|
|
1671
|
+
});
|
|
1672
|
+
req.on('end', () => {
|
|
1673
|
+
if (!body.trim()) return resolve({});
|
|
1674
|
+
try { resolve(JSON.parse(body)); } catch (e) { reject(e); }
|
|
1675
|
+
});
|
|
1676
|
+
req.on('error', reject);
|
|
1677
|
+
});
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
function sendJson(res, status, value) {
|
|
1681
|
+
res.writeHead(status, {
|
|
1682
|
+
'content-type': 'application/json; charset=utf-8',
|
|
1683
|
+
'access-control-allow-origin': 'http://localhost',
|
|
1684
|
+
});
|
|
1685
|
+
res.end(JSON.stringify(value, null, 2));
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
function sendHtml(res, value) {
|
|
1689
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
1690
|
+
res.end(value);
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
async function handleTaskApi(req, res, taskDb, db) {
|
|
1694
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
1695
|
+
if (req.method === 'OPTIONS') return sendJson(res, 200, { ok: true });
|
|
1696
|
+
if (req.method === 'GET' && url.pathname === '/') return sendHtml(res, taskBoardHtml());
|
|
1697
|
+
if (req.method === 'GET' && url.pathname === '/api/tasks') {
|
|
1698
|
+
const { projection, outPath } = writeDefaultProjection(taskDb, db);
|
|
1699
|
+
return sendJson(res, 200, { ok: true, projection_path: outPath, projection });
|
|
1700
|
+
}
|
|
1701
|
+
if (req.method === 'POST' && url.pathname === '/api/tasks') {
|
|
1702
|
+
const body = await readJsonBody(req);
|
|
1703
|
+
const title = String(body.title || '').trim();
|
|
1704
|
+
if (!title) return sendJson(res, 400, { ok: false, reason: 'missing_title', detail: 'title required' });
|
|
1705
|
+
const result = taskDb.addTask(db, {
|
|
1706
|
+
title,
|
|
1707
|
+
tag: body.tag ? String(body.tag) : 'tasks',
|
|
1708
|
+
workspaceRoot: taskDb.workspaceRoot(),
|
|
1709
|
+
});
|
|
1710
|
+
const { projection, outPath } = writeDefaultProjection(taskDb, db);
|
|
1711
|
+
return sendJson(res, 200, { ok: true, action: 'created', task_id: result.id, projection_path: outPath, task: taskFromProjection(projection, result.id) });
|
|
1712
|
+
}
|
|
1713
|
+
const match = url.pathname.match(/^\/api\/tasks\/([^/]+)\/(claim|message|finish|review|events)$/);
|
|
1714
|
+
if (!match) return sendJson(res, 404, { ok: false, reason: 'not_found' });
|
|
1715
|
+
const resolved = resolveTaskRef(taskDb, db, match[1]);
|
|
1716
|
+
if (!resolved.ok) return sendJson(res, resolved.reason === 'ambiguous' ? 409 : 404, { ok: false, reason: resolved.reason });
|
|
1717
|
+
const taskId = resolved.id;
|
|
1718
|
+
const op = match[2];
|
|
1719
|
+
if (req.method === 'GET' && op === 'events') {
|
|
1720
|
+
const events = taskDb.listTaskEvents(db, { taskId, limit: 500 });
|
|
1721
|
+
return sendJson(res, 200, { ok: true, events });
|
|
1722
|
+
}
|
|
1723
|
+
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, reason: 'method_not_allowed' });
|
|
1724
|
+
const body = await readJsonBody(req);
|
|
1725
|
+
if (op === 'claim') {
|
|
1726
|
+
const owner = String(body.owner || body.actor || DEFAULT_OWNER);
|
|
1727
|
+
const result = taskDb.claimTask(db, { id: taskId, claimedBy: owner });
|
|
1728
|
+
if (!result.claimed) return sendJson(res, 409, { ok: false, reason: result.reason, claimed_by: result.claimed_by || null });
|
|
1729
|
+
const { projection, outPath } = writeDefaultProjection(taskDb, db);
|
|
1730
|
+
return sendJson(res, 200, { ok: true, action: 'claimed', task_id: taskId, projection_path: outPath, task: taskFromProjection(projection, taskId) });
|
|
1731
|
+
}
|
|
1732
|
+
if (op === 'message') {
|
|
1733
|
+
const result = taskDb.noteTask(db, { id: taskId, actor: String(body.actor || DEFAULT_OWNER), content: String(body.content || '') });
|
|
1734
|
+
if (!result.noted) return sendJson(res, 404, { ok: false, reason: result.reason });
|
|
1735
|
+
const { projection, outPath } = writeDefaultProjection(taskDb, db);
|
|
1736
|
+
return sendJson(res, 200, { ok: true, action: 'noted', task_id: taskId, projection_path: outPath, task: taskFromProjection(projection, taskId) });
|
|
1737
|
+
}
|
|
1738
|
+
if (op === 'finish') {
|
|
1739
|
+
const currentTask = taskDb.getTask(db, taskId);
|
|
1740
|
+
const done = taskDb.doneTask(db, { id: taskId, status: body.failed ? 'failed' : 'done' });
|
|
1741
|
+
if (!done.updated) return sendJson(res, 409, { ok: false, reason: 'not_open_or_claimed' });
|
|
1742
|
+
const shouldReview = body.proof || body.lesson || body.next || body.reward !== undefined;
|
|
1743
|
+
let episode = null;
|
|
1744
|
+
let nextCreated = null;
|
|
1745
|
+
if (shouldReview) {
|
|
1746
|
+
const reviewed = taskDb.reviewTask(db, {
|
|
1747
|
+
id: taskId,
|
|
1748
|
+
actor: String(body.actor || DEFAULT_OWNER),
|
|
1749
|
+
reward: body.reward === undefined ? 1 : body.reward,
|
|
1750
|
+
lesson: String(body.lesson || ''),
|
|
1751
|
+
nextTask: String(body.next || ''),
|
|
1752
|
+
proof: String(body.proof || ''),
|
|
1753
|
+
});
|
|
1754
|
+
episode = reviewed.episode;
|
|
1755
|
+
nextCreated = body.createNext ? createNextTaskIfRequested(taskDb, db, ['--create-next'], currentTask, episode.next_task_suggestion) : null;
|
|
1756
|
+
}
|
|
1757
|
+
const { projection, outPath } = writeDefaultProjection(taskDb, db);
|
|
1758
|
+
return sendJson(res, 200, {
|
|
1759
|
+
ok: true,
|
|
1760
|
+
action: 'finished',
|
|
1761
|
+
task_id: taskId,
|
|
1762
|
+
reviewed: Boolean(episode),
|
|
1763
|
+
episode,
|
|
1764
|
+
next_task_id: nextCreated ? nextCreated.id : null,
|
|
1765
|
+
projection_path: outPath,
|
|
1766
|
+
task: taskFromProjection(projection, taskId),
|
|
1767
|
+
});
|
|
1768
|
+
}
|
|
1769
|
+
if (op === 'review') {
|
|
1770
|
+
const currentTask = taskDb.getTask(db, taskId);
|
|
1771
|
+
const reviewed = taskDb.reviewTask(db, {
|
|
1772
|
+
id: taskId,
|
|
1773
|
+
actor: String(body.actor || DEFAULT_OWNER),
|
|
1774
|
+
reward: body.reward === undefined ? 1 : body.reward,
|
|
1775
|
+
lesson: String(body.lesson || ''),
|
|
1776
|
+
nextTask: String(body.next || ''),
|
|
1777
|
+
proof: String(body.proof || ''),
|
|
1778
|
+
});
|
|
1779
|
+
const nextCreated = body.createNext ? createNextTaskIfRequested(taskDb, db, ['--create-next'], currentTask, reviewed.episode.next_task_suggestion) : null;
|
|
1780
|
+
const { projection, outPath } = writeDefaultProjection(taskDb, db);
|
|
1781
|
+
return sendJson(res, 200, { ok: true, action: 'reviewed', task_id: taskId, episode: reviewed.episode, next_task_id: nextCreated ? nextCreated.id : null, projection_path: outPath, task: taskFromProjection(projection, taskId) });
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
function cmdServe(args) {
|
|
1786
|
+
const host = String(flag(args, '--host') || '127.0.0.1');
|
|
1787
|
+
const port = Number(flag(args, '--port') || process.env.PORT || 8787);
|
|
1788
|
+
const taskDb = getTaskDb();
|
|
1789
|
+
const db = taskDb.open();
|
|
1790
|
+
const server = http.createServer((req, res) => {
|
|
1791
|
+
handleTaskApi(req, res, taskDb, db).catch((e) => {
|
|
1792
|
+
sendJson(res, 500, { ok: false, reason: 'server_error', detail: String(e && e.message || e) });
|
|
1793
|
+
});
|
|
1794
|
+
});
|
|
1795
|
+
return new Promise((resolve, reject) => {
|
|
1796
|
+
server.on('error', reject);
|
|
1797
|
+
server.listen(port, host, () => {
|
|
1798
|
+
const addr = server.address();
|
|
1799
|
+
const actualPort = addr && addr.port || port;
|
|
1800
|
+
console.log(`Task board: http://${host}:${actualPort}`);
|
|
1801
|
+
console.log(`Workspace: ${taskDb.workspaceRoot()}`);
|
|
1802
|
+
});
|
|
1803
|
+
|
|
1804
|
+
const shutdown = () => {
|
|
1805
|
+
server.close(() => resolve());
|
|
1806
|
+
};
|
|
1807
|
+
process.once('SIGTERM', shutdown);
|
|
1808
|
+
process.once('SIGINT', shutdown);
|
|
1809
|
+
});
|
|
1810
|
+
}
|
|
1811
|
+
|
|
194
1812
|
async function run(args) {
|
|
195
|
-
const
|
|
196
|
-
const
|
|
1813
|
+
const raw = args || [];
|
|
1814
|
+
const first = raw[0];
|
|
1815
|
+
const sub = !first || first.startsWith('--') ? 'desk' : first;
|
|
1816
|
+
const rest = !first || first.startsWith('--') ? raw : raw.slice(1);
|
|
197
1817
|
switch (sub) {
|
|
1818
|
+
case 'desk': return cmdHome(rest);
|
|
1819
|
+
case 'today': return cmdDay(rest);
|
|
1820
|
+
case 'day': return cmdDay(rest);
|
|
198
1821
|
case 'add': return cmdAdd(rest);
|
|
1822
|
+
case 'new': return cmdAdd(rest);
|
|
1823
|
+
case 'delegate': return cmdDelegate(rest);
|
|
1824
|
+
case 'assign': return cmdDelegate(rest);
|
|
199
1825
|
case 'list': return cmdList(rest);
|
|
200
1826
|
case 'ls': return cmdList(rest);
|
|
201
1827
|
case 'claim': return cmdClaim(rest);
|
|
1828
|
+
case 'start': return cmdClaim(rest);
|
|
1829
|
+
case 'next': return cmdNext(rest);
|
|
1830
|
+
case 'note': return cmdNote(rest);
|
|
1831
|
+
case 'say': return cmdNote(rest);
|
|
1832
|
+
case 'show': return cmdShow(rest);
|
|
202
1833
|
case 'done': return cmdDone(rest);
|
|
1834
|
+
case 'finish': return cmdFinish(rest);
|
|
203
1835
|
case 'fail': return cmdDone([...rest, '--failed']);
|
|
1836
|
+
case 'review': return cmdReview(rest);
|
|
1837
|
+
case 'status': return cmdStatus(rest);
|
|
1838
|
+
case 'setup': return cmdSetup(rest);
|
|
1839
|
+
case 'serve': return cmdServe(rest);
|
|
204
1840
|
case 'import': return cmdImport(rest);
|
|
205
|
-
case '
|
|
1841
|
+
case 'events': return cmdEvents(rest);
|
|
1842
|
+
case 'export': return cmdExport(rest);
|
|
1843
|
+
case 'render': return cmdRender(rest);
|
|
1844
|
+
case 'sync': return cmdSync(rest);
|
|
1845
|
+
case 'where': return cmdWhere(rest);
|
|
206
1846
|
case 'help':
|
|
207
1847
|
case '--help':
|
|
208
1848
|
case '-h':
|