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.
Files changed (37) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +1 -1
  3. package/bin/loop-engineering.mjs +15 -2
  4. package/docs/agent-team-backlog.json +1 -0
  5. package/docs/agent-team-terminal-contract.json +1 -0
  6. package/docs/human-gate-command.md +17 -0
  7. package/docs/multi-agent-control-plane.md +8 -0
  8. package/docs/operator-dashboard.md +25 -2
  9. package/docs/operator-workspace-project.md +30 -0
  10. package/docs/quota-runtime-decision.md +9 -0
  11. package/lib/human-gate-channel-adapter.mjs +37 -0
  12. package/lib/human-gate-command.mjs +161 -0
  13. package/lib/operator-dashboard.mjs +75 -8
  14. package/lib/quota-runtime-decision.mjs +62 -0
  15. package/lib/todo-control-plane.mjs +105 -6
  16. package/package.json +21 -6
  17. package/scripts/agent-team-control-plane-self-test.mjs +29 -0
  18. package/scripts/agent-team-final-judgement.mjs +27 -0
  19. package/scripts/dashboard-autostart-install.mjs +91 -0
  20. package/scripts/dashboard-autostart-self-test.mjs +46 -0
  21. package/scripts/distribution-skill-self-test.mjs +13 -3
  22. package/scripts/hermes-doctor.mjs +2 -1
  23. package/scripts/hermes-install-self-test.mjs +2 -1
  24. package/scripts/hermes-install.mjs +11 -4
  25. package/scripts/human-gate-command-self-test.mjs +52 -0
  26. package/scripts/human-gate-final-judgement.mjs +30 -0
  27. package/scripts/live-agent-team-conformance.mjs +61 -0
  28. package/scripts/openclaw-doctor.mjs +13 -0
  29. package/scripts/openclaw-install-self-test.mjs +8 -3
  30. package/scripts/openclaw-install.mjs +48 -6
  31. package/scripts/openclaw-smoke.mjs +4 -0
  32. package/scripts/operator-dashboard-self-test.mjs +27 -1
  33. package/scripts/operator-workspace-final-judgement.mjs +41 -0
  34. package/scripts/quota-runtime-decision-self-test.mjs +29 -0
  35. package/scripts/todo-control-plane-self-test.mjs +4 -1
  36. package/skills/taskforce-loop-engineering/SKILL.md +12 -0
  37. 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
- 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) }));
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
- 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, 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: 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, execution_steps: steps, production_evidence: trustEvidence, cost: { quotas: control.quotas, requested_total: control.todos.reduce((sum, item) => sum + Number(item.cost?.amount ?? 0), 0) } });
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 html() {
177
- 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>`;
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 (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()); }
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().replace("fetch('/api/v1/overview?'+p)", "fetch('./projection.json')"));
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: 2, fencing_counter: 0, agents: {}, todos: {}, handoffs: {}, quotas: {}, updated_at: null };
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
- 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() };
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
- if (todo.cost_envelope.amount > available) reasons.push('quota_exhausted');
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.12",
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": "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",
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;