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.
@@ -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.13",
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",
@@ -18,24 +18,30 @@
18
18
  "run-loop-cron.sh": "scripts/run-loop-cron.sh"
19
19
  },
20
20
  "scripts": {
21
- "test": "npm run check && npm run check:competitive && npm run check:operator-workspace",
21
+ "test": "npm run check:human-gates && npm run check && npm run check:competitive && npm run check:operator-workspace",
22
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",
23
23
  "check:competitive": "node --check lib/transactional-state-kernel.mjs && node --check lib/goal-api.mjs && node scripts/competitive-acceptance.mjs",
24
- "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",
25
26
  "demo:adapter": "node examples/adapter-sdk-demo.mjs",
26
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",
27
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",
28
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",
29
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",
30
31
  "check:project-gates": "node --check lib/core.mjs && node scripts/project-gate-reconciliation-self-test.mjs",
31
- "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",
32
34
  "pack:dry": "npm pack --dry-run"
33
35
  },
34
36
  "exports": {
35
37
  ".": "./lib/goal-api.mjs",
36
38
  "./goal": "./lib/goal-api.mjs",
37
39
  "./transactional-kernel": "./lib/transactional-state-kernel.mjs",
38
- "./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"
39
45
  },
40
46
  "engines": {
41
47
  "node": ">=22"
@@ -69,5 +75,12 @@
69
75
  "url": "https://github.com/ambitioncn/taskforce-loop-engineering/issues"
70
76
  },
71
77
  "homepage": "https://github.com/ambitioncn/taskforce-loop-engineering#readme",
72
- "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": ""
73
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;
@@ -1,14 +1,24 @@
1
1
  import assert from 'node:assert/strict';
2
- import { readFile } from 'node:fs/promises';
2
+ import { access, readFile } from 'node:fs/promises';
3
3
 
4
- const skill = await readFile(new URL('../skills/taskforce-loop-engineering/SKILL.md', import.meta.url), 'utf8');
4
+ const distributedSkillUrl = new URL('../skills/taskforce-loop-engineering/SKILL.md', import.meta.url);
5
+ const workspaceSkillUrl = new URL('../../../skills/taskforce-loop-engineering/SKILL.md', import.meta.url);
6
+ const skill = await readFile(distributedSkillUrl, 'utf8');
5
7
 
6
8
  for (const forbidden of ['ironman-task-runner', 'ironman-task-runner.mjs']) {
7
9
  assert.equal(skill.includes(forbidden), false, `distributed skill contains local dispatcher reference: ${forbidden}`);
8
10
  }
9
11
 
10
- for (const required of ['agent-tasks', 'scripts/loops/openclaw-loop.mjs', 'configs/loops/queues/']) {
12
+ for (const required of ['agent-tasks', 'scripts/loops/openclaw-loop.mjs', 'scripts/loops/openclaw-loop-gate.mjs', 'configs/loops/queues/', 'feishu_signature_unverified', 'ignored_untrusted_chat', 'Dashboard and chat']) {
11
13
  assert.equal(skill.includes(required), true, `distributed skill is missing generic integration guidance: ${required}`);
12
14
  }
13
15
 
16
+ try {
17
+ await access(workspaceSkillUrl);
18
+ const workspaceSkill = await readFile(workspaceSkillUrl, 'utf8');
19
+ assert.equal(workspaceSkill, skill, 'workspace taskforce-loop-engineering skill drifted from the distributed skill');
20
+ } catch (error) {
21
+ if (error?.code !== 'ENOENT') throw error;
22
+ }
23
+
14
24
  console.log('distribution skill self-test passed');
@@ -0,0 +1,52 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtemp, rm } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import path from 'node:path';
5
+ import { createHumanGate, executeGateCommand, getHumanGate, parseBoundReply } from '../lib/human-gate-command.mjs';
6
+ import { handleChannelGateEvent, normalizeFeishuGateEvent, renderGateForChannel } from '../lib/human-gate-channel-adapter.mjs';
7
+ import { createDashboardServer } from '../lib/operator-dashboard.mjs';
8
+
9
+ const root = await mkdtemp(path.join(tmpdir(), 'loop-gate-command-'));
10
+ const base = { project: 'p', task: 't', action: 'deploy', reason: 'release', impact: 'production', risk: { level: 'high' }, cost: { amount: 10, currency: 'CNY', budget: 100 }, evidence: ['test'], dashboard_url: 'http://127.0.0.1:4174/', expiry: '2030-01-01T00:00:00.000Z', allowed_actors: ['owner'], source_bindings: [{ channel: 'feishu', message_id: 'card-1', reply_to: 'card-1', adapter: 'feishu' }, { channel: 'dashboard', message_id: 'gate-panel', reply_to: 'gate-panel', adapter: 'dashboard' }] };
11
+ const command = { gate_id: 'gate_high', decision: 'approve', expected_generation: 1, actor_id: 'owner', source_channel: 'feishu', source_message_id: 'card-1', event_type: 'card_button', idempotency_key: 'event-1' };
12
+ try {
13
+ await createHumanGate(root, { ...base, gate_id: 'gate_high' });
14
+ const card = await renderGateForChannel(root, 'gate_high');
15
+ assert.deepEqual(card.buttons.map((x) => x.decision).sort(), ['approve', 'reject', 'request_revision']); assert.equal(card.fields.cost.budget, 100);
16
+ const first = await executeGateCommand(root, command, { now: '2029-01-01T00:00:00.000Z' }); assert.equal(first.outcome, 'confirmation_required'); assert.equal(first.resulting_generation, 2);
17
+ const replay = await executeGateCommand(root, command); assert.equal(replay.replayed, true); assert.equal(replay.receipt_id, first.receipt_id);
18
+ await assert.rejects(executeGateCommand(root, { ...command, idempotency_key: 'event-stale' }, { now: '2029-01-01T00:00:02.000Z' }), /stale_generation/);
19
+ assert.equal((await executeGateCommand(root, { ...command, expected_generation: 2, idempotency_key: 'event-2' }, { now: '2029-01-01T00:00:03.000Z' })).outcome, 'approved');
20
+ await assert.rejects(executeGateCommand(root, { ...command, expected_generation: 2, idempotency_key: 'event-after' }), /gate_already_processed/);
21
+ await createHumanGate(root, { ...base, gate_id: 'gate_low', action: 'local edit', risk: { level: 'low' }, confirmation_required: false });
22
+ for (const bad of [{ actor_id: 'intruder' }, { source_channel: 'other' }, { source_message_id: 'forward' }, { event_type: 'natural_language' }]) await assert.rejects(executeGateCommand(root, { ...command, gate_id: 'gate_low', idempotency_key: `bad-${Object.keys(bad)[0]}`, ...bad }, { now: '2029-01-01T00:00:00.000Z' }), /(unauthorized|mismatch|untrusted)/);
23
+ assert.equal((await handleChannelGateEvent(root, { kind: 'ordinary_message', text: '好的,同意第一个' })).outcome, 'ignored_untrusted_chat'); assert.equal(parseBoundReply('同意'), null); assert.equal(parseBoundReply('/approve gate_low').gate_id, 'gate_low');
24
+ assert.equal((await handleChannelGateEvent(root, { kind: 'ordinary_message', text: '/show_gate gate_low' })).outcome, 'display_only');
25
+ const revision = await handleChannelGateEvent(root, { kind: 'message_reply', event_id: 'rev-1', actor_id: 'owner', channel: 'feishu', card_message_id: 'card-1', reply_to: 'card-1', text: '/request_revision gate_low fix evidence', expected_generation: 1 }, { now: '2029-01-01T00:00:00.000Z' });
26
+ assert.equal(revision.outcome, 'revision_created'); assert.equal((await getHumanGate(root, 'gate_low')).generation, 2);
27
+ assert.equal(revision.synchronized_card.buttons.every((button) => button.expected_generation === 2), true);
28
+ await createHumanGate(root, { ...base, gate_id: 'gate_expired', expiry: '2028-01-01T00:00:00.000Z', confirmation_required: false });
29
+ await assert.rejects(executeGateCommand(root, { ...command, gate_id: 'gate_expired', idempotency_key: 'expired' }, { now: '2029-01-01T00:00:00.000Z' }), /gate_expired/);
30
+ const feishuPayload = { header: { event_id: 'fs-1' }, event: { operator: { operator_id: { open_id: 'owner' } }, context: { open_message_id: 'card-1' }, action: { value: { gate_id: 'gate_high', decision: 'approve', expected_generation: 2 } } } };
31
+ assert.throws(() => normalizeFeishuGateEvent(feishuPayload), /feishu_signature_unverified/);
32
+ const feishu = normalizeFeishuGateEvent(feishuPayload, { signatureVerified: true }); assert.equal(feishu.kind, 'card_button');
33
+ await createHumanGate(root, { ...base, gate_id: 'gate_race', confirmation_required: false });
34
+ const raceBase = { ...command, gate_id: 'gate_race', decision: 'reject', expected_generation: 1 };
35
+ const race = await Promise.allSettled([executeGateCommand(root, { ...raceBase, idempotency_key: 'race-a' }), executeGateCommand(root, { ...raceBase, idempotency_key: 'race-b' })]);
36
+ assert.equal(race.filter((x) => x.status === 'fulfilled').length, 1); assert.equal(race.filter((x) => x.status === 'rejected').length, 1);
37
+ await createHumanGate(root, { ...base, gate_id: 'gate_cross', confirmation_required: false });
38
+ const cross = await Promise.allSettled([
39
+ executeGateCommand(root, { ...command, gate_id: 'gate_cross', idempotency_key: 'cross-feishu' }),
40
+ executeGateCommand(root, { ...command, gate_id: 'gate_cross', source_channel: 'dashboard', source_message_id: 'gate-panel', idempotency_key: 'cross-dashboard' })
41
+ ]);
42
+ assert.equal(cross.filter((x) => x.status === 'fulfilled').length, 1);
43
+ await createHumanGate(root, { ...base, gate_id: 'gate_http', confirmation_required: false });
44
+ const server = await createDashboardServer(root, { host: '127.0.0.1', port: 0 });
45
+ try {
46
+ const address = server.address();
47
+ const response = await fetch(`http://127.0.0.1:${address.port}/api/v1/gate-commands`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ gate_id: 'gate_http', decision: 'approve', expected_generation: 1, actor_id: 'owner', source_message_id: 'gate-panel', idempotency_key: 'http-1' }) });
48
+ assert.equal(response.status, 200); assert.equal((await response.json()).outcome, 'approved');
49
+ const gatesResponse = await fetch(`http://127.0.0.1:${address.port}/api/v1/gates`); assert.equal(gatesResponse.status, 200); assert.ok((await gatesResponse.json()).some((g) => g.gate_id === 'gate_http'));
50
+ } finally { await new Promise((resolve) => server.close(resolve)); }
51
+ console.log('human-gate-command self-test: ok (unit, integration, replay, expiry, authorization, misrecognition, Feishu mapping, concurrency)');
52
+ } finally { await rm(root, { recursive: true, force: true }); }
@@ -0,0 +1,30 @@
1
+ import assert from 'node:assert/strict';
2
+ import { readFile } from 'node:fs/promises';
3
+
4
+ const files = {
5
+ core: new URL('../lib/human-gate-command.mjs', import.meta.url),
6
+ adapter: new URL('../lib/human-gate-channel-adapter.mjs', import.meta.url),
7
+ dashboard: new URL('../lib/operator-dashboard.mjs', import.meta.url),
8
+ tests: new URL('./human-gate-command-self-test.mjs', import.meta.url),
9
+ docs: new URL('../docs/human-gate-command.md', import.meta.url)
10
+ };
11
+ const source = Object.fromEntries(await Promise.all(Object.entries(files).map(async ([key, file]) => [key, await readFile(file, 'utf8')])));
12
+ const checks = {
13
+ single_command_core: /executeGateCommand/.test(source.core) && /executeGateCommand/.test(source.adapter) && /executeGateCommand/.test(source.dashboard),
14
+ decisions: ['approve', 'reject', 'request_revision'].every((value) => source.core.includes(value)),
15
+ strict_binding: ['expected_generation', 'actor_unauthorized', 'source_channel_mismatch', 'source_message_mismatch', 'reply_binding_mismatch', 'gate_expired'].every((value) => source.core.includes(value)),
16
+ receipt_and_idempotency: /receipt_id/.test(source.core) && /idempotency_key_reused/.test(source.core),
17
+ cas_generation_fence: /gate_conflict_retry/.test(source.core) && /stale_generation/.test(source.core),
18
+ confirmation: /awaiting_confirmation/.test(source.core) && /confirmation_required/.test(source.core),
19
+ revision_artifact: /revisions/.test(source.core) && /supersedes_generation/.test(source.core),
20
+ fail_closed_chat: /ignored_untrusted_chat/.test(source.adapter) && /\/show_gate/.test(source.adapter),
21
+ synchronized_card: /synchronized_card/.test(source.adapter) && /disabled/.test(source.core),
22
+ complete_card: ['project', 'task', 'gate_id', 'action', 'reason', 'impact', 'risk', 'cost', 'evidence', 'dashboard_url', 'expiry', 'generation'].every((value) => source.core.includes(value)),
23
+ dashboard_buttons: /gate-commands/.test(source.dashboard) && ['approve', 'reject', 'request_revision'].every((value) => source.dashboard.includes(value)),
24
+ feishu_feasibility_no_send: /normalizeFeishuGateEvent/.test(source.adapter) && !/fetch\(|spawn\(|message.send/.test(source.adapter),
25
+ feishu_signature_boundary: /feishu_signature_unverified/.test(source.adapter) && /signatureVerified/.test(source.adapter),
26
+ fault_injection: ['race', 'replay', 'expired', 'intruder', 'forward', 'ordinary_message'].every((value) => source.tests.includes(value)),
27
+ documented_trust_boundary: /sole mutation boundary/.test(source.docs) && /no external calls/.test(source.docs) && /Ordinary chat is fail-closed/.test(source.docs)
28
+ };
29
+ for (const [name, passed] of Object.entries(checks)) assert.equal(passed, true, `terminal acceptance failed: ${name}`);
30
+ console.log(JSON.stringify({ outcome: 'accept', independent: true, checks: Object.keys(checks).length, accepted: Object.keys(checks), external_messages_sent: 0, online_test_residue: 0 }, null, 2));
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from 'node:child_process';
3
+ import { writeFile } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { randomUUID } from 'node:crypto';
6
+
7
+ const specs = [
8
+ { runtime: 'openclaw', executable: process.env.LOOP_OPENCLAW_BIN ?? 'openclaw' },
9
+ { runtime: 'codex-cli', executable: process.env.LOOP_CODEX_BIN ?? 'codex' },
10
+ { runtime: 'claude-code', executable: process.env.LOOP_CLAUDE_BIN ?? 'claude' }
11
+ ];
12
+ const runTasks = process.argv.includes('--run-tasks');
13
+ const taskToken = `LOOP_AGENT_TEAM_CONFORMANCE_${randomUUID()}`;
14
+ const prompt = `Return exactly this token and nothing else: ${taskToken}`;
15
+ const taskArgs = {
16
+ openclaw: ['agent', '--agent', process.env.LOOP_OPENCLAW_AGENT ?? 'main', '--session-key', `agent:${process.env.LOOP_OPENCLAW_AGENT ?? 'main'}:loop-conformance-${randomUUID()}`, '--message', prompt, '--thinking', 'off', '--timeout', '120', '--json'],
17
+ 'codex-cli': ['exec', '--ephemeral', '--skip-git-repo-check', '--sandbox', 'read-only', '--color', 'never', prompt],
18
+ 'claude-code': ['--print', '--no-session-persistence', '--permission-mode', 'plan', '--tools', '', '--model', process.env.LOOP_CLAUDE_MODEL ?? 'haiku', '--max-budget-usd', process.env.LOOP_CLAUDE_MAX_BUDGET_USD ?? '0.10', prompt]
19
+ };
20
+
21
+ function taskOutputMatches(runtime, stdout) {
22
+ if (runtime === 'openclaw') {
23
+ try {
24
+ const parsed = JSON.parse(stdout);
25
+ const texts = parsed?.result?.payloads?.map((item) => item.text).filter(Boolean) ?? [];
26
+ return texts.length === 1 && texts[0].trim() === taskToken;
27
+ } catch { return false; }
28
+ }
29
+ const lines = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
30
+ return lines.at(-1) === taskToken;
31
+ }
32
+
33
+ const results = specs.map((spec) => {
34
+ const probe = spawnSync(spec.executable, ['--version'], { encoding: 'utf8', timeout: 15_000 });
35
+ const available = !probe.error && probe.status === 0;
36
+ const result = { runtime: spec.runtime, executable: spec.executable, available, version: available ? (probe.stdout || probe.stderr).trim() : null, error: available ? null : (probe.error?.code ?? `exit_${probe.status}`), task_probe: null };
37
+ if (available && runTasks) {
38
+ const task = spawnSync(spec.executable, taskArgs[spec.runtime], { encoding: 'utf8', timeout: 180_000, maxBuffer: 4 * 1024 * 1024 });
39
+ const output = `${task.stdout ?? ''}\n${task.stderr ?? ''}`;
40
+ const tokenMatches = output.split(taskToken).length - 1;
41
+ const semanticMatch = taskOutputMatches(spec.runtime, task.stdout ?? '');
42
+ result.task_probe = {
43
+ attempted: true,
44
+ passed: !task.error && task.status === 0 && semanticMatch,
45
+ exit_status: task.status,
46
+ signal: task.signal,
47
+ token_match_count: tokenMatches,
48
+ semantic_output_match: semanticMatch,
49
+ error: task.error?.code ?? null,
50
+ output_bytes: Buffer.byteLength(output)
51
+ };
52
+ }
53
+ return result;
54
+ });
55
+ const unavailable = results.filter((item) => !item.available);
56
+ const failedTasks = runTasks ? results.filter((item) => !item.task_probe?.passed) : [];
57
+ const evidence = { version: 2, kind: runTasks ? 'live_agent_team_task_conformance' : 'live_agent_team_conformance_preflight', generated_at: new Date().toISOString(), passed: unavailable.length === 0 && failedTasks.length === 0, simulated: false, task_probe_requested: runTasks, task_contract: runTasks ? { operation: 'exact-token-response', external_delivery: false, filesystem_write_requested: false, unique_token: true } : null, results, next_action: unavailable.length ? 'install_or_bind_missing_runtime_executables_then_run_runtime_specific_task_probes' : failedTasks.length ? 'inspect_failed_runtime_task_probe' : runTasks ? 'run_full_regression_packaged_install_and_terminal_judgement' : 'rerun_with_--run-tasks' };
58
+ const outputIndex = process.argv.indexOf('--output');
59
+ if (outputIndex >= 0) await writeFile(path.resolve(process.argv[outputIndex + 1]), `${JSON.stringify(evidence, null, 2)}\n`);
60
+ console.log(JSON.stringify(evidence, null, 2));
61
+ if (!evidence.passed) process.exitCode = 2;
@@ -43,6 +43,7 @@ async function main() {
43
43
  'scripts/loops/openclaw-loop-dispatch.mjs',
44
44
  'scripts/loops/openclaw-loop.mjs',
45
45
  'scripts/loops/openclaw-loop-notify.mjs',
46
+ 'scripts/loops/openclaw-loop-gate.mjs',
46
47
  'AGENTS.md'
47
48
  ];
48
49
  const systemdUserDir = path.join(process.env.XDG_CONFIG_HOME || path.join(process.env.HOME || '', '.config'), 'systemd', 'user');
@@ -73,6 +74,13 @@ async function main() {
73
74
  });
74
75
  checks.push({ id: 'notification_dry_run', ok: smoke.code === 0, detail: (smoke.stdout || smoke.stderr).trim().slice(0, 500) });
75
76
  }
77
+ const gateBridge = path.join(args.root, 'scripts/loops/openclaw-loop-gate.mjs');
78
+ if (await present(gateBridge)) {
79
+ const syntax = await run(process.execPath, ['--check', gateBridge], { cwd: args.root });
80
+ checks.push({ id: 'human_gate_bridge_syntax', ok: syntax.code === 0, detail: (syntax.stderr || syntax.stdout).trim().slice(0, 500) });
81
+ const selfTest = await run(process.execPath, [gateBridge, '--self-test'], { cwd: args.root, env: { ...process.env, LOOP_WORKSPACE_ROOT: args.root } });
82
+ checks.push({ id: 'human_gate_bridge_self_test', ok: selfTest.code === 0 && /"externalWrite":false/.test(selfTest.stdout), detail: (selfTest.stdout || selfTest.stderr).trim().slice(0, 500) });
83
+ }
76
84
  const failed = checks.filter((check) => !check.ok);
77
85
  const report = { version: 1, status: failed.length ? 'fail' : 'ok', readOnly: true, externalWrite: false, root: args.root, queue: args.queue, workerAgent: args.workerAgent, checks, failed: failed.map((check) => check.id) };
78
86
  console.log(args.json ? JSON.stringify(report, null, 2) : `OpenClaw Loop doctor: ${report.status}\nchecks: ${checks.length - failed.length}/${checks.length}\nfailed: ${report.failed.join(', ') || 'none'}`);
@@ -104,6 +104,8 @@ if (schedulerTick.code !== 0) throw new Error(`installed scheduler tick failed:
104
104
  const schedulerState = JSON.parse(await readFile(path.join(root, 'runtime/loops/test-tasks/scheduler/state.json'), 'utf8'));
105
105
  if (!schedulerState.generatedAt || !schedulerState.nextRunAt) throw new Error('installed scheduler tick did not persist its heartbeat and cadence');
106
106
  const notifier = await readFile(path.join(root, 'scripts/loops/openclaw-loop-notify.mjs'), 'utf8');
107
+ const gateBridge = await readFile(path.join(root, 'scripts/loops/openclaw-loop-gate.mjs'), 'utf8');
108
+ if (!gateBridge.includes('feishu_signature_unverified') || !gateBridge.includes('ignored_untrusted_chat') || !gateBridge.includes('handleChannelGateEvent')) throw new Error('installed Human Gate bridge is incomplete');
107
109
  if (!notifier.includes("'message', 'send'") || !notifier.includes('source.channel') || !notifier.includes('source.target')) throw new Error('channel-neutral notifier missing');
108
110
  const delivery = await new Promise((resolve) => {
109
111
  const child = spawn(process.execPath, [path.join(root, 'scripts/loops/openclaw-loop-notify.mjs'), 'async result'], {
@@ -129,7 +131,7 @@ const doctorResult = await new Promise((resolve) => {
129
131
  });
130
132
  if (doctorResult.code !== 0) throw new Error(`doctor failed: ${doctorResult.stderr}`);
131
133
  const doctorReport = JSON.parse(doctorResult.stdout);
132
- if (doctorReport.status !== 'ok' || doctorReport.externalWrite !== false || !doctorReport.checks.some((check) => check.id === 'notification_dry_run' && check.ok)) throw new Error('doctor did not complete a safe notification dry-run');
134
+ if (doctorReport.status !== 'ok' || doctorReport.externalWrite !== false || !doctorReport.checks.some((check) => check.id === 'notification_dry_run' && check.ok) || !doctorReport.checks.some((check) => check.id === 'human_gate_bridge_self_test' && check.ok)) throw new Error('doctor did not complete safe notification and Human Gate self-tests');
133
135
  const smoke = new URL('./openclaw-smoke.mjs', import.meta.url).pathname;
134
136
  const smokeSource = await readFile(smoke, 'utf8');
135
137
  if (!smokeSource.includes('Do not change user or project files, configuration, credentials, or external state.')
@@ -151,7 +153,7 @@ const smokeReport = JSON.parse(smokeResult.stdout);
151
153
  if (smokeReport.status !== 'ok' || smokeReport.externalWrite !== false || !smokeReport.steps.every((step) => step.ok)) throw new Error('end-to-end smoke did not pass safely');
152
154
  try { await readFile(path.join(root, `configs/loops/queues/${smokeReport.smokeQueue}.json`)); throw new Error('smoke config was not cleaned'); } catch (error) { if (error.code !== 'ENOENT') throw error; }
153
155
  try { await readFile(path.join(root, `runtime/loops/${smokeReport.smokeQueue}/state.json`)); throw new Error('smoke runtime was not cleaned'); } catch (error) { if (error.code !== 'ENOENT') throw error; }
154
- for (const generated of ['scripts/loops/openclaw-loop-dispatch.mjs', 'scripts/loops/openclaw-loop.mjs', 'scripts/loops/openclaw-loop-notify.mjs']) {
156
+ for (const generated of ['scripts/loops/openclaw-loop-dispatch.mjs', 'scripts/loops/openclaw-loop.mjs', 'scripts/loops/openclaw-loop-notify.mjs', 'scripts/loops/openclaw-loop-gate.mjs']) {
155
157
  const syntax = await run(['--help']);
156
158
  if (syntax.code !== 0) throw new Error(`installer help failed while checking ${generated}`);
157
159
  const check = await new Promise((resolve) => {