taskforce-loop-engineering 0.8.4 → 0.9.0

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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.0 - 2026-08-08
4
+
5
+ - Add a native Hermes Agent conversation integration with a plan-first installer, `hermes -z` one-shot worker dispatcher, `hermes send` notifier, managed scheduler, and source-bound delivery routing.
6
+ - Add a read-only Hermes doctor and disposable end-to-end smoke covering task contracts, checkpoints, final judgement, human-gate scanning, and terminal notification return.
7
+ - Add Hermes integration self-tests, including real systemd unit verification for paths containing spaces and non-ASCII characters.
8
+ - Add an explicit cross-platform installation confirmation summary to both OpenClaw and Hermes plans, showing the target platform, absolute CLI path, workspace, queue, scheduler, notification routing, and write status before installation.
9
+
10
+ ## 0.8.5 - 2026-08-08
11
+
12
+ - Fixed generated systemd scheduler units so `WorkingDirectory` and `ExecStart` use unquoted, byte-safe systemd path escapes.
13
+ - Added real `systemd-analyze verify` coverage for scheduler paths containing spaces and non-ASCII characters.
14
+
3
15
  ## 0.8.4 - 2026-08-07
4
16
 
5
17
  - Reclaim queue locks immediately when their recorded owner PID no longer
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Taskforce Loop Engineering
2
2
 
3
- OpenClaw-native loop engineering for repeated agent work. It provides a small
3
+ Durable loop engineering for repeated agent work on OpenClaw and Hermes Agent. It provides a small
4
4
  Node CLI that executes JSON loop specs, records append-only run artifacts, and
5
5
  uses a circuit breaker to escalate repeated failures.
6
6
 
@@ -39,8 +39,9 @@ when exactly one exists. If there is no unambiguous choice, it fails with the
39
39
  available agent ids and asks for `--worker-agent`; it never creates an agent.
40
40
  An explicitly selected worker must already exist.
41
41
 
42
- The default is read-only and reports the selected worker and files it would
43
- create. After review,
42
+ The default is read-only and prints an installation confirmation summary with
43
+ the target platform, absolute platform CLI path, workspace, queue, scheduler,
44
+ notification routing, and whether writes are enabled. After review,
44
45
  install the queue, configurable worker dispatcher, atomic route-and-run wrapper,
45
46
  channel-neutral asynchronous notifier, workspace health preflight, and managed
46
47
  `AGENTS.md` routing block with:
@@ -114,6 +115,61 @@ older managed integrations. Uninstall first disables the timer, then removes
114
115
  only clean managed files, units, and that exact instructions block; queue
115
116
  runtime is explicitly retained.
116
117
 
118
+ ## Hermes Agent conversation installer
119
+
120
+ Hermes Agent is supported through a native dispatcher and notifier while the
121
+ platform-neutral Loop Engineering CLI continues to own durable task state,
122
+ checkpoints, revisions, and final judgement. Generate a read-only plan first:
123
+
124
+ ```bash
125
+ loop-engineering-hermes-install \
126
+ --root /path/to/hermes/workspace \
127
+ --queue agent-tasks
128
+ ```
129
+
130
+ After review, install the Hermes one-shot worker dispatcher, `hermes send`
131
+ notifier, queue wrapper, workspace routing instructions, and managed systemd
132
+ user timer:
133
+
134
+ ```bash
135
+ loop-engineering-hermes-install \
136
+ --root /path/to/hermes/workspace \
137
+ --queue agent-tasks \
138
+ --confirm-install
139
+ ```
140
+
141
+ Like the OpenClaw installer, the read-only Hermes plan prints the target
142
+ platform, absolute Hermes CLI path, workspace, queue, scheduler, notification
143
+ routing, and write status before `--confirm-install` can enable changes.
144
+
145
+ The dispatcher invokes `hermes -z` (`--oneshot`) with the managed task contract and artifact
146
+ paths. Notifications invoke `hermes send --to` without an LLM call. Conversation
147
+ routing must preserve `--source-target` in Hermes
148
+ `platform:chat_id[:thread_id]` form so progress, human gates, and terminal
149
+ results return to the correct chat. The installer records the absolute Hermes
150
+ executable path so systemd does not depend on an interactive shell `PATH`. The
151
+ scheduler uses systemd rather than a
152
+ Hermes Cron agent session, so queue continuity remains entirely artifact-based.
153
+ This managed installer currently targets Linux hosts with systemd user services;
154
+ the platform-neutral core CLI can still run manually on other Hermes platforms.
155
+
156
+ Validate the integration and then run a disposable read-only end-to-end smoke:
157
+
158
+ ```bash
159
+ loop-engineering-hermes-doctor \
160
+ --root /path/to/hermes/workspace \
161
+ --queue agent-tasks
162
+
163
+ loop-engineering-hermes-smoke \
164
+ --root /path/to/hermes/workspace \
165
+ --queue agent-tasks
166
+ ```
167
+
168
+ The doctor checks the Hermes CLI, `hermes send`, generated files, queue wiring,
169
+ and a delivery dry-run. The smoke creates a temporary queue, executes a bounded
170
+ Hermes worker task, verifies contract/plan/final-judgement artifacts, tests the
171
+ notification return path without sending externally, and removes its artifacts.
172
+
117
173
  ## Commands
118
174
 
119
175
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskforce-loop-engineering",
3
- "version": "0.8.4",
3
+ "version": "0.9.0",
4
4
  "private": false,
5
5
  "description": "OpenClaw-native loop engineering CLI, templates, and skill for verifiable agent work loops.",
6
6
  "type": "module",
@@ -11,12 +11,16 @@
11
11
  "loop-engineering-openclaw-doctor": "scripts/openclaw-doctor.mjs",
12
12
  "loop-engineering-openclaw-smoke": "scripts/openclaw-smoke.mjs",
13
13
  "loop-engineering-openclaw-manage": "scripts/openclaw-manage.mjs",
14
+ "loop-engineering-hermes-install": "scripts/hermes-install.mjs",
15
+ "loop-engineering-hermes-doctor": "scripts/hermes-doctor.mjs",
16
+ "loop-engineering-hermes-smoke": "scripts/hermes-smoke.mjs",
14
17
  "run-loop-cron.sh": "scripts/run-loop-cron.sh"
15
18
  },
16
19
  "scripts": {
17
20
  "check:config-drift": "node --check scripts/config-drift-self-test.mjs && node scripts/config-drift-self-test.mjs",
18
21
  "check:openclaw-install": "node --check scripts/openclaw-install.mjs && node --check scripts/openclaw-doctor.mjs && node --check scripts/openclaw-smoke.mjs && node --check scripts/openclaw-manage.mjs && node scripts/openclaw-install-self-test.mjs",
19
- "check": "npm run check:config-drift && npm run check:openclaw-install && node --check bin/loop-engineering.mjs && node --check lib/core.mjs && node scripts/final-judgement-self-test.mjs && node scripts/route-notify-self-test.mjs && node scripts/scheduler-heartbeat-self-test.mjs && node bin/loop-engineering.mjs verify --config templates/workspace-health.json --root . && node bin/loop-engineering.mjs queue-status --queue smoke --root . --json >/dev/null && node bin/loop-engineering.mjs project-intake --root /tmp/loop-engineering-check --name smoke-project --brief \"Build a small website project\" --type auto --check \"npm test\" --json >/dev/null && node bin/loop-engineering.mjs project-plan --root /tmp/loop-engineering-check --project smoke-project --force --json >/dev/null && node bin/loop-engineering.mjs project-status --root /tmp/loop-engineering-check --project smoke-project --json >/dev/null && node bin/loop-engineering.mjs queue-scheduler-tick --queue smoke --root /tmp/loop-engineering-check --plan-only --force-due --json >/dev/null && node bin/loop-engineering.mjs queue-scheduler-tick --queue smoke-progress --root /tmp/loop-engineering-check --plan-only --force-due --progress-report --progress-report-when-not-due --json >/dev/null && node bin/loop-engineering.mjs workflow-metrics --queue smoke --root . --json >/dev/null && node bin/loop-engineering.mjs workflow-tune-plan --queue smoke --root . --json >/dev/null && node bin/loop-engineering.mjs code-queue-init --queue smoke-code --root /tmp/loop-engineering-check --force >/dev/null && node bin/loop-engineering.mjs code-worktree-list --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-task-status --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-task-dashboard --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-worktree-cleanup-plan --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-worktree-cleanup --queue smoke-code --root /tmp/loop-engineering-check --confirm-cleanup --json >/dev/null && node bin/loop-engineering.mjs code-patch-verify --patch README.md --root . --json >/dev/null && node bin/loop-engineering.mjs code-patch-apply-plan --patch README.md --json >/dev/null && node bin/loop-engineering.mjs queue-revision-ci-self-test --queue smoke-ci --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs summarize --root . --json >/dev/null && node bin/loop-engineering.mjs doctor --root . --json >/dev/null",
22
+ "check:hermes-install": "node --check scripts/hermes-install.mjs && node --check scripts/hermes-doctor.mjs && node --check scripts/hermes-smoke.mjs && node scripts/hermes-install-self-test.mjs",
23
+ "check": "npm run check:config-drift && npm run check:openclaw-install && npm run check:hermes-install && node --check bin/loop-engineering.mjs && node --check lib/core.mjs && node scripts/final-judgement-self-test.mjs && node scripts/route-notify-self-test.mjs && node scripts/scheduler-heartbeat-self-test.mjs && node bin/loop-engineering.mjs verify --config templates/workspace-health.json --root . && node bin/loop-engineering.mjs queue-status --queue smoke --root . --json >/dev/null && node bin/loop-engineering.mjs project-intake --root /tmp/loop-engineering-check --name smoke-project --brief \"Build a small website project\" --type auto --check \"npm test\" --json >/dev/null && node bin/loop-engineering.mjs project-plan --root /tmp/loop-engineering-check --project smoke-project --force --json >/dev/null && node bin/loop-engineering.mjs project-status --root /tmp/loop-engineering-check --project smoke-project --json >/dev/null && node bin/loop-engineering.mjs queue-scheduler-tick --queue smoke --root /tmp/loop-engineering-check --plan-only --force-due --json >/dev/null && node bin/loop-engineering.mjs queue-scheduler-tick --queue smoke-progress --root /tmp/loop-engineering-check --plan-only --force-due --progress-report --progress-report-when-not-due --json >/dev/null && node bin/loop-engineering.mjs workflow-metrics --queue smoke --root . --json >/dev/null && node bin/loop-engineering.mjs workflow-tune-plan --queue smoke --root . --json >/dev/null && node bin/loop-engineering.mjs code-queue-init --queue smoke-code --root /tmp/loop-engineering-check --force >/dev/null && node bin/loop-engineering.mjs code-worktree-list --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-task-status --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-task-dashboard --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-worktree-cleanup-plan --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-worktree-cleanup --queue smoke-code --root /tmp/loop-engineering-check --confirm-cleanup --json >/dev/null && node bin/loop-engineering.mjs code-patch-verify --patch README.md --root . --json >/dev/null && node bin/loop-engineering.mjs code-patch-apply-plan --patch README.md --json >/dev/null && node bin/loop-engineering.mjs queue-revision-ci-self-test --queue smoke-ci --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs summarize --root . --json >/dev/null && node bin/loop-engineering.mjs doctor --root . --json >/dev/null",
20
24
  "pack:dry": "npm pack --dry-run"
21
25
  },
22
26
  "engines": {
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+ import { access, readFile } from 'node:fs/promises';
3
+ import { spawn } from 'node:child_process';
4
+ import path from 'node:path';
5
+
6
+ function parseArgs(argv) {
7
+ const out = { root: process.cwd(), queue: 'agent-tasks', hermesBin: 'hermes', json: false };
8
+ for (let i = 0; i < argv.length; i++) { const arg = argv[i]; if (arg === '--root') out.root = path.resolve(argv[++i]); else if (arg === '--queue') out.queue = argv[++i]; else if (arg === '--hermes-bin') out.hermesBin = argv[++i]; else if (arg === '--json') out.json = true; else if (arg === '--help' || arg === '-h') out.help = true; else throw new Error(`Unknown argument: ${arg}`); }
9
+ return out;
10
+ }
11
+ function run(command, args, options = {}) { return new Promise((resolve) => { const child = spawn(command, args, { cwd: options.cwd, 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('error', (error) => resolve({ code: 127, stdout, stderr: `${stderr}${error.message}` })); child.on('close', (code, signal) => resolve({ code: code ?? (signal ? 128 : 1), stdout, stderr })); }); }
12
+ async function present(file) { try { await access(file); return true; } catch { return false; } }
13
+
14
+ async function main() {
15
+ const args = parseArgs(process.argv.slice(2));
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'];
18
+ const checks = []; for (const relative of required) checks.push({ id: `file:${relative}`, ok: await present(path.join(args.root, relative)) });
19
+ 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
+ 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) });
21
+ const queueFile = path.join(args.root, `configs/loops/queues/${args.queue}.json`);
22
+ if (await present(queueFile)) { try { const queue = JSON.parse(await readFile(queueFile, 'utf8')); checks.push({ id: 'queue_config', ok: queue.queue === args.queue && queue.dispatcher === 'node scripts/loops/hermes-loop-dispatch.mjs' }); } catch (error) { checks.push({ id: 'queue_config', ok: false, detail: error.message }); } }
23
+ const notifier = path.join(args.root, 'scripts/loops/hermes-loop-notify.mjs');
24
+ if (await present(notifier)) { const dry = await run(process.execPath, [notifier, 'Loop Engineering notification dry-run'], { cwd: args.root, env: { ...process.env, LOOP_NOTIFICATION_DRY_RUN: '1', LOOP_NOTIFICATION_SOURCE: JSON.stringify({ channel: 'telegram', target: 'telegram:loop-doctor-dry-run' }) } }); checks.push({ id: 'notification_dry_run', ok: dry.code === 0 && dry.stdout.includes('"dryRun":true'), detail: (dry.stdout || dry.stderr).trim().slice(0, 500) }); }
25
+ const failed = checks.filter((check) => !check.ok); const report = { version: 1, platform: 'hermes', status: failed.length ? 'fail' : 'ok', readOnly: true, externalWrite: false, root: args.root, queue: args.queue, checks, failed: failed.map((check) => check.id) };
26
+ console.log(args.json ? JSON.stringify(report, null, 2) : `Hermes Loop doctor: ${report.status}\nchecks: ${checks.length - failed.length}/${checks.length}\nfailed: ${report.failed.join(', ') || 'none'}`); if (failed.length) process.exitCode = 1;
27
+ }
28
+ main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; });
@@ -0,0 +1,42 @@
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 hermes 安装-'));
8
+ const mockHermes = path.join(root, 'mock-hermes.mjs'); const mockSystemctl = path.join(root, 'mock-systemctl.mjs'); const systemctlCapture = path.join(root, 'systemctl.jsonl'); const sendCapture = path.join(root, 'send.json');
9
+ process.env.XDG_CONFIG_HOME = path.join(root, 'xdg'); process.env.SYSTEMCTL_CAPTURE = systemctlCapture;
10
+ await writeFile(path.join(root, 'AGENTS.md'), '# Test workspace\n');
11
+ await writeFile(mockHermes, `#!/usr/bin/env node
12
+ import { mkdir, writeFile } from 'node:fs/promises';
13
+ import path from 'node:path';
14
+ const args = process.argv.slice(2);
15
+ if (args[0] === '--version') console.log('Hermes Agent v0.20.0');
16
+ else if (args[0] === 'send' && args.includes('--help')) console.log('hermes send --to TARGET MESSAGE');
17
+ else if (args[0] === 'send') { if (process.env.HERMES_SEND_CAPTURE) await writeFile(process.env.HERMES_SEND_CAPTURE, JSON.stringify(args)); console.log('sent'); }
18
+ else if (args.includes('-z')) {
19
+ if (process.env.LOOP_CHECKPOINTS_DIR) { const dir = path.resolve(process.cwd(), process.env.LOOP_CHECKPOINTS_DIR); await mkdir(dir, { recursive: true }); await writeFile(path.join(dir, 'cp1.json'), JSON.stringify({ version: 1, task_id: process.env.LOOP_TASK_ID, checkpoint_id: 'cp1', status: 'ready_for_acceptance', summary: 'Hermes read-only smoke completed.', files_changed: [], verification: [{ command: 'mock hermes smoke', outcome: 'passed' }], blockers: [], risks: [], next_action: 'acceptance_review' })); }
20
+ console.log('SMOKE_OK');
21
+ } else process.exitCode = 2;
22
+ `); await chmod(mockHermes, 0o755);
23
+ await writeFile(mockSystemctl, `#!/usr/bin/env node
24
+ import { appendFile } from 'node:fs/promises';
25
+ if (process.env.SYSTEMCTL_CAPTURE) await appendFile(process.env.SYSTEMCTL_CAPTURE, JSON.stringify(process.argv.slice(2)) + '\\n');
26
+ `); await chmod(mockSystemctl, 0o755);
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
+ 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
+ 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}`);
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
+ 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
+ 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');
34
+ const dispatcher = await readFile(path.join(root, 'scripts/loops/hermes-loop-dispatch.mjs'), 'utf8'); if (!dispatcher.includes("'-z', prompt")) throw new Error('Hermes one-shot dispatcher missing');
35
+ 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}`);
36
+ 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');
37
+ 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');
38
+ const verify = await run('systemd-analyze', ['verify', serviceFile, timerFile]); if (verify.code !== 0) throw new Error(`Hermes systemd units invalid: ${verify.stderr || verify.stdout}`);
39
+ 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}`);
40
+ 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}`);
41
+ const calls = await readFile(systemctlCapture, 'utf8'); if (!calls.includes('["--user","enable","--now","hermes-loop-hermes-tasks-scheduler.timer"]')) throw new Error('Hermes scheduler timer was not enabled');
42
+ console.log('hermes installer self-test passed');
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env node
2
+ import { access, appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { createHash } from 'node:crypto';
4
+ import { spawn } from 'node:child_process';
5
+ import path from 'node:path';
6
+
7
+ function parseArgs(argv) {
8
+ const out = { root: process.cwd(), queue: 'agent-tasks', hermesBin: 'hermes', systemctlBin: 'systemctl', json: false, confirmInstall: false, force: false };
9
+ for (let i = 0; i < argv.length; i++) {
10
+ const arg = argv[i];
11
+ if (arg === '--root') out.root = path.resolve(argv[++i]);
12
+ else if (arg === '--queue') out.queue = argv[++i];
13
+ else if (arg === '--hermes-bin') out.hermesBin = argv[++i];
14
+ else if (arg === '--systemctl-bin') out.systemctlBin = argv[++i];
15
+ else if (arg === '--confirm-install') out.confirmInstall = true;
16
+ else if (arg === '--force') out.force = true;
17
+ else if (arg === '--json') out.json = true;
18
+ else if (arg === '--help' || arg === '-h') out.help = true;
19
+ else throw new Error(`Unknown argument: ${arg}`);
20
+ }
21
+ return out;
22
+ }
23
+
24
+ function safeId(value, label) { if (!/^[a-zA-Z0-9._-]+$/.test(value)) throw new Error(`${label} contains unsupported characters.`); return value; }
25
+ function systemdEscapePath(value) {
26
+ return [...Buffer.from(String(value))].map((byte) => /[A-Za-z0-9/_.:-]/.test(String.fromCharCode(byte)) ? String.fromCharCode(byte) : `\\x${byte.toString(16).padStart(2, '0')}`).join('');
27
+ }
28
+ const sha256 = (value) => createHash('sha256').update(value).digest('hex');
29
+ async function exists(file) { try { await access(file); return true; } catch { return false; } }
30
+ async function resolveExecutable(command) {
31
+ if (path.isAbsolute(command) || command.includes(path.sep)) {
32
+ const resolved = path.resolve(command); if (!await exists(resolved)) throw new Error(`Executable not found: ${resolved}`); return resolved;
33
+ }
34
+ for (const dir of String(process.env.PATH || '').split(path.delimiter).filter(Boolean)) { const candidate = path.join(dir, command); if (await exists(candidate)) return candidate; }
35
+ throw new Error(`Executable not found on PATH: ${command}`);
36
+ }
37
+ function run(command, args, options = {}) {
38
+ return new Promise((resolve) => {
39
+ const child = spawn(command, args, { cwd: options.cwd, env: options.env || process.env, stdio: ['ignore', 'pipe', 'pipe'] });
40
+ let stdout = ''; let stderr = '';
41
+ child.stdout.on('data', (chunk) => { stdout += chunk; }); child.stderr.on('data', (chunk) => { stderr += chunk; });
42
+ child.on('error', (error) => resolve({ code: 127, stdout, stderr: `${stderr}${error.message}` }));
43
+ child.on('close', (code, signal) => resolve({ code: code ?? (signal ? 128 : 1), stdout, stderr }));
44
+ });
45
+ }
46
+
47
+ function formatConfirmationSummary(summary) {
48
+ return [
49
+ 'Installation confirmation',
50
+ ` target platform: ${summary.targetPlatform}`,
51
+ ` platform CLI: ${summary.platformCli}`,
52
+ ` workspace: ${summary.workspace}`,
53
+ ` queue: ${summary.queue}`,
54
+ ` scheduler: ${summary.scheduler}`,
55
+ ` notification target: ${summary.notificationTarget}`,
56
+ ` writes enabled: ${summary.writesEnabled ? 'yes' : 'no (plan only)'}`
57
+ ].join('\n');
58
+ }
59
+
60
+ function dispatcherSource({ hermesBin }) {
61
+ return `#!/usr/bin/env node
62
+ import { readFile } from 'node:fs/promises';
63
+ import { spawn } from 'node:child_process';
64
+ const task = JSON.parse(await readFile(process.env.LOOP_TASK_FILE, 'utf8'));
65
+ const prompt = [
66
+ 'You are receiving an already loop-managed task.',
67
+ 'Do not route or enqueue this task again.',
68
+ 'Read the task contract, development plan, acceptance plan, and live amendments before implementation.',
69
+ \`Task id: \${task.id}\`,
70
+ \`Task contract: \${process.env.LOOP_TASK_CONTRACT_FILE || 'not provided'}\`,
71
+ \`Development plan: \${process.env.LOOP_DEV_PLAN_FILE || 'not provided'}\`,
72
+ \`Acceptance plan: \${process.env.LOOP_ACCEPTANCE_PLAN_FILE || 'not provided'}\`,
73
+ \`Live amendments: \${process.env.LOOP_LATEST_AMENDMENT_FILE || 'not provided'}\`,
74
+ \`Checkpoints dir: \${process.env.LOOP_CHECKPOINTS_DIR || 'not provided'}\`,
75
+ '', task.body, '',
76
+ 'Before each checkpoint and final completion, reread the live amendment file. Write a checkpoint when possible and finish with status, evidence, verification, blockers, and next action.'
77
+ ].join('\\n');
78
+ const child = spawn(${JSON.stringify(hermesBin)}, ['-z', prompt], { cwd: process.cwd(), env: process.env, stdio: 'inherit' });
79
+ child.on('close', (code, signal) => { process.exitCode = code ?? (signal ? 128 : 1); });
80
+ `;
81
+ }
82
+
83
+ function wrapperSource({ queue, loopBin }) {
84
+ return `#!/usr/bin/env node
85
+ import { spawn } from 'node:child_process';
86
+ const [command, ...rest] = process.argv.slice(2);
87
+ function run(args) { return new Promise((resolve) => { const child = spawn(process.execPath, [${JSON.stringify(loopBin)}, ...args], { cwd: process.cwd(), env: process.env, stdio: 'inherit' }); child.on('close', (code, signal) => resolve(code ?? (signal ? 128 : 1))); }); }
88
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
89
+ async function runWhenUnlocked(args, waitMs = 300000) { const deadline = Date.now() + waitMs; while (true) { const code = await run(args); if (code !== 2 || Date.now() >= deadline) return code; await wait(250); } }
90
+ if (command === 'route') {
91
+ const messageIndex = rest.indexOf('--message'); const message = messageIndex >= 0 ? String(rest[messageIndex + 1] || '') : '';
92
+ const amendment = /(?:继续(?:当前|这个)?\\s*loop|给(?:当前|这个)?\\s*loop\\s*(?:补充|增加|加)|补充当前\\s*loop)/i.test(message);
93
+ const routeCode = await run(['route-message', '--queue', ${JSON.stringify(queue)}, '--route', '--confirm-execute', amendment ? '--amend-active' : '--supersede-active', ...rest]);
94
+ const queueOnly = /(?:只入队|只排队|暂不执行|不立即执行)/.test(message);
95
+ const runCode = routeCode || queueOnly || amendment ? routeCode : await runWhenUnlocked(['run-queue', '--config', ${JSON.stringify(`configs/loops/queues/${queue}.json`)}, '--progress-notify-command', 'node scripts/loops/hermes-loop-notify.mjs']);
96
+ const humanCode = await run(['queue-human-input-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/hermes-loop-notify.mjs']);
97
+ const terminalCode = await run(['queue-terminal-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/hermes-loop-notify.mjs']);
98
+ process.exitCode = routeCode || runCode || humanCode || terminalCode;
99
+ } else if (command === 'run-once') {
100
+ const runCode = await run(['run-queue', '--config', ${JSON.stringify(`configs/loops/queues/${queue}.json`)}, '--progress-notify-command', 'node scripts/loops/hermes-loop-notify.mjs', ...rest]);
101
+ const humanCode = await run(['queue-human-input-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/hermes-loop-notify.mjs']);
102
+ const terminalCode = await run(['queue-terminal-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/hermes-loop-notify.mjs']);
103
+ process.exitCode = runCode || humanCode || terminalCode;
104
+ } else if (command === 'scheduler-tick') {
105
+ const tickCode = await run(['queue-scheduler-tick', '--config', ${JSON.stringify(`configs/loops/queues/${queue}.json`)}, '--progress-notify-command', 'node scripts/loops/hermes-loop-notify.mjs', ...rest]);
106
+ const humanCode = await run(['queue-human-input-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/hermes-loop-notify.mjs']);
107
+ const terminalCode = await run(['queue-terminal-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/hermes-loop-notify.mjs']);
108
+ process.exitCode = tickCode || humanCode || terminalCode;
109
+ } else { console.error('Usage: node scripts/loops/hermes-loop.mjs route|run-once|scheduler-tick'); process.exitCode = 1; }
110
+ `;
111
+ }
112
+
113
+ function notifierSource({ hermesBin }) {
114
+ return `#!/usr/bin/env node
115
+ import { spawn } from 'node:child_process';
116
+ const message = process.argv.slice(2).join(' ').trim();
117
+ const rawSource = process.env.LOOP_HUMAN_INPUT_SOURCE || process.env.LOOP_NOTIFICATION_SOURCE || '';
118
+ if (!message) { console.error('loop notifier requires a message'); process.exit(2); }
119
+ let source; try { source = JSON.parse(rawSource); } catch { console.error('loop notifier received invalid source metadata'); process.exit(2); }
120
+ if (!source || typeof source !== 'object' || !source.target) { console.error('Hermes loop notifier requires source.target in platform[:chat[:thread]] form'); process.exit(2); }
121
+ const args = ['send', '--to', String(source.target), '--quiet', message];
122
+ if (process.env.LOOP_NOTIFICATION_DRY_RUN === '1') { console.log(JSON.stringify({ dryRun: true, command: ${JSON.stringify(hermesBin)}, args })); }
123
+ else { const child = spawn(${JSON.stringify(hermesBin)}, args, { cwd: process.cwd(), env: process.env, stdio: 'inherit' }); child.on('close', (code, signal) => { process.exitCode = code ?? (signal ? 128 : 1); }); }
124
+ `;
125
+ }
126
+
127
+ function schedulerServiceSource({ root, queue }) { return `[Unit]\nDescription=Taskforce Loop Engineering Hermes scheduler for ${queue}\nAfter=default.target\n\n[Service]\nType=oneshot\nWorkingDirectory=${systemdEscapePath(root)}\nExecStart=${systemdEscapePath(process.execPath)} ${systemdEscapePath(path.join(root, 'scripts', 'loops', 'hermes-loop.mjs'))} scheduler-tick --json\n`; }
128
+ function schedulerTimerSource({ queue }) { return `[Unit]\nDescription=Wake Taskforce Loop Engineering Hermes scheduler for ${queue}\n\n[Timer]\nOnBootSec=30s\nOnUnitActiveSec=1min\nAccuracySec=10s\nPersistent=true\nUnit=hermes-loop-${queue}-scheduler.service\n\n[Install]\nWantedBy=timers.target\n`; }
129
+ function instructionsBlock({ queue }) { return `\n<!-- loop-engineering:hermes:start -->\n## Loop Engineering conversation routing (Hermes)\n\n- Route only explicit Loop Engineering requests. Already managed tasks must never be routed again.\n- Run \`node scripts/loops/hermes-loop.mjs route --message "<full user message>"\` and pass \`--source-channel <platform> --source-target <platform:chat_id[:thread_id]>\` so notifications return to the originating Hermes conversation.\n- \`走 loop\` executes immediately; only \`只入队\` or \`只排队\` suppresses execution. External, destructive, production, credential, or memory migration actions remain separately gated.\n- Queue: \`${queue}\`.\n<!-- loop-engineering:hermes:end -->\n`; }
130
+
131
+ async function main() {
132
+ const args = parseArgs(process.argv.slice(2));
133
+ if (args.help) { console.log('Usage: loop-engineering-hermes-install [--root workspace] [--queue agent-tasks] [--hermes-bin hermes] [--systemctl-bin systemctl] [--confirm-install] [--force] [--json]'); return; }
134
+ safeId(args.queue, 'queue');
135
+ args.hermesBin = await resolveExecutable(args.hermesBin);
136
+ const hermes = await run(args.hermesBin, ['--version'], { cwd: args.root });
137
+ if (hermes.code !== 0) throw new Error(`Cannot run Hermes CLI with ${args.hermesBin}: ${(hermes.stderr || hermes.stdout).trim() || `exit ${hermes.code}`}`);
138
+ const loopBin = new URL('../bin/loop-engineering.mjs', import.meta.url).pathname;
139
+ const systemdUserDir = path.join(process.env.XDG_CONFIG_HOME || path.join(process.env.HOME || '', '.config'), 'systemd', 'user');
140
+ const unit = `hermes-loop-${args.queue}-scheduler.service`; const timer = `hermes-loop-${args.queue}-scheduler.timer`;
141
+ 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) };
142
+ 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));
143
+ const confirmationSummary = { targetPlatform: 'Hermes', platformCli: args.hermesBin, workspace: args.root, queue: args.queue, scheduler: `systemd user timer ${timer}`, notificationTarget: 'source-bound at runtime (original Hermes conversation)', writesEnabled: args.confirmInstall };
144
+ const report = { version: 1, platform: 'hermes', 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 };
145
+ if (conflicts.length && !args.force && args.confirmInstall) throw new Error(`Refusing to overwrite: ${conflicts.join(', ')}. Use --force after review.`);
146
+ if (!args.json) console.log(formatConfirmationSummary(confirmationSummary));
147
+ if (args.confirmInstall) {
148
+ await mkdir(path.dirname(files.queueConfig), { recursive: true }); await mkdir(path.dirname(files.dispatcher), { recursive: true }); await mkdir(path.join(args.root, 'runtime', 'loops', args.queue), { recursive: true });
149
+ if (!await exists(files.workspaceHealth)) await writeFile(files.workspaceHealth, `${JSON.stringify({ id: 'workspace-health', goal: 'Keep this workspace loop-ready and detect obvious drift.', level: 'L1', mode: 'report-only', maxRuntimeMs: 120000, breaker: { maxConsecutiveFailures: 3, sameFailureThreshold: 2 }, checks: [{ id: 'workspace-root', type: 'files', paths: ['.'] }] }, null, 2)}\n`);
150
+ const queueContent = `${JSON.stringify({ queue: args.queue, description: 'Hermes conversation queue.', dispatcher: 'node scripts/loops/hermes-loop-dispatch.mjs', preflightConfig: 'configs/loops/workspace-health.json', timeoutMs: 1800000, leaseMs: 1860000, staleActiveMs: 3600000, scheduler: { required: true, heartbeatMaxAgeMs: 300000, initialInterval: '1m', minInterval: '1m', maxInterval: '4h', speedupFactor: 0.5, backoffFactor: 2, idleBackoffFactor: 2, humanGateBackoffFactor: 3, longRunHeadroomFactor: 1.25, jitter: '10s' }, retry: { maxAttempts: 1, runtimeRecoveryMaxAttempts: 2, sessionMaxTicks: 10, retryDelayMs: 0, retryExitCodes: [1], requiresHumanActionPatterns: ['requires human', '需要人工', 'Permission denied', 'Operation not permitted'] }, revisionPolicy: { enabled: true, maxRevisionRounds: 3, sameFailureThreshold: 2, requireStrategyChange: true } }, null, 2)}\n`;
151
+ const contents = { queueConfig: queueContent, dispatcher: dispatcherSource(args), wrapper: wrapperSource({ queue: args.queue, loopBin }), notifier: notifierSource(args), schedulerService: schedulerServiceSource({ root: args.root, queue: args.queue }), schedulerTimer: schedulerTimerSource({ queue: args.queue }) };
152
+ await mkdir(systemdUserDir, { recursive: true });
153
+ for (const [kind, content] of Object.entries(contents)) await writeFile(files[kind], content);
154
+ 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()}`);
155
+ 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()}`);
156
+ const marker = instructionsBlock({ queue: args.queue }); const current = await readFile(files.instructions, 'utf8').catch(() => ''); if (!current.includes('<!-- loop-engineering:hermes:start -->')) await appendFile(files.instructions, marker);
157
+ await mkdir(path.dirname(files.manifest), { recursive: true }); await writeFile(files.manifest, `${JSON.stringify({ version: 1, platform: 'hermes', 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`);
158
+ }
159
+ console.log(args.json ? JSON.stringify(report, null, 2) : `Hermes Loop installer: ${report.status}\nnext: ${args.confirmInstall ? 'Run loop-engineering-hermes-doctor, then route a harmless smoke task.' : 'Review this summary, then rerun with --confirm-install.'}`);
160
+ }
161
+ main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; });
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ import { access, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { spawn } from 'node:child_process';
4
+ import path from 'node:path';
5
+
6
+ function parseArgs(argv) {
7
+ const out = { root: process.cwd(), queue: 'agent-tasks', hermesBin: 'hermes', loopBin: 'loop-engineering', json: false, keepArtifacts: false };
8
+ for (let i = 0; i < argv.length; i++) { const arg = argv[i]; if (arg === '--root') out.root = path.resolve(argv[++i]); else if (arg === '--queue') out.queue = argv[++i]; else if (arg === '--hermes-bin') out.hermesBin = argv[++i]; else if (arg === '--loop-bin') out.loopBin = argv[++i]; else if (arg === '--keep-artifacts') out.keepArtifacts = true; else if (arg === '--json') out.json = true; else if (arg === '--help' || arg === '-h') out.help = true; else throw new Error(`Unknown argument: ${arg}`); }
9
+ return out;
10
+ }
11
+ function run(command, args, options = {}) { return new Promise((resolve) => { const child = spawn(command, args, { cwd: options.cwd, 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('error', (error) => resolve({ code: 127, stdout, stderr: `${stderr}${error.message}` })); child.on('close', (code, signal) => resolve({ code: code ?? (signal ? 128 : 1), stdout, stderr })); }); }
12
+ async function exists(file) { try { await access(file); return true; } catch { return false; } }
13
+ function safeId(value) { if (!/^[a-zA-Z0-9._-]+$/.test(value)) throw new Error('queue contains unsupported characters.'); return value; }
14
+
15
+ async function main() {
16
+ const args = parseArgs(process.argv.slice(2));
17
+ if (args.help) { console.log('Usage: loop-engineering-hermes-smoke [--root workspace] [--queue agent-tasks] [--hermes-bin hermes] [--loop-bin loop-engineering] [--keep-artifacts] [--json]'); return; }
18
+ safeId(args.queue); const runToken = `${Date.now()}-${process.pid}`; const smokeQueue = `smoke-${args.queue}-${runToken}`;
19
+ const baseConfig = path.join(args.root, `configs/loops/queues/${args.queue}.json`); const smokeConfig = path.join(args.root, `configs/loops/queues/${smokeQueue}.json`); const smokeRuntime = path.join(args.root, 'runtime', 'loops', smokeQueue); const doctorScript = new URL('./hermes-doctor.mjs', import.meta.url).pathname;
20
+ const steps = []; let taskId = null;
21
+ try {
22
+ const doctor = await run(process.execPath, [doctorScript, '--root', args.root, '--queue', args.queue, '--hermes-bin', args.hermesBin, '--json'], { cwd: args.root }); steps.push({ id: 'doctor', ok: doctor.code === 0 }); if (doctor.code !== 0) throw new Error(`doctor failed: ${doctor.stderr || doctor.stdout}`);
23
+ const config = JSON.parse(await readFile(baseConfig, 'utf8')); config.queue = smokeQueue; config.description = 'Temporary read-only Hermes integration smoke queue.'; config.retry = { ...(config.retry || {}), maxAttempts: 1 }; await mkdir(path.dirname(smokeConfig), { recursive: true }); await writeFile(smokeConfig, `${JSON.stringify(config, null, 2)}\n`);
24
+ const message = '走 loop:Perform a read-only Hermes integration smoke. Do not change files or external state. Report SMOKE_OK with verification evidence.';
25
+ const route = await run(args.loopBin, ['route-message', '--root', args.root, '--queue', smokeQueue, '--message', message, '--route', '--confirm-execute', '--source-channel', 'telegram', '--source-target', 'telegram:loop-smoke-dry-run', '--source-message-id', `smoke-${runToken}`, '--json'], { cwd: args.root }); steps.push({ id: 'route', ok: route.code === 0 }); if (route.code !== 0) throw new Error(`route failed: ${route.stderr || route.stdout}`);
26
+ taskId = JSON.parse(route.stdout).task?.id || null;
27
+ const execute = await run(args.loopBin, ['run-queue', '--root', args.root, '--config', path.relative(args.root, smokeConfig), '--json'], { cwd: args.root }); steps.push({ id: 'worker_execution', ok: execute.code === 0 }); if (execute.code !== 0) throw new Error(`worker execution failed: ${execute.stderr || execute.stdout}`);
28
+ const taskDir = taskId ? path.join(smokeRuntime, 'tasks', taskId) : '';
29
+ for (const artifact of ['task_contract.json', 'dev_plan.json', 'acceptance_plan.json', 'final_judgement.json']) { const ok = Boolean(taskDir) && await exists(path.join(taskDir, artifact)); steps.push({ id: `artifact:${artifact}`, ok }); if (!ok) throw new Error(`missing smoke artifact: ${artifact}`); }
30
+ const notifyEnv = { ...process.env, LOOP_NOTIFICATION_DRY_RUN: '1' }; const notifyCommand = 'node scripts/loops/hermes-loop-notify.mjs';
31
+ const humanNotify = await run(args.loopBin, ['queue-human-input-notify', '--root', args.root, '--queue', smokeQueue, '--notify-command', notifyCommand, '--json'], { cwd: args.root, env: notifyEnv }); steps.push({ id: 'human_gate_scan', ok: humanNotify.code === 0 });
32
+ const terminalNotify = await run(args.loopBin, ['queue-terminal-notify', '--root', args.root, '--queue', smokeQueue, '--notify-command', notifyCommand, '--json'], { cwd: args.root, env: notifyEnv }); const terminal = terminalNotify.code === 0 ? JSON.parse(terminalNotify.stdout) : null; const terminalOk = terminalNotify.code === 0 && terminal?.sent === 1; steps.push({ id: 'terminal_dry_run_return', ok: terminalOk }); if (!terminalOk) throw new Error(`terminal dry-run failed: ${terminalNotify.stderr || terminalNotify.stdout}`);
33
+ const report = { version: 1, platform: 'hermes', status: 'ok', readOnlyTask: true, externalWrite: false, queue: args.queue, smokeQueue, taskId, steps, keptArtifacts: args.keepArtifacts };
34
+ console.log(args.json ? JSON.stringify(report, null, 2) : `Hermes Loop smoke: ok\ntask: ${taskId}\nexternal write: no\nartifacts: ${args.keepArtifacts ? smokeRuntime : 'cleaned'}`);
35
+ } finally { if (!args.keepArtifacts && smokeQueue.startsWith(`smoke-${args.queue}-`)) { await rm(smokeConfig, { force: true }); await rm(smokeRuntime, { recursive: true, force: true }); } }
36
+ }
37
+ main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; });
@@ -4,7 +4,7 @@ import { spawn } from 'node:child_process';
4
4
  import { tmpdir } from 'node:os';
5
5
  import path from 'node:path';
6
6
 
7
- const root = await mkdtemp(path.join(tmpdir(), 'loop-openclaw-install-'));
7
+ const root = await mkdtemp(path.join(tmpdir(), 'loop openclaw 安装-'));
8
8
  const deliveryCapture = path.join(root, 'delivery.json');
9
9
  const mockOpenClaw = path.join(root, 'mock-openclaw.mjs');
10
10
  const mockSystemctl = path.join(root, 'mock-systemctl.mjs');
@@ -46,7 +46,9 @@ function run(args) {
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.status !== 'plan_only' || planReport.workerAgent !== 'builder' || planReport.workerSelection !== 'only_available' || !planReport.workerValidated || planReport.createsWorkerAgent) throw new Error(`plan failed: ${plan.stderr}`);
49
+ if (plan.code !== 0 || 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}`);
50
+ const humanPlan = await run(installBase);
51
+ if (humanPlan.code !== 0 || !humanPlan.stdout.includes('Installation confirmation') || !humanPlan.stdout.includes('target platform: OpenClaw') || !humanPlan.stdout.includes('writes enabled: no (plan only)')) throw new Error('human-readable OpenClaw confirmation summary missing');
50
52
  const missingWorker = await run([...installBase, '--worker-agent', 'missing', '--json']);
51
53
  if (missingWorker.code === 0 || !missingWorker.stderr.includes('does not exist')) throw new Error('installer accepted a missing worker agent');
52
54
  const install = await run([...installBase, '--worker-agent', 'builder', '--confirm-install', '--json']);
@@ -66,6 +68,14 @@ const timerFile = path.join(process.env.XDG_CONFIG_HOME, 'systemd/user/openclaw-
66
68
  const service = await readFile(serviceFile, 'utf8');
67
69
  const timer = await readFile(timerFile, 'utf8');
68
70
  if (!service.includes('scheduler-tick') || !timer.includes('OnUnitActiveSec=1min')) throw new Error('scheduler systemd units were not installed');
71
+ 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');
72
+ const systemdVerify = await new Promise((resolve) => {
73
+ const child = spawn('systemd-analyze', ['verify', serviceFile, timerFile], { stdio: ['ignore', 'pipe', 'pipe'] });
74
+ let stdout = ''; let stderr = ''; child.stdout.on('data', (chunk) => { stdout += chunk; }); child.stderr.on('data', (chunk) => { stderr += chunk; });
75
+ child.on('error', (error) => resolve({ code: 127, stdout, stderr: `${stderr}${error.message}` }));
76
+ child.on('close', (code) => resolve({ code, stdout, stderr }));
77
+ });
78
+ if (systemdVerify.code !== 0) throw new Error(`systemd rejected generated scheduler units: ${systemdVerify.stderr || systemdVerify.stdout}`);
69
79
  const installSystemctlCalls = await readFile(systemctlCapture, 'utf8');
70
80
  if (!installSystemctlCalls.includes('["--user","enable","--now","openclaw-loop-test-tasks-scheduler.timer"]')) throw new Error('scheduler timer was not enabled');
71
81
  const schedulerTick = await new Promise((resolve) => {
@@ -22,8 +22,12 @@ function parseArgs(argv) {
22
22
  return out;
23
23
  }
24
24
 
25
- function systemdEscape(value) {
26
- return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
25
+ function systemdEscapePath(value) {
26
+ return [...Buffer.from(String(value))]
27
+ .map((byte) => /[A-Za-z0-9/_.:-]/.test(String.fromCharCode(byte))
28
+ ? String.fromCharCode(byte)
29
+ : `\\x${byte.toString(16).padStart(2, '0')}`)
30
+ .join('');
27
31
  }
28
32
 
29
33
  function safeId(value, label) {
@@ -63,6 +67,32 @@ async function exists(file) {
63
67
  try { await access(file); return true; } catch { return false; }
64
68
  }
65
69
 
70
+ async function resolveExecutable(command) {
71
+ if (path.isAbsolute(command) || command.includes(path.sep)) {
72
+ const resolved = path.resolve(command);
73
+ if (!await exists(resolved)) throw new Error(`Executable not found: ${resolved}`);
74
+ return resolved;
75
+ }
76
+ for (const dir of String(process.env.PATH || '').split(path.delimiter).filter(Boolean)) {
77
+ const candidate = path.join(dir, command);
78
+ if (await exists(candidate)) return candidate;
79
+ }
80
+ throw new Error(`Executable not found on PATH: ${command}`);
81
+ }
82
+
83
+ function formatConfirmationSummary(summary) {
84
+ return [
85
+ 'Installation confirmation',
86
+ ` target platform: ${summary.targetPlatform}`,
87
+ ` platform CLI: ${summary.platformCli}`,
88
+ ` workspace: ${summary.workspace}`,
89
+ ` queue: ${summary.queue}`,
90
+ ` scheduler: ${summary.scheduler}`,
91
+ ` notification target: ${summary.notificationTarget}`,
92
+ ` writes enabled: ${summary.writesEnabled ? 'yes' : 'no (plan only)'}`
93
+ ].join('\n');
94
+ }
95
+
66
96
  function dispatcherSource({ workerAgent, openclawBin }) {
67
97
  return `#!/usr/bin/env node
68
98
  import { readFile } from 'node:fs/promises';
@@ -140,7 +170,7 @@ if (command === 'route') {
140
170
  }
141
171
 
142
172
  function schedulerServiceSource({ root, queue }) {
143
- return `[Unit]\nDescription=Taskforce Loop Engineering scheduler for ${queue}\nAfter=default.target\n\n[Service]\nType=oneshot\nWorkingDirectory="${systemdEscape(root)}"\nExecStart="${systemdEscape(process.execPath)}" "${systemdEscape(path.join(root, 'scripts', 'loops', 'openclaw-loop.mjs'))}" scheduler-tick --json\n`;
173
+ return `[Unit]\nDescription=Taskforce Loop Engineering scheduler for ${queue}\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`;
144
174
  }
145
175
 
146
176
  function schedulerTimerSource({ queue }) {
@@ -188,6 +218,7 @@ async function main() {
188
218
  }
189
219
  safeId(args.queue, 'queue');
190
220
  if (args.workerAgent) safeId(args.workerAgent, 'worker agent');
221
+ args.openclawBin = await resolveExecutable(args.openclawBin);
191
222
  const worker = await resolveWorkerAgent(args);
192
223
  args.workerAgent = worker.workerAgent;
193
224
  args.loopBin = new URL('../bin/loop-engineering.mjs', import.meta.url).pathname;
@@ -207,9 +238,11 @@ async function main() {
207
238
  };
208
239
  const conflicts = [];
209
240
  for (const [kind, file] of Object.entries(files)) if (!['instructions', 'workspaceHealth', 'manifest'].includes(kind) && await exists(file)) conflicts.push(path.relative(args.root, file));
210
- const report = { version: 1, 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 }, files: Object.fromEntries(Object.entries(files).map(([key, file]) => [key, path.relative(args.root, file)])), conflicts };
241
+ const confirmationSummary = { targetPlatform: 'OpenClaw', platformCli: args.openclawBin, workspace: args.root, queue: args.queue, scheduler: `systemd user timer ${schedulerTimer}`, notificationTarget: 'source-bound at runtime (original OpenClaw conversation)', writesEnabled: args.confirmInstall };
242
+ const report = { version: 1, platform: 'openclaw', 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 }, confirmationSummary, files: Object.fromEntries(Object.entries(files).map(([key, file]) => [key, path.relative(args.root, file)])), conflicts };
211
243
  report.next = args.confirmInstall ? 'Run loop-engineering-openclaw-doctor, then route a harmless smoke task.' : 'Review this plan, then rerun with --confirm-install.';
212
244
  if (conflicts.length && !args.force && args.confirmInstall) throw new Error(`Refusing to overwrite: ${conflicts.join(', ')}. Use --force after review.`);
245
+ if (!args.json) console.log(formatConfirmationSummary(confirmationSummary));
213
246
  if (args.confirmInstall) {
214
247
  await mkdir(path.dirname(files.queueConfig), { recursive: true });
215
248
  await mkdir(path.dirname(files.dispatcher), { recursive: true });
@@ -268,7 +301,7 @@ async function main() {
268
301
  retainedOnUninstall: [`runtime/loops/${args.queue}`]
269
302
  }, null, 2)}\n`);
270
303
  }
271
- console.log(args.json ? JSON.stringify(report, null, 2) : `OpenClaw integration: ${report.status}\nqueue: ${report.queue}\nworker: ${report.workerAgent}\nconflicts: ${report.conflicts.join(', ') || 'none'}\nnext: ${report.next}`);
304
+ console.log(args.json ? JSON.stringify(report, null, 2) : `OpenClaw integration: ${report.status}\nconflicts: ${report.conflicts.join(', ') || 'none'}\nnext: ${report.next}`);
272
305
  }
273
306
 
274
307
  main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; });
@@ -11,7 +11,7 @@ Do not route ordinary chat, research, explanations, or simple direct tasks into
11
11
 
12
12
  ## Distribution and CLI Installation
13
13
 
14
- A ClawHub installation may provide only this `SKILL.md`; it does **not** prove that the Loop Engineering CLI or OpenClaw integration is installed. Before running loop commands, check the deployment explicitly:
14
+ A skill installation may provide only this `SKILL.md`; it does **not** prove that the Loop Engineering CLI or an OpenClaw/Hermes integration is installed. Before running loop commands, check the deployment explicitly:
15
15
 
16
16
  ```bash
17
17
  command -v loop-engineering
@@ -62,7 +62,7 @@ loop-engineering-openclaw-install \
62
62
  --queue agent-tasks
63
63
  ```
64
64
 
65
- Review the detected agents and planned files. Then install with an existing worker-agent id:
65
+ Review the installation confirmation summary before proceeding. It must show the target platform, absolute platform CLI path, workspace, queue, scheduler, notification routing, and `writes enabled: no (plan only)`. If any field identifies the wrong platform or destination, stop. Then install with an existing worker-agent id:
66
66
 
67
67
  ```bash
68
68
  loop-engineering-openclaw-install \
@@ -88,6 +88,34 @@ loop-engineering-openclaw-smoke \
88
88
 
89
89
  If the CLI or integration is missing and the user requested installation or repair, install it within the authorized host/workspace scope, then run doctor and the disposable smoke. If the user only asked what is missing, report the exact package, repository, commands, and current deployment state without mutating the system.
90
90
 
91
+ ### Hermes Agent Integration
92
+
93
+ The core CLI is platform-neutral, but Hermes conversation routing requires its own dispatcher and notifier. Generate a plan and verify its installation confirmation summary identifies Hermes, the absolute Hermes CLI path, intended workspace and queue, systemd scheduler, source-bound notification routing, and `writes enabled: no (plan only)`. If any field identifies the wrong platform or destination, stop. Confirm installation only after that review, then run the read-only doctor and disposable smoke:
94
+
95
+ ```bash
96
+ loop-engineering-hermes-install \
97
+ --root /path/to/hermes/workspace \
98
+ --queue agent-tasks
99
+
100
+ loop-engineering-hermes-install \
101
+ --root /path/to/hermes/workspace \
102
+ --queue agent-tasks \
103
+ --confirm-install
104
+
105
+ loop-engineering-hermes-doctor \
106
+ --root /path/to/hermes/workspace \
107
+ --queue agent-tasks
108
+
109
+ loop-engineering-hermes-smoke \
110
+ --root /path/to/hermes/workspace \
111
+ --queue agent-tasks
112
+ ```
113
+
114
+ The generated worker uses `hermes -z` (`--oneshot`); the notifier uses `hermes send` without
115
+ an LLM call. Preserve source metadata and pass `--source-target` in Hermes
116
+ `platform:chat_id[:thread_id]` format. The managed systemd scheduler owns wakeups
117
+ so durable queue continuity does not depend on resuming a Hermes Cron session.
118
+
91
119
  ## Conversation Contract
92
120
 
93
121
  Interpret explicit loop language as follows:
@@ -1,7 +1,7 @@
1
1
  # npm Package
2
2
 
3
3
  Package name: `taskforce-loop-engineering`
4
- Version: `0.7.0`
4
+ Version: `0.9.0`
5
5
 
6
6
  Install from npm:
7
7
 
@@ -9,6 +9,15 @@ Install from npm:
9
9
  npm install -g taskforce-loop-engineering
10
10
  ```
11
11
 
12
+ Hermes Agent integration commands:
13
+
14
+ ```bash
15
+ loop-engineering-hermes-install --root /path/to/hermes/workspace --queue agent-tasks
16
+ loop-engineering-hermes-install --root /path/to/hermes/workspace --queue agent-tasks --confirm-install
17
+ loop-engineering-hermes-doctor --root /path/to/hermes/workspace --queue agent-tasks
18
+ loop-engineering-hermes-smoke --root /path/to/hermes/workspace --queue agent-tasks
19
+ ```
20
+
12
21
  Installed commands:
13
22
 
14
23
  ```bash