taskforce-loop-engineering 0.15.12 → 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 +13 -0
- package/README.md +1 -1
- 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/operator-dashboard.md +25 -2
- package/docs/operator-workspace-project.md +30 -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 +75 -8
- package/lib/quota-runtime-decision.mjs +62 -0
- package/lib/todo-control-plane.mjs +105 -6
- package/package.json +21 -6
- package/scripts/agent-team-control-plane-self-test.mjs +29 -0
- package/scripts/agent-team-final-judgement.mjs +27 -0
- package/scripts/dashboard-autostart-install.mjs +91 -0
- package/scripts/dashboard-autostart-self-test.mjs +46 -0
- package/scripts/distribution-skill-self-test.mjs +13 -3
- package/scripts/hermes-doctor.mjs +2 -1
- package/scripts/hermes-install-self-test.mjs +2 -1
- package/scripts/hermes-install.mjs +11 -4
- 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 +13 -0
- package/scripts/openclaw-install-self-test.mjs +8 -3
- package/scripts/openclaw-install.mjs +48 -6
- package/scripts/openclaw-smoke.mjs +4 -0
- package/scripts/operator-dashboard-self-test.mjs +27 -1
- package/scripts/operator-workspace-final-judgement.mjs +41 -0
- 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 +12 -0
- package/templates/operator-projection.schema.json +1 -1
|
@@ -5,7 +5,7 @@ import { spawn } from 'node:child_process';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
|
|
7
7
|
function parseArgs(argv) {
|
|
8
|
-
const out = { root: process.cwd(), queue: 'agent-tasks', workerAgent: null, openclawBin: 'openclaw', systemctlBin: 'systemctl', language: 'auto', json: false, confirmInstall: false, force: false };
|
|
8
|
+
const out = { root: process.cwd(), queue: 'agent-tasks', workerAgent: null, openclawBin: 'openclaw', systemctlBin: 'systemctl', dashboardListen: 'localhost', tailscaleBin: 'tailscale', language: 'auto', json: false, confirmInstall: false, force: false };
|
|
9
9
|
for (let i = 0; i < argv.length; i++) {
|
|
10
10
|
const arg = argv[i];
|
|
11
11
|
if (arg === '--root') out.root = path.resolve(argv[++i]);
|
|
@@ -13,6 +13,8 @@ function parseArgs(argv) {
|
|
|
13
13
|
else if (arg === '--worker-agent') out.workerAgent = argv[++i];
|
|
14
14
|
else if (arg === '--openclaw-bin') out.openclawBin = argv[++i];
|
|
15
15
|
else if (arg === '--systemctl-bin') out.systemctlBin = argv[++i];
|
|
16
|
+
else if (arg === '--dashboard-listen') out.dashboardListen = argv[++i];
|
|
17
|
+
else if (arg === '--tailscale-bin') out.tailscaleBin = argv[++i];
|
|
16
18
|
else if (arg === '--language') out.language = argv[++i];
|
|
17
19
|
else if (arg === '--confirm-install') out.confirmInstall = true;
|
|
18
20
|
else if (arg === '--force') out.force = true;
|
|
@@ -20,6 +22,7 @@ function parseArgs(argv) {
|
|
|
20
22
|
else if (arg === '--help' || arg === '-h') out.help = true;
|
|
21
23
|
else throw new Error(`Unknown argument: ${arg}`);
|
|
22
24
|
}
|
|
25
|
+
if (!['localhost', 'tailscale'].includes(out.dashboardListen)) throw new Error('--dashboard-listen must be localhost or tailscale.');
|
|
23
26
|
return out;
|
|
24
27
|
}
|
|
25
28
|
|
|
@@ -178,6 +181,7 @@ function run(args) {
|
|
|
178
181
|
child.on('close', (code, signal) => resolve(code ?? (signal ? 128 : 1)));
|
|
179
182
|
});
|
|
180
183
|
}
|
|
184
|
+
|
|
181
185
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
182
186
|
async function runWhenUnlocked(args, waitMs = 300000) {
|
|
183
187
|
const deadline = Date.now() + waitMs;
|
|
@@ -226,6 +230,31 @@ if (command === 'route') {
|
|
|
226
230
|
`;
|
|
227
231
|
}
|
|
228
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
|
+
|
|
229
258
|
function schedulerServiceSource({ root, queue, language }) {
|
|
230
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`;
|
|
231
260
|
}
|
|
@@ -267,6 +296,8 @@ function instructionsBlock({ queue, language }) {
|
|
|
267
296
|
- 从当前工作区运行 \`node scripts/loops/openclaw-loop.mjs route --message "<完整用户消息>"\`,并保留来源元数据。
|
|
268
297
|
- 会话来源任务必须使用标准包装器。缺少来源元数据时必须失败关闭,不能退回手工入队或直接运行队列。
|
|
269
298
|
- 人工门禁或终态只有在通知命令成功并写入通知记录后才算已送达。
|
|
299
|
+
- Dashboard 与聊天审批必须共用 Gate Command 和同一份门禁状态;聊天接入使用 \`scripts/loops/openclaw-loop-gate.mjs\`。
|
|
300
|
+
- 飞书回调必须先由可信传输层验签,再设置 \`LOOP_FEISHU_SIGNATURE_VERIFIED=1\` 调用 Gate bridge;未验签失败关闭。普通聊天、引用、转发和截图不能审批。
|
|
270
301
|
- 已由 Loop 管理的任务必须直接执行,不能再次路由。
|
|
271
302
|
- 状态查询只读。高风险外部动作、破坏性操作、生产变更、凭据操作和记忆迁移仍需单独确认。
|
|
272
303
|
- 队列:\`${queue}\`。
|
|
@@ -280,6 +311,8 @@ function instructionsBlock({ queue, language }) {
|
|
|
280
311
|
- Run \`node scripts/loops/openclaw-loop.mjs route --message "<full user message>"\` from this workspace and preserve source metadata when available.
|
|
281
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.
|
|
282
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.
|
|
283
316
|
- An already loop-managed task must be executed directly and never routed again.
|
|
284
317
|
- Status questions are read-only. High-risk external, destructive, production, credential, or memory migration actions remain separately gated.
|
|
285
318
|
- Queue: \`${queue}\`.
|
|
@@ -290,7 +323,7 @@ async function main() {
|
|
|
290
323
|
const args = parseArgs(process.argv.slice(2));
|
|
291
324
|
args.language = resolveLanguage(args.language);
|
|
292
325
|
if (args.help) {
|
|
293
|
-
console.log('Usage: loop-engineering-openclaw-install [--root workspace] [--queue agent-tasks] [--worker-agent agent-id] [--language auto|en|zh] [--openclaw-bin openclaw] [--systemctl-bin systemctl] [--confirm-install] [--force] [--json]');
|
|
326
|
+
console.log('Usage: loop-engineering-openclaw-install [--root workspace] [--queue agent-tasks] [--worker-agent agent-id] [--dashboard-listen localhost|tailscale] [--tailscale-bin tailscale] [--language auto|en|zh] [--openclaw-bin openclaw] [--systemctl-bin systemctl] [--confirm-install] [--force] [--json]');
|
|
294
327
|
return;
|
|
295
328
|
}
|
|
296
329
|
safeId(args.queue, 'queue');
|
|
@@ -299,6 +332,7 @@ async function main() {
|
|
|
299
332
|
const worker = await resolveWorkerAgent(args);
|
|
300
333
|
args.workerAgent = worker.workerAgent;
|
|
301
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;
|
|
302
336
|
const systemdUserDir = path.join(process.env.XDG_CONFIG_HOME || path.join(process.env.HOME || '', '.config'), 'systemd', 'user');
|
|
303
337
|
const schedulerUnit = `openclaw-loop-${args.queue}-scheduler.service`;
|
|
304
338
|
const schedulerTimer = `openclaw-loop-${args.queue}-scheduler.timer`;
|
|
@@ -308,6 +342,7 @@ async function main() {
|
|
|
308
342
|
dispatcher: path.join(args.root, 'scripts', 'loops', 'openclaw-loop-dispatch.mjs'),
|
|
309
343
|
wrapper: path.join(args.root, 'scripts', 'loops', 'openclaw-loop.mjs'),
|
|
310
344
|
notifier: path.join(args.root, 'scripts', 'loops', 'openclaw-loop-notify.mjs'),
|
|
345
|
+
gateBridge: path.join(args.root, 'scripts', 'loops', 'openclaw-loop-gate.mjs'),
|
|
311
346
|
manifest: path.join(args.root, 'runtime', 'loop-engineering-openclaw-install.json'),
|
|
312
347
|
instructions: path.join(args.root, 'AGENTS.md'),
|
|
313
348
|
schedulerService: path.join(systemdUserDir, schedulerUnit),
|
|
@@ -315,8 +350,9 @@ async function main() {
|
|
|
315
350
|
};
|
|
316
351
|
const conflicts = [];
|
|
317
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));
|
|
318
|
-
const
|
|
319
|
-
const
|
|
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';
|
|
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 };
|
|
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 };
|
|
320
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 重新运行。');
|
|
321
357
|
if (conflicts.length && !args.force && args.confirmInstall) throw new Error(`Refusing to overwrite: ${conflicts.join(', ')}. Use --force after review.`);
|
|
322
358
|
if (!args.json) console.log(formatConfirmationSummary(confirmationSummary, args.language));
|
|
@@ -346,12 +382,14 @@ async function main() {
|
|
|
346
382
|
const dispatcherContent = dispatcherSource(args);
|
|
347
383
|
const wrapperContent = wrapperSource(args);
|
|
348
384
|
const notifierContent = notifierSource(args);
|
|
385
|
+
const gateBridgeContent = gateBridgeSource(args);
|
|
349
386
|
const schedulerServiceContent = schedulerServiceSource(args);
|
|
350
387
|
const schedulerTimerContent = schedulerTimerSource(args);
|
|
351
388
|
await writeFile(files.queueConfig, queueContent);
|
|
352
389
|
await writeFile(files.dispatcher, dispatcherContent);
|
|
353
390
|
await writeFile(files.wrapper, wrapperContent);
|
|
354
391
|
await writeFile(files.notifier, notifierContent);
|
|
392
|
+
await writeFile(files.gateBridge, gateBridgeContent);
|
|
355
393
|
await mkdir(systemdUserDir, { recursive: true });
|
|
356
394
|
await writeFile(files.schedulerService, schedulerServiceContent);
|
|
357
395
|
await writeFile(files.schedulerTimer, schedulerTimerContent);
|
|
@@ -359,17 +397,21 @@ async function main() {
|
|
|
359
397
|
if (daemonReload.code !== 0) throw new Error(`Cannot reload user systemd units: ${(daemonReload.stderr || daemonReload.stdout).trim() || `exit ${daemonReload.code}`}`);
|
|
360
398
|
const enableTimer = await run(args.systemctlBin, ['--user', 'enable', '--now', schedulerTimer], { cwd: args.root });
|
|
361
399
|
if (enableTimer.code !== 0) throw new Error(`Cannot enable Loop scheduler timer ${schedulerTimer}: ${(enableTimer.stderr || enableTimer.stdout).trim() || `exit ${enableTimer.code}`}`);
|
|
400
|
+
const dashboardInstaller = new URL('./dashboard-autostart-install.mjs', import.meta.url).pathname;
|
|
401
|
+
const dashboardInstall = await run(process.execPath, [dashboardInstaller, '--root', args.root, '--listen', args.dashboardListen, '--tailscale-bin', args.tailscaleBin, '--systemctl-bin', args.systemctlBin, '--confirm-install', '--json'], { cwd: args.root });
|
|
402
|
+
if (dashboardInstall.code !== 0) throw new Error(`Cannot install Dashboard gateway autostart: ${(dashboardInstall.stderr || dashboardInstall.stdout).trim()}`);
|
|
362
403
|
const instructions = await exists(files.instructions) ? await readFile(files.instructions, 'utf8') : '';
|
|
363
404
|
const managedInstructions = instructionsBlock(args);
|
|
364
405
|
if (!instructions.includes('<!-- loop-engineering:openclaw:start -->')) await appendFile(files.instructions, managedInstructions);
|
|
365
406
|
await mkdir(path.dirname(files.manifest), { recursive: true });
|
|
366
407
|
await writeFile(files.manifest, `${JSON.stringify({
|
|
367
|
-
version:
|
|
408
|
+
version: 4, queue: args.queue, language: args.language, workerAgent: args.workerAgent, openclawBin: args.openclawBin, systemctlBin: args.systemctlBin, installedAt: new Date().toISOString(),
|
|
368
409
|
managedFiles: [
|
|
369
410
|
{ path: path.relative(args.root, files.queueConfig), sha256: sha256(queueContent) },
|
|
370
411
|
{ path: path.relative(args.root, files.dispatcher), sha256: sha256(dispatcherContent) },
|
|
371
412
|
{ path: path.relative(args.root, files.wrapper), sha256: sha256(wrapperContent) },
|
|
372
|
-
{ 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) }
|
|
373
415
|
],
|
|
374
416
|
managedUnits: [
|
|
375
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.';
|
|
@@ -12,6 +12,10 @@ await mkdir(path.join(loops, 'action-reservations'), { recursive: true });
|
|
|
12
12
|
await mkdir(path.join(loops, 'legacy', 'waiting'), { recursive: true });
|
|
13
13
|
await mkdir(path.join(loops, 'legacy', 'active'), { recursive: true });
|
|
14
14
|
await mkdir(path.join(loops, 'projects', 'p3', 'intake'), { recursive: true });
|
|
15
|
+
await mkdir(path.join(loops, 'projects', 'p3', 'backlog'), { recursive: true });
|
|
16
|
+
await mkdir(path.join(loops, 'projects', 'p3', 'completion'), { recursive: true });
|
|
17
|
+
await mkdir(path.join(loops, 'legacy', 'tasks', 'task-project', 'checkpoints'), { recursive: true });
|
|
18
|
+
await mkdir(path.join(loops, 'legacy', 'tasks', 'task-project', 'reviews'), { recursive: true });
|
|
15
19
|
await mkdir(path.join(root, '.production-evidence'), { recursive: true });
|
|
16
20
|
const now = '2026-08-13T16:00:00.000Z';
|
|
17
21
|
const control = {
|
|
@@ -27,6 +31,13 @@ await writeFile(path.join(loops, 'action-reservations', 'x.json'), JSON.stringif
|
|
|
27
31
|
await writeFile(path.join(loops, 'legacy', 'waiting', 'vps.json'), JSON.stringify({ id: 'vps', title: 'VPS down', parked: { kind: 'external_condition', next_check_at: '2026-08-13T17:00:00.000Z' }, provider: 'hidden' }));
|
|
28
32
|
await writeFile(path.join(loops, 'legacy', 'active', 'bad.json'), '{broken');
|
|
29
33
|
await writeFile(path.join(loops, 'projects', 'p3', 'intake', 'latest.json'), JSON.stringify({ version: 1, goal: 'Operator dashboard', token: 'hidden' }));
|
|
34
|
+
await writeFile(path.join(loops, 'projects', 'p3', 'backlog', 'initial.json'), JSON.stringify({ tasks: [{ id: 'm1', title: 'Projection foundation', status: 'completed' }, { id: 'm2', title: 'Interaction polish', status: 'pending' }] }));
|
|
35
|
+
await writeFile(path.join(loops, 'projects', 'p3', 'terminal-contract.json'), JSON.stringify({ status: 'active', terminalState: { accepted: false, userVisibleOutcome: 'Daily browser workspace' }, milestoneRule: 'A milestone is not project completion.', completionRule: 'All requirements and final judge must pass.', requirements: [{ id: 'workspace', status: 'active' }] }));
|
|
36
|
+
await writeFile(path.join(loops, 'projects', 'p3', 'completion', 'latest.json'), JSON.stringify({ status: 'active', terminalAccepted: false, unmet: ['interaction polish'] }));
|
|
37
|
+
await writeFile(path.join(loops, 'legacy', 'tasks', 'task-project', 'task_contract.json'), JSON.stringify({ title: 'Project foundation', task_scope: 'project' }));
|
|
38
|
+
await writeFile(path.join(loops, 'legacy', 'tasks', 'task-project', 'checkpoints', 'cp1.json'), JSON.stringify({ checkpoint_id: 'cp1', milestone_id: 'm1', project_id: 'p3', status: 'ready_for_acceptance', summary: 'Foundation ready', amendment_version: 2, created_at: now }));
|
|
39
|
+
await writeFile(path.join(loops, 'legacy', 'tasks', 'task-project', 'reviews', 'cp1.json'), JSON.stringify({ checkpoint_id: 'cp1', status: 'accepted', passed: 'projection complete', created_at: now }));
|
|
40
|
+
await writeFile(path.join(loops, 'legacy', 'tasks', 'task-project', 'final_judgement.json'), JSON.stringify({ outcome: 'project_in_progress', reasons: ['Milestone accepted; terminal contract remains open.'], created_at: now }));
|
|
30
41
|
await writeFile(path.join(root, '.production-evidence', 'public-summary.json'), JSON.stringify({ schema: 'loop.production_trust_public_summary', schema_version: 1, passed: true, evidence_digest: 'fixture', metrics: { duplicate_settled_effects: 0 } }));
|
|
31
42
|
await registerStep(root, { stepId: 'run:1:effect:send', kind: 'effect', effectKey: 'send:1', input: { api_key: 'hidden', body: 'hello' } });
|
|
32
43
|
const ledgerClaim = await claimStep(root, { stepId: 'run:1:effect:send', owner: 'a' });
|
|
@@ -44,6 +55,11 @@ assert.equal(first.actions[0].state, 'reconciliation_required');
|
|
|
44
55
|
assert.equal(first.execution_steps[0].state, 'reconciliation_required');
|
|
45
56
|
assert.equal(first.overview.reconciliation_required_steps, 1);
|
|
46
57
|
assert.equal(first.overview.production_trust, 'passed');
|
|
58
|
+
assert.equal(first.overview.task_workspace_count, 1);
|
|
59
|
+
assert.equal(first.projects[0].terminal_accepted, false);
|
|
60
|
+
assert.equal(first.projects[0].project_summary.completed_milestones, 1);
|
|
61
|
+
assert.equal(first.projects[0].tasks[0].timeline.at(-1).type, 'final_judge');
|
|
62
|
+
assert.equal(first.projects[0].tasks[0].revision_lineage[0].milestone_id, 'm1');
|
|
47
63
|
assert.equal(first.production_evidence.metrics.duplicate_settled_effects, 0);
|
|
48
64
|
assert.equal(first.queues[0].tasks[0].state, 'waiting_for_external_condition');
|
|
49
65
|
assert.equal(first.agents[0].provider_token, '[REDACTED]');
|
|
@@ -68,6 +84,16 @@ try {
|
|
|
68
84
|
const detail = await fetch(`${base}/api/v1/todos/human`).then((response) => response.json());
|
|
69
85
|
assert.match(detail.title, /onerror/);
|
|
70
86
|
const page = await fetch(base).then((response) => response.text());
|
|
87
|
+
assert.match(page, /aria-live="polite"/);
|
|
88
|
+
assert.match(page, /aria-pressed/);
|
|
89
|
+
assert.match(page, /history\.replaceState/);
|
|
90
|
+
assert.match(page, /No matching operational work/);
|
|
91
|
+
assert.match(page, /Unable to load workspace/);
|
|
92
|
+
assert.match(page, /Gates & reservations/);
|
|
93
|
+
assert.match(page, /approve/);
|
|
94
|
+
assert.doesNotMatch(page, /confirm-send|settle reservation/i);
|
|
95
|
+
const project = await fetch(`${base}/api/v1/projects/p3`).then((response) => response.json());
|
|
96
|
+
assert.equal(project.terminal_contract.milestone_rule, 'A milestone is not project completion.');
|
|
71
97
|
assert.doesNotMatch(page, /<img src=x/);
|
|
72
98
|
assert.equal((await fetch(`${base}/api/v1/todos/%2e%2e%2fsecret`)).status, 400);
|
|
73
99
|
assert.equal((await fetch(`${base}/api/v1/unknown`)).status, 404);
|
|
@@ -81,4 +107,4 @@ const started = performance.now(); const large = await buildOperatorProjection(r
|
|
|
81
107
|
assert.equal(large.queues.find((queue) => queue.id === 'large').tasks.length, 500);
|
|
82
108
|
assert.ok(performance.now() - started < 5000, '500 task projection should finish under 5 seconds');
|
|
83
109
|
|
|
84
|
-
console.log(JSON.stringify({ status: 'ok', assertions: 'empty-compatible, legacy, malformed, deterministic, read-only, P0 gates, P1 reconciliation, P2 lease/handoff, redaction, XSS, traversal, bind safety, export, restart-safe server, large queue performance' }));
|
|
110
|
+
console.log(JSON.stringify({ status: 'ok', assertions: 'empty-compatible, legacy, malformed, deterministic, read-only, project terminal/milestone split, revision and final-judge timeline, persistent URL state, keyboard and live-region accessibility, empty/error states, responsive UI, P0 gates, P1 reconciliation, P2 lease/handoff, redaction, XSS, traversal, bind safety, export, restart-safe server, large queue performance' }));
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
|
|
4
|
+
const files = {
|
|
5
|
+
implementation: await readFile(new URL('../lib/operator-dashboard.mjs', import.meta.url), 'utf8'),
|
|
6
|
+
tests: await readFile(new URL('./operator-dashboard-self-test.mjs', import.meta.url), 'utf8'),
|
|
7
|
+
docs: await readFile(new URL('../docs/operator-dashboard.md', import.meta.url), 'utf8'),
|
|
8
|
+
contract: await readFile(new URL('../docs/operator-workspace-project.md', import.meta.url), 'utf8'),
|
|
9
|
+
schema: await readFile(new URL('../templates/operator-projection.schema.json', import.meta.url), 'utf8')
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const criteria = [
|
|
13
|
+
['terminal contract', files.implementation, 'terminal_contract'],
|
|
14
|
+
['milestone status', files.implementation, 'milestones'],
|
|
15
|
+
['total project acceptance', files.implementation, 'terminal_accepted'],
|
|
16
|
+
['revision lineage', files.implementation, 'revision_lineage'],
|
|
17
|
+
['human gates', files.implementation, 'human_input_context.json'],
|
|
18
|
+
['reservations', files.implementation, 'action-reservations'],
|
|
19
|
+
['acceptance review', files.implementation, 'acceptance_review'],
|
|
20
|
+
['independent final judge', files.implementation, 'final_judge'],
|
|
21
|
+
['project detail endpoint', files.implementation, '/api/v1/projects/'],
|
|
22
|
+
['read-only HTTP methods', files.implementation, "request.method !== 'GET'"],
|
|
23
|
+
['loopback safety', files.implementation, 'allowNonLoopback'],
|
|
24
|
+
['redaction', files.implementation, '[REDACTED]'],
|
|
25
|
+
['persistent URL state', files.implementation, 'history.replaceState'],
|
|
26
|
+
['keyboard controls', files.implementation, "n.type='button'"],
|
|
27
|
+
['live status', files.implementation, 'aria-live'],
|
|
28
|
+
['responsive viewport', files.implementation, '@media(max-width:640px)'],
|
|
29
|
+
['empty state', files.implementation, 'No matching operational work'],
|
|
30
|
+
['error state', files.implementation, 'Unable to load workspace'],
|
|
31
|
+
['static export', files.implementation, "html('./projection.json')"],
|
|
32
|
+
['schema task workspaces', files.schema, 'task_workspaces'],
|
|
33
|
+
['security tests', files.tests, '%2e%2e%2fsecret'],
|
|
34
|
+
['large workspace test', files.tests, '500 task projection'],
|
|
35
|
+
['operator docs', files.docs, 'responsive'],
|
|
36
|
+
['terminal completion rule', files.contract, 'No single page, milestone, checkpoint'],
|
|
37
|
+
['three milestones', files.contract, 'Release hardening']
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
for (const [name, body, token] of criteria) assert.ok(body.includes(token), `${name} criterion missing: ${token}`);
|
|
41
|
+
console.log(JSON.stringify({ outcome: 'accept', checks: criteria.length, accepted: criteria.map(([name]) => name) }, null, 2));
|
|
@@ -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 });
|
|
@@ -72,6 +72,8 @@ loop-engineering-openclaw-install \
|
|
|
72
72
|
--confirm-install
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
+
The confirmed install also creates `loop-engineering-dashboard.service` on port `4174` and couples it to `openclaw-gateway.service`. Dashboard listening defaults to local-only `127.0.0.1`. To permit Tailnet clients, pass `--dashboard-listen tailscale`; the installer resolves `tailscale ip -4`, binds only that `100.x` address, and fails closed rather than binding all interfaces. Tailnet mode relies on Tailscale ACLs/Grants and does not add application-level login. The plan must declare the selected mode before writes, and the doctor must verify the Dashboard service and gateway drop-in. Use `loop-engineering-dashboard-autostart-install --listen localhost|tailscale` only for standalone install or repair.
|
|
76
|
+
|
|
75
77
|
The installer never creates the worker agent. After installation, verify wiring before using a real task:
|
|
76
78
|
|
|
77
79
|
```bash
|
|
@@ -111,11 +113,21 @@ loop-engineering-hermes-smoke \
|
|
|
111
113
|
--queue agent-tasks
|
|
112
114
|
```
|
|
113
115
|
|
|
116
|
+
The confirmed Hermes install creates the same Dashboard service and couples it to `hermes-gateway.service`. It accepts `--dashboard-listen localhost|tailscale` with the same default-local, Tailnet-only binding, fail-closed resolution, and ACL/Grant boundary as OpenClaw. The plan and doctor must expose and verify this coupling.
|
|
117
|
+
|
|
114
118
|
The generated worker uses `hermes -z` (`--oneshot`); the notifier uses `hermes send` without
|
|
115
119
|
an LLM call. Preserve source metadata and pass `--source-target` in Hermes
|
|
116
120
|
`platform:chat_id[:thread_id]` format. The managed systemd scheduler owns wakeups
|
|
117
121
|
so durable queue continuity does not depend on resuming a Hermes Cron session.
|
|
118
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
|
+
|
|
119
131
|
## Conversation Contract
|
|
120
132
|
|
|
121
133
|
Interpret explicit loop language as follows:
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://loop-engineering.local/schema/operator-projection-v1.json","title":"Loop Engineering read-only operator projection v1","type":"object","required":["schema_version","generated_at","source","health","overview","projects","queues","todos","agents","handoffs","gates","actions","cost"],"properties":{"schema_version":{"const":"1.0.0"},"generated_at":{"type":"string","format":"date-time"},"source":{"type":"object"},"health":{"type":"object"},"overview":{"type":"object"},"projects":{"type":"array"},"queues":{"type":"array"},"todos":{"type":"array"},"agents":{"type":"array"},"handoffs":{"type":"array"},"gates":{"type":"array"},"actions":{"type":"array"},"cost":{"type":"object"}},"additionalProperties":false}
|
|
1
|
+
{"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://loop-engineering.local/schema/operator-projection-v1.json","title":"Loop Engineering read-only operator projection v1","type":"object","required":["schema_version","generated_at","source","health","overview","projects","task_workspaces","queues","todos","agents","handoffs","gates","actions","cost"],"properties":{"schema_version":{"const":"1.0.0"},"generated_at":{"type":"string","format":"date-time"},"source":{"type":"object"},"health":{"type":"object"},"overview":{"type":"object"},"projects":{"type":"array"},"task_workspaces":{"type":"array"},"queues":{"type":"array"},"todos":{"type":"array"},"agents":{"type":"array"},"handoffs":{"type":"array"},"gates":{"type":"array"},"actions":{"type":"array"},"execution_steps":{"type":"array"},"production_evidence":{},"cost":{"type":"object"}},"additionalProperties":false}
|