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.
Files changed (37) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +1 -1
  3. package/bin/loop-engineering.mjs +15 -2
  4. package/docs/agent-team-backlog.json +1 -0
  5. package/docs/agent-team-terminal-contract.json +1 -0
  6. package/docs/human-gate-command.md +17 -0
  7. package/docs/multi-agent-control-plane.md +8 -0
  8. package/docs/operator-dashboard.md +25 -2
  9. package/docs/operator-workspace-project.md +30 -0
  10. package/docs/quota-runtime-decision.md +9 -0
  11. package/lib/human-gate-channel-adapter.mjs +37 -0
  12. package/lib/human-gate-command.mjs +161 -0
  13. package/lib/operator-dashboard.mjs +75 -8
  14. package/lib/quota-runtime-decision.mjs +62 -0
  15. package/lib/todo-control-plane.mjs +105 -6
  16. package/package.json +21 -6
  17. package/scripts/agent-team-control-plane-self-test.mjs +29 -0
  18. package/scripts/agent-team-final-judgement.mjs +27 -0
  19. package/scripts/dashboard-autostart-install.mjs +91 -0
  20. package/scripts/dashboard-autostart-self-test.mjs +46 -0
  21. package/scripts/distribution-skill-self-test.mjs +13 -3
  22. package/scripts/hermes-doctor.mjs +2 -1
  23. package/scripts/hermes-install-self-test.mjs +2 -1
  24. package/scripts/hermes-install.mjs +11 -4
  25. package/scripts/human-gate-command-self-test.mjs +52 -0
  26. package/scripts/human-gate-final-judgement.mjs +30 -0
  27. package/scripts/live-agent-team-conformance.mjs +61 -0
  28. package/scripts/openclaw-doctor.mjs +13 -0
  29. package/scripts/openclaw-install-self-test.mjs +8 -3
  30. package/scripts/openclaw-install.mjs +48 -6
  31. package/scripts/openclaw-smoke.mjs +4 -0
  32. package/scripts/operator-dashboard-self-test.mjs +27 -1
  33. package/scripts/operator-workspace-final-judgement.mjs +41 -0
  34. package/scripts/quota-runtime-decision-self-test.mjs +29 -0
  35. package/scripts/todo-control-plane-self-test.mjs +4 -1
  36. package/skills/taskforce-loop-engineering/SKILL.md +12 -0
  37. package/templates/operator-projection.schema.json +1 -1
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+ import { mkdir, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { spawn } from 'node:child_process';
5
+
6
+ function parseArgs(argv) {
7
+ const out = { root: process.cwd(), listen: 'localhost', host: null, port: 4174, tailscaleBin: 'tailscale', systemctlBin: 'systemctl', confirmInstall: false, json: false };
8
+ for (let i = 0; i < argv.length; i += 1) {
9
+ const arg = argv[i];
10
+ if (arg === '--root') out.root = path.resolve(argv[++i]);
11
+ else if (arg === '--listen') out.listen = argv[++i];
12
+ else if (arg === '--host') out.host = argv[++i];
13
+ else if (arg === '--port') out.port = Number(argv[++i]);
14
+ else if (arg === '--tailscale-bin') out.tailscaleBin = argv[++i];
15
+ else if (arg === '--systemctl-bin') out.systemctlBin = argv[++i];
16
+ else if (arg === '--confirm-install') out.confirmInstall = true;
17
+ else if (arg === '--json') out.json = true;
18
+ else if (arg === '--help') out.help = true;
19
+ else throw new Error(`Unknown argument: ${arg}`);
20
+ }
21
+ if (!['localhost', 'tailscale'].includes(out.listen)) throw new Error('--listen must be localhost or tailscale.');
22
+ if (out.host && out.listen === 'tailscale') throw new Error('--host cannot be combined with --listen tailscale.');
23
+ if (out.host && !['127.0.0.1', '::1', 'localhost'].includes(out.host)) throw new Error('--host only accepts a loopback address; use --listen tailscale for Tailnet access.');
24
+ if (!Number.isInteger(out.port) || out.port < 1 || out.port > 65535) throw new Error('--port must be an integer from 1 to 65535.');
25
+ return out;
26
+ }
27
+
28
+ function systemdEscapePath(value) {
29
+ return [...value].map((character) => {
30
+ if (/[A-Za-z0-9_/:.\-]/.test(character)) return character;
31
+ return `\\x${character.codePointAt(0).toString(16).padStart(2, '0')}`;
32
+ }).join('');
33
+ }
34
+
35
+ function run(command, args) {
36
+ return new Promise((resolve) => {
37
+ const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
38
+ let stdout = ''; let stderr = '';
39
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
40
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
41
+ child.on('error', (error) => resolve({ code: 127, stdout, stderr: `${stderr}${error.message}` }));
42
+ child.on('close', (code) => resolve({ code, stdout, stderr }));
43
+ });
44
+ }
45
+
46
+ const args = parseArgs(process.argv.slice(2));
47
+ if (args.help) {
48
+ console.log('Usage: loop-engineering-dashboard-autostart-install --root <workspace> [--listen localhost|tailscale] [--port 4174] [--tailscale-bin tailscale] [--confirm-install] [--json]');
49
+ process.exit(0);
50
+ }
51
+
52
+ const packageRoot = path.resolve(new URL('..', import.meta.url).pathname);
53
+ const cli = path.join(packageRoot, 'bin', 'loop-engineering.mjs');
54
+ const userSystemdDir = path.join(process.env.XDG_CONFIG_HOME || path.join(process.env.HOME || '', '.config'), 'systemd', 'user');
55
+ const dashboardUnit = 'loop-engineering-dashboard.service';
56
+ const gatewayUnits = ['openclaw-gateway.service', 'hermes-gateway.service'];
57
+ const servicePath = path.join(userSystemdDir, dashboardUnit);
58
+ const host = args.listen === 'localhost' ? (args.host || '127.0.0.1') : await (async () => {
59
+ const result = await run(args.tailscaleBin, ['ip', '-4']);
60
+ if (result.code !== 0) throw new Error(`Cannot resolve Tailscale IPv4 address: ${(result.stderr || result.stdout).trim() || `exit ${result.code}`}`);
61
+ const addresses = result.stdout.split(/\s+/).filter(Boolean);
62
+ if (addresses.length !== 1 || !/^100\.(?:\d{1,3}\.){2}\d{1,3}$/.test(addresses[0])) throw new Error(`Expected exactly one Tailscale IPv4 address, received: ${addresses.join(', ') || 'none'}`);
63
+ return addresses[0];
64
+ })();
65
+ const nonLoopback = args.listen === 'tailscale' ? ' --allow-non-loopback' : '';
66
+ const service = `[Unit]\nDescription=Taskforce Loop Engineering read-only project workspace\nAfter=network.target\n\n[Service]\nType=simple\nWorkingDirectory=${systemdEscapePath(args.root)}\nExecStart=${systemdEscapePath(process.execPath)} ${systemdEscapePath(cli)} dashboard-serve --root ${systemdEscapePath(args.root)} --host ${host} --port ${args.port}${nonLoopback}\nRestart=on-failure\nRestartSec=3s\nNoNewPrivileges=true\nPrivateTmp=true\n\n[Install]\nWantedBy=default.target\n`;
67
+ const dropIn = `[Unit]\nWants=${dashboardUnit}\nAfter=${dashboardUnit}\n`;
68
+ const report = {
69
+ status: args.confirmInstall ? 'installed' : 'plan_only',
70
+ listen: args.listen,
71
+ host,
72
+ dashboard: `http://${host}:${args.port}/`,
73
+ readOnly: true,
74
+ service: servicePath,
75
+ gatewayDropIns: gatewayUnits.map((unit) => path.join(userSystemdDir, `${unit}.d`, 'loop-engineering-dashboard.conf')),
76
+ writesEnabled: args.confirmInstall
77
+ };
78
+
79
+ if (args.confirmInstall) {
80
+ await mkdir(userSystemdDir, { recursive: true });
81
+ await writeFile(servicePath, service);
82
+ for (const unit of gatewayUnits) {
83
+ const directory = path.join(userSystemdDir, `${unit}.d`);
84
+ await mkdir(directory, { recursive: true });
85
+ await writeFile(path.join(directory, 'loop-engineering-dashboard.conf'), dropIn);
86
+ }
87
+ const reload = await run(args.systemctlBin, ['--user', 'daemon-reload']);
88
+ if (reload.code !== 0) throw new Error(`Cannot reload user systemd units: ${(reload.stderr || reload.stdout).trim()}`);
89
+ }
90
+
91
+ console.log(args.json ? JSON.stringify(report, null, 2) : `${report.status}: ${report.dashboard}\nservice: ${report.service}\ngateways: ${gatewayUnits.join(', ')}`);
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+ import { chmod, mkdtemp, readFile, writeFile } from 'node:fs/promises';
3
+ import { spawn } from 'node:child_process';
4
+ import { tmpdir } from 'node:os';
5
+ import path from 'node:path';
6
+
7
+ const root = await mkdtemp(path.join(tmpdir(), 'loop-dashboard-autostart-'));
8
+ const mockSystemctl = path.join(root, 'systemctl.mjs');
9
+ const mockTailscale = path.join(root, 'tailscale.mjs');
10
+ const calls = path.join(root, 'calls.jsonl');
11
+ process.env.XDG_CONFIG_HOME = path.join(root, 'xdg');
12
+ process.env.SYSTEMCTL_CAPTURE = calls;
13
+ await writeFile(mockSystemctl, `#!/usr/bin/env node\nimport { appendFile } from 'node:fs/promises';\nawait appendFile(process.env.SYSTEMCTL_CAPTURE, JSON.stringify(process.argv.slice(2)) + '\\n');\n`);
14
+ await chmod(mockSystemctl, 0o755);
15
+ await writeFile(mockTailscale, `#!/usr/bin/env node\nif (process.argv.slice(2).join(' ') !== 'ip -4') process.exit(2);\nconsole.log('100.64.10.20');\n`);
16
+ await chmod(mockTailscale, 0o755);
17
+ const installer = new URL('./dashboard-autostart-install.mjs', import.meta.url).pathname;
18
+ const result = await new Promise((resolve) => {
19
+ const child = spawn(process.execPath, [installer, '--root', root, '--port', '4174', '--systemctl-bin', mockSystemctl, '--confirm-install', '--json'], { env: process.env, stdio: ['ignore', 'pipe', 'pipe'] });
20
+ let stdout = ''; let stderr = '';
21
+ child.stdout.on('data', (chunk) => { stdout += chunk; }); child.stderr.on('data', (chunk) => { stderr += chunk; });
22
+ child.on('close', (code) => resolve({ code, stdout, stderr }));
23
+ });
24
+ if (result.code !== 0) throw new Error(result.stderr);
25
+ const report = JSON.parse(result.stdout);
26
+ if (report.status !== 'installed' || report.dashboard !== 'http://127.0.0.1:4174/') throw new Error('unexpected install report');
27
+ const unitDir = path.join(process.env.XDG_CONFIG_HOME, 'systemd', 'user');
28
+ const service = await readFile(path.join(unitDir, 'loop-engineering-dashboard.service'), 'utf8');
29
+ if (!service.includes('dashboard-serve') || !service.includes('--port 4174') || !service.includes('NoNewPrivileges=true')) throw new Error('dashboard service is incomplete');
30
+ for (const gateway of ['openclaw-gateway.service', 'hermes-gateway.service']) {
31
+ const dropIn = await readFile(path.join(unitDir, `${gateway}.d`, 'loop-engineering-dashboard.conf'), 'utf8');
32
+ if (!dropIn.includes('Wants=loop-engineering-dashboard.service') || !dropIn.includes('After=loop-engineering-dashboard.service')) throw new Error(`${gateway} is not wired to dashboard`);
33
+ }
34
+ if (!(await readFile(calls, 'utf8')).includes('["--user","daemon-reload"]')) throw new Error('systemd daemon was not reloaded');
35
+ const tailnetResult = await new Promise((resolve) => {
36
+ const child = spawn(process.execPath, [installer, '--root', root, '--listen', 'tailscale', '--tailscale-bin', mockTailscale, '--port', '4174', '--systemctl-bin', mockSystemctl, '--confirm-install', '--json'], { env: process.env, stdio: ['ignore', 'pipe', 'pipe'] });
37
+ let stdout = ''; let stderr = '';
38
+ child.stdout.on('data', (chunk) => { stdout += chunk; }); child.stderr.on('data', (chunk) => { stderr += chunk; });
39
+ child.on('close', (code) => resolve({ code, stdout, stderr }));
40
+ });
41
+ if (tailnetResult.code !== 0) throw new Error(tailnetResult.stderr);
42
+ const tailnetReport = JSON.parse(tailnetResult.stdout);
43
+ if (tailnetReport.listen !== 'tailscale' || tailnetReport.dashboard !== 'http://100.64.10.20:4174/') throw new Error('unexpected Tailnet install report');
44
+ const tailnetService = await readFile(path.join(unitDir, 'loop-engineering-dashboard.service'), 'utf8');
45
+ if (!tailnetService.includes('--host 100.64.10.20') || !tailnetService.includes('--allow-non-loopback')) throw new Error('Tailnet dashboard service is incomplete');
46
+ console.log('dashboard autostart self-test passed');
@@ -1,14 +1,24 @@
1
1
  import assert from 'node:assert/strict';
2
- import { readFile } from 'node:fs/promises';
2
+ import { access, readFile } from 'node:fs/promises';
3
3
 
4
- const skill = await readFile(new URL('../skills/taskforce-loop-engineering/SKILL.md', import.meta.url), 'utf8');
4
+ const distributedSkillUrl = new URL('../skills/taskforce-loop-engineering/SKILL.md', import.meta.url);
5
+ const workspaceSkillUrl = new URL('../../../skills/taskforce-loop-engineering/SKILL.md', import.meta.url);
6
+ const skill = await readFile(distributedSkillUrl, 'utf8');
5
7
 
6
8
  for (const forbidden of ['ironman-task-runner', 'ironman-task-runner.mjs']) {
7
9
  assert.equal(skill.includes(forbidden), false, `distributed skill contains local dispatcher reference: ${forbidden}`);
8
10
  }
9
11
 
10
- for (const required of ['agent-tasks', 'scripts/loops/openclaw-loop.mjs', 'configs/loops/queues/']) {
12
+ for (const required of ['agent-tasks', 'scripts/loops/openclaw-loop.mjs', 'scripts/loops/openclaw-loop-gate.mjs', 'configs/loops/queues/', 'feishu_signature_unverified', 'ignored_untrusted_chat', 'Dashboard and chat']) {
11
13
  assert.equal(skill.includes(required), true, `distributed skill is missing generic integration guidance: ${required}`);
12
14
  }
13
15
 
16
+ try {
17
+ await access(workspaceSkillUrl);
18
+ const workspaceSkill = await readFile(workspaceSkillUrl, 'utf8');
19
+ assert.equal(workspaceSkill, skill, 'workspace taskforce-loop-engineering skill drifted from the distributed skill');
20
+ } catch (error) {
21
+ if (error?.code !== 'ENOENT') throw error;
22
+ }
23
+
14
24
  console.log('distribution skill self-test passed');
@@ -14,7 +14,8 @@ async function present(file) { try { await access(file); return true; } catch {
14
14
  async function main() {
15
15
  const args = parseArgs(process.argv.slice(2));
16
16
  if (args.help) { console.log('Usage: loop-engineering-hermes-doctor [--root workspace] [--queue agent-tasks] [--hermes-bin hermes] [--json]'); return; }
17
- const required = [`configs/loops/queues/${args.queue}.json`, 'configs/loops/workspace-health.json', 'scripts/loops/hermes-loop-dispatch.mjs', 'scripts/loops/hermes-loop.mjs', 'scripts/loops/hermes-loop-notify.mjs', 'runtime/loop-engineering-hermes-install.json', 'AGENTS.md'];
17
+ const systemdUserDir = path.join(process.env.XDG_CONFIG_HOME || path.join(process.env.HOME || '', '.config'), 'systemd', 'user');
18
+ const required = [`configs/loops/queues/${args.queue}.json`, 'configs/loops/workspace-health.json', 'scripts/loops/hermes-loop-dispatch.mjs', 'scripts/loops/hermes-loop.mjs', 'scripts/loops/hermes-loop-notify.mjs', 'runtime/loop-engineering-hermes-install.json', 'AGENTS.md', path.relative(args.root, path.join(systemdUserDir, 'loop-engineering-dashboard.service')), path.relative(args.root, path.join(systemdUserDir, 'hermes-gateway.service.d', 'loop-engineering-dashboard.conf'))];
18
19
  const checks = []; for (const relative of required) checks.push({ id: `file:${relative}`, ok: await present(path.join(args.root, relative)) });
19
20
  const cli = await run(args.hermesBin, ['--version'], { cwd: args.root }); checks.push({ id: 'hermes_cli', ok: cli.code === 0, detail: (cli.stdout || cli.stderr).trim().slice(0, 300) });
20
21
  const sendHelp = await run(args.hermesBin, ['send', '--help'], { cwd: args.root }); checks.push({ id: 'hermes_send', ok: sendHelp.code === 0, detail: (sendHelp.stdout || sendHelp.stderr).trim().slice(0, 300) });
@@ -27,7 +27,7 @@ if (process.env.SYSTEMCTL_CAPTURE) await appendFile(process.env.SYSTEMCTL_CAPTUR
27
27
  function run(command, args, options = {}) { return new Promise((resolve) => { const child = spawn(command, args, { cwd: options.cwd || root, env: options.env || process.env, stdio: ['ignore', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; child.stdout.on('data', (chunk) => { stdout += chunk; }); child.stderr.on('data', (chunk) => { stderr += chunk; }); child.on('close', (code) => resolve({ code, stdout, stderr })); }); }
28
28
  const installer = new URL('./hermes-install.mjs', import.meta.url).pathname; const doctor = new URL('./hermes-doctor.mjs', import.meta.url).pathname; const smoke = new URL('./hermes-smoke.mjs', import.meta.url).pathname; const loopBin = new URL('../bin/loop-engineering.mjs', import.meta.url).pathname;
29
29
  const base = ['--root', root, '--queue', 'hermes-tasks', '--hermes-bin', mockHermes, '--systemctl-bin', mockSystemctl];
30
- const plan = await run(process.execPath, [installer, ...base, '--json']); const planReport = JSON.parse(plan.stdout); if (plan.code !== 0 || planReport.status !== 'plan_only' || planReport.confirmationSummary?.targetPlatform !== 'Hermes' || planReport.confirmationSummary?.writesEnabled !== false || !path.isAbsolute(planReport.confirmationSummary?.platformCli || '') || !planReport.confirmationSummary?.notificationTarget.includes('Hermes')) throw new Error(`Hermes install plan failed: ${plan.stderr}`);
30
+ const plan = await run(process.execPath, [installer, ...base, '--json']); const planReport = JSON.parse(plan.stdout); if (plan.code !== 0 || planReport.status !== 'plan_only' || planReport.confirmationSummary?.targetPlatform !== 'Hermes' || planReport.confirmationSummary?.writesEnabled !== false || !path.isAbsolute(planReport.confirmationSummary?.platformCli || '') || !planReport.confirmationSummary?.notificationTarget.includes('Hermes') || planReport.dashboardAutostart?.gateway !== 'hermes-gateway.service') throw new Error(`Hermes install plan failed: ${plan.stderr}`);
31
31
  const humanPlan = await run(process.execPath, [installer, ...base]); if (humanPlan.code !== 0 || !humanPlan.stdout.includes('Installation confirmation') || !humanPlan.stdout.includes('target platform: Hermes') || !humanPlan.stdout.includes('writes enabled: no (plan only)')) throw new Error('human-readable Hermes confirmation summary missing');
32
32
  const install = await run(process.execPath, [installer, ...base, '--confirm-install', '--json']); if (install.code !== 0 || JSON.parse(install.stdout).status !== 'installed') throw new Error(`Hermes install failed: ${install.stderr}`);
33
33
  const queue = JSON.parse(await readFile(path.join(root, 'configs/loops/queues/hermes-tasks.json'), 'utf8')); if (queue.dispatcher !== 'node scripts/loops/hermes-loop-dispatch.mjs' || queue.scheduler?.required !== true) throw new Error('Hermes queue wiring missing');
@@ -38,6 +38,7 @@ const dispatcher = await readFile(path.join(root, 'scripts/loops/hermes-loop-dis
38
38
  const notifier = path.join(root, 'scripts/loops/hermes-loop-notify.mjs'); const notify = await run(process.execPath, [notifier, 'hello from loop'], { env: { ...process.env, HERMES_SEND_CAPTURE: sendCapture, LOOP_NOTIFICATION_SOURCE: JSON.stringify({ channel: 'telegram', target: 'telegram:12345' }) } }); if (notify.code !== 0) throw new Error(`Hermes notifier failed: ${notify.stderr}`);
39
39
  const sent = JSON.parse(await readFile(sendCapture, 'utf8')); if (!sent.includes('telegram:12345') || !sent.includes('hello from loop')) throw new Error('Hermes notifier did not preserve delivery target/message');
40
40
  const serviceFile = path.join(process.env.XDG_CONFIG_HOME, 'systemd/user/hermes-loop-hermes-tasks-scheduler.service'); const timerFile = path.join(process.env.XDG_CONFIG_HOME, 'systemd/user/hermes-loop-hermes-tasks-scheduler.timer'); const service = await readFile(serviceFile, 'utf8'); if (service.includes('WorkingDirectory="') || !service.includes('\\x20') || !service.includes('\\xe5\\xae\\x89\\xe8\\xa3\\x85')) throw new Error('Hermes systemd paths were not escaped');
41
+ const dashboardServiceFile = path.join(process.env.XDG_CONFIG_HOME, 'systemd/user/loop-engineering-dashboard.service'); const dashboardDropIn = path.join(process.env.XDG_CONFIG_HOME, 'systemd/user/hermes-gateway.service.d/loop-engineering-dashboard.conf'); if (!(await readFile(dashboardServiceFile, 'utf8')).includes('dashboard-serve') || !(await readFile(dashboardDropIn, 'utf8')).includes('Wants=loop-engineering-dashboard.service')) throw new Error('Hermes installer did not install Dashboard gateway autostart');
41
42
  const verify = await run('systemd-analyze', ['verify', serviceFile, timerFile]); if (verify.code !== 0) throw new Error(`Hermes systemd units invalid: ${verify.stderr || verify.stdout}`);
42
43
  const doctorResult = await run(process.execPath, [doctor, '--root', root, '--queue', 'hermes-tasks', '--hermes-bin', mockHermes, '--json']); if (doctorResult.code !== 0 || JSON.parse(doctorResult.stdout).status !== 'ok') throw new Error(`Hermes doctor failed: ${doctorResult.stderr || doctorResult.stdout}`);
43
44
  const smokeResult = await run(process.execPath, [smoke, '--root', root, '--queue', 'hermes-tasks', '--hermes-bin', mockHermes, '--loop-bin', loopBin, '--json']); if (smokeResult.code !== 0 || JSON.parse(smokeResult.stdout).status !== 'ok') throw new Error(`Hermes smoke failed: ${smokeResult.stderr || smokeResult.stdout}`);
@@ -5,13 +5,15 @@ 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', hermesBin: 'hermes', systemctlBin: 'systemctl', language: 'auto', json: false, confirmInstall: false, force: false };
8
+ const out = { root: process.cwd(), queue: 'agent-tasks', hermesBin: 'hermes', 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]);
12
12
  else if (arg === '--queue') out.queue = argv[++i];
13
13
  else if (arg === '--hermes-bin') out.hermesBin = argv[++i];
14
14
  else if (arg === '--systemctl-bin') out.systemctlBin = argv[++i];
15
+ else if (arg === '--dashboard-listen') out.dashboardListen = argv[++i];
16
+ else if (arg === '--tailscale-bin') out.tailscaleBin = argv[++i];
15
17
  else if (arg === '--language') out.language = argv[++i];
16
18
  else if (arg === '--confirm-install') out.confirmInstall = true;
17
19
  else if (arg === '--force') out.force = true;
@@ -19,6 +21,7 @@ function parseArgs(argv) {
19
21
  else if (arg === '--help' || arg === '-h') out.help = true;
20
22
  else throw new Error(`Unknown argument: ${arg}`);
21
23
  }
24
+ if (!['localhost', 'tailscale'].includes(out.dashboardListen)) throw new Error('--dashboard-listen must be localhost or tailscale.');
22
25
  return out;
23
26
  }
24
27
 
@@ -145,7 +148,7 @@ function instructionsBlock({ queue, language }) {
145
148
  async function main() {
146
149
  const args = parseArgs(process.argv.slice(2));
147
150
  args.language = resolveLanguage(args.language);
148
- if (args.help) { console.log('Usage: loop-engineering-hermes-install [--root workspace] [--queue agent-tasks] [--language auto|en|zh] [--hermes-bin hermes] [--systemctl-bin systemctl] [--confirm-install] [--force] [--json]'); return; }
151
+ if (args.help) { console.log('Usage: loop-engineering-hermes-install [--root workspace] [--queue agent-tasks] [--dashboard-listen localhost|tailscale] [--tailscale-bin tailscale] [--language auto|en|zh] [--hermes-bin hermes] [--systemctl-bin systemctl] [--confirm-install] [--force] [--json]'); return; }
149
152
  safeId(args.queue, 'queue');
150
153
  args.hermesBin = await resolveExecutable(args.hermesBin);
151
154
  const hermes = await run(args.hermesBin, ['--version'], { cwd: args.root });
@@ -155,8 +158,9 @@ async function main() {
155
158
  const unit = `hermes-loop-${args.queue}-scheduler.service`; const timer = `hermes-loop-${args.queue}-scheduler.timer`;
156
159
  const files = { workspaceHealth: path.join(args.root, 'configs/loops/workspace-health.json'), queueConfig: path.join(args.root, 'configs/loops/queues', `${args.queue}.json`), dispatcher: path.join(args.root, 'scripts/loops/hermes-loop-dispatch.mjs'), wrapper: path.join(args.root, 'scripts/loops/hermes-loop.mjs'), notifier: path.join(args.root, 'scripts/loops/hermes-loop-notify.mjs'), manifest: path.join(args.root, 'runtime/loop-engineering-hermes-install.json'), instructions: path.join(args.root, 'AGENTS.md'), schedulerService: path.join(systemdUserDir, unit), schedulerTimer: path.join(systemdUserDir, timer) };
157
160
  const conflicts = []; for (const [kind, file] of Object.entries(files)) if (!['instructions', 'workspaceHealth', 'manifest'].includes(kind) && await exists(file)) conflicts.push(path.relative(args.root, file));
158
- const confirmationSummary = { targetPlatform: 'Hermes', platformCli: args.hermesBin, workspace: args.root, queue: args.queue, scheduler: `systemd user timer ${timer}`, notificationTarget: text(args.language, 'source-bound at runtime (original Hermes conversation)', '运行时绑定到原始 Hermes 会话'), writesEnabled: args.confirmInstall };
159
- const report = { version: 1, platform: 'hermes', language: args.language, status: args.confirmInstall ? 'installed' : 'plan_only', readOnly: !args.confirmInstall, root: args.root, queue: args.queue, hermesBin: args.hermesBin, hermesVersion: (hermes.stdout || hermes.stderr).trim().slice(0, 200), scheduler: { required: true, unit, timer }, confirmationSummary, files: Object.fromEntries(Object.entries(files).map(([key, file]) => [key, path.relative(args.root, file)])), conflicts };
161
+ const dashboardDescription = args.dashboardListen === 'tailscale' ? 'read-only Tailnet address on port 4174 coupled to hermes-gateway.service' : 'read-only http://127.0.0.1:4174/ coupled to hermes-gateway.service';
162
+ const confirmationSummary = { targetPlatform: 'Hermes', platformCli: args.hermesBin, workspace: args.root, queue: args.queue, scheduler: `systemd user timer ${timer}`, dashboard: dashboardDescription, notificationTarget: text(args.language, 'source-bound at runtime (original Hermes conversation)', '运行时绑定到原始 Hermes 会话'), writesEnabled: args.confirmInstall };
163
+ const report = { version: 1, platform: 'hermes', language: args.language, status: args.confirmInstall ? 'installed' : 'plan_only', readOnly: !args.confirmInstall, root: args.root, queue: args.queue, hermesBin: args.hermesBin, hermesVersion: (hermes.stdout || hermes.stderr).trim().slice(0, 200), scheduler: { required: true, unit, timer }, dashboardAutostart: { required: true, listen: args.dashboardListen, address: args.dashboardListen === 'localhost' ? 'http://127.0.0.1:4174/' : null, gateway: 'hermes-gateway.service' }, confirmationSummary, files: Object.fromEntries(Object.entries(files).map(([key, file]) => [key, path.relative(args.root, file)])), conflicts };
160
164
  if (conflicts.length && !args.force && args.confirmInstall) throw new Error(`Refusing to overwrite: ${conflicts.join(', ')}. Use --force after review.`);
161
165
  if (!args.json) console.log(formatConfirmationSummary(confirmationSummary, args.language));
162
166
  if (args.confirmInstall) {
@@ -168,6 +172,9 @@ async function main() {
168
172
  for (const [kind, content] of Object.entries(contents)) await writeFile(files[kind], content);
169
173
  const reload = await run(args.systemctlBin, ['--user', 'daemon-reload'], { cwd: args.root }); if (reload.code !== 0) throw new Error(`Cannot reload user systemd units: ${(reload.stderr || reload.stdout).trim()}`);
170
174
  const enable = await run(args.systemctlBin, ['--user', 'enable', '--now', timer], { cwd: args.root }); if (enable.code !== 0) throw new Error(`Cannot enable Hermes Loop scheduler ${timer}: ${(enable.stderr || enable.stdout).trim()}`);
175
+ const dashboardInstaller = new URL('./dashboard-autostart-install.mjs', import.meta.url).pathname;
176
+ 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 });
177
+ if (dashboardInstall.code !== 0) throw new Error(`Cannot install Dashboard gateway autostart: ${(dashboardInstall.stderr || dashboardInstall.stdout).trim()}`);
171
178
  const marker = instructionsBlock({ queue: args.queue, language: args.language }); const current = await readFile(files.instructions, 'utf8').catch(() => ''); if (!current.includes('<!-- loop-engineering:hermes:start -->')) await appendFile(files.instructions, marker);
172
179
  await mkdir(path.dirname(files.manifest), { recursive: true }); await writeFile(files.manifest, `${JSON.stringify({ version: 2, platform: 'hermes', language: args.language, installedAt: new Date().toISOString(), root: args.root, queue: args.queue, hermesBin: args.hermesBin, files: Object.entries(contents).map(([kind, content]) => ({ kind, path: files[kind], sha256: sha256(content) })), scheduler: { unit, timer } }, null, 2)}\n`);
173
180
  }
@@ -0,0 +1,52 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtemp, rm } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import path from 'node:path';
5
+ import { createHumanGate, executeGateCommand, getHumanGate, parseBoundReply } from '../lib/human-gate-command.mjs';
6
+ import { handleChannelGateEvent, normalizeFeishuGateEvent, renderGateForChannel } from '../lib/human-gate-channel-adapter.mjs';
7
+ import { createDashboardServer } from '../lib/operator-dashboard.mjs';
8
+
9
+ const root = await mkdtemp(path.join(tmpdir(), 'loop-gate-command-'));
10
+ const base = { project: 'p', task: 't', action: 'deploy', reason: 'release', impact: 'production', risk: { level: 'high' }, cost: { amount: 10, currency: 'CNY', budget: 100 }, evidence: ['test'], dashboard_url: 'http://127.0.0.1:4174/', expiry: '2030-01-01T00:00:00.000Z', allowed_actors: ['owner'], source_bindings: [{ channel: 'feishu', message_id: 'card-1', reply_to: 'card-1', adapter: 'feishu' }, { channel: 'dashboard', message_id: 'gate-panel', reply_to: 'gate-panel', adapter: 'dashboard' }] };
11
+ const command = { gate_id: 'gate_high', decision: 'approve', expected_generation: 1, actor_id: 'owner', source_channel: 'feishu', source_message_id: 'card-1', event_type: 'card_button', idempotency_key: 'event-1' };
12
+ try {
13
+ await createHumanGate(root, { ...base, gate_id: 'gate_high' });
14
+ const card = await renderGateForChannel(root, 'gate_high');
15
+ assert.deepEqual(card.buttons.map((x) => x.decision).sort(), ['approve', 'reject', 'request_revision']); assert.equal(card.fields.cost.budget, 100);
16
+ const first = await executeGateCommand(root, command, { now: '2029-01-01T00:00:00.000Z' }); assert.equal(first.outcome, 'confirmation_required'); assert.equal(first.resulting_generation, 2);
17
+ const replay = await executeGateCommand(root, command); assert.equal(replay.replayed, true); assert.equal(replay.receipt_id, first.receipt_id);
18
+ await assert.rejects(executeGateCommand(root, { ...command, idempotency_key: 'event-stale' }, { now: '2029-01-01T00:00:02.000Z' }), /stale_generation/);
19
+ assert.equal((await executeGateCommand(root, { ...command, expected_generation: 2, idempotency_key: 'event-2' }, { now: '2029-01-01T00:00:03.000Z' })).outcome, 'approved');
20
+ await assert.rejects(executeGateCommand(root, { ...command, expected_generation: 2, idempotency_key: 'event-after' }), /gate_already_processed/);
21
+ await createHumanGate(root, { ...base, gate_id: 'gate_low', action: 'local edit', risk: { level: 'low' }, confirmation_required: false });
22
+ for (const bad of [{ actor_id: 'intruder' }, { source_channel: 'other' }, { source_message_id: 'forward' }, { event_type: 'natural_language' }]) await assert.rejects(executeGateCommand(root, { ...command, gate_id: 'gate_low', idempotency_key: `bad-${Object.keys(bad)[0]}`, ...bad }, { now: '2029-01-01T00:00:00.000Z' }), /(unauthorized|mismatch|untrusted)/);
23
+ assert.equal((await handleChannelGateEvent(root, { kind: 'ordinary_message', text: '好的,同意第一个' })).outcome, 'ignored_untrusted_chat'); assert.equal(parseBoundReply('同意'), null); assert.equal(parseBoundReply('/approve gate_low').gate_id, 'gate_low');
24
+ assert.equal((await handleChannelGateEvent(root, { kind: 'ordinary_message', text: '/show_gate gate_low' })).outcome, 'display_only');
25
+ const revision = await handleChannelGateEvent(root, { kind: 'message_reply', event_id: 'rev-1', actor_id: 'owner', channel: 'feishu', card_message_id: 'card-1', reply_to: 'card-1', text: '/request_revision gate_low fix evidence', expected_generation: 1 }, { now: '2029-01-01T00:00:00.000Z' });
26
+ assert.equal(revision.outcome, 'revision_created'); assert.equal((await getHumanGate(root, 'gate_low')).generation, 2);
27
+ assert.equal(revision.synchronized_card.buttons.every((button) => button.expected_generation === 2), true);
28
+ await createHumanGate(root, { ...base, gate_id: 'gate_expired', expiry: '2028-01-01T00:00:00.000Z', confirmation_required: false });
29
+ await assert.rejects(executeGateCommand(root, { ...command, gate_id: 'gate_expired', idempotency_key: 'expired' }, { now: '2029-01-01T00:00:00.000Z' }), /gate_expired/);
30
+ const feishuPayload = { header: { event_id: 'fs-1' }, event: { operator: { operator_id: { open_id: 'owner' } }, context: { open_message_id: 'card-1' }, action: { value: { gate_id: 'gate_high', decision: 'approve', expected_generation: 2 } } } };
31
+ assert.throws(() => normalizeFeishuGateEvent(feishuPayload), /feishu_signature_unverified/);
32
+ const feishu = normalizeFeishuGateEvent(feishuPayload, { signatureVerified: true }); assert.equal(feishu.kind, 'card_button');
33
+ await createHumanGate(root, { ...base, gate_id: 'gate_race', confirmation_required: false });
34
+ const raceBase = { ...command, gate_id: 'gate_race', decision: 'reject', expected_generation: 1 };
35
+ const race = await Promise.allSettled([executeGateCommand(root, { ...raceBase, idempotency_key: 'race-a' }), executeGateCommand(root, { ...raceBase, idempotency_key: 'race-b' })]);
36
+ assert.equal(race.filter((x) => x.status === 'fulfilled').length, 1); assert.equal(race.filter((x) => x.status === 'rejected').length, 1);
37
+ await createHumanGate(root, { ...base, gate_id: 'gate_cross', confirmation_required: false });
38
+ const cross = await Promise.allSettled([
39
+ executeGateCommand(root, { ...command, gate_id: 'gate_cross', idempotency_key: 'cross-feishu' }),
40
+ executeGateCommand(root, { ...command, gate_id: 'gate_cross', source_channel: 'dashboard', source_message_id: 'gate-panel', idempotency_key: 'cross-dashboard' })
41
+ ]);
42
+ assert.equal(cross.filter((x) => x.status === 'fulfilled').length, 1);
43
+ await createHumanGate(root, { ...base, gate_id: 'gate_http', confirmation_required: false });
44
+ const server = await createDashboardServer(root, { host: '127.0.0.1', port: 0 });
45
+ try {
46
+ const address = server.address();
47
+ const response = await fetch(`http://127.0.0.1:${address.port}/api/v1/gate-commands`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ gate_id: 'gate_http', decision: 'approve', expected_generation: 1, actor_id: 'owner', source_message_id: 'gate-panel', idempotency_key: 'http-1' }) });
48
+ assert.equal(response.status, 200); assert.equal((await response.json()).outcome, 'approved');
49
+ const gatesResponse = await fetch(`http://127.0.0.1:${address.port}/api/v1/gates`); assert.equal(gatesResponse.status, 200); assert.ok((await gatesResponse.json()).some((g) => g.gate_id === 'gate_http'));
50
+ } finally { await new Promise((resolve) => server.close(resolve)); }
51
+ console.log('human-gate-command self-test: ok (unit, integration, replay, expiry, authorization, misrecognition, Feishu mapping, concurrency)');
52
+ } finally { await rm(root, { recursive: true, force: true }); }
@@ -0,0 +1,30 @@
1
+ import assert from 'node:assert/strict';
2
+ import { readFile } from 'node:fs/promises';
3
+
4
+ const files = {
5
+ core: new URL('../lib/human-gate-command.mjs', import.meta.url),
6
+ adapter: new URL('../lib/human-gate-channel-adapter.mjs', import.meta.url),
7
+ dashboard: new URL('../lib/operator-dashboard.mjs', import.meta.url),
8
+ tests: new URL('./human-gate-command-self-test.mjs', import.meta.url),
9
+ docs: new URL('../docs/human-gate-command.md', import.meta.url)
10
+ };
11
+ const source = Object.fromEntries(await Promise.all(Object.entries(files).map(async ([key, file]) => [key, await readFile(file, 'utf8')])));
12
+ const checks = {
13
+ single_command_core: /executeGateCommand/.test(source.core) && /executeGateCommand/.test(source.adapter) && /executeGateCommand/.test(source.dashboard),
14
+ decisions: ['approve', 'reject', 'request_revision'].every((value) => source.core.includes(value)),
15
+ strict_binding: ['expected_generation', 'actor_unauthorized', 'source_channel_mismatch', 'source_message_mismatch', 'reply_binding_mismatch', 'gate_expired'].every((value) => source.core.includes(value)),
16
+ receipt_and_idempotency: /receipt_id/.test(source.core) && /idempotency_key_reused/.test(source.core),
17
+ cas_generation_fence: /gate_conflict_retry/.test(source.core) && /stale_generation/.test(source.core),
18
+ confirmation: /awaiting_confirmation/.test(source.core) && /confirmation_required/.test(source.core),
19
+ revision_artifact: /revisions/.test(source.core) && /supersedes_generation/.test(source.core),
20
+ fail_closed_chat: /ignored_untrusted_chat/.test(source.adapter) && /\/show_gate/.test(source.adapter),
21
+ synchronized_card: /synchronized_card/.test(source.adapter) && /disabled/.test(source.core),
22
+ complete_card: ['project', 'task', 'gate_id', 'action', 'reason', 'impact', 'risk', 'cost', 'evidence', 'dashboard_url', 'expiry', 'generation'].every((value) => source.core.includes(value)),
23
+ dashboard_buttons: /gate-commands/.test(source.dashboard) && ['approve', 'reject', 'request_revision'].every((value) => source.dashboard.includes(value)),
24
+ feishu_feasibility_no_send: /normalizeFeishuGateEvent/.test(source.adapter) && !/fetch\(|spawn\(|message.send/.test(source.adapter),
25
+ feishu_signature_boundary: /feishu_signature_unverified/.test(source.adapter) && /signatureVerified/.test(source.adapter),
26
+ fault_injection: ['race', 'replay', 'expired', 'intruder', 'forward', 'ordinary_message'].every((value) => source.tests.includes(value)),
27
+ documented_trust_boundary: /sole mutation boundary/.test(source.docs) && /no external calls/.test(source.docs) && /Ordinary chat is fail-closed/.test(source.docs)
28
+ };
29
+ for (const [name, passed] of Object.entries(checks)) assert.equal(passed, true, `terminal acceptance failed: ${name}`);
30
+ console.log(JSON.stringify({ outcome: 'accept', independent: true, checks: Object.keys(checks).length, accepted: Object.keys(checks), external_messages_sent: 0, online_test_residue: 0 }, null, 2));
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from 'node:child_process';
3
+ import { writeFile } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { randomUUID } from 'node:crypto';
6
+
7
+ const specs = [
8
+ { runtime: 'openclaw', executable: process.env.LOOP_OPENCLAW_BIN ?? 'openclaw' },
9
+ { runtime: 'codex-cli', executable: process.env.LOOP_CODEX_BIN ?? 'codex' },
10
+ { runtime: 'claude-code', executable: process.env.LOOP_CLAUDE_BIN ?? 'claude' }
11
+ ];
12
+ const runTasks = process.argv.includes('--run-tasks');
13
+ const taskToken = `LOOP_AGENT_TEAM_CONFORMANCE_${randomUUID()}`;
14
+ const prompt = `Return exactly this token and nothing else: ${taskToken}`;
15
+ const taskArgs = {
16
+ openclaw: ['agent', '--agent', process.env.LOOP_OPENCLAW_AGENT ?? 'main', '--session-key', `agent:${process.env.LOOP_OPENCLAW_AGENT ?? 'main'}:loop-conformance-${randomUUID()}`, '--message', prompt, '--thinking', 'off', '--timeout', '120', '--json'],
17
+ 'codex-cli': ['exec', '--ephemeral', '--skip-git-repo-check', '--sandbox', 'read-only', '--color', 'never', prompt],
18
+ 'claude-code': ['--print', '--no-session-persistence', '--permission-mode', 'plan', '--tools', '', '--model', process.env.LOOP_CLAUDE_MODEL ?? 'haiku', '--max-budget-usd', process.env.LOOP_CLAUDE_MAX_BUDGET_USD ?? '0.10', prompt]
19
+ };
20
+
21
+ function taskOutputMatches(runtime, stdout) {
22
+ if (runtime === 'openclaw') {
23
+ try {
24
+ const parsed = JSON.parse(stdout);
25
+ const texts = parsed?.result?.payloads?.map((item) => item.text).filter(Boolean) ?? [];
26
+ return texts.length === 1 && texts[0].trim() === taskToken;
27
+ } catch { return false; }
28
+ }
29
+ const lines = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
30
+ return lines.at(-1) === taskToken;
31
+ }
32
+
33
+ const results = specs.map((spec) => {
34
+ const probe = spawnSync(spec.executable, ['--version'], { encoding: 'utf8', timeout: 15_000 });
35
+ const available = !probe.error && probe.status === 0;
36
+ const result = { runtime: spec.runtime, executable: spec.executable, available, version: available ? (probe.stdout || probe.stderr).trim() : null, error: available ? null : (probe.error?.code ?? `exit_${probe.status}`), task_probe: null };
37
+ if (available && runTasks) {
38
+ const task = spawnSync(spec.executable, taskArgs[spec.runtime], { encoding: 'utf8', timeout: 180_000, maxBuffer: 4 * 1024 * 1024 });
39
+ const output = `${task.stdout ?? ''}\n${task.stderr ?? ''}`;
40
+ const tokenMatches = output.split(taskToken).length - 1;
41
+ const semanticMatch = taskOutputMatches(spec.runtime, task.stdout ?? '');
42
+ result.task_probe = {
43
+ attempted: true,
44
+ passed: !task.error && task.status === 0 && semanticMatch,
45
+ exit_status: task.status,
46
+ signal: task.signal,
47
+ token_match_count: tokenMatches,
48
+ semantic_output_match: semanticMatch,
49
+ error: task.error?.code ?? null,
50
+ output_bytes: Buffer.byteLength(output)
51
+ };
52
+ }
53
+ return result;
54
+ });
55
+ const unavailable = results.filter((item) => !item.available);
56
+ const failedTasks = runTasks ? results.filter((item) => !item.task_probe?.passed) : [];
57
+ const evidence = { version: 2, kind: runTasks ? 'live_agent_team_task_conformance' : 'live_agent_team_conformance_preflight', generated_at: new Date().toISOString(), passed: unavailable.length === 0 && failedTasks.length === 0, simulated: false, task_probe_requested: runTasks, task_contract: runTasks ? { operation: 'exact-token-response', external_delivery: false, filesystem_write_requested: false, unique_token: true } : null, results, next_action: unavailable.length ? 'install_or_bind_missing_runtime_executables_then_run_runtime_specific_task_probes' : failedTasks.length ? 'inspect_failed_runtime_task_probe' : runTasks ? 'run_full_regression_packaged_install_and_terminal_judgement' : 'rerun_with_--run-tasks' };
58
+ const outputIndex = process.argv.indexOf('--output');
59
+ if (outputIndex >= 0) await writeFile(path.resolve(process.argv[outputIndex + 1]), `${JSON.stringify(evidence, null, 2)}\n`);
60
+ console.log(JSON.stringify(evidence, null, 2));
61
+ if (!evidence.passed) process.exitCode = 2;
@@ -43,8 +43,14 @@ async function main() {
43
43
  'scripts/loops/openclaw-loop-dispatch.mjs',
44
44
  'scripts/loops/openclaw-loop.mjs',
45
45
  'scripts/loops/openclaw-loop-notify.mjs',
46
+ 'scripts/loops/openclaw-loop-gate.mjs',
46
47
  'AGENTS.md'
47
48
  ];
49
+ const systemdUserDir = path.join(process.env.XDG_CONFIG_HOME || path.join(process.env.HOME || '', '.config'), 'systemd', 'user');
50
+ required.push(
51
+ path.relative(args.root, path.join(systemdUserDir, 'loop-engineering-dashboard.service')),
52
+ path.relative(args.root, path.join(systemdUserDir, 'openclaw-gateway.service.d', 'loop-engineering-dashboard.conf'))
53
+ );
48
54
  const checks = [];
49
55
  for (const relative of required) checks.push({ id: `file:${relative}`, ok: await present(path.join(args.root, relative)) });
50
56
  const cli = await run(args.openclawBin, ['--version'], { cwd: args.root });
@@ -68,6 +74,13 @@ async function main() {
68
74
  });
69
75
  checks.push({ id: 'notification_dry_run', ok: smoke.code === 0, detail: (smoke.stdout || smoke.stderr).trim().slice(0, 500) });
70
76
  }
77
+ const gateBridge = path.join(args.root, 'scripts/loops/openclaw-loop-gate.mjs');
78
+ if (await present(gateBridge)) {
79
+ const syntax = await run(process.execPath, ['--check', gateBridge], { cwd: args.root });
80
+ checks.push({ id: 'human_gate_bridge_syntax', ok: syntax.code === 0, detail: (syntax.stderr || syntax.stdout).trim().slice(0, 500) });
81
+ const selfTest = await run(process.execPath, [gateBridge, '--self-test'], { cwd: args.root, env: { ...process.env, LOOP_WORKSPACE_ROOT: args.root } });
82
+ checks.push({ id: 'human_gate_bridge_self_test', ok: selfTest.code === 0 && /"externalWrite":false/.test(selfTest.stdout), detail: (selfTest.stdout || selfTest.stderr).trim().slice(0, 500) });
83
+ }
71
84
  const failed = checks.filter((check) => !check.ok);
72
85
  const report = { version: 1, status: failed.length ? 'fail' : 'ok', readOnly: true, externalWrite: false, root: args.root, queue: args.queue, workerAgent: args.workerAgent, checks, failed: failed.map((check) => check.id) };
73
86
  console.log(args.json ? JSON.stringify(report, null, 2) : `OpenClaw Loop doctor: ${report.status}\nchecks: ${checks.length - failed.length}/${checks.length}\nfailed: ${report.failed.join(', ') || 'none'}`);
@@ -46,7 +46,7 @@ function run(args, env = process.env) {
46
46
  const installBase = ['--root', root, '--queue', 'test-tasks', '--openclaw-bin', mockOpenClaw, '--systemctl-bin', mockSystemctl];
47
47
  const plan = await run([...installBase, '--json']);
48
48
  const planReport = JSON.parse(plan.stdout);
49
- if (plan.code !== 0 || planReport.language !== 'en' || planReport.status !== 'plan_only' || planReport.platform !== 'openclaw' || planReport.workerAgent !== 'builder' || planReport.workerSelection !== 'only_available' || !planReport.workerValidated || planReport.createsWorkerAgent || planReport.confirmationSummary?.targetPlatform !== 'OpenClaw' || planReport.confirmationSummary?.writesEnabled !== false || !path.isAbsolute(planReport.confirmationSummary?.platformCli || '') || !planReport.confirmationSummary?.notificationTarget.includes('OpenClaw')) throw new Error(`plan failed: ${plan.stderr}`);
49
+ if (plan.code !== 0 || planReport.language !== 'en' || planReport.status !== 'plan_only' || planReport.platform !== 'openclaw' || planReport.workerAgent !== 'builder' || planReport.workerSelection !== 'only_available' || !planReport.workerValidated || planReport.createsWorkerAgent || planReport.confirmationSummary?.targetPlatform !== 'OpenClaw' || planReport.confirmationSummary?.writesEnabled !== false || !path.isAbsolute(planReport.confirmationSummary?.platformCli || '') || !planReport.confirmationSummary?.notificationTarget.includes('OpenClaw') || planReport.dashboardAutostart?.gateway !== 'openclaw-gateway.service') throw new Error(`plan failed: ${plan.stderr}`);
50
50
  const zhPlan = await run([...installBase, '--language', 'zh']);
51
51
  if (zhPlan.code !== 0 || !zhPlan.stdout.includes('安装确认') || !zhPlan.stdout.includes('目标平台:OpenClaw') || !zhPlan.stdout.includes('允许写入:否(仅生成计划)')) throw new Error('explicit Chinese installation summary missing');
52
52
  const autoZhPlan = await run([...installBase, '--json'], { ...process.env, LC_ALL: 'zh_CN.UTF-8', LANG: 'C' });
@@ -79,9 +79,12 @@ const missingSourceRoute = await new Promise((resolve) => {
79
79
  if (missingSourceRoute.code !== 2 || !missingSourceRoute.stderr.includes('requires conversation metadata')) throw new Error('installed wrapper did not fail closed without source routing');
80
80
  const serviceFile = path.join(process.env.XDG_CONFIG_HOME, 'systemd/user/openclaw-loop-test-tasks-scheduler.service');
81
81
  const timerFile = path.join(process.env.XDG_CONFIG_HOME, 'systemd/user/openclaw-loop-test-tasks-scheduler.timer');
82
+ const dashboardServiceFile = path.join(process.env.XDG_CONFIG_HOME, 'systemd/user/loop-engineering-dashboard.service');
83
+ const dashboardDropIn = path.join(process.env.XDG_CONFIG_HOME, 'systemd/user/openclaw-gateway.service.d/loop-engineering-dashboard.conf');
82
84
  const service = await readFile(serviceFile, 'utf8');
83
85
  const timer = await readFile(timerFile, 'utf8');
84
86
  if (!service.includes('scheduler-tick') || !timer.includes('OnUnitActiveSec=1min')) throw new Error('scheduler systemd units were not installed');
87
+ if (!(await readFile(dashboardServiceFile, 'utf8')).includes('dashboard-serve') || !(await readFile(dashboardDropIn, 'utf8')).includes('Wants=loop-engineering-dashboard.service')) throw new Error('OpenClaw installer did not install Dashboard gateway autostart');
85
88
  if (service.includes('WorkingDirectory="') || service.includes('ExecStart="') || !service.includes('\\x20') || !service.includes('\\xe5\\xae\\x89\\xe8\\xa3\\x85')) throw new Error('scheduler service paths were not encoded with systemd path escapes');
86
89
  const systemdVerify = await new Promise((resolve) => {
87
90
  const child = spawn('systemd-analyze', ['verify', serviceFile, timerFile], { stdio: ['ignore', 'pipe', 'pipe'] });
@@ -101,6 +104,8 @@ if (schedulerTick.code !== 0) throw new Error(`installed scheduler tick failed:
101
104
  const schedulerState = JSON.parse(await readFile(path.join(root, 'runtime/loops/test-tasks/scheduler/state.json'), 'utf8'));
102
105
  if (!schedulerState.generatedAt || !schedulerState.nextRunAt) throw new Error('installed scheduler tick did not persist its heartbeat and cadence');
103
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');
104
109
  if (!notifier.includes("'message', 'send'") || !notifier.includes('source.channel') || !notifier.includes('source.target')) throw new Error('channel-neutral notifier missing');
105
110
  const delivery = await new Promise((resolve) => {
106
111
  const child = spawn(process.execPath, [path.join(root, 'scripts/loops/openclaw-loop-notify.mjs'), 'async result'], {
@@ -126,7 +131,7 @@ const doctorResult = await new Promise((resolve) => {
126
131
  });
127
132
  if (doctorResult.code !== 0) throw new Error(`doctor failed: ${doctorResult.stderr}`);
128
133
  const doctorReport = JSON.parse(doctorResult.stdout);
129
- 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');
130
135
  const smoke = new URL('./openclaw-smoke.mjs', import.meta.url).pathname;
131
136
  const smokeSource = await readFile(smoke, 'utf8');
132
137
  if (!smokeSource.includes('Do not change user or project files, configuration, credentials, or external state.')
@@ -148,7 +153,7 @@ const smokeReport = JSON.parse(smokeResult.stdout);
148
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');
149
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; }
150
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; }
151
- 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']) {
152
157
  const syntax = await run(['--help']);
153
158
  if (syntax.code !== 0) throw new Error(`installer help failed while checking ${generated}`);
154
159
  const check = await new Promise((resolve) => {