@anhlp/alp-core 0.0.0-beta.1
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/LICENSE +202 -0
- package/NOTICE +7 -0
- package/README.md +7 -0
- package/dist/adapter.d.ts +20 -0
- package/dist/adapter.js +30 -0
- package/dist/atomic.d.ts +2 -0
- package/dist/atomic.js +28 -0
- package/dist/backup.d.ts +27 -0
- package/dist/backup.js +582 -0
- package/dist/beads.d.ts +65 -0
- package/dist/beads.js +213 -0
- package/dist/delegation.d.ts +2 -0
- package/dist/delegation.js +32 -0
- package/dist/errors.d.ts +4 -0
- package/dist/errors.js +11 -0
- package/dist/formulas.d.ts +25 -0
- package/dist/formulas.js +201 -0
- package/dist/hook-run.d.ts +44 -0
- package/dist/hook-run.js +60 -0
- package/dist/hooks.d.ts +35 -0
- package/dist/hooks.js +30 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +63 -0
- package/dist/init.d.ts +30 -0
- package/dist/init.js +88 -0
- package/dist/library-edit.d.ts +90 -0
- package/dist/library-edit.js +315 -0
- package/dist/library.d.ts +28 -0
- package/dist/library.js +108 -0
- package/dist/log.d.ts +37 -0
- package/dist/log.js +156 -0
- package/dist/migrations.d.ts +9 -0
- package/dist/migrations.js +95 -0
- package/dist/models.d.ts +11 -0
- package/dist/models.js +44 -0
- package/dist/permissions.d.ts +14 -0
- package/dist/permissions.js +229 -0
- package/dist/profiles.d.ts +27 -0
- package/dist/profiles.js +122 -0
- package/dist/promptsafe.d.ts +1 -0
- package/dist/promptsafe.js +13 -0
- package/dist/providers.d.ts +90 -0
- package/dist/providers.js +101 -0
- package/dist/resolver.d.ts +41 -0
- package/dist/resolver.js +240 -0
- package/dist/safe-path.d.ts +2 -0
- package/dist/safe-path.js +24 -0
- package/dist/shell.d.ts +16 -0
- package/dist/shell.js +22 -0
- package/dist/state-schema.d.ts +9 -0
- package/dist/state-schema.js +54 -0
- package/dist/task-rows.d.ts +47 -0
- package/dist/task-rows.js +45 -0
- package/dist/tasks.d.ts +128 -0
- package/dist/tasks.js +835 -0
- package/dist/teams.d.ts +69 -0
- package/dist/teams.js +159 -0
- package/dist/template-path.d.ts +2 -0
- package/dist/template-path.js +10 -0
- package/dist/trust.d.ts +8 -0
- package/dist/trust.js +47 -0
- package/dist/types.d.ts +51 -0
- package/dist/upgrade.d.ts +10 -0
- package/dist/upgrade.js +200 -0
- package/dist/user-settings.d.ts +6 -0
- package/dist/user-settings.js +72 -0
- package/dist/validation.d.ts +17 -0
- package/dist/validation.js +295 -0
- package/dist/verify.d.ts +15 -0
- package/dist/verify.js +123 -0
- package/dist/workflow.d.ts +14 -0
- package/dist/workflow.js +24 -0
- package/package.json +309 -0
- package/src/adapter.d.ts +20 -0
- package/src/adapter.js +29 -0
- package/src/atomic.d.ts +2 -0
- package/src/atomic.js +28 -0
- package/src/backup.d.ts +27 -0
- package/src/backup.js +570 -0
- package/src/beads.js +236 -0
- package/src/delegation.js +28 -0
- package/src/errors.js +7 -0
- package/src/formulas.d.ts +25 -0
- package/src/formulas.js +202 -0
- package/src/hook-run.js +57 -0
- package/src/hooks.js +36 -0
- package/src/index.js +31 -0
- package/src/init.js +92 -0
- package/src/library-edit.js +346 -0
- package/src/library.js +117 -0
- package/src/log.d.ts +37 -0
- package/src/log.js +177 -0
- package/src/migrations.d.ts +9 -0
- package/src/migrations.js +111 -0
- package/src/models.js +45 -0
- package/src/permissions.d.ts +14 -0
- package/src/permissions.js +242 -0
- package/src/profiles.js +139 -0
- package/src/promptsafe.d.ts +1 -0
- package/src/promptsafe.js +19 -0
- package/src/providers.js +122 -0
- package/src/resolver.js +238 -0
- package/src/safe-path.js +20 -0
- package/src/shell.js +28 -0
- package/src/state-schema.d.ts +9 -0
- package/src/state-schema.js +60 -0
- package/src/task-rows.js +44 -0
- package/src/tasks.d.ts +128 -0
- package/src/tasks.js +881 -0
- package/src/teams.js +170 -0
- package/src/template-path.js +8 -0
- package/src/trust.js +59 -0
- package/src/types.d.ts +51 -0
- package/src/upgrade.js +195 -0
- package/src/user-settings.d.ts +6 -0
- package/src/user-settings.js +76 -0
- package/src/validation.d.ts +17 -0
- package/src/validation.js +302 -0
- package/src/verify.d.ts +15 -0
- package/src/verify.js +127 -0
- package/src/workflow.js +28 -0
- package/templates/ALP.md +52 -0
- package/templates/agents/lead/AGENT.md +90 -0
- package/templates/agents/main/AGENT.md +150 -0
- package/templates/agents/oracle/AGENT.md +44 -0
- package/templates/agents/peer/AGENT.md +55 -0
- package/templates/agents/reviewer/AGENT.md +80 -0
- package/templates/agents/supervisor/AGENT.md +72 -0
- package/templates/providers/antigravity.json +6 -0
- package/templates/providers/opencode.json +7 -0
- package/templates/role-skills.json +8 -0
- package/templates/skills/bug-loop/SKILL.md +44 -0
- package/templates/skills/bug-loop/references/test-proof.md +25 -0
- package/templates/skills/goal-griller/SKILL.md +49 -0
- package/templates/skills/prompt-leverage/SKILL.md +37 -0
- package/templates/skills/prompt-leverage/references/brief-template.md +27 -0
- package/templates/skills/sequence-execution-plan/SKILL.md +42 -0
- package/templates/skills/sequence-execution-plan/references/plan-template.md +22 -0
- package/templates/skills/smart-commits/SKILL.md +40 -0
- package/templates/skills/xia/SKILL.md +42 -0
- package/templates/teams/cafe/HOUSE_RULES.md +1 -0
- package/templates/teams/cafe/team.json +15 -0
- package/templates/teams/pho/HOUSE_RULES.md +1 -0
- package/templates/teams/pho/team.json +14 -0
package/src/tasks.js
ADDED
|
@@ -0,0 +1,881 @@
|
|
|
1
|
+
import { mkdir, readFile, readdir, rmdir, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { moveFile } from './atomic.js';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { AlpError } from './errors.js';
|
|
6
|
+
import { assertNoSymlinks } from './safe-path.js';
|
|
7
|
+
import { describeVerification } from './verify.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The project's task graph (plans/reference/ALPD.md §20), modelled on beads:
|
|
11
|
+
* one JSON file per task in .alp/tasks, committed with the project. A relation
|
|
12
|
+
* lives on the dependent task, so adding a child or a blocker never touches the
|
|
13
|
+
* other task's file. Every writer, the CLI and alpd alike, goes through this
|
|
14
|
+
* module: it holds a lock directory while it reads, checks and writes a task.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Relative to the project, written with slashes on every platform: it appears in messages and in what agents read. */
|
|
18
|
+
export const TASKS_DIR = '.alp/tasks';
|
|
19
|
+
export const TASK_TYPES = ['task', 'bug', 'feature', 'chore', 'epic'];
|
|
20
|
+
export const TASK_STATUSES = ['open', 'in_progress', 'review', 'closed'];
|
|
21
|
+
export const CLOSE_REASONS = ['done', 'wontfix', 'duplicate', 'superseded'];
|
|
22
|
+
export const GATE_KINDS = ['human', 'timer', 'gh:pr', 'gh:run'];
|
|
23
|
+
export const MAX_GATES = 10;
|
|
24
|
+
export const TITLE_CHARS = 200;
|
|
25
|
+
export const DESCRIPTION_CHARS = 8000;
|
|
26
|
+
export const NOTE_CHARS = 2000;
|
|
27
|
+
export const MAX_LABELS = 10;
|
|
28
|
+
export const MAX_PATHS = 50;
|
|
29
|
+
export const MAX_LINKS = 50;
|
|
30
|
+
const LOG_KEEP = 50;
|
|
31
|
+
const LOCK = '.lock';
|
|
32
|
+
const LOCK_STALE_MS = 10_000;
|
|
33
|
+
const LOCK_WAIT_MS = 5_000;
|
|
34
|
+
const GITIGNORE = `${LOCK}\n*.tmp\n`;
|
|
35
|
+
|
|
36
|
+
const ID = /^t-[0-9a-f]{4,12}(?:\.\d+)*$/;
|
|
37
|
+
export const isTaskId = id => typeof id === 'string' && ID.test(id);
|
|
38
|
+
|
|
39
|
+
export const tasksDir = projectRoot => path.join(path.resolve(projectRoot), TASKS_DIR);
|
|
40
|
+
const taskFile = (projectRoot, id) => path.join(tasksDir(projectRoot), `${id}.json`);
|
|
41
|
+
const now = () => new Date().toISOString();
|
|
42
|
+
const fail = (code, message) => { throw new AlpError(code, message); };
|
|
43
|
+
|
|
44
|
+
/** Validate all repository-controlled task paths before reads or lock creation. */
|
|
45
|
+
async function taskNames(projectRoot) {
|
|
46
|
+
const scope = path.join(path.resolve(projectRoot), '.alp');
|
|
47
|
+
await assertNoSymlinks(scope, 'tasks');
|
|
48
|
+
let names;
|
|
49
|
+
try { names = await readdir(tasksDir(projectRoot)); }
|
|
50
|
+
catch (error) { if (error.code === 'ENOENT') return []; throw error; }
|
|
51
|
+
for (const name of [...names.filter(name => name.endsWith('.json')), LOCK, '.gitignore']) {
|
|
52
|
+
await assertNoSymlinks(scope, path.join('tasks', name));
|
|
53
|
+
}
|
|
54
|
+
return names;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Reads every task. Invalid JSON is reported and skipped; unsafe paths are fatal. */
|
|
58
|
+
export async function loadTasks(projectRoot) {
|
|
59
|
+
const dir = tasksDir(projectRoot);
|
|
60
|
+
const names = await taskNames(projectRoot);
|
|
61
|
+
const tasks = [];
|
|
62
|
+
const errors = [];
|
|
63
|
+
for (const name of names.filter(name => name.endsWith('.json')).sort()) {
|
|
64
|
+
const file = path.join(dir, name);
|
|
65
|
+
try {
|
|
66
|
+
const task = JSON.parse(await readFile(file, 'utf8'));
|
|
67
|
+
if (!task || typeof task !== 'object' || task.id !== name.slice(0, -5) || !isTaskId(task.id) || !TASK_STATUSES.includes(task.status) || typeof task.title !== 'string') {
|
|
68
|
+
throw new Error('not an ALP task, or its id differs from the file name');
|
|
69
|
+
}
|
|
70
|
+
tasks.push(normalize(task));
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (error.code === 'ENOENT') continue;
|
|
73
|
+
errors.push({ file: path.relative(path.resolve(projectRoot), file), error: error.message });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { tasks, errors };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Fills fields an older or hand-written file lacks, so readers can rely on them. */
|
|
80
|
+
function normalize(task) {
|
|
81
|
+
return {
|
|
82
|
+
description: '', type: 'task', priority: 2, labels: [], paths: [], parent: null, blockedBy: [],
|
|
83
|
+
discoveredFrom: null, related: [], gates: [], assignee: null, handoff: null, verified: null, closed: null, log: [], rev: 0,
|
|
84
|
+
...task,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function getTask(projectRoot, id) {
|
|
89
|
+
const { tasks } = await loadTasks(projectRoot);
|
|
90
|
+
return tasks.find(task => task.id === id) ?? fail('TASK_NOT_FOUND', `No task ${id} in ${TASKS_DIR}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// --- the graph -------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
const byId = tasks => new Map(tasks.map(task => [task.id, task]));
|
|
96
|
+
|
|
97
|
+
/** Open blockers of a task: its own unclosed blockedBy, then those of its ancestors. Missing tasks block nothing. */
|
|
98
|
+
export function blockersOf(task, tasks, index = byId(tasks)) {
|
|
99
|
+
const found = [];
|
|
100
|
+
const seen = new Set();
|
|
101
|
+
for (let current = task; current && !seen.has(current.id); current = current.parent ? index.get(current.parent) : undefined) {
|
|
102
|
+
seen.add(current.id);
|
|
103
|
+
for (const id of current.blockedBy) {
|
|
104
|
+
const blocker = index.get(id);
|
|
105
|
+
if (blocker && blocker.status !== 'closed' && !found.includes(id)) found.push(id);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return found;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Whether a gate still holds its task back: unresolved, and for a timer, before its time. */
|
|
112
|
+
export const gateOpen = (gate, now = Date.now()) => !gate.resolved && !(gate.kind === 'timer' && Date.parse(gate.until) <= now);
|
|
113
|
+
|
|
114
|
+
export function describeGate(gate) {
|
|
115
|
+
const what = gate.kind === 'human' ? gate.note : gate.kind === 'timer' ? `until ${gate.until}` : `${gate.repo ? `${gate.repo}#` : '#'}${gate.ref}`;
|
|
116
|
+
return `${gate.id} ${gate.kind}${what ? `: ${what}` : ''}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Open gates of a task and of its ancestors, as "<task> <gate>" labels. */
|
|
120
|
+
export function gatesOf(task, tasks, index = byId(tasks), now = Date.now()) {
|
|
121
|
+
const found = [];
|
|
122
|
+
const seen = new Set();
|
|
123
|
+
for (let current = task; current && !seen.has(current.id); current = current.parent ? index.get(current.parent) : undefined) {
|
|
124
|
+
seen.add(current.id);
|
|
125
|
+
for (const gate of current.gates ?? []) if (gateOpen(gate, now)) found.push(current.id === task.id ? describeGate(gate) : `${current.id} ${describeGate(gate)}`);
|
|
126
|
+
}
|
|
127
|
+
return found;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const rank = (a, b) => a.priority - b.priority || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id);
|
|
131
|
+
|
|
132
|
+
/** Tasks anyone can start now: open, not an epic, and blocked by nothing; most urgent first. */
|
|
133
|
+
export function readyTasks(tasks) {
|
|
134
|
+
const index = byId(tasks);
|
|
135
|
+
return tasks.filter(task => task.status === 'open' && task.type !== 'epic' && !blockersOf(task, tasks, index).length && !gatesOf(task, tasks, index).length).sort(rank);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export const childrenOf = (id, tasks) => tasks.filter(task => task.parent === id).sort(rank);
|
|
139
|
+
|
|
140
|
+
/** A short row for lists and agent tool results. */
|
|
141
|
+
export function summarize(task, tasks, index = byId(tasks)) {
|
|
142
|
+
const blockers = task.status === 'closed' ? [] : blockersOf(task, tasks, index);
|
|
143
|
+
const gates = task.status === 'closed' ? [] : gatesOf(task, tasks, index);
|
|
144
|
+
return {
|
|
145
|
+
id: task.id, title: task.title, type: task.type, priority: task.priority, status: task.status,
|
|
146
|
+
...(task.labels.length ? { labels: task.labels } : {}),
|
|
147
|
+
...(task.parent ? { parent: task.parent } : {}),
|
|
148
|
+
...(blockers.length ? { blockedBy: blockers } : {}),
|
|
149
|
+
...(gates.length ? { gates } : {}),
|
|
150
|
+
...(task.assignee ? { assignee: task.assignee.agent } : {}),
|
|
151
|
+
...(task.verified && task.status !== 'closed' ? { verified: task.verified.skipped ? 'skipped' : task.verified.passed ? 'passed' : 'failed' } : {}),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Lists tasks; by default every task that is not closed, most urgent first. */
|
|
156
|
+
export function listTasks(tasks, { status, label, parent, all = false } = {}) {
|
|
157
|
+
return tasks
|
|
158
|
+
.filter(task => status ? task.status === status : all || task.status !== 'closed')
|
|
159
|
+
.filter(task => !label || task.labels.includes(label))
|
|
160
|
+
.filter(task => !parent || task.parent === parent)
|
|
161
|
+
.sort(rank);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The chain an edge from `from` to `to` would close, or undefined. Edges: blockedBy and parent. */
|
|
165
|
+
function cycle(tasks, from, to) {
|
|
166
|
+
const index = byId(tasks);
|
|
167
|
+
const next = id => { const task = index.get(id); return task ? [...task.blockedBy, ...(task.parent ? [task.parent] : [])] : []; };
|
|
168
|
+
const seen = new Set();
|
|
169
|
+
const walk = (id, trail) => {
|
|
170
|
+
if (id === from) return [...trail, id];
|
|
171
|
+
if (seen.has(id)) return undefined;
|
|
172
|
+
seen.add(id);
|
|
173
|
+
for (const after of next(id)) { const found = walk(after, [...trail, id]); if (found) return found; }
|
|
174
|
+
return undefined;
|
|
175
|
+
};
|
|
176
|
+
return walk(to, [from])?.join(' → ');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const ancestors = (id, tasks) => {
|
|
180
|
+
const index = byId(tasks);
|
|
181
|
+
const found = [];
|
|
182
|
+
for (let current = index.get(id)?.parent; current && !found.includes(current); current = index.get(current)?.parent) found.push(current);
|
|
183
|
+
return found;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// --- validation ----------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
function checkText(value, name, limit, { required = false } = {}) {
|
|
189
|
+
if (value === undefined && !required) return undefined;
|
|
190
|
+
if (typeof value !== 'string' || (required && !value.trim()) || value.length > limit) fail('INVALID_TASK', `${name} must be ${required ? 'nonempty ' : ''}text of at most ${limit} characters`);
|
|
191
|
+
return required ? value.trim() : value;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function list(value, name, limit, check = item => typeof item === 'string' && !!item.trim()) {
|
|
195
|
+
if (value === undefined) return undefined;
|
|
196
|
+
if (!Array.isArray(value) || value.length > limit || !value.every(check)) fail('INVALID_TASK', `${name} must list at most ${limit} entries`);
|
|
197
|
+
return [...new Set(value.map(item => item.trim()))];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function normalizePaths(value, projectRoot) {
|
|
201
|
+
const paths = list(value, 'paths', MAX_PATHS);
|
|
202
|
+
if (!paths) return undefined;
|
|
203
|
+
const root = path.resolve(projectRoot);
|
|
204
|
+
return [...new Set(paths.map(entry => {
|
|
205
|
+
const relative = path.relative(root, path.resolve(root, entry)).split(path.sep).join('/');
|
|
206
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) fail('INVALID_TASK', `${entry} is outside the project`);
|
|
207
|
+
return relative || '.';
|
|
208
|
+
}))];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function fields(input, projectRoot) {
|
|
212
|
+
const out = {};
|
|
213
|
+
if (input.title !== undefined) out.title = checkText(input.title, 'title', TITLE_CHARS, { required: true });
|
|
214
|
+
if (input.description !== undefined) out.description = checkText(input.description, 'description', DESCRIPTION_CHARS);
|
|
215
|
+
if (input.type !== undefined) {
|
|
216
|
+
if (!TASK_TYPES.includes(input.type)) fail('INVALID_TASK', `type must be one of ${TASK_TYPES.join(', ')}`);
|
|
217
|
+
out.type = input.type;
|
|
218
|
+
}
|
|
219
|
+
if (input.priority !== undefined) {
|
|
220
|
+
const priority = typeof input.priority === 'string' ? Number(input.priority.replace(/^p/i, '')) : input.priority;
|
|
221
|
+
if (!Number.isInteger(priority) || priority < 0 || priority > 4) fail('INVALID_TASK', 'priority must be 0 (urgent) to 4 (backlog)');
|
|
222
|
+
out.priority = priority;
|
|
223
|
+
}
|
|
224
|
+
if (input.labels !== undefined) out.labels = list(input.labels, 'labels', MAX_LABELS, label => typeof label === 'string' && /^[\w.:/-]{1,50}$/.test(label));
|
|
225
|
+
if (input.paths !== undefined) out.paths = normalizePaths(input.paths, projectRoot);
|
|
226
|
+
return out;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const taskIds = (value, name) => list(value, name, MAX_LINKS, isTaskId);
|
|
230
|
+
|
|
231
|
+
function requireTask(tasks, id, role) {
|
|
232
|
+
if (!isTaskId(id)) fail('INVALID_TASK', `${role} must be a task id such as t-a3f8`);
|
|
233
|
+
return byId(tasks).get(id) ?? fail('TASK_NOT_FOUND', `No task ${id}`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function addLog(task, by, event, details = {}) {
|
|
237
|
+
task.log = [...task.log, { at: now(), by, event, ...details }].slice(-LOG_KEEP);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// --- storage -----------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
const queues = new Map();
|
|
243
|
+
|
|
244
|
+
/** Runs `work` holding the project's task lock: in this process, then across processes. */
|
|
245
|
+
async function locked(projectRoot, work) {
|
|
246
|
+
const dir = tasksDir(projectRoot);
|
|
247
|
+
const previous = queues.get(dir) ?? Promise.resolve();
|
|
248
|
+
const run = previous.catch(() => {}).then(async () => {
|
|
249
|
+
await taskNames(projectRoot);
|
|
250
|
+
await prepare(dir);
|
|
251
|
+
const lock = path.join(dir, LOCK);
|
|
252
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
253
|
+
for (;;) {
|
|
254
|
+
try { await mkdir(lock); break; }
|
|
255
|
+
catch (error) {
|
|
256
|
+
if (error.code !== 'EEXIST') throw error;
|
|
257
|
+
const age = await stat(lock).then(info => Date.now() - info.mtimeMs, () => 0);
|
|
258
|
+
if (age > LOCK_STALE_MS) { await rmdir(lock).catch(() => {}); continue; }
|
|
259
|
+
if (Date.now() > deadline) fail('TASKS_LOCKED', `${path.join(TASKS_DIR, LOCK)} is held by another writer; retry, or remove it if no ALP process is running`);
|
|
260
|
+
await new Promise(resolve => setTimeout(resolve, 20));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
try { return await work(); }
|
|
264
|
+
finally { await rmdir(lock).catch(() => {}); }
|
|
265
|
+
});
|
|
266
|
+
queues.set(dir, run);
|
|
267
|
+
return run;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Runs `work` holding the project's task lock, as every task writer does; backups and restores use it (ALPD §68). */
|
|
271
|
+
export const withTaskLock = (projectRoot, work) => locked(projectRoot, work);
|
|
272
|
+
|
|
273
|
+
/** Creates the directory and its .gitignore on first use, so tasks are ready to commit. */
|
|
274
|
+
async function prepare(dir) {
|
|
275
|
+
await mkdir(dir, { recursive: true });
|
|
276
|
+
await writeFile(path.join(dir, '.gitignore'), GITIGNORE, { flag: 'wx' }).catch(error => { if (error.code !== 'EEXIST') throw error; });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function save(projectRoot, task) {
|
|
280
|
+
const file = taskFile(projectRoot, task.id);
|
|
281
|
+
await assertNoSymlinks(path.join(path.resolve(projectRoot), ".alp"), path.join("tasks", `${task.id}.json`));
|
|
282
|
+
const temporary = `${file}.${randomUUID().slice(0, 8)}.tmp`;
|
|
283
|
+
await writeFile(temporary, JSON.stringify(task, null, 2) + '\n');
|
|
284
|
+
await moveFile(temporary, file);
|
|
285
|
+
return task;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function newId(tasks, title, parent) {
|
|
289
|
+
const taken = new Set(tasks.map(task => task.id));
|
|
290
|
+
if (parent) {
|
|
291
|
+
const prefix = `${parent}.`;
|
|
292
|
+
const used = tasks.filter(task => task.id.startsWith(prefix) && !task.id.slice(prefix.length).includes('.')).map(task => Number(task.id.slice(prefix.length)));
|
|
293
|
+
return `${prefix}${Math.max(0, ...used) + 1}`;
|
|
294
|
+
}
|
|
295
|
+
const hash = createHash('sha256').update(`${title}\0${now()}\0${randomUUID()}`).digest('hex');
|
|
296
|
+
for (let length = 4; length <= 12; length++) if (!taken.has(`t-${hash.slice(0, length)}`)) return `t-${hash.slice(0, length)}`;
|
|
297
|
+
return fail('INVALID_TASK', 'Could not choose a free task id');
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Creates a task. `by` is user, or the agent that created it.
|
|
302
|
+
* @param {string} projectRoot
|
|
303
|
+
* @param {{ title: string, description?: string, type?: string, priority?: number, labels?: string[], paths?: string[], parent?: string, blockedBy?: string[], related?: string[], discoveredFrom?: string }} input
|
|
304
|
+
* @param {string} by
|
|
305
|
+
*/
|
|
306
|
+
export function createTask(projectRoot, input, by) {
|
|
307
|
+
return locked(projectRoot, async () => {
|
|
308
|
+
const { tasks } = await loadTasks(projectRoot);
|
|
309
|
+
if (input?.title === undefined) fail('INVALID_TASK', 'A task needs a title');
|
|
310
|
+
const values = fields(input, projectRoot);
|
|
311
|
+
const parent = input.parent === undefined || input.parent === null ? undefined : requireTask(tasks, input.parent, 'parent');
|
|
312
|
+
if (parent?.status === 'closed') fail('INVALID_TASK', `Parent ${parent.id} is closed; reopen it first`);
|
|
313
|
+
const blockedBy = taskIds(input.blockedBy, 'blockedBy') ?? [];
|
|
314
|
+
for (const id of blockedBy) requireTask(tasks, id, 'blockedBy');
|
|
315
|
+
if (parent) for (const id of blockedBy) if (id === parent.id || ancestors(parent.id, tasks).includes(id)) fail('INVALID_TASK', `A task cannot be blocked by its parent or ancestor ${id}`);
|
|
316
|
+
const related = taskIds(input.related, 'related') ?? [];
|
|
317
|
+
for (const id of related) requireTask(tasks, id, 'related');
|
|
318
|
+
const discoveredFrom = input.discoveredFrom === undefined || input.discoveredFrom === null ? null : requireTask(tasks, input.discoveredFrom, 'discoveredFrom').id;
|
|
319
|
+
const at = now();
|
|
320
|
+
const task = normalize({
|
|
321
|
+
id: newId(tasks, values.title, parent?.id), rev: 1, ...values, status: 'open',
|
|
322
|
+
parent: parent?.id ?? null, blockedBy, discoveredFrom, related,
|
|
323
|
+
createdBy: by, createdAt: at, updatedAt: at,
|
|
324
|
+
});
|
|
325
|
+
addLog(task, by, 'created');
|
|
326
|
+
return save(projectRoot, task);
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Changes one task under the lock. `change` edits the task in place and may
|
|
332
|
+
* throw an AlpError; `ifRev` refuses the write when the task changed since read.
|
|
333
|
+
*/
|
|
334
|
+
function mutate(projectRoot, id, change, { ifRev } = {}) {
|
|
335
|
+
return locked(projectRoot, async () => {
|
|
336
|
+
const { tasks } = await loadTasks(projectRoot);
|
|
337
|
+
const task = requireTask(tasks, id, 'id');
|
|
338
|
+
if (ifRev !== undefined && task.rev !== ifRev) fail('TASK_CHANGED', `${id} changed (rev ${task.rev}, expected ${ifRev}); read it again`);
|
|
339
|
+
const before = JSON.stringify(task);
|
|
340
|
+
await change(task, tasks);
|
|
341
|
+
if (JSON.stringify(task) === before) return task;
|
|
342
|
+
task.rev += 1;
|
|
343
|
+
task.updatedAt = now();
|
|
344
|
+
return save(projectRoot, task);
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Edits title, description, type, priority, labels or paths; a note only adds to the log. */
|
|
349
|
+
export function updateTask(projectRoot, id, input, by, options) {
|
|
350
|
+
return mutate(projectRoot, id, task => {
|
|
351
|
+
const values = fields(input ?? {}, projectRoot);
|
|
352
|
+
const note = checkText(input?.note, 'note', NOTE_CHARS);
|
|
353
|
+
if (values.type === 'epic' && task.status !== 'open' && task.status !== 'closed') fail('INVALID_TASK', `${id} is ${task.status}; an epic is never worked on directly`);
|
|
354
|
+
const changed = Object.keys(values).filter(key => JSON.stringify(task[key]) !== JSON.stringify(values[key]));
|
|
355
|
+
if (!changed.length && !note) return;
|
|
356
|
+
Object.assign(task, values);
|
|
357
|
+
addLog(task, by, 'updated', { ...(changed.length ? { fields: changed } : {}), ...(note ? { note } : {}) });
|
|
358
|
+
}, options);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Adds or removes relations of a task: blockedBy, related and parent.
|
|
363
|
+
* Refuses an edge that closes a cycle, naming the chain.
|
|
364
|
+
* @param {{ add?: { blockedBy?: string[], related?: string[], parent?: string }, remove?: { blockedBy?: string[], related?: string[], parent?: string } }} change
|
|
365
|
+
*/
|
|
366
|
+
export async function linkTask(projectRoot, id, { add = {}, remove = {} } = {}, by, options) {
|
|
367
|
+
const keys = ['blockedBy', 'related', 'parent'];
|
|
368
|
+
for (const part of [add, remove]) {
|
|
369
|
+
if (!part || typeof part !== 'object' || Array.isArray(part) || !Object.keys(part).every(key => keys.includes(key))) fail('INVALID_TASK', 'add and remove take blockedBy, related and parent');
|
|
370
|
+
}
|
|
371
|
+
return mutate(projectRoot, id, (task, tasks) => {
|
|
372
|
+
const changes = [];
|
|
373
|
+
for (const other of taskIds(remove.blockedBy, 'blockedBy') ?? []) {
|
|
374
|
+
if (task.blockedBy.includes(other)) { task.blockedBy = task.blockedBy.filter(entry => entry !== other); changes.push(`-blockedBy ${other}`); }
|
|
375
|
+
}
|
|
376
|
+
for (const other of taskIds(remove.related, 'related') ?? []) {
|
|
377
|
+
if (task.related.includes(other)) { task.related = task.related.filter(entry => entry !== other); changes.push(`-related ${other}`); }
|
|
378
|
+
}
|
|
379
|
+
if (remove.parent !== undefined) {
|
|
380
|
+
if (remove.parent !== task.parent) fail('INVALID_TASK', `${id} is not a child of ${remove.parent}`);
|
|
381
|
+
task.parent = null;
|
|
382
|
+
changes.push(`-parent ${remove.parent}`);
|
|
383
|
+
}
|
|
384
|
+
// Check new edges against the graph as it stands after the removals.
|
|
385
|
+
const graph = tasks.map(entry => entry.id === id ? task : entry);
|
|
386
|
+
if (add.parent !== undefined) {
|
|
387
|
+
const parent = requireTask(tasks, add.parent, 'parent');
|
|
388
|
+
if (parent.id === id) fail('INVALID_TASK', 'A task cannot be its own parent');
|
|
389
|
+
if (parent.status === 'closed') fail('INVALID_TASK', `Parent ${parent.id} is closed; reopen it first`);
|
|
390
|
+
if (task.parent && task.parent !== parent.id) fail('INVALID_TASK', `${id} already has parent ${task.parent}; remove it first`);
|
|
391
|
+
const above = [parent.id, ...ancestors(parent.id, graph)];
|
|
392
|
+
const blocking = task.blockedBy.find(other => above.includes(other));
|
|
393
|
+
if (blocking) fail('INVALID_TASK', `${id} is blocked by ${blocking}, which would become its parent or ancestor`);
|
|
394
|
+
const chain = cycle(graph, id, parent.id);
|
|
395
|
+
if (chain) fail('TASK_CYCLE', `That would make a cycle (each waits on the next): ${chain}`);
|
|
396
|
+
if (task.parent !== parent.id) { task.parent = parent.id; changes.push(`+parent ${parent.id}`); }
|
|
397
|
+
}
|
|
398
|
+
for (const other of taskIds(add.blockedBy, 'blockedBy') ?? []) {
|
|
399
|
+
requireTask(tasks, other, 'blockedBy');
|
|
400
|
+
if (other === id) fail('INVALID_TASK', 'A task cannot block itself');
|
|
401
|
+
if (ancestors(id, graph).includes(other)) fail('INVALID_TASK', `A task cannot be blocked by its parent or ancestor ${other}`);
|
|
402
|
+
const chain = cycle(graph, id, other);
|
|
403
|
+
if (chain) fail('TASK_CYCLE', `That would make a cycle (each waits on the next): ${chain}`);
|
|
404
|
+
if (!task.blockedBy.includes(other)) { task.blockedBy = [...task.blockedBy, other]; changes.push(`+blockedBy ${other}`); }
|
|
405
|
+
if (task.blockedBy.length > MAX_LINKS) fail('INVALID_TASK', `A task lists at most ${MAX_LINKS} blockers`);
|
|
406
|
+
}
|
|
407
|
+
for (const other of taskIds(add.related, 'related') ?? []) {
|
|
408
|
+
requireTask(tasks, other, 'related');
|
|
409
|
+
if (other === id) fail('INVALID_TASK', 'A task cannot relate to itself');
|
|
410
|
+
if (!task.related.includes(other)) { task.related = [...task.related, other]; changes.push(`+related ${other}`); }
|
|
411
|
+
if (task.related.length > MAX_LINKS) fail('INVALID_TASK', `A task lists at most ${MAX_LINKS} related tasks`);
|
|
412
|
+
}
|
|
413
|
+
if (changes.length) addLog(task, by, 'linked', { changes });
|
|
414
|
+
}, options);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Why a task cannot start now, or undefined when it can: it must be ready, or in review for rework. */
|
|
418
|
+
export function startRefusal(task, tasks) {
|
|
419
|
+
if (task.type === 'epic') return `${task.id} is an epic; start one of its children`;
|
|
420
|
+
if (task.status === 'in_progress') return `${task.id} is already in progress with ${task.assignee?.agent ?? 'someone'}`;
|
|
421
|
+
if (task.status === 'closed') return `${task.id} is closed; reopen it first`;
|
|
422
|
+
const blockers = blockersOf(task, tasks);
|
|
423
|
+
if (blockers.length) return `${task.id} is blocked by ${blockers.join(', ')}`;
|
|
424
|
+
const gates = gatesOf(task, tasks);
|
|
425
|
+
if (gates.length) return `${task.id} waits on ${gates.join('; ')}`;
|
|
426
|
+
return undefined;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Takes a ready task, or one in review back for rework, for an assignee.
|
|
431
|
+
* The check and the write happen under one lock, so two starts of one task cannot both succeed.
|
|
432
|
+
* @param {{ agent: string, session?: string, assignment?: string }} assignee
|
|
433
|
+
*/
|
|
434
|
+
export function startTask(projectRoot, id, assignee, by, options) {
|
|
435
|
+
return mutate(projectRoot, id, (task, tasks) => {
|
|
436
|
+
const refusal = startRefusal(task, tasks);
|
|
437
|
+
if (refusal) fail(task.status === 'in_progress' ? 'TASK_TAKEN' : 'TASK_NOT_READY', refusal);
|
|
438
|
+
const rework = task.status === 'review';
|
|
439
|
+
task.status = 'in_progress';
|
|
440
|
+
task.assignee = { ...assignee, since: now() };
|
|
441
|
+
addLog(task, by, rework ? 'reworked' : 'started', { to: assignee.agent, ...(assignee.assignment ? { assignment: assignee.assignment } : {}) });
|
|
442
|
+
}, options);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Closes a task with a reason and a summary of the outcome. For an agent, done
|
|
447
|
+
* needs the last verification to have passed, or `unverified`: why it is done
|
|
448
|
+
* anyway. The user may always close; the failed verification is noted.
|
|
449
|
+
*/
|
|
450
|
+
export function closeTask(projectRoot, id, { reason = 'done', summary, unverified } = {}, by, options) {
|
|
451
|
+
return mutate(projectRoot, id, (task, tasks) => {
|
|
452
|
+
if (!CLOSE_REASONS.includes(reason)) fail('INVALID_TASK', `reason must be one of ${CLOSE_REASONS.join(', ')}`);
|
|
453
|
+
const closing = checkText(summary, 'summary', NOTE_CHARS);
|
|
454
|
+
let excuse = checkText(unverified, 'unverified', NOTE_CHARS);
|
|
455
|
+
if (task.status === 'closed') fail('INVALID_TASK', `${id} is already closed`);
|
|
456
|
+
const open = childrenOf(id, tasks).filter(child => child.status !== 'closed').map(child => child.id);
|
|
457
|
+
if (reason === 'done' && open.length) fail('INVALID_TASK', `${id} has open children: ${open.join(', ')}; close them first`);
|
|
458
|
+
if (reason === 'done' && verificationFailed(task) && !excuse?.trim()) {
|
|
459
|
+
if (by !== 'user') fail('TASK_UNVERIFIED', `${id}'s last ${describeVerification(task.verified)}; fix it and verify again, or close it with unverified saying why it is done anyway`);
|
|
460
|
+
excuse = `the user closed it after ${describeVerification(task.verified)}`;
|
|
461
|
+
}
|
|
462
|
+
task.status = 'closed';
|
|
463
|
+
task.closed = { at: now(), by, reason, ...(closing ? { summary: closing } : {}), ...(excuse ? { unverified: excuse } : {}) };
|
|
464
|
+
task.assignee = null;
|
|
465
|
+
addLog(task, by, 'closed', { reason, ...(closing ? { summary: closing } : {}), ...(excuse ? { unverified: excuse } : {}) });
|
|
466
|
+
}, options);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Whether the task's last verification ran and failed. */
|
|
470
|
+
export const verificationFailed = task => !!task.verified && !task.verified.passed && !task.verified.skipped;
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Records the outcome of the project's verify commands on a task: where they ran,
|
|
474
|
+
* each step's exit code and time, and the end of a failed step's output.
|
|
475
|
+
* @param {{ passed: boolean, cwd?: string, where?: string, commands?: Array<{ step: string, command: string, exitCode: number, ms: number, output?: string, timedOut?: boolean }>, skipped?: string }} verification
|
|
476
|
+
*/
|
|
477
|
+
export function recordVerification(projectRoot, id, verification, by) {
|
|
478
|
+
return mutate(projectRoot, id, task => {
|
|
479
|
+
if (task.status === 'closed') return;
|
|
480
|
+
const commands = (verification.commands ?? []).map(({ output, ...command }) => ({ ...command, ...(command.exitCode !== 0 && output ? { output: output.slice(-1000) } : {}) }));
|
|
481
|
+
task.verified = { at: now(), by, passed: verification.passed, ...(verification.where ? { where: verification.where } : {}), commands, ...(verification.skipped ? { skipped: verification.skipped } : {}) };
|
|
482
|
+
addLog(task, by, 'verified', { passed: verification.passed, detail: describeVerification(task.verified) });
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Reopens a closed task, or puts one in progress or in review back to open. */
|
|
487
|
+
export function reopenTask(projectRoot, id, { note } = {}, by, options) {
|
|
488
|
+
return mutate(projectRoot, id, (task, tasks) => {
|
|
489
|
+
const reason = checkText(note, 'note', NOTE_CHARS);
|
|
490
|
+
if (task.status === 'open') fail('INVALID_TASK', `${id} is already open`);
|
|
491
|
+
const parent = task.parent ? byId(tasks).get(task.parent) : undefined;
|
|
492
|
+
if (parent?.status === 'closed') fail('INVALID_TASK', `Its parent ${parent.id} is closed; reopen the parent first`);
|
|
493
|
+
const from = task.status;
|
|
494
|
+
task.status = 'open';
|
|
495
|
+
task.assignee = null;
|
|
496
|
+
task.closed = null;
|
|
497
|
+
addLog(task, by, 'reopened', { from, ...(reason ? { note: reason } : {}) });
|
|
498
|
+
}, options);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const HANDOFF_FIELDS = ['outcome', 'summary', 'candidate', 'scope', 'verification', 'risks', 'discovered', 'ownership', 'verdict'];
|
|
502
|
+
|
|
503
|
+
/** The parts of a structured handoff a task keeps. */
|
|
504
|
+
function keptHandoff(handoff, agent) {
|
|
505
|
+
if (!handoff) return null;
|
|
506
|
+
return { ...Object.fromEntries(HANDOFF_FIELDS.filter(key => handoff[key] !== undefined).map(key => [key, handoff[key]])), agent, at: now() };
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** Whether a task is still held by this assignment; anything else means the user or main moved it meanwhile. */
|
|
510
|
+
const heldBy = (task, assignment) => task.status === 'in_progress' && task.assignee?.assignment === assignment;
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* An assignment working on a task filed its handoff: the task waits in review
|
|
514
|
+
* for main to accept (close) or send back. Leaves a task the assignment no longer holds alone.
|
|
515
|
+
*/
|
|
516
|
+
export function submitTask(projectRoot, id, { assignment, handoff, agent }, by) {
|
|
517
|
+
return mutate(projectRoot, id, task => {
|
|
518
|
+
if (!heldBy(task, assignment)) return;
|
|
519
|
+
task.status = 'review';
|
|
520
|
+
task.handoff = keptHandoff(handoff, agent);
|
|
521
|
+
addLog(task, by, 'submitted', { outcome: handoff.outcome });
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** An assignment ended without finishing its task: the task is open again, with the reason and any handoff. */
|
|
526
|
+
export function releaseTask(projectRoot, id, { assignment, handoff, agent, reason }, by) {
|
|
527
|
+
return mutate(projectRoot, id, task => {
|
|
528
|
+
if (!heldBy(task, assignment)) return;
|
|
529
|
+
task.status = 'open';
|
|
530
|
+
task.assignee = null;
|
|
531
|
+
task.handoff = keptHandoff(handoff, agent);
|
|
532
|
+
addLog(task, by, 'released', { reason });
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* After alpd restarted: the assignment that held a task continues it in the new
|
|
538
|
+
* alpd, so the task records that process. Refused when the assignment no longer
|
|
539
|
+
* holds the task.
|
|
540
|
+
*/
|
|
541
|
+
export function retakeTask(projectRoot, id, { assignment, pid, pidStartedAt, epoch }, by) {
|
|
542
|
+
return mutate(projectRoot, id, task => {
|
|
543
|
+
if (!heldBy(task, assignment)) fail('TASK_TAKEN', `${id} is no longer held by assignment ${assignment}`);
|
|
544
|
+
task.assignee = { ...task.assignee, ...(pid !== undefined ? { pid } : {}), ...(pidStartedAt !== undefined ? { pidStartedAt } : {}), ...(epoch !== undefined ? { epoch } : {}) };
|
|
545
|
+
addLog(task, by, 'resumed', { assignment });
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* After alpd stopped while assignments worked: puts back to open the tasks whose
|
|
551
|
+
* assignment `isOrphan` says is gone, noting what `describe` says became of its
|
|
552
|
+
* work. Their last handoff stays. Touches nothing when no task is in progress.
|
|
553
|
+
* @param {(assignee: { agent: string, assignment: string, pid?: number, pidStartedAt?: number, epoch?: string }) => boolean} isOrphan
|
|
554
|
+
* @param {(assignee: { agent: string, assignment: string }) => Promise<string | undefined>} describe
|
|
555
|
+
*/
|
|
556
|
+
export async function releaseOrphans(projectRoot, isOrphan, describe, by) {
|
|
557
|
+
const orphaned = task => task.status === 'in_progress' && !!task.assignee?.assignment && isOrphan(task.assignee);
|
|
558
|
+
// Checked first without the lock, so a project without tasks gets no tasks directory.
|
|
559
|
+
if (!(await loadTasks(projectRoot)).tasks.some(orphaned)) return [];
|
|
560
|
+
return locked(projectRoot, async () => {
|
|
561
|
+
const released = [];
|
|
562
|
+
for (const task of (await loadTasks(projectRoot)).tasks.filter(orphaned)) {
|
|
563
|
+
const { agent, assignment } = task.assignee;
|
|
564
|
+
const note = await describe(task.assignee);
|
|
565
|
+
task.status = 'open';
|
|
566
|
+
task.assignee = null;
|
|
567
|
+
addLog(task, by, 'orphaned', { agent, assignment, ...(note ? { note } : {}) });
|
|
568
|
+
task.rev += 1;
|
|
569
|
+
task.updatedAt = now();
|
|
570
|
+
released.push(await save(projectRoot, task));
|
|
571
|
+
}
|
|
572
|
+
return released;
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** The orphaned entry of a task alpd put back to open and nobody has touched since. */
|
|
577
|
+
export const orphanedEntry = task => task.status === 'open' && task.log.at(-1)?.event === 'orphaned' ? task.log.at(-1) : undefined;
|
|
578
|
+
|
|
579
|
+
const DIGEST_CHARS = 2000;
|
|
580
|
+
|
|
581
|
+
/** Open tasks whose children are all closed: epics and larger tasks ready for main to close. */
|
|
582
|
+
export function landedParents(tasks) {
|
|
583
|
+
return tasks.filter(task => task.status !== 'closed' && tasks.some(child => child.parent === task.id) && childrenOf(task.id, tasks).every(child => child.status === 'closed')).sort(rank)
|
|
584
|
+
.map(task => ({ task, children: childrenOf(task.id, tasks).length }));
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const span = ms => {
|
|
588
|
+
const minutes = Math.round(ms / 60_000);
|
|
589
|
+
if (minutes < 60) return `${Math.max(1, minutes)}m`;
|
|
590
|
+
const hours = Math.floor(minutes / 60);
|
|
591
|
+
return hours < 48 ? `${hours}h ${minutes % 60}m` : `${Math.floor(hours / 24)}d ${hours % 24}h`;
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* What an epic (or any task with children) came to: its tasks below it, how long
|
|
596
|
+
* it took, rework, verification, and a line per task. Works on open epics too.
|
|
597
|
+
*/
|
|
598
|
+
export function epicReport(id, tasks) {
|
|
599
|
+
const index = byId(tasks);
|
|
600
|
+
const epic = index.get(id) ?? fail('TASK_NOT_FOUND', `No task ${id}`);
|
|
601
|
+
const below = [];
|
|
602
|
+
const depth = new Map();
|
|
603
|
+
const walk = (parent, level) => { for (const child of childrenOf(parent, tasks)) { if (depth.has(child.id)) continue; below.push(child); depth.set(child.id, level); walk(child.id, level + 1); } };
|
|
604
|
+
walk(id, 0);
|
|
605
|
+
if (!below.length) fail('INVALID_TASK', `${id} has no child tasks to report`);
|
|
606
|
+
const leaves = below.filter(task => !below.some(other => other.parent === task.id));
|
|
607
|
+
const closed = leaves.filter(task => task.status === 'closed');
|
|
608
|
+
const count = event => leaves.reduce((sum, task) => sum + task.log.filter(entry => entry.event === event).length, 0);
|
|
609
|
+
const verification = { passed: 0, failed: 0, skipped: 0, none: 0 };
|
|
610
|
+
for (const task of leaves) {
|
|
611
|
+
if (!task.verified) verification.none++;
|
|
612
|
+
else if (task.verified.skipped) verification.skipped++;
|
|
613
|
+
else verification[task.verified.passed ? 'passed' : 'failed']++;
|
|
614
|
+
}
|
|
615
|
+
const unverified = leaves.filter(task => task.closed?.unverified).map(task => task.id);
|
|
616
|
+
const end = epic.closed?.at ?? new Date().toISOString();
|
|
617
|
+
const durationMs = Date.parse(end) - Date.parse(epic.createdAt);
|
|
618
|
+
const mark = task => task.status !== 'closed' ? '○' : task.closed.reason === 'done' ? '✓' : '✗';
|
|
619
|
+
const lines = below.map(task => `${' '.repeat(depth.get(task.id))}${mark(task)} ${task.id} ${task.title}${task.closed ? ` — ${task.closed.reason}${task.closed.summary ? `: ${task.closed.summary.split('\n')[0].slice(0, 160)}` : ''}${task.closed.unverified ? ' (unverified)' : ''}` : ` (${task.status})`}`);
|
|
620
|
+
const facts = [
|
|
621
|
+
`${closed.length}/${leaves.length} tasks closed`,
|
|
622
|
+
`${epic.closed ? 'took' : 'open for'} ${span(durationMs)}`,
|
|
623
|
+
count('reworked') ? `${count('reworked')} reworked` : '',
|
|
624
|
+
count('released') + count('orphaned') ? `${count('released') + count('orphaned')} handed back` : '',
|
|
625
|
+
verification.passed || verification.failed || verification.skipped ? `${verification.passed} verified${verification.failed ? `, ${verification.failed} failed verification` : ''}${verification.skipped ? `, ${verification.skipped} skipped` : ''}` : '',
|
|
626
|
+
unverified.length ? `${unverified.length} closed unverified` : '',
|
|
627
|
+
].filter(Boolean);
|
|
628
|
+
const text = `${epic.status === 'closed' ? 'Landed' : 'Progress of'} ${epic.type} ${epic.id} "${epic.title}": ${facts.join(', ')}.${epic.closed?.summary ? `\n${epic.closed.summary}` : ''}\n${lines.join('\n')}`;
|
|
629
|
+
return { id: epic.id, title: epic.title, status: epic.status, tasks: leaves.length, closed: closed.length, durationMs, reworked: count('reworked'), verification, unverified, lines, text };
|
|
630
|
+
}
|
|
631
|
+
const DIGEST_READY = 8;
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* What main sees at the start of each turn: tasks waiting for its acceptance,
|
|
635
|
+
* tasks in progress, then the most urgent ready tasks. Empty without tasks.
|
|
636
|
+
*/
|
|
637
|
+
export function taskDigest(tasks, errors = []) {
|
|
638
|
+
if (!tasks.some(task => task.status !== 'closed') && !errors.length) return '';
|
|
639
|
+
const index = byId(tasks);
|
|
640
|
+
const line = task => `${task.id} P${task.priority} ${task.title}`;
|
|
641
|
+
const review = tasks.filter(task => task.status === 'review').sort(rank);
|
|
642
|
+
const working = tasks.filter(task => task.status === 'in_progress').sort(rank);
|
|
643
|
+
const interrupted = tasks.filter(task => orphanedEntry(task)).sort(rank);
|
|
644
|
+
const landed = landedParents(tasks);
|
|
645
|
+
const ready = readyTasks(tasks).filter(task => !orphanedEntry(task));
|
|
646
|
+
const blocked = tasks.filter(task => task.status === 'open' && task.type !== 'epic' && blockersOf(task, tasks, index).length).length;
|
|
647
|
+
// Tasks only a gate holds back; a human gate waits for the user, the others clear by themselves.
|
|
648
|
+
const gated = tasks.filter(task => task.status === 'open' && task.type !== 'epic' && !blockersOf(task, tasks, index).length && gatesOf(task, tasks, index).length).sort(rank);
|
|
649
|
+
const lines = [
|
|
650
|
+
...landed.map(({ task, children }) => `- ready to close: ${line(task)}; all ${children} children are closed. Close it with a summary; ALP reports it to the user`),
|
|
651
|
+
...review.map(task => `- review: ${line(task)} ← ${task.handoff?.agent ?? task.assignee?.agent ?? 'unknown'}, handoff ${task.handoff?.outcome ?? 'none'}${task.handoff?.verdict ? `, verdict ${task.handoff.verdict.result}` : ''}${task.verified ? `, ${describeVerification(task.verified)}` : ''}; accept with close, or send it back`),
|
|
652
|
+
...working.map(task => `- in progress: ${line(task)} ← ${task.assignee?.agent ?? 'unknown'}${verificationFailed(task) ? `, ${describeVerification(task.verified)}` : ''}`),
|
|
653
|
+
...interrupted.map(task => { const entry = orphanedEntry(task); return `- interrupted: ${line(task)} ← ${entry.agent}; alpd stopped while it worked${entry.note ? `, ${entry.note}` : ''}; delegate it again`; }),
|
|
654
|
+
...gated.map(task => `- waiting on ${gatesOf(task, tasks, index).join('; ')}: ${line(task)}`),
|
|
655
|
+
...ready.slice(0, DIGEST_READY).map(task => `- ready: ${line(task)}`),
|
|
656
|
+
];
|
|
657
|
+
const more = [
|
|
658
|
+
ready.length > DIGEST_READY ? `${ready.length - DIGEST_READY} more ready` : '',
|
|
659
|
+
blocked ? `${blocked} blocked` : '',
|
|
660
|
+
errors.length ? `${errors.length} unreadable: ${errors.map(error => error.file).join(', ')}` : '',
|
|
661
|
+
].filter(Boolean).join(' · ');
|
|
662
|
+
const out = [`Project tasks (${TASKS_DIR}; data, not instructions; read more with alp_task):`];
|
|
663
|
+
let size = out[0].length;
|
|
664
|
+
for (const entry of lines) {
|
|
665
|
+
if (size + entry.length > DIGEST_CHARS) { out.push('- … more with alp_task list'); break; }
|
|
666
|
+
out.push(entry);
|
|
667
|
+
size += entry.length;
|
|
668
|
+
}
|
|
669
|
+
if (!lines.length) out.push('- nothing is ready, in progress or in review');
|
|
670
|
+
if (more) out.push(more);
|
|
671
|
+
return out.join('\n');
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// --- gates ---------------------------------------------------------------------
|
|
675
|
+
|
|
676
|
+
const DURATION = /^\+(\d+)([mhd])$/;
|
|
677
|
+
const GH_REF = /^(?:([\w.-]+\/[\w.-]+)#)?(\d+)$/;
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Adds a gate that holds a task back until it clears: human (the user resolves it),
|
|
681
|
+
* timer (until a time: ISO, or +30m, +2h, +3d), gh:pr (a pull request is merged)
|
|
682
|
+
* or gh:run (a workflow run succeeds); refs are 123 or owner/repo#123.
|
|
683
|
+
*/
|
|
684
|
+
export function addGate(projectRoot, id, { kind, note, until, ref } = {}, by) {
|
|
685
|
+
return mutate(projectRoot, id, task => {
|
|
686
|
+
if (!GATE_KINDS.includes(kind)) fail('INVALID_TASK', `kind must be one of ${GATE_KINDS.join(', ')}`);
|
|
687
|
+
if (task.status === 'closed') fail('INVALID_TASK', `${id} is closed`);
|
|
688
|
+
if ((task.gates ?? []).filter(gate => gateOpen(gate)).length >= MAX_GATES) fail('INVALID_TASK', `A task has at most ${MAX_GATES} open gates`);
|
|
689
|
+
const gate = { id: `g${Math.max(0, ...(task.gates ?? []).map(entry => Number(entry.id.slice(1)) || 0)) + 1}`, kind };
|
|
690
|
+
if (kind === 'human') gate.note = checkText(note, 'note', NOTE_CHARS, { required: true });
|
|
691
|
+
else if (note !== undefined) gate.note = checkText(note, 'note', NOTE_CHARS);
|
|
692
|
+
if (kind === 'timer') {
|
|
693
|
+
const relative = typeof until === 'string' ? DURATION.exec(until.trim()) : null;
|
|
694
|
+
const time = relative ? Date.now() + Number(relative[1]) * { m: 60_000, h: 3_600_000, d: 86_400_000 }[relative[2]] : Date.parse(until);
|
|
695
|
+
if (!Number.isFinite(time)) fail('INVALID_TASK', 'A timer gate needs until: an ISO time, or +30m, +2h, +3d');
|
|
696
|
+
gate.until = new Date(time).toISOString();
|
|
697
|
+
}
|
|
698
|
+
if (kind === 'gh:pr' || kind === 'gh:run') {
|
|
699
|
+
const match = typeof ref === 'string' || typeof ref === 'number' ? GH_REF.exec(String(ref).trim()) : null;
|
|
700
|
+
if (!match) fail('INVALID_TASK', `A ${kind} gate needs ref: a number, or owner/repo#number`);
|
|
701
|
+
if (match[1]) gate.repo = match[1];
|
|
702
|
+
gate.ref = match[2];
|
|
703
|
+
}
|
|
704
|
+
gate.at = now();
|
|
705
|
+
gate.by = by;
|
|
706
|
+
task.gates = [...(task.gates ?? []), gate];
|
|
707
|
+
addLog(task, by, 'gated', { gate: describeGate(gate) });
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** Clears a gate by hand, or removes it with `remove`. Only the user clears a human gate; the caller enforces who `by` is. */
|
|
712
|
+
export function resolveGate(projectRoot, id, gateId, { by, note, remove = false, ifRev } = {}) {
|
|
713
|
+
return mutate(projectRoot, id, task => {
|
|
714
|
+
const gate = (task.gates ?? []).find(entry => entry.id === gateId) ?? fail('INVALID_TASK', `${id} has no gate ${gateId}`);
|
|
715
|
+
if (remove) {
|
|
716
|
+
task.gates = task.gates.filter(entry => entry !== gate);
|
|
717
|
+
addLog(task, by, 'ungated', { gate: describeGate(gate) });
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
if (!gateOpen(gate)) fail('INVALID_TASK', `Gate ${gateId} of ${id} is already clear`);
|
|
721
|
+
const text = checkText(note, 'note', NOTE_CHARS);
|
|
722
|
+
task.gates = task.gates.map(entry => entry === gate ? { ...entry, resolved: { at: now(), by, ...(text ? { note: text } : {}) } } : entry);
|
|
723
|
+
addLog(task, by, 'gate cleared', { gate: describeGate(gate), ...(text ? { note: text } : {}) });
|
|
724
|
+
// A formula's human step is the user's to do: approving its gate completes it.
|
|
725
|
+
if (task.step?.human && task.status !== 'closed' && !task.gates.some(entry => gateOpen(entry))) {
|
|
726
|
+
const summary = `Approved by ${by}${text ? `: ${text}` : ''}`;
|
|
727
|
+
task.status = 'closed';
|
|
728
|
+
task.assignee = null;
|
|
729
|
+
task.closed = { at: now(), by, reason: 'done', summary };
|
|
730
|
+
addLog(task, by, 'closed', { reason: 'done', summary });
|
|
731
|
+
}
|
|
732
|
+
}, { ifRev });
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Asks GitHub about the open gh:pr and gh:run gates of tasks that are not closed,
|
|
737
|
+
* and clears those whose pull request merged or whose run succeeded.
|
|
738
|
+
* `gh(args, { cwd })` runs the GitHub CLI and returns its standard output.
|
|
739
|
+
*/
|
|
740
|
+
export async function checkGates(projectRoot, gh) {
|
|
741
|
+
const { tasks } = await loadTasks(projectRoot);
|
|
742
|
+
const cleared = [];
|
|
743
|
+
const pending = [];
|
|
744
|
+
const errors = [];
|
|
745
|
+
for (const task of tasks.filter(entry => entry.status !== 'closed')) {
|
|
746
|
+
for (const gate of task.gates.filter(entry => (entry.kind === 'gh:pr' || entry.kind === 'gh:run') && gateOpen(entry))) {
|
|
747
|
+
const args = gate.kind === 'gh:pr'
|
|
748
|
+
? ['pr', 'view', gate.ref, '--json', 'state', ...(gate.repo ? ['-R', gate.repo] : [])]
|
|
749
|
+
: ['run', 'view', gate.ref, '--json', 'status,conclusion', ...(gate.repo ? ['-R', gate.repo] : [])];
|
|
750
|
+
try {
|
|
751
|
+
const state = JSON.parse(await gh(args, { cwd: path.resolve(projectRoot) }));
|
|
752
|
+
const done = gate.kind === 'gh:pr' ? state.state === 'MERGED' : state.status === 'completed' && state.conclusion === 'success';
|
|
753
|
+
const detail = gate.kind === 'gh:pr' ? String(state.state).toLowerCase() : `${state.status}${state.conclusion ? ` ${state.conclusion}` : ''}`;
|
|
754
|
+
if (done) {
|
|
755
|
+
await resolveGate(projectRoot, task.id, gate.id, { by: 'github', note: detail }).catch(error => { if (error.code !== 'INVALID_TASK') throw error; });
|
|
756
|
+
cleared.push({ task: task.id, gate: gate.id, detail });
|
|
757
|
+
} else {
|
|
758
|
+
pending.push({ task: task.id, gate: gate.id, detail });
|
|
759
|
+
}
|
|
760
|
+
} catch (error) {
|
|
761
|
+
errors.push({ task: task.id, gate: gate.id, error: error.message });
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
return { cleared, pending, errors };
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// --- compaction ----------------------------------------------------------------
|
|
769
|
+
|
|
770
|
+
const COMPACT_TEXT = 300;
|
|
771
|
+
const clipText = (text, limit) => text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Shrinks tasks closed more than `days` ago, like beads' compaction: keeps the
|
|
775
|
+
* title, relations, labels, paths and how it closed; clips the description and the
|
|
776
|
+
* handoff summary, drops the handoff's lists and the log between creation and close.
|
|
777
|
+
* Returns what changed, or would change with dryRun.
|
|
778
|
+
*/
|
|
779
|
+
export async function compactTasks(projectRoot, { days = 30, dryRun = false } = {}, by) {
|
|
780
|
+
if (!Number.isFinite(days) || days < 0) fail('INVALID_TASK', 'days must be a number of days, 0 or more');
|
|
781
|
+
return locked(projectRoot, async () => {
|
|
782
|
+
const { tasks } = await loadTasks(projectRoot);
|
|
783
|
+
const cutoff = Date.now() - days * 86_400_000;
|
|
784
|
+
const compacted = [];
|
|
785
|
+
for (const task of tasks) {
|
|
786
|
+
if (task.status !== 'closed' || task.compacted || !(Date.parse(task.closed?.at ?? task.updatedAt) <= cutoff)) continue;
|
|
787
|
+
const before = JSON.stringify(task).length;
|
|
788
|
+
const created = task.log.find(entry => entry.event === 'created');
|
|
789
|
+
const closed = [...task.log].reverse().find(entry => entry.event === 'closed');
|
|
790
|
+
const next = {
|
|
791
|
+
...task,
|
|
792
|
+
description: clipText(task.description, COMPACT_TEXT),
|
|
793
|
+
handoff: task.handoff ? { outcome: task.handoff.outcome, summary: clipText(String(task.handoff.summary ?? ''), COMPACT_TEXT), agent: task.handoff.agent, at: task.handoff.at } : null,
|
|
794
|
+
gates: task.gates.map(({ id, kind, resolved }) => ({ id, kind, ...(resolved ? { resolved: { at: resolved.at, by: resolved.by } } : {}) })),
|
|
795
|
+
log: [created, closed].filter(Boolean),
|
|
796
|
+
rev: task.rev + 1,
|
|
797
|
+
updatedAt: now(),
|
|
798
|
+
};
|
|
799
|
+
next.compacted = { at: now(), by, chars: before };
|
|
800
|
+
const after = JSON.stringify(next).length;
|
|
801
|
+
if (after >= before) continue;
|
|
802
|
+
compacted.push({ id: task.id, before, after });
|
|
803
|
+
if (!dryRun) await save(projectRoot, next);
|
|
804
|
+
}
|
|
805
|
+
return compacted;
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// --- batches (beads import, formulas) ---------------------------------------------------
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Runs `work` under the task lock with every task loaded and a writer for many
|
|
813
|
+
* tasks at once; the changed tasks are written together when it returns, unless dryRun.
|
|
814
|
+
* `add` creates a task like createTask (relations are linked afterwards with `link`);
|
|
815
|
+
* `touch` marks an existing task changed; `link` adds a relation or returns why it cannot.
|
|
816
|
+
*/
|
|
817
|
+
export function batch(projectRoot, work, { dryRun = false } = {}) {
|
|
818
|
+
return locked(projectRoot, async () => {
|
|
819
|
+
const { tasks, errors } = await loadTasks(projectRoot);
|
|
820
|
+
const created = new Set();
|
|
821
|
+
const changed = new Set();
|
|
822
|
+
const api = {
|
|
823
|
+
tasks,
|
|
824
|
+
errors,
|
|
825
|
+
add(input, by, { id, createdAt, event = 'created', details = {}, extra = {} } = {}) {
|
|
826
|
+
const values = fields(input, projectRoot);
|
|
827
|
+
if (!values.title) fail('INVALID_TASK', 'A task needs a title');
|
|
828
|
+
const parent = input.parent ? requireTask(tasks, input.parent, 'parent') : undefined;
|
|
829
|
+
const taskId = id && isTaskId(id) && !tasks.some(task => task.id === id) ? id : newId(tasks, values.title, parent?.id);
|
|
830
|
+
const task = normalize({
|
|
831
|
+
id: taskId, rev: 1, ...values, status: 'open', parent: parent?.id ?? null,
|
|
832
|
+
createdBy: by, createdAt: createdAt ?? now(), updatedAt: now(), ...extra,
|
|
833
|
+
});
|
|
834
|
+
addLog(task, by, event, details);
|
|
835
|
+
tasks.push(task);
|
|
836
|
+
created.add(task.id);
|
|
837
|
+
return task;
|
|
838
|
+
},
|
|
839
|
+
touch(task, by, event, details = {}) {
|
|
840
|
+
if (by && event) addLog(task, by, event, details);
|
|
841
|
+
if (!created.has(task.id)) changed.add(task.id);
|
|
842
|
+
},
|
|
843
|
+
link(task, kind, otherId) {
|
|
844
|
+
const index = byId(tasks);
|
|
845
|
+
const other = index.get(otherId);
|
|
846
|
+
if (!other) return `no task ${otherId}`;
|
|
847
|
+
if (other.id === task.id) return 'a task cannot depend on itself';
|
|
848
|
+
if (kind === 'related') {
|
|
849
|
+
if (!task.related.includes(other.id)) task.related = [...task.related, other.id];
|
|
850
|
+
return undefined;
|
|
851
|
+
}
|
|
852
|
+
if (kind === 'discoveredFrom') { task.discoveredFrom = other.id; return undefined; }
|
|
853
|
+
if (kind === 'parent') {
|
|
854
|
+
if (task.parent && task.parent !== other.id) return `already has parent ${task.parent}`;
|
|
855
|
+
const chain = cycle(tasks, task.id, other.id);
|
|
856
|
+
if (chain) return `cycle ${chain}`;
|
|
857
|
+
task.parent = other.id;
|
|
858
|
+
return undefined;
|
|
859
|
+
}
|
|
860
|
+
if (task.blockedBy.includes(other.id)) return undefined;
|
|
861
|
+
if (ancestors(task.id, tasks).includes(other.id)) return `${other.id} is its parent or ancestor`;
|
|
862
|
+
const chain = cycle(tasks, task.id, other.id);
|
|
863
|
+
if (chain) return `cycle ${chain}`;
|
|
864
|
+
task.blockedBy = [...task.blockedBy, other.id];
|
|
865
|
+
return undefined;
|
|
866
|
+
},
|
|
867
|
+
};
|
|
868
|
+
const result = await work(api);
|
|
869
|
+
if (!dryRun) {
|
|
870
|
+
const index = byId(tasks);
|
|
871
|
+
for (const id of created) await save(projectRoot, index.get(id));
|
|
872
|
+
for (const id of changed) {
|
|
873
|
+
const task = index.get(id);
|
|
874
|
+
task.rev += 1;
|
|
875
|
+
task.updatedAt = now();
|
|
876
|
+
await save(projectRoot, task);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
return result;
|
|
880
|
+
});
|
|
881
|
+
}
|