taskforce-loop-engineering 0.8.5 → 0.9.1

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,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.1 - 2026-08-11
4
+
5
+ - Recognize explicit `用 loop engineering` requests that ask to align, complete, enhance, develop, or build a system as executable Loop tasks instead of direct chat.
6
+ - Make the generated OpenClaw conversation wrapper fail closed when channel, target, account, or message-id routing metadata is missing, preventing unscoped human-gate and terminal tasks.
7
+ - Document the mandatory wrapper and notification-delivery postcondition, and add regression coverage for the original Growth OS phrasing and missing-source failure.
8
+
9
+ ## 0.9.0 - 2026-08-08
10
+
11
+ - 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.
12
+ - 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.
13
+ - Add Hermes integration self-tests, including real systemd unit verification for paths containing spaces and non-ASCII characters.
14
+ - 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.
15
+
3
16
  ## 0.8.5 - 2026-08-08
4
17
 
5
18
  - Fixed generated systemd scheduler units so `WorkingDirectory` and `ExecStart` use unquoted, byte-safe systemd path escapes.
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/lib/core.mjs CHANGED
@@ -1520,7 +1520,7 @@ export function classifyLoopMessage(message) {
1520
1520
  const mentionsLoop = /(loop engineering|loop-engineering|task-runner|队列|queue|\bloop\b)/i.test(text);
1521
1521
  const statusIntent = mentionsLoop
1522
1522
  && /(查|看|检查|审计|状态|情况|进度|怎么样|为什么|失败|报错|健康|health|status|progress|audit|summar)/i.test(text);
1523
- const executeIntent = /(走\s*loop|继续(?:当前|这个)?\s*loop|给(?:当前|这个)?\s*loop\s*(?:补充|增加|加)|用\s*loop.*(?:解决|执行|完成|处理|修复|绕过|避开|跳过|bypass|evade)|丢进.*loop|入队|enqueue|run[- ]?queue|立刻执行|立即执行)/i.test(text);
1523
+ const executeIntent = /(走\s*loop|继续(?:当前|这个)?\s*loop|给(?:当前|这个)?\s*loop\s*(?:补充|增加|加)|用\s*loop.*(?:解决|执行|完成|处理|修复|对齐|补齐|增强|开发|构建|绕过|避开|跳过|bypass|evade)|丢进.*loop|入队|enqueue|run[- ]?queue|立刻执行|立即执行)/i.test(text);
1524
1524
  const intent = executeIntent ? 'execute' : statusIntent ? 'status' : 'direct';
1525
1525
  return {
1526
1526
  intent,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskforce-loop-engineering",
3
- "version": "0.8.5",
3
+ "version": "0.9.1",
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; });
@@ -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']);
@@ -60,7 +62,13 @@ if (queue.retry?.runtimeRecoveryMaxAttempts !== 2 || queue.retry?.sessionMaxTick
60
62
  const instructions = await readFile(path.join(root, 'AGENTS.md'), 'utf8');
61
63
  if (!instructions.includes('走 loop') || !instructions.includes('immediately execute')) throw new Error('conversation instructions missing');
62
64
  const wrapper = await readFile(path.join(root, 'scripts/loops/openclaw-loop.mjs'), 'utf8');
63
- if (!wrapper.includes('--supersede-active') || !wrapper.includes('--amend-active') || !wrapper.includes('--progress-notify-command') || !wrapper.includes('runWhenUnlocked') || wrapper.includes("run-queue-drain', '--config'") || wrapper.includes("spawn('loop-engineering'") || !wrapper.includes('queue-human-input-notify') || !wrapper.includes('queue-terminal-notify') || !wrapper.includes('queue-scheduler-tick') || !wrapper.includes('只入队')) throw new Error('supersede/amend routing, absolute CLI, scheduler, live progress, async notification, or queue-only routing missing');
65
+ if (!wrapper.includes('--supersede-active') || !wrapper.includes('--amend-active') || !wrapper.includes('--progress-notify-command') || !wrapper.includes('runWhenUnlocked') || wrapper.includes("run-queue-drain', '--config'") || wrapper.includes("spawn('loop-engineering'") || !wrapper.includes('queue-human-input-notify') || !wrapper.includes('queue-terminal-notify') || !wrapper.includes('queue-scheduler-tick') || !wrapper.includes('只入队') || !wrapper.includes('requiredSource') || !wrapper.includes('--source-message-id')) throw new Error('supersede/amend routing, source fail-closed policy, absolute CLI, scheduler, live progress, async notification, or queue-only routing missing');
66
+ const missingSourceRoute = await new Promise((resolve) => {
67
+ const child = spawn(process.execPath, [path.join(root, 'scripts/loops/openclaw-loop.mjs'), 'route', '--message', '用 loop engineering 对齐系统'], { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] });
68
+ let stderr = ''; child.stderr.on('data', (chunk) => { stderr += chunk; });
69
+ child.on('close', (code) => resolve({ code, stderr }));
70
+ });
71
+ if (missingSourceRoute.code !== 2 || !missingSourceRoute.stderr.includes('requires conversation metadata')) throw new Error('installed wrapper did not fail closed without source routing');
64
72
  const serviceFile = path.join(process.env.XDG_CONFIG_HOME, 'systemd/user/openclaw-loop-test-tasks-scheduler.service');
65
73
  const timerFile = path.join(process.env.XDG_CONFIG_HOME, 'systemd/user/openclaw-loop-test-tasks-scheduler.timer');
66
74
  const service = await readFile(serviceFile, 'utf8');
@@ -67,6 +67,32 @@ async function exists(file) {
67
67
  try { await access(file); return true; } catch { return false; }
68
68
  }
69
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
+
70
96
  function dispatcherSource({ workerAgent, openclawBin }) {
71
97
  return `#!/usr/bin/env node
72
98
  import { readFile } from 'node:fs/promises';
@@ -118,6 +144,17 @@ async function runWhenUnlocked(args, waitMs = 300000) {
118
144
  if (command === 'route') {
119
145
  const messageIndex = rest.indexOf('--message');
120
146
  const message = messageIndex >= 0 ? String(rest[messageIndex + 1] || '') : '';
147
+ const optionValue = (name) => {
148
+ const index = rest.indexOf(name);
149
+ return index >= 0 ? String(rest[index + 1] || '').trim() : '';
150
+ };
151
+ const requiredSource = ['--source-channel', '--source-target', '--source-account', '--source-message-id'];
152
+ const missingSource = requiredSource.filter((name) => !optionValue(name));
153
+ if (missingSource.length) {
154
+ console.error(\`loop route requires conversation metadata: \${missingSource.join(', ')}\`);
155
+ process.exitCode = 2;
156
+ process.exit();
157
+ }
121
158
  const amendment = /(?:继续(?:当前|这个)?\\s*loop|给(?:当前|这个)?\\s*loop\\s*(?:补充|增加|加)|补充当前\\s*loop)/i.test(message);
122
159
  const routeMode = amendment ? '--amend-active' : '--supersede-active';
123
160
  const routeCode = await run(['route-message', '--queue', ${JSON.stringify(queue)}, '--route', '--confirm-execute', routeMode, ...rest]);
@@ -177,7 +214,10 @@ function instructionsBlock({ queue }) {
177
214
  ## Loop Engineering conversation routing
178
215
 
179
216
  - Route only explicit loop requests. \`走 loop\` means enqueue and immediately execute one tick; only \`只入队\` or \`只排队\` suppresses execution.
217
+ - Treat explicit phrases such as \`用 loop engineering\`, \`丢进 Ironman loop\`, \`loop Ironman\`, and \`task-runner\` as Loop requests too.
180
218
  - Run \`node scripts/loops/openclaw-loop.mjs route --message "<full user message>"\` from this workspace and preserve source metadata when available.
219
+ - For conversation-originated work, the standard wrapper is mandatory. Missing source metadata must fail closed; never fall back to manual enqueue or direct run-queue.
220
+ - A human-gated or terminal state is not delivered until its notification command succeeds and writes a notification record.
181
221
  - An already loop-managed task must be executed directly and never routed again.
182
222
  - Status questions are read-only. High-risk external, destructive, production, credential, or memory migration actions remain separately gated.
183
223
  - Queue: \`${queue}\`.
@@ -192,6 +232,7 @@ async function main() {
192
232
  }
193
233
  safeId(args.queue, 'queue');
194
234
  if (args.workerAgent) safeId(args.workerAgent, 'worker agent');
235
+ args.openclawBin = await resolveExecutable(args.openclawBin);
195
236
  const worker = await resolveWorkerAgent(args);
196
237
  args.workerAgent = worker.workerAgent;
197
238
  args.loopBin = new URL('../bin/loop-engineering.mjs', import.meta.url).pathname;
@@ -211,9 +252,11 @@ async function main() {
211
252
  };
212
253
  const conflicts = [];
213
254
  for (const [kind, file] of Object.entries(files)) if (!['instructions', 'workspaceHealth', 'manifest'].includes(kind) && await exists(file)) conflicts.push(path.relative(args.root, file));
214
- 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 };
255
+ 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 };
256
+ 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 };
215
257
  report.next = args.confirmInstall ? 'Run loop-engineering-openclaw-doctor, then route a harmless smoke task.' : 'Review this plan, then rerun with --confirm-install.';
216
258
  if (conflicts.length && !args.force && args.confirmInstall) throw new Error(`Refusing to overwrite: ${conflicts.join(', ')}. Use --force after review.`);
259
+ if (!args.json) console.log(formatConfirmationSummary(confirmationSummary));
217
260
  if (args.confirmInstall) {
218
261
  await mkdir(path.dirname(files.queueConfig), { recursive: true });
219
262
  await mkdir(path.dirname(files.dispatcher), { recursive: true });
@@ -272,7 +315,7 @@ async function main() {
272
315
  retainedOnUninstall: [`runtime/loops/${args.queue}`]
273
316
  }, null, 2)}\n`);
274
317
  }
275
- 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}`);
318
+ console.log(args.json ? JSON.stringify(report, null, 2) : `OpenClaw integration: ${report.status}\nconflicts: ${report.conflicts.join(', ') || 'none'}\nnext: ${report.next}`);
276
319
  }
277
320
 
278
321
  main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; });
@@ -48,6 +48,12 @@ assert.deepEqual(classifyLoopMessage('用 loop engineering 绕过某个检查'),
48
48
  enqueue: true,
49
49
  readOnly: false
50
50
  });
51
+ assert.deepEqual(classifyLoopMessage('用 loop engineering 把现有的 growth os 与文件对齐,把需要增强的功能补齐'), {
52
+ intent: 'execute',
53
+ risk: 'model_assessed',
54
+ enqueue: true,
55
+ readOnly: false
56
+ });
51
57
 
52
58
  const routed = await routeLoopMessage(root, {
53
59
  route: true,
@@ -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