taskforce-loop-engineering 0.15.13 → 0.15.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/bin/loop-engineering.mjs +15 -2
  3. package/docs/agent-team-backlog.json +1 -0
  4. package/docs/agent-team-terminal-contract.json +1 -0
  5. package/docs/human-gate-command.md +17 -0
  6. package/docs/multi-agent-control-plane.md +8 -0
  7. package/docs/quota-runtime-decision.md +9 -0
  8. package/lib/core.mjs +74 -10
  9. package/lib/human-gate-channel-adapter.mjs +37 -0
  10. package/lib/human-gate-command.mjs +161 -0
  11. package/lib/operator-dashboard.mjs +24 -3
  12. package/lib/quota-runtime-decision.mjs +62 -0
  13. package/lib/todo-control-plane.mjs +105 -6
  14. package/package.json +19 -6
  15. package/scripts/agent-team-control-plane-self-test.mjs +29 -0
  16. package/scripts/agent-team-final-judgement.mjs +27 -0
  17. package/scripts/distribution-skill-self-test.mjs +13 -3
  18. package/scripts/final-judgement-self-test.mjs +25 -0
  19. package/scripts/human-gate-command-self-test.mjs +52 -0
  20. package/scripts/human-gate-final-judgement.mjs +30 -0
  21. package/scripts/live-agent-team-conformance.mjs +61 -0
  22. package/scripts/openclaw-doctor.mjs +8 -0
  23. package/scripts/openclaw-install-self-test.mjs +4 -2
  24. package/scripts/openclaw-install.mjs +38 -3
  25. package/scripts/openclaw-smoke.mjs +4 -0
  26. package/scripts/operator-dashboard-self-test.mjs +2 -1
  27. package/scripts/project-gate-reconciliation-self-test.mjs +27 -12
  28. package/scripts/quota-runtime-decision-self-test.mjs +29 -0
  29. package/scripts/route-notify-self-test.mjs +4 -4
  30. package/scripts/todo-control-plane-self-test.mjs +4 -1
  31. package/skills/taskforce-loop-engineering/SKILL.md +8 -0
  32. package/skills/taskforce-loop-engineering/references/npm-package.md +1 -1
@@ -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) => {
@@ -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/);
@@ -40,7 +40,7 @@ assert(authorizedContract.constraints.allowed_actions.includes('paid_provider_ac
40
40
  assert(authorizedContract.constraints.blocked_actions.includes('credential_change_without_explicit_confirmation'));
41
41
  await writeFile(projectFile, `${JSON.stringify(spec, null, 2)}\n`);
42
42
 
43
- const b = await enqueueTask(root, { queue, title: 'OpenReel B-01', task: 'Project openreel requirement B-01', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
43
+ const b = await enqueueTask(root, { queue, title: 'OpenReel B-01', task: 'Project openreel requirement B-01', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner', sourceAccount: 'main' });
44
44
  const checkpointDir = path.join(taskRuntimeDirFor(root, queue, b.task.id), 'checkpoints');
45
45
  await mkdir(checkpointDir, { recursive: true });
46
46
  await writeFile(path.join(checkpointDir, 'cp1.json'), `${JSON.stringify({ version: 1, task_id: b.task.id, checkpoint_id: 'cp1', milestone_id: 'B-01', requirement_ids: ['B-01'], status: 'needs_human_input', blockers: ['Authorize B-01'] }, null, 2)}\n`);
@@ -132,7 +132,7 @@ await writeFile(projectFile, `${JSON.stringify({
132
132
 
133
133
  // A ready milestone with project in progress and a deferred authorization is
134
134
  // converted into a structured waiting gate, not left as prose on a done task.
135
- const deferred = await enqueueTask(root, { queue, title: 'OpenReel deferred S-01', task: 'Project openreel S-01', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
135
+ const deferred = await enqueueTask(root, { queue, title: 'OpenReel deferred S-01', task: 'Project openreel S-01', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner', sourceAccount: 'main' });
136
136
  const deferredDir = path.join(taskRuntimeDirFor(root, queue, deferred.task.id), 'checkpoints');
137
137
  await mkdir(deferredDir, { recursive: true });
138
138
  await writeFile(path.join(deferredDir, 'cp-ready.json'), `${JSON.stringify({
@@ -164,7 +164,7 @@ await writeFile(authoritativeBacklog, `${JSON.stringify({
164
164
  { id: 'LOCAL-02', status: 'pending', dependsOn: ['LOCAL-01'] }
165
165
  ]
166
166
  }, null, 2)}\n`);
167
- const futureGateTask = await enqueueTask(root, { queue, title: 'OpenReel future production gate', task: 'Continue safe local backlog', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
167
+ const futureGateTask = await enqueueTask(root, { queue, title: 'OpenReel future production gate', task: 'Continue safe local backlog', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner', sourceAccount: 'main' });
168
168
  const futureGateDir = path.join(taskRuntimeDirFor(root, queue, futureGateTask.task.id), 'checkpoints');
169
169
  await mkdir(futureGateDir, { recursive: true });
170
170
  await writeFile(path.join(futureGateDir, 'cp1.json'), `${JSON.stringify({
@@ -180,7 +180,7 @@ assert.equal((await queueStatus(root, queue)).waiting, 0);
180
180
  // deferred_gates must not materialize a gate without a concrete action and
181
181
  // authority requirement.
182
182
  await reconcileProjectGates(root, { queue });
183
- const conditional = await enqueueTask(root, { queue, title: 'OpenReel conditional policy boundary', task: 'Continue safe OpenReel backlog', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
183
+ const conditional = await enqueueTask(root, { queue, title: 'OpenReel conditional policy boundary', task: 'Continue safe OpenReel backlog', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner', sourceAccount: 'main' });
184
184
  const conditionalDir = path.join(taskRuntimeDirFor(root, queue, conditional.task.id), 'checkpoints');
185
185
  await mkdir(conditionalDir, { recursive: true });
186
186
  await writeFile(path.join(conditionalDir, 'cp1.json'), `${JSON.stringify({
@@ -194,7 +194,7 @@ assert.equal(conditionalNotice.results.some((item) => item.taskId === conditiona
194
194
  // A conditional formal blocker becomes current when the producer explicitly
195
195
  // marks it needed now and materialize=true. It must stop once, rather than be
196
196
  // filtered into a needs_revision/project_in_progress polling loop.
197
- const conditionalNow = await enqueueTask(root, { queue, title: 'OpenReel conditional blocker now', task: 'Wait for a real external precondition', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
197
+ const conditionalNow = await enqueueTask(root, { queue, title: 'OpenReel conditional blocker now', task: 'Wait for a real external precondition', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner', sourceAccount: 'main' });
198
198
  const conditionalNowDir = path.join(taskRuntimeDirFor(root, queue, conditionalNow.task.id), 'checkpoints');
199
199
  await mkdir(conditionalNowDir, { recursive: true });
200
200
  await writeFile(path.join(conditionalNowDir, 'cp1.json'), `${JSON.stringify({
@@ -213,7 +213,7 @@ await reconcileProjectGates(root, { queue });
213
213
  // Authorization already granted or already consumed is audit context, not a
214
214
  // new human-input request. A future boundary is likewise dormant.
215
215
  for (const authorizationState of ['authorized', 'consumed', 'future']) {
216
- const stateTask = await enqueueTask(root, { queue, title: `OpenReel ${authorizationState} authority`, task: 'Continue within recorded authority', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
216
+ const stateTask = await enqueueTask(root, { queue, title: `OpenReel ${authorizationState} authority`, task: 'Continue within recorded authority', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner', sourceAccount: 'main' });
217
217
  const stateDir = path.join(taskRuntimeDirFor(root, queue, stateTask.task.id), 'checkpoints');
218
218
  await mkdir(stateDir, { recursive: true });
219
219
  await writeFile(path.join(stateDir, 'cp1.json'), `${JSON.stringify({
@@ -239,7 +239,7 @@ await writeFile(subprojectBacklog, `${JSON.stringify({
239
239
  { id: 'CDQI2-11', status: 'in_progress', dependsOn: ['CDQI2-10'] }
240
240
  ]
241
241
  }, null, 2)}\n`);
242
- const subprojectTask = await enqueueTask(root, { queue, title: 'OpenReel CDQI2 actionable backlog', task: 'Continue CDQI2-11', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
242
+ const subprojectTask = await enqueueTask(root, { queue, title: 'OpenReel CDQI2 actionable backlog', task: 'Continue CDQI2-11', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner', sourceAccount: 'main' });
243
243
  const subprojectDir = path.join(taskRuntimeDirFor(root, queue, subprojectTask.task.id), 'checkpoints');
244
244
  await mkdir(subprojectDir, { recursive: true });
245
245
  await writeFile(path.join(subprojectDir, 'cp1.json'), `${JSON.stringify({
@@ -254,7 +254,7 @@ assert.equal(subprojectNotice.results.some((item) => item.taskId === subprojectT
254
254
  // A genuinely missing current authorization becomes a waiting gate once no
255
255
  // safe project work remains.
256
256
  await writeFile(authoritativeBacklog, `${JSON.stringify({ status: 'ongoing', items: [{ id: 'GLOBAL-01', status: 'accepted', dependsOn: [] }] }, null, 2)}\n`);
257
- const missing = await enqueueTask(root, { queue, title: 'OpenReel missing current authority', task: 'Perform currently gated action', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
257
+ const missing = await enqueueTask(root, { queue, title: 'OpenReel missing current authority', task: 'Perform currently gated action', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner', sourceAccount: 'main' });
258
258
  const missingDir = path.join(taskRuntimeDirFor(root, queue, missing.task.id), 'checkpoints');
259
259
  await mkdir(missingDir, { recursive: true });
260
260
  await writeFile(path.join(missingDir, 'cp1.json'), `${JSON.stringify({
@@ -279,7 +279,7 @@ await writeFile(projectFile, `${JSON.stringify({
279
279
  backupRestoreRollbackRehearsal: 'standing_authorization_openreel_2026-08-19'
280
280
  }
281
281
  }, null, 2)}\n`);
282
- const coveredBlocker = await enqueueTask(root, { queue, title: 'OpenReel covered production blocker', task: 'Deploy accepted candidate', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
282
+ const coveredBlocker = await enqueueTask(root, { queue, title: 'OpenReel covered production blocker', task: 'Deploy accepted candidate', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner', sourceAccount: 'main' });
283
283
  const coveredContract = await writeTaskContract(root, queue, coveredBlocker.task);
284
284
  assert.equal(coveredContract.contract.constraints.project_authorization.production_authorized, true);
285
285
  const coveredDir = path.join(taskRuntimeDirFor(root, queue, coveredBlocker.task.id), 'checkpoints');
@@ -294,7 +294,7 @@ assert.equal(coveredNotice.results.some((item) => item.taskId === coveredBlocker
294
294
 
295
295
  // Explicitly authorized blocker metadata is also non-materializable, while a
296
296
  // genuinely missing publication permission remains a human gate.
297
- const publication = await enqueueTask(root, { queue, title: 'OpenReel publication blocker', task: 'Publish candidate', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
297
+ const publication = await enqueueTask(root, { queue, title: 'OpenReel publication blocker', task: 'Publish candidate', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner', sourceAccount: 'main' });
298
298
  await writeTaskContract(root, queue, publication.task);
299
299
  const publicationDir = path.join(taskRuntimeDirFor(root, queue, publication.task.id), 'checkpoints');
300
300
  await mkdir(publicationDir, { recursive: true });
@@ -332,7 +332,7 @@ await rename(legacyInbox, `${legacyInbox}.moved`);
332
332
  await reconcileProjectGates(root, { queue });
333
333
  assert.equal((await queueStatus(root, queue)).done >= 1, true, 'accepted project task must close in done, not canceled');
334
334
  assert.equal(JSON.parse(await readFile(legacyGateFile, 'utf8')).status, 'superseded');
335
- const acceptedOptional = await enqueueTask(root, { queue, title: 'OpenReel optional operations transfer', task: 'Project openreel optional post-completion transfer', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
335
+ const acceptedOptional = await enqueueTask(root, { queue, title: 'OpenReel optional operations transfer', task: 'Project openreel optional post-completion transfer', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner', sourceAccount: 'main' });
336
336
  const acceptedOptionalDir = path.join(taskRuntimeDirFor(root, queue, acceptedOptional.task.id), 'checkpoints');
337
337
  await mkdir(acceptedOptionalDir, { recursive: true });
338
338
  await writeFile(path.join(acceptedOptionalDir, 'cp1.json'), `${JSON.stringify({
@@ -344,4 +344,19 @@ const acceptedNotice = await notifyHumanInputRequests(root, { queue, notifyComma
344
344
  assert.equal(acceptedNotice.results.some((item) => item.taskId === acceptedOptional.task.id), false);
345
345
  assert.equal((await queueStatus(root, queue)).waiting, 0);
346
346
 
347
- console.log(JSON.stringify({ status: 'ok', assertions: ['standing project authorization reaches task contract', 'structured gate context', 'B-01 waiting to zero', 'project-isolated supersede', 'doctor strong validation', 'authoritative ledger drift', 'ready milestone deferred gate', 'future gate does not stop safe actionable backlog', 'conditional policy prose does not create a gate', 'authorized and consumed authority do not create gates', 'checkpoint-bound subproject backlog remains actionable', 'missing current authority creates a gate', 'standing-authorized production blocker does not create a gate', 'missing publication blocker creates a gate', 'accepted project optional deferred gate stays out of queue'] }));
347
+ // Missing source/actor/generation binding must fail closed: no waiting move,
348
+ // no gate command text, and no notification invocation.
349
+ const unbound = await enqueueTask(root, { queue, title: 'Unbound human request', task: 'Project openreel B-01', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
350
+ const unboundDir = path.join(taskRuntimeDirFor(root, queue, unbound.task.id), 'checkpoints');
351
+ await mkdir(unboundDir, { recursive: true });
352
+ await writeFile(path.join(unboundDir, 'cp1.json'), `${JSON.stringify({
353
+ version: 1, task_id: unbound.task.id, checkpoint_id: 'cp1', milestone_id: 'B-01',
354
+ status: 'needs_human_input', blockers: [{ action: 'approve', required_authority: 'owner', authorization_state: 'missing', needed_when: 'now' }]
355
+ }, null, 2)}\n`);
356
+ const unboundNotice = await notifyHumanInputRequests(root, { queue, notifyCommand: '/bin/true' });
357
+ const unboundResult = unboundNotice.results.find((item) => item.taskId === unbound.task.id);
358
+ assert.equal(unboundResult.outcome, 'fail_closed');
359
+ assert.equal(unboundResult.message, undefined);
360
+ assert.equal((await queueStatus(root, queue)).waiting, 0);
361
+
362
+ console.log(JSON.stringify({ status: 'ok', assertions: ['standing project authorization reaches task contract', 'structured gate context', 'B-01 waiting to zero', 'project-isolated supersede', 'doctor strong validation', 'authoritative ledger drift', 'ready milestone deferred gate', 'future gate does not stop safe actionable backlog', 'conditional policy prose does not create a gate', 'authorized and consumed authority do not create gates', 'checkpoint-bound subproject backlog remains actionable', 'missing current authority creates a gate', 'standing-authorized production blocker does not create a gate', 'missing publication blocker creates a gate', 'accepted project optional deferred gate stays out of queue', 'unbound gate fails closed without actionable text'] }));
@@ -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'] }));
@@ -423,8 +423,8 @@ await writeJson(path.join(root, 'configs', 'loops', 'queues', `${queue}.json`),
423
423
 
424
424
  const gateDryRun = await notifyHumanInputRequests(root, { queue, dryRun: true });
425
425
  assert.equal(gateDryRun.results[0].outcome, 'dry_run');
426
- assert.match(gateDryRun.results[0].message, /Provide the SMS code/);
427
- assert.match(gateDryRun.results[0].message, /正在等待你的输入/);
426
+ assert.equal(gateDryRun.results[0].preview, 'authoritative_gate_would_be_materialized');
427
+ assert.equal(gateDryRun.results[0].message, undefined, 'dry-run must not emit an actionable reply command before gate materialization');
428
428
  const gateSent = await notifyHumanInputRequests(root, { queue, notifyCommand: '/bin/true' });
429
429
  assert.equal(gateSent.sent, 1);
430
430
  const gateId = gateSent.results[0].gateId;
@@ -544,7 +544,7 @@ await writeJson(path.join(regressionRoot, 'configs', 'loops', 'projects', 'demo.
544
544
  for (const [id, enqueuedAt] of [['history-done', '2026-01-01T00:00:00Z'], ['latest-done', '2026-01-02T00:00:00Z']]) {
545
545
  await writeJson(path.join(queueSubdirFor(regressionRoot, regressionQueue, 'done'), `${id}.json`), {
546
546
  id, title: `demo ${id}`, body: 'demo R-1', projectId: 'demo', status: 'completed', enqueuedAt,
547
- source: { channel: 'test', target: 'owner' }
547
+ source: { channel: 'test', target: 'owner', account: 'main' }
548
548
  });
549
549
  await writeJson(path.join(taskRuntimeDirFor(regressionRoot, regressionQueue, id), 'checkpoints', 'cp1.json'), {
550
550
  version: 1, task_id: id, checkpoint_id: 'cp1', milestone_id: 'R-1', sequence: 1,
@@ -577,7 +577,7 @@ await writeJson(path.join(queueSubdirFor(regressionRoot, regressionQueue, 'faile
577
577
  projectId: 'demo',
578
578
  status: 'blocked',
579
579
  enqueuedAt: '2026-01-03T00:00:00Z',
580
- source: { channel: 'feishu', target: 'owner' }
580
+ source: { channel: 'feishu', target: 'owner', account: 'main' }
581
581
  });
582
582
  await writeJson(path.join(taskRuntimeDirFor(regressionRoot, regressionQueue, blockedProjectTask), 'checkpoints', 'cp2.json'), {
583
583
  version: 1,
@@ -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:
@@ -1,7 +1,7 @@
1
1
  # npm Package
2
2
 
3
3
  Package name: `taskforce-loop-engineering`
4
- Version: `0.9.0`
4
+ Version: `0.15.15`
5
5
 
6
6
  Install from npm:
7
7