taskforce-loop-engineering 0.9.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,198 @@
1
+ import { createServer } from 'node:http';
2
+ import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ export const DASHBOARD_SCHEMA_VERSION = '1.0.0';
6
+ const SENSITIVE = /(^|_)(secret|token|password|credential|api[_-]?key|private[_-]?key|provider)(_|$)/i;
7
+ const STATES = new Set(['runnable', 'active', 'parked', 'waiting_for_human', 'waiting_for_external_condition', 'timed_out_or_escalated', 'reconciliation_required', 'blocked', 'completed', 'failed']);
8
+
9
+ function clean(value, key = '') {
10
+ if (SENSITIVE.test(key)) return '[REDACTED]';
11
+ if (Array.isArray(value)) return value.map((item) => clean(item));
12
+ if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map((name) => [name, clean(value[name], name)]));
13
+ return value;
14
+ }
15
+
16
+ function safeId(value) {
17
+ const result = String(value ?? '');
18
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,299}$/.test(result)) throw new Error('Unsafe dashboard identifier.');
19
+ return result;
20
+ }
21
+
22
+ function relativeLink(root, file) {
23
+ const relative = path.relative(root, file);
24
+ return relative && !relative.startsWith('..') && !path.isAbsolute(relative) ? relative.split(path.sep).join('/') : null;
25
+ }
26
+
27
+ async function json(file, warnings, root) {
28
+ for (let attempt = 0; attempt < 2; attempt += 1) {
29
+ try { return JSON.parse(await readFile(file, 'utf8')); }
30
+ catch (error) {
31
+ if (error.code === 'ENOENT') return null;
32
+ if (attempt === 0) continue;
33
+ warnings.push({ code: 'malformed_artifact', artifact: relativeLink(root, file), message: String(error.message).split('\n')[0] });
34
+ return null;
35
+ }
36
+ }
37
+ }
38
+
39
+ async function dirs(dir) {
40
+ return (await readdir(dir, { withFileTypes: true }).catch(() => [])).filter((item) => item.isDirectory()).map((item) => item.name).sort();
41
+ }
42
+
43
+ async function files(dir, suffix = '.json') {
44
+ return (await readdir(dir, { withFileTypes: true }).catch(() => [])).filter((item) => item.isFile() && item.name.endsWith(suffix)).map((item) => path.join(dir, item.name)).sort();
45
+ }
46
+
47
+ function normalizedState(item, location, nowMs) {
48
+ const raw = String(item.state ?? item.status ?? '').toLowerCase();
49
+ if (raw === 'unknown' || item.reconciliation?.required) return 'reconciliation_required';
50
+ if (raw === 'claimed' || raw === 'running' || raw === 'active' || location === 'active') {
51
+ if (item.claim?.lease_expires_at && Date.parse(item.claim.lease_expires_at) <= nowMs) return 'reconciliation_required';
52
+ return 'active';
53
+ }
54
+ if (['completed', 'accepted', 'settled', 'released', 'success', 'succeeded'].includes(raw) || location === 'completed') return 'completed';
55
+ if (['failed', 'error', 'cancelled', 'goal_unreachable'].includes(raw) || location === 'failed') return 'failed';
56
+ if (raw.includes('timeout') || raw.includes('escalat')) return 'timed_out_or_escalated';
57
+ const parked = item.parked ?? item.wait ?? (location === 'waiting' ? item : null);
58
+ const wait = String(parked?.state ?? parked?.kind ?? raw).toLowerCase();
59
+ if (wait.includes('human')) return 'waiting_for_human';
60
+ if (wait.includes('external') || wait.includes('condition')) return 'waiting_for_external_condition';
61
+ if (parked || location === 'waiting' || raw === 'parked') return 'parked';
62
+ if (raw === 'blocked' || item.blocked_reasons?.length) return 'blocked';
63
+ if (raw === 'handoff_pending') return 'active';
64
+ return 'runnable';
65
+ }
66
+
67
+ function todoProjection(todo, nowMs) {
68
+ const state = normalizedState(todo, null, nowMs);
69
+ return clean({
70
+ id: String(todo.id), title: todo.title ?? todo.goal ?? String(todo.id), project_id: todo.project_id ?? todo.projectId ?? null,
71
+ state, source_version: todo.version ?? 1, priority: Number(todo.priority ?? 0), risk: todo.risk ?? null,
72
+ authority: todo.authority_class ?? todo.authorization?.scope ?? null, required_capabilities: todo.required_capabilities ?? [],
73
+ owner: todo.claim?.owner ?? null, lease: todo.claim ? { fencing_token: todo.claim.fencing_token ?? null, claimed_at: todo.claim.claimed_at ?? null, expires_at: todo.claim.lease_expires_at ?? null, expired: Date.parse(todo.claim.lease_expires_at ?? '') <= nowMs } : null,
74
+ gate: todo.parked ?? null, blocked_reasons: todo.blocked_reasons ?? [], lineage: todo.lineage ?? null,
75
+ acceptance: todo.acceptance_contract ?? null, evidence: todo.evidence ?? [], evidence_requirements: todo.evidence_requirements ?? [],
76
+ cost: todo.cost_envelope ?? null, idempotency_keys: todo.idempotency_keys ?? [], next_action: state === 'reconciliation_required' ? 'reconcile_unknown_action_or_expired_lease' : state === 'waiting_for_human' ? 'await_human_response' : state === 'waiting_for_external_condition' ? 'verify_external_condition' : state === 'runnable' ? 'claim_todo' : null,
77
+ created_at: todo.created_at ?? null, updated_at: todo.updated_at ?? null
78
+ });
79
+ }
80
+
81
+ async function controlPlane(root, warnings, nowMs) {
82
+ const file = path.join(root, 'runtime', 'loops', 'control-plane', 'state.json');
83
+ const state = await json(file, warnings, root);
84
+ if (!state) return { todos: [], agents: [], handoffs: [], quotas: {} };
85
+ return {
86
+ todos: Object.values(state.todos ?? {}).map((todo) => todoProjection(todo, nowMs)).sort((a, b) => b.priority - a.priority || a.id.localeCompare(b.id)),
87
+ agents: Object.values(state.agents ?? {}).map(clean).sort((a, b) => String(a.id).localeCompare(String(b.id))),
88
+ handoffs: Object.values(state.handoffs ?? {}).map(clean).sort((a, b) => String(a.created_at ?? '').localeCompare(String(b.created_at ?? '')) || String(a.id).localeCompare(String(b.id))),
89
+ quotas: clean(state.quotas ?? {}), updated_at: state.updated_at ?? null, source_version: state.version ?? 1
90
+ };
91
+ }
92
+
93
+ async function actions(root, warnings, nowMs) {
94
+ const dir = path.join(root, 'runtime', 'loops', 'action-reservations');
95
+ const result = [];
96
+ for (const file of await files(dir)) {
97
+ const item = await json(file, warnings, root);
98
+ if (!item) continue;
99
+ const state = normalizedState(item, null, nowMs);
100
+ result.push(clean({ idempotency_key: item.idempotency_key, kind: item.kind, state, reservation_state: item.state, request_fingerprint: item.request_fingerprint, authorization: item.authorization, owner: item.claim?.owner ?? null, lease: item.claim ? { fencing_token: item.claim.fencing_token, expires_at: item.claim.lease_expires_at, expired: Date.parse(item.claim.lease_expires_at ?? '') <= nowMs } : null, reconciliation: item.reconciliation, settlement: item.settlement, release: item.release, created_at: item.created_at, updated_at: item.updated_at }));
101
+ }
102
+ return result.sort((a, b) => String(a.idempotency_key).localeCompare(String(b.idempotency_key)));
103
+ }
104
+
105
+ async function legacyQueues(root, warnings, nowMs) {
106
+ const loops = path.join(root, 'runtime', 'loops');
107
+ const excluded = new Set(['control-plane', 'action-reservations', 'projects']);
108
+ const queues = [];
109
+ for (const name of (await dirs(loops)).filter((item) => !excluded.has(item))) {
110
+ const base = path.join(loops, name); const counts = Object.fromEntries([...STATES].map((state) => [state, 0])); const tasks = [];
111
+ for (const location of ['inbox', 'active', 'waiting', 'completed', 'failed']) {
112
+ for (const file of await files(path.join(base, location))) {
113
+ const item = await json(file, warnings, root); if (!item) continue;
114
+ const state = normalizedState(item, location, nowMs); counts[state] += 1;
115
+ tasks.push(clean({ id: String(item.id ?? path.basename(file, '.json')), title: item.title ?? item.goal ?? item.task ?? path.basename(file, '.json'), state, queue: name, location, project_id: item.project_id ?? item.projectId ?? null, owner: item.owner ?? item.claim?.owner ?? null, gate: item.parked ?? item.wait ?? null, next_wake: item.next_wake_at ?? item.nextWakeAt ?? item.parked?.next_check_at ?? null, next_action: item.next_action ?? item.nextAction ?? null, risk: item.risk ?? null, evidence_links: [relativeLink(root, file)].filter(Boolean), updated_at: item.updated_at ?? item.created_at ?? null }));
116
+ }
117
+ }
118
+ const stateFile = path.join(base, 'state.json'); const state = await json(stateFile, warnings, root);
119
+ queues.push({ id: name, counts, tasks: tasks.sort((a, b) => a.id.localeCompare(b.id)), scheduler: clean(state), source_version: state?.version ?? 1 });
120
+ }
121
+ return queues;
122
+ }
123
+
124
+ async function projects(root, warnings) {
125
+ const result = [];
126
+ const runtime = path.join(root, 'runtime', 'loops', 'projects');
127
+ for (const id of await dirs(runtime)) {
128
+ const base = path.join(runtime, id);
129
+ const intake = await json(path.join(base, 'intake', 'latest.json'), warnings, root);
130
+ const backlog = await json(path.join(base, 'backlog', 'initial.json'), warnings, root);
131
+ const completion = await json(path.join(base, 'completion', 'latest.json'), warnings, root);
132
+ result.push(clean({ id, goal: intake?.goal ?? intake?.brief ?? null, queue: intake?.queue ?? null, status: completion?.status ?? (completion ? 'completed' : 'active'), acceptance: intake?.acceptance ?? null, backlog: backlog?.tasks ?? backlog?.items ?? backlog ?? null, evidence_links: [intake && relativeLink(root, path.join(base, 'intake', 'latest.json')), backlog && relativeLink(root, path.join(base, 'backlog', 'initial.json')), completion && relativeLink(root, path.join(base, 'completion', 'latest.json'))].filter(Boolean) }));
133
+ }
134
+ return result.sort((a, b) => a.id.localeCompare(b.id));
135
+ }
136
+
137
+ export async function buildOperatorProjection(root, options = {}) {
138
+ const resolved = path.resolve(root); const now = options.now ? new Date(options.now) : new Date();
139
+ if (Number.isNaN(now.getTime())) throw new Error('Invalid projection time.');
140
+ const warnings = []; const before = await stat(path.join(resolved, 'runtime', 'loops')).catch(() => null);
141
+ const [control, reservations, queueList, projectList] = await Promise.all([controlPlane(resolved, warnings, now.getTime()), actions(resolved, warnings, now.getTime()), legacyQueues(resolved, warnings, now.getTime()), projects(resolved, warnings)]);
142
+ const newest = [control.updated_at, ...control.todos.map((item) => item.updated_at), ...reservations.map((item) => item.updated_at), ...queueList.flatMap((queue) => queue.tasks.map((item) => item.updated_at))].filter(Boolean).sort().at(-1) ?? null;
143
+ const counts = Object.fromEntries([...STATES].map((state) => [state, 0]));
144
+ for (const item of [...control.todos, ...queueList.flatMap((queue) => queue.tasks)]) counts[item.state] += 1;
145
+ const after = await stat(path.join(resolved, 'runtime', 'loops')).catch(() => null);
146
+ if (before && after && before.mtimeMs !== after.mtimeMs) warnings.push({ code: 'concurrent_update', artifact: 'runtime/loops', message: 'Artifacts changed while the projection was read; refresh recommended.' });
147
+ return clean({ schema_version: DASHBOARD_SCHEMA_VERSION, generated_at: now.toISOString(), source: { root: resolved, read_only: true, newest_artifact_at: newest, freshness_seconds: newest ? Math.max(0, Math.floor((now.getTime() - Date.parse(newest)) / 1000)) : null }, health: { status: warnings.length ? 'degraded' : 'ok', warnings }, overview: { counts, queue_count: queueList.length, project_count: projectList.length, todo_count: control.todos.length, action_count: reservations.length }, projects: projectList, queues: queueList, todos: control.todos, agents: control.agents, handoffs: control.handoffs, gates: control.todos.filter((item) => ['parked', 'waiting_for_human', 'waiting_for_external_condition', 'timed_out_or_escalated'].includes(item.state)).map((item) => ({ todo_id: item.id, state: item.state, gate: item.gate, next_action: item.next_action })), actions: reservations, cost: { quotas: control.quotas, requested_total: control.todos.reduce((sum, item) => sum + Number(item.cost?.amount ?? 0), 0) } });
148
+ }
149
+
150
+ export function filterProjection(projection, options = {}) {
151
+ const query = String(options.query ?? '').toLowerCase(); const state = options.state;
152
+ const match = (item) => (!state || item.state === state) && (!query || JSON.stringify(item).toLowerCase().includes(query));
153
+ return { ...projection, todos: projection.todos.filter(match), queues: projection.queues.map((queue) => ({ ...queue, tasks: queue.tasks.filter(match) })), actions: projection.actions.filter(match) };
154
+ }
155
+
156
+ export function dashboardHealth(projection, options = {}) {
157
+ const maxAge = Number(options.maxAgeSeconds ?? 3600);
158
+ const stale = projection.source.freshness_seconds !== null && projection.source.freshness_seconds > maxAge;
159
+ return { schema_version: DASHBOARD_SCHEMA_VERSION, status: projection.health.status === 'ok' && !stale ? 'ok' : 'degraded', read_only: true, stale, freshness_seconds: projection.source.freshness_seconds, warnings: projection.health.warnings };
160
+ }
161
+
162
+ function html() {
163
+ return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Loop Engineering Operator Dashboard</title><style>body{font:14px system-ui;margin:2rem;background:#10151c;color:#e8edf2}input,select{padding:.55rem;background:#18222e;color:inherit;border:1px solid #445}table{width:100%;border-collapse:collapse;margin-top:1rem}th,td{text-align:left;padding:.55rem;border-bottom:1px solid #344}.pill{padding:.2rem .5rem;border-radius:1rem;background:#25364a}a{color:#78b7ff}</style></head><body><h1>Loop Engineering</h1><p id="health">Loading read-only projection…</p><input id="q" placeholder="Search"><select id="s"><option value="">All states</option></select><table><thead><tr><th>State</th><th>Task</th><th>Owner</th><th>Next action</th></tr></thead><tbody id="rows"></tbody></table><script>const states=['runnable','active','parked','waiting_for_human','waiting_for_external_condition','timed_out_or_escalated','reconciliation_required','blocked','completed','failed'];s.innerHTML+=states.map(x=>'<option>'+x+'</option>').join('');async function draw(){const p=new URLSearchParams({q:q.value,state:s.value});const d=await fetch('/api/v1/overview?'+p).then(r=>r.json());health.textContent=d.health.status+' · '+d.overview.todo_count+' typed todos · '+d.overview.queue_count+' queues';const all=[...d.todos,...d.queues.flatMap(x=>x.tasks)];rows.replaceChildren(...all.map(x=>{const tr=document.createElement('tr');for(const v of [x.state,x.title,x.owner??'—',x.next_action??'—']){const td=document.createElement('td');td.textContent=String(v);tr.append(td)}return tr}))}q.oninput=draw;s.onchange=draw;draw()</script></body></html>`;
164
+ }
165
+
166
+ function loopback(host) { return host === '127.0.0.1' || host === '::1' || host === 'localhost'; }
167
+
168
+ export async function createDashboardServer(root, options = {}) {
169
+ const host = options.host ?? '127.0.0.1'; const port = Number(options.port ?? 0);
170
+ if (!loopback(host) && options.allowNonLoopback !== true) throw new Error('Non-loopback dashboard bind requires --allow-non-loopback.');
171
+ const server = createServer(async (request, response) => {
172
+ try {
173
+ const url = new URL(request.url, 'http://localhost');
174
+ if (request.method !== 'GET' && request.method !== 'HEAD') { response.writeHead(405, { Allow: 'GET, HEAD' }); return response.end(); }
175
+ if (url.pathname.includes('..') || /%2e/i.test(request.url)) { response.writeHead(400); return response.end('unsafe path'); }
176
+ if (url.pathname === '/') { response.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'content-security-policy': "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; object-src 'none'; base-uri 'none'", 'x-content-type-options': 'nosniff' }); return response.end(request.method === 'HEAD' ? '' : html()); }
177
+ const projection = await buildOperatorProjection(root);
178
+ let body;
179
+ if (url.pathname === '/api/v1/overview') body = filterProjection(projection, { query: url.searchParams.get('q'), state: url.searchParams.get('state') });
180
+ else if (url.pathname === '/api/v1/health') body = dashboardHealth(projection, { maxAgeSeconds: url.searchParams.get('max_age_seconds') ?? 3600 });
181
+ else if (url.pathname === '/api/v1/todos') body = filterProjection(projection, { query: url.searchParams.get('q'), state: url.searchParams.get('state') }).todos;
182
+ else if (url.pathname.startsWith('/api/v1/todos/')) body = projection.todos.find((item) => item.id === safeId(decodeURIComponent(url.pathname.slice('/api/v1/todos/'.length)))) ?? null;
183
+ else if (url.pathname === '/api/v1/actions') body = projection.actions;
184
+ else { response.writeHead(404); return response.end('not found'); }
185
+ response.writeHead(body === null ? 404 : 200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' }); response.end(request.method === 'HEAD' ? '' : `${JSON.stringify(body)}\n`);
186
+ } catch (error) { response.writeHead(500, { 'content-type': 'application/json; charset=utf-8' }); response.end(`${JSON.stringify({ error: 'projection_failed', message: String(error.message) })}\n`); }
187
+ });
188
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, host, resolve); });
189
+ return server;
190
+ }
191
+
192
+ export async function exportDashboard(root, outputDir, options = {}) {
193
+ const target = path.resolve(outputDir); const projection = await buildOperatorProjection(root, options);
194
+ await mkdir(target, { recursive: true });
195
+ await writeFile(path.join(target, 'projection.json'), `${JSON.stringify(projection, null, 2)}\n`);
196
+ await writeFile(path.join(target, 'index.html'), html().replace("fetch('/api/v1/overview?'+p)", "fetch('./projection.json')"));
197
+ return { schema_version: DASHBOARD_SCHEMA_VERSION, output_dir: target, files: ['index.html', 'projection.json'], read_only_source: true };
198
+ }
@@ -0,0 +1,287 @@
1
+ import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { inspectAction } from './action-reservations.mjs';
5
+
6
+ const TODO_STATES = new Set(['runnable', 'blocked', 'claimed', 'handoff_pending', 'completed']);
7
+ const RISK = new Set(['low', 'medium', 'high', 'critical']);
8
+
9
+ function text(value, label) {
10
+ if (typeof value !== 'string' || !value.trim()) throw new Error(`${label} must be a non-empty string.`);
11
+ return value.trim();
12
+ }
13
+
14
+ function id(value, label) {
15
+ const result = text(value, label);
16
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,199}$/.test(result)) throw new Error(`${label} contains unsafe characters.`);
17
+ return result;
18
+ }
19
+
20
+ function strings(value, label) {
21
+ if (value === undefined) return [];
22
+ if (!Array.isArray(value) || value.some((item) => typeof item !== 'string' || !item.trim())) throw new Error(`${label} must be an array of strings.`);
23
+ return [...new Set(value.map((item) => item.trim()))].sort();
24
+ }
25
+
26
+ function location(root) {
27
+ const dir = path.join(root, 'runtime', 'loops', 'control-plane');
28
+ return { dir, file: path.join(dir, 'state.json'), lock: path.join(dir, 'state.lock'), audit: path.join(dir, 'audit.jsonl') };
29
+ }
30
+
31
+ function emptyState() {
32
+ return { version: 2, fencing_counter: 0, agents: {}, todos: {}, handoffs: {}, quotas: {}, updated_at: null };
33
+ }
34
+
35
+ async function readState(file) {
36
+ try { return JSON.parse(await readFile(file, 'utf8')); } catch (error) { if (error.code === 'ENOENT') return emptyState(); throw error; }
37
+ }
38
+
39
+ async function atomicWrite(file, value) {
40
+ const temp = `${file}.${process.pid}.${randomUUID()}.tmp`;
41
+ await writeFile(temp, `${JSON.stringify(value, null, 2)}\n`, { flag: 'wx' });
42
+ await rename(temp, file);
43
+ }
44
+
45
+ async function acquire(lock, timeoutMs = 5000) {
46
+ const deadline = Date.now() + timeoutMs;
47
+ while (true) {
48
+ try { await mkdir(lock); return; } catch (error) {
49
+ if (error.code !== 'EEXIST') throw error;
50
+ const info = await stat(lock).catch(() => null);
51
+ if (info && Date.now() - info.mtimeMs > 30_000) await rm(lock, { recursive: true, force: true });
52
+ else if (Date.now() >= deadline) throw new Error('Timed out acquiring control-plane mutex.');
53
+ else await new Promise((resolve) => setTimeout(resolve, 10));
54
+ }
55
+ }
56
+ }
57
+
58
+ async function transaction(root, operation) {
59
+ const place = location(root);
60
+ await mkdir(place.dir, { recursive: true });
61
+ await acquire(place.lock);
62
+ try {
63
+ const state = await readState(place.file);
64
+ const result = await operation(state);
65
+ if (result.changed) {
66
+ state.updated_at = new Date().toISOString();
67
+ await atomicWrite(place.file, state);
68
+ if (result.event) await writeFile(place.audit, `${JSON.stringify(result.event)}\n`, { flag: 'a' });
69
+ }
70
+ return result.output;
71
+ } finally { await rm(place.lock, { recursive: true, force: true }); }
72
+ }
73
+
74
+ function event(type, todo, extra = {}) {
75
+ return { version: 1, event_id: randomUUID(), type, todo_id: todo.id, at: new Date().toISOString(), owner: todo.claim?.owner ?? null, fencing_token: todo.claim?.fencing_token ?? null, ...extra };
76
+ }
77
+
78
+ function normalizeAgent(input) {
79
+ const authority = strings(input.authority_grants ?? input.authorityGrants, 'authority_grants');
80
+ return { id: id(input.id ?? input.agent_id, 'agent id'), capabilities: strings(input.capabilities, 'capabilities'), authority_grants: authority, quota_grants: { ...(input.quota_grants ?? input.quotaGrants ?? {}) }, registered_at: new Date().toISOString() };
81
+ }
82
+
83
+ export async function registerAgent(root, input) {
84
+ const agent = normalizeAgent(input);
85
+ return transaction(root, async (state) => {
86
+ const previous = state.agents[agent.id];
87
+ state.agents[agent.id] = { ...agent, registered_at: previous?.registered_at ?? agent.registered_at, updated_at: new Date().toISOString() };
88
+ return { changed: true, output: state.agents[agent.id], event: { version: 1, event_id: randomUUID(), type: previous ? 'agent_updated' : 'agent_registered', agent_id: agent.id, at: new Date().toISOString() } };
89
+ });
90
+ }
91
+
92
+ function normalizeTodo(input) {
93
+ const todoId = id(input.id, 'todo id');
94
+ const risk = input.risk ?? 'low';
95
+ if (!RISK.has(risk)) throw new Error(`Unsupported risk: ${risk}`);
96
+ const priority = Number(input.priority ?? 0);
97
+ const cost = Number(input.cost ?? input.cost_envelope?.amount ?? 0);
98
+ if (!Number.isFinite(priority) || !Number.isFinite(cost) || cost < 0) throw new Error('priority and cost must be valid numbers.');
99
+ const acceptance = input.acceptance_contract ?? input.acceptanceContract;
100
+ if (!acceptance || typeof acceptance !== 'object') throw new Error('acceptance_contract is required.');
101
+ const evidence = strings(input.evidence_requirements ?? input.evidenceRequirements, 'evidence_requirements');
102
+ if (!evidence.length) throw new Error('evidence_requirements must not be empty.');
103
+ const created = new Date().toISOString();
104
+ return {
105
+ version: 2, id: todoId, title: text(input.title, 'title'), project_id: input.project_id ?? input.projectId ?? null,
106
+ dependencies: strings(input.dependencies, 'dependencies'), priority, risk, authority_class: text(input.authority_class ?? input.authorityClass ?? 'local', 'authority_class'),
107
+ required_capabilities: strings(input.required_capabilities ?? input.requiredCapabilities, 'required_capabilities'), acceptance_contract: acceptance,
108
+ evidence_requirements: evidence, cost_envelope: { quota: input.quota ?? input.cost_envelope?.quota ?? 'default', amount: cost },
109
+ state: 'runnable', blocked_reasons: [], claim: null, handoff_id: null, parked: input.parked ?? null,
110
+ authorization: input.authorization ?? null, idempotency_keys: strings(input.idempotency_keys ?? input.idempotencyKeys, 'idempotency_keys'),
111
+ lineage: input.lineage ?? { root_todo_id: todoId, parent_todo_id: null }, context: input.context ?? {}, evidence: input.evidence ?? [],
112
+ created_at: created, updated_at: created
113
+ };
114
+ }
115
+
116
+ export async function createTodo(root, input) {
117
+ const todo = normalizeTodo(input);
118
+ return transaction(root, async (state) => {
119
+ if (state.todos[todo.id]) throw new Error(`Todo already exists: ${todo.id}`);
120
+ for (const dependency of todo.dependencies) if (dependency === todo.id) throw new Error('A todo cannot depend on itself.');
121
+ state.todos[todo.id] = todo;
122
+ return { changed: true, output: todo, event: event('todo_created', todo) };
123
+ });
124
+ }
125
+
126
+ function capabilityEligible(agent, todo) { return todo.required_capabilities.every((item) => agent.capabilities.includes(item)); }
127
+ function authorityEligible(agent, todo) { return agent.authority_grants.includes('*') || agent.authority_grants.includes(todo.authority_class); }
128
+
129
+ async function eligibility(root, state, todo, agent, now = Date.now()) {
130
+ const reasons = [];
131
+ if (!['runnable', 'blocked'].includes(todo.state)) reasons.push(`state:${todo.state}`);
132
+ if (todo.parked && !['runnable', 'resumed'].includes(todo.parked.state)) reasons.push('parked_human_gate');
133
+ const missing = todo.dependencies.filter((dep) => state.todos[dep]?.state !== 'completed');
134
+ if (missing.length) reasons.push(`dependencies:${missing.join(',')}`);
135
+ if (!capabilityEligible(agent, todo)) reasons.push('capability_mismatch');
136
+ if (!authorityEligible(agent, todo)) reasons.push('authority_mismatch');
137
+ const quota = todo.cost_envelope.quota;
138
+ const available = Number(agent.quota_grants?.[quota] ?? state.quotas?.[quota] ?? 0);
139
+ if (todo.cost_envelope.amount > available) reasons.push('quota_exhausted');
140
+ for (const key of todo.idempotency_keys) {
141
+ const action = await inspectAction(root, key);
142
+ if (action?.state === 'unknown') reasons.push(`action_reconciliation:${key}`);
143
+ if (action?.state === 'claimed' && Date.parse(action.claim?.lease_expires_at ?? '') <= now) reasons.push(`action_reconciliation:${key}`);
144
+ }
145
+ return { eligible: reasons.length === 0, reasons };
146
+ }
147
+
148
+ export async function listTodos(root, options = {}) {
149
+ const state = await readState(location(root).file);
150
+ return Object.values(state.todos).filter((todo) => !options.state || todo.state === options.state).sort((a, b) => b.priority - a.priority || a.created_at.localeCompare(b.created_at) || a.id.localeCompare(b.id));
151
+ }
152
+
153
+ export async function inspectTodo(root, todoId) { return (await readState(location(root).file)).todos[id(todoId, 'todo id')] ?? null; }
154
+
155
+ export async function claimTodo(root, input) {
156
+ const agentId = id(input.agent_id ?? input.agentId, 'agent id');
157
+ const leaseMs = Number(input.lease_ms ?? input.leaseMs ?? 60_000);
158
+ if (!Number.isInteger(leaseMs) || leaseMs <= 0) throw new Error('lease_ms must be a positive integer.');
159
+ return transaction(root, async (state) => {
160
+ const agent = state.agents[agentId];
161
+ if (!agent) throw new Error(`Agent not registered: ${agentId}`);
162
+ const candidates = input.todo_id ?? input.todoId ? [state.todos[id(input.todo_id ?? input.todoId, 'todo id')]].filter(Boolean) : Object.values(state.todos);
163
+ candidates.sort((a, b) => b.priority - a.priority || a.created_at.localeCompare(b.created_at) || a.id.localeCompare(b.id));
164
+ const rejected = [];
165
+ for (const todo of candidates) {
166
+ const check = await eligibility(root, state, todo, agent);
167
+ if (!check.eligible) { rejected.push({ todo_id: todo.id, reasons: check.reasons }); continue; }
168
+ const now = new Date();
169
+ const token = ++state.fencing_counter;
170
+ todo.state = 'claimed'; todo.blocked_reasons = []; todo.updated_at = now.toISOString();
171
+ todo.claim = { owner: agentId, fencing_token: token, claimed_at: now.toISOString(), lease_expires_at: new Date(now.getTime() + leaseMs).toISOString() };
172
+ const audit = event('todo_claimed', todo, { agent_id: agentId });
173
+ todo.ownership_events = [...(todo.ownership_events ?? []), audit];
174
+ return { changed: true, output: { claimed: true, todo, fencing_token: token }, event: audit };
175
+ }
176
+ return { changed: false, output: { claimed: false, reason: rejected[0]?.reasons?.[0] ?? 'no_eligible_todo', rejected } };
177
+ });
178
+ }
179
+
180
+ function assertClaim(todo, agentId, token) {
181
+ if (!todo || todo.state !== 'claimed' || todo.claim?.owner !== agentId || todo.claim?.fencing_token !== Number(token)) throw new Error('Stale or invalid todo fencing token.');
182
+ if (Date.parse(todo.claim.lease_expires_at) <= Date.now()) throw new Error('Todo lease has expired.');
183
+ }
184
+
185
+ export async function renewTodo(root, input) {
186
+ return transaction(root, async (state) => {
187
+ const todo = state.todos[id(input.todo_id ?? input.todoId, 'todo id')];
188
+ assertClaim(todo, id(input.agent_id ?? input.agentId, 'agent id'), input.fencing_token ?? input.fencingToken);
189
+ const leaseMs = Number(input.lease_ms ?? input.leaseMs ?? 60_000);
190
+ if (!Number.isInteger(leaseMs) || leaseMs <= 0) throw new Error('lease_ms must be a positive integer.');
191
+ todo.claim.lease_expires_at = new Date(Date.now() + leaseMs).toISOString(); todo.updated_at = new Date().toISOString();
192
+ const audit = event('todo_lease_renewed', todo);
193
+ todo.ownership_events.push(audit);
194
+ return { changed: true, output: todo, event: audit };
195
+ });
196
+ }
197
+
198
+ export async function releaseTodo(root, input) {
199
+ return transaction(root, async (state) => {
200
+ const todo = state.todos[id(input.todo_id ?? input.todoId, 'todo id')];
201
+ assertClaim(todo, id(input.agent_id ?? input.agentId, 'agent id'), input.fencing_token ?? input.fencingToken);
202
+ const previous = todo.claim; todo.state = input.completed ? 'completed' : 'runnable'; todo.claim = null; todo.updated_at = new Date().toISOString();
203
+ if (input.evidence) todo.evidence = [...todo.evidence, input.evidence];
204
+ const audit = event(input.completed ? 'todo_completed' : 'todo_released', todo, { previous_owner: previous.owner, previous_fencing_token: previous.fencing_token, reason: input.reason ?? null });
205
+ todo.ownership_events.push(audit);
206
+ return { changed: true, output: todo, event: audit };
207
+ });
208
+ }
209
+
210
+ export async function handoffTodo(root, input) {
211
+ return transaction(root, async (state) => {
212
+ const todo = state.todos[id(input.todo_id ?? input.todoId, 'todo id')];
213
+ const source = id(input.agent_id ?? input.agentId, 'agent id');
214
+ assertClaim(todo, source, input.fencing_token ?? input.fencingToken);
215
+ const target = id(input.target_agent_id ?? input.targetAgentId, 'target agent id');
216
+ if (!state.agents[target]) throw new Error(`Agent not registered: ${target}`);
217
+ const targetCheck = await eligibility(root, { ...state, todos: { ...state.todos, [todo.id]: { ...todo, state: 'runnable' } } }, { ...todo, state: 'runnable' }, state.agents[target]);
218
+ if (!targetCheck.eligible) throw new Error(`Target agent is ineligible: ${targetCheck.reasons.join(', ')}`);
219
+ const handoffId = id(input.handoff_id ?? input.handoffId ?? `handoff:${todo.id}:${state.fencing_counter + 1}`, 'handoff id');
220
+ const packet = { version: 1, id: handoffId, todo_id: todo.id, from_agent_id: source, to_agent_id: target, state: 'pending', created_at: new Date().toISOString(), lineage: todo.lineage, context: todo.context, evidence: todo.evidence, authorization: todo.authorization, idempotency_keys: todo.idempotency_keys, source_fencing_token: todo.claim.fencing_token };
221
+ state.handoffs[handoffId] = packet; todo.state = 'handoff_pending'; todo.handoff_id = handoffId;
222
+ const audit = event('handoff_created', todo, { handoff_id: handoffId, from_agent_id: source, to_agent_id: target }); todo.ownership_events.push(audit);
223
+ return { changed: true, output: packet, event: audit };
224
+ });
225
+ }
226
+
227
+ export async function decideHandoff(root, input) {
228
+ return transaction(root, async (state) => {
229
+ const packet = state.handoffs[id(input.handoff_id ?? input.handoffId, 'handoff id')];
230
+ if (!packet || packet.state !== 'pending') throw new Error('Pending handoff not found.');
231
+ const target = id(input.agent_id ?? input.agentId, 'agent id');
232
+ if (packet.to_agent_id !== target) throw new Error('Only the target agent can decide a handoff.');
233
+ const todo = state.todos[packet.todo_id];
234
+ if (input.accept) {
235
+ const check = await eligibility(root, { ...state, todos: { ...state.todos, [todo.id]: { ...todo, state: 'runnable' } } }, { ...todo, state: 'runnable' }, state.agents[target]);
236
+ if (!check.eligible) throw new Error(`Target agent is no longer eligible: ${check.reasons.join(', ')}`);
237
+ const token = ++state.fencing_counter; const now = new Date();
238
+ todo.state = 'claimed'; todo.claim = { owner: target, fencing_token: token, claimed_at: now.toISOString(), lease_expires_at: new Date(now.getTime() + Number(input.lease_ms ?? input.leaseMs ?? 60_000)).toISOString() };
239
+ packet.state = 'accepted'; packet.decided_at = now.toISOString(); packet.target_fencing_token = token;
240
+ } else {
241
+ packet.state = 'rejected'; packet.decided_at = new Date().toISOString(); packet.reason = input.reason ?? null;
242
+ if (todo.claim && Date.parse(todo.claim.lease_expires_at) > Date.now()) todo.state = 'claimed'; else { todo.state = 'runnable'; todo.claim = null; }
243
+ }
244
+ todo.handoff_id = null; todo.updated_at = new Date().toISOString();
245
+ const audit = event(input.accept ? 'handoff_accepted' : 'handoff_rejected', todo, { handoff_id: packet.id, from_agent_id: packet.from_agent_id, to_agent_id: target }); todo.ownership_events.push(audit);
246
+ return { changed: true, output: { packet, todo }, event: audit };
247
+ });
248
+ }
249
+
250
+ export async function recoverTodos(root, input = {}) {
251
+ return transaction(root, async (state) => {
252
+ const results = []; const now = Number(input.now ?? Date.now()); const audits = [];
253
+ for (const todo of Object.values(state.todos)) {
254
+ if (!['claimed', 'handoff_pending'].includes(todo.state) || !todo.claim || Date.parse(todo.claim.lease_expires_at) > now) continue;
255
+ if (todo.parked && !['runnable', 'resumed'].includes(todo.parked.state)) { results.push({ todo_id: todo.id, outcome: 'parked_not_recovered' }); continue; }
256
+ const actionBlocks = [];
257
+ for (const key of todo.idempotency_keys) { const action = await inspectAction(root, key); if (['claimed', 'unknown'].includes(action?.state)) actionBlocks.push(key); }
258
+ const previous = todo.claim;
259
+ if (actionBlocks.length) { todo.state = 'blocked'; todo.blocked_reasons = actionBlocks.map((key) => `action_reconciliation:${key}`); }
260
+ else { todo.state = 'runnable'; todo.blocked_reasons = []; }
261
+ todo.claim = null; todo.handoff_id = null; todo.updated_at = new Date(now).toISOString();
262
+ const audit = event('todo_orphan_recovered', todo, { previous_owner: previous.owner, previous_fencing_token: previous.fencing_token, outcome: todo.state }); todo.ownership_events.push(audit); audits.push(audit);
263
+ results.push({ todo_id: todo.id, outcome: todo.state, blocked_reasons: todo.blocked_reasons });
264
+ }
265
+ return { changed: results.length > 0, output: { recovered: results.length, results }, event: audits.length ? { version: 1, event_id: randomUUID(), type: 'recovery_batch', at: new Date().toISOString(), transitions: audits } : null };
266
+ });
267
+ }
268
+
269
+ export async function importLegacyTodos(root, input = {}) {
270
+ const queueRoot = path.join(root, 'runtime', 'loops'); const imported = [];
271
+ for (const queue of await readdir(queueRoot, { withFileTypes: true }).catch(() => [])) {
272
+ if (!queue.isDirectory() || queue.name === 'control-plane' || queue.name === 'action-reservations') continue;
273
+ for (const subdir of ['inbox', 'waiting', 'active']) {
274
+ const dir = path.join(queueRoot, queue.name, subdir);
275
+ for (const file of await readdir(dir).catch(() => [])) {
276
+ if (!file.endsWith('.json')) continue;
277
+ const legacy = JSON.parse(await readFile(path.join(dir, file), 'utf8'));
278
+ const legacyId = legacy.id ?? path.basename(file, '.json');
279
+ try {
280
+ const todo = await createTodo(root, { id: `legacy:${queue.name}:${legacyId}`, title: legacy.title ?? legacy.goal ?? legacyId, project_id: legacy.projectId ?? null, priority: legacy.priority ?? 0, risk: legacy.risk ?? 'medium', authority_class: legacy.authorityClass ?? 'local', required_capabilities: legacy.requiredCapabilities ?? ['loop-task'], dependencies: legacy.dependencies ?? [], acceptance_contract: legacy.acceptance_contract ?? { source: 'legacy', checks: legacy.checks ?? [] }, evidence_requirements: legacy.evidence_requirements ?? ['legacy-task-result'], quota: 'default', cost: 0, parked: subdir === 'waiting' ? (legacy.parked ?? { state: 'waiting_for_human' }) : null, context: { legacy_queue: queue.name, legacy_file: path.relative(root, path.join(dir, file)) }, lineage: legacy.lineage });
281
+ imported.push(todo.id);
282
+ } catch (error) { if (!String(error.message).startsWith('Todo already exists:')) throw error; }
283
+ }
284
+ }
285
+ }
286
+ return { imported: imported.length, todo_ids: imported };
287
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskforce-loop-engineering",
3
- "version": "0.9.1",
3
+ "version": "0.12.0",
4
4
  "private": false,
5
5
  "description": "OpenClaw-native loop engineering CLI, templates, and skill for verifiable agent work loops.",
6
6
  "type": "module",
@@ -20,7 +20,7 @@
20
20
  "check:config-drift": "node --check scripts/config-drift-self-test.mjs && node scripts/config-drift-self-test.mjs",
21
21
  "check:openclaw-install": "node --check scripts/openclaw-install.mjs && node --check scripts/openclaw-doctor.mjs && node --check scripts/openclaw-smoke.mjs && node --check scripts/openclaw-manage.mjs && node scripts/openclaw-install-self-test.mjs",
22
22
  "check:hermes-install": "node --check scripts/hermes-install.mjs && node --check scripts/hermes-doctor.mjs && node --check scripts/hermes-smoke.mjs && node scripts/hermes-install-self-test.mjs",
23
- "check": "npm run check:config-drift && npm run check:openclaw-install && npm run check:hermes-install && node --check bin/loop-engineering.mjs && node --check lib/core.mjs && node scripts/final-judgement-self-test.mjs && node scripts/route-notify-self-test.mjs && node scripts/scheduler-heartbeat-self-test.mjs && node bin/loop-engineering.mjs verify --config templates/workspace-health.json --root . && node bin/loop-engineering.mjs queue-status --queue smoke --root . --json >/dev/null && node bin/loop-engineering.mjs project-intake --root /tmp/loop-engineering-check --name smoke-project --brief \"Build a small website project\" --type auto --check \"npm test\" --json >/dev/null && node bin/loop-engineering.mjs project-plan --root /tmp/loop-engineering-check --project smoke-project --force --json >/dev/null && node bin/loop-engineering.mjs project-status --root /tmp/loop-engineering-check --project smoke-project --json >/dev/null && node bin/loop-engineering.mjs queue-scheduler-tick --queue smoke --root /tmp/loop-engineering-check --plan-only --force-due --json >/dev/null && node bin/loop-engineering.mjs queue-scheduler-tick --queue smoke-progress --root /tmp/loop-engineering-check --plan-only --force-due --progress-report --progress-report-when-not-due --json >/dev/null && node bin/loop-engineering.mjs workflow-metrics --queue smoke --root . --json >/dev/null && node bin/loop-engineering.mjs workflow-tune-plan --queue smoke --root . --json >/dev/null && node bin/loop-engineering.mjs code-queue-init --queue smoke-code --root /tmp/loop-engineering-check --force >/dev/null && node bin/loop-engineering.mjs code-worktree-list --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-task-status --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-task-dashboard --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-worktree-cleanup-plan --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-worktree-cleanup --queue smoke-code --root /tmp/loop-engineering-check --confirm-cleanup --json >/dev/null && node bin/loop-engineering.mjs code-patch-verify --patch README.md --root . --json >/dev/null && node bin/loop-engineering.mjs code-patch-apply-plan --patch README.md --json >/dev/null && node bin/loop-engineering.mjs queue-revision-ci-self-test --queue smoke-ci --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs summarize --root . --json >/dev/null && node bin/loop-engineering.mjs doctor --root . --json >/dev/null",
23
+ "check": "npm run check:config-drift && npm run check:openclaw-install && npm run check:hermes-install && node --check bin/loop-engineering.mjs && node --check lib/core.mjs && node --check lib/action-reservations.mjs && node --check lib/todo-control-plane.mjs && node --check lib/operator-dashboard.mjs && node scripts/action-reservation-self-test.mjs && node scripts/todo-control-plane-self-test.mjs && node scripts/operator-dashboard-self-test.mjs && node scripts/final-judgement-self-test.mjs && node scripts/route-notify-self-test.mjs && node scripts/scheduler-heartbeat-self-test.mjs && node scripts/human-gate-lifecycle-v2-self-test.mjs && node bin/loop-engineering.mjs verify --config templates/workspace-health.json --root . && node bin/loop-engineering.mjs queue-status --queue smoke --root . --json >/dev/null && node bin/loop-engineering.mjs project-intake --root /tmp/loop-engineering-check --name smoke-project --brief \"Build a small website project\" --type auto --check \"npm test\" --json >/dev/null && node bin/loop-engineering.mjs project-plan --root /tmp/loop-engineering-check --project smoke-project --force --json >/dev/null && node bin/loop-engineering.mjs project-status --root /tmp/loop-engineering-check --project smoke-project --json >/dev/null && node bin/loop-engineering.mjs queue-scheduler-tick --queue smoke --root /tmp/loop-engineering-check --plan-only --force-due --json >/dev/null && node bin/loop-engineering.mjs queue-scheduler-tick --queue smoke-progress --root /tmp/loop-engineering-check --plan-only --force-due --progress-report --progress-report-when-not-due --json >/dev/null && node bin/loop-engineering.mjs workflow-metrics --queue smoke --root . --json >/dev/null && node bin/loop-engineering.mjs workflow-tune-plan --queue smoke --root . --json >/dev/null && node bin/loop-engineering.mjs code-queue-init --queue smoke-code --root /tmp/loop-engineering-check --force >/dev/null && node bin/loop-engineering.mjs code-worktree-list --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-task-status --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-task-dashboard --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-worktree-cleanup-plan --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-worktree-cleanup --queue smoke-code --root /tmp/loop-engineering-check --confirm-cleanup --json >/dev/null && node bin/loop-engineering.mjs code-patch-verify --patch README.md --root . --json >/dev/null && node bin/loop-engineering.mjs code-patch-apply-plan --patch README.md --json >/dev/null && node bin/loop-engineering.mjs queue-revision-ci-self-test --queue smoke-ci --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs summarize --root . --json >/dev/null && node bin/loop-engineering.mjs dashboard-health --root . --max-age-seconds 999999999 --json >/dev/null && node bin/loop-engineering.mjs doctor --root . --json >/dev/null",
24
24
  "pack:dry": "npm pack --dry-run"
25
25
  },
26
26
  "engines": {
@@ -31,6 +31,7 @@
31
31
  "MIGRATING.md",
32
32
  "README.md",
33
33
  "bin/",
34
+ "docs/",
34
35
  "lib/",
35
36
  "scripts/",
36
37
  "templates/",
@@ -0,0 +1,65 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtemp } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import {
6
+ actionAdapters,
7
+ claimAction,
8
+ inspectAction,
9
+ markActionUnknown,
10
+ migrateLegacyActionArtifact,
11
+ reconcileAction,
12
+ releaseAction,
13
+ reserveAction,
14
+ settleAction
15
+ } from '../lib/action-reservations.mjs';
16
+
17
+ const root = await mkdtemp(path.join(tmpdir(), 'loop-action-reservation-'));
18
+ const base = { idempotencyKey: 'paid:task-1:step-1', kind: 'paid_api', authorizationScope: 'approval:task-1:provider-call', request: { model: 'mock', promptHash: 'abc', cents: 4 } };
19
+
20
+ const reserved = await reserveAction(root, base);
21
+ assert.equal(reserved.created, true);
22
+ assert.equal((await reserveAction(root, base)).duplicate, true);
23
+ await assert.rejects(() => reserveAction(root, { ...base, request: { ...base.request, cents: 5 } }), /different request/);
24
+
25
+ // Concurrent workers get one atomic lease and one fencing token.
26
+ const claims = await Promise.all(Array.from({ length: 12 }, (_, i) => claimAction(root, { idempotencyKey: base.idempotencyKey, owner: `worker-${i}`, leaseMs: 1000 })));
27
+ assert.equal(claims.filter((item) => item.claimed).length, 1);
28
+ const winner = claims.find((item) => item.claimed);
29
+ await assert.rejects(() => settleAction(root, { idempotencyKey: base.idempotencyKey, fencingToken: winner.fencingToken + 1 }), /fencing token/);
30
+ assert.equal((await settleAction(root, { idempotencyKey: base.idempotencyKey, fencingToken: winner.fencingToken, evidence: { upstreamId: 'mock-1' } })).settled, true);
31
+ assert.equal((await settleAction(root, { idempotencyKey: base.idempotencyKey, fencingToken: winner.fencingToken })).duplicate, true);
32
+ assert.equal((await inspectAction(root, base.idempotencyKey)).authorization.state, 'consumed');
33
+ await assert.rejects(() => releaseAction(root, { idempotencyKey: base.idempotencyKey, reason: 'late release' }), /cannot be released/);
34
+
35
+ // Crash before send: an expired claim becomes unknown, never blindly claimable.
36
+ const beforeSend = { ...base, idempotencyKey: 'paid:crash-before-send' };
37
+ await reserveAction(root, beforeSend);
38
+ await claimAction(root, { idempotencyKey: beforeSend.idempotencyKey, owner: 'crashed', leaseMs: 1 });
39
+ await new Promise((resolve) => setTimeout(resolve, 5));
40
+ assert.equal((await claimAction(root, { idempotencyKey: beforeSend.idempotencyKey, owner: 'recovery', leaseMs: 10 })).reason, 'reconcile_required');
41
+ await reconcileAction(root, { idempotencyKey: beforeSend.idempotencyKey, outcome: 'not_accepted', evidence: { local: 'adapter_not_invoked' } });
42
+ assert.equal((await claimAction(root, { idempotencyKey: beforeSend.idempotencyKey, owner: 'recovery', leaseMs: 100 })).claimed, true);
43
+
44
+ // Crash after upstream acceptance: reconciliation settles without another send/charge.
45
+ const afterAcceptance = { ...base, idempotencyKey: 'paid:crash-after-acceptance' };
46
+ await reserveAction(root, afterAcceptance);
47
+ const afterClaim = await claimAction(root, { idempotencyKey: afterAcceptance.idempotencyKey, owner: 'worker', leaseMs: 100 });
48
+ await markActionUnknown(root, { idempotencyKey: afterAcceptance.idempotencyKey, fencingToken: afterClaim.fencingToken, reason: 'accepted_before_local_commit' });
49
+ await reconcileAction(root, { idempotencyKey: afterAcceptance.idempotencyKey, outcome: 'accepted', evidence: { upstreamId: 'mock-accepted' } });
50
+ assert.equal((await claimAction(root, { idempotencyKey: afterAcceptance.idempotencyKey, owner: 'retry' })).reason, 'settled');
51
+
52
+ // Notification adapter suppresses duplicates, and an unused reservation can release authorization.
53
+ await actionAdapters.notification.reserve(root, { idempotencyKey: 'notify:task-1:terminal', authorizationScope: 'task-1:source-chat', request: { target: 'mock-chat', digest: 'done' } });
54
+ const notifyDuplicate = await actionAdapters.notification.reserve(root, { idempotencyKey: 'notify:task-1:terminal', authorizationScope: 'task-1:source-chat', request: { target: 'mock-chat', digest: 'done' } });
55
+ assert.equal(notifyDuplicate.duplicate, true);
56
+ const releasable = { ...base, idempotencyKey: 'deploy:cancelled', kind: 'deployment', authorizationScope: 'approval:deploy-staging' };
57
+ await reserveAction(root, releasable);
58
+ assert.equal((await releaseAction(root, { idempotencyKey: releasable.idempotencyKey, reason: 'operator_cancelled', evidence: { ticket: 'mock' } })).record.authorization.state, 'released');
59
+
60
+ // Legacy artifacts are imported without changing their logical identity.
61
+ const migrated = await migrateLegacyActionArtifact(root, { idempotency_key: 'legacy:notification:1', kind: 'notification', authorization_scope: 'legacy:chat', request: { digest: 'old' } });
62
+ assert.equal(migrated.created, true);
63
+ assert.equal((await migrateLegacyActionArtifact(root, { idempotency_key: 'legacy:notification:1', kind: 'notification', authorization_scope: 'legacy:chat', request: { digest: 'old' } })).duplicate, true);
64
+
65
+ console.log(JSON.stringify({ status: 'ok', assertions: 'reservation, fingerprint, concurrency, fencing, crash recovery, reconciliation, authorization, release, adapters, migration' }));
@@ -31,6 +31,8 @@ const plan = await run(process.execPath, [installer, ...base, '--json']); const
31
31
  const humanPlan = await run(process.execPath, [installer, ...base]); if (humanPlan.code !== 0 || !humanPlan.stdout.includes('Installation confirmation') || !humanPlan.stdout.includes('target platform: Hermes') || !humanPlan.stdout.includes('writes enabled: no (plan only)')) throw new Error('human-readable Hermes confirmation summary missing');
32
32
  const install = await run(process.execPath, [installer, ...base, '--confirm-install', '--json']); if (install.code !== 0 || JSON.parse(install.stdout).status !== 'installed') throw new Error(`Hermes install failed: ${install.stderr}`);
33
33
  const queue = JSON.parse(await readFile(path.join(root, 'configs/loops/queues/hermes-tasks.json'), 'utf8')); if (queue.dispatcher !== 'node scripts/loops/hermes-loop-dispatch.mjs' || queue.scheduler?.required !== true) throw new Error('Hermes queue wiring missing');
34
+ const instructions = await readFile(path.join(root, 'AGENTS.md'), 'utf8'); if (!instructions.includes('Use Loop Engineering to fix this issue') || !instructions.includes('Queue this only; do not run it yet') || /走 loop|只入队|只排队/.test(instructions)) throw new Error('Hermes English conversation instructions are missing or contain Chinese routing examples');
35
+ const wrapper = await readFile(path.join(root, 'scripts/loops/hermes-loop.mjs'), 'utf8'); if (!wrapper.includes('queue') || !wrapper.includes('continue') || /只入队|只排队|继续当前/.test(wrapper)) throw new Error('Hermes English wrapper routing was not fully localized');
34
36
  const dispatcher = await readFile(path.join(root, 'scripts/loops/hermes-loop-dispatch.mjs'), 'utf8'); if (!dispatcher.includes("'-z', prompt")) throw new Error('Hermes one-shot dispatcher missing');
35
37
  const notifier = path.join(root, 'scripts/loops/hermes-loop-notify.mjs'); const notify = await run(process.execPath, [notifier, 'hello from loop'], { env: { ...process.env, HERMES_SEND_CAPTURE: sendCapture, LOOP_NOTIFICATION_SOURCE: JSON.stringify({ channel: 'telegram', target: 'telegram:12345' }) } }); if (notify.code !== 0) throw new Error(`Hermes notifier failed: ${notify.stderr}`);
36
38
  const sent = JSON.parse(await readFile(sendCapture, 'utf8')); if (!sent.includes('telegram:12345') || !sent.includes('hello from loop')) throw new Error('Hermes notifier did not preserve delivery target/message');