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.
@@ -181,6 +181,7 @@ function run(args) {
181
181
  child.on('close', (code, signal) => resolve(code ?? (signal ? 128 : 1)));
182
182
  });
183
183
  }
184
+
184
185
  const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
185
186
  async function runWhenUnlocked(args, waitMs = 300000) {
186
187
  const deadline = Date.now() + waitMs;
@@ -229,6 +230,31 @@ if (command === 'route') {
229
230
  `;
230
231
  }
231
232
 
233
+ function gateBridgeSource({ humanGateAdapter }) {
234
+ return `#!/usr/bin/env node
235
+ import { handleChannelGateEvent, normalizeFeishuGateEvent } from ${JSON.stringify(humanGateAdapter)};
236
+ if (process.argv.includes('--self-test')) {
237
+ let rejected = false;
238
+ try { normalizeFeishuGateEvent({ event: {} }); } catch (error) { rejected = error?.message === 'feishu_signature_unverified'; }
239
+ if (!rejected) throw new Error('feishu_signature_boundary_not_fail_closed');
240
+ const ignored = await handleChannelGateEvent(process.env.LOOP_WORKSPACE_ROOT || process.cwd(), { kind: 'ordinary_message', text: 'approve the first one' });
241
+ if (ignored.outcome !== 'ignored_untrusted_chat') throw new Error('ordinary_chat_not_fail_closed');
242
+ process.stdout.write(JSON.stringify({ status: 'ok', externalWrite: false, signatureBoundary: 'fail_closed', ordinaryChat: 'ignored' }) + '\\n');
243
+ process.exit(0);
244
+ }
245
+ const chunks = [];
246
+ for await (const chunk of process.stdin) chunks.push(chunk);
247
+ if (!chunks.length) throw new Error('gate_event_required_on_stdin');
248
+ const payload = JSON.parse(Buffer.concat(chunks).toString('utf8'));
249
+ const channel = String(process.env.LOOP_GATE_CHANNEL || payload.channel || '').toLowerCase();
250
+ const event = channel === 'feishu'
251
+ ? normalizeFeishuGateEvent(payload, { signatureVerified: process.env.LOOP_FEISHU_SIGNATURE_VERIFIED === '1' })
252
+ : payload;
253
+ const result = await handleChannelGateEvent(process.env.LOOP_WORKSPACE_ROOT || process.cwd(), event);
254
+ process.stdout.write(JSON.stringify(result) + '\\n');
255
+ `;
256
+ }
257
+
232
258
  function schedulerServiceSource({ root, queue, language }) {
233
259
  return `[Unit]\nDescription=${text(language, `Taskforce Loop Engineering scheduler for ${queue}`, `${queue} 的 Taskforce Loop Engineering 调度器`)}\nAfter=default.target\n\n[Service]\nType=oneshot\nWorkingDirectory=${systemdEscapePath(root)}\nExecStart=${systemdEscapePath(process.execPath)} ${systemdEscapePath(path.join(root, 'scripts', 'loops', 'openclaw-loop.mjs'))} scheduler-tick --json\n`;
234
260
  }
@@ -270,6 +296,8 @@ function instructionsBlock({ queue, language }) {
270
296
  - 从当前工作区运行 \`node scripts/loops/openclaw-loop.mjs route --message "<完整用户消息>"\`,并保留来源元数据。
271
297
  - 会话来源任务必须使用标准包装器。缺少来源元数据时必须失败关闭,不能退回手工入队或直接运行队列。
272
298
  - 人工门禁或终态只有在通知命令成功并写入通知记录后才算已送达。
299
+ - Dashboard 与聊天审批必须共用 Gate Command 和同一份门禁状态;聊天接入使用 \`scripts/loops/openclaw-loop-gate.mjs\`。
300
+ - 飞书回调必须先由可信传输层验签,再设置 \`LOOP_FEISHU_SIGNATURE_VERIFIED=1\` 调用 Gate bridge;未验签失败关闭。普通聊天、引用、转发和截图不能审批。
273
301
  - 已由 Loop 管理的任务必须直接执行,不能再次路由。
274
302
  - 状态查询只读。高风险外部动作、破坏性操作、生产变更、凭据操作和记忆迁移仍需单独确认。
275
303
  - 队列:\`${queue}\`。
@@ -283,6 +311,8 @@ function instructionsBlock({ queue, language }) {
283
311
  - Run \`node scripts/loops/openclaw-loop.mjs route --message "<full user message>"\` from this workspace and preserve source metadata when available.
284
312
  - For conversation-originated work, the standard wrapper is mandatory. Missing source metadata must fail closed; never fall back to manual enqueue or direct run-queue.
285
313
  - A human-gated or terminal state is not delivered until its notification command succeeds and writes a notification record.
314
+ - Dashboard and chat approvals must share the Gate Command core and one gate state; chat transports use \`scripts/loops/openclaw-loop-gate.mjs\`.
315
+ - A trusted transport must verify Feishu callbacks before setting \`LOOP_FEISHU_SIGNATURE_VERIFIED=1\`; unverified callbacks fail closed. Ordinary chat, quotes, forwards, and screenshots cannot approve.
286
316
  - An already loop-managed task must be executed directly and never routed again.
287
317
  - Status questions are read-only. High-risk external, destructive, production, credential, or memory migration actions remain separately gated.
288
318
  - Queue: \`${queue}\`.
@@ -302,6 +332,7 @@ async function main() {
302
332
  const worker = await resolveWorkerAgent(args);
303
333
  args.workerAgent = worker.workerAgent;
304
334
  args.loopBin = new URL('../bin/loop-engineering.mjs', import.meta.url).pathname;
335
+ args.humanGateAdapter = new URL('../lib/human-gate-channel-adapter.mjs', import.meta.url).href;
305
336
  const systemdUserDir = path.join(process.env.XDG_CONFIG_HOME || path.join(process.env.HOME || '', '.config'), 'systemd', 'user');
306
337
  const schedulerUnit = `openclaw-loop-${args.queue}-scheduler.service`;
307
338
  const schedulerTimer = `openclaw-loop-${args.queue}-scheduler.timer`;
@@ -311,6 +342,7 @@ async function main() {
311
342
  dispatcher: path.join(args.root, 'scripts', 'loops', 'openclaw-loop-dispatch.mjs'),
312
343
  wrapper: path.join(args.root, 'scripts', 'loops', 'openclaw-loop.mjs'),
313
344
  notifier: path.join(args.root, 'scripts', 'loops', 'openclaw-loop-notify.mjs'),
345
+ gateBridge: path.join(args.root, 'scripts', 'loops', 'openclaw-loop-gate.mjs'),
314
346
  manifest: path.join(args.root, 'runtime', 'loop-engineering-openclaw-install.json'),
315
347
  instructions: path.join(args.root, 'AGENTS.md'),
316
348
  schedulerService: path.join(systemdUserDir, schedulerUnit),
@@ -319,7 +351,7 @@ async function main() {
319
351
  const conflicts = [];
320
352
  for (const [kind, file] of Object.entries(files)) if (!['instructions', 'workspaceHealth', 'manifest'].includes(kind) && await exists(file)) conflicts.push(path.relative(args.root, file));
321
353
  const dashboardDescription = args.dashboardListen === 'tailscale' ? 'read-only Tailnet address on port 4174 coupled to openclaw-gateway.service' : 'read-only http://127.0.0.1:4174/ coupled to openclaw-gateway.service';
322
- const confirmationSummary = { targetPlatform: 'OpenClaw', platformCli: args.openclawBin, workspace: args.root, queue: args.queue, scheduler: `systemd user timer ${schedulerTimer}`, dashboard: dashboardDescription, notificationTarget: text(args.language, 'source-bound at runtime (original OpenClaw conversation)', '运行时绑定到原始 OpenClaw 会话'), writesEnabled: args.confirmInstall };
354
+ const confirmationSummary = { targetPlatform: 'OpenClaw', platformCli: args.openclawBin, workspace: args.root, queue: args.queue, scheduler: `systemd user timer ${schedulerTimer}`, dashboard: dashboardDescription, humanGate: 'Dashboard + source-bound chat Gate Command bridge', notificationTarget: text(args.language, 'source-bound at runtime (original OpenClaw conversation)', '运行时绑定到原始 OpenClaw 会话'), writesEnabled: args.confirmInstall };
323
355
  const report = { version: 1, platform: 'openclaw', language: args.language, status: args.confirmInstall ? 'installed' : 'plan_only', readOnly: !args.confirmInstall, root: args.root, queue: args.queue, workerAgent: args.workerAgent, workerSelection: worker.selection, availableAgents: worker.availableAgents, workerValidated: true, createsWorkerAgent: false, openclawBin: args.openclawBin, systemctlBin: args.systemctlBin, scheduler: { required: true, unit: schedulerUnit, timer: schedulerTimer }, dashboardAutostart: { required: true, listen: args.dashboardListen, address: args.dashboardListen === 'localhost' ? 'http://127.0.0.1:4174/' : null, gateway: 'openclaw-gateway.service' }, confirmationSummary, files: Object.fromEntries(Object.entries(files).map(([key, file]) => [key, path.relative(args.root, file)])), conflicts };
324
356
  report.next = args.confirmInstall ? text(args.language, 'Run loop-engineering-openclaw-doctor, then route a harmless smoke task.', '运行 loop-engineering-openclaw-doctor,然后路由一个无害的冒烟任务。') : text(args.language, 'Review this plan, then rerun with --confirm-install.', '检查此计划,然后使用 --confirm-install 重新运行。');
325
357
  if (conflicts.length && !args.force && args.confirmInstall) throw new Error(`Refusing to overwrite: ${conflicts.join(', ')}. Use --force after review.`);
@@ -350,12 +382,14 @@ async function main() {
350
382
  const dispatcherContent = dispatcherSource(args);
351
383
  const wrapperContent = wrapperSource(args);
352
384
  const notifierContent = notifierSource(args);
385
+ const gateBridgeContent = gateBridgeSource(args);
353
386
  const schedulerServiceContent = schedulerServiceSource(args);
354
387
  const schedulerTimerContent = schedulerTimerSource(args);
355
388
  await writeFile(files.queueConfig, queueContent);
356
389
  await writeFile(files.dispatcher, dispatcherContent);
357
390
  await writeFile(files.wrapper, wrapperContent);
358
391
  await writeFile(files.notifier, notifierContent);
392
+ await writeFile(files.gateBridge, gateBridgeContent);
359
393
  await mkdir(systemdUserDir, { recursive: true });
360
394
  await writeFile(files.schedulerService, schedulerServiceContent);
361
395
  await writeFile(files.schedulerTimer, schedulerTimerContent);
@@ -371,12 +405,13 @@ async function main() {
371
405
  if (!instructions.includes('<!-- loop-engineering:openclaw:start -->')) await appendFile(files.instructions, managedInstructions);
372
406
  await mkdir(path.dirname(files.manifest), { recursive: true });
373
407
  await writeFile(files.manifest, `${JSON.stringify({
374
- version: 3, queue: args.queue, language: args.language, workerAgent: args.workerAgent, openclawBin: args.openclawBin, systemctlBin: args.systemctlBin, installedAt: new Date().toISOString(),
408
+ version: 4, queue: args.queue, language: args.language, workerAgent: args.workerAgent, openclawBin: args.openclawBin, systemctlBin: args.systemctlBin, installedAt: new Date().toISOString(),
375
409
  managedFiles: [
376
410
  { path: path.relative(args.root, files.queueConfig), sha256: sha256(queueContent) },
377
411
  { path: path.relative(args.root, files.dispatcher), sha256: sha256(dispatcherContent) },
378
412
  { path: path.relative(args.root, files.wrapper), sha256: sha256(wrapperContent) },
379
- { path: path.relative(args.root, files.notifier), sha256: sha256(notifierContent) }
413
+ { path: path.relative(args.root, files.notifier), sha256: sha256(notifierContent) },
414
+ { path: path.relative(args.root, files.gateBridge), sha256: sha256(gateBridgeContent) }
380
415
  ],
381
416
  managedUnits: [
382
417
  { path: files.schedulerService, unit: schedulerUnit, sha256: sha256(schedulerServiceContent) },
@@ -53,6 +53,10 @@ async function main() {
53
53
  const doctor = await run(process.execPath, [doctorScript, '--root', args.root, '--queue', args.queue, '--worker-agent', args.workerAgent, '--openclaw-bin', args.openclawBin, '--json'], { cwd: args.root });
54
54
  steps.push({ id: 'doctor', ok: doctor.code === 0 });
55
55
  if (doctor.code !== 0) throw new Error(`doctor failed: ${doctor.stderr || doctor.stdout}`);
56
+ const doctorReport = JSON.parse(doctor.stdout);
57
+ const gateSelfTestOk = doctorReport.checks?.some((check) => check.id === 'human_gate_bridge_self_test' && check.ok);
58
+ steps.push({ id: 'human_gate_bridge_self_test', ok: gateSelfTestOk === true });
59
+ if (!gateSelfTestOk) throw new Error('doctor did not verify the installed Human Gate bridge');
56
60
  const config = JSON.parse(await readFile(baseConfig, 'utf8'));
57
61
  config.queue = smokeQueue;
58
62
  config.description = 'Temporary read-only OpenClaw integration smoke queue.';
@@ -90,7 +90,8 @@ try {
90
90
  assert.match(page, /No matching operational work/);
91
91
  assert.match(page, /Unable to load workspace/);
92
92
  assert.match(page, /Gates & reservations/);
93
- assert.doesNotMatch(page, /approve|confirm-send|settle reservation/i);
93
+ assert.match(page, /approve/);
94
+ assert.doesNotMatch(page, /confirm-send|settle reservation/i);
94
95
  const project = await fetch(`${base}/api/v1/projects/p3`).then((response) => response.json());
95
96
  assert.equal(project.terminal_contract.milestone_rule, 'A milestone is not project completion.');
96
97
  assert.doesNotMatch(page, /<img src=x/);
@@ -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 { decideQuota, readQuotaLedger, recordVerifiedSliceSpend } from '../lib/quota-runtime-decision.mjs';
6
+
7
+ const budget = { tokens: 100, time_ms: 1_000, money_minor: 50, rounds: 3 };
8
+ const request = { tokens: 10, time_ms: 100, money_minor: 5, rounds: 1 };
9
+ assert.equal(decideQuota({ has_work: true, limits: budget, request }).decision, 'execute');
10
+ assert.equal(decideQuota({ has_work: false, limits: budget }).decision, 'silent');
11
+ assert.equal(decideQuota({ has_work: true, repairable_error: true, limits: budget }).decision, 'self-repair');
12
+ assert.equal(decideQuota({ has_work: true, external_condition_pending: true, limits: budget }).decision, 'wait');
13
+ assert.equal(decideQuota({ has_work: true, limits: budget, spend: { tokens: 95 }, request }).decision, 'wait');
14
+ assert.equal(decideQuota({ has_work: true, limits: budget, spend: { tokens: 95 }, request, can_wait_for_reset: false }).decision, 'ask');
15
+
16
+ const fallback = decideQuota({ has_work: true, limits: budget, request, lane_id: 'paid', lanes: [
17
+ { id: 'paid', state: 'waiting_for_human' },
18
+ { id: 'local', state: 'runnable', safe_fallback: true, audited: true }
19
+ ] });
20
+ assert.equal(fallback.decision, 'execute'); assert.equal(fallback.lane_id, 'local'); assert.equal(fallback.reason, 'audited_safe_fallback');
21
+ assert.equal(decideQuota({ has_work: true, limits: budget, request, lane_id: 'paid', lanes: [{ id: 'paid', state: 'waiting_for_human' }, { id: 'unsafe', state: 'runnable', safe_fallback: true, audited: false }] }).decision, 'ask');
22
+
23
+ const root = await mkdtemp(path.join(tmpdir(), 'quota-runtime-'));
24
+ assert.equal((await recordVerifiedSliceSpend(root, { slice_id: 'idle', status: 'idle', verified: false, spend: request })).recorded, false);
25
+ assert.deepEqual((await readQuotaLedger(root)).spend, { tokens: 0, time_ms: 0, money_minor: 0, rounds: 0 });
26
+ assert.equal((await recordVerifiedSliceSpend(root, { slice_id: 's1', status: 'completed', verified: true, spend: request, evidence: 'test:pass' })).recorded, true);
27
+ assert.equal((await recordVerifiedSliceSpend(root, { slice_id: 's1', status: 'completed', verified: true, spend: request })).recorded, false);
28
+ assert.deepEqual((await readQuotaLedger(root)).spend, request);
29
+ console.log(JSON.stringify({ ok: true, assertions: ['five decisions', 'scheduler hint', 'four budget dimensions', 'verified-only spend', 'idle free', 'idempotent spend', 'audited fallback'] }));
@@ -23,7 +23,10 @@ await assert.rejects(() => renewTodo(root, { todoId: 'race', agentId: loser ===
23
23
  await todo('capability');
24
24
  assert.equal((await claimTodo(root, { todoId: 'capability', agentId: 'observer' })).reason, 'capability_mismatch');
25
25
  await todo('quota');
26
- assert.equal((await claimTodo(root, { todoId: 'quota', agentId: 'poor' })).reason, 'quota_exhausted');
26
+ const quotaRejected = await claimTodo(root, { todoId: 'quota', agentId: 'poor' });
27
+ assert.equal(quotaRejected.reason, 'quota_exhausted');
28
+ assert.equal(quotaRejected.decision, 'wait');
29
+ assert.equal(quotaRejected.scheduler_hint.action, 'wait');
27
30
 
28
31
  await todo('dependency');
29
32
  await todo('dependent', { dependencies: ['dependency'], priority: 100 });
@@ -120,6 +120,14 @@ an LLM call. Preserve source metadata and pass `--source-target` in Hermes
120
120
  `platform:chat_id[:thread_id]` format. The managed systemd scheduler owns wakeups
121
121
  so durable queue continuity does not depend on resuming a Hermes Cron session.
122
122
 
123
+ ## Cross-interface Human Gates
124
+
125
+ Dashboard and chat approvals use one Gate Command core and one authoritative gate artifact. Cards must show project/task, Gate ID, action, reason, impact, risk, cost/budget, evidence, Dashboard URL, expiry and generation; processed cards are refreshed disabled. Only card buttons or exact card-bound `/approve gate_<id>`, `/reject gate_<id>` and `/request_revision gate_<id> <reason>` replies may mutate a gate. Ordinary chat, quotes, forwards, screenshots and ordinal phrases fail closed as `ignored_untrusted_chat`; `/show_gate gate_<id>` is display-only.
126
+
127
+ The OpenClaw installer generates `scripts/loops/openclaw-loop-gate.mjs`. A trusted Feishu callback/plugin transport must verify the official signature or encrypted-event envelope, timestamp/nonce and secret before invoking it with `LOOP_GATE_CHANNEL=feishu` and `LOOP_FEISHU_SIGNATURE_VERIFIED=1`; missing verification fails with `feishu_signature_unverified`. The bridge itself performs no delivery. Other channel transports pass normalized, source-bound events. Dashboard and chat commands share actor/source binding, generation fencing, idempotency receipts, confirmation escalation and synchronized card state.
128
+
129
+ After installation, run `loop-engineering-openclaw-doctor` and `loop-engineering-openclaw-smoke`. Doctor syntax-checks and locally self-tests the Gate bridge; smoke remains disposable and uses dry-run notifications. Neither command sends an online test message.
130
+
123
131
  ## Conversation Contract
124
132
 
125
133
  Interpret explicit loop language as follows: