taskforce-loop-engineering 0.15.13 → 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 CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.15.14 - 2026-08-30
6
+
7
+ - Add one authoritative cross-interface Human Gate command core for Dashboard and trusted chat adapters, with strict source binding, generation fencing, idempotency receipts, confirmation escalation, and fail-closed handling of ordinary chat.
8
+ - Upgrade quota checks into a runtime decision engine covering token, time, cost, and turn budgets, verified-only spend, idle-free accounting, scheduler hints, and audited safe fallback work.
9
+ - Productize the durable multi-agent control plane with agent registration, explainable matching, targeted wakeups, peer collaboration, fenced handoffs, conflict/orphan governance, and cross-runtime conformance evidence.
10
+ - Extend the operator workspace, OpenClaw installer, doctor, smoke, and bundled skill to expose and verify the new gate, quota, team, and project-control capabilities.
11
+
5
12
  ## 0.15.13 - 2026-08-28
6
13
 
7
14
  - Add the project-first read-only operator workspace with terminal contracts, milestone/project status separation, revision lineage, human gates, external-action reservations, and acceptance/final-judge timelines.
@@ -81,6 +81,7 @@ import {
81
81
  settleAction
82
82
  } from '../lib/action-reservations.mjs';
83
83
  import {
84
+ acknowledgeWake,
84
85
  claimTodo,
85
86
  createTodo,
86
87
  decideHandoff,
@@ -88,10 +89,15 @@ import {
88
89
  importLegacyTodos,
89
90
  inspectTodo,
90
91
  listTodos,
92
+ matchTodo,
91
93
  recoverTodos,
92
94
  registerAgent,
95
+ resolveOwnershipConflict,
93
96
  releaseTodo,
94
- renewTodo
97
+ renewTodo,
98
+ sendPeerMessage,
99
+ teamWorkbench,
100
+ wakeAgent
95
101
  } from '../lib/todo-control-plane.mjs';
96
102
  import {
97
103
  buildOperatorProjection,
@@ -146,6 +152,7 @@ function parseArgs(argv) {
146
152
  else if (a === '--handoff-id') args.handoffId = argv[++i];
147
153
  else if (a === '--todo-json') args.todoJson = argv[++i];
148
154
  else if (a === '--agent-json') args.agentJson = argv[++i];
155
+ else if (a === '--payload-json') args.payloadJson = argv[++i];
149
156
  else if (a === '--state') args.todoState = argv[++i];
150
157
  else if (a === '--run-id') args.runId = argv[++i];
151
158
  else if (a === '--output') args.output = argv[++i];
@@ -5653,7 +5660,7 @@ async function main() {
5653
5660
  if (command === 'summarize') return summarizeCommand(args);
5654
5661
  if (command === 'doctor') return doctorCommand(args);
5655
5662
  if (command.startsWith('dashboard-')) return dashboardCommand(command, args);
5656
- if (command === 'agent-register' || command.startsWith('todo-')) return todoControlPlaneCommand(command, args);
5663
+ if (['agent-register', 'agent-wake', 'agent-wake-ack', 'peer-message', 'team-workbench'].includes(command) || command.startsWith('todo-')) return todoControlPlaneCommand(command, args);
5657
5664
  if (command.startsWith('action-')) return actionReservationCommand(command, args);
5658
5665
  if (command === 'repair-plan') return repairPlanCommand(args);
5659
5666
  if (command === 'project-intake') return projectIntakeCommand(args);
@@ -5767,6 +5774,12 @@ async function todoControlPlaneCommand(command, args) {
5767
5774
  else if (command === 'todo-reject') result = await decideHandoff(args.root, { ...args, accept: false });
5768
5775
  else if (command === 'todo-recover') result = await recoverTodos(args.root, args);
5769
5776
  else if (command === 'todo-import-legacy') result = await importLegacyTodos(args.root, args);
5777
+ else if (command === 'todo-match') result = await matchTodo(args.root, args);
5778
+ else if (command === 'agent-wake') result = await wakeAgent(args.root, await jsonInput(args.payloadJson, '--payload-json'));
5779
+ else if (command === 'agent-wake-ack') result = await acknowledgeWake(args.root, await jsonInput(args.payloadJson, '--payload-json'));
5780
+ else if (command === 'peer-message') result = await sendPeerMessage(args.root, await jsonInput(args.payloadJson, '--payload-json'));
5781
+ else if (command === 'todo-conflict-resolve') result = await resolveOwnershipConflict(args.root, await jsonInput(args.payloadJson, '--payload-json'));
5782
+ else if (command === 'team-workbench') result = await teamWorkbench(args.root, args);
5770
5783
  else throw new Error(`Unknown command: ${command}`);
5771
5784
  console.log(JSON.stringify(result, null, 2));
5772
5785
  return result === null ? 1 : 0;
@@ -0,0 +1 @@
1
+ {"version":1,"project":"agent-team-control-plane","items":[{"id":"AT-1","status":"done","depends_on":[]},{"id":"AT-2","status":"done","depends_on":["AT-1"]},{"id":"AT-3","status":"done","depends_on":["AT-1","AT-2"]},{"id":"AT-4","status":"done","depends_on":["AT-1"]},{"id":"AT-5","status":"done","depends_on":["AT-2","AT-4"]},{"id":"AT-6","status":"done","depends_on":["AT-1","AT-3","AT-4","AT-5"]},{"id":"AT-7","status":"done","depends_on":["AT-6"],"evidence":"live-runtime-conformance.json"},{"id":"AT-8","status":"done","depends_on":["AT-7"],"evidence":"npm test, clean package smoke, agent-team-final-judgement.json"}]}
@@ -0,0 +1 @@
1
+ {"version":1,"project":"agent-team-control-plane","status":"complete","terminal_user_visible_outcome":"A fresh Loop Engineering workspace can register heterogeneous agents and operate them as one durable team through the existing todo control plane.","constraints":["extend runtime/loops/control-plane; do not create a second orchestrator","all ownership mutations remain fenced and audited","external effects remain capability and authorization gated"],"requirements":[{"id":"AT-1","title":"product agent registry","status":"done","evidence":"lib/todo-control-plane.mjs"},{"id":"AT-2","title":"explainable capability budget dependency load matching","status":"done","evidence":"matchTodo and agent-team-control-plane-self-test.mjs"},{"id":"AT-3","title":"durable targeted wake","status":"done","evidence":"wakeAgent/acknowledgeWake"},{"id":"AT-4","title":"role-independent peer collaboration and durable handoff","status":"done","evidence":"sendPeerMessage plus fenced handoff"},{"id":"AT-5","title":"conflict and orphan governance","status":"done","evidence":"resolveOwnershipConflict/recoverTodos/teamWorkbench"},{"id":"AT-6","title":"team workbench and CLI","status":"done","evidence":"teamWorkbench and CLI commands"},{"id":"AT-7","title":"Codex OpenClaw Claude cross-runtime conformance","status":"done","evidence":"runtime-adapter-conformance.mjs, agent-team-control-plane-self-test.mjs and live-runtime-conformance.json"},{"id":"AT-8","title":"full regression packaged install final judgement","status":"done","evidence":"npm test, clean npm pack/install/export smoke and agent-team-final-judgement.json"}],"acceptance":["all requirements done","targeted and full tests pass","clean package install passes","live runtime probes use actual installed executables","P0/P1/P2 safety remains intact"],"milestone_rule":"A milestone or simulated runtime test is not project completion.","completion_rule":"Every requirement and check must pass with durable evidence and no unmet blocker."}
@@ -0,0 +1,17 @@
1
+ # Cross-interface Human Gate command core
2
+
3
+ `lib/human-gate-command.mjs` is the sole mutation boundary for governed Human Gates. Dashboard POST actions and channel callbacks must call `executeGateCommand`; projections and adapters must not write gate files directly.
4
+
5
+ Each gate records project, task, Gate ID, action, reason, impact, risk, cost/budget, evidence, Dashboard URL, expiry, generation, allowed actors and one or more exact source bindings. Commands require `gate_id`, `expected_generation`, actor identity, channel/message binding, reply binding for reply commands, and an idempotency key. Successful commands produce durable receipts. Per-gate exclusive creation plus the generation fence makes only the first valid cross-interface command commit.
6
+
7
+ Allowed mutations are card-button callbacks and replies matching `/approve gate_<id>`, `/reject gate_<id>`, or `/request_revision gate_<id> <reason>` where the reply is bound to the registered card. Ordinary language, ordinal references, quotes, forwards and screenshots are ignored or rejected.
8
+
9
+ High/critical risk, production, external publication, irreversible work, or cost at/above the configured threshold enters `awaiting_confirmation` and increments generation. A second command against the new generation is required. `request_revision` writes a revision artifact, increments generation, and invalidates old cards. Clients refresh from `/api/v1/gates`; processed cards render disabled.
10
+
11
+ The Feishu adapter is deliberately transport-neutral. The HTTP/plugin transport must verify the official Feishu callback signature or encrypted-event envelope before normalization, then call `normalizeFeishuGateEvent(payload, { signatureVerified: true })`. Missing or failed verification throws `feishu_signature_unverified`; the adapter does not accept a raw callback on trust. Secret lookup, timestamp/nonce freshness and cryptographic verification belong to the transport boundary and must never be inferred from payload fields. The adapter performs no network I/O.
12
+
13
+ Delivery/update code should render `gateCard`, send it through the platform transport, register the returned message ID as a source binding, and refresh or disable the card after every durable receipt. Ordinary chat is fail-closed: only an exact card button, an exact command reply bound to the registered card, or the display-only `/show_gate gate_<id>` route is recognized. Natural language, quotes, forwards and screenshots never call the command core.
14
+
15
+ OpenClaw installation creates `scripts/loops/openclaw-loop-gate.mjs`, a stdin/stdout Gate Command bridge. A trusted callback/plugin handler verifies the Feishu envelope first and invokes the bridge with `LOOP_GATE_CHANNEL=feishu` and `LOOP_FEISHU_SIGNATURE_VERIFIED=1`; other channels must supply an already normalized event. The bridge calls the same `executeGateCommand` path used by Dashboard. Dashboard and chat therefore share generation fencing, actor/source binding, receipts, idempotency and synchronized-card state; neither interface owns separate approval state.
16
+
17
+ After installation run `loop-engineering-openclaw-doctor`, then `loop-engineering-openclaw-smoke`; both remain local/dry-run and verify the installed Gate Command bridge. Run `npm run check:human-gates` for unit, adapter, Dashboard HTTP integration, concurrency, replay, expiry, authorization, signature-boundary and misrecognition coverage. Tests use temporary local artifacts and make no external calls.
@@ -1,5 +1,7 @@
1
1
  # Multi-Agent Control Plane (P2)
2
2
 
3
+ The product team layer extends this same control-plane state—never a second orchestrator—with runtime-aware registry records, capacity/load-aware explainable matching, targeted wake events, role-independent peer messages, conflict records, and a unified workbench.
4
+
3
5
  P2 stores typed todos, registered agents, leases, handoffs, and ownership history in `runtime/loops/control-plane/state.json`. Every mutation is serialized by an atomic filesystem mutex and committed with rename. `audit.jsonl` records each ownership transition.
4
6
 
5
7
  ## Todo contract
@@ -26,6 +28,12 @@ todo-handoff --todo-id ID --agent-id AGENT --target-agent-id AGENT --fencing-tok
26
28
  todo-accept|todo-reject --handoff-id ID --agent-id AGENT
27
29
  todo-recover [--now EPOCH_MS]
28
30
  todo-import-legacy
31
+ todo-match [--todo-id ID]
32
+ agent-wake --payload-json JSON
33
+ agent-wake-ack --payload-json JSON
34
+ peer-message --payload-json JSON
35
+ todo-conflict-resolve --payload-json JSON
36
+ team-workbench
29
37
  ```
30
38
 
31
39
  `--todo-json` and `--agent-json` accept either an inline JSON object or a file path. All commands accept `--root` and emit JSON.
@@ -0,0 +1,9 @@
1
+ # Quota runtime decision engine
2
+
3
+ Quota is a runtime policy decision, not only a static credit check. Import `decideQuota` from `taskforce-loop-engineering/quota` and provide `limits`, already-recorded `spend`, the next slice `request`, work/error/external state, and optional lanes.
4
+
5
+ The result always chooses one of `execute`, `wait`, `ask`, `self-repair`, or `silent` and includes a scheduler hint. Budget vectors use `tokens`, `time_ms`, `money_minor`, and `rounds`.
6
+
7
+ `recordVerifiedSliceSpend` records spend only when a slice is both `completed` and `verified`. Idle, waiting, failed, or partially completed attempts record nothing. `slice_id` makes recording idempotent.
8
+
9
+ A lane waiting for a human may fall back only to a different lane marked both `safe_fallback: true` and `audited: true`; otherwise the decision is `ask`.
@@ -0,0 +1,37 @@
1
+ import { executeGateCommand, gateCard, getHumanGate, parseBoundReply } from './human-gate-command.mjs';
2
+
3
+ export async function renderGateForChannel(root, gateId) { return gateCard(await getHumanGate(root, gateId)); }
4
+
5
+ export async function handleChannelGateEvent(root, event, options = {}) {
6
+ if (event.kind === 'card_button') {
7
+ const receipt = await executeGateCommand(root, {
8
+ gate_id: event.action?.gate_id, decision: event.action?.decision,
9
+ expected_generation: event.action?.expected_generation, actor_id: event.actor_id,
10
+ source_channel: event.channel, source_message_id: event.message_id, reply_to: event.reply_to ?? null,
11
+ event_type: 'card_button', idempotency_key: event.event_id, reason: event.action?.reason
12
+ }, options);
13
+ return { ...receipt, synchronized_card: await renderGateForChannel(root, event.action?.gate_id) };
14
+ }
15
+ if (event.kind === 'message_reply' && event.reply_to) {
16
+ const parsed = parseBoundReply(event.text); if (!parsed) return { outcome: 'ignored_untrusted_chat' };
17
+ const receipt = await executeGateCommand(root, {
18
+ ...parsed, expected_generation: event.expected_generation, actor_id: event.actor_id,
19
+ source_channel: event.channel, source_message_id: event.card_message_id,
20
+ reply_to: event.reply_to, event_type: 'bound_reply', idempotency_key: event.event_id
21
+ }, options);
22
+ return { ...receipt, synchronized_card: await renderGateForChannel(root, parsed.gate_id) };
23
+ }
24
+ const show = String(event.text ?? '').trim().match(/^\/show_gate\s+(gate_[a-zA-Z0-9._:-]+)$/);
25
+ if (show) return { outcome: 'display_only', card: await renderGateForChannel(root, show[1]) };
26
+ return { outcome: 'ignored_untrusted_chat' };
27
+ }
28
+
29
+ // Feishu callback feasibility mapping. It performs no network I/O.
30
+ export function normalizeFeishuGateEvent(payload, options = {}) {
31
+ if (options.signatureVerified !== true) throw new Error('feishu_signature_unverified');
32
+ if (payload?.event?.action?.value?.gate_id) return { kind: 'card_button', event_id: payload.header?.event_id, actor_id: payload.event.operator?.operator_id?.open_id, channel: 'feishu', message_id: payload.event.context?.open_message_id, action: payload.event.action.value };
33
+ const message = payload?.event?.message;
34
+ let text = message?.content;
35
+ try { const parsed = JSON.parse(text); text = typeof parsed?.text === 'string' ? parsed.text : ''; } catch {}
36
+ return { kind: message?.parent_id ? 'message_reply' : 'ordinary_message', event_id: payload.header?.event_id, actor_id: payload.event?.sender?.sender_id?.open_id, channel: 'feishu', card_message_id: message?.root_id, reply_to: message?.parent_id, text, expected_generation: payload.expected_generation };
37
+ }
@@ -0,0 +1,161 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { mkdir, open, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ const DECISIONS = new Set(['approve', 'reject', 'request_revision']);
6
+ const SOURCES = new Set(['card_button', 'bound_reply']);
7
+
8
+ function required(value, name) {
9
+ if (value === undefined || value === null || value === '') throw new Error(`missing_${name}`);
10
+ return String(value);
11
+ }
12
+
13
+ function safe(value, name) {
14
+ const text = required(value, name);
15
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,299}$/.test(text)) throw new Error(`invalid_${name}`);
16
+ return text;
17
+ }
18
+
19
+ function digest(value) { return createHash('sha256').update(JSON.stringify(value)).digest('hex'); }
20
+ function gateDir(root) { return path.join(root, 'runtime', 'loops', 'human-gates'); }
21
+ function gateFile(root, gateId) { return path.join(gateDir(root), 'gates', `${safe(gateId, 'gate_id')}.json`); }
22
+ function receiptFile(root, key) { return path.join(gateDir(root), 'receipts', `${digest(key)}.json`); }
23
+
24
+ async function readJson(file) { return JSON.parse(await readFile(file, 'utf8')); }
25
+ async function atomicJson(file, value) {
26
+ await mkdir(path.dirname(file), { recursive: true });
27
+ const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
28
+ await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
29
+ await rename(temporary, file);
30
+ }
31
+
32
+ async function lock(root, gateId, callback) {
33
+ const lockFile = path.join(gateDir(root), 'locks', `${safe(gateId, 'gate_id')}.lock`);
34
+ await mkdir(path.dirname(lockFile), { recursive: true });
35
+ let handle;
36
+ try { handle = await open(lockFile, 'wx', 0o600); }
37
+ catch (error) { if (error.code === 'EEXIST') throw new Error('gate_conflict_retry'); throw error; }
38
+ try { return await callback(); }
39
+ finally { await handle.close(); await rm(lockFile, { force: true }); }
40
+ }
41
+
42
+ function publicGate(gate) {
43
+ return {
44
+ gate_id: gate.gate_id, project: gate.project, task: gate.task, action: gate.action,
45
+ reason: gate.reason, impact: gate.impact, risk: gate.risk, cost: gate.cost,
46
+ evidence: gate.evidence, dashboard_url: gate.dashboard_url, expiry: gate.expiry,
47
+ generation: gate.generation, status: gate.status, decisions: [...DECISIONS],
48
+ confirmation_required: gate.confirmation_required,
49
+ source_binding: gate.source_binding, source_bindings: gate.source_bindings,
50
+ processed: gate.processed ?? null
51
+ };
52
+ }
53
+
54
+ export async function createHumanGate(root, input, options = {}) {
55
+ const gateId = safe(input.gate_id ?? `gate_${randomUUID()}`, 'gate_id');
56
+ const now = options.now ?? new Date().toISOString();
57
+ const risk = input.risk ?? { level: 'low', reasons: [] };
58
+ const highRisk = input.confirmation_required ?? (
59
+ ['high', 'critical'].includes(String(risk.level).toLowerCase()) ||
60
+ Boolean(input.production || input.external_publish || input.irreversible || Number(input.cost?.amount ?? 0) >= Number(input.cost?.confirmation_threshold ?? Infinity))
61
+ );
62
+ const bindingsInput = input.source_bindings ?? [input.source_binding];
63
+ const sourceBindings = bindingsInput.map((binding) => ({
64
+ channel: required(binding?.channel, 'source_channel'), message_id: required(binding?.message_id, 'source_message_id'),
65
+ reply_to: binding?.reply_to ? String(binding.reply_to) : String(binding?.message_id), adapter: binding?.adapter ?? null
66
+ }));
67
+ const gate = {
68
+ version: 1, gate_id: gateId, project: required(input.project, 'project'), task: required(input.task, 'task'),
69
+ action: required(input.action, 'action'), reason: required(input.reason, 'reason'), impact: required(input.impact, 'impact'),
70
+ risk, cost: input.cost ?? { amount: 0, currency: 'CNY', budget: null }, evidence: input.evidence ?? [],
71
+ dashboard_url: input.dashboard_url ?? null, expiry: required(input.expiry, 'expiry'), generation: Number(input.generation ?? 1),
72
+ status: 'pending', confirmation_required: highRisk, confirmation: null,
73
+ allowed_actors: (input.allowed_actors ?? []).map(String),
74
+ source_binding: sourceBindings[0], source_bindings: sourceBindings,
75
+ revision_history: [], created_at: now, updated_at: now
76
+ };
77
+ if (!Number.isInteger(gate.generation) || gate.generation < 1 || Number.isNaN(Date.parse(gate.expiry))) throw new Error('invalid_gate_generation_or_expiry');
78
+ const file = gateFile(root, gateId);
79
+ await mkdir(path.dirname(file), { recursive: true });
80
+ try { const handle = await open(file, 'wx', 0o600); await handle.writeFile(`${JSON.stringify(gate, null, 2)}\n`); await handle.close(); }
81
+ catch (error) { if (error.code === 'EEXIST') throw new Error('gate_already_exists'); throw error; }
82
+ return publicGate(gate);
83
+ }
84
+
85
+ export async function getHumanGate(root, gateId) { return publicGate(await readJson(gateFile(root, gateId))); }
86
+
87
+ function validateBinding(gate, command, now) {
88
+ if (!DECISIONS.has(command.decision)) throw new Error('invalid_decision');
89
+ if (!SOURCES.has(command.event_type)) throw new Error('untrusted_event_type');
90
+ if (Number(command.expected_generation) !== gate.generation) throw new Error('stale_generation');
91
+ if (gate.status !== 'pending' && gate.status !== 'awaiting_confirmation') throw new Error('gate_already_processed');
92
+ if (Date.parse(gate.expiry) <= Date.parse(now)) throw new Error('gate_expired');
93
+ const actor = required(command.actor_id, 'actor_id');
94
+ if (gate.allowed_actors.length && !gate.allowed_actors.includes(actor)) throw new Error('actor_unauthorized');
95
+ const channel = required(command.source_channel, 'source_channel'); const messageId = required(command.source_message_id, 'source_message_id');
96
+ const binding = (gate.source_bindings ?? [gate.source_binding]).find((item) => item.channel === channel && item.message_id === messageId);
97
+ if (!binding) throw new Error((gate.source_bindings ?? [gate.source_binding]).some((item) => item.channel === channel) ? 'source_message_mismatch' : 'source_channel_mismatch');
98
+ if (command.event_type === 'bound_reply' && required(command.reply_to, 'reply_to') !== binding.reply_to) throw new Error('reply_binding_mismatch');
99
+ if (command.event_type === 'bound_reply' && required(command.gate_id, 'gate_id') !== gate.gate_id) throw new Error('reply_gate_id_required');
100
+ }
101
+
102
+ export async function executeGateCommand(root, command, options = {}) {
103
+ const gateId = safe(command.gate_id, 'gate_id');
104
+ const idempotencyKey = required(command.idempotency_key, 'idempotency_key');
105
+ const priorFile = receiptFile(root, idempotencyKey);
106
+ try { const prior = await readJson(priorFile); if (prior.command_fingerprint !== digest(command)) throw new Error('idempotency_key_reused'); return { ...prior, replayed: true }; }
107
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
108
+ return lock(root, gateId, async () => {
109
+ try { const prior = await readJson(priorFile); if (prior.command_fingerprint !== digest(command)) throw new Error('idempotency_key_reused'); return { ...prior, replayed: true }; }
110
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
111
+ const file = gateFile(root, gateId); const gate = await readJson(file); const now = options.now ?? new Date().toISOString();
112
+ validateBinding(gate, command, now);
113
+ const before = gate.generation; let outcome;
114
+ if (command.decision === 'approve' && gate.confirmation_required && gate.status !== 'awaiting_confirmation') {
115
+ gate.status = 'awaiting_confirmation'; gate.generation += 1;
116
+ gate.confirmation = { first_actor_id: command.actor_id, first_receipt_at: now };
117
+ outcome = 'confirmation_required';
118
+ } else if (command.decision === 'request_revision') {
119
+ gate.revision_history.push({ generation: gate.generation, reason: required(command.reason, 'revision_reason'), actor_id: command.actor_id, at: now });
120
+ gate.generation += 1; gate.status = 'pending'; gate.reason = command.reason;
121
+ gate.processed = { decision: 'request_revision', actor_id: command.actor_id, at: now, superseded_generation: before };
122
+ outcome = 'revision_created';
123
+ } else {
124
+ gate.status = command.decision === 'approve' ? 'approved' : 'rejected';
125
+ gate.processed = { decision: command.decision, actor_id: command.actor_id, at: now, generation: before };
126
+ outcome = gate.status;
127
+ }
128
+ gate.updated_at = now;
129
+ const receipt = {
130
+ version: 1, receipt_id: `receipt_${randomUUID()}`, gate_id: gateId, decision: command.decision, outcome,
131
+ actor_id: command.actor_id, event_type: command.event_type, source_channel: command.source_channel,
132
+ source_message_id: command.source_message_id, reply_to: command.reply_to ?? null,
133
+ expected_generation: Number(command.expected_generation), resulting_generation: gate.generation,
134
+ idempotency_key: idempotencyKey, command_fingerprint: digest(command), created_at: now, replayed: false
135
+ };
136
+ await atomicJson(file, gate);
137
+ if (outcome === 'revision_created') await atomicJson(path.join(gateDir(root), 'revisions', `${gateId}.generation-${gate.generation}.json`), {
138
+ version: 1, gate_id: gateId, amendment_type: 'request_revision', supersedes_generation: before,
139
+ generation: gate.generation, reason: command.reason, actor_id: command.actor_id, source_receipt_id: receipt.receipt_id, created_at: now
140
+ });
141
+ await atomicJson(priorFile, receipt);
142
+ return receipt;
143
+ });
144
+ }
145
+
146
+ export function parseBoundReply(text) {
147
+ const match = String(text ?? '').trim().match(/^\/(approve|reject|request_revision)\s+(gate_[a-zA-Z0-9._:-]+)(?:\s+(.+))?$/);
148
+ if (!match) return null;
149
+ return { decision: match[1], gate_id: match[2], reason: match[3] ?? null };
150
+ }
151
+
152
+ export function gateCard(gate) {
153
+ const g = publicGate(gate);
154
+ return { type: 'human_gate_card', title: `${g.project} · Human Gate`, fields: g, buttons: [...DECISIONS].map((decision) => ({ decision, gate_id: g.gate_id, expected_generation: g.generation, disabled: !['pending', 'awaiting_confirmation'].includes(g.status) })) };
155
+ }
156
+
157
+ export async function listHumanGates(root) {
158
+ const dir = path.join(gateDir(root), 'gates');
159
+ const names = (await import('node:fs/promises')).readdir(dir).catch(() => []);
160
+ return Promise.all((await names).filter((name) => name.endsWith('.json')).sort().map(async (name) => publicGate(await readJson(path.join(dir, name)))));
161
+ }
@@ -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;
@@ -217,10 +218,22 @@ export function dashboardHealth(projection, options = {}) {
217
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 };
218
219
  }
219
220
 
220
- function html(dataUrl = '/api/v1/overview?') {
221
+ function legacyHtml(dataUrl = '/api/v1/overview?') {
221
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>`;
222
223
  }
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>`);
235
+ }
236
+
224
237
  function loopback(host) { return host === '127.0.0.1' || host === '::1' || host === 'localhost'; }
225
238
 
226
239
  export async function createDashboardServer(root, options = {}) {
@@ -229,9 +242,16 @@ export async function createDashboardServer(root, options = {}) {
229
242
  const server = createServer(async (request, response) => {
230
243
  try {
231
244
  const url = new URL(request.url, 'http://localhost');
232
- if (request.method !== 'GET' && request.method !== 'HEAD') { response.writeHead(405, { Allow: 'GET, HEAD' }); return response.end(); }
233
245
  if (url.pathname.includes('..') || /%2e/i.test(request.url)) { response.writeHead(400); return response.end('unsafe path'); }
234
- 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">')); }
235
255
  const projection = await buildOperatorProjection(root);
236
256
  let body;
237
257
  if (url.pathname === '/api/v1/overview') body = filterProjection(projection, { query: url.searchParams.get('q'), state: url.searchParams.get('state') });
@@ -240,6 +260,7 @@ export async function createDashboardServer(root, options = {}) {
240
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;
241
261
  else if (url.pathname === '/api/v1/actions') body = projection.actions;
242
262
  else if (url.pathname === '/api/v1/projects') body = projection.projects;
263
+ else if (url.pathname === '/api/v1/gates') body = await listHumanGates(root);
243
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;
244
265
  else { response.writeHead(404); return response.end('not found'); }
245
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`);
@@ -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
+ }