taskforce-loop-engineering 0.8.5 → 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 +7 -0
- package/README.md +59 -3
- package/package.json +6 -2
- package/scripts/hermes-doctor.mjs +28 -0
- package/scripts/hermes-install-self-test.mjs +42 -0
- package/scripts/hermes-install.mjs +161 -0
- package/scripts/hermes-smoke.mjs +37 -0
- package/scripts/openclaw-install-self-test.mjs +3 -1
- package/scripts/openclaw-install.mjs +31 -2
- package/skills/taskforce-loop-engineering/SKILL.md +30 -2
- package/skills/taskforce-loop-engineering/references/npm-package.md +10 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
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
|
+
|
|
3
10
|
## 0.8.5 - 2026-08-08
|
|
4
11
|
|
|
5
12
|
- 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
|
-
|
|
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
|
|
43
|
-
|
|
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.
|
|
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
|
|
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']);
|
|
@@ -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';
|
|
@@ -192,6 +218,7 @@ async function main() {
|
|
|
192
218
|
}
|
|
193
219
|
safeId(args.queue, 'queue');
|
|
194
220
|
if (args.workerAgent) safeId(args.workerAgent, 'worker agent');
|
|
221
|
+
args.openclawBin = await resolveExecutable(args.openclawBin);
|
|
195
222
|
const worker = await resolveWorkerAgent(args);
|
|
196
223
|
args.workerAgent = worker.workerAgent;
|
|
197
224
|
args.loopBin = new URL('../bin/loop-engineering.mjs', import.meta.url).pathname;
|
|
@@ -211,9 +238,11 @@ async function main() {
|
|
|
211
238
|
};
|
|
212
239
|
const conflicts = [];
|
|
213
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));
|
|
214
|
-
const
|
|
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 };
|
|
215
243
|
report.next = args.confirmInstall ? 'Run loop-engineering-openclaw-doctor, then route a harmless smoke task.' : 'Review this plan, then rerun with --confirm-install.';
|
|
216
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));
|
|
217
246
|
if (args.confirmInstall) {
|
|
218
247
|
await mkdir(path.dirname(files.queueConfig), { recursive: true });
|
|
219
248
|
await mkdir(path.dirname(files.dispatcher), { recursive: true });
|
|
@@ -272,7 +301,7 @@ async function main() {
|
|
|
272
301
|
retainedOnUninstall: [`runtime/loops/${args.queue}`]
|
|
273
302
|
}, null, 2)}\n`);
|
|
274
303
|
}
|
|
275
|
-
console.log(args.json ? JSON.stringify(report, null, 2) : `OpenClaw integration: ${report.status}\
|
|
304
|
+
console.log(args.json ? JSON.stringify(report, null, 2) : `OpenClaw integration: ${report.status}\nconflicts: ${report.conflicts.join(', ') || 'none'}\nnext: ${report.next}`);
|
|
276
305
|
}
|
|
277
306
|
|
|
278
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
|
|
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
|
|
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.
|
|
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
|