taskforce-loop-engineering 0.15.12 → 0.15.14
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/CHANGELOG.md +13 -0
- package/README.md +1 -1
- package/bin/loop-engineering.mjs +15 -2
- package/docs/agent-team-backlog.json +1 -0
- package/docs/agent-team-terminal-contract.json +1 -0
- package/docs/human-gate-command.md +17 -0
- package/docs/multi-agent-control-plane.md +8 -0
- package/docs/operator-dashboard.md +25 -2
- package/docs/operator-workspace-project.md +30 -0
- package/docs/quota-runtime-decision.md +9 -0
- package/lib/human-gate-channel-adapter.mjs +37 -0
- package/lib/human-gate-command.mjs +161 -0
- package/lib/operator-dashboard.mjs +75 -8
- package/lib/quota-runtime-decision.mjs +62 -0
- package/lib/todo-control-plane.mjs +105 -6
- package/package.json +21 -6
- package/scripts/agent-team-control-plane-self-test.mjs +29 -0
- package/scripts/agent-team-final-judgement.mjs +27 -0
- package/scripts/dashboard-autostart-install.mjs +91 -0
- package/scripts/dashboard-autostart-self-test.mjs +46 -0
- package/scripts/distribution-skill-self-test.mjs +13 -3
- package/scripts/hermes-doctor.mjs +2 -1
- package/scripts/hermes-install-self-test.mjs +2 -1
- package/scripts/hermes-install.mjs +11 -4
- package/scripts/human-gate-command-self-test.mjs +52 -0
- package/scripts/human-gate-final-judgement.mjs +30 -0
- package/scripts/live-agent-team-conformance.mjs +61 -0
- package/scripts/openclaw-doctor.mjs +13 -0
- package/scripts/openclaw-install-self-test.mjs +8 -3
- package/scripts/openclaw-install.mjs +48 -6
- package/scripts/openclaw-smoke.mjs +4 -0
- package/scripts/operator-dashboard-self-test.mjs +27 -1
- package/scripts/operator-workspace-final-judgement.mjs +41 -0
- package/scripts/quota-runtime-decision-self-test.mjs +29 -0
- package/scripts/todo-control-plane-self-test.mjs +4 -1
- package/skills/taskforce-loop-engineering/SKILL.md +12 -0
- package/templates/operator-projection.schema.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
2
|
import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { executeGateCommand, listHumanGates } from './human-gate-command.mjs';
|
|
4
5
|
|
|
5
6
|
export const DASHBOARD_SCHEMA_VERSION = '1.0.0';
|
|
6
7
|
const SENSITIVE = /(^|_)(secret|token|password|credential|api[_-]?key|private[_-]?key|provider)(_|$)/i;
|
|
@@ -142,8 +143,51 @@ async function projects(root, warnings) {
|
|
|
142
143
|
const base = path.join(runtime, id);
|
|
143
144
|
const intake = await json(path.join(base, 'intake', 'latest.json'), warnings, root);
|
|
144
145
|
const backlog = await json(path.join(base, 'backlog', 'initial.json'), warnings, root);
|
|
146
|
+
const terminalContract = await json(path.join(base, 'terminal-contract.json'), warnings, root);
|
|
145
147
|
const completion = await json(path.join(base, 'completion', 'latest.json'), warnings, root);
|
|
146
|
-
|
|
148
|
+
const milestones = (backlog?.tasks ?? backlog?.items ?? (Array.isArray(backlog) ? backlog : [])).map((item, index) => ({
|
|
149
|
+
id: item.id ?? `milestone-${index + 1}`, title: item.title ?? item.task ?? `Milestone ${index + 1}`,
|
|
150
|
+
status: item.status ?? 'pending', acceptance: item.acceptance ?? [], evidence: item.evidence ?? []
|
|
151
|
+
}));
|
|
152
|
+
const accepted = completion?.terminalAccepted === true || terminalContract?.terminalState?.accepted === true;
|
|
153
|
+
result.push(clean({
|
|
154
|
+
id, goal: intake?.goal ?? intake?.brief ?? null, brief: intake?.brief ?? null, queue: intake?.queue ?? null,
|
|
155
|
+
status: accepted ? 'completed' : completion?.status ?? terminalContract?.status ?? 'active', terminal_accepted: accepted,
|
|
156
|
+
project_summary: { completed_milestones: milestones.filter((item) => ['completed', 'accepted'].includes(item.status)).length, total_milestones: milestones.length, unmet: completion?.unmet ?? terminalContract?.unmet ?? [], blockers: completion?.blockers ?? terminalContract?.blockers ?? [] },
|
|
157
|
+
terminal_contract: terminalContract ? { status: terminalContract.status ?? null, terminal_state: terminalContract.terminalState ?? null, milestone_rule: terminalContract.milestoneRule ?? null, completion_rule: terminalContract.completionRule ?? null, requirements: terminalContract.requirements ?? [], residual_risks: terminalContract.residualRisks ?? [] } : null,
|
|
158
|
+
acceptance: intake?.acceptance ?? null, milestones, completion: completion ?? null,
|
|
159
|
+
evidence_links: [intake && relativeLink(root, path.join(base, 'intake', 'latest.json')), backlog && relativeLink(root, path.join(base, 'backlog', 'initial.json')), terminalContract && relativeLink(root, path.join(base, 'terminal-contract.json')), completion && relativeLink(root, path.join(base, 'completion', 'latest.json'))].filter(Boolean)
|
|
160
|
+
}));
|
|
161
|
+
}
|
|
162
|
+
return result.sort((a, b) => a.id.localeCompare(b.id));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function taskWorkspaces(root, warnings) {
|
|
166
|
+
const loops = path.join(root, 'runtime', 'loops'); const result = [];
|
|
167
|
+
for (const queue of (await dirs(loops)).filter((name) => !['control-plane', 'action-reservations', 'execution-ledger', 'projects'].includes(name))) {
|
|
168
|
+
const tasksRoot = path.join(loops, queue, 'tasks');
|
|
169
|
+
for (const taskId of await dirs(tasksRoot)) {
|
|
170
|
+
const base = path.join(tasksRoot, taskId);
|
|
171
|
+
const [contract, finalJudgement, humanContext, amendment] = await Promise.all([
|
|
172
|
+
json(path.join(base, 'task_contract.json'), warnings, root), json(path.join(base, 'final_judgement.json'), warnings, root),
|
|
173
|
+
json(path.join(base, 'human_input_context.json'), warnings, root), json(path.join(base, 'amendments', 'latest.json'), warnings, root)
|
|
174
|
+
]);
|
|
175
|
+
const checkpoints = []; const reviews = [];
|
|
176
|
+
for (const file of await files(path.join(base, 'checkpoints'))) { const item = await json(file, warnings, root); if (item) checkpoints.push({ ...clean(item), evidence_link: relativeLink(root, file) }); }
|
|
177
|
+
for (const file of await files(path.join(base, 'reviews'))) { const item = await json(file, warnings, root); if (item) reviews.push({ ...clean(item), evidence_link: relativeLink(root, file) }); }
|
|
178
|
+
if (!contract && !checkpoints.length && !finalJudgement) continue;
|
|
179
|
+
const projectIds = [...new Set(checkpoints.map((item) => item.project_id).filter(Boolean))];
|
|
180
|
+
const timeline = [
|
|
181
|
+
...checkpoints.map((item) => ({ type: 'checkpoint', at: item.created_at ?? null, id: item.checkpoint_id, status: item.status, summary: item.summary, revision_of: item.revises_checkpoint_id ?? null, amendment_version: item.amendment_version ?? 0, evidence_link: item.evidence_link })),
|
|
182
|
+
...reviews.map((item) => {
|
|
183
|
+
const failed = Array.isArray(item.failed) ? item.failed : item.failed == null ? [] : [item.failed];
|
|
184
|
+
const passed = Array.isArray(item.passed) ? item.passed : item.passed == null ? [] : [item.passed];
|
|
185
|
+
return { type: 'acceptance_review', at: item.created_at ?? null, id: item.checkpoint_id, status: item.status, summary: failed.map(String).join('; ') || passed.slice(0, 2).map(String).join('; '), evidence_link: item.evidence_link };
|
|
186
|
+
}),
|
|
187
|
+
...(finalJudgement ? [{ type: 'final_judge', at: finalJudgement.created_at ?? null, id: 'final_judgement', status: finalJudgement.outcome, summary: (finalJudgement.reasons ?? []).join('; '), evidence_link: relativeLink(root, path.join(base, 'final_judgement.json')) }] : [])
|
|
188
|
+
].sort((a, b) => String(a.at ?? '').localeCompare(String(b.at ?? '')) || a.type.localeCompare(b.type));
|
|
189
|
+
result.push(clean({ id: taskId, queue, title: contract?.title ?? taskId, scope: contract?.task_scope ?? null, project_ids: projectIds, amendment_version: amendment?.version ?? amendment?.amendment_version ?? 0, final_judgement: finalJudgement?.outcome ?? null, gates: humanContext?.gates ?? [], revision_lineage: checkpoints.map((item) => ({ checkpoint_id: item.checkpoint_id, milestone_id: item.milestone_id ?? item.checkpoint_id, revises_checkpoint_id: item.revises_checkpoint_id ?? null, sequence: item.sequence ?? null, status: item.status })), timeline, evidence_links: [relativeLink(root, path.join(base, 'task_contract.json'))].filter(Boolean) }));
|
|
190
|
+
}
|
|
147
191
|
}
|
|
148
192
|
return result.sort((a, b) => a.id.localeCompare(b.id));
|
|
149
193
|
}
|
|
@@ -152,13 +196,14 @@ export async function buildOperatorProjection(root, options = {}) {
|
|
|
152
196
|
const resolved = path.resolve(root); const now = options.now ? new Date(options.now) : new Date();
|
|
153
197
|
if (Number.isNaN(now.getTime())) throw new Error('Invalid projection time.');
|
|
154
198
|
const warnings = []; const before = await stat(path.join(resolved, 'runtime', 'loops')).catch(() => null);
|
|
155
|
-
const [control, reservations, steps, queueList, projectList, trustEvidence] = await Promise.all([controlPlane(resolved, warnings, now.getTime()), actions(resolved, warnings, now.getTime()), executionSteps(resolved, warnings, now.getTime()), legacyQueues(resolved, warnings, now.getTime()), projects(resolved, warnings), productionEvidence(resolved, warnings)]);
|
|
199
|
+
const [control, reservations, steps, queueList, projectList, taskList, trustEvidence] = await Promise.all([controlPlane(resolved, warnings, now.getTime()), actions(resolved, warnings, now.getTime()), executionSteps(resolved, warnings, now.getTime()), legacyQueues(resolved, warnings, now.getTime()), projects(resolved, warnings), taskWorkspaces(resolved, warnings), productionEvidence(resolved, warnings)]);
|
|
156
200
|
const newest = [control.updated_at, ...control.todos.map((item) => item.updated_at), ...reservations.map((item) => item.updated_at), ...steps.map((item) => item.updated_at), ...queueList.flatMap((queue) => queue.tasks.map((item) => item.updated_at))].filter(Boolean).sort().at(-1) ?? null;
|
|
157
201
|
const counts = Object.fromEntries([...STATES].map((state) => [state, 0]));
|
|
158
202
|
for (const item of [...control.todos, ...queueList.flatMap((queue) => queue.tasks)]) counts[item.state] += 1;
|
|
159
203
|
const after = await stat(path.join(resolved, 'runtime', 'loops')).catch(() => null);
|
|
160
204
|
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.' });
|
|
161
|
-
|
|
205
|
+
const projectsWithTasks = projectList.map((project) => ({ ...project, tasks: taskList.filter((task) => task.project_ids.includes(project.id)) }));
|
|
206
|
+
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, task_workspace_count: taskList.length, todo_count: control.todos.length, action_count: reservations.length, step_count: steps.length, reconciliation_required_steps: steps.filter((item) => item.state === 'reconciliation_required').length, production_trust: trustEvidence?.passed ? 'passed' : trustEvidence ? 'failed' : 'not_generated' }, projects: projectsWithTasks, task_workspaces: taskList, 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 })), ...taskList.flatMap((task) => task.gates.map((gate) => ({ task_id: task.id, ...gate })))], actions: reservations, execution_steps: steps, production_evidence: trustEvidence, cost: { quotas: control.quotas, requested_total: control.todos.reduce((sum, item) => sum + Number(item.cost?.amount ?? 0), 0) } });
|
|
162
207
|
}
|
|
163
208
|
|
|
164
209
|
export function filterProjection(projection, options = {}) {
|
|
@@ -173,8 +218,20 @@ export function dashboardHealth(projection, options = {}) {
|
|
|
173
218
|
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 };
|
|
174
219
|
}
|
|
175
220
|
|
|
176
|
-
function
|
|
177
|
-
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Loop Engineering
|
|
221
|
+
function legacyHtml(dataUrl = '/api/v1/overview?') {
|
|
222
|
+
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Loop Engineering Workspace</title><style>:root{color-scheme:dark;--bg:#091018;--panel:#111c28;--line:#26384b;--muted:#91a5b8;--accent:#77d6c9}*{box-sizing:border-box}body{font:14px/1.5 ui-sans-serif,system-ui;margin:0;background:var(--bg);color:#eef6fb}header{position:sticky;top:0;z-index:2;padding:1rem clamp(1rem,4vw,3rem);background:#091018ee;border-bottom:1px solid var(--line);backdrop-filter:blur(12px)}h1,h2,h3,p{margin:.2rem 0}.eyebrow,.muted{color:var(--muted)}main{padding:1.25rem clamp(1rem,4vw,3rem);display:grid;gap:1rem}.stats,.projects,.split{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:.8rem}.card{background:linear-gradient(150deg,#142230,#0d1822);border:1px solid var(--line);border-radius:14px;padding:1rem;min-width:0}.stat strong{font-size:1.65rem}.bar{height:7px;background:#223141;border-radius:9px;overflow:hidden;margin:.75rem 0}.bar i{display:block;height:100%;background:var(--accent)}.pill{display:inline-block;padding:.15rem .5rem;border-radius:1rem;background:#24384a;color:#d9edf8}.ok{color:#7ee2a8}.warn{color:#ffcf70}button,input,select{padding:.65rem;background:#111d29;color:inherit;border:1px solid #3a5268;border-radius:8px}button.project{width:100%;text-align:left;font:inherit}button.project[aria-pressed=true]{border-color:var(--accent)}table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:.6rem;border-bottom:1px solid var(--line);vertical-align:top}.scroll{overflow:auto}.timeline{border-left:2px solid var(--line);padding-left:1rem}.event{margin:.65rem 0}.project{cursor:pointer}.project:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid var(--accent);outline-offset:2px}#detail:empty{display:none}.empty{padding:1rem;text-align:center;color:var(--muted)}@media(max-width:640px){header{position:static}.desktop{display:none}td,th{min-width:120px}.split{grid-template-columns:1fr}input,select{width:100%;margin-top:.4rem}}</style></head><body><header><div class="eyebrow">READ-ONLY OPERATOR WORKSPACE</div><h1>Loop Engineering</h1><p id="health" role="status" aria-live="polite">Loading durable artifacts…</p></header><main><section class="stats" id="stats" aria-label="Workspace summary"></section><section aria-labelledby="projects-title"><h2 id="projects-title">Projects</h2><p class="muted">Project completion is separate from milestone completion.</p><div class="projects" id="projects"></div></section><section class="card" id="detail" tabindex="-1" aria-live="polite"></section><section class="card"><div class="split"><div><h2>Operational queue</h2><p class="muted">Human gates, reservations and reconciliation remain visible and immutable here.</p></div><div><label>Search <input id="q" placeholder="Task, owner or action"></label><label>State <select id="s"><option value="">All states</option></select></label></div></div><div class="scroll"><table><thead><tr><th>State</th><th>Task</th><th class="desktop">Owner</th><th>Next action</th></tr></thead><tbody id="rows"></tbody></table></div></section></main><script>const dataUrl=${JSON.stringify(dataUrl)},initial=new URLSearchParams(location.search);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('');q.value=initial.get('q')??'';s.value=initial.get('state')??'';let selected=initial.get('project'),model=null;const el=(tag,text,cls)=>{const n=document.createElement(tag);n.textContent=text??'';if(cls)n.className=cls;return n};function syncUrl(){if(dataUrl.startsWith('./'))return;const p=new URLSearchParams();if(q.value)p.set('q',q.value);if(s.value)p.set('state',s.value);if(selected)p.set('project',selected);history.replaceState(null,'','?'+p)}function projectDetail(p,focus=false){selected=p.id;detail.replaceChildren();detail.append(el('h2',p.id),el('p',p.goal,'muted'));const c=p.terminal_contract;if(c){detail.append(el('h3','Terminal contract'),el('p',c.terminal_state?.userVisibleOutcome??'No user-visible outcome recorded'),el('p',c.milestone_rule??'','warn'))}detail.append(el('h3','Milestones'));const list=el('div');for(const m of p.milestones??[])list.append(el('p',(m.status??'pending')+' · '+m.title));detail.append(list,el('h3','Gates & reservations'));const operational=el('div');const gates=(p.tasks??[]).flatMap(x=>x.gates??[]);operational.append(el('p',(gates.length?gates.length+' human/external gate(s)':'No project gates')+' · '+model.actions.length+' workspace reservation(s)','muted'));detail.append(operational,el('h3','Acceptance & final judge timeline'));const tl=el('div',null,'timeline');const events=(p.tasks??[]).flatMap(x=>x.timeline??[]);if(!events.length)tl.append(el('p','No acceptance events yet.','empty'));for(const t of events)tl.append(el('div',(t.at??'undated')+' · '+t.type+' · '+t.status+' — '+(t.summary??''),'event'));detail.append(tl);syncUrl();drawProjects();if(focus)detail.focus()}function drawProjects(){projects.replaceChildren(...model.projects.map(p=>{const n=el('button',null,'card project');n.type='button';n.setAttribute('aria-pressed',String(selected===p.id));const done=p.project_summary?.completed_milestones??0,total=p.project_summary?.total_milestones??0;n.append(el('span',p.terminal_accepted?'PROJECT ACCEPTED':String(p.status).toUpperCase(),p.terminal_accepted?'pill ok':'pill'),el('h3',p.id),el('p',p.goal,'muted'));const b=el('div',null,'bar'),i=el('i');i.style.width=(total?done/total*100:0)+'%';b.append(i);n.append(b,el('p',done+' / '+total+' milestones'));n.onclick=()=>projectDetail(p,true);return n}));if(!model.projects.length)projects.append(el('p','No project artifacts found.','card empty'))}async function draw(){try{const params=new URLSearchParams({q:q.value,state:s.value});const response=await fetch(dataUrl+(dataUrl.includes('?')?params:''));if(!response.ok)throw new Error('HTTP '+response.status);model=await response.json();health.textContent=model.health.status+' · generated '+new Date(model.generated_at).toLocaleString()+' · source is read-only';stats.replaceChildren(...[['Projects',model.overview.project_count],['Task workspaces',model.overview.task_workspace_count],['Human gates',model.gates.length],['Reservations',model.overview.action_count]].map(([k,v])=>{const n=el('div',null,'card stat');n.append(el('div',k,'muted'),el('strong',v));return n}));drawProjects();const all=[...model.todos,...model.queues.flatMap(x=>x.tasks)];rows.replaceChildren(...all.map(x=>{const tr=el('tr');for(const [v,c] of [[x.state,''],[x.title,''],[x.owner??'—','desktop'],[x.next_action??'—','']])tr.append(el('td',String(v),c));return tr}));if(!all.length){const td=el('td','No matching operational work.','empty');td.colSpan=4;const tr=el('tr');tr.append(td);rows.append(tr)}const chosen=model.projects.find(p=>p.id===selected);if(chosen)projectDetail(chosen)}catch(error){health.textContent='Unable to load workspace · '+error.message;health.className='warn'}}q.oninput=()=>{syncUrl();draw()};s.onchange=()=>{syncUrl();draw()};addEventListener('popstate',()=>location.reload());draw()</script></body></html>`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function gateHtml(dataUrl = '/api/v1/overview?') {
|
|
226
|
+
if (dataUrl.startsWith('./')) return legacyHtml(dataUrl);
|
|
227
|
+
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Loop Engineering Human Gates</title><style>:root{color-scheme:dark}body{font:14px system-ui;max-width:1100px;margin:auto;padding:24px;background:#091018;color:#eef6fb}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:14px}.card{background:#111c28;border:1px solid #30465b;border-radius:14px;padding:16px}.meta{color:#9db0c2;white-space:pre-wrap}button,input{padding:9px;margin:4px;border-radius:8px;border:1px solid #4c657b;background:#162737;color:inherit}button:disabled{opacity:.45}.approve{border-color:#55bd82}.reject{border-color:#db6e74}.revision{border-color:#d8ad55}</style></head><body><h1>Loop Engineering</h1><p id="health">Loading authoritative artifacts…</p><label>Verified actor identity <input id="actor" autocomplete="username" placeholder="actor id"></label><h2>Human Gates</h2><div id="gates" class="grid"></div><h2>Workspace</h2><div id="workspace" class="grid"></div><script>const node=(tag,text,cls)=>{const n=document.createElement(tag);n.textContent=text;if(cls)n.className=cls;return n};async function decide(g,d){const binding=(g.source_bindings||[]).find(x=>x.channel==='dashboard');if(!binding)return alert('This gate has no registered Dashboard binding.');if(!actor.value)return alert('Actor identity is required.');const reason=d==='request_revision'?prompt('Revision reason (required)'):null;if(d==='request_revision'&&!reason)return;const body={gate_id:g.gate_id,decision:d,expected_generation:g.generation,actor_id:actor.value,source_message_id:binding.message_id,reply_to:binding.reply_to,idempotency_key:crypto.randomUUID(),reason};const r=await fetch('/api/v1/gate-commands',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)});const out=await r.json();if(!r.ok)return alert(out.message||out.error);alert(out.outcome+(out.outcome==='confirmation_required'?' — confirm again on generation '+out.resulting_generation:''));await draw()}async function draw(){const [overview,gs]=await Promise.all([fetch('/api/v1/overview').then(r=>r.json()),fetch('/api/v1/gates').then(r=>r.json())]);health.textContent=overview.health.status+' · Loop artifacts remain the only source of truth';workspace.replaceChildren(...[['Projects',overview.overview.project_count],['Tasks',overview.overview.task_workspace_count],['Reservations',overview.overview.action_count]].map(([k,v])=>{const n=node('div','', 'card');n.append(node('h3',k),node('strong',String(v)));return n}));gates.replaceChildren(...gs.map(g=>{const n=node('article','', 'card'),active=['pending','awaiting_confirmation'].includes(g.status);n.append(node('h3',g.project+' · '+g.task),node('p','Gate ID: '+g.gate_id+' · generation '+g.generation+' · '+g.status,'meta'),node('p','Action: '+g.action+'\nReason: '+g.reason+'\nImpact: '+g.impact+'\nRisk: '+JSON.stringify(g.risk)+'\nCost/budget: '+JSON.stringify(g.cost)+'\nEvidence: '+JSON.stringify(g.evidence)+'\nDashboard: '+(g.dashboard_url||location.href)+'\nExpiry: '+g.expiry,'meta'));for(const d of ['approve','reject','request_revision']){const b=node('button',d,d==='request_revision'?'revision':d);b.disabled=!active;b.onclick=()=>decide(g,d);n.append(b)}return n}));if(!gs.length)gates.append(node('p','No Human Gates.','card'))}draw()</script></body></html>`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function html(dataUrl = '/api/v1/overview?') {
|
|
231
|
+
if (dataUrl.startsWith('./')) return legacyHtml(dataUrl);
|
|
232
|
+
const panel = `<section aria-labelledby="human-gates-title"><h2 id="human-gates-title">Human Gates</h2><p class="muted">Only exact card actions or card-bound commands can decide a gate.</p><label>Verified actor identity <input id="gateactor" autocomplete="username" placeholder="actor id"></label><div class="projects" id="gatecards"></div></section>`;
|
|
233
|
+
const behavior = `<script>async function gateDecision(g,d){const binding=(g.source_bindings||[]).find(x=>x.channel==='dashboard');if(!binding)return alert('No registered Dashboard binding.');if(!gateactor.value)return alert('Actor identity is required.');const reason=d==='request_revision'?prompt('Revision reason (required)'):null;if(d==='request_revision'&&!reason)return;const r=await fetch('/api/v1/gate-commands',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({gate_id:g.gate_id,decision:d,expected_generation:g.generation,actor_id:gateactor.value,source_message_id:binding.message_id,reply_to:binding.reply_to,idempotency_key:crypto.randomUUID(),reason})}),out=await r.json();if(!r.ok)return alert(out.message||out.error);await drawHumanGates()}async function drawHumanGates(){const gs=await fetch('/api/v1/gates').then(r=>r.json());gatecards.replaceChildren(...gs.map(g=>{const n=el('article',null,'card'),active=['pending','awaiting_confirmation'].includes(g.status);n.append(el('h3',g.project+' · '+g.task),el('p','Gate ID: '+g.gate_id+' · generation '+g.generation+' · '+g.status,'muted'),el('p','Action: '+g.action+' | Reason: '+g.reason+' | Impact: '+g.impact+' | Risk: '+JSON.stringify(g.risk)+' | Cost/budget: '+JSON.stringify(g.cost)+' | Evidence: '+JSON.stringify(g.evidence)+' | Dashboard: '+(g.dashboard_url||location.href)+' | Expiry: '+g.expiry,'muted'));for(const d of ['approve','reject','request_revision']){const b=el('button',d);b.type='button';b.setAttribute('aria-pressed','false');b.disabled=!active;b.onclick=()=>gateDecision(g,d);n.append(b)}return n}));if(!gs.length)gatecards.append(el('p','No pending gates.','card empty'))}drawHumanGates()</script>`;
|
|
234
|
+
return legacyHtml(dataUrl).replace('READ-ONLY OPERATOR WORKSPACE', 'GOVERNED OPERATOR WORKSPACE').replace('</main>', `${panel}</main>`).replace('</body>', `${behavior}</body>`);
|
|
178
235
|
}
|
|
179
236
|
|
|
180
237
|
function loopback(host) { return host === '127.0.0.1' || host === '::1' || host === 'localhost'; }
|
|
@@ -185,9 +242,16 @@ export async function createDashboardServer(root, options = {}) {
|
|
|
185
242
|
const server = createServer(async (request, response) => {
|
|
186
243
|
try {
|
|
187
244
|
const url = new URL(request.url, 'http://localhost');
|
|
188
|
-
if (request.method !== 'GET' && request.method !== 'HEAD') { response.writeHead(405, { Allow: 'GET, HEAD' }); return response.end(); }
|
|
189
245
|
if (url.pathname.includes('..') || /%2e/i.test(request.url)) { response.writeHead(400); return response.end('unsafe path'); }
|
|
190
|
-
if (
|
|
246
|
+
if (request.method === 'POST' && url.pathname === '/api/v1/gate-commands') {
|
|
247
|
+
let raw = ''; for await (const chunk of request) { raw += chunk; if (raw.length > 65536) throw new Error('request_too_large'); }
|
|
248
|
+
const command = JSON.parse(raw || '{}');
|
|
249
|
+
const receipt = await executeGateCommand(root, { ...command, event_type: 'card_button', source_channel: 'dashboard' });
|
|
250
|
+
response.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' });
|
|
251
|
+
return response.end(`${JSON.stringify(receipt)}\n`);
|
|
252
|
+
}
|
|
253
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') { response.writeHead(405, { Allow: 'GET, HEAD, POST' }); return response.end(); }
|
|
254
|
+
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().replace('<p id="health">', '<p id="health" role="status" aria-live="polite">')); }
|
|
191
255
|
const projection = await buildOperatorProjection(root);
|
|
192
256
|
let body;
|
|
193
257
|
if (url.pathname === '/api/v1/overview') body = filterProjection(projection, { query: url.searchParams.get('q'), state: url.searchParams.get('state') });
|
|
@@ -195,6 +259,9 @@ export async function createDashboardServer(root, options = {}) {
|
|
|
195
259
|
else if (url.pathname === '/api/v1/todos') body = filterProjection(projection, { query: url.searchParams.get('q'), state: url.searchParams.get('state') }).todos;
|
|
196
260
|
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;
|
|
197
261
|
else if (url.pathname === '/api/v1/actions') body = projection.actions;
|
|
262
|
+
else if (url.pathname === '/api/v1/projects') body = projection.projects;
|
|
263
|
+
else if (url.pathname === '/api/v1/gates') body = await listHumanGates(root);
|
|
264
|
+
else if (url.pathname.startsWith('/api/v1/projects/')) body = projection.projects.find((item) => item.id === safeId(decodeURIComponent(url.pathname.slice('/api/v1/projects/'.length)))) ?? null;
|
|
198
265
|
else { response.writeHead(404); return response.end('not found'); }
|
|
199
266
|
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`);
|
|
200
267
|
} catch (error) { response.writeHead(500, { 'content-type': 'application/json; charset=utf-8' }); response.end(`${JSON.stringify({ error: 'projection_failed', message: String(error.message) })}\n`); }
|
|
@@ -207,6 +274,6 @@ export async function exportDashboard(root, outputDir, options = {}) {
|
|
|
207
274
|
const target = path.resolve(outputDir); const projection = await buildOperatorProjection(root, options);
|
|
208
275
|
await mkdir(target, { recursive: true });
|
|
209
276
|
await writeFile(path.join(target, 'projection.json'), `${JSON.stringify(projection, null, 2)}\n`);
|
|
210
|
-
await writeFile(path.join(target, 'index.html'), html(
|
|
277
|
+
await writeFile(path.join(target, 'index.html'), html('./projection.json'));
|
|
211
278
|
return { schema_version: DASHBOARD_SCHEMA_VERSION, output_dir: target, files: ['index.html', 'projection.json'], read_only_source: true };
|
|
212
279
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
export const QUOTA_DECISIONS = Object.freeze(['execute', 'wait', 'ask', 'self-repair', 'silent']);
|
|
6
|
+
export const BUDGET_DIMENSIONS = Object.freeze(['tokens', 'time_ms', 'money_minor', 'rounds']);
|
|
7
|
+
|
|
8
|
+
const finite = (value, fallback = 0) => Number.isFinite(Number(value)) ? Number(value) : fallback;
|
|
9
|
+
const vector = (value = {}) => Object.fromEntries(BUDGET_DIMENSIONS.map((key) => [key, Math.max(0, finite(value[key]))]));
|
|
10
|
+
const plus = (left, right) => Object.fromEntries(BUDGET_DIMENSIONS.map((key) => [key, left[key] + right[key]]));
|
|
11
|
+
const remaining = (limits, spend) => Object.fromEntries(BUDGET_DIMENSIONS.map((key) => [key, Math.max(0, limits[key] - spend[key])]));
|
|
12
|
+
const exceeds = (request, available) => BUDGET_DIMENSIONS.filter((key) => request[key] > available[key]);
|
|
13
|
+
|
|
14
|
+
function schedulerHint(decision, input = {}) {
|
|
15
|
+
const delays = { execute: 0, 'self-repair': 0, ask: null, silent: null, wait: Math.max(1_000, finite(input.retry_after_ms, 30_000)) };
|
|
16
|
+
return {
|
|
17
|
+
action: decision,
|
|
18
|
+
eligible_at: delays[decision] === null ? null : new Date(Date.now() + delays[decision]).toISOString(),
|
|
19
|
+
retry_after_ms: delays[decision],
|
|
20
|
+
wake_on: decision === 'ask' ? ['human_gate_resolved'] : decision === 'silent' ? ['new_work', 'budget_reset'] : decision === 'wait' ? ['timer', 'budget_reset', 'lane_change'] : []
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function decideQuota(input = {}) {
|
|
25
|
+
const limits = vector(input.limits);
|
|
26
|
+
const spend = vector(input.spend);
|
|
27
|
+
const request = vector(input.request);
|
|
28
|
+
const available = remaining(limits, spend);
|
|
29
|
+
const exhausted = exceeds(request, available);
|
|
30
|
+
const lanes = Array.isArray(input.lanes) ? input.lanes : [];
|
|
31
|
+
const selected = input.lane_id ? lanes.find((lane) => lane.id === input.lane_id) : lanes[0];
|
|
32
|
+
const fallback = lanes.find((lane) => lane.id !== selected?.id && lane.safe_fallback === true && lane.audited === true && lane.state === 'runnable');
|
|
33
|
+
let decision = 'execute'; let reason = 'budget_available'; let lane = selected?.id ?? null;
|
|
34
|
+
|
|
35
|
+
if (!input.has_work) { decision = 'silent'; reason = 'no_work'; }
|
|
36
|
+
else if (input.repairable_error) { decision = 'self-repair'; reason = 'repairable_runtime_error'; }
|
|
37
|
+
else if (selected?.state === 'waiting_for_human' && fallback) { decision = 'execute'; reason = 'audited_safe_fallback'; lane = fallback.id; }
|
|
38
|
+
else if (selected?.state === 'waiting_for_human') { decision = 'ask'; reason = 'human_gate_required'; }
|
|
39
|
+
else if (input.external_condition_pending) { decision = 'wait'; reason = 'external_condition_pending'; }
|
|
40
|
+
else if (exhausted.length) { decision = input.can_wait_for_reset === false ? 'ask' : 'wait'; reason = `budget_exhausted:${exhausted.join(',')}`; }
|
|
41
|
+
|
|
42
|
+
return { version: 1, decision, reason, lane_id: lane, limits, spend, request, remaining: available, scheduler_hint: schedulerHint(decision, input) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function ledgerPath(root) { return path.join(root, 'runtime', 'loops', 'quota', 'ledger.json'); }
|
|
46
|
+
async function readLedger(root) { try { return JSON.parse(await readFile(ledgerPath(root), 'utf8')); } catch (error) { if (error.code === 'ENOENT') return { version: 1, spend: vector(), entries: [] }; throw error; } }
|
|
47
|
+
|
|
48
|
+
export async function readQuotaLedger(root) { return readLedger(root); }
|
|
49
|
+
|
|
50
|
+
export async function recordVerifiedSliceSpend(root, input = {}) {
|
|
51
|
+
if (input.status !== 'completed' || input.verified !== true) return { recorded: false, reason: 'slice_not_completed_and_verified' };
|
|
52
|
+
const amount = vector(input.spend);
|
|
53
|
+
const ledger = await readLedger(root);
|
|
54
|
+
const sliceId = String(input.slice_id ?? '').trim();
|
|
55
|
+
if (!sliceId) throw new Error('slice_id is required.');
|
|
56
|
+
if (ledger.entries.some((entry) => entry.slice_id === sliceId)) return { recorded: false, reason: 'slice_already_recorded', ledger };
|
|
57
|
+
const entry = { id: randomUUID(), slice_id: sliceId, spend: amount, completed_at: input.completed_at ?? new Date().toISOString(), evidence: input.evidence ?? null };
|
|
58
|
+
ledger.spend = plus(vector(ledger.spend), amount); ledger.entries.push(entry);
|
|
59
|
+
const file = ledgerPath(root); await mkdir(path.dirname(file), { recursive: true });
|
|
60
|
+
const temp = `${file}.${process.pid}.${randomUUID()}.tmp`; await writeFile(temp, `${JSON.stringify(ledger, null, 2)}\n`, { flag: 'wx' }); await rename(temp, file);
|
|
61
|
+
return { recorded: true, entry, ledger };
|
|
62
|
+
}
|
|
@@ -2,6 +2,7 @@ import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/p
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { inspectAction } from './action-reservations.mjs';
|
|
5
|
+
import { decideQuota } from './quota-runtime-decision.mjs';
|
|
5
6
|
|
|
6
7
|
const TODO_STATES = new Set(['runnable', 'blocked', 'claimed', 'handoff_pending', 'completed']);
|
|
7
8
|
const RISK = new Set(['low', 'medium', 'high', 'critical']);
|
|
@@ -29,7 +30,7 @@ function location(root) {
|
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
function emptyState() {
|
|
32
|
-
return { version:
|
|
33
|
+
return { version: 3, fencing_counter: 0, agents: {}, todos: {}, handoffs: {}, peer_messages: {}, conflicts: {}, wake_events: {}, quotas: {}, updated_at: null };
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
async function readState(file) {
|
|
@@ -61,6 +62,7 @@ async function transaction(root, operation) {
|
|
|
61
62
|
await acquire(place.lock);
|
|
62
63
|
try {
|
|
63
64
|
const state = await readState(place.file);
|
|
65
|
+
state.version = Math.max(Number(state.version ?? 1), 3); state.agents ??= {}; state.todos ??= {}; state.handoffs ??= {}; state.peer_messages ??= {}; state.conflicts ??= {}; state.wake_events ??= {}; state.quotas ??= {};
|
|
64
66
|
const result = await operation(state);
|
|
65
67
|
if (result.changed) {
|
|
66
68
|
state.updated_at = new Date().toISOString();
|
|
@@ -77,7 +79,16 @@ function event(type, todo, extra = {}) {
|
|
|
77
79
|
|
|
78
80
|
function normalizeAgent(input) {
|
|
79
81
|
const authority = strings(input.authority_grants ?? input.authorityGrants, 'authority_grants');
|
|
80
|
-
|
|
82
|
+
const maxConcurrent = Number(input.max_concurrent ?? input.maxConcurrent ?? 1000);
|
|
83
|
+
if (!Number.isInteger(maxConcurrent) || maxConcurrent < 1) throw new Error('max_concurrent must be a positive integer.');
|
|
84
|
+
return {
|
|
85
|
+
id: id(input.id ?? input.agent_id, 'agent id'), runtime: text(input.runtime ?? 'generic', 'runtime'),
|
|
86
|
+
capabilities: strings(input.capabilities, 'capabilities'), authority_grants: authority,
|
|
87
|
+
quota_grants: { ...(input.quota_grants ?? input.quotaGrants ?? {}) }, runtime_budget_limits: input.runtime_budget_limits ?? input.runtimeBudgetLimits,
|
|
88
|
+
runtime_budget_spend: input.runtime_budget_spend ?? input.runtimeBudgetSpend, max_concurrent: maxConcurrent,
|
|
89
|
+
wake: { mode: input.wake?.mode ?? 'inbox', target: input.wake?.target ?? null },
|
|
90
|
+
status: input.status ?? 'available', metadata: input.metadata ?? {}, registered_at: new Date().toISOString()
|
|
91
|
+
};
|
|
81
92
|
}
|
|
82
93
|
|
|
83
94
|
export async function registerAgent(root, input) {
|
|
@@ -125,6 +136,7 @@ export async function createTodo(root, input) {
|
|
|
125
136
|
|
|
126
137
|
function capabilityEligible(agent, todo) { return todo.required_capabilities.every((item) => agent.capabilities.includes(item)); }
|
|
127
138
|
function authorityEligible(agent, todo) { return agent.authority_grants.includes('*') || agent.authority_grants.includes(todo.authority_class); }
|
|
139
|
+
function activeLoad(state, agentId) { return Object.values(state.todos).filter((todo) => ['claimed', 'handoff_pending'].includes(todo.state) && todo.claim?.owner === agentId).length; }
|
|
128
140
|
|
|
129
141
|
async function eligibility(root, state, todo, agent, now = Date.now()) {
|
|
130
142
|
const reasons = [];
|
|
@@ -134,15 +146,102 @@ async function eligibility(root, state, todo, agent, now = Date.now()) {
|
|
|
134
146
|
if (missing.length) reasons.push(`dependencies:${missing.join(',')}`);
|
|
135
147
|
if (!capabilityEligible(agent, todo)) reasons.push('capability_mismatch');
|
|
136
148
|
if (!authorityEligible(agent, todo)) reasons.push('authority_mismatch');
|
|
149
|
+
if (agent.status !== 'available') reasons.push(`agent_status:${agent.status}`);
|
|
150
|
+
if (activeLoad(state, agent.id) >= (agent.max_concurrent ?? 1)) reasons.push('agent_at_capacity');
|
|
137
151
|
const quota = todo.cost_envelope.quota;
|
|
138
152
|
const available = Number(agent.quota_grants?.[quota] ?? state.quotas?.[quota] ?? 0);
|
|
139
|
-
|
|
153
|
+
const quotaDecision = decideQuota({
|
|
154
|
+
has_work: true,
|
|
155
|
+
limits: agent.runtime_budget_limits ?? { money_minor: available },
|
|
156
|
+
spend: agent.runtime_budget_spend ?? {},
|
|
157
|
+
request: todo.context?.runtime_budget_request ?? { money_minor: todo.cost_envelope.amount },
|
|
158
|
+
lane_id: todo.context?.lane_id,
|
|
159
|
+
lanes: todo.context?.lanes,
|
|
160
|
+
external_condition_pending: todo.context?.external_condition_pending,
|
|
161
|
+
repairable_error: todo.context?.repairable_error,
|
|
162
|
+
can_wait_for_reset: todo.context?.can_wait_for_reset
|
|
163
|
+
});
|
|
164
|
+
if (quotaDecision.decision !== 'execute') reasons.push(quotaDecision.reason.startsWith('budget_exhausted:') ? 'quota_exhausted' : `quota_decision:${quotaDecision.decision}`);
|
|
140
165
|
for (const key of todo.idempotency_keys) {
|
|
141
166
|
const action = await inspectAction(root, key);
|
|
142
167
|
if (action?.state === 'unknown') reasons.push(`action_reconciliation:${key}`);
|
|
143
168
|
if (action?.state === 'claimed' && Date.parse(action.claim?.lease_expires_at ?? '') <= now) reasons.push(`action_reconciliation:${key}`);
|
|
144
169
|
}
|
|
145
|
-
return { eligible: reasons.length === 0, reasons };
|
|
170
|
+
return { eligible: reasons.length === 0, reasons, quota_decision: quotaDecision };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function matchTodo(root, input = {}) {
|
|
174
|
+
const state = await readState(location(root).file);
|
|
175
|
+
const requested = input.todo_id ?? input.todoId;
|
|
176
|
+
const todos = requested ? [state.todos[id(requested, 'todo id')]].filter(Boolean) : Object.values(state.todos);
|
|
177
|
+
const matches = [];
|
|
178
|
+
for (const todo of todos.sort((a, b) => b.priority - a.priority || a.created_at.localeCompare(b.created_at) || a.id.localeCompare(b.id))) {
|
|
179
|
+
const candidates = [];
|
|
180
|
+
for (const agent of Object.values(state.agents)) {
|
|
181
|
+
const check = await eligibility(root, state, todo, agent);
|
|
182
|
+
const load = activeLoad(state, agent.id); const capacity = agent.max_concurrent ?? 1;
|
|
183
|
+
const capabilitySurplus = agent.capabilities.filter((item) => !todo.required_capabilities.includes(item)).length;
|
|
184
|
+
candidates.push({ agent_id: agent.id, runtime: agent.runtime, eligible: check.eligible, reasons: check.reasons, load, capacity, score: check.eligible ? (load / capacity) * 100 + capabilitySurplus : null, quota_decision: check.quota_decision });
|
|
185
|
+
}
|
|
186
|
+
candidates.sort((a, b) => (a.eligible === b.eligible ? 0 : a.eligible ? -1 : 1) || (a.score ?? Infinity) - (b.score ?? Infinity) || a.agent_id.localeCompare(b.agent_id));
|
|
187
|
+
matches.push({ todo_id: todo.id, selected_agent_id: candidates.find((item) => item.eligible)?.agent_id ?? null, candidates });
|
|
188
|
+
}
|
|
189
|
+
return requested ? matches[0] ?? null : matches;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function wakeAgent(root, input) {
|
|
193
|
+
const agentId = id(input.agent_id ?? input.agentId, 'agent id');
|
|
194
|
+
return transaction(root, async (state) => {
|
|
195
|
+
const agent = state.agents[agentId]; if (!agent) throw new Error(`Agent not registered: ${agentId}`);
|
|
196
|
+
const todoId = id(input.todo_id ?? input.todoId, 'todo id'); if (!state.todos[todoId]) throw new Error(`Todo not found: ${todoId}`);
|
|
197
|
+
const wakeId = id(input.wake_id ?? input.wakeId ?? `wake:${todoId}:${agentId}:${randomUUID()}`, 'wake id');
|
|
198
|
+
const wake = { version: 1, id: wakeId, agent_id: agentId, todo_id: todoId, runtime: agent.runtime, mode: agent.wake?.mode ?? 'inbox', target: agent.wake?.target ?? null, reason: input.reason ?? 'matched_todo', state: 'pending', created_at: new Date().toISOString() };
|
|
199
|
+
state.wake_events[wakeId] = wake;
|
|
200
|
+
return { changed: true, output: wake, event: { version: 1, event_id: randomUUID(), type: 'agent_targeted_wake', agent_id: agentId, todo_id: todoId, wake_id: wakeId, at: wake.created_at } };
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function acknowledgeWake(root, input) {
|
|
205
|
+
return transaction(root, async (state) => {
|
|
206
|
+
const wake = state.wake_events[id(input.wake_id ?? input.wakeId, 'wake id')];
|
|
207
|
+
if (!wake || wake.state !== 'pending') throw new Error('Pending wake not found.');
|
|
208
|
+
if (wake.agent_id !== id(input.agent_id ?? input.agentId, 'agent id')) throw new Error('Only the targeted agent can acknowledge a wake.');
|
|
209
|
+
wake.state = 'acknowledged'; wake.acknowledged_at = new Date().toISOString();
|
|
210
|
+
return { changed: true, output: wake, event: { version: 1, event_id: randomUUID(), type: 'agent_wake_acknowledged', agent_id: wake.agent_id, todo_id: wake.todo_id, wake_id: wake.id, at: wake.acknowledged_at } };
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export async function sendPeerMessage(root, input) {
|
|
215
|
+
return transaction(root, async (state) => {
|
|
216
|
+
const from = id(input.from_agent_id ?? input.fromAgentId, 'from agent id'); const to = id(input.to_agent_id ?? input.toAgentId, 'to agent id');
|
|
217
|
+
if (!state.agents[from] || !state.agents[to]) throw new Error('Both peer agents must be registered.');
|
|
218
|
+
const todoId = id(input.todo_id ?? input.todoId, 'todo id'); if (!state.todos[todoId]) throw new Error(`Todo not found: ${todoId}`);
|
|
219
|
+
const messageId = id(input.message_id ?? input.messageId ?? `peer:${todoId}:${randomUUID()}`, 'message id');
|
|
220
|
+
const message = { version: 1, id: messageId, todo_id: todoId, from_agent_id: from, to_agent_id: to, kind: input.kind ?? 'collaboration', body: text(input.body, 'body'), evidence_refs: strings(input.evidence_refs ?? input.evidenceRefs, 'evidence_refs'), state: 'pending', created_at: new Date().toISOString() };
|
|
221
|
+
state.peer_messages[messageId] = message;
|
|
222
|
+
return { changed: true, output: message, event: { version: 1, event_id: randomUUID(), type: 'peer_message_created', message_id: messageId, todo_id: todoId, agent_id: to, at: message.created_at } };
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function resolveOwnershipConflict(root, input) {
|
|
227
|
+
return transaction(root, async (state) => {
|
|
228
|
+
const todo = state.todos[id(input.todo_id ?? input.todoId, 'todo id')]; if (!todo) throw new Error('Todo not found.');
|
|
229
|
+
const winner = id(input.winner_agent_id ?? input.winnerAgentId, 'winner agent id'); if (!state.agents[winner]) throw new Error('Winner agent not registered.');
|
|
230
|
+
const contenders = strings(input.contenders ?? [todo.claim?.owner, winner].filter(Boolean), 'contenders');
|
|
231
|
+
const conflictId = id(input.conflict_id ?? input.conflictId ?? `conflict:${todo.id}:${randomUUID()}`, 'conflict id');
|
|
232
|
+
const previous = todo.claim; const token = ++state.fencing_counter; const now = new Date();
|
|
233
|
+
todo.state = 'claimed'; todo.claim = { owner: winner, fencing_token: token, claimed_at: now.toISOString(), lease_expires_at: new Date(now.getTime() + Number(input.lease_ms ?? input.leaseMs ?? 60_000)).toISOString() }; todo.updated_at = now.toISOString();
|
|
234
|
+
const conflict = { version: 1, id: conflictId, todo_id: todo.id, contenders, winner_agent_id: winner, previous_owner: previous?.owner ?? null, reason: text(input.reason, 'reason'), state: 'resolved', fencing_token: token, resolved_at: now.toISOString() };
|
|
235
|
+
state.conflicts[conflictId] = conflict;
|
|
236
|
+
const audit = event('ownership_conflict_resolved', todo, { conflict_id: conflictId, contenders, winner_agent_id: winner, previous_fencing_token: previous?.fencing_token ?? null }); todo.ownership_events = [...(todo.ownership_events ?? []), audit];
|
|
237
|
+
return { changed: true, output: { conflict, todo }, event: audit };
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export async function teamWorkbench(root, input = {}) {
|
|
242
|
+
const state = await readState(location(root).file); const now = Number(input.now ?? Date.now());
|
|
243
|
+
const agents = Object.values(state.agents).map((agent) => ({ ...agent, load: activeLoad(state, agent.id), pending_wakes: Object.values(state.wake_events ?? {}).filter((wake) => wake.agent_id === agent.id && wake.state === 'pending').length, pending_messages: Object.values(state.peer_messages ?? {}).filter((message) => message.to_agent_id === agent.id && message.state === 'pending').length })).sort((a, b) => a.id.localeCompare(b.id));
|
|
244
|
+
const todos = Object.values(state.todos); return { version: 1, generated_at: new Date(now).toISOString(), agents, todos, handoffs: Object.values(state.handoffs ?? {}), wake_events: Object.values(state.wake_events ?? {}), peer_messages: Object.values(state.peer_messages ?? {}), conflicts: Object.values(state.conflicts ?? {}), governance: { orphan_candidates: todos.filter((todo) => todo.claim && Date.parse(todo.claim.lease_expires_at) <= now).map((todo) => todo.id), ownership_conflicts: Object.values(state.conflicts ?? {}).filter((item) => item.state !== 'resolved').map((item) => item.id), unmatched_runnable: (await matchTodo(root)).filter((item) => !item.selected_agent_id).map((item) => item.todo_id) } };
|
|
146
245
|
}
|
|
147
246
|
|
|
148
247
|
export async function listTodos(root, options = {}) {
|
|
@@ -164,7 +263,7 @@ export async function claimTodo(root, input) {
|
|
|
164
263
|
const rejected = [];
|
|
165
264
|
for (const todo of candidates) {
|
|
166
265
|
const check = await eligibility(root, state, todo, agent);
|
|
167
|
-
if (!check.eligible) { rejected.push({ todo_id: todo.id, reasons: check.reasons }); continue; }
|
|
266
|
+
if (!check.eligible) { rejected.push({ todo_id: todo.id, reasons: check.reasons, quota_decision: check.quota_decision }); continue; }
|
|
168
267
|
const now = new Date();
|
|
169
268
|
const token = ++state.fencing_counter;
|
|
170
269
|
todo.state = 'claimed'; todo.blocked_reasons = []; todo.updated_at = now.toISOString();
|
|
@@ -173,7 +272,7 @@ export async function claimTodo(root, input) {
|
|
|
173
272
|
todo.ownership_events = [...(todo.ownership_events ?? []), audit];
|
|
174
273
|
return { changed: true, output: { claimed: true, todo, fencing_token: token }, event: audit };
|
|
175
274
|
}
|
|
176
|
-
return { changed: false, output: { claimed: false, reason: rejected[0]?.reasons?.[0] ?? 'no_eligible_todo', rejected } };
|
|
275
|
+
return { changed: false, output: { claimed: false, reason: rejected[0]?.reasons?.[0] ?? 'no_eligible_todo', decision: rejected[0]?.quota_decision?.decision ?? 'silent', scheduler_hint: rejected[0]?.quota_decision?.scheduler_hint ?? null, rejected } };
|
|
177
276
|
});
|
|
178
277
|
}
|
|
179
278
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "taskforce-loop-engineering",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.14",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "OpenClaw-native loop engineering CLI, templates, and skill for verifiable agent work loops.",
|
|
6
6
|
"type": "module",
|
|
@@ -14,26 +14,34 @@
|
|
|
14
14
|
"loop-engineering-hermes-install": "scripts/hermes-install.mjs",
|
|
15
15
|
"loop-engineering-hermes-doctor": "scripts/hermes-doctor.mjs",
|
|
16
16
|
"loop-engineering-hermes-smoke": "scripts/hermes-smoke.mjs",
|
|
17
|
+
"loop-engineering-dashboard-autostart-install": "scripts/dashboard-autostart-install.mjs",
|
|
17
18
|
"run-loop-cron.sh": "scripts/run-loop-cron.sh"
|
|
18
19
|
},
|
|
19
20
|
"scripts": {
|
|
20
|
-
"test": "npm run check && npm run check:competitive",
|
|
21
|
+
"test": "npm run check:human-gates && npm run check && npm run check:competitive && npm run check:operator-workspace",
|
|
22
|
+
"check:operator-workspace": "node --check scripts/operator-workspace-final-judgement.mjs && node --check scripts/dashboard-autostart-install.mjs && node scripts/dashboard-autostart-self-test.mjs && node scripts/operator-workspace-final-judgement.mjs",
|
|
21
23
|
"check:competitive": "node --check lib/transactional-state-kernel.mjs && node --check lib/goal-api.mjs && node scripts/competitive-acceptance.mjs",
|
|
22
|
-
"check:adapters": "node --check lib/runtime-adapter-sdk.mjs && node scripts/runtime-adapter-conformance.mjs",
|
|
24
|
+
"check:adapters": "node --check lib/runtime-adapter-sdk.mjs && node scripts/runtime-adapter-conformance.mjs && node scripts/agent-team-control-plane-self-test.mjs",
|
|
25
|
+
"check:agent-team-live": "node scripts/live-agent-team-conformance.mjs",
|
|
23
26
|
"demo:adapter": "node examples/adapter-sdk-demo.mjs",
|
|
24
27
|
"check:production-trust": "node --check lib/runtime-adapter-v1.mjs && node --check lib/durable-journal.mjs && node --check lib/execution-ledger.mjs && node --check lib/production-evidence.mjs && node --check lib/upgrade-planner.mjs && node scripts/production-acceptance.mjs",
|
|
25
28
|
"check:config-drift": "node scripts/distribution-skill-self-test.mjs && node --check scripts/config-drift-self-test.mjs && node scripts/config-drift-self-test.mjs",
|
|
26
29
|
"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",
|
|
27
30
|
"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",
|
|
28
31
|
"check:project-gates": "node --check lib/core.mjs && node scripts/project-gate-reconciliation-self-test.mjs",
|
|
29
|
-
"check": "
|
|
32
|
+
"check:human-gates": "node --check lib/human-gate-command.mjs && node --check lib/human-gate-channel-adapter.mjs && node scripts/human-gate-command-self-test.mjs && node scripts/human-gate-final-judgement.mjs",
|
|
33
|
+
"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/quota-runtime-decision.mjs && node --check lib/operator-dashboard.mjs && node scripts/action-reservation-self-test.mjs && node scripts/todo-control-plane-self-test.mjs && node scripts/quota-runtime-decision-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",
|
|
30
34
|
"pack:dry": "npm pack --dry-run"
|
|
31
35
|
},
|
|
32
36
|
"exports": {
|
|
33
37
|
".": "./lib/goal-api.mjs",
|
|
34
38
|
"./goal": "./lib/goal-api.mjs",
|
|
35
39
|
"./transactional-kernel": "./lib/transactional-state-kernel.mjs",
|
|
36
|
-
"./runtime-adapter-sdk": "./lib/runtime-adapter-sdk.mjs"
|
|
40
|
+
"./runtime-adapter-sdk": "./lib/runtime-adapter-sdk.mjs",
|
|
41
|
+
"./quota": "./lib/quota-runtime-decision.mjs",
|
|
42
|
+
"./todo-control-plane": "./lib/todo-control-plane.mjs",
|
|
43
|
+
"./human-gate-command": "./lib/human-gate-command.mjs",
|
|
44
|
+
"./human-gate-channel-adapter": "./lib/human-gate-channel-adapter.mjs"
|
|
37
45
|
},
|
|
38
46
|
"engines": {
|
|
39
47
|
"node": ">=22"
|
|
@@ -67,5 +75,12 @@
|
|
|
67
75
|
"url": "https://github.com/ambitioncn/taskforce-loop-engineering/issues"
|
|
68
76
|
},
|
|
69
77
|
"homepage": "https://github.com/ambitioncn/taskforce-loop-engineering#readme",
|
|
70
|
-
"license": "Apache-2.0"
|
|
78
|
+
"license": "Apache-2.0",
|
|
79
|
+
"main": "index.js",
|
|
80
|
+
"directories": {
|
|
81
|
+
"doc": "docs",
|
|
82
|
+
"example": "examples",
|
|
83
|
+
"lib": "lib"
|
|
84
|
+
},
|
|
85
|
+
"author": ""
|
|
71
86
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtemp } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { acknowledgeWake, claimTodo, createTodo, decideHandoff, handoffTodo, matchTodo, registerAgent, resolveOwnershipConflict, sendPeerMessage, teamWorkbench, wakeAgent } from '../lib/todo-control-plane.mjs';
|
|
6
|
+
|
|
7
|
+
const root = await mkdtemp(path.join(tmpdir(), 'loop-agent-team-'));
|
|
8
|
+
for (const [id, runtime, capabilities, max] of [
|
|
9
|
+
['openclaw-worker', 'openclaw', ['code', 'research'], 2],
|
|
10
|
+
['codex-worker', 'codex-cli', ['code', 'review'], 1],
|
|
11
|
+
['claude-worker', 'claude-code', ['research', 'review'], 1]
|
|
12
|
+
]) await registerAgent(root, { id, runtime, capabilities, authority_grants: ['local'], quota_grants: { default: 20 }, max_concurrent: max, wake: { mode: 'runtime-session', target: `${runtime}:session` } });
|
|
13
|
+
const make = (id, capabilities, dependencies = []) => createTodo(root, { id, title: id, required_capabilities: capabilities, dependencies, authority_class: 'local', acceptance_contract: { checks: ['verified'] }, evidence_requirements: ['artifact'], cost: 1 });
|
|
14
|
+
await make('research', ['research']); await make('implementation', ['code'], ['research']); await make('review', ['review'], ['implementation']);
|
|
15
|
+
const researchMatch = await matchTodo(root, { todoId: 'research' });
|
|
16
|
+
assert.equal(researchMatch.selected_agent_id, 'claude-worker');
|
|
17
|
+
assert.match(researchMatch.candidates.find((candidate) => candidate.agent_id === 'codex-worker').reasons.join(','), /capability_mismatch/);
|
|
18
|
+
const wake = await wakeAgent(root, { todoId: 'research', agentId: researchMatch.selected_agent_id });
|
|
19
|
+
assert.equal(wake.runtime, 'claude-code'); assert.equal((await acknowledgeWake(root, { wakeId: wake.id, agentId: 'claude-worker' })).state, 'acknowledged');
|
|
20
|
+
const researchClaim = await claimTodo(root, { todoId: 'research', agentId: 'claude-worker' }); assert.equal(researchClaim.claimed, true);
|
|
21
|
+
const peer = await sendPeerMessage(root, { todoId: 'research', fromAgentId: 'claude-worker', toAgentId: 'openclaw-worker', kind: 'request_evidence', body: 'Please validate source evidence.', evidenceRefs: ['artifact:research-plan'] }); assert.equal(peer.to_agent_id, 'openclaw-worker');
|
|
22
|
+
const handoff = await handoffTodo(root, { todoId: 'research', agentId: 'claude-worker', targetAgentId: 'openclaw-worker', fencingToken: researchClaim.fencing_token });
|
|
23
|
+
const accepted = await decideHandoff(root, { handoffId: handoff.id, agentId: 'openclaw-worker', accept: true }); assert.equal(accepted.todo.claim.owner, 'openclaw-worker');
|
|
24
|
+
const conflict = await resolveOwnershipConflict(root, { todoId: 'research', winnerAgentId: 'claude-worker', contenders: ['openclaw-worker', 'claude-worker'], reason: 'research capability and dependency ownership', leaseMs: 1000 });
|
|
25
|
+
assert.equal(conflict.todo.claim.owner, 'claude-worker'); assert.ok(conflict.todo.claim.fencing_token > accepted.todo.claim.fencing_token);
|
|
26
|
+
const workbench = await teamWorkbench(root); assert.equal(workbench.agents.length, 3); assert.equal(workbench.peer_messages.length, 1); assert.equal(workbench.conflicts.length, 1);
|
|
27
|
+
assert.ok(workbench.governance.unmatched_runnable.includes('implementation'));
|
|
28
|
+
assert.deepEqual(new Set(workbench.agents.map((agent) => agent.runtime)), new Set(['openclaw', 'codex-cli', 'claude-code']));
|
|
29
|
+
console.log(JSON.stringify({ status: 'passed', boundary: 'durable control-plane runtime identities; credential-free external effects', assertions: 15, runtimes: workbench.agents.map(({ id, runtime }) => ({ id, runtime })) }));
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
function option(name, fallback) {
|
|
6
|
+
const index = process.argv.indexOf(name);
|
|
7
|
+
return index >= 0 ? process.argv[index + 1] : fallback;
|
|
8
|
+
}
|
|
9
|
+
const contractPath = path.resolve(option('--contract', 'docs/agent-team-terminal-contract.json'));
|
|
10
|
+
const backlogPath = path.resolve(option('--backlog', 'docs/agent-team-backlog.json'));
|
|
11
|
+
const evidenceOption = option('--evidence');
|
|
12
|
+
if (!evidenceOption) throw new Error('--evidence is required');
|
|
13
|
+
const evidencePath = path.resolve(evidenceOption);
|
|
14
|
+
const outputPath = option('--output');
|
|
15
|
+
const [contract, backlog, evidence] = await Promise.all([contractPath, backlogPath, evidencePath].map(async (file) => JSON.parse(await readFile(file, 'utf8'))));
|
|
16
|
+
const runtimeNames = new Set(evidence.results?.filter((item) => item.available && item.task_probe?.passed).map((item) => item.runtime));
|
|
17
|
+
const checks = {
|
|
18
|
+
terminal_contract_complete: contract.status === 'complete' && contract.requirements.every((item) => item.status === 'done'),
|
|
19
|
+
backlog_terminal: backlog.items.every((item) => item.status === 'done'),
|
|
20
|
+
real_not_simulated: evidence.kind === 'live_agent_team_task_conformance' && evidence.simulated === false,
|
|
21
|
+
all_runtime_tasks_passed: evidence.passed === true && ['openclaw', 'codex-cli', 'claude-code'].every((runtime) => runtimeNames.has(runtime))
|
|
22
|
+
};
|
|
23
|
+
const passed = Object.values(checks).every(Boolean);
|
|
24
|
+
const judgement = { version: 1, project: contract.project, generated_at: new Date().toISOString(), passed, status: passed ? 'accepted' : 'needs_revision', checks, evidence: { contract: contractPath, backlog: backlogPath, live_runtime: evidencePath } };
|
|
25
|
+
if (outputPath) await writeFile(path.resolve(outputPath), `${JSON.stringify(judgement, null, 2)}\n`);
|
|
26
|
+
console.log(JSON.stringify(judgement, null, 2));
|
|
27
|
+
if (!passed) process.exitCode = 2;
|