taskforce-loop-engineering 0.7.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/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "taskforce-loop-engineering",
3
+ "version": "0.7.0",
4
+ "private": false,
5
+ "description": "OpenClaw-native loop engineering CLI, templates, and skill for verifiable agent work loops.",
6
+ "type": "module",
7
+ "bin": {
8
+ "loop-engineering": "bin/loop-engineering.mjs",
9
+ "agent-loop": "bin/loop-engineering.mjs",
10
+ "loop-engineering-openclaw-install": "scripts/openclaw-install.mjs",
11
+ "loop-engineering-openclaw-doctor": "scripts/openclaw-doctor.mjs",
12
+ "loop-engineering-openclaw-smoke": "scripts/openclaw-smoke.mjs",
13
+ "loop-engineering-openclaw-manage": "scripts/openclaw-manage.mjs",
14
+ "run-loop-cron.sh": "scripts/run-loop-cron.sh"
15
+ },
16
+ "scripts": {
17
+ "check:config-drift": "node --check scripts/config-drift-self-test.mjs && node scripts/config-drift-self-test.mjs",
18
+ "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/route-notify-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 --root . --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
+ "pack:dry": "npm pack --dry-run"
21
+ },
22
+ "engines": {
23
+ "node": ">=22"
24
+ },
25
+ "files": [
26
+ "CHANGELOG.md",
27
+ "MIGRATING.md",
28
+ "README.md",
29
+ "bin/",
30
+ "lib/",
31
+ "scripts/",
32
+ "templates/",
33
+ "skills/taskforce-loop-engineering/"
34
+ ],
35
+ "keywords": [
36
+ "openclaw",
37
+ "agent",
38
+ "taskforce-loop-engineering",
39
+ "loop-engineering",
40
+ "automation",
41
+ "cron",
42
+ "health-check"
43
+ ],
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "git+https://github.com/ambitioncn/taskforce-loop-engineering.git"
47
+ },
48
+ "bugs": {
49
+ "url": "https://github.com/ambitioncn/taskforce-loop-engineering/issues"
50
+ },
51
+ "homepage": "https://github.com/ambitioncn/taskforce-loop-engineering#readme",
52
+ "license": "Apache-2.0"
53
+ }
@@ -0,0 +1,21 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtemp, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import path from 'node:path';
5
+ import { loopRepairPlan, runCheck, validateSpec } from '../lib/core.mjs';
6
+
7
+ const root = await mkdtemp(path.join(tmpdir(), 'loop-config-drift-'));
8
+ await writeFile(path.join(root, 'config.json'), `${JSON.stringify({ model: 'new-model' })}\n`);
9
+ const check = { id: 'default-model', type: 'json-value', file: 'config.json', pointer: '/model', expected: 'old-model' };
10
+ validateSpec({ id: 'config-drift-smoke', goal: 'Detect explainable configuration drift.', level: 'L1', mode: 'report-only', checks: [check] });
11
+ const result = await runCheck(root, check);
12
+ assert.equal(result.ok, false);
13
+ assert.equal(result.expected, 'old-model');
14
+ assert.equal(result.actual, 'new-model');
15
+ assert.equal(result.drift.kind, 'configuration_value_mismatch');
16
+ const plan = loopRepairPlan({ loopId: 'config-drift-smoke', outcome: 'failure', runPath: 'example.json', checks: [result] });
17
+ assert.equal(plan.status, 'review_required');
18
+ assert.equal(plan.readOnly, true);
19
+ assert.equal(plan.autoApply, false);
20
+ assert.equal(plan.findings[0].actual, 'new-model');
21
+ console.log('config drift self-test passed');
@@ -0,0 +1,77 @@
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', workerAgent: 'main', openclawBin: 'openclaw', json: false };
8
+ for (let i = 0; i < argv.length; i++) {
9
+ const arg = argv[i];
10
+ if (arg === '--root') out.root = path.resolve(argv[++i]);
11
+ else if (arg === '--queue') out.queue = argv[++i];
12
+ else if (arg === '--worker-agent') out.workerAgent = argv[++i];
13
+ else if (arg === '--openclaw-bin') out.openclawBin = argv[++i];
14
+ else if (arg === '--json') out.json = true;
15
+ else if (arg === '--help' || arg === '-h') out.help = true;
16
+ else throw new Error(`Unknown argument: ${arg}`);
17
+ }
18
+ return out;
19
+ }
20
+
21
+ function run(command, args, options = {}) {
22
+ return new Promise((resolve) => {
23
+ const child = spawn(command, args, { cwd: options.cwd, env: options.env || process.env, stdio: ['ignore', 'pipe', 'pipe'] });
24
+ let stdout = ''; let stderr = '';
25
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
26
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
27
+ child.on('error', (error) => resolve({ code: 127, stdout, stderr: `${stderr}${error.message}` }));
28
+ child.on('close', (code, signal) => resolve({ code: code ?? (signal ? 128 : 1), stdout, stderr }));
29
+ });
30
+ }
31
+
32
+ async function present(file) { try { await access(file); return true; } catch { return false; } }
33
+
34
+ async function main() {
35
+ const args = parseArgs(process.argv.slice(2));
36
+ if (args.help) {
37
+ console.log('Usage: loop-engineering-openclaw-doctor [--root workspace] [--queue agent-tasks] [--worker-agent main] [--openclaw-bin openclaw] [--json]');
38
+ return;
39
+ }
40
+ const required = [
41
+ `configs/loops/queues/${args.queue}.json`,
42
+ 'configs/loops/workspace-health.json',
43
+ 'scripts/loops/openclaw-loop-dispatch.mjs',
44
+ 'scripts/loops/openclaw-loop.mjs',
45
+ 'scripts/loops/openclaw-loop-notify.mjs',
46
+ 'AGENTS.md'
47
+ ];
48
+ const checks = [];
49
+ for (const relative of required) checks.push({ id: `file:${relative}`, ok: await present(path.join(args.root, relative)) });
50
+ const cli = await run(args.openclawBin, ['--version'], { cwd: args.root });
51
+ checks.push({ id: 'openclaw_cli', ok: cli.code === 0, detail: (cli.stdout || cli.stderr).trim().slice(0, 300) });
52
+ const agentsResult = await run(args.openclawBin, ['agents', 'list', '--json'], { cwd: args.root });
53
+ let agents = [];
54
+ try { agents = JSON.parse(agentsResult.stdout); } catch { /* reported below */ }
55
+ checks.push({ id: 'worker_agent', ok: agentsResult.code === 0 && Array.isArray(agents) && agents.some((agent) => agent?.id === args.workerAgent), detail: args.workerAgent });
56
+ const queueFile = path.join(args.root, `configs/loops/queues/${args.queue}.json`);
57
+ if (await present(queueFile)) {
58
+ try {
59
+ const queue = JSON.parse(await readFile(queueFile, 'utf8'));
60
+ checks.push({ id: 'queue_config', ok: queue.queue === args.queue && queue.dispatcher === 'node scripts/loops/openclaw-loop-dispatch.mjs' });
61
+ } catch (error) { checks.push({ id: 'queue_config', ok: false, detail: error.message }); }
62
+ }
63
+ const notifier = path.join(args.root, 'scripts/loops/openclaw-loop-notify.mjs');
64
+ if (await present(notifier)) {
65
+ const smoke = await run(process.execPath, [notifier, 'Loop Engineering notification dry-run'], {
66
+ cwd: args.root,
67
+ env: { ...process.env, LOOP_NOTIFICATION_DRY_RUN: '1', LOOP_NOTIFICATION_SOURCE: JSON.stringify({ channel: 'feishu', target: 'user:loop-doctor-dry-run', account: 'doctor', reply_to: 'doctor-message' }) }
68
+ });
69
+ checks.push({ id: 'notification_dry_run', ok: smoke.code === 0, detail: (smoke.stdout || smoke.stderr).trim().slice(0, 500) });
70
+ }
71
+ const failed = checks.filter((check) => !check.ok);
72
+ const report = { version: 1, status: failed.length ? 'fail' : 'ok', readOnly: true, externalWrite: false, root: args.root, queue: args.queue, workerAgent: args.workerAgent, checks, failed: failed.map((check) => check.id) };
73
+ console.log(args.json ? JSON.stringify(report, null, 2) : `OpenClaw Loop doctor: ${report.status}\nchecks: ${checks.length - failed.length}/${checks.length}\nfailed: ${report.failed.join(', ') || 'none'}`);
74
+ if (failed.length) process.exitCode = 1;
75
+ }
76
+
77
+ main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; });
@@ -0,0 +1,129 @@
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-openclaw-install-'));
8
+ const deliveryCapture = path.join(root, 'delivery.json');
9
+ const mockOpenClaw = path.join(root, 'mock-openclaw.mjs');
10
+ await writeFile(mockOpenClaw, `#!/usr/bin/env node
11
+ import { mkdir, writeFile } from 'node:fs/promises';
12
+ import path from 'node:path';
13
+ const args = process.argv.slice(2);
14
+ if (args[0] === '--version') console.log('OpenClaw mock 1.0');
15
+ else if (args[0] === 'agents' && args[1] === 'list') console.log(JSON.stringify([{ id: 'builder' }]));
16
+ else {
17
+ if (args[0] === 'agent' && process.env.LOOP_CHECKPOINTS_DIR) {
18
+ const dir = path.resolve(process.cwd(), process.env.LOOP_CHECKPOINTS_DIR);
19
+ await mkdir(dir, { recursive: true });
20
+ 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: 'Read-only smoke completed.', files_changed: [], verification: [{ command: 'mock smoke', outcome: 'passed' }], blockers: [], risks: [], next_action: 'acceptance_review' }));
21
+ }
22
+ if (process.env.DELIVERY_CAPTURE) await writeFile(process.env.DELIVERY_CAPTURE, JSON.stringify(args));
23
+ console.log(JSON.stringify({ ok: true, dryRun: args.includes('--dry-run') }));
24
+ }
25
+ `);
26
+ await chmod(mockOpenClaw, 0o755);
27
+ const installer = new URL('./openclaw-install.mjs', import.meta.url).pathname;
28
+ function run(args) {
29
+ return new Promise((resolve) => {
30
+ const child = spawn(process.execPath, [installer, ...args], { stdio: ['ignore', 'pipe', 'pipe'] });
31
+ let stdout = ''; let stderr = '';
32
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
33
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
34
+ child.on('close', (code) => resolve({ code, stdout, stderr }));
35
+ });
36
+ }
37
+ const plan = await run(['--root', root, '--queue', 'test-tasks', '--openclaw-bin', mockOpenClaw, '--json']);
38
+ const planReport = JSON.parse(plan.stdout);
39
+ 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}`);
40
+ const missingWorker = await run(['--root', root, '--queue', 'test-tasks', '--worker-agent', 'missing', '--openclaw-bin', mockOpenClaw, '--json']);
41
+ if (missingWorker.code === 0 || !missingWorker.stderr.includes('does not exist')) throw new Error('installer accepted a missing worker agent');
42
+ const install = await run(['--root', root, '--queue', 'test-tasks', '--worker-agent', 'builder', '--openclaw-bin', mockOpenClaw, '--confirm-install', '--json']);
43
+ if (install.code !== 0 || JSON.parse(install.stdout).status !== 'installed') throw new Error(`install failed: ${install.stderr}`);
44
+ const queue = JSON.parse(await readFile(path.join(root, 'configs/loops/queues/test-tasks.json'), 'utf8'));
45
+ if (queue.dispatcher !== 'node scripts/loops/openclaw-loop-dispatch.mjs') throw new Error('dispatcher was not installed');
46
+ const dispatcher = await readFile(path.join(root, 'scripts/loops/openclaw-loop-dispatch.mjs'), 'utf8');
47
+ if (!dispatcher.includes('already loop-managed') || !dispatcher.includes("'--agent', \"builder\"") || !dispatcher.includes('LOOP_LATEST_AMENDMENT_FILE')) throw new Error('worker, recursion guard, or amendment polling missing');
48
+ const instructions = await readFile(path.join(root, 'AGENTS.md'), 'utf8');
49
+ if (!instructions.includes('走 loop') || !instructions.includes('immediately execute')) throw new Error('conversation instructions missing');
50
+ const wrapper = await readFile(path.join(root, 'scripts/loops/openclaw-loop.mjs'), 'utf8');
51
+ 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('queue-human-input-notify') || !wrapper.includes('queue-terminal-notify') || !wrapper.includes('只入队')) throw new Error('supersede/amend routing, live progress, async notification, or queue-only routing missing');
52
+ const notifier = await readFile(path.join(root, 'scripts/loops/openclaw-loop-notify.mjs'), 'utf8');
53
+ if (!notifier.includes("'message', 'send'") || !notifier.includes('source.channel') || !notifier.includes('source.target')) throw new Error('channel-neutral notifier missing');
54
+ const delivery = await new Promise((resolve) => {
55
+ const child = spawn(process.execPath, [path.join(root, 'scripts/loops/openclaw-loop-notify.mjs'), 'async result'], {
56
+ cwd: root,
57
+ env: { ...process.env, DELIVERY_CAPTURE: deliveryCapture, LOOP_NOTIFICATION_SOURCE: JSON.stringify({ channel: 'slack', target: 'channel:C123', account: 'work', reply_to: 'M456' }) },
58
+ stdio: ['ignore', 'pipe', 'pipe']
59
+ });
60
+ let stderr = ''; child.stderr.on('data', (chunk) => { stderr += chunk; });
61
+ child.on('close', (code) => resolve({ code, stderr }));
62
+ });
63
+ if (delivery.code !== 0) throw new Error(`notifier delivery failed: ${delivery.stderr}`);
64
+ const deliveredArgs = JSON.parse(await readFile(deliveryCapture, 'utf8'));
65
+ for (const expected of ['message', 'send', '--channel', 'slack', '--target', 'channel:C123', '--account', 'work', '--reply-to', 'M456', 'async result']) {
66
+ if (!deliveredArgs.includes(expected)) throw new Error(`notifier did not forward ${expected}`);
67
+ }
68
+ const doctor = new URL('./openclaw-doctor.mjs', import.meta.url).pathname;
69
+ const doctorResult = await new Promise((resolve) => {
70
+ const child = spawn(process.execPath, [doctor, '--root', root, '--queue', 'test-tasks', '--worker-agent', 'builder', '--openclaw-bin', mockOpenClaw, '--json'], { stdio: ['ignore', 'pipe', 'pipe'] });
71
+ let stdout = ''; let stderr = '';
72
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
73
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
74
+ child.on('close', (code) => resolve({ code, stdout, stderr }));
75
+ });
76
+ if (doctorResult.code !== 0) throw new Error(`doctor failed: ${doctorResult.stderr}`);
77
+ const doctorReport = JSON.parse(doctorResult.stdout);
78
+ if (doctorReport.status !== 'ok' || doctorReport.externalWrite !== false || !doctorReport.checks.some((check) => check.id === 'notification_dry_run' && check.ok)) throw new Error('doctor did not complete a safe notification dry-run');
79
+ const smoke = new URL('./openclaw-smoke.mjs', import.meta.url).pathname;
80
+ const loopBin = new URL('../bin/loop-engineering.mjs', import.meta.url).pathname;
81
+ const smokeResult = await new Promise((resolve) => {
82
+ const child = spawn(process.execPath, [smoke, '--root', root, '--queue', 'test-tasks', '--worker-agent', 'builder', '--openclaw-bin', mockOpenClaw, '--loop-bin', loopBin, '--json'], { stdio: ['ignore', 'pipe', 'pipe'] });
83
+ let stdout = ''; let stderr = '';
84
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
85
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
86
+ child.on('close', (code) => resolve({ code, stdout, stderr }));
87
+ });
88
+ if (smokeResult.code !== 0) throw new Error(`smoke failed: ${smokeResult.stderr}`);
89
+ const smokeReport = JSON.parse(smokeResult.stdout);
90
+ if (smokeReport.status !== 'ok' || smokeReport.externalWrite !== false || !smokeReport.steps.every((step) => step.ok)) throw new Error('end-to-end smoke did not pass safely');
91
+ try { await readFile(path.join(root, `configs/loops/queues/${smokeReport.smokeQueue}.json`)); throw new Error('smoke config was not cleaned'); } catch (error) { if (error.code !== 'ENOENT') throw error; }
92
+ try { await readFile(path.join(root, `runtime/loops/${smokeReport.smokeQueue}/state.json`)); throw new Error('smoke runtime was not cleaned'); } catch (error) { if (error.code !== 'ENOENT') throw error; }
93
+ for (const generated of ['scripts/loops/openclaw-loop-dispatch.mjs', 'scripts/loops/openclaw-loop.mjs', 'scripts/loops/openclaw-loop-notify.mjs']) {
94
+ const syntax = await run(['--help']);
95
+ if (syntax.code !== 0) throw new Error(`installer help failed while checking ${generated}`);
96
+ const check = await new Promise((resolve) => {
97
+ const child = spawn(process.execPath, ['--check', path.join(root, generated)], { stdio: ['ignore', 'pipe', 'pipe'] });
98
+ let stderr = ''; child.stderr.on('data', (chunk) => { stderr += chunk; });
99
+ child.on('close', (code) => resolve({ code, stderr }));
100
+ });
101
+ if (check.code !== 0) throw new Error(`generated script syntax failed: ${generated}: ${check.stderr}`);
102
+ }
103
+ const conflict = await run(['--root', root, '--queue', 'test-tasks', '--worker-agent', 'builder', '--openclaw-bin', mockOpenClaw, '--confirm-install', '--json']);
104
+ if (conflict.code === 0) throw new Error('installer overwrote existing files without --force');
105
+ const manager = new URL('./openclaw-manage.mjs', import.meta.url).pathname;
106
+ async function manage(args) {
107
+ return new Promise((resolve) => {
108
+ const child = spawn(process.execPath, [manager, '--root', root, ...args, '--json'], { stdio: ['ignore', 'pipe', 'pipe'] });
109
+ let stdout = ''; let stderr = ''; child.stdout.on('data', (c) => { stdout += c; }); child.stderr.on('data', (c) => { stderr += c; });
110
+ child.on('close', (code) => resolve({ code, stdout, stderr }));
111
+ });
112
+ }
113
+ await writeFile(path.join(root, 'scripts/loops/openclaw-loop.mjs'), `${wrapper}\n// local edit\n`);
114
+ const modifiedPlan = await manage(['--action', 'uninstall-plan']);
115
+ if (modifiedPlan.code !== 0 || JSON.parse(modifiedPlan.stdout).status !== 'review_required') throw new Error('modified managed file was not detected');
116
+ const refusedUninstall = await manage(['--action', 'uninstall', '--confirm-uninstall']);
117
+ if (refusedUninstall.code === 0) throw new Error('uninstall removed modified managed content');
118
+ await writeFile(path.join(root, 'scripts/loops/openclaw-loop.mjs'), wrapper);
119
+ const upgradePlan = await manage(['--action', 'upgrade-plan']);
120
+ if (upgradePlan.code !== 0 || JSON.parse(upgradePlan.stdout).status !== 'ready') throw new Error(`upgrade plan failed: ${upgradePlan.stderr}`);
121
+ const upgrade = await manage(['--action', 'upgrade', '--confirm-upgrade']);
122
+ if (upgrade.code !== 0 || JSON.parse(upgrade.stdout).status !== 'upgraded') throw new Error(`upgrade failed: ${upgrade.stderr}`);
123
+ const uninstallPlan = await manage(['--action', 'uninstall-plan']);
124
+ if (uninstallPlan.code !== 0 || JSON.parse(uninstallPlan.stdout).status !== 'ready') throw new Error(`uninstall plan failed: ${uninstallPlan.stderr}`);
125
+ const uninstall = await manage(['--action', 'uninstall', '--confirm-uninstall']);
126
+ if (uninstall.code !== 0 || JSON.parse(uninstall.stdout).status !== 'uninstalled') throw new Error(`uninstall failed: ${uninstall.stderr}`);
127
+ if (!await readFile(path.join(root, 'runtime/loops/test-tasks/state.json'), 'utf8').catch(() => 'retained')) throw new Error('unexpected runtime cleanup result');
128
+ if (await readFile(path.join(root, 'scripts/loops/openclaw-loop.mjs'), 'utf8').then(() => true).catch(() => false)) throw new Error('managed wrapper survived uninstall');
129
+ console.log('openclaw installer self-test passed');
@@ -0,0 +1,235 @@
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', workerAgent: null, openclawBin: 'openclaw', 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 === '--worker-agent') out.workerAgent = argv[++i];
14
+ else if (arg === '--openclaw-bin') out.openclawBin = 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) {
25
+ if (!/^[a-zA-Z0-9._-]+$/.test(value)) throw new Error(`${label} contains unsupported characters.`);
26
+ return value;
27
+ }
28
+ const sha256 = (value) => createHash('sha256').update(value).digest('hex');
29
+
30
+ function run(command, args, options = {}) {
31
+ return new Promise((resolve) => {
32
+ const child = spawn(command, args, { cwd: options.cwd, env: process.env, stdio: ['ignore', 'pipe', 'pipe'] });
33
+ let stdout = ''; let stderr = '';
34
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
35
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
36
+ child.on('error', (error) => resolve({ code: 127, stdout, stderr: `${stderr}${error.message}` }));
37
+ child.on('close', (code, signal) => resolve({ code: code ?? (signal ? 128 : 1), stdout, stderr }));
38
+ });
39
+ }
40
+
41
+ async function resolveWorkerAgent(args) {
42
+ const result = await run(args.openclawBin, ['agents', 'list', '--json'], { cwd: args.root });
43
+ if (result.code !== 0) throw new Error(`Cannot inspect OpenClaw agents with ${args.openclawBin}: ${(result.stderr || result.stdout).trim() || `exit ${result.code}`}`);
44
+ let agents;
45
+ try { agents = JSON.parse(result.stdout); } catch { throw new Error('OpenClaw agents list did not return valid JSON.'); }
46
+ if (!Array.isArray(agents)) throw new Error('OpenClaw agents list did not return an array.');
47
+ const ids = agents.map((agent) => agent?.id).filter((id) => typeof id === 'string' && id);
48
+ if (args.workerAgent) {
49
+ if (!ids.includes(args.workerAgent)) throw new Error(`Worker agent ${args.workerAgent} does not exist. Available agents: ${ids.join(', ') || 'none'}. Create it first or choose an existing agent.`);
50
+ return { workerAgent: args.workerAgent, selection: 'explicit', availableAgents: ids };
51
+ }
52
+ if (ids.includes('main')) return { workerAgent: 'main', selection: 'default_main', availableAgents: ids };
53
+ if (ids.length === 1) return { workerAgent: ids[0], selection: 'only_available', availableAgents: ids };
54
+ throw new Error(`Cannot choose a worker agent automatically. Available agents: ${ids.join(', ') || 'none'}. Pass --worker-agent <id>; create the agent first if needed.`);
55
+ }
56
+
57
+ async function exists(file) {
58
+ try { await access(file); return true; } catch { return false; }
59
+ }
60
+
61
+ function dispatcherSource({ workerAgent, openclawBin }) {
62
+ return `#!/usr/bin/env node
63
+ import { readFile } from 'node:fs/promises';
64
+ import { spawn } from 'node:child_process';
65
+ const task = JSON.parse(await readFile(process.env.LOOP_TASK_FILE, 'utf8'));
66
+ const prompt = [
67
+ 'You are receiving an already loop-managed task.',
68
+ 'Do not route or enqueue this task again, even if its quoted request contains a loop trigger.',
69
+ 'Read the task contract, development plan, and acceptance plan before implementation.',
70
+ \`Task id: \${task.id}\`,
71
+ \`Task contract: \${process.env.LOOP_TASK_CONTRACT_FILE || 'not provided'}\`,
72
+ \`Development plan: \${process.env.LOOP_DEV_PLAN_FILE || 'not provided'}\`,
73
+ \`Acceptance plan: \${process.env.LOOP_ACCEPTANCE_PLAN_FILE || 'not provided'}\`,
74
+ \`Live amendments: \${process.env.LOOP_LATEST_AMENDMENT_FILE || 'not provided'}\`,
75
+ \`Checkpoints dir: \${process.env.LOOP_CHECKPOINTS_DIR || 'not provided'}\`,
76
+ '', task.body, '',
77
+ 'Before writing each checkpoint and before final completion, reread the live amendment file if it exists. Treat every recorded amendment as part of the task contract and acceptance criteria.',
78
+ 'Write a checkpoint when possible. Include the latest amendment_version applied. Finish with status, evidence, verification, blockers, and next action.'
79
+ ].join('\\n');
80
+ const child = spawn(${JSON.stringify(openclawBin)}, [
81
+ 'agent', '--agent', ${JSON.stringify(workerAgent)},
82
+ '--session-key', \`agent:${workerAgent}:loop-task-\${task.id}\`,
83
+ '--message', prompt, '--json', '--timeout', '1800'
84
+ ], { cwd: process.cwd(), env: process.env, stdio: 'inherit' });
85
+ child.on('close', (code, signal) => { process.exitCode = code ?? (signal ? 128 : 1); });
86
+ `;
87
+ }
88
+
89
+ function wrapperSource({ queue }) {
90
+ return `#!/usr/bin/env node
91
+ import { spawn } from 'node:child_process';
92
+ const [command, ...rest] = process.argv.slice(2);
93
+ function run(args) {
94
+ return new Promise((resolve) => {
95
+ const child = spawn('loop-engineering', args, { cwd: process.cwd(), env: process.env, stdio: 'inherit' });
96
+ child.on('close', (code, signal) => resolve(code ?? (signal ? 128 : 1)));
97
+ });
98
+ }
99
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
100
+ async function runWhenUnlocked(args, waitMs = 300000) {
101
+ const deadline = Date.now() + waitMs;
102
+ while (true) {
103
+ const code = await run(args);
104
+ if (code !== 2 || Date.now() >= deadline) return code;
105
+ await wait(250);
106
+ }
107
+ }
108
+ if (command === 'route') {
109
+ const messageIndex = rest.indexOf('--message');
110
+ const message = messageIndex >= 0 ? String(rest[messageIndex + 1] || '') : '';
111
+ const amendment = /(?:继续(?:当前|这个)?\\s*loop|给(?:当前|这个)?\\s*loop\\s*(?:补充|增加|加)|补充当前\\s*loop)/i.test(message);
112
+ const routeMode = amendment ? '--amend-active' : '--supersede-active';
113
+ const routeCode = await run(['route-message', '--queue', ${JSON.stringify(queue)}, '--route', '--confirm-execute', routeMode, ...rest]);
114
+ const queueOnly = /(?:只入队|只排队|暂不执行|不立即执行)/.test(message);
115
+ const runCode = routeCode || queueOnly || amendment ? routeCode : await runWhenUnlocked(['run-queue', '--config', ${JSON.stringify(`configs/loops/queues/${queue}.json`)}, '--progress-notify-command', 'node scripts/loops/openclaw-loop-notify.mjs']);
116
+ const humanNotifyCode = await run(['queue-human-input-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/openclaw-loop-notify.mjs']);
117
+ const terminalNotifyCode = await run(['queue-terminal-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/openclaw-loop-notify.mjs']);
118
+ process.exitCode = routeCode || runCode || humanNotifyCode || terminalNotifyCode;
119
+ } else if (command === 'run-once') {
120
+ const runCode = await run(['run-queue', '--config', ${JSON.stringify(`configs/loops/queues/${queue}.json`)}, '--progress-notify-command', 'node scripts/loops/openclaw-loop-notify.mjs', ...rest]);
121
+ const humanNotifyCode = await run(['queue-human-input-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/openclaw-loop-notify.mjs']);
122
+ const terminalNotifyCode = await run(['queue-terminal-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/openclaw-loop-notify.mjs']);
123
+ process.exitCode = runCode || humanNotifyCode || terminalNotifyCode;
124
+ } else {
125
+ console.error('Usage: node scripts/loops/openclaw-loop.mjs route --message "走 loop:任务" [source metadata]');
126
+ process.exitCode = 1;
127
+ }
128
+ `;
129
+ }
130
+
131
+ function notifierSource({ openclawBin }) {
132
+ return `#!/usr/bin/env node
133
+ import { spawn } from 'node:child_process';
134
+ const message = process.argv.slice(2).join(' ').trim();
135
+ const rawSource = process.env.LOOP_HUMAN_INPUT_SOURCE || process.env.LOOP_NOTIFICATION_SOURCE || '';
136
+ if (!message) { console.error('loop notifier requires a message argument.'); process.exit(2); }
137
+ let source;
138
+ try { source = JSON.parse(rawSource); } catch { console.error('loop notifier received invalid source metadata.'); process.exit(2); }
139
+ if (!source || typeof source !== 'object' || !source.channel || !source.target) {
140
+ console.error('loop notifier requires source.channel and source.target; refusing an unscoped delivery.');
141
+ process.exit(2);
142
+ }
143
+ const args = ['message', 'send', '--channel', String(source.channel), '--target', String(source.target), '--message', message, '--json'];
144
+ if (source.account) args.push('--account', String(source.account));
145
+ if (source.reply_to) args.push('--reply-to', String(source.reply_to));
146
+ if (process.env.LOOP_NOTIFICATION_DRY_RUN === '1') args.push('--dry-run');
147
+ const child = spawn(${JSON.stringify(openclawBin)}, args, { cwd: process.cwd(), env: process.env, stdio: 'inherit' });
148
+ child.on('close', (code, signal) => { process.exitCode = code ?? (signal ? 128 : 1); });
149
+ `;
150
+ }
151
+
152
+ function instructionsBlock({ queue }) {
153
+ return `\n<!-- loop-engineering:openclaw:start -->
154
+ ## Loop Engineering conversation routing
155
+
156
+ - Route only explicit loop requests. \`走 loop\` means enqueue and immediately execute one tick; only \`只入队\` or \`只排队\` suppresses execution.
157
+ - Run \`node scripts/loops/openclaw-loop.mjs route --message "<full user message>"\` from this workspace and preserve source metadata when available.
158
+ - An already loop-managed task must be executed directly and never routed again.
159
+ - Status questions are read-only. High-risk external, destructive, production, credential, or memory migration actions remain separately gated.
160
+ - Queue: \`${queue}\`.
161
+ <!-- loop-engineering:openclaw:end -->\n`;
162
+ }
163
+
164
+ async function main() {
165
+ const args = parseArgs(process.argv.slice(2));
166
+ if (args.help) {
167
+ console.log('Usage: loop-engineering-openclaw-install [--root workspace] [--queue agent-tasks] [--worker-agent agent-id] [--openclaw-bin openclaw] [--confirm-install] [--force] [--json]');
168
+ return;
169
+ }
170
+ safeId(args.queue, 'queue');
171
+ if (args.workerAgent) safeId(args.workerAgent, 'worker agent');
172
+ const worker = await resolveWorkerAgent(args);
173
+ args.workerAgent = worker.workerAgent;
174
+ const files = {
175
+ workspaceHealth: path.join(args.root, 'configs', 'loops', 'workspace-health.json'),
176
+ queueConfig: path.join(args.root, 'configs', 'loops', 'queues', `${args.queue}.json`),
177
+ dispatcher: path.join(args.root, 'scripts', 'loops', 'openclaw-loop-dispatch.mjs'),
178
+ wrapper: path.join(args.root, 'scripts', 'loops', 'openclaw-loop.mjs'),
179
+ notifier: path.join(args.root, 'scripts', 'loops', 'openclaw-loop-notify.mjs'),
180
+ manifest: path.join(args.root, 'runtime', 'loop-engineering-openclaw-install.json'),
181
+ instructions: path.join(args.root, 'AGENTS.md')
182
+ };
183
+ const conflicts = [];
184
+ for (const [kind, file] of Object.entries(files)) if (!['instructions', 'workspaceHealth', 'manifest'].includes(kind) && await exists(file)) conflicts.push(path.relative(args.root, file));
185
+ 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, files: Object.fromEntries(Object.entries(files).map(([key, file]) => [key, path.relative(args.root, file)])), conflicts };
186
+ report.next = args.confirmInstall ? 'Run loop-engineering-openclaw-doctor, then route a harmless smoke task.' : 'Review this plan, then rerun with --confirm-install.';
187
+ if (conflicts.length && !args.force && args.confirmInstall) throw new Error(`Refusing to overwrite: ${conflicts.join(', ')}. Use --force after review.`);
188
+ if (args.confirmInstall) {
189
+ await mkdir(path.dirname(files.queueConfig), { recursive: true });
190
+ await mkdir(path.dirname(files.dispatcher), { recursive: true });
191
+ await mkdir(path.join(args.root, 'runtime', 'loops', args.queue), { recursive: true });
192
+ if (!await exists(files.workspaceHealth)) {
193
+ await writeFile(files.workspaceHealth, `${JSON.stringify({
194
+ id: 'workspace-health', goal: 'Keep this workspace loop-ready and detect obvious drift.', level: 'L1', mode: 'report-only',
195
+ maxRuntimeMs: 120000,
196
+ breaker: { maxConsecutiveFailures: 3, sameFailureThreshold: 2 },
197
+ checks: [{ id: 'workspace-root', type: 'files', paths: ['.'] }]
198
+ }, null, 2)}\n`);
199
+ }
200
+ const queueContent = `${JSON.stringify({
201
+ queue: args.queue,
202
+ description: `OpenClaw conversation queue dispatched to agent ${args.workerAgent}.`,
203
+ dispatcher: 'node scripts/loops/openclaw-loop-dispatch.mjs',
204
+ preflightConfig: 'configs/loops/workspace-health.json',
205
+ timeoutMs: 1800000, leaseMs: 1860000, staleActiveMs: 3600000,
206
+ retry: { maxAttempts: 1, retryDelayMs: 0, retryExitCodes: [1], requiresHumanActionPatterns: ['requires human', '需要人工', 'Permission denied', 'Operation not permitted'] },
207
+ revisionPolicy: { enabled: true, maxRevisionRounds: 3, sameFailureThreshold: 2, requireStrategyChange: true }
208
+ }, null, 2)}\n`;
209
+ const dispatcherContent = dispatcherSource(args);
210
+ const wrapperContent = wrapperSource(args);
211
+ const notifierContent = notifierSource(args);
212
+ await writeFile(files.queueConfig, queueContent);
213
+ await writeFile(files.dispatcher, dispatcherContent);
214
+ await writeFile(files.wrapper, wrapperContent);
215
+ await writeFile(files.notifier, notifierContent);
216
+ const instructions = await exists(files.instructions) ? await readFile(files.instructions, 'utf8') : '';
217
+ const managedInstructions = instructionsBlock(args);
218
+ if (!instructions.includes('<!-- loop-engineering:openclaw:start -->')) await appendFile(files.instructions, managedInstructions);
219
+ await mkdir(path.dirname(files.manifest), { recursive: true });
220
+ await writeFile(files.manifest, `${JSON.stringify({
221
+ version: 1, queue: args.queue, workerAgent: args.workerAgent, openclawBin: args.openclawBin, installedAt: new Date().toISOString(),
222
+ managedFiles: [
223
+ { path: path.relative(args.root, files.queueConfig), sha256: sha256(queueContent) },
224
+ { path: path.relative(args.root, files.dispatcher), sha256: sha256(dispatcherContent) },
225
+ { path: path.relative(args.root, files.wrapper), sha256: sha256(wrapperContent) },
226
+ { path: path.relative(args.root, files.notifier), sha256: sha256(notifierContent) }
227
+ ],
228
+ managedInstructions: { path: 'AGENTS.md', sha256: sha256(managedInstructions), content: managedInstructions },
229
+ retainedOnUninstall: [`runtime/loops/${args.queue}`]
230
+ }, null, 2)}\n`);
231
+ }
232
+ 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}`);
233
+ }
234
+
235
+ main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; });
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ import { createHash } from 'node:crypto';
3
+ import { access, readFile, rm, writeFile } from 'node:fs/promises';
4
+ import { spawn } from 'node:child_process';
5
+ import path from 'node:path';
6
+
7
+ const sha256 = (value) => createHash('sha256').update(value).digest('hex');
8
+ async function exists(file) { try { await access(file); return true; } catch { return false; } }
9
+ function parseArgs(argv) {
10
+ const out = { root: process.cwd(), action: 'uninstall-plan', json: false, confirm: false };
11
+ for (let i = 0; i < argv.length; i++) {
12
+ const arg = argv[i];
13
+ if (arg === '--root') out.root = path.resolve(argv[++i]);
14
+ else if (arg === '--action') out.action = argv[++i];
15
+ else if (arg === '--confirm-uninstall' || arg === '--confirm-upgrade') out.confirm = true;
16
+ else if (arg === '--json') out.json = true;
17
+ else if (arg === '--help' || arg === '-h') out.help = true;
18
+ else throw new Error(`Unknown argument: ${arg}`);
19
+ }
20
+ return out;
21
+ }
22
+
23
+ async function main() {
24
+ const args = parseArgs(process.argv.slice(2));
25
+ if (args.help) { console.log('Usage: loop-engineering-openclaw-manage --action upgrade-plan|upgrade|uninstall-plan|uninstall [--root workspace] [--confirm-upgrade|--confirm-uninstall] [--json]'); return; }
26
+ if (!['upgrade-plan', 'upgrade', 'uninstall-plan', 'uninstall'].includes(args.action)) throw new Error('Unsupported action.');
27
+ if (args.action === 'uninstall' && !args.confirm) throw new Error('uninstall requires --confirm-uninstall.');
28
+ if (args.action === 'upgrade' && !args.confirm) throw new Error('upgrade requires --confirm-upgrade.');
29
+ const manifestFile = path.join(args.root, 'runtime', 'loop-engineering-openclaw-install.json');
30
+ if (!await exists(manifestFile)) throw new Error('OpenClaw integration manifest not found; refusing unmanaged removal.');
31
+ const manifest = JSON.parse(await readFile(manifestFile, 'utf8'));
32
+ const files = [];
33
+ for (const entry of manifest.managedFiles || []) {
34
+ const file = path.join(args.root, entry.path);
35
+ const present = await exists(file);
36
+ const current = present ? await readFile(file, 'utf8') : '';
37
+ files.push({ path: entry.path, present, clean: present && sha256(current) === entry.sha256 });
38
+ }
39
+ const agentsFile = path.join(args.root, manifest.managedInstructions?.path || 'AGENTS.md');
40
+ const agentsText = await exists(agentsFile) ? await readFile(agentsFile, 'utf8') : '';
41
+ const block = manifest.managedInstructions?.content || '';
42
+ const instructionsClean = Boolean(block) && sha256(block) === manifest.managedInstructions?.sha256 && agentsText.includes(block);
43
+ const modified = files.filter((item) => item.present && !item.clean).map((item) => item.path);
44
+ const plan = { version: 1, action: args.action, readOnly: args.action.endsWith('-plan'), queue: manifest.queue, workerAgent: manifest.workerAgent, files, instructionsClean, modified, retained: manifest.retainedOnUninstall || [], ready: modified.length === 0 && instructionsClean };
45
+ if (args.action === 'uninstall') {
46
+ if (!plan.ready) throw new Error(`Refusing uninstall because managed content changed: ${[...modified, ...(!instructionsClean ? ['AGENTS.md managed block'] : [])].join(', ')}`);
47
+ for (const item of files) if (item.present && item.clean) await rm(path.join(args.root, item.path), { force: true });
48
+ await writeFile(agentsFile, agentsText.replace(block, ''));
49
+ await rm(manifestFile, { force: true });
50
+ plan.status = 'uninstalled'; plan.readOnly = false;
51
+ } else if (args.action === 'upgrade') {
52
+ if (!plan.ready) throw new Error(`Refusing upgrade because managed content changed: ${[...modified, ...(!instructionsClean ? ['AGENTS.md managed block'] : [])].join(', ')}`);
53
+ const installer = new URL('./openclaw-install.mjs', import.meta.url).pathname;
54
+ const result = await new Promise((resolve) => {
55
+ const child = spawn(process.execPath, [installer, '--root', args.root, '--queue', manifest.queue, '--worker-agent', manifest.workerAgent, '--openclaw-bin', manifest.openclawBin || 'openclaw', '--confirm-install', '--force', '--json'], { stdio: ['ignore', 'pipe', 'pipe'] });
56
+ let stdout = ''; let stderr = ''; child.stdout.on('data', (c) => { stdout += c; }); child.stderr.on('data', (c) => { stderr += c; });
57
+ child.on('close', (code) => resolve({ code, stdout, stderr }));
58
+ });
59
+ if (result.code !== 0) throw new Error(`upgrade installer failed: ${result.stderr || result.stdout}`);
60
+ plan.status = 'upgraded'; plan.readOnly = false;
61
+ } else plan.status = plan.ready ? 'ready' : 'review_required';
62
+ console.log(args.json ? JSON.stringify(plan, null, 2) : `OpenClaw integration ${args.action}: ${plan.status}\nmodified: ${modified.join(', ') || 'none'}\nretained: ${plan.retained.join(', ') || 'none'}`);
63
+ }
64
+ main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; });