taskforce-loop-engineering 0.15.13 → 0.15.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/bin/loop-engineering.mjs +15 -2
- package/docs/agent-team-backlog.json +1 -0
- package/docs/agent-team-terminal-contract.json +1 -0
- package/docs/human-gate-command.md +17 -0
- package/docs/multi-agent-control-plane.md +8 -0
- package/docs/quota-runtime-decision.md +9 -0
- package/lib/human-gate-channel-adapter.mjs +37 -0
- package/lib/human-gate-command.mjs +161 -0
- package/lib/operator-dashboard.mjs +24 -3
- package/lib/quota-runtime-decision.mjs +62 -0
- package/lib/todo-control-plane.mjs +105 -6
- package/package.json +19 -6
- package/scripts/agent-team-control-plane-self-test.mjs +29 -0
- package/scripts/agent-team-final-judgement.mjs +27 -0
- package/scripts/distribution-skill-self-test.mjs +13 -3
- package/scripts/human-gate-command-self-test.mjs +52 -0
- package/scripts/human-gate-final-judgement.mjs +30 -0
- package/scripts/live-agent-team-conformance.mjs +61 -0
- package/scripts/openclaw-doctor.mjs +8 -0
- package/scripts/openclaw-install-self-test.mjs +4 -2
- package/scripts/openclaw-install.mjs +38 -3
- package/scripts/openclaw-smoke.mjs +4 -0
- package/scripts/operator-dashboard-self-test.mjs +2 -1
- package/scripts/quota-runtime-decision-self-test.mjs +29 -0
- package/scripts/todo-control-plane-self-test.mjs +4 -1
- package/skills/taskforce-loop-engineering/SKILL.md +8 -0
|
@@ -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:
|
|
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.
|
|
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
|
-
|
|
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:
|